huskies: merge 606_story_extract_project_service
This commit is contained in:
@@ -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<PathBuf>) -> 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<Option<PathBuf>, 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<String> {
|
||||
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<Option<String>, 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<String>, path: &str) -> Vec<String> {
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user