diff --git a/app/src/lib.rs b/app/src/lib.rs index f4d877c..3077222 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -1,6 +1,8 @@ -mod db; -mod routes; -mod server; +pub mod db; +pub mod routes; +pub mod server; +pub mod services; +pub mod xcode; use clap::{Parser, Subcommand}; use db::Database; @@ -133,7 +135,6 @@ fn run_desktop(debug: bool) { info!("Application exit requested"); } }); - return; } #[cfg(not(target_os = "macos"))] diff --git a/app/src/routes/health.rs b/app/src/routes/health.rs new file mode 100644 index 0000000..9ff9cb7 --- /dev/null +++ b/app/src/routes/health.rs @@ -0,0 +1,15 @@ +use axum::{http::StatusCode, response::IntoResponse, Json}; +use serde_json::json; + +/// Health check endpoint +pub async fn health() -> impl IntoResponse { + (StatusCode::OK, Json(json!({ "status": "ok" }))) +} + +/// About endpoint with app info +pub async fn about() -> impl IntoResponse { + Json(json!({ + "name": "plasma", + "version": env!("CARGO_PKG_VERSION"), + })) +} diff --git a/app/src/routes/mod.rs b/app/src/routes/mod.rs index 47cee1b..e9e3c20 100644 --- a/app/src/routes/mod.rs +++ b/app/src/routes/mod.rs @@ -1,26 +1,23 @@ -use crate::db::entity::projects::{self, ProjectType}; +mod health; +mod projects; +mod xcode; + use crate::server::AppState; use axum::{ - extract::{Query, State}, - http::StatusCode, - response::{IntoResponse, Json}, routing::{get, post}, Router, }; -use sea_orm::{entity::*, query::*}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use std::path::{Path, PathBuf}; use std::sync::Arc; use tower_http::services::{ServeDir, ServeFile}; /// Create all routes for the application pub fn create_routes(frontend_dir: Option<&str>) -> Router> { let api_routes = Router::new() - .route("/health", get(health)) - .route("/about", get(about)) - .route("/projects/validate", post(validate_project)) - .route("/projects/recent", get(get_recent_projects)); + .route("/health", get(health::health)) + .route("/about", get(health::about)) + .route("/projects/validate", post(projects::validate_project)) + .route("/projects/recent", get(projects::get_recent_projects)) + .route("/xcode/discover", post(xcode::discover_project)); let router = Router::new().nest("/api", api_routes); @@ -32,460 +29,3 @@ pub fn create_routes(frontend_dir: Option<&str>) -> Router> { router } } - -/// Health check endpoint -async fn health() -> impl IntoResponse { - (StatusCode::OK, Json(json!({ "status": "ok" }))) -} - -/// About endpoint with app info -async fn about() -> impl IntoResponse { - Json(json!({ - "name": "plasma", - "version": env!("CARGO_PKG_VERSION"), - })) -} - -#[derive(Debug, Deserialize)] -struct ValidateProjectRequest { - path: String, -} - -#[derive(Debug, Serialize)] -#[serde(tag = "valid")] -enum ValidateProjectResponse { - #[serde(rename = "true")] - Valid { - /// Project type inferred from path - #[serde(rename = "type")] - project_type: ProjectType, - name: String, - /// Canonical path to the project file (.xcworkspace, .xcodeproj, or build.gradle) - path: String, - }, - #[serde(rename = "false")] - Invalid { error: String }, -} - -/// Detected project information -struct DetectedProject { - project_type: ProjectType, - name: String, - /// Full path to the project file - path: PathBuf, -} - -/// Validate that a path contains a valid project (Xcode or Android) -async fn validate_project( - State(state): State>, - Json(request): Json, -) -> impl IntoResponse { - let path = Path::new(&request.path); - - if !path.exists() { - return ( - StatusCode::BAD_REQUEST, - Json(ValidateProjectResponse::Invalid { - error: "Path does not exist".to_string(), - }), - ); - } - - // Check if this is a direct project file/bundle path, or a directory to search - let detected = if is_project_path(path) { - detect_project_from_path(path) - } else if path.is_dir() { - detect_project_in_directory(path) - } else { - None - }; - - match detected { - Some(project) => { - let path_str = project.path.to_string_lossy().to_string(); - - // Try to find existing project by path - let existing = projects::Entity::find() - .filter(projects::Column::Path.eq(&path_str)) - .one(state.db.conn()) - .await; - - let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); - - match existing { - Ok(Some(existing_project)) => { - // Update existing project - let mut active: projects::ActiveModel = existing_project.into(); - active.name = Set(project.name.clone()); - active.last_opened_at = Set(Some(now)); - let _ = active.update(state.db.conn()).await; - } - Ok(None) => { - // Insert new project - let new_project = projects::ActiveModel { - id: NotSet, - path: Set(path_str.clone()), - name: Set(project.name.clone()), - last_opened_at: Set(Some(now.clone())), - created_at: Set(Some(now)), - }; - let _ = projects::Entity::insert(new_project) - .exec(state.db.conn()) - .await; - } - Err(_) => {} - } - - ( - StatusCode::OK, - Json(ValidateProjectResponse::Valid { - project_type: project.project_type, - name: project.name, - path: path_str, - }), - ) - } - None => ( - StatusCode::BAD_REQUEST, - Json(ValidateProjectResponse::Invalid { - error: "No Xcode or Android project found".to_string(), - }), - ), - } -} - -/// Check if a path is a project file/bundle (not just a regular directory) -fn is_project_path(path: &Path) -> bool { - let Some(name) = path.file_name() else { - return false; - }; - let name = name.to_string_lossy(); - - name.ends_with(".xcworkspace") - || name.ends_with(".xcodeproj") - || name == "build.gradle" - || name == "build.gradle.kts" -} - -/// Detect project from a direct project file/bundle path -fn detect_project_from_path(path: &Path) -> Option { - let file_name = path.file_name()?.to_string_lossy(); - - // Xcode workspace - if file_name.ends_with(".xcworkspace") { - let name = file_name.trim_end_matches(".xcworkspace").to_string(); - return Some(DetectedProject { - project_type: ProjectType::Xcode, - name, - path: path.to_path_buf(), - }); - } - - // Xcode project - if file_name.ends_with(".xcodeproj") { - let name = file_name.trim_end_matches(".xcodeproj").to_string(); - return Some(DetectedProject { - project_type: ProjectType::Xcode, - name, - path: path.to_path_buf(), - }); - } - - // Android Gradle build file - if file_name == "build.gradle" || file_name == "build.gradle.kts" { - let name = path - .parent() - .and_then(|p| p.file_name()) - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "Unknown".to_string()); - return Some(DetectedProject { - project_type: ProjectType::Android, - name, - path: path.to_path_buf(), - }); - } - - None -} - -/// Detect project by searching a directory for project files -fn detect_project_in_directory(path: &Path) -> Option { - let entries = std::fs::read_dir(path).ok()?; - - // First pass: look for workspace (takes priority) - for entry in entries.flatten() { - let file_name = entry.file_name(); - let file_name_str = file_name.to_string_lossy(); - - if file_name_str.ends_with(".xcworkspace") { - let name = file_name_str.trim_end_matches(".xcworkspace").to_string(); - return Some(DetectedProject { - project_type: ProjectType::Xcode, - name, - path: entry.path(), - }); - } - } - - // Second pass: look for project or gradle - let entries = std::fs::read_dir(path).ok()?; - for entry in entries.flatten() { - let file_name = entry.file_name(); - let file_name_str = file_name.to_string_lossy(); - - if file_name_str.ends_with(".xcodeproj") { - let name = file_name_str.trim_end_matches(".xcodeproj").to_string(); - return Some(DetectedProject { - project_type: ProjectType::Xcode, - name, - path: entry.path(), - }); - } - - if file_name_str == "build.gradle" || file_name_str == "build.gradle.kts" { - let name = path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "Unknown".to_string()); - return Some(DetectedProject { - project_type: ProjectType::Android, - name, - path: entry.path(), - }); - } - } - - None -} - -#[derive(Debug, Deserialize)] -struct RecentProjectsQuery { - #[serde(default)] - query: Option, - #[serde(default = "default_limit")] - limit: u64, -} - -fn default_limit() -> u64 { - 10 -} - -#[derive(Debug, Serialize)] -struct RecentProject { - /// Path to the project file - path: String, - name: String, - /// Project type inferred from path - #[serde(rename = "type")] - project_type: ProjectType, - /// Whether the project file still exists - valid: bool, -} - -/// Get recent projects, validating each one still exists -async fn get_recent_projects( - State(state): State>, - Query(params): Query, -) -> impl IntoResponse { - let query = projects::Entity::find() - .order_by_desc(projects::Column::LastOpenedAt) - .limit(params.limit); - - let query = if let Some(ref search) = params.query { - query.filter(projects::Column::Path.contains(search)) - } else { - query - }; - - match query.all(state.db.conn()).await { - Ok(projects) => { - // Validate each project file still exists and has valid type - let validated: Vec = projects - .into_iter() - .filter_map(|p| { - let project_type = p.project_type()?; - let path = Path::new(&p.path); - let valid = path.exists(); - Some(RecentProject { - path: p.path, - name: p.name, - project_type, - valid, - }) - }) - .collect(); - - (StatusCode::OK, Json(json!({ "projects": validated }))) - } - Err(_) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "Failed to fetch projects" })), - ), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - fn create_test_dir() -> TempDir { - tempfile::tempdir().expect("Failed to create temp dir") - } - - #[test] - fn test_detect_xcode_project_in_directory() { - let dir = create_test_dir(); - std::fs::create_dir(dir.path().join("MyApp.xcodeproj")).unwrap(); - - let result = detect_project_in_directory(dir.path()); - assert!(result.is_some()); - let project = result.unwrap(); - assert_eq!(project.project_type, ProjectType::Xcode); - assert_eq!(project.name, "MyApp"); - assert!(project.path.ends_with("MyApp.xcodeproj")); - } - - #[test] - fn test_detect_xcode_workspace_in_directory() { - let dir = create_test_dir(); - std::fs::create_dir(dir.path().join("MyWorkspace.xcworkspace")).unwrap(); - - let result = detect_project_in_directory(dir.path()); - assert!(result.is_some()); - let project = result.unwrap(); - assert_eq!(project.project_type, ProjectType::Xcode); - assert_eq!(project.name, "MyWorkspace"); - assert!(project.path.ends_with("MyWorkspace.xcworkspace")); - } - - #[test] - fn test_detect_android_project_groovy() { - let dir = create_test_dir(); - std::fs::write(dir.path().join("build.gradle"), "// gradle build").unwrap(); - - let result = detect_project_in_directory(dir.path()); - assert!(result.is_some()); - let project = result.unwrap(); - assert_eq!(project.project_type, ProjectType::Android); - assert!(project.path.ends_with("build.gradle")); - } - - #[test] - fn test_detect_android_project_kotlin() { - let dir = create_test_dir(); - std::fs::write( - dir.path().join("build.gradle.kts"), - "// kotlin gradle build", - ) - .unwrap(); - - let result = detect_project_in_directory(dir.path()); - assert!(result.is_some()); - let project = result.unwrap(); - assert_eq!(project.project_type, ProjectType::Android); - assert!(project.path.ends_with("build.gradle.kts")); - } - - #[test] - fn test_detect_no_project() { - let dir = create_test_dir(); - std::fs::write(dir.path().join("README.md"), "# Hello").unwrap(); - - let result = detect_project_in_directory(dir.path()); - assert!(result.is_none()); - } - - #[test] - fn test_detect_empty_directory() { - let dir = create_test_dir(); - - let result = detect_project_in_directory(dir.path()); - assert!(result.is_none()); - } - - #[test] - fn test_workspace_takes_priority_over_project() { - let dir = create_test_dir(); - std::fs::create_dir(dir.path().join("MyApp.xcodeproj")).unwrap(); - std::fs::create_dir(dir.path().join("MyApp.xcworkspace")).unwrap(); - - let result = detect_project_in_directory(dir.path()); - assert!(result.is_some()); - let project = result.unwrap(); - assert_eq!(project.project_type, ProjectType::Xcode); - assert_eq!(project.name, "MyApp"); - // Workspace should take priority - assert!(project.path.ends_with("MyApp.xcworkspace")); - } - - #[test] - fn test_detect_direct_xcworkspace_path() { - let dir = create_test_dir(); - let workspace_path = dir.path().join("MyApp.xcworkspace"); - std::fs::create_dir(&workspace_path).unwrap(); - - // Direct path to workspace - let result = detect_project_from_path(&workspace_path); - assert!(result.is_some()); - let project = result.unwrap(); - assert_eq!(project.project_type, ProjectType::Xcode); - assert_eq!(project.name, "MyApp"); - } - - #[test] - fn test_detect_direct_xcodeproj_path() { - let dir = create_test_dir(); - let proj_path = dir.path().join("MyApp.xcodeproj"); - std::fs::create_dir(&proj_path).unwrap(); - - let result = detect_project_from_path(&proj_path); - assert!(result.is_some()); - let project = result.unwrap(); - assert_eq!(project.project_type, ProjectType::Xcode); - assert_eq!(project.name, "MyApp"); - } - - #[test] - fn test_detect_direct_gradle_path() { - let dir = create_test_dir(); - let gradle_path = dir.path().join("build.gradle"); - std::fs::write(&gradle_path, "// gradle").unwrap(); - - let result = detect_project_from_path(&gradle_path); - assert!(result.is_some()); - let project = result.unwrap(); - assert_eq!(project.project_type, ProjectType::Android); - } - - #[test] - fn test_is_project_path() { - assert!(is_project_path(Path::new("/path/to/MyApp.xcworkspace"))); - assert!(is_project_path(Path::new("/path/to/MyApp.xcodeproj"))); - assert!(is_project_path(Path::new("/path/to/build.gradle"))); - assert!(is_project_path(Path::new("/path/to/build.gradle.kts"))); - assert!(!is_project_path(Path::new("/path/to/some/directory"))); - assert!(!is_project_path(Path::new("/path/to/file.txt"))); - } - - #[test] - fn test_project_type_from_path() { - assert_eq!( - ProjectType::from_path(Path::new("/path/MyApp.xcworkspace")), - Some(ProjectType::Xcode) - ); - assert_eq!( - ProjectType::from_path(Path::new("/path/MyApp.xcodeproj")), - Some(ProjectType::Xcode) - ); - assert_eq!( - ProjectType::from_path(Path::new("/path/build.gradle")), - Some(ProjectType::Android) - ); - assert_eq!( - ProjectType::from_path(Path::new("/path/build.gradle.kts")), - Some(ProjectType::Android) - ); - assert_eq!(ProjectType::from_path(Path::new("/path/other")), None); - } -} diff --git a/app/src/routes/projects.rs b/app/src/routes/projects.rs new file mode 100644 index 0000000..6707c65 --- /dev/null +++ b/app/src/routes/projects.rs @@ -0,0 +1,94 @@ +use crate::server::AppState; +use crate::services::projects; +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::path::Path; +use std::sync::Arc; + +#[derive(Debug, Deserialize)] +pub struct ValidateProjectRequest { + pub path: String, +} + +#[derive(Debug, Serialize)] +#[serde(untagged)] +enum ValidateProjectResponse { + Valid(projects::Project), + Invalid { error: String }, +} + +/// Validate that a path contains a valid project (Xcode or Android) +pub async fn validate_project( + State(state): State>, + Json(request): Json, +) -> impl IntoResponse { + let path = Path::new(&request.path); + + if !path.exists() { + return ( + StatusCode::BAD_REQUEST, + Json(ValidateProjectResponse::Invalid { + error: "Path does not exist".to_string(), + }), + ); + } + + match projects::detect_project(path) { + Some(project) => { + // Save to database in the background + if let Err(e) = + projects::save_project(state.db.conn(), &project.path, &project.name).await + { + tracing::warn!("Failed to save project to database: {}", e); + } + + ( + StatusCode::OK, + Json(ValidateProjectResponse::Valid(project)), + ) + } + None => ( + StatusCode::BAD_REQUEST, + Json(ValidateProjectResponse::Invalid { + error: "No Xcode or Android project found".to_string(), + }), + ), + } +} + +#[derive(Debug, Deserialize)] +pub struct RecentProjectsQuery { + #[serde(default)] + pub query: Option, + #[serde(default = "default_limit")] + pub limit: u64, +} + +fn default_limit() -> u64 { + 10 +} + +/// Get recent projects, validating each one still exists +pub async fn get_recent_projects( + State(state): State>, + Query(params): Query, +) -> impl IntoResponse { + match projects::get_recent_projects(state.db.conn(), params.query.as_deref(), params.limit) + .await + { + Ok(projects) => (StatusCode::OK, Json(json!({ "projects": projects }))), + Err(e) => { + tracing::error!("Failed to fetch projects: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Failed to fetch projects" })), + ) + } + } +} diff --git a/app/src/routes/xcode.rs b/app/src/routes/xcode.rs new file mode 100644 index 0000000..1bece27 --- /dev/null +++ b/app/src/routes/xcode.rs @@ -0,0 +1,26 @@ +use crate::xcode; +use axum::{http::StatusCode, response::IntoResponse, Json}; +use serde::Deserialize; +use serde_json::json; +use std::path::Path; + +#[derive(Debug, Deserialize)] +pub struct DiscoverProjectRequest { + pub path: String, +} + +/// Discover Xcode project information (schemes, targets, configurations) +pub async fn discover_project(Json(request): Json) -> impl IntoResponse { + let path = Path::new(&request.path); + + match xcode::discover_project(path).await { + Ok(project) => { + (StatusCode::OK, Json(serde_json::to_value(project).unwrap())).into_response() + } + Err(error) => ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": error.to_string() })), + ) + .into_response(), + } +} diff --git a/app/src/services/mod.rs b/app/src/services/mod.rs new file mode 100644 index 0000000..b42e1c6 --- /dev/null +++ b/app/src/services/mod.rs @@ -0,0 +1 @@ +pub mod projects; diff --git a/app/src/services/projects.rs b/app/src/services/projects.rs new file mode 100644 index 0000000..c419c56 --- /dev/null +++ b/app/src/services/projects.rs @@ -0,0 +1,286 @@ +use crate::db::entity::projects; +use sea_orm::{entity::*, query::*, DatabaseConnection}; +use serde::Serialize; +use std::path::Path; + +// Re-export ProjectType so other modules can access it +pub use crate::db::entity::projects::ProjectType; + +#[derive(Debug, Serialize)] +pub struct Project { + pub path: String, + pub name: String, + #[serde(rename = "type")] + pub project_type: ProjectType, + pub valid: bool, +} + +/// Detect project from a path +pub fn detect_project(path: &Path) -> Option { + // If the path itself is a project file/bundle, use it directly + if is_project_path(path) { + detect_from_project_path(path) + } else if path.is_dir() { + // Search directory for project files + detect_from_directory(path) + } else { + None + } +} + +/// Check if a path points directly to a project file/bundle +fn is_project_path(path: &Path) -> bool { + let Some(name) = path.file_name() else { + return false; + }; + let name = name.to_string_lossy(); + + name.ends_with(".xcworkspace") + || name.ends_with(".xcodeproj") + || name == "build.gradle" + || name == "build.gradle.kts" +} + +/// Detect project from a direct project file/bundle path +fn detect_from_project_path(path: &Path) -> Option { + let file_name = path.file_name()?.to_string_lossy(); + + // Xcode workspace + if file_name.ends_with(".xcworkspace") { + let name = file_name.trim_end_matches(".xcworkspace").to_string(); + return Some(Project { + project_type: ProjectType::Xcode, + name, + path: path.to_string_lossy().to_string(), + valid: path.exists(), + }); + } + + // Xcode project + if file_name.ends_with(".xcodeproj") { + let name = file_name.trim_end_matches(".xcodeproj").to_string(); + return Some(Project { + project_type: ProjectType::Xcode, + name, + path: path.to_string_lossy().to_string(), + valid: path.exists(), + }); + } + + // Android Gradle build file + if file_name == "build.gradle" || file_name == "build.gradle.kts" { + let name = path + .parent() + .and_then(|p| p.file_name()) + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "Unknown".to_string()); + return Some(Project { + project_type: ProjectType::Android, + name, + path: path.to_string_lossy().to_string(), + valid: path.exists(), + }); + } + + None +} + +/// Detect project by searching a directory +fn detect_from_directory(path: &Path) -> Option { + let entries = std::fs::read_dir(path).ok()?; + + // First pass: look for workspace (takes priority) + for entry in entries.flatten() { + let file_name = entry.file_name(); + let file_name_str = file_name.to_string_lossy(); + + if file_name_str.ends_with(".xcworkspace") { + let name = file_name_str.trim_end_matches(".xcworkspace").to_string(); + let project_path = entry.path(); + return Some(Project { + project_type: ProjectType::Xcode, + name, + path: project_path.to_string_lossy().to_string(), + valid: project_path.exists(), + }); + } + } + + // Second pass: look for project or gradle + let entries = std::fs::read_dir(path).ok()?; + for entry in entries.flatten() { + let file_name = entry.file_name(); + let file_name_str = file_name.to_string_lossy(); + + if file_name_str.ends_with(".xcodeproj") { + let name = file_name_str.trim_end_matches(".xcodeproj").to_string(); + let project_path = entry.path(); + return Some(Project { + project_type: ProjectType::Xcode, + name, + path: project_path.to_string_lossy().to_string(), + valid: project_path.exists(), + }); + } + + if file_name_str == "build.gradle" || file_name_str == "build.gradle.kts" { + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "Unknown".to_string()); + let project_path = entry.path(); + return Some(Project { + project_type: ProjectType::Android, + name, + path: project_path.to_string_lossy().to_string(), + valid: project_path.exists(), + }); + } + } + + None +} + +/// Save or update a project in the database +pub async fn save_project( + db: &DatabaseConnection, + path: &str, + name: &str, +) -> Result<(), sea_orm::DbErr> { + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + + // Try to find existing project + let existing = projects::Entity::find() + .filter(projects::Column::Path.eq(path)) + .one(db) + .await?; + + match existing { + Some(existing_project) => { + // Update existing project + let mut active: projects::ActiveModel = existing_project.into(); + active.name = Set(name.to_string()); + active.last_opened_at = Set(Some(now)); + active.update(db).await?; + } + None => { + // Insert new project + let new_project = projects::ActiveModel { + id: NotSet, + path: Set(path.to_string()), + name: Set(name.to_string()), + last_opened_at: Set(Some(now.clone())), + created_at: Set(Some(now)), + }; + projects::Entity::insert(new_project).exec(db).await?; + } + } + + Ok(()) +} + +/// Get recent projects from the database +pub async fn get_recent_projects( + db: &DatabaseConnection, + query: Option<&str>, + limit: u64, +) -> Result, sea_orm::DbErr> { + let mut select = projects::Entity::find() + .order_by_desc(projects::Column::LastOpenedAt) + .limit(limit); + + if let Some(search) = query { + select = select.filter(projects::Column::Path.contains(search)); + } + + let projects = select.all(db).await?; + + let validated: Vec = projects + .into_iter() + .filter_map(|p| { + let project_type = p.project_type()?; + let path = Path::new(&p.path); + let valid = path.exists(); + Some(Project { + path: p.path, + name: p.name, + project_type, + valid, + }) + }) + .collect(); + + Ok(validated) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn create_test_dir() -> TempDir { + tempfile::tempdir().expect("Failed to create temp dir") + } + + #[test] + fn test_detect_xcode_project_in_directory() { + let dir = create_test_dir(); + std::fs::create_dir(dir.path().join("MyApp.xcodeproj")).unwrap(); + + let result = detect_project(dir.path()); + assert!(result.is_some()); + let project = result.unwrap(); + assert_eq!(project.project_type, ProjectType::Xcode); + assert_eq!(project.name, "MyApp"); + assert!(project.path.ends_with("MyApp.xcodeproj")); + } + + #[test] + fn test_detect_xcode_workspace_in_directory() { + let dir = create_test_dir(); + std::fs::create_dir(dir.path().join("MyWorkspace.xcworkspace")).unwrap(); + + let result = detect_project(dir.path()); + assert!(result.is_some()); + let project = result.unwrap(); + assert_eq!(project.project_type, ProjectType::Xcode); + assert_eq!(project.name, "MyWorkspace"); + assert!(project.path.ends_with("MyWorkspace.xcworkspace")); + } + + #[test] + fn test_workspace_takes_priority_over_project() { + let dir = create_test_dir(); + std::fs::create_dir(dir.path().join("MyApp.xcodeproj")).unwrap(); + std::fs::create_dir(dir.path().join("MyApp.xcworkspace")).unwrap(); + + let result = detect_project(dir.path()); + assert!(result.is_some()); + let project = result.unwrap(); + assert_eq!(project.name, "MyApp"); + assert!(project.path.ends_with("MyApp.xcworkspace")); + } + + #[test] + fn test_detect_direct_xcworkspace_path() { + let dir = create_test_dir(); + let workspace_path = dir.path().join("MyApp.xcworkspace"); + std::fs::create_dir(&workspace_path).unwrap(); + + let result = detect_project(&workspace_path); + assert!(result.is_some()); + let project = result.unwrap(); + assert_eq!(project.project_type, ProjectType::Xcode); + assert_eq!(project.name, "MyApp"); + } + + #[test] + fn test_is_project_path() { + assert!(is_project_path(Path::new("/path/to/MyApp.xcworkspace"))); + assert!(is_project_path(Path::new("/path/to/MyApp.xcodeproj"))); + assert!(is_project_path(Path::new("/path/to/build.gradle"))); + assert!(is_project_path(Path::new("/path/to/build.gradle.kts"))); + assert!(!is_project_path(Path::new("/path/to/some/directory"))); + assert!(!is_project_path(Path::new("/path/to/file.txt"))); + } +} diff --git a/app/src/xcode/discovery.rs b/app/src/xcode/discovery.rs new file mode 100644 index 0000000..2662015 --- /dev/null +++ b/app/src/xcode/discovery.rs @@ -0,0 +1,126 @@ +use crate::services::projects; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use tokio::process::Command; + +#[derive(Debug, thiserror::Error)] +pub enum DiscoveryError { + #[error("No Xcode project found at path")] + ProjectNotFound, + + #[error("Not an Xcode project: {0:?}")] + NotXcodeProject(projects::ProjectType), + + #[error("Failed to execute xcodebuild: {0}")] + XcodebuildExecution(#[from] std::io::Error), + + #[error("xcodebuild failed: {0}")] + XcodebuildFailed(String), + + #[error("Failed to parse xcodebuild output: {0}")] + ParseError(#[from] serde_json::Error), + + #[error("No project/workspace info in xcodebuild output")] + MissingProjectInfo, +} + +impl DiscoveryError { + /// Convert error to user-friendly string for HTTP responses + pub fn to_user_message(&self) -> String { + self.to_string() + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct XcodeProject { + pub path: String, + pub project_type: ProjectType, + pub schemes: Vec, + pub targets: Vec, + pub configurations: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ProjectType { + Project, + Workspace, +} + +#[derive(Debug, Deserialize)] +struct XcodeBuildList { + project: Option, + workspace: Option, +} + +#[derive(Debug, Deserialize)] +struct ProjectInfo { + #[serde(default)] + configurations: Vec, + #[serde(default)] + schemes: Vec, + #[serde(default)] + targets: Vec, +} + +/// Discover Xcode project details including schemes, targets, and configurations +pub async fn discover_project(path: &Path) -> Result { + // Use the services layer to detect the project + let project = projects::detect_project(path).ok_or(DiscoveryError::ProjectNotFound)?; + + // Ensure it's an Xcode project + if !matches!(project.project_type, projects::ProjectType::Xcode) { + return Err(DiscoveryError::NotXcodeProject(project.project_type)); + } + + // Determine if it's a workspace or project based on the path extension + let project_type = if project.path.ends_with(".xcworkspace") { + ProjectType::Workspace + } else { + ProjectType::Project + }; + + // Run xcodebuild to get project details + let output = match project_type { + ProjectType::Workspace => { + Command::new("xcodebuild") + .arg("-workspace") + .arg(&project.path) + .arg("-list") + .arg("-json") + .output() + .await? + } + ProjectType::Project => { + Command::new("xcodebuild") + .arg("-project") + .arg(&project.path) + .arg("-list") + .arg("-json") + .output() + .await? + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(DiscoveryError::XcodebuildFailed(stderr.to_string())); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let build_list: XcodeBuildList = serde_json::from_str(&stdout)?; + + let info = match project_type { + ProjectType::Workspace => build_list.workspace, + ProjectType::Project => build_list.project, + } + .ok_or(DiscoveryError::MissingProjectInfo)?; + + Ok(XcodeProject { + path: project.path, + project_type, + schemes: info.schemes, + targets: info.targets, + configurations: info.configurations, + }) +} diff --git a/app/src/xcode/mod.rs b/app/src/xcode/mod.rs new file mode 100644 index 0000000..65d3c58 --- /dev/null +++ b/app/src/xcode/mod.rs @@ -0,0 +1,3 @@ +pub mod discovery; + +pub use discovery::*; diff --git a/app/tests/fixtures/xcode/Plasma.xcworkspace/contents.xcworkspacedata b/app/tests/fixtures/xcode/Plasma.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..6bfa539 --- /dev/null +++ b/app/tests/fixtures/xcode/Plasma.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app/tests/fixtures/xcode/Plasma.xcworkspace/xcshareddata/xcschemes/Plasma Workspace.xcscheme b/app/tests/fixtures/xcode/Plasma.xcworkspace/xcshareddata/xcschemes/Plasma Workspace.xcscheme new file mode 100644 index 0000000..67ae9e5 --- /dev/null +++ b/app/tests/fixtures/xcode/Plasma.xcworkspace/xcshareddata/xcschemes/Plasma Workspace.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/tests/fixtures/xcode/Plasma.xcworkspace/xcuserdata/pepicrft.xcuserdatad/UserInterfaceState.xcuserstate b/app/tests/fixtures/xcode/Plasma.xcworkspace/xcuserdata/pepicrft.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..956fd16 Binary files /dev/null and b/app/tests/fixtures/xcode/Plasma.xcworkspace/xcuserdata/pepicrft.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/app/tests/fixtures/xcode/Plasma.xcworkspace/xcuserdata/pepicrft.xcuserdatad/xcschemes/xcschememanagement.plist b/app/tests/fixtures/xcode/Plasma.xcworkspace/xcuserdata/pepicrft.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..b9f56c7 --- /dev/null +++ b/app/tests/fixtures/xcode/Plasma.xcworkspace/xcuserdata/pepicrft.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + Plasma Workspace.xcscheme_^#shared#^_ + + orderHint + 1 + + + + diff --git a/app/tests/fixtures/xcode/Plasma/Plasma.xcodeproj/project.pbxproj b/app/tests/fixtures/xcode/Plasma/Plasma.xcodeproj/project.pbxproj new file mode 100644 index 0000000..e9cf81e --- /dev/null +++ b/app/tests/fixtures/xcode/Plasma/Plasma.xcodeproj/project.pbxproj @@ -0,0 +1,333 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + 6C0D70AD2EFD2C490038CC5D /* Plasma.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Plasma.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 6C0D70AF2EFD2C490038CC5D /* Plasma */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Plasma; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 6C0D70AA2EFD2C490038CC5D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 6C0D70A42EFD2C490038CC5D = { + isa = PBXGroup; + children = ( + 6C0D70AF2EFD2C490038CC5D /* Plasma */, + 6C0D70AE2EFD2C490038CC5D /* Products */, + ); + sourceTree = ""; + }; + 6C0D70AE2EFD2C490038CC5D /* Products */ = { + isa = PBXGroup; + children = ( + 6C0D70AD2EFD2C490038CC5D /* Plasma.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 6C0D70AC2EFD2C490038CC5D /* Plasma */ = { + isa = PBXNativeTarget; + buildConfigurationList = 6C0D70B82EFD2C4A0038CC5D /* Build configuration list for PBXNativeTarget "Plasma" */; + buildPhases = ( + 6C0D70A92EFD2C490038CC5D /* Sources */, + 6C0D70AA2EFD2C490038CC5D /* Frameworks */, + 6C0D70AB2EFD2C490038CC5D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 6C0D70AF2EFD2C490038CC5D /* Plasma */, + ); + name = Plasma; + packageProductDependencies = ( + ); + productName = Plasma; + productReference = 6C0D70AD2EFD2C490038CC5D /* Plasma.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 6C0D70A52EFD2C490038CC5D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2620; + LastUpgradeCheck = 2620; + TargetAttributes = { + 6C0D70AC2EFD2C490038CC5D = { + CreatedOnToolsVersion = 26.2; + }; + }; + }; + buildConfigurationList = 6C0D70A82EFD2C490038CC5D /* Build configuration list for PBXProject "Plasma" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 6C0D70A42EFD2C490038CC5D; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 6C0D70AE2EFD2C490038CC5D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 6C0D70AC2EFD2C490038CC5D /* Plasma */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 6C0D70AB2EFD2C490038CC5D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 6C0D70A92EFD2C490038CC5D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 6C0D70B62EFD2C4A0038CC5D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 6C0D70B72EFD2C4A0038CC5D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 6C0D70B92EFD2C4A0038CC5D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = me.pepicrft.Plasma; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 6C0D70BA2EFD2C4A0038CC5D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = me.pepicrft.Plasma; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 6C0D70A82EFD2C490038CC5D /* Build configuration list for PBXProject "Plasma" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6C0D70B62EFD2C4A0038CC5D /* Debug */, + 6C0D70B72EFD2C4A0038CC5D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 6C0D70B82EFD2C4A0038CC5D /* Build configuration list for PBXNativeTarget "Plasma" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6C0D70B92EFD2C4A0038CC5D /* Debug */, + 6C0D70BA2EFD2C4A0038CC5D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 6C0D70A52EFD2C490038CC5D /* Project object */; +} diff --git a/app/tests/fixtures/xcode/Plasma/Plasma.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/app/tests/fixtures/xcode/Plasma/Plasma.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/app/tests/fixtures/xcode/Plasma/Plasma.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app/tests/fixtures/xcode/Plasma/Plasma.xcodeproj/project.xcworkspace/xcuserdata/pepicrft.xcuserdatad/UserInterfaceState.xcuserstate b/app/tests/fixtures/xcode/Plasma/Plasma.xcodeproj/project.xcworkspace/xcuserdata/pepicrft.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..ef71a6d Binary files /dev/null and b/app/tests/fixtures/xcode/Plasma/Plasma.xcodeproj/project.xcworkspace/xcuserdata/pepicrft.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/app/tests/fixtures/xcode/Plasma/Plasma.xcodeproj/xcshareddata/xcschemes/Plasma Project.xcscheme b/app/tests/fixtures/xcode/Plasma/Plasma.xcodeproj/xcshareddata/xcschemes/Plasma Project.xcscheme new file mode 100644 index 0000000..2e3e70d --- /dev/null +++ b/app/tests/fixtures/xcode/Plasma/Plasma.xcodeproj/xcshareddata/xcschemes/Plasma Project.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/tests/fixtures/xcode/Plasma/Plasma.xcodeproj/xcuserdata/pepicrft.xcuserdatad/xcschemes/xcschememanagement.plist b/app/tests/fixtures/xcode/Plasma/Plasma.xcodeproj/xcuserdata/pepicrft.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..31735fc --- /dev/null +++ b/app/tests/fixtures/xcode/Plasma/Plasma.xcodeproj/xcuserdata/pepicrft.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,22 @@ + + + + + SchemeUserState + + Plasma Project.xcscheme_^#shared#^_ + + orderHint + 0 + + + SuppressBuildableAutocreation + + 6C0D70AC2EFD2C490038CC5D + + primary + + + + + diff --git a/app/tests/fixtures/xcode/Plasma/Plasma/Assets.xcassets/AccentColor.colorset/Contents.json b/app/tests/fixtures/xcode/Plasma/Plasma/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/app/tests/fixtures/xcode/Plasma/Plasma/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/app/tests/fixtures/xcode/Plasma/Plasma/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/tests/fixtures/xcode/Plasma/Plasma/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/app/tests/fixtures/xcode/Plasma/Plasma/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/app/tests/fixtures/xcode/Plasma/Plasma/Assets.xcassets/Contents.json b/app/tests/fixtures/xcode/Plasma/Plasma/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/app/tests/fixtures/xcode/Plasma/Plasma/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/app/tests/fixtures/xcode/Plasma/Plasma/ContentView.swift b/app/tests/fixtures/xcode/Plasma/Plasma/ContentView.swift new file mode 100644 index 0000000..1e3bdce --- /dev/null +++ b/app/tests/fixtures/xcode/Plasma/Plasma/ContentView.swift @@ -0,0 +1,24 @@ +// +// ContentView.swift +// Plasma +// +// Created by Pedro Piñera Buendía on 25.12.25. +// + +import SwiftUI + +struct ContentView: View { + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundStyle(.tint) + Text("Hello, world!") + } + .padding() + } +} + +#Preview { + ContentView() +} diff --git a/app/tests/fixtures/xcode/Plasma/Plasma/PlasmaApp.swift b/app/tests/fixtures/xcode/Plasma/Plasma/PlasmaApp.swift new file mode 100644 index 0000000..2e941b9 --- /dev/null +++ b/app/tests/fixtures/xcode/Plasma/Plasma/PlasmaApp.swift @@ -0,0 +1,17 @@ +// +// PlasmaApp.swift +// Plasma +// +// Created by Pedro Piñera Buendía on 25.12.25. +// + +import SwiftUI + +@main +struct PlasmaApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/app/tests/xcode_integration_tests.rs b/app/tests/xcode_integration_tests.rs new file mode 100644 index 0000000..8be6072 --- /dev/null +++ b/app/tests/xcode_integration_tests.rs @@ -0,0 +1,367 @@ +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use serde_json::{json, Value}; +use std::fs; +use std::sync::Arc; +use tempfile::TempDir; +use tower::ServiceExt; + +// Helper to create test infrastructure +fn create_test_dir() -> TempDir { + tempfile::tempdir().expect("Failed to create temp dir") +} + +fn create_mock_xcodeproj(dir: &std::path::Path, name: &str) { + let proj_path = dir.join(format!("{}.xcodeproj", name)); + fs::create_dir(&proj_path).unwrap(); + + // Create a minimal project.pbxproj file + let pbxproj = proj_path.join("project.pbxproj"); + fs::write(&pbxproj, "// Mock project file").unwrap(); +} + +fn create_mock_xcworkspace(dir: &std::path::Path, name: &str) { + let workspace_path = dir.join(format!("{}.xcworkspace", name)); + fs::create_dir(&workspace_path).unwrap(); + + // Create contents.xcworkspacedata + let contents = workspace_path.join("contents.xcworkspacedata"); + fs::write( + &contents, + r#" + +"#, + ) + .unwrap(); +} + +async fn create_test_app() -> axum::Router { + // Create in-memory database for testing + let db = app_lib::db::Database::new(std::path::Path::new(":memory:")) + .await + .unwrap(); + + let state = Arc::new(app_lib::server::AppState { db }); + + app_lib::routes::create_routes(None).with_state(state) +} + +#[tokio::test] +async fn test_xcode_schemes_endpoint_with_xcodeproj() { + let app = create_test_app().await; + let temp_dir = create_test_dir(); + create_mock_xcodeproj(temp_dir.path(), "TestApp"); + + let proj_path = temp_dir.path().join("TestApp.xcodeproj"); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/xcode/discover") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "path": proj_path.to_str().unwrap() + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + // Note: This will fail with xcodebuild error since we have a mock project + // In a real scenario, xcodebuild would need a valid project structure + // We're testing the endpoint accepts the request properly + assert!(response.status() == StatusCode::OK || response.status() == StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_xcode_schemes_endpoint_with_directory() { + let app = create_test_app().await; + let temp_dir = create_test_dir(); + create_mock_xcodeproj(temp_dir.path(), "TestApp"); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/xcode/discover") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "path": temp_dir.path().to_str().unwrap() + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + // Should find the project in the directory + assert!(response.status() == StatusCode::OK || response.status() == StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_xcode_schemes_endpoint_nonexistent_path() { + let app = create_test_app().await; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/xcode/discover") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "path": "/nonexistent/path" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: Value = serde_json::from_slice(&body).unwrap(); + + // Should return an error for nonexistent path (either "does not exist" or "No Xcode project found") + let error = json["error"].as_str().unwrap(); + assert!( + error.contains("does not exist") || error.contains("No Xcode project found"), + "Unexpected error message: {}", + error + ); +} + +#[tokio::test] +async fn test_xcode_schemes_endpoint_directory_without_project() { + let app = create_test_app().await; + let temp_dir = create_test_dir(); + + // Create a file but no Xcode project + fs::write(temp_dir.path().join("README.md"), "# Test").unwrap(); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/xcode/discover") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "path": temp_dir.path().to_str().unwrap() + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: Value = serde_json::from_slice(&body).unwrap(); + + // Should return an error for directory without project + let error = json["error"].as_str().unwrap(); + assert!( + error.contains("No .xcworkspace or .xcodeproj found") + || error.contains("No Xcode project found"), + "Unexpected error message: {}", + error + ); +} + +#[tokio::test] +async fn test_xcode_schemes_endpoint_workspace_priority() { + let app = create_test_app().await; + let temp_dir = create_test_dir(); + + // Create both project and workspace + create_mock_xcodeproj(temp_dir.path(), "TestApp"); + create_mock_xcworkspace(temp_dir.path(), "TestWorkspace"); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/xcode/discover") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "path": temp_dir.path().to_str().unwrap() + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + // Should prefer workspace over project (will fail with xcodebuild but that's ok) + // We're testing that the endpoint picks the workspace + assert!(response.status() == StatusCode::OK || response.status() == StatusCode::BAD_REQUEST); + + if response.status() == StatusCode::BAD_REQUEST { + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: Value = serde_json::from_slice(&body).unwrap(); + + // Error should reference xcodebuild, not "not found" + let error = json["error"].as_str().unwrap(); + assert!( + error.contains("xcodebuild") || error.contains("workspace"), + "Error should be from xcodebuild execution, not file discovery: {}", + error + ); + } +} + +#[tokio::test] +async fn test_xcode_schemes_endpoint_malformed_json() { + let app = create_test_app().await; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/xcode/discover") + .header("content-type", "application/json") + .body(Body::from("{invalid json}")) + .unwrap(), + ) + .await + .unwrap(); + + // Should return bad request for malformed JSON + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_xcode_schemes_endpoint_missing_path_field() { + let app = create_test_app().await; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/xcode/discover") + .header("content-type", "application/json") + .body(Body::from(json!({}).to_string())) + .unwrap(), + ) + .await + .unwrap(); + + // Axum returns 422 Unprocessable Entity when required JSON fields are missing + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +// Tests with real Xcode fixture + +fn fixture_path(relative: &str) -> String { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + format!("{}/tests/fixtures/xcode/{}", manifest_dir, relative) +} + +#[tokio::test] +#[cfg(target_os = "macos")] +async fn test_real_xcode_project_discovery() { + let app = create_test_app().await; + let project_path = fixture_path("Plasma/Plasma.xcodeproj"); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/xcode/discover") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "path": project_path + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: Value = serde_json::from_slice(&body).unwrap(); + + // Verify the response structure + assert_eq!(json["project_type"], "project"); + assert!(json["schemes"].is_array()); + assert!(json["targets"].is_array()); + assert!(json["configurations"].is_array()); + + // Verify expected values from the fixture + let schemes = json["schemes"].as_array().unwrap(); + assert_eq!(schemes.len(), 1); + assert_eq!(schemes[0], "Plasma Project"); + + let targets = json["targets"].as_array().unwrap(); + assert_eq!(targets.len(), 1); + assert_eq!(targets[0], "Plasma"); + + let configurations = json["configurations"].as_array().unwrap(); + assert_eq!(configurations.len(), 2); + assert!(configurations.contains(&json!("Debug"))); + assert!(configurations.contains(&json!("Release"))); +} + +#[tokio::test] +#[cfg(target_os = "macos")] +async fn test_real_xcode_project_discovery_from_directory() { + let app = create_test_app().await; + let directory_path = fixture_path("Plasma"); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/xcode/discover") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "path": directory_path + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: Value = serde_json::from_slice(&body).unwrap(); + + // Should discover the project in the directory + assert_eq!(json["project_type"], "project"); + + let schemes = json["schemes"].as_array().unwrap(); + assert_eq!(schemes[0], "Plasma Project"); +}