huskies: merge 606_story_extract_project_service
This commit is contained in:
+24
-10
@@ -1,6 +1,7 @@
|
|||||||
//! HTTP project endpoints — REST API for project initialization and context management.
|
//! HTTP project endpoints — thin adapters over `service::project`.
|
||||||
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
use crate::http::context::{AppContext, OpenApiResult, bad_request, not_found};
|
||||||
use crate::io::fs;
|
use crate::service::project::{self as svc, Error as ProjectError};
|
||||||
|
use poem::http::StatusCode;
|
||||||
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -15,6 +16,17 @@ struct PathPayload {
|
|||||||
path: String,
|
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 struct ProjectApi {
|
||||||
pub ctx: Arc<AppContext>,
|
pub ctx: Arc<AppContext>,
|
||||||
}
|
}
|
||||||
@@ -26,8 +38,8 @@ impl ProjectApi {
|
|||||||
/// Returns null when no project is open.
|
/// Returns null when no project is open.
|
||||||
#[oai(path = "/project", method = "get")]
|
#[oai(path = "/project", method = "get")]
|
||||||
async fn get_current_project(&self) -> OpenApiResult<Json<Option<String>>> {
|
async fn get_current_project(&self) -> OpenApiResult<Json<Option<String>>> {
|
||||||
let result = fs::get_current_project(&self.ctx.state, self.ctx.store.as_ref())
|
let result = svc::get_current_project(&self.ctx.state, self.ctx.store.as_ref())
|
||||||
.map_err(bad_request)?;
|
.map_err(map_project_error)?;
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,14 +48,14 @@ impl ProjectApi {
|
|||||||
/// Persists the selected path for later sessions.
|
/// Persists the selected path for later sessions.
|
||||||
#[oai(path = "/project", method = "post")]
|
#[oai(path = "/project", method = "post")]
|
||||||
async fn open_project(&self, payload: Json<PathPayload>) -> OpenApiResult<Json<String>> {
|
async fn open_project(&self, payload: Json<PathPayload>) -> OpenApiResult<Json<String>> {
|
||||||
let confirmed = fs::open_project(
|
let confirmed = svc::open_project(
|
||||||
payload.0.path,
|
payload.0.path,
|
||||||
&self.ctx.state,
|
&self.ctx.state,
|
||||||
self.ctx.store.as_ref(),
|
self.ctx.store.as_ref(),
|
||||||
self.ctx.agents.port(),
|
self.ctx.agents.port(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(bad_request)?;
|
.map_err(map_project_error)?;
|
||||||
Ok(Json(confirmed))
|
Ok(Json(confirmed))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,21 +67,23 @@ impl ProjectApi {
|
|||||||
"[MERGE-DEBUG] DELETE /project called! \
|
"[MERGE-DEBUG] DELETE /project called! \
|
||||||
Backtrace: this is the only code path that clears project_root."
|
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))
|
Ok(Json(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List known projects from the store.
|
/// List known projects from the store.
|
||||||
#[oai(path = "/projects", method = "get")]
|
#[oai(path = "/projects", method = "get")]
|
||||||
async fn list_known_projects(&self) -> OpenApiResult<Json<Vec<String>>> {
|
async fn list_known_projects(&self) -> OpenApiResult<Json<Vec<String>>> {
|
||||||
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))
|
Ok(Json(projects))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forget a known project path.
|
/// Forget a known project path.
|
||||||
#[oai(path = "/projects/forget", method = "post")]
|
#[oai(path = "/projects/forget", method = "post")]
|
||||||
async fn forget_known_project(&self, payload: Json<PathPayload>) -> OpenApiResult<Json<bool>> {
|
async fn forget_known_project(&self, payload: Json<PathPayload>) -> OpenApiResult<Json<bool>> {
|
||||||
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))
|
Ok(Json(true))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,4 @@ pub use files::{
|
|||||||
};
|
};
|
||||||
pub use paths::{find_story_kit_root, get_home_directory, resolve_cli_path};
|
pub use paths::{find_story_kit_root, get_home_directory, resolve_cli_path};
|
||||||
pub use preferences::{get_model_preference, set_model_preference};
|
pub use preferences::{get_model_preference, set_model_preference};
|
||||||
pub use project::{
|
pub use project::open_project;
|
||||||
close_project, forget_known_project, get_current_project, get_known_projects, open_project,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ pub async fn open_project(
|
|||||||
Ok(path)
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn close_project(state: &SessionState, store: &dyn StoreOps) -> Result<(), String> {
|
pub fn close_project(state: &SessionState, store: &dyn StoreOps) -> Result<(), String> {
|
||||||
{
|
{
|
||||||
// TRACE:MERGE-DEBUG — remove once root cause is found
|
// 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn get_current_project(
|
pub fn get_current_project(
|
||||||
state: &SessionState,
|
state: &SessionState,
|
||||||
store: &dyn StoreOps,
|
store: &dyn StoreOps,
|
||||||
@@ -131,6 +133,7 @@ pub fn get_current_project(
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn get_known_projects(store: &dyn StoreOps) -> Result<Vec<String>, String> {
|
pub fn get_known_projects(store: &dyn StoreOps) -> Result<Vec<String>, String> {
|
||||||
let projects = store
|
let projects = store
|
||||||
.get(KEY_KNOWN_PROJECTS)
|
.get(KEY_KNOWN_PROJECTS)
|
||||||
@@ -143,6 +146,7 @@ pub fn get_known_projects(store: &dyn StoreOps) -> Result<Vec<String>, String> {
|
|||||||
Ok(projects)
|
Ok(projects)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn forget_known_project(path: String, store: &dyn StoreOps) -> Result<(), String> {
|
pub fn forget_known_project(path: String, store: &dyn StoreOps) -> Result<(), String> {
|
||||||
let mut known_projects = get_known_projects(store)?;
|
let mut known_projects = get_known_projects(store)?;
|
||||||
let original_len = known_projects.len();
|
let original_len = known_projects.len();
|
||||||
|
|||||||
@@ -8,4 +8,5 @@
|
|||||||
pub mod agents;
|
pub mod agents;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
|
pub mod project;
|
||||||
pub mod ws;
|
pub mod ws;
|
||||||
|
|||||||
@@ -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