Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions app/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
mod db;
mod routes;
mod server;
pub mod db;
pub mod routes;
pub mod server;
pub mod xcode;

use clap::{Parser, Subcommand};
use db::Database;
Expand Down
31 changes: 30 additions & 1 deletion app/src/routes/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::db::entity::projects::{self, ProjectType};
use crate::server::AppState;
use crate::xcode;
use axum::{
extract::{Query, State},
http::StatusCode,
Expand All @@ -20,7 +21,8 @@ pub fn create_routes(frontend_dir: Option<&str>) -> Router<Arc<AppState>> {
.route("/health", get(health))
.route("/about", get(about))
.route("/projects/validate", post(validate_project))
.route("/projects/recent", get(get_recent_projects));
.route("/projects/recent", get(get_recent_projects))
.route("/xcode/schemes", post(get_xcode_schemes));

let router = Router::new().nest("/api", api_routes);

Expand Down Expand Up @@ -324,6 +326,33 @@ async fn get_recent_projects(
}
}

#[derive(Debug, Deserialize)]
struct XcodeSchemesRequest {
path: String,
}

/// Get Xcode schemes for a project or workspace
async fn get_xcode_schemes(Json(request): Json<XcodeSchemesRequest>) -> impl IntoResponse {
let path = Path::new(&request.path);

match xcode::discover_project(path) {
Copy link

Copilot AI Dec 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The discover_project function performs blocking I/O operations (file system reads, spawning a process, waiting for output) but is being called directly in an async handler. This can block the Tokio runtime thread pool and degrade performance. Consider wrapping the call in tokio::task::spawn_blocking to move the blocking work to a dedicated thread pool.

Copilot uses AI. Check for mistakes.
Ok(project) => (StatusCode::OK, Json(json!({
"path": project.path,
"project_type": match project.project_type {
xcode::ProjectType::Project => "project",
xcode::ProjectType::Workspace => "workspace",
},
"schemes": project.schemes,
"targets": project.targets,
"configurations": project.configurations,
}))),
Err(error) => (
StatusCode::BAD_REQUEST,
Json(json!({ "error": error })),
),
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
3 changes: 3 additions & 0 deletions app/src/xcode/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pub mod schemes;

pub use schemes::*;
248 changes: 248 additions & 0 deletions app/src/xcode/schemes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::Command;

#[derive(Debug, Serialize, Deserialize)]
pub struct XcodeProject {
pub path: PathBuf,
pub project_type: ProjectType,
pub schemes: Vec<String>,
pub targets: Vec<String>,
pub configurations: Vec<String>,
}

#[derive(Debug, Serialize, Deserialize)]
pub enum ProjectType {
Project,
Workspace,
}

#[derive(Debug, Deserialize)]
struct XcodeBuildList {
project: Option<ProjectInfo>,
workspace: Option<ProjectInfo>,
}

#[derive(Debug, Deserialize)]
struct ProjectInfo {
#[serde(default)]
configurations: Vec<String>,
#[serde(default)]
schemes: Vec<String>,
#[serde(default)]
targets: Vec<String>,
}

pub fn discover_project(path: &Path) -> Result<XcodeProject, String> {
let (project_path, project_type) = find_project_or_workspace(path)?;

let output = match project_type {
ProjectType::Workspace => Command::new("xcodebuild")
.arg("-workspace")
.arg(&project_path)
.arg("-list")
.arg("-json")
.output()
.map_err(|e| format!("Failed to execute xcodebuild: {}", e))?,
ProjectType::Project => Command::new("xcodebuild")
.arg("-project")
.arg(&project_path)
.arg("-list")
.arg("-json")
.output()
.map_err(|e| format!("Failed to execute xcodebuild: {}", e))?,
};

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("xcodebuild failed: {}", stderr));
}

let stdout = String::from_utf8_lossy(&output.stdout);
let build_list: XcodeBuildList =
serde_json::from_str(&stdout).map_err(|e| format!("Failed to parse xcodebuild output: {}", e))?;

let info = match project_type {
ProjectType::Workspace => build_list.workspace,
ProjectType::Project => build_list.project,
}
.ok_or_else(|| "No project/workspace info in xcodebuild output".to_string())?;

Ok(XcodeProject {
path: project_path,
project_type,
schemes: info.schemes,
targets: info.targets,
configurations: info.configurations,
})
}

fn find_project_or_workspace(path: &Path) -> Result<(PathBuf, ProjectType), String> {
if !path.exists() {
return Err(format!("Path does not exist: {}", path.display()));
}

// If the path itself is a .xcworkspace or .xcodeproj, use it directly
if let Some(ext) = path.extension() {
match ext.to_str() {
Some("xcworkspace") => return Ok((path.to_path_buf(), ProjectType::Workspace)),
Some("xcodeproj") => return Ok((path.to_path_buf(), ProjectType::Project)),
_ => {}
}
}

// Otherwise, search in the directory
let search_dir = if path.is_dir() { path } else { path.parent().unwrap() };
Copy link

Copilot AI Dec 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unwrap() call here could panic if the path has no parent (e.g., if path is the root directory "/" or a path component without a parent). This should be handled gracefully since the path could be user-provided. Consider returning an error instead.

Suggested change
let search_dir = if path.is_dir() { path } else { path.parent().unwrap() };
let search_dir = if path.is_dir() {
path
} else {
path
.parent()
.ok_or_else(|| format!("Path has no parent directory: {}", path.display()))?
};

Copilot uses AI. Check for mistakes.

// Prefer workspace over project
for entry in std::fs::read_dir(search_dir).map_err(|e| format!("Failed to read directory: {}", e))? {
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
let entry_path = entry.path();

if let Some(ext) = entry_path.extension() {
if ext == "xcworkspace" {
return Ok((entry_path, ProjectType::Workspace));
}
}
}

// Fall back to project if no workspace found
for entry in std::fs::read_dir(search_dir).map_err(|e| format!("Failed to read directory: {}", e))? {
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
let entry_path = entry.path();

if let Some(ext) = entry_path.extension() {
if ext == "xcodeproj" {
return Ok((entry_path, ProjectType::Project));
Copy link

Copilot AI Dec 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The directory is being read twice - once to search for workspace files (lines 98-107) and again to search for project files (lines 110-119). This is inefficient and could cause issues if the directory contents change between reads. Consider collecting all entries in a single pass and then checking for workspace files first, then project files.

Suggested change
// Prefer workspace over project
for entry in std::fs::read_dir(search_dir).map_err(|e| format!("Failed to read directory: {}", e))? {
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
let entry_path = entry.path();
if let Some(ext) = entry_path.extension() {
if ext == "xcworkspace" {
return Ok((entry_path, ProjectType::Workspace));
}
}
}
// Fall back to project if no workspace found
for entry in std::fs::read_dir(search_dir).map_err(|e| format!("Failed to read directory: {}", e))? {
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
let entry_path = entry.path();
if let Some(ext) = entry_path.extension() {
if ext == "xcodeproj" {
return Ok((entry_path, ProjectType::Project));
// Read directory entries once
let entries: Vec<PathBuf> = std::fs::read_dir(search_dir)
.map_err(|e| format!("Failed to read directory: {}", e))?
.map(|entry_res| {
entry_res
.map(|entry| entry.path())
.map_err(|e| format!("Failed to read entry: {}", e))
})
.collect::<Result<_, _>>()?;
// Prefer workspace over project
for entry_path in &entries {
if let Some(ext) = entry_path.extension() {
if ext == "xcworkspace" {
return Ok((entry_path.clone(), ProjectType::Workspace));
}
}
}
// Fall back to project if no workspace found
for entry_path in &entries {
if let Some(ext) = entry_path.extension() {
if ext == "xcodeproj" {
return Ok((entry_path.clone(), ProjectType::Project));

Copilot uses AI. Check for mistakes.
}
}
}

Err(format!(
"No .xcworkspace or .xcodeproj found in {}",
search_dir.display()
))
}

#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;

fn create_test_dir() -> TempDir {
tempfile::tempdir().expect("Failed to create temp dir")
}

fn create_mock_xcodeproj(dir: &Path, name: &str) {
let proj_path = dir.join(format!("{}.xcodeproj", name));
fs::create_dir(&proj_path).unwrap();

// Create a minimal project.pbxproj file so xcodebuild doesn't fail
let pbxproj = proj_path.join("project.pbxproj");
fs::write(&pbxproj, "// Mock project file").unwrap();
}

fn create_mock_xcworkspace(dir: &Path, name: &str) {
let workspace_path = dir.join(format!("{}.xcworkspace", name));
fs::create_dir(&workspace_path).unwrap();

// Create contents.xcworkspacedata
let contents_dir = workspace_path.join("contents.xcworkspacedata");
fs::write(&contents_dir, "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Workspace version=\"1.0\"></Workspace>").unwrap();
}

#[test]
fn test_find_xcodeproj_direct_path() {
let dir = create_test_dir();
create_mock_xcodeproj(dir.path(), "TestApp");

let proj_path = dir.path().join("TestApp.xcodeproj");
let result = find_project_or_workspace(&proj_path);

assert!(result.is_ok());
let (path, project_type) = result.unwrap();
assert_eq!(path, proj_path);
assert!(matches!(project_type, ProjectType::Project));
}

#[test]
fn test_find_xcworkspace_direct_path() {
let dir = create_test_dir();
create_mock_xcworkspace(dir.path(), "TestWorkspace");

let workspace_path = dir.path().join("TestWorkspace.xcworkspace");
let result = find_project_or_workspace(&workspace_path);

assert!(result.is_ok());
let (path, project_type) = result.unwrap();
assert_eq!(path, workspace_path);
assert!(matches!(project_type, ProjectType::Workspace));
}

#[test]
fn test_find_xcodeproj_in_directory() {
let dir = create_test_dir();
create_mock_xcodeproj(dir.path(), "TestApp");

let result = find_project_or_workspace(dir.path());

assert!(result.is_ok());
let (path, project_type) = result.unwrap();
assert!(path.ends_with("TestApp.xcodeproj"));
assert!(matches!(project_type, ProjectType::Project));
}

#[test]
fn test_find_xcworkspace_in_directory() {
let dir = create_test_dir();
create_mock_xcworkspace(dir.path(), "TestWorkspace");

let result = find_project_or_workspace(dir.path());

assert!(result.is_ok());
let (path, project_type) = result.unwrap();
assert!(path.ends_with("TestWorkspace.xcworkspace"));
assert!(matches!(project_type, ProjectType::Workspace));
}

#[test]
fn test_workspace_takes_priority_over_project() {
let dir = create_test_dir();
create_mock_xcodeproj(dir.path(), "TestApp");
create_mock_xcworkspace(dir.path(), "TestWorkspace");

let result = find_project_or_workspace(dir.path());

assert!(result.is_ok());
let (path, project_type) = result.unwrap();
assert!(path.ends_with("TestWorkspace.xcworkspace"));
assert!(matches!(project_type, ProjectType::Workspace));
}

#[test]
fn test_nonexistent_path() {
let result = find_project_or_workspace(Path::new("/nonexistent/path"));
assert!(result.is_err());
assert!(result.unwrap_err().contains("does not exist"));
}

#[test]
fn test_directory_without_xcode_project() {
let dir = create_test_dir();
fs::write(dir.path().join("README.md"), "# Test").unwrap();

let result = find_project_or_workspace(dir.path());
assert!(result.is_err());
assert!(result.unwrap_err().contains("No .xcworkspace or .xcodeproj found"));
}

#[test]
fn test_empty_directory() {
let dir = create_test_dir();

let result = find_project_or_workspace(dir.path());
assert!(result.is_err());
assert!(result.unwrap_err().contains("No .xcworkspace or .xcodeproj found"));
}
}
Loading
Loading