diff --git a/server/src/io/fs/files.rs b/server/src/io/fs/files.rs new file mode 100644 index 00000000..36b98f54 --- /dev/null +++ b/server/src/io/fs/files.rs @@ -0,0 +1,263 @@ +use crate::state::SessionState; +use serde::Serialize; +use std::fs; +use std::path::PathBuf; + +use super::paths::{resolve_path, resolve_path_impl}; + +async fn read_file_impl(full_path: PathBuf) -> Result { + tokio::task::spawn_blocking(move || { + fs::read_to_string(&full_path).map_err(|e| format!("Failed to read file: {}", e)) + }) + .await + .map_err(|e| format!("Task failed: {}", e))? +} + +pub async fn read_file(path: String, state: &SessionState) -> Result { + let full_path = resolve_path(state, &path)?; + read_file_impl(full_path).await +} + +async fn write_file_impl(full_path: PathBuf, content: String) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + if let Some(parent) = full_path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create directories: {}", e))?; + } + + fs::write(&full_path, content).map_err(|e| format!("Failed to write file: {}", e)) + }) + .await + .map_err(|e| format!("Task failed: {}", e))? +} + +pub async fn write_file(path: String, content: String, state: &SessionState) -> Result<(), String> { + let root = state.get_project_root()?; + let full_path = resolve_path_impl(root, &path)?; + write_file_impl(full_path, content).await +} + +#[derive(Serialize, Debug, poem_openapi::Object)] +pub struct FileEntry { + pub name: String, + pub kind: String, +} + +async fn list_directory_impl(full_path: PathBuf) -> Result, String> { + tokio::task::spawn_blocking(move || { + let entries = fs::read_dir(&full_path).map_err(|e| format!("Failed to read dir: {}", e))?; + + let mut result = Vec::new(); + for entry in entries { + let entry = entry.map_err(|e| e.to_string())?; + let ft = entry.file_type().map_err(|e| e.to_string())?; + let name = entry.file_name().to_string_lossy().to_string(); + + result.push(FileEntry { + name, + kind: if ft.is_dir() { + "dir".to_string() + } else { + "file".to_string() + }, + }); + } + + result.sort_by(|a, b| match (a.kind.as_str(), b.kind.as_str()) { + ("dir", "file") => std::cmp::Ordering::Less, + ("file", "dir") => std::cmp::Ordering::Greater, + _ => a.name.cmp(&b.name), + }); + + Ok(result) + }) + .await + .map_err(|e| format!("Task failed: {}", e))? +} + +pub async fn list_directory(path: String, state: &SessionState) -> Result, String> { + let full_path = resolve_path(state, &path)?; + list_directory_impl(full_path).await +} + +pub async fn list_directory_absolute(path: String) -> Result, String> { + let full_path = PathBuf::from(path); + list_directory_impl(full_path).await +} + +pub async fn create_directory_absolute(path: String) -> Result { + let full_path = PathBuf::from(path); + tokio::task::spawn_blocking(move || { + fs::create_dir_all(&full_path).map_err(|e| format!("Failed to create directory: {}", e))?; + Ok(true) + }) + .await + .map_err(|e| format!("Task failed: {}", e))? +} + +/// List all files in the project recursively, respecting .gitignore. +/// Returns relative paths from the project root (files only, not directories). +pub async fn list_project_files(state: &SessionState) -> Result, String> { + let root = state.get_project_root()?; + list_project_files_impl(root).await +} + +pub async fn list_project_files_impl(root: PathBuf) -> Result, String> { + use ignore::WalkBuilder; + + let root_clone = root.clone(); + let files = tokio::task::spawn_blocking(move || { + let mut result = Vec::new(); + let walker = WalkBuilder::new(&root_clone).git_ignore(true).build(); + + for entry in walker.flatten() { + if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) { + let relative = entry + .path() + .strip_prefix(&root_clone) + .unwrap_or(entry.path()) + .to_string_lossy() + .to_string(); + result.push(relative); + } + } + + result.sort(); + result + }) + .await + .map_err(|e| format!("Task failed: {e}"))?; + + Ok(files) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::SessionState; + use std::path::PathBuf; + use tempfile::tempdir; + + fn make_state_with_root(path: PathBuf) -> SessionState { + let state = SessionState::default(); + { + let mut root = state.project_root.lock().unwrap(); + *root = Some(path); + } + state + } + + // --- file operations --- + + #[tokio::test] + async fn read_file_impl_reads_content() { + let dir = tempdir().unwrap(); + let file = dir.path().join("test.txt"); + fs::write(&file, "hello world").unwrap(); + + let content = read_file_impl(file).await.unwrap(); + assert_eq!(content, "hello world"); + } + + #[tokio::test] + async fn read_file_impl_errors_on_missing() { + let dir = tempdir().unwrap(); + let result = read_file_impl(dir.path().join("missing.txt")).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn write_file_impl_creates_and_writes() { + let dir = tempdir().unwrap(); + let file = dir.path().join("sub").join("output.txt"); + + write_file_impl(file.clone(), "content".to_string()) + .await + .unwrap(); + + assert_eq!(fs::read_to_string(&file).unwrap(), "content"); + } + + // --- list directory --- + + #[tokio::test] + async fn list_directory_impl_returns_sorted_entries() { + let dir = tempdir().unwrap(); + fs::create_dir(dir.path().join("zdir")).unwrap(); + fs::create_dir(dir.path().join("adir")).unwrap(); + fs::write(dir.path().join("file.txt"), "").unwrap(); + + let entries = list_directory_impl(dir.path().to_path_buf()).await.unwrap(); + + assert_eq!(entries[0].name, "adir"); + assert_eq!(entries[0].kind, "dir"); + assert_eq!(entries[1].name, "zdir"); + assert_eq!(entries[1].kind, "dir"); + assert_eq!(entries[2].name, "file.txt"); + assert_eq!(entries[2].kind, "file"); + } + + // --- list_project_files_impl --- + + #[tokio::test] + async fn list_project_files_returns_all_files() { + let dir = tempdir().unwrap(); + fs::create_dir(dir.path().join("src")).unwrap(); + fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap(); + fs::write(dir.path().join("README.md"), "# readme").unwrap(); + + let files = list_project_files_impl(dir.path().to_path_buf()) + .await + .unwrap(); + + assert!(files.contains(&"README.md".to_string())); + assert!(files.contains(&"src/main.rs".to_string())); + } + + #[tokio::test] + async fn list_project_files_excludes_dirs_from_output() { + let dir = tempdir().unwrap(); + fs::create_dir(dir.path().join("subdir")).unwrap(); + fs::write(dir.path().join("file.txt"), "").unwrap(); + + let files = list_project_files_impl(dir.path().to_path_buf()) + .await + .unwrap(); + + assert!(files.contains(&"file.txt".to_string())); + assert!(!files.iter().any(|f| f == "subdir")); + } + + #[tokio::test] + async fn list_project_files_returns_sorted() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("z.txt"), "").unwrap(); + fs::write(dir.path().join("a.txt"), "").unwrap(); + + let files = list_project_files_impl(dir.path().to_path_buf()) + .await + .unwrap(); + + let a_idx = files.iter().position(|f| f == "a.txt").unwrap(); + let z_idx = files.iter().position(|f| f == "z.txt").unwrap(); + assert!(a_idx < z_idx); + } + + #[tokio::test] + async fn list_project_files_with_state() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("hello.rs"), "").unwrap(); + let state = make_state_with_root(dir.path().to_path_buf()); + + let files = list_project_files(&state).await.unwrap(); + + assert!(files.contains(&"hello.rs".to_string())); + } + + #[tokio::test] + async fn list_project_files_errors_without_project() { + let state = SessionState::default(); + let result = list_project_files(&state).await; + assert!(result.is_err()); + } +} diff --git a/server/src/io/fs/mod.rs b/server/src/io/fs/mod.rs new file mode 100644 index 00000000..b0117af0 --- /dev/null +++ b/server/src/io/fs/mod.rs @@ -0,0 +1,15 @@ +pub mod files; +pub mod paths; +pub mod preferences; +pub mod project; +pub mod scaffold; + +pub use files::{ + create_directory_absolute, list_directory, list_directory_absolute, list_project_files, + read_file, write_file, FileEntry, +}; +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, +}; diff --git a/server/src/io/fs/paths.rs b/server/src/io/fs/paths.rs new file mode 100644 index 00000000..e09bd076 --- /dev/null +++ b/server/src/io/fs/paths.rs @@ -0,0 +1,154 @@ +use crate::state::SessionState; +use std::path::{Path, PathBuf}; + +/// Resolve a path argument supplied on the CLI against the given working +/// directory. Relative paths (including `.`) are joined with `cwd` and +/// then canonicalized when possible. Absolute paths are returned +/// canonicalized when possible, unchanged otherwise. +pub fn resolve_cli_path(cwd: &Path, path_arg: &str) -> PathBuf { + let p = PathBuf::from(path_arg); + let joined = if p.is_absolute() { p } else { cwd.join(p) }; + // Canonicalize resolves `.`, `..` and symlinks. We fall back to the + // joined (non-canonical) path when the target does not yet exist so + // that callers can still create it later. + std::fs::canonicalize(&joined).unwrap_or(joined) +} + +/// Walk from `start` up through parent directories, returning the first +/// directory that contains a `.storkit/` subdirectory, or `None`. +pub fn find_story_kit_root(start: &Path) -> Option { + let mut current = start.to_path_buf(); + loop { + if current.join(".storkit").is_dir() { + return Some(current); + } + if !current.pop() { + return None; + } + } +} + +pub fn get_home_directory() -> Result { + let home = homedir::my_home() + .map_err(|e| format!("Failed to resolve home directory: {e}"))? + .ok_or_else(|| "Home directory not found".to_string())?; + Ok(home.to_string_lossy().to_string()) +} + +/// Resolves a relative path against the active project root (pure function for testing). +/// Returns error if path attempts traversal (..). +pub(crate) fn resolve_path_impl(root: PathBuf, relative_path: &str) -> Result { + if relative_path.contains("..") { + return Err("Security Violation: Directory traversal ('..') is not allowed.".to_string()); + } + + Ok(root.join(relative_path)) +} + +/// Resolves a relative path against the active project root. +/// Returns error if no project is open or if path attempts traversal (..). +pub(crate) fn resolve_path(state: &SessionState, relative_path: &str) -> Result { + let root = state.get_project_root()?; + resolve_path_impl(root, relative_path) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + // --- resolve_path_impl --- + + #[test] + fn resolve_path_joins_relative_to_root() { + let root = PathBuf::from("/projects/myapp"); + let result = resolve_path_impl(root, "src/main.rs").unwrap(); + assert_eq!(result, PathBuf::from("/projects/myapp/src/main.rs")); + } + + #[test] + fn resolve_path_rejects_traversal() { + let root = PathBuf::from("/projects/myapp"); + let result = resolve_path_impl(root, "../etc/passwd"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("traversal")); + } + + // --- find_story_kit_root --- + + #[test] + fn find_story_kit_root_returns_cwd_when_story_kit_in_cwd() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join(".storkit")).unwrap(); + + let result = find_story_kit_root(tmp.path()); + assert_eq!(result, Some(tmp.path().to_path_buf())); + } + + #[test] + fn find_story_kit_root_returns_parent_when_story_kit_in_parent() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join(".storkit")).unwrap(); + let child = tmp.path().join("subdir").join("nested"); + std::fs::create_dir_all(&child).unwrap(); + + let result = find_story_kit_root(&child); + assert_eq!(result, Some(tmp.path().to_path_buf())); + } + + #[test] + fn find_story_kit_root_returns_none_when_no_story_kit() { + let tmp = tempfile::tempdir().unwrap(); + + let result = find_story_kit_root(tmp.path()); + assert_eq!(result, None); + } + + #[test] + fn find_story_kit_root_prefers_nearest_ancestor() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join(".storkit")).unwrap(); + let child = tmp.path().join("inner"); + std::fs::create_dir_all(child.join(".storkit")).unwrap(); + + let result = find_story_kit_root(&child); + assert_eq!(result, Some(child)); + } + + // --- resolve_cli_path --- + + #[test] + fn resolve_cli_path_absolute_returned_unchanged_when_nonexistent() { + let cwd = PathBuf::from("/some/cwd"); + let result = resolve_cli_path(&cwd, "/nonexistent/absolute/path"); + assert_eq!(result, PathBuf::from("/nonexistent/absolute/path")); + } + + #[test] + fn resolve_cli_path_dot_resolves_to_cwd() { + let tmp = tempdir().unwrap(); + let cwd = tmp.path().to_path_buf(); + let result = resolve_cli_path(&cwd, "."); + // Canonicalize should resolve "." in an existing dir to the canonical cwd + assert_eq!(result, cwd.canonicalize().unwrap_or(cwd)); + } + + #[test] + fn resolve_cli_path_relative_resolves_against_cwd() { + let tmp = tempdir().unwrap(); + let cwd = tmp.path().to_path_buf(); + let subdir = cwd.join("sub"); + std::fs::create_dir_all(&subdir).unwrap(); + let result = resolve_cli_path(&cwd, "sub"); + assert_eq!(result, subdir.canonicalize().unwrap_or(subdir)); + } + + #[test] + fn resolve_cli_path_nonexistent_relative_falls_back_to_joined() { + let tmp = tempdir().unwrap(); + let cwd = tmp.path().to_path_buf(); + let result = resolve_cli_path(&cwd, "newproject"); + // Path doesn't exist yet — canonicalize fails, fallback is cwd/newproject + assert_eq!(result, cwd.join("newproject")); + } +} diff --git a/server/src/io/fs/preferences.rs b/server/src/io/fs/preferences.rs new file mode 100644 index 00000000..f8eb1d4d --- /dev/null +++ b/server/src/io/fs/preferences.rs @@ -0,0 +1,52 @@ +use crate::store::StoreOps; +use serde_json::json; + +const KEY_SELECTED_MODEL: &str = "selected_model"; + +pub fn get_model_preference(store: &dyn StoreOps) -> Result, String> { + if let Some(model) = store + .get(KEY_SELECTED_MODEL) + .as_ref() + .and_then(|val| val.as_str()) + { + return Ok(Some(model.to_string())); + } + Ok(None) +} + +pub fn set_model_preference(model: String, store: &dyn StoreOps) -> Result<(), String> { + store.set(KEY_SELECTED_MODEL, json!(model)); + 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() + } + + // --- model preference --- + + #[test] + fn model_preference_none_by_default() { + let dir = tempdir().unwrap(); + let store = make_store(&dir); + assert!(get_model_preference(&store).unwrap().is_none()); + } + + #[test] + fn set_and_get_model_preference() { + let dir = tempdir().unwrap(); + let store = make_store(&dir); + set_model_preference("claude-3-sonnet".to_string(), &store).unwrap(); + assert_eq!( + get_model_preference(&store).unwrap(), + Some("claude-3-sonnet".to_string()) + ); + } +} diff --git a/server/src/io/fs/project.rs b/server/src/io/fs/project.rs new file mode 100644 index 00000000..874caa4c --- /dev/null +++ b/server/src/io/fs/project.rs @@ -0,0 +1,411 @@ +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(".storkit").is_dir() { + scaffold_story_kit(&path, port)?; + } + 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) +} + +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(()) +} + +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) +} + +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) +} + +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_does_not_overwrite_existing_mcp_json() { + // scaffold must NOT overwrite .mcp.json when it already exists — QA + // test servers share the real project root, and re-writing would + // clobber the file with the wrong port. + 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 an already-configured project. + let mcp_path = project_dir.join(".mcp.json"); + fs::write(&mcp_path, "{\"existing\": true}").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_eq!( + fs::read_to_string(&mcp_path).unwrap(), + "{\"existing\": true}", + "open_project must not overwrite an existing .mcp.json" + ); + } + + #[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 `storkit` in empty directory skips scaffold. + /// `open_project` on a directory without `.storkit/` must create all required scaffold + /// files — the same files that `storkit .` 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(".storkit/project.toml").exists(), + "open_project must create .storkit/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(); + + // .storkit/ should have been created automatically + assert!(project_dir.join(".storkit").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(".storkit"); + 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 .storkit/ content should not be overwritten + assert_eq!(fs::read_to_string(&readme).unwrap(), "custom content"); + } +} diff --git a/server/src/io/fs.rs b/server/src/io/fs/scaffold.rs similarity index 60% rename from server/src/io/fs.rs rename to server/src/io/fs/scaffold.rs index d98a5f4d..5a00886b 100644 --- a/server/src/io/fs.rs +++ b/server/src/io/fs/scaffold.rs @@ -1,22 +1,15 @@ -use crate::state::SessionState; -use crate::store::StoreOps; -use serde::Serialize; -use serde_json::json; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; -const KEY_LAST_PROJECT: &str = "last_project_path"; -const KEY_SELECTED_MODEL: &str = "selected_model"; -const KEY_KNOWN_PROJECTS: &str = "known_projects"; +const STORY_KIT_README: &str = include_str!("../../../../.storkit/README.md"); -const STORY_KIT_README: &str = include_str!("../../../.storkit/README.md"); - -const BOT_TOML_MATRIX_EXAMPLE: &str = include_str!("../../../.storkit/bot.toml.matrix.example"); +const BOT_TOML_MATRIX_EXAMPLE: &str = + include_str!("../../../../.storkit/bot.toml.matrix.example"); const BOT_TOML_WHATSAPP_META_EXAMPLE: &str = - include_str!("../../../.storkit/bot.toml.whatsapp-meta.example"); + include_str!("../../../../.storkit/bot.toml.whatsapp-meta.example"); const BOT_TOML_WHATSAPP_TWILIO_EXAMPLE: &str = - include_str!("../../../.storkit/bot.toml.whatsapp-twilio.example"); -const BOT_TOML_SLACK_EXAMPLE: &str = include_str!("../../../.storkit/bot.toml.slack.example"); + include_str!("../../../../.storkit/bot.toml.whatsapp-twilio.example"); +const BOT_TOML_SLACK_EXAMPLE: &str = include_str!("../../../../.storkit/bot.toml.slack.example"); const STORY_KIT_CONTEXT: &str = "\n\ # Project Context\n\ @@ -252,72 +245,6 @@ fn generate_project_toml(root: &Path) -> String { format!("{components}\n{DEFAULT_PROJECT_AGENTS_TOML}") } -/// Resolve a path argument supplied on the CLI against the given working -/// directory. Relative paths (including `.`) are joined with `cwd` and -/// then canonicalized when possible. Absolute paths are returned -/// canonicalized when possible, unchanged otherwise. -pub fn resolve_cli_path(cwd: &Path, path_arg: &str) -> PathBuf { - let p = PathBuf::from(path_arg); - let joined = if p.is_absolute() { p } else { cwd.join(p) }; - // Canonicalize resolves `.`, `..` and symlinks. We fall back to the - // joined (non-canonical) path when the target does not yet exist so - // that callers can still create it later. - std::fs::canonicalize(&joined).unwrap_or(joined) -} - -/// Walk from `start` up through parent directories, returning the first -/// directory that contains a `.storkit/` subdirectory, or `None`. -pub fn find_story_kit_root(start: &Path) -> Option { - let mut current = start.to_path_buf(); - loop { - if current.join(".storkit").is_dir() { - return Some(current); - } - if !current.pop() { - return None; - } - } -} - -pub fn get_home_directory() -> Result { - let home = homedir::my_home() - .map_err(|e| format!("Failed to resolve home directory: {e}"))? - .ok_or_else(|| "Home directory not found".to_string())?; - Ok(home.to_string_lossy().to_string()) -} - -/// Resolves a relative path against the active project root (pure function for testing). -/// Returns error if path attempts traversal (..). -fn resolve_path_impl(root: PathBuf, relative_path: &str) -> Result { - if relative_path.contains("..") { - return Err("Security Violation: Directory traversal ('..') is not allowed.".to_string()); - } - - Ok(root.join(relative_path)) -} - -/// Resolves a relative path against the active project root. -/// Returns error if no project is open or if path attempts traversal (..). -fn resolve_path(state: &SessionState, relative_path: &str) -> Result { - let root = state.get_project_root()?; - resolve_path_impl(root, relative_path) -} - -/// 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))? -} - fn write_file_if_missing(path: &Path, content: &str) -> Result<(), String> { if path.exists() { return Ok(()); @@ -437,7 +364,7 @@ fn append_root_gitignore_entries(root: &Path) -> Result<(), String> { Ok(()) } -fn scaffold_story_kit(root: &Path, port: u16) -> Result<(), String> { +pub(crate) fn scaffold_story_kit(root: &Path, port: u16) -> Result<(), String> { let story_kit_root = root.join(".storkit"); let specs_root = story_kit_root.join("specs"); let tech_root = specs_root.join("tech"); @@ -565,622 +492,11 @@ fn scaffold_story_kit(root: &Path, port: u16) -> Result<(), String> { Ok(()) } -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(".storkit").is_dir() { - scaffold_story_kit(&path, port)?; - } - 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) -} - -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(()) -} - -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) -} - -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) -} - -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(()) -} - -pub fn get_model_preference(store: &dyn StoreOps) -> Result, String> { - if let Some(model) = store - .get(KEY_SELECTED_MODEL) - .as_ref() - .and_then(|val| val.as_str()) - { - return Ok(Some(model.to_string())); - } - Ok(None) -} - -pub fn set_model_preference(model: String, store: &dyn StoreOps) -> Result<(), String> { - store.set(KEY_SELECTED_MODEL, json!(model)); - store.save()?; - Ok(()) -} - -async fn read_file_impl(full_path: PathBuf) -> Result { - tokio::task::spawn_blocking(move || { - fs::read_to_string(&full_path).map_err(|e| format!("Failed to read file: {}", e)) - }) - .await - .map_err(|e| format!("Task failed: {}", e))? -} - -pub async fn read_file(path: String, state: &SessionState) -> Result { - let full_path = resolve_path(state, &path)?; - read_file_impl(full_path).await -} - -async fn write_file_impl(full_path: PathBuf, content: String) -> Result<(), String> { - tokio::task::spawn_blocking(move || { - if let Some(parent) = full_path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create directories: {}", e))?; - } - - fs::write(&full_path, content).map_err(|e| format!("Failed to write file: {}", e)) - }) - .await - .map_err(|e| format!("Task failed: {}", e))? -} - -pub async fn write_file(path: String, content: String, state: &SessionState) -> Result<(), String> { - let root = state.get_project_root()?; - let full_path = resolve_path_impl(root, &path)?; - write_file_impl(full_path, content).await -} - -#[derive(Serialize, Debug, poem_openapi::Object)] -pub struct FileEntry { - pub name: String, - pub kind: String, -} - -async fn list_directory_impl(full_path: PathBuf) -> Result, String> { - tokio::task::spawn_blocking(move || { - let entries = fs::read_dir(&full_path).map_err(|e| format!("Failed to read dir: {}", e))?; - - let mut result = Vec::new(); - for entry in entries { - let entry = entry.map_err(|e| e.to_string())?; - let ft = entry.file_type().map_err(|e| e.to_string())?; - let name = entry.file_name().to_string_lossy().to_string(); - - result.push(FileEntry { - name, - kind: if ft.is_dir() { - "dir".to_string() - } else { - "file".to_string() - }, - }); - } - - result.sort_by(|a, b| match (a.kind.as_str(), b.kind.as_str()) { - ("dir", "file") => std::cmp::Ordering::Less, - ("file", "dir") => std::cmp::Ordering::Greater, - _ => a.name.cmp(&b.name), - }); - - Ok(result) - }) - .await - .map_err(|e| format!("Task failed: {}", e))? -} - -pub async fn list_directory(path: String, state: &SessionState) -> Result, String> { - let full_path = resolve_path(state, &path)?; - list_directory_impl(full_path).await -} - -pub async fn list_directory_absolute(path: String) -> Result, String> { - let full_path = PathBuf::from(path); - list_directory_impl(full_path).await -} - -pub async fn create_directory_absolute(path: String) -> Result { - let full_path = PathBuf::from(path); - tokio::task::spawn_blocking(move || { - fs::create_dir_all(&full_path).map_err(|e| format!("Failed to create directory: {}", e))?; - Ok(true) - }) - .await - .map_err(|e| format!("Task failed: {}", e))? -} - -/// List all files in the project recursively, respecting .gitignore. -/// Returns relative paths from the project root (files only, not directories). -pub async fn list_project_files(state: &SessionState) -> Result, String> { - let root = state.get_project_root()?; - list_project_files_impl(root).await -} - -pub async fn list_project_files_impl(root: PathBuf) -> Result, String> { - use ignore::WalkBuilder; - - let root_clone = root.clone(); - let files = tokio::task::spawn_blocking(move || { - let mut result = Vec::new(); - let walker = WalkBuilder::new(&root_clone).git_ignore(true).build(); - - for entry in walker.flatten() { - if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) { - let relative = entry - .path() - .strip_prefix(&root_clone) - .unwrap_or(entry.path()) - .to_string_lossy() - .to_string(); - result.push(relative); - } - } - - result.sort(); - result - }) - .await - .map_err(|e| format!("Task failed: {e}"))?; - - Ok(files) -} - #[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 - } - - // --- resolve_path_impl --- - - #[test] - fn resolve_path_joins_relative_to_root() { - let root = PathBuf::from("/projects/myapp"); - let result = resolve_path_impl(root, "src/main.rs").unwrap(); - assert_eq!(result, PathBuf::from("/projects/myapp/src/main.rs")); - } - - #[test] - fn resolve_path_rejects_traversal() { - let root = PathBuf::from("/projects/myapp"); - let result = resolve_path_impl(root, "../etc/passwd"); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("traversal")); - } - - // --- 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_does_not_overwrite_existing_mcp_json() { - // scaffold must NOT overwrite .mcp.json when it already exists — QA - // test servers share the real project root, and re-writing would - // clobber the file with the wrong port. - 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 an already-configured project. - let mcp_path = project_dir.join(".mcp.json"); - fs::write(&mcp_path, "{\"existing\": true}").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_eq!( - fs::read_to_string(&mcp_path).unwrap(), - "{\"existing\": true}", - "open_project must not overwrite an existing .mcp.json" - ); - } - - #[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 `storkit` in empty directory skips scaffold. - /// `open_project` on a directory without `.storkit/` must create all required scaffold - /// files — the same files that `storkit .` 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(".storkit/project.toml").exists(), - "open_project must create .storkit/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"]); - } - - // --- model preference --- - - #[test] - fn model_preference_none_by_default() { - let dir = tempdir().unwrap(); - let store = make_store(&dir); - assert!(get_model_preference(&store).unwrap().is_none()); - } - - #[test] - fn set_and_get_model_preference() { - let dir = tempdir().unwrap(); - let store = make_store(&dir); - set_model_preference("claude-3-sonnet".to_string(), &store).unwrap(); - assert_eq!( - get_model_preference(&store).unwrap(), - Some("claude-3-sonnet".to_string()) - ); - } - - // --- file operations --- - - #[tokio::test] - async fn read_file_impl_reads_content() { - let dir = tempdir().unwrap(); - let file = dir.path().join("test.txt"); - fs::write(&file, "hello world").unwrap(); - - let content = read_file_impl(file).await.unwrap(); - assert_eq!(content, "hello world"); - } - - #[tokio::test] - async fn read_file_impl_errors_on_missing() { - let dir = tempdir().unwrap(); - let result = read_file_impl(dir.path().join("missing.txt")).await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn write_file_impl_creates_and_writes() { - let dir = tempdir().unwrap(); - let file = dir.path().join("sub").join("output.txt"); - - write_file_impl(file.clone(), "content".to_string()) - .await - .unwrap(); - - assert_eq!(fs::read_to_string(&file).unwrap(), "content"); - } - - // --- list directory --- - - #[tokio::test] - async fn list_directory_impl_returns_sorted_entries() { - let dir = tempdir().unwrap(); - fs::create_dir(dir.path().join("zdir")).unwrap(); - fs::create_dir(dir.path().join("adir")).unwrap(); - fs::write(dir.path().join("file.txt"), "").unwrap(); - - let entries = list_directory_impl(dir.path().to_path_buf()).await.unwrap(); - - assert_eq!(entries[0].name, "adir"); - assert_eq!(entries[0].kind, "dir"); - assert_eq!(entries[1].name, "zdir"); - assert_eq!(entries[1].kind, "dir"); - assert_eq!(entries[2].name, "file.txt"); - assert_eq!(entries[2].kind, "file"); - } - - // --- 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()); - } - - // --- find_story_kit_root --- - - #[test] - fn find_story_kit_root_returns_cwd_when_story_kit_in_cwd() { - let tmp = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(tmp.path().join(".storkit")).unwrap(); - - let result = find_story_kit_root(tmp.path()); - assert_eq!(result, Some(tmp.path().to_path_buf())); - } - - #[test] - fn find_story_kit_root_returns_parent_when_story_kit_in_parent() { - let tmp = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(tmp.path().join(".storkit")).unwrap(); - let child = tmp.path().join("subdir").join("nested"); - std::fs::create_dir_all(&child).unwrap(); - - let result = find_story_kit_root(&child); - assert_eq!(result, Some(tmp.path().to_path_buf())); - } - - #[test] - fn find_story_kit_root_returns_none_when_no_story_kit() { - let tmp = tempfile::tempdir().unwrap(); - - let result = find_story_kit_root(tmp.path()); - assert_eq!(result, None); - } - - #[test] - fn find_story_kit_root_prefers_nearest_ancestor() { - let tmp = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(tmp.path().join(".storkit")).unwrap(); - let child = tmp.path().join("inner"); - std::fs::create_dir_all(child.join(".storkit")).unwrap(); - - let result = find_story_kit_root(&child); - assert_eq!(result, Some(child)); - } - // --- scaffold --- #[test] @@ -1496,80 +812,6 @@ mod tests { ); } - // --- 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(); - - // .storkit/ should have been created automatically - assert!(project_dir.join(".storkit").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(".storkit"); - 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 .storkit/ content should not be overwritten - assert_eq!(fs::read_to_string(&readme).unwrap(), "custom content"); - } - - // --- resolve_cli_path --- - - #[test] - fn resolve_cli_path_absolute_returned_unchanged_when_nonexistent() { - let cwd = PathBuf::from("/some/cwd"); - let result = resolve_cli_path(&cwd, "/nonexistent/absolute/path"); - assert_eq!(result, PathBuf::from("/nonexistent/absolute/path")); - } - - #[test] - fn resolve_cli_path_dot_resolves_to_cwd() { - let tmp = tempdir().unwrap(); - let cwd = tmp.path().to_path_buf(); - let result = resolve_cli_path(&cwd, "."); - // Canonicalize should resolve "." in an existing dir to the canonical cwd - assert_eq!(result, cwd.canonicalize().unwrap_or(cwd)); - } - - #[test] - fn resolve_cli_path_relative_resolves_against_cwd() { - let tmp = tempdir().unwrap(); - let cwd = tmp.path().to_path_buf(); - let subdir = cwd.join("sub"); - fs::create_dir_all(&subdir).unwrap(); - let result = resolve_cli_path(&cwd, "sub"); - assert_eq!(result, subdir.canonicalize().unwrap_or(subdir)); - } - - #[test] - fn resolve_cli_path_nonexistent_relative_falls_back_to_joined() { - let tmp = tempdir().unwrap(); - let cwd = tmp.path().to_path_buf(); - let result = resolve_cli_path(&cwd, "newproject"); - // Path doesn't exist yet — canonicalize fails, fallback is cwd/newproject - assert_eq!(result, cwd.join("newproject")); - } - // --- detect_components_toml --- #[test] @@ -1940,68 +1182,4 @@ mod tests { "scaffold should not overwrite existing project.toml" ); } - - // --- list_project_files_impl --- - - #[tokio::test] - async fn list_project_files_returns_all_files() { - let dir = tempdir().unwrap(); - fs::create_dir(dir.path().join("src")).unwrap(); - fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap(); - fs::write(dir.path().join("README.md"), "# readme").unwrap(); - - let files = list_project_files_impl(dir.path().to_path_buf()) - .await - .unwrap(); - - assert!(files.contains(&"README.md".to_string())); - assert!(files.contains(&"src/main.rs".to_string())); - } - - #[tokio::test] - async fn list_project_files_excludes_dirs_from_output() { - let dir = tempdir().unwrap(); - fs::create_dir(dir.path().join("subdir")).unwrap(); - fs::write(dir.path().join("file.txt"), "").unwrap(); - - let files = list_project_files_impl(dir.path().to_path_buf()) - .await - .unwrap(); - - assert!(files.contains(&"file.txt".to_string())); - assert!(!files.iter().any(|f| f == "subdir")); - } - - #[tokio::test] - async fn list_project_files_returns_sorted() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("z.txt"), "").unwrap(); - fs::write(dir.path().join("a.txt"), "").unwrap(); - - let files = list_project_files_impl(dir.path().to_path_buf()) - .await - .unwrap(); - - let a_idx = files.iter().position(|f| f == "a.txt").unwrap(); - let z_idx = files.iter().position(|f| f == "z.txt").unwrap(); - assert!(a_idx < z_idx); - } - - #[tokio::test] - async fn list_project_files_with_state() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("hello.rs"), "").unwrap(); - let state = make_state_with_root(dir.path().to_path_buf()); - - let files = list_project_files(&state).await.unwrap(); - - assert!(files.contains(&"hello.rs".to_string())); - } - - #[tokio::test] - async fn list_project_files_errors_without_project() { - let state = SessionState::default(); - let result = list_project_files(&state).await; - assert!(result.is_err()); - } }