huskies: merge 606_story_extract_project_service

This commit is contained in:
dave
2026-04-24 14:56:54 +00:00
parent d9e883c21d
commit 1910365321
7 changed files with 376 additions and 13 deletions
+102
View File
@@ -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<String, Error> {
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<Option<String>, 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<Vec<String>, 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<String> = known.into_iter().filter(|p| p != &path).collect();
if updated.len() == before {
return Ok(());
}
io::save_known_projects(&updated, store)
}