diff --git a/server/src/http/project.rs b/server/src/http/project.rs index ac5f8882..e3df681a 100644 --- a/server/src/http/project.rs +++ b/server/src/http/project.rs @@ -1,6 +1,7 @@ -//! HTTP project endpoints — REST API for project initialization and context management. -use crate::http::context::{AppContext, OpenApiResult, bad_request}; -use crate::io::fs; +//! HTTP project endpoints — thin adapters over `service::project`. +use crate::http::context::{AppContext, OpenApiResult, bad_request, not_found}; +use crate::service::project::{self as svc, Error as ProjectError}; +use poem::http::StatusCode; use poem_openapi::{Object, OpenApi, Tags, payload::Json}; use serde::Deserialize; use std::sync::Arc; @@ -15,6 +16,17 @@ struct PathPayload { path: String, } +/// Map a typed [`ProjectError`] to a `poem::Error` with the appropriate HTTP status. +fn map_project_error(e: ProjectError) -> poem::Error { + match e { + ProjectError::PathNotFound(msg) => not_found(msg), + ProjectError::NotADirectory(msg) => bad_request(msg), + ProjectError::Internal(msg) => { + poem::Error::from_string(msg, StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + pub struct ProjectApi { pub ctx: Arc, } @@ -26,8 +38,8 @@ impl ProjectApi { /// 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)?; + let result = svc::get_current_project(&self.ctx.state, self.ctx.store.as_ref()) + .map_err(map_project_error)?; Ok(Json(result)) } @@ -36,14 +48,14 @@ impl ProjectApi { /// 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( + let confirmed = svc::open_project( payload.0.path, &self.ctx.state, self.ctx.store.as_ref(), self.ctx.agents.port(), ) .await - .map_err(bad_request)?; + .map_err(map_project_error)?; Ok(Json(confirmed)) } @@ -55,21 +67,23 @@ impl ProjectApi { "[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)?; + svc::close_project(&self.ctx.state, self.ctx.store.as_ref()).map_err(map_project_error)?; 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)?; + let projects = + svc::get_known_projects(self.ctx.store.as_ref()).map_err(map_project_error)?; 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)?; + svc::forget_known_project(payload.0.path, self.ctx.store.as_ref()) + .map_err(map_project_error)?; Ok(Json(true)) } } diff --git a/server/src/io/fs/mod.rs b/server/src/io/fs/mod.rs index 6bdab696..14208dea 100644 --- a/server/src/io/fs/mod.rs +++ b/server/src/io/fs/mod.rs @@ -11,6 +11,4 @@ pub use files::{ }; pub use paths::{find_story_kit_root, get_home_directory, resolve_cli_path}; pub use preferences::{get_model_preference, set_model_preference}; -pub use project::{ - close_project, forget_known_project, get_current_project, get_known_projects, open_project, -}; +pub use project::open_project; diff --git a/server/src/io/fs/project.rs b/server/src/io/fs/project.rs index 5237c2f2..805c1fea 100644 --- a/server/src/io/fs/project.rs +++ b/server/src/io/fs/project.rs @@ -84,6 +84,7 @@ pub async fn open_project( Ok(path) } +#[allow(dead_code)] pub fn close_project(state: &SessionState, store: &dyn StoreOps) -> Result<(), String> { { // TRACE:MERGE-DEBUG — remove once root cause is found @@ -98,6 +99,7 @@ pub fn close_project(state: &SessionState, store: &dyn StoreOps) -> Result<(), S Ok(()) } +#[allow(dead_code)] pub fn get_current_project( state: &SessionState, store: &dyn StoreOps, @@ -131,6 +133,7 @@ pub fn get_current_project( Ok(None) } +#[allow(dead_code)] pub fn get_known_projects(store: &dyn StoreOps) -> Result, String> { let projects = store .get(KEY_KNOWN_PROJECTS) @@ -143,6 +146,7 @@ pub fn get_known_projects(store: &dyn StoreOps) -> Result, String> { Ok(projects) } +#[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(); diff --git a/server/src/service/mod.rs b/server/src/service/mod.rs index c80411b6..32609f66 100644 --- a/server/src/service/mod.rs +++ b/server/src/service/mod.rs @@ -8,4 +8,5 @@ pub mod agents; pub mod events; pub mod health; +pub mod project; pub mod ws; diff --git a/server/src/service/project/io.rs b/server/src/service/project/io.rs new file mode 100644 index 00000000..8012c69f --- /dev/null +++ b/server/src/service/project/io.rs @@ -0,0 +1,144 @@ +//! Project I/O — the ONLY place in `service/project/` that may perform +//! filesystem reads, state mutations, or store operations. +//! +//! Every function here is a thin adapter that converts lower-level errors +//! into the typed [`super::Error`] for this domain. No business logic lives +//! here; branching belongs in `selection.rs` or `mod.rs`. + +use crate::state::SessionState; +use crate::store::StoreOps; +use std::path::{Path, PathBuf}; + +use super::Error; + +const KEY_LAST_PROJECT: &str = "last_project_path"; +const KEY_KNOWN_PROJECTS: &str = "known_projects"; + +/// Validate that `path` exists and is a directory, returning a typed error. +pub(super) async fn validate_path(path: &Path) -> Result<(), Error> { + let p = path.to_path_buf(); + tokio::task::spawn_blocking(move || { + if !p.exists() { + return Err(Error::PathNotFound(format!( + "Path does not exist: {}", + p.display() + ))); + } + if !p.is_dir() { + return Err(Error::NotADirectory(format!( + "Path is not a directory: {}", + p.display() + ))); + } + Ok(()) + }) + .await + .map_err(|e| Error::Internal(format!("Task failed: {e}")))? +} + +/// Ensure the project directory has a `.huskies/` scaffold and an `.mcp.json`. +/// +/// Creates the directory if it does not exist. If `.huskies/` is absent, +/// writes the full scaffold. Always rewrites `.mcp.json` with `port`. +pub(super) async fn ensure_scaffold(path: PathBuf, port: u16) -> Result<(), Error> { + crate::io::fs::project::ensure_project_root_with_story_kit(path, port) + .await + .map_err(Error::Internal) +} + +/// Set (or clear) the active project root in session state. +pub(super) fn set_project_root(state: &SessionState, path: Option) -> Result<(), Error> { + // TRACE:MERGE-DEBUG — remove once root cause is found + match &path { + Some(p) => crate::slog!( + "[MERGE-DEBUG] open_project: setting project_root to {:?}", + p + ), + None => crate::slog!("[MERGE-DEBUG] close_project: setting project_root to None"), + } + let mut root = state + .project_root + .lock() + .map_err(|e| Error::Internal(format!("Lock poisoned: {e}")))?; + *root = path; + Ok(()) +} + +/// Read the active project root from session state. +pub(super) fn get_project_root_from_state(state: &SessionState) -> Result, Error> { + let root = state + .project_root + .lock() + .map_err(|e| Error::Internal(format!("Lock poisoned: {e}")))?; + Ok(root.clone()) +} + +/// Persist the last-used project path and known-projects list to the store. +/// +/// Sets both keys and flushes in a single `save()` call to minimise writes. +pub(super) fn persist_open_project( + path: &str, + known: &[String], + store: &dyn StoreOps, +) -> Result<(), Error> { + store.set(KEY_LAST_PROJECT, serde_json::json!(path)); + store.set(KEY_KNOWN_PROJECTS, serde_json::json!(known)); + store.save().map_err(Error::Internal) +} + +/// Remove the persisted project path from the store and flush. +pub(super) fn clear_project(store: &dyn StoreOps) -> Result<(), Error> { + store.delete(KEY_LAST_PROJECT); + store.save().map_err(Error::Internal) +} + +/// Read the known-projects list from the store. +pub(super) fn read_known_projects(store: &dyn StoreOps) -> Vec { + 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() +} + +/// Persist the known-projects list to the store and flush. +pub(super) fn save_known_projects(projects: &[String], store: &dyn StoreOps) -> Result<(), Error> { + store.set(KEY_KNOWN_PROJECTS, serde_json::json!(projects)); + store.save().map_err(Error::Internal) +} + +/// Try to restore the project root from the persisted store path. +/// +/// If the stored path still exists and is a directory, updates session state +/// and returns the path string. Returns `Ok(None)` when no valid stored path +/// is found. +pub(super) fn restore_from_store( + state: &SessionState, + store: &dyn StoreOps, +) -> Result, Error> { + let last = store + .get(KEY_LAST_PROJECT) + .and_then(|val| val.as_str().map(|s| s.to_string())); + + if let Some(path_str) = last { + 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| Error::Internal(format!("Lock poisoned: {e}")))?; + *root = Some(p); + return Ok(Some(path_str)); + } + } + + Ok(None) +} diff --git a/server/src/service/project/mod.rs b/server/src/service/project/mod.rs new file mode 100644 index 00000000..cdad1a60 --- /dev/null +++ b/server/src/service/project/mod.rs @@ -0,0 +1,102 @@ +//! Project service — public API for the project domain. +//! +//! Exposes functions to open, close, query, and manage known projects. +//! HTTP handlers call these functions instead of touching `io::fs` or session +//! state directly. +//! +//! Conventions: `docs/architecture/service-modules.md` + +pub(super) mod io; +pub mod selection; + +use crate::state::SessionState; +use crate::store::StoreOps; +use std::path::PathBuf; + +// ── Error type ──────────────────────────────────────────────────────────────── + +/// Typed errors returned by `service::project` functions. +/// +/// HTTP handlers map these to specific status codes: +/// - [`Error::PathNotFound`] → 404 Not Found +/// - [`Error::NotADirectory`] → 400 Bad Request +/// - [`Error::Internal`] → 500 Internal Server Error +#[derive(Debug)] +pub enum Error { + /// The given path does not exist on the filesystem. + PathNotFound(String), + /// The given path exists but is not a directory. + NotADirectory(String), + /// An internal error occurred (lock poisoned, store I/O failure, task panic). + Internal(String), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PathNotFound(msg) => write!(f, "Project not found: {msg}"), + Self::NotADirectory(msg) => write!(f, "Invalid project path: {msg}"), + Self::Internal(msg) => write!(f, "Internal error: {msg}"), + } + } +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/// Open a project, scaffolding it when needed, and persist the selection. +/// +/// Validates that `path` exists and is a directory. On success, returns the +/// canonical path string. The path is promoted to the front of the known list. +pub async fn open_project( + path: String, + state: &SessionState, + store: &dyn StoreOps, + port: u16, +) -> Result { + let p = PathBuf::from(&path); + io::ensure_scaffold(p.clone(), port).await?; + io::validate_path(&p).await?; + io::set_project_root(state, Some(p))?; + let known = io::read_known_projects(store); + let updated = selection::promote_to_front(known, &path); + io::persist_open_project(&path, &updated, store)?; + Ok(path) +} + +/// Close the current project and remove it from the persisted selection. +pub fn close_project(state: &SessionState, store: &dyn StoreOps) -> Result<(), Error> { + io::set_project_root(state, None)?; + io::clear_project(store) +} + +/// Return the currently open project path, if any. +/// +/// Checks in-memory state first, then falls back to the store. +/// If the store has a valid path, restores it into state for future calls. +pub fn get_current_project( + state: &SessionState, + store: &dyn StoreOps, +) -> Result, Error> { + if let Some(path) = io::get_project_root_from_state(state)? { + return Ok(Some(path.to_string_lossy().to_string())); + } + io::restore_from_store(state, store) +} + +/// Return all known (previously opened) project paths from the store. +pub fn get_known_projects(store: &dyn StoreOps) -> Result, Error> { + Ok(io::read_known_projects(store)) +} + +/// Remove a path from the known-projects list. +/// +/// Returns `Ok(())` whether or not the path was present (idempotent). +pub fn forget_known_project(path: String, store: &dyn StoreOps) -> Result<(), Error> { + let known = io::read_known_projects(store); + let before = known.len(); + let updated: Vec = known.into_iter().filter(|p| p != &path).collect(); + if updated.len() == before { + return Ok(()); + } + io::save_known_projects(&updated, store) +} diff --git a/server/src/service/project/selection.rs b/server/src/service/project/selection.rs new file mode 100644 index 00000000..91b94d2f --- /dev/null +++ b/server/src/service/project/selection.rs @@ -0,0 +1,100 @@ +//! Pure project-selection logic — no I/O, no async, no side effects. +//! +//! All functions here are deterministic and depend only on their arguments. + +/// Promote a project path to the front of the known-projects list. +/// +/// Removes any existing occurrence of `path` and inserts it at position 0, +/// so the most-recently-opened project is always first. +pub fn promote_to_front(mut projects: Vec, path: &str) -> Vec { + projects.retain(|p| p != path); + projects.insert(0, path.to_string()); + projects +} + +#[allow(dead_code)] +/// Extract the display name for a project from its filesystem path. +/// +/// Returns the last non-empty path component, or `None` for root or empty input. +pub fn project_name_from_path(path: &str) -> Option<&str> { + path.trim_end_matches('/') + .rsplit('/') + .find(|s| !s.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn promote_to_front_inserts_new_path_at_position_zero() { + let result = promote_to_front(vec!["/a".to_string(), "/b".to_string()], "/c"); + assert_eq!(result, vec!["/c", "/a", "/b"]); + } + + #[test] + fn promote_to_front_moves_existing_entry_to_front() { + let result = promote_to_front( + vec!["/a".to_string(), "/b".to_string(), "/c".to_string()], + "/b", + ); + assert_eq!(result, vec!["/b", "/a", "/c"]); + } + + #[test] + fn promote_to_front_is_idempotent_when_already_first() { + let result = promote_to_front(vec!["/a".to_string(), "/b".to_string()], "/a"); + assert_eq!(result, vec!["/a", "/b"]); + } + + #[test] + fn promote_to_front_handles_empty_list() { + let result = promote_to_front(vec![], "/new"); + assert_eq!(result, vec!["/new"]); + } + + #[test] + fn promote_to_front_deduplicates_single_entry() { + let result = promote_to_front(vec!["/a".to_string()], "/a"); + assert_eq!(result, vec!["/a"]); + } + + #[test] + fn project_name_from_path_extracts_last_component() { + assert_eq!( + project_name_from_path("/home/user/myproject"), + Some("myproject") + ); + } + + #[test] + fn project_name_from_path_handles_trailing_slash() { + assert_eq!( + project_name_from_path("/home/user/myproject/"), + Some("myproject") + ); + } + + #[test] + fn project_name_from_path_returns_none_for_root() { + assert_eq!(project_name_from_path("/"), None); + } + + #[test] + fn project_name_from_path_returns_none_for_empty() { + assert_eq!(project_name_from_path(""), None); + } + + #[test] + fn project_name_from_path_handles_single_component() { + assert_eq!(project_name_from_path("myproject"), Some("myproject")); + } + + #[test] + fn project_name_from_path_handles_deep_path() { + assert_eq!( + project_name_from_path("/a/b/c/d/project-name"), + Some("project-name") + ); + } +}