use crate::http::context::{AppContext, OpenApiResult, bad_request}; use crate::io::fs; use poem_openapi::{Object, OpenApi, Tags, payload::Json}; use serde::Deserialize; use std::sync::Arc; #[derive(Tags)] enum ProjectTags { Project, } #[derive(Deserialize, Object)] struct PathPayload { path: String, } pub struct ProjectApi { pub ctx: Arc, } #[OpenApi(tag = "ProjectTags::Project")] impl ProjectApi { /// Get the currently open project path (if any). /// /// Returns null when no project is open. #[oai(path = "/project", method = "get")] async fn get_current_project(&self) -> OpenApiResult>> { let result = fs::get_current_project(&self.ctx.state, self.ctx.store.as_ref()) .map_err(bad_request)?; Ok(Json(result)) } /// Open a project and set it as the current project. /// /// Persists the selected path for later sessions. #[oai(path = "/project", method = "post")] async fn open_project(&self, payload: Json) -> OpenApiResult> { let confirmed = fs::open_project( payload.0.path, &self.ctx.state, self.ctx.store.as_ref(), ) .await .map_err(bad_request)?; Ok(Json(confirmed)) } /// Close the current project and clear the stored selection. #[oai(path = "/project", method = "delete")] async fn close_project(&self) -> OpenApiResult> { // TRACE:MERGE-DEBUG — remove once root cause is found crate::slog_error!( "[MERGE-DEBUG] DELETE /project called! \ Backtrace: this is the only code path that clears project_root." ); fs::close_project(&self.ctx.state, self.ctx.store.as_ref()).map_err(bad_request)?; Ok(Json(true)) } /// List known projects from the store. #[oai(path = "/projects", method = "get")] async fn list_known_projects(&self) -> OpenApiResult>> { let projects = fs::get_known_projects(self.ctx.store.as_ref()).map_err(bad_request)?; Ok(Json(projects)) } /// Forget a known project path. #[oai(path = "/projects/forget", method = "post")] async fn forget_known_project(&self, payload: Json) -> OpenApiResult> { fs::forget_known_project(payload.0.path, self.ctx.store.as_ref()).map_err(bad_request)?; Ok(Json(true)) } } #[cfg(test)] mod tests { use super::*; use crate::http::context::AppContext; use tempfile::TempDir; fn make_api(dir: &TempDir) -> ProjectApi { ProjectApi { ctx: Arc::new(AppContext::new_test(dir.path().to_path_buf())), } } #[tokio::test] async fn get_current_project_returns_none_when_unset() { let dir = TempDir::new().unwrap(); let api = make_api(&dir); // Clear the project root that new_test sets api.close_project().await.unwrap(); let result = api.get_current_project().await.unwrap(); assert!(result.0.is_none()); } #[tokio::test] async fn get_current_project_returns_path_from_state() { let dir = TempDir::new().unwrap(); let api = make_api(&dir); let result = api.get_current_project().await.unwrap(); assert_eq!(result.0, Some(dir.path().to_string_lossy().to_string())); } #[tokio::test] async fn open_project_succeeds_with_valid_directory() { let dir = TempDir::new().unwrap(); let api = make_api(&dir); let path = dir.path().to_string_lossy().to_string(); let payload = Json(PathPayload { path: path.clone() }); let result = api.open_project(payload).await.unwrap(); assert_eq!(result.0, path); } #[tokio::test] async fn open_project_fails_with_nonexistent_file_path() { let dir = TempDir::new().unwrap(); let api = make_api(&dir); // Create a file (not a directory) to trigger validation error let file_path = dir.path().join("not_a_dir.txt"); std::fs::write(&file_path, "content").unwrap(); let payload = Json(PathPayload { path: file_path.to_string_lossy().to_string(), }); let result = api.open_project(payload).await; assert!(result.is_err()); } #[tokio::test] async fn close_project_returns_true() { let dir = TempDir::new().unwrap(); let api = make_api(&dir); let result = api.close_project().await.unwrap(); assert!(result.0); } #[tokio::test] async fn close_project_clears_current_project() { let dir = TempDir::new().unwrap(); let api = make_api(&dir); // Verify project is set initially let before = api.get_current_project().await.unwrap(); assert!(before.0.is_some()); // Close the project api.close_project().await.unwrap(); // Verify project is now None let after = api.get_current_project().await.unwrap(); assert!(after.0.is_none()); } #[tokio::test] async fn list_known_projects_returns_empty_initially() { let dir = TempDir::new().unwrap(); let api = make_api(&dir); // Close the project so the store has no known projects api.close_project().await.unwrap(); let result = api.list_known_projects().await.unwrap(); assert!(result.0.is_empty()); } #[tokio::test] async fn list_known_projects_returns_project_after_open() { let dir = TempDir::new().unwrap(); let api = make_api(&dir); let path = dir.path().to_string_lossy().to_string(); api.open_project(Json(PathPayload { path: path.clone() })) .await .unwrap(); let result = api.list_known_projects().await.unwrap(); assert!(result.0.contains(&path)); } #[tokio::test] async fn forget_known_project_removes_project() { let dir = TempDir::new().unwrap(); let api = make_api(&dir); let path = dir.path().to_string_lossy().to_string(); api.open_project(Json(PathPayload { path: path.clone() })) .await .unwrap(); let before = api.list_known_projects().await.unwrap(); assert!(before.0.contains(&path)); let result = api .forget_known_project(Json(PathPayload { path: path.clone() })) .await .unwrap(); assert!(result.0); let after = api.list_known_projects().await.unwrap(); assert!(!after.0.contains(&path)); } #[tokio::test] async fn forget_known_project_returns_true_for_nonexistent_path() { let dir = TempDir::new().unwrap(); let api = make_api(&dir); let result = api .forget_known_project(Json(PathPayload { path: "/some/unknown/path".to_string(), })) .await .unwrap(); assert!(result.0); } }