//! Project management — tracks known projects and resolves the active project root. use crate::state::SessionState; use crate::store::StoreOps; use serde_json::json; use std::fs; use std::path::PathBuf; use super::scaffold::scaffold_story_kit; const KEY_LAST_PROJECT: &str = "last_project_path"; const KEY_KNOWN_PROJECTS: &str = "known_projects"; /// Validate that a path exists and is a directory (pure function for testing) async fn validate_project_path(path: PathBuf) -> Result<(), String> { tokio::task::spawn_blocking(move || { if !path.exists() { return Err(format!("Path does not exist: {}", path.display())); } if !path.is_dir() { return Err(format!("Path is not a directory: {}", path.display())); } Ok(()) }) .await .map_err(|e| format!("Task failed: {}", e))? } pub(crate) async fn ensure_project_root_with_story_kit( path: PathBuf, port: u16, ) -> Result<(), String> { tokio::task::spawn_blocking(move || { if !path.exists() { fs::create_dir_all(&path) .map_err(|e| format!("Failed to create project directory: {}", e))?; } if !path.join(".huskies").is_dir() { scaffold_story_kit(&path, port)?; } // Always update .mcp.json with the current port so the bot connects to // the right endpoint even when HUSKIES_PORT changes between restarts. let mcp_content = format!( "{{\n \"mcpServers\": {{\n \"huskies\": {{\n \"type\": \"http\",\n \"url\": \"http://localhost:{port}/mcp\"\n }}\n }}\n}}\n" ); fs::write(path.join(".mcp.json"), mcp_content) .map_err(|e| format!("Failed to write .mcp.json: {}", e))?; Ok(()) }) .await .map_err(|e| format!("Task failed: {}", e))? } pub async fn open_project( path: String, state: &SessionState, store: &dyn StoreOps, port: u16, ) -> Result { let p = PathBuf::from(&path); ensure_project_root_with_story_kit(p.clone(), port).await?; validate_project_path(p.clone()).await?; { // TRACE:MERGE-DEBUG — remove once root cause is found crate::slog!( "[MERGE-DEBUG] open_project: setting project_root to {:?}", p ); let mut root = state.project_root.lock().map_err(|e| e.to_string())?; *root = Some(p); } store.set(KEY_LAST_PROJECT, json!(path)); let mut known_projects = get_known_projects(store)?; known_projects.retain(|p| p != &path); known_projects.insert(0, path.clone()); store.set(KEY_KNOWN_PROJECTS, json!(known_projects)); store.save()?; Ok(path) } /// Close the active project by clearing the in-memory root and stored path. #[allow(dead_code)] pub fn close_project(state: &SessionState, store: &dyn StoreOps) -> Result<(), String> { { // TRACE:MERGE-DEBUG — remove once root cause is found crate::slog!("[MERGE-DEBUG] close_project: setting project_root to None"); let mut root = state.project_root.lock().map_err(|e| e.to_string())?; *root = None; } store.delete(KEY_LAST_PROJECT); store.save()?; Ok(()) } /// Return the active project path, restoring it from the store if needed. #[allow(dead_code)] pub fn get_current_project( state: &SessionState, store: &dyn StoreOps, ) -> Result, String> { { let root = state.project_root.lock().map_err(|e| e.to_string())?; if let Some(path) = &*root { return Ok(Some(path.to_string_lossy().to_string())); } } if let Some(path_str) = store .get(KEY_LAST_PROJECT) .as_ref() .and_then(|val| val.as_str()) { let p = PathBuf::from(path_str); if p.exists() && p.is_dir() { // TRACE:MERGE-DEBUG — remove once root cause is found crate::slog!( "[MERGE-DEBUG] get_current_project: project_root was None, \ restoring from store to {:?}", p ); let mut root = state.project_root.lock().map_err(|e| e.to_string())?; *root = Some(p); return Ok(Some(path_str.to_string())); } } Ok(None) } /// List all previously-opened project paths from the store. #[allow(dead_code)] pub fn get_known_projects(store: &dyn StoreOps) -> Result, String> { let projects = store .get(KEY_KNOWN_PROJECTS) .and_then(|val| val.as_array().cloned()) .unwrap_or_default() .into_iter() .filter_map(|val| val.as_str().map(|s| s.to_string())) .collect(); Ok(projects) } /// Remove a project path from the known-projects list in the store. #[allow(dead_code)] pub fn forget_known_project(path: String, store: &dyn StoreOps) -> Result<(), String> { let mut known_projects = get_known_projects(store)?; let original_len = known_projects.len(); known_projects.retain(|p| p != &path); if known_projects.len() == original_len { return Ok(()); } store.set(KEY_KNOWN_PROJECTS, json!(known_projects)); store.save()?; Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::store::JsonFileStore; use tempfile::tempdir; fn make_store(dir: &tempfile::TempDir) -> JsonFileStore { JsonFileStore::new(dir.path().join("test_store.json")).unwrap() } fn make_state_with_root(path: PathBuf) -> SessionState { let state = SessionState::default(); { let mut root = state.project_root.lock().unwrap(); *root = Some(path); } state } // --- open/close/get project --- #[tokio::test] async fn open_project_sets_root_and_persists() { let dir = tempdir().unwrap(); let project_dir = dir.path().join("myproject"); fs::create_dir_all(&project_dir).unwrap(); let store = make_store(&dir); let state = SessionState::default(); let result = open_project( project_dir.to_string_lossy().to_string(), &state, &store, 3001, ) .await; assert!(result.is_ok()); let root = state.get_project_root().unwrap(); assert_eq!(root, project_dir); } #[tokio::test] async fn open_project_updates_mcp_json_with_current_port() { // .mcp.json must always be updated with the actual running port so the // bot connects to the right MCP endpoint even when HUSKIES_PORT changes. let dir = tempdir().unwrap(); let project_dir = dir.path().join("myproject"); fs::create_dir_all(&project_dir).unwrap(); // Pre-write .mcp.json with a different port to simulate a stale file. let mcp_path = project_dir.join(".mcp.json"); fs::write(&mcp_path, "{\"stale\": true}").unwrap(); let store = make_store(&dir); let state = SessionState::default(); open_project( project_dir.to_string_lossy().to_string(), &state, &store, 3002, ) .await .unwrap(); let content = fs::read_to_string(&mcp_path).unwrap(); assert!( content.contains("3002"), "open_project must update .mcp.json with the actual running port" ); assert!( content.contains("localhost"), "mcp.json must reference localhost" ); } #[tokio::test] async fn open_project_writes_mcp_json_when_missing() { let dir = tempdir().unwrap(); let project_dir = dir.path().join("myproject"); fs::create_dir_all(&project_dir).unwrap(); let store = make_store(&dir); let state = SessionState::default(); open_project( project_dir.to_string_lossy().to_string(), &state, &store, 3001, ) .await .unwrap(); let mcp_path = project_dir.join(".mcp.json"); assert!( mcp_path.exists(), "open_project should write .mcp.json for new projects" ); let content = fs::read_to_string(&mcp_path).unwrap(); assert!( content.contains("3001"), "mcp.json should reference the server port" ); assert!( content.contains("localhost"), "mcp.json should reference localhost" ); } /// Regression test for bug 371: no-arg `huskies` in empty directory skips scaffold. /// `open_project` on a directory without `.huskies/` must create all required scaffold /// files — the same files that `huskies .` produces. #[tokio::test] async fn open_project_on_empty_dir_creates_full_scaffold() { let dir = tempdir().unwrap(); let project_dir = dir.path().join("myproject"); fs::create_dir_all(&project_dir).unwrap(); let store = make_store(&dir); let state = SessionState::default(); open_project( project_dir.to_string_lossy().to_string(), &state, &store, 3001, ) .await .unwrap(); assert!( project_dir.join(".huskies/project.toml").exists(), "open_project must create .huskies/project.toml" ); assert!( project_dir.join(".mcp.json").exists(), "open_project must create .mcp.json" ); assert!( project_dir.join("CLAUDE.md").exists(), "open_project must create CLAUDE.md" ); assert!( project_dir.join("script/test").exists(), "open_project must create script/test" ); } #[tokio::test] async fn close_project_clears_root() { let dir = tempdir().unwrap(); let project_dir = dir.path().join("myproject"); fs::create_dir_all(&project_dir).unwrap(); let store = make_store(&dir); let state = make_state_with_root(project_dir); close_project(&state, &store).unwrap(); let root = state.project_root.lock().unwrap(); assert!(root.is_none()); } #[tokio::test] async fn get_current_project_returns_none_when_no_project() { let dir = tempdir().unwrap(); let store = make_store(&dir); let state = SessionState::default(); let result = get_current_project(&state, &store).unwrap(); assert!(result.is_none()); } #[tokio::test] async fn get_current_project_returns_active_root() { let dir = tempdir().unwrap(); let store = make_store(&dir); let state = make_state_with_root(dir.path().to_path_buf()); let result = get_current_project(&state, &store).unwrap(); assert!(result.is_some()); } // --- known projects --- #[test] fn known_projects_empty_by_default() { let dir = tempdir().unwrap(); let store = make_store(&dir); let projects = get_known_projects(&store).unwrap(); assert!(projects.is_empty()); } #[tokio::test] async fn open_project_adds_to_known_projects() { let dir = tempdir().unwrap(); let project_dir = dir.path().join("proj1"); fs::create_dir_all(&project_dir).unwrap(); let store = make_store(&dir); let state = SessionState::default(); open_project( project_dir.to_string_lossy().to_string(), &state, &store, 3001, ) .await .unwrap(); let projects = get_known_projects(&store).unwrap(); assert_eq!(projects.len(), 1); } #[test] fn forget_known_project_removes_it() { let dir = tempdir().unwrap(); let store = make_store(&dir); store.set(KEY_KNOWN_PROJECTS, json!(["/a", "/b", "/c"])); forget_known_project("/b".to_string(), &store).unwrap(); let projects = get_known_projects(&store).unwrap(); assert_eq!(projects, vec!["/a", "/c"]); } #[test] fn forget_unknown_project_is_noop() { let dir = tempdir().unwrap(); let store = make_store(&dir); store.set(KEY_KNOWN_PROJECTS, json!(["/a"])); forget_known_project("/nonexistent".to_string(), &store).unwrap(); let projects = get_known_projects(&store).unwrap(); assert_eq!(projects, vec!["/a"]); } // --- validate_project_path --- #[tokio::test] async fn validate_project_path_rejects_missing() { let result = validate_project_path(PathBuf::from("/nonexistent/path")).await; assert!(result.is_err()); } #[tokio::test] async fn validate_project_path_rejects_file() { let dir = tempdir().unwrap(); let file = dir.path().join("not_a_dir.txt"); fs::write(&file, "").unwrap(); let result = validate_project_path(file).await; assert!(result.is_err()); } #[tokio::test] async fn validate_project_path_accepts_directory() { let dir = tempdir().unwrap(); let result = validate_project_path(dir.path().to_path_buf()).await; assert!(result.is_ok()); } // --- open_project scaffolding --- #[tokio::test] async fn open_project_scaffolds_when_story_kit_missing() { let dir = tempdir().unwrap(); let project_dir = dir.path().join("myproject"); fs::create_dir_all(&project_dir).unwrap(); let store = make_store(&dir); let state = SessionState::default(); open_project( project_dir.to_string_lossy().to_string(), &state, &store, 3001, ) .await .unwrap(); // .huskies/ should have been created automatically assert!(project_dir.join(".huskies").is_dir()); } #[tokio::test] async fn open_project_does_not_overwrite_existing_story_kit() { let dir = tempdir().unwrap(); let project_dir = dir.path().join("myproject"); let sk_dir = project_dir.join(".huskies"); fs::create_dir_all(&sk_dir).unwrap(); let readme = sk_dir.join("README.md"); fs::write(&readme, "custom content").unwrap(); let store = make_store(&dir); let state = SessionState::default(); open_project( project_dir.to_string_lossy().to_string(), &state, &store, 3001, ) .await .unwrap(); // Existing .huskies/ content should not be overwritten assert_eq!(fs::read_to_string(&readme).unwrap(), "custom content"); } }