use crate::state::SessionState; use crate::store::StoreOps; use serde::Serialize; use serde_json::json; use std::fs; use std::path::{Path, PathBuf}; 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!("../../../.story_kit/README.md"); const STORY_KIT_CONTEXT: &str = "\n\ # Project Context\n\ \n\ ## High-Level Goal\n\ \n\ TODO: Describe the high-level goal of this project.\n\ \n\ ## Core Features\n\ \n\ TODO: List the core features of this project.\n\ \n\ ## Domain Definition\n\ \n\ TODO: Define the key domain concepts and entities.\n\ \n\ ## Glossary\n\ \n\ TODO: Define abbreviations and technical terms.\n"; const STORY_KIT_STACK: &str = "\n\ # Tech Stack & Constraints\n\ \n\ ## Core Stack\n\ \n\ TODO: Describe the language, frameworks, and runtimes.\n\ \n\ ## Coding Standards\n\ \n\ TODO: Describe code style, linting rules, and error handling conventions.\n\ \n\ ## Quality Gates\n\ \n\ TODO: List the commands that must pass before merging (e.g., cargo test, npm run build).\n\ \n\ ## Libraries\n\ \n\ TODO: List approved libraries and their purpose.\n"; const STORY_KIT_SCRIPT_TEST: &str = "#!/usr/bin/env bash\nset -euo pipefail\n\n# Add your project's test commands here.\n# Story Kit agents invoke this script as the canonical test runner.\n# Exit 0 on success, non-zero on failure.\necho \"No tests configured\"\n"; const STORY_KIT_CLAUDE_MD: &str = "\n\ Never chain shell commands with `&&`, `||`, or `;` in a single Bash call. \ The permission system validates the entire command string, and chained commands \ won't match allow rules like `Bash(git *)`. Use separate Bash calls instead — \ parallel calls work fine.\n\ \n\ Read .story_kit/README.md to see our dev process.\n"; const STORY_KIT_CLAUDE_SETTINGS: &str = r#"{ "permissions": { "allow": [ "Bash(cargo build:*)", "Bash(cargo check:*)", "Bash(cargo clippy:*)", "Bash(cargo test:*)", "Bash(cargo run:*)", "Bash(cargo nextest run:*)", "Bash(git *)", "Bash(ls *)", "Bash(mkdir *)", "Bash(mv *)", "Bash(rm *)", "Bash(touch *)", "Bash(echo:*)", "Bash(pwd *)", "Bash(pnpm install:*)", "Bash(pnpm run build:*)", "Bash(pnpm run test:*)", "Bash(pnpm test:*)", "Bash(pnpm build:*)", "Bash(npm run build:*)", "Bash(npx tsc:*)", "Bash(npx vitest:*)", "Bash(npx @biomejs/biome check:*)", "Bash(npx playwright test:*)", "Bash(script/test:*)", "Bash(./script/test:*)", "Edit", "Write", "mcp__story-kit__*" ] }, "enabledMcpjsonServers": [ "story-kit" ] } "#; const DEFAULT_PROJECT_AGENTS_TOML: &str = r#"[[agent]] name = "coder-1" stage = "coder" role = "Full-stack engineer. Implements features across all components." model = "sonnet" max_turns = 50 max_budget_usd = 5.00 prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. Follow the workflow through implementation and verification. The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop.\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates when your process exits." system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Commit all your work before finishing. Do not accept stories, move them to archived, or merge to master." [[agent]] name = "qa" stage = "qa" role = "Reviews coder work: runs quality gates, generates testing plans, and reports findings." model = "sonnet" max_turns = 40 max_budget_usd = 4.00 prompt = "You are the QA agent for story {{story_id}}. Review the coder's work and produce a structured QA report. Run quality gates (linting, tests), attempt a build, and generate a manual testing plan. Do NOT modify any code." system_prompt = "You are a QA agent. Your job is read-only: review code quality, run tests, and produce a structured QA report. Do not modify code." [[agent]] name = "mergemaster" stage = "mergemaster" role = "Merges completed work into master, runs quality gates, and archives stories." model = "sonnet" max_turns = 30 max_budget_usd = 5.00 prompt = "You are the mergemaster agent for story {{story_id}}. Call merge_agent_work(story_id='{{story_id}}') to start the merge pipeline. Then poll get_merge_status(story_id='{{story_id}}') every 15 seconds until the status is 'completed' or 'failed'. Report the final result. If the merge fails, call report_merge_failure." system_prompt = "You are the mergemaster agent. Call merge_agent_work to start the merge, then poll get_merge_status every 15 seconds until done. Never manually move story files. Call report_merge_failure when merges fail." "#; /// Detect the tech stack from the project root and return TOML `[[component]]` entries. /// /// Inspects well-known marker files at the project root to identify which /// tech stacks are present, then emits one `[[component]]` entry per detected /// stack with sensible default `setup` commands. If no markers are found, a /// single fallback `app` component with an empty `setup` list is returned so /// that the pipeline never breaks on an unknown stack. pub fn detect_components_toml(root: &Path) -> String { let mut sections = Vec::new(); if root.join("Cargo.toml").exists() { sections.push( "[[component]]\nname = \"server\"\npath = \".\"\nsetup = [\"cargo check\"]\n" .to_string(), ); } if root.join("package.json").exists() { let setup_cmd = if root.join("pnpm-lock.yaml").exists() { "pnpm install" } else { "npm install" }; sections.push(format!( "[[component]]\nname = \"frontend\"\npath = \".\"\nsetup = [\"{setup_cmd}\"]\n" )); } if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() { sections.push( "[[component]]\nname = \"python\"\npath = \".\"\nsetup = [\"pip install -r requirements.txt\"]\n" .to_string(), ); } if root.join("go.mod").exists() { sections.push( "[[component]]\nname = \"go\"\npath = \".\"\nsetup = [\"go build ./...\"]\n" .to_string(), ); } if root.join("Gemfile").exists() { sections.push( "[[component]]\nname = \"ruby\"\npath = \".\"\nsetup = [\"bundle install\"]\n" .to_string(), ); } if sections.is_empty() { // No tech stack markers detected — emit two example components so that // the scaffold is immediately usable and agents can see the expected // format. The ONBOARDING_PROMPT instructs the chat agent to inspect // the project and replace these placeholders with real definitions. sections.push( "# EXAMPLE: Replace with your actual backend component.\n\ # Common patterns: \"cargo check\" (Rust), \"go build ./...\" (Go),\n\ # \"python -m pytest\" (Python), \"mvn verify\" (Java)\n\ [[component]]\n\ name = \"backend\"\n\ path = \".\"\n\ setup = [\"cargo check\"]\n\ teardown = []\n" .to_string(), ); sections.push( "# EXAMPLE: Replace with your actual frontend component.\n\ # Common patterns: \"pnpm install\" (pnpm), \"npm install\" (npm),\n\ # \"yarn\" (Yarn), \"bun install\" (Bun)\n\ [[component]]\n\ name = \"frontend\"\n\ path = \".\"\n\ setup = [\"pnpm install\"]\n\ teardown = []\n" .to_string(), ); } sections.join("\n") } /// Generate a complete `project.toml` for a new project at `root`. /// /// Detects the tech stack via [`detect_components_toml`] and prepends the /// resulting `[[component]]` entries before the default `[[agent]]` sections. fn generate_project_toml(root: &Path) -> String { let components = detect_components_toml(root); 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 `.story_kit/` subdirectory, or `None`. pub fn find_story_kit_root(start: &Path) -> Option { let mut current = start.to_path_buf(); loop { if current.join(".story_kit").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(()); } fs::write(path, content).map_err(|e| format!("Failed to write file: {}", e))?; Ok(()) } /// Write `content` to `path` if missing, then ensure the file is executable. fn write_script_if_missing(path: &Path, content: &str) -> Result<(), String> { write_file_if_missing(path, content)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = fs::metadata(path) .map_err(|e| format!("Failed to read permissions for {}: {}", path.display(), e))? .permissions(); perms.set_mode(0o755); fs::set_permissions(path, perms) .map_err(|e| format!("Failed to set permissions on {}: {}", path.display(), e))?; } Ok(()) } /// Write (or idempotently update) `.story_kit/.gitignore` with Story Kit–specific /// ignore patterns for files that live inside the `.story_kit/` directory. /// Patterns are relative to `.story_kit/` as git resolves `.gitignore` files /// relative to the directory that contains them. fn write_story_kit_gitignore(root: &Path) -> Result<(), String> { // Entries that belong inside .story_kit/.gitignore (relative to .story_kit/). let entries = [ "bot.toml", "matrix_store/", "matrix_device_id", "worktrees/", "merge_workspace/", "coverage/", ]; let gitignore_path = root.join(".story_kit").join(".gitignore"); let existing = if gitignore_path.exists() { fs::read_to_string(&gitignore_path) .map_err(|e| format!("Failed to read .story_kit/.gitignore: {}", e))? } else { String::new() }; let missing: Vec<&str> = entries .iter() .copied() .filter(|e| !existing.lines().any(|l| l.trim() == *e)) .collect(); if missing.is_empty() { return Ok(()); } let mut new_content = existing; if !new_content.is_empty() && !new_content.ends_with('\n') { new_content.push('\n'); } for entry in missing { new_content.push_str(entry); new_content.push('\n'); } fs::write(&gitignore_path, new_content) .map_err(|e| format!("Failed to write .story_kit/.gitignore: {}", e))?; Ok(()) } /// Append root-level Story Kit entries to the project `.gitignore`. /// Only `store.json` and `.story_kit_port` remain here because they live at /// the project root and git does not support `../` patterns in `.gitignore` /// files, so they cannot be expressed in `.story_kit/.gitignore`. fn append_root_gitignore_entries(root: &Path) -> Result<(), String> { let entries = [".story_kit_port", "store.json"]; let gitignore_path = root.join(".gitignore"); let existing = if gitignore_path.exists() { fs::read_to_string(&gitignore_path) .map_err(|e| format!("Failed to read .gitignore: {}", e))? } else { String::new() }; let missing: Vec<&str> = entries .iter() .copied() .filter(|e| !existing.lines().any(|l| l.trim() == *e)) .collect(); if missing.is_empty() { return Ok(()); } let mut new_content = existing; if !new_content.is_empty() && !new_content.ends_with('\n') { new_content.push('\n'); } for entry in missing { new_content.push_str(entry); new_content.push('\n'); } fs::write(&gitignore_path, new_content) .map_err(|e| format!("Failed to write .gitignore: {}", e))?; Ok(()) } fn scaffold_story_kit(root: &Path) -> Result<(), String> { let story_kit_root = root.join(".story_kit"); let specs_root = story_kit_root.join("specs"); let tech_root = specs_root.join("tech"); let functional_root = specs_root.join("functional"); let script_root = root.join("script"); // Create the work/ pipeline directories, each with a .gitkeep so empty dirs survive git clone let work_stages = [ "1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived", ]; for stage in &work_stages { let dir = story_kit_root.join("work").join(stage); fs::create_dir_all(&dir) .map_err(|e| format!("Failed to create work/{}: {}", stage, e))?; write_file_if_missing(&dir.join(".gitkeep"), "")?; } fs::create_dir_all(&tech_root).map_err(|e| format!("Failed to create specs/tech: {}", e))?; fs::create_dir_all(&functional_root) .map_err(|e| format!("Failed to create specs/functional: {}", e))?; fs::create_dir_all(&script_root) .map_err(|e| format!("Failed to create script/ directory: {}", e))?; write_file_if_missing(&story_kit_root.join("README.md"), STORY_KIT_README)?; let project_toml_content = generate_project_toml(root); write_file_if_missing(&story_kit_root.join("project.toml"), &project_toml_content)?; write_file_if_missing(&specs_root.join("00_CONTEXT.md"), STORY_KIT_CONTEXT)?; write_file_if_missing(&tech_root.join("STACK.md"), STORY_KIT_STACK)?; write_script_if_missing(&script_root.join("test"), STORY_KIT_SCRIPT_TEST)?; write_file_if_missing(&root.join("CLAUDE.md"), STORY_KIT_CLAUDE_MD)?; // Create .claude/settings.json with sensible permission defaults so that // Claude Code (both agents and web UI chat) can operate without constant // permission prompts. let claude_dir = root.join(".claude"); fs::create_dir_all(&claude_dir) .map_err(|e| format!("Failed to create .claude/ directory: {}", e))?; write_file_if_missing(&claude_dir.join("settings.json"), STORY_KIT_CLAUDE_SETTINGS)?; write_story_kit_gitignore(root)?; append_root_gitignore_entries(root)?; // Run `git init` if the directory is not already a git repo, then make an initial commit if !root.join(".git").exists() { let init_status = std::process::Command::new("git") .args(["init"]) .current_dir(root) .status() .map_err(|e| format!("Failed to run git init: {}", e))?; if !init_status.success() { return Err("git init failed".to_string()); } let add_output = std::process::Command::new("git") .args(["add", ".story_kit", "script", ".gitignore", "CLAUDE.md", ".claude"]) .current_dir(root) .output() .map_err(|e| format!("Failed to run git add: {}", e))?; if !add_output.status.success() { return Err(format!( "git add failed: {}", String::from_utf8_lossy(&add_output.stderr) )); } let commit_output = std::process::Command::new("git") .args([ "-c", "user.email=story-kit@localhost", "-c", "user.name=Story Kit", "commit", "-m", "Initial Story Kit scaffold", ]) .current_dir(root) .output() .map_err(|e| format!("Failed to run git commit: {}", e))?; if !commit_output.status.success() { return Err(format!( "git commit failed: {}", String::from_utf8_lossy(&commit_output.stderr) )); } } Ok(()) } async fn ensure_project_root_with_story_kit(path: PathBuf) -> 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(".story_kit").is_dir() { scaffold_story_kit(&path)?; } Ok(()) }) .await .map_err(|e| format!("Task failed: {}", e))? } pub async fn open_project( path: String, state: &SessionState, store: &dyn StoreOps, ) -> Result { let p = PathBuf::from(&path); ensure_project_root_with_story_kit(p.clone()).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, ) .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_write_mcp_json() { // open_project must NOT overwrite .mcp.json — test servers started by QA // agents share the real project root, so writing here would clobber the // root .mcp.json with the wrong port. .mcp.json is written once during // worktree creation (worktree.rs) and should not be touched again. 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, ) .await .unwrap(); let mcp_path = project_dir.join(".mcp.json"); assert!( !mcp_path.exists(), "open_project must not write .mcp.json — that would overwrite the root with the wrong port" ); } #[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, ) .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(".story_kit")).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(".story_kit")).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(".story_kit")).unwrap(); let child = tmp.path().join("inner"); std::fs::create_dir_all(child.join(".story_kit")).unwrap(); let result = find_story_kit_root(&child); assert_eq!(result, Some(child)); } // --- scaffold --- #[test] fn scaffold_story_kit_creates_structure() { let dir = tempdir().unwrap(); scaffold_story_kit(dir.path()).unwrap(); assert!(dir.path().join(".story_kit/README.md").exists()); assert!(dir.path().join(".story_kit/project.toml").exists()); assert!(dir.path().join(".story_kit/specs/00_CONTEXT.md").exists()); assert!(dir.path().join(".story_kit/specs/tech/STACK.md").exists()); // Old stories/ dirs should NOT be created assert!(!dir.path().join(".story_kit/stories").exists()); assert!(dir.path().join("script/test").exists()); } #[test] fn scaffold_story_kit_creates_work_pipeline_dirs() { let dir = tempdir().unwrap(); scaffold_story_kit(dir.path()).unwrap(); let stages = ["1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived"]; for stage in &stages { let path = dir.path().join(".story_kit/work").join(stage); assert!(path.is_dir(), "work/{} should be a directory", stage); assert!( path.join(".gitkeep").exists(), "work/{} should have a .gitkeep file", stage ); } } #[test] fn scaffold_story_kit_project_toml_has_coder_qa_mergemaster() { let dir = tempdir().unwrap(); scaffold_story_kit(dir.path()).unwrap(); let content = fs::read_to_string(dir.path().join(".story_kit/project.toml")).unwrap(); assert!(content.contains("[[agent]]")); assert!(content.contains("stage = \"coder\"")); assert!(content.contains("stage = \"qa\"")); assert!(content.contains("stage = \"mergemaster\"")); assert!(content.contains("model = \"sonnet\"")); } #[test] fn scaffold_context_is_blank_template_not_story_kit_content() { let dir = tempdir().unwrap(); scaffold_story_kit(dir.path()).unwrap(); let content = fs::read_to_string(dir.path().join(".story_kit/specs/00_CONTEXT.md")).unwrap(); assert!(content.contains("")); assert!(content.contains("## High-Level Goal")); assert!(content.contains("## Core Features")); assert!(content.contains("## Domain Definition")); assert!(content.contains("## Glossary")); // Must NOT contain Story Kit-specific content assert!(!content.contains("Agentic AI Code Assistant")); assert!(!content.contains("Poem HTTP server")); } #[test] fn scaffold_stack_is_blank_template_not_story_kit_content() { let dir = tempdir().unwrap(); scaffold_story_kit(dir.path()).unwrap(); let content = fs::read_to_string(dir.path().join(".story_kit/specs/tech/STACK.md")).unwrap(); assert!(content.contains("")); assert!(content.contains("## Core Stack")); assert!(content.contains("## Coding Standards")); assert!(content.contains("## Quality Gates")); assert!(content.contains("## Libraries")); // Must NOT contain Story Kit-specific content assert!(!content.contains("Poem HTTP server")); assert!(!content.contains("TypeScript + React")); } #[cfg(unix)] #[test] fn scaffold_story_kit_creates_executable_script_test() { use std::os::unix::fs::PermissionsExt; let dir = tempdir().unwrap(); scaffold_story_kit(dir.path()).unwrap(); let script_test = dir.path().join("script/test"); assert!(script_test.exists(), "script/test should be created"); let perms = fs::metadata(&script_test).unwrap().permissions(); assert!( perms.mode() & 0o111 != 0, "script/test should be executable" ); } #[test] fn scaffold_story_kit_does_not_overwrite_existing() { let dir = tempdir().unwrap(); let readme = dir.path().join(".story_kit/README.md"); fs::create_dir_all(readme.parent().unwrap()).unwrap(); fs::write(&readme, "custom content").unwrap(); scaffold_story_kit(dir.path()).unwrap(); assert_eq!(fs::read_to_string(&readme).unwrap(), "custom content"); } #[test] fn scaffold_story_kit_is_idempotent() { let dir = tempdir().unwrap(); scaffold_story_kit(dir.path()).unwrap(); let readme_content = fs::read_to_string(dir.path().join(".story_kit/README.md")).unwrap(); let toml_content = fs::read_to_string(dir.path().join(".story_kit/project.toml")).unwrap(); // Run again — must not change content or add duplicate .gitignore entries scaffold_story_kit(dir.path()).unwrap(); assert_eq!( fs::read_to_string(dir.path().join(".story_kit/README.md")).unwrap(), readme_content ); assert_eq!( fs::read_to_string(dir.path().join(".story_kit/project.toml")).unwrap(), toml_content ); let story_kit_gitignore = fs::read_to_string(dir.path().join(".story_kit/.gitignore")).unwrap(); let count = story_kit_gitignore .lines() .filter(|l| l.trim() == "worktrees/") .count(); assert_eq!( count, 1, ".story_kit/.gitignore should not have duplicate entries" ); } #[test] fn scaffold_story_kit_existing_git_repo_no_commit() { let dir = tempdir().unwrap(); // Initialize a git repo before scaffold std::process::Command::new("git") .args(["init"]) .current_dir(dir.path()) .status() .unwrap(); std::process::Command::new("git") .args([ "-c", "user.email=test@test.com", "-c", "user.name=Test", "commit", "--allow-empty", "-m", "pre-scaffold", ]) .current_dir(dir.path()) .status() .unwrap(); scaffold_story_kit(dir.path()).unwrap(); // Only 1 commit should exist — scaffold must not commit into an existing repo let log_output = std::process::Command::new("git") .args(["log", "--oneline"]) .current_dir(dir.path()) .output() .unwrap(); let log = String::from_utf8_lossy(&log_output.stdout); let commit_count = log.lines().count(); assert_eq!( commit_count, 1, "scaffold should not create a commit in an existing git repo" ); } #[test] fn scaffold_creates_story_kit_gitignore_with_relative_entries() { let dir = tempdir().unwrap(); scaffold_story_kit(dir.path()).unwrap(); // .story_kit/.gitignore must contain relative patterns for files under .story_kit/ let sk_content = fs::read_to_string(dir.path().join(".story_kit/.gitignore")).unwrap(); assert!(sk_content.contains("worktrees/")); assert!(sk_content.contains("merge_workspace/")); assert!(sk_content.contains("coverage/")); // Must NOT contain absolute .story_kit/ prefixed paths assert!(!sk_content.contains(".story_kit/")); // Root .gitignore must contain root-level story-kit entries let root_content = fs::read_to_string(dir.path().join(".gitignore")).unwrap(); assert!(root_content.contains(".story_kit_port")); assert!(root_content.contains("store.json")); // Root .gitignore must NOT contain .story_kit/ sub-directory patterns assert!(!root_content.contains(".story_kit/worktrees/")); assert!(!root_content.contains(".story_kit/merge_workspace/")); assert!(!root_content.contains(".story_kit/coverage/")); } #[test] fn scaffold_story_kit_gitignore_does_not_duplicate_existing_entries() { let dir = tempdir().unwrap(); // Pre-create .story_kit dir and .gitignore with some entries already present fs::create_dir_all(dir.path().join(".story_kit")).unwrap(); fs::write( dir.path().join(".story_kit/.gitignore"), "worktrees/\ncoverage/\n", ) .unwrap(); scaffold_story_kit(dir.path()).unwrap(); let content = fs::read_to_string(dir.path().join(".story_kit/.gitignore")).unwrap(); let worktrees_count = content .lines() .filter(|l| l.trim() == "worktrees/") .count(); assert_eq!(worktrees_count, 1, "worktrees/ should not be duplicated"); let coverage_count = content .lines() .filter(|l| l.trim() == "coverage/") .count(); assert_eq!(coverage_count, 1, "coverage/ should not be duplicated"); // The missing entry must have been added assert!(content.contains("merge_workspace/")); } // --- CLAUDE.md scaffold --- #[test] fn scaffold_creates_claude_md_at_project_root() { let dir = tempdir().unwrap(); scaffold_story_kit(dir.path()).unwrap(); let claude_md = dir.path().join("CLAUDE.md"); assert!(claude_md.exists(), "CLAUDE.md should be created at project root"); let content = fs::read_to_string(&claude_md).unwrap(); assert!( content.contains(""), "CLAUDE.md should contain the scaffold sentinel" ); assert!( content.contains("Read .story_kit/README.md"), "CLAUDE.md should include directive to read .story_kit/README.md" ); assert!( content.contains("Never chain shell commands"), "CLAUDE.md should include command chaining rule" ); } #[test] fn scaffold_does_not_overwrite_existing_claude_md() { let dir = tempdir().unwrap(); let claude_md = dir.path().join("CLAUDE.md"); fs::write(&claude_md, "custom CLAUDE.md content").unwrap(); scaffold_story_kit(dir.path()).unwrap(); assert_eq!( fs::read_to_string(&claude_md).unwrap(), "custom CLAUDE.md content", "scaffold should not overwrite an existing CLAUDE.md" ); } // --- 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, ) .await .unwrap(); // .story_kit/ should have been created automatically assert!(project_dir.join(".story_kit").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(".story_kit"); 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, ) .await .unwrap(); // Existing .story_kit/ 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] fn detect_no_markers_returns_fallback_components() { let dir = tempdir().unwrap(); let toml = detect_components_toml(dir.path()); // At least one [[component]] entry should always be present assert!( toml.contains("[[component]]"), "should always emit at least one component" ); // The fallback should include example backend and frontend entries assert!( toml.contains("name = \"backend\"") || toml.contains("name = \"frontend\""), "fallback should include example component entries" ); } #[test] fn detect_cargo_toml_generates_rust_component() { let dir = tempdir().unwrap(); fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"\n").unwrap(); let toml = detect_components_toml(dir.path()); assert!(toml.contains("name = \"server\"")); assert!(toml.contains("setup = [\"cargo check\"]")); } #[test] fn detect_package_json_with_pnpm_lock_generates_pnpm_component() { let dir = tempdir().unwrap(); fs::write(dir.path().join("package.json"), "{}").unwrap(); fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap(); let toml = detect_components_toml(dir.path()); assert!(toml.contains("name = \"frontend\"")); assert!(toml.contains("setup = [\"pnpm install\"]")); } #[test] fn detect_package_json_without_pnpm_lock_generates_npm_component() { let dir = tempdir().unwrap(); fs::write(dir.path().join("package.json"), "{}").unwrap(); let toml = detect_components_toml(dir.path()); assert!(toml.contains("name = \"frontend\"")); assert!(toml.contains("setup = [\"npm install\"]")); } #[test] fn detect_pyproject_toml_generates_python_component() { let dir = tempdir().unwrap(); fs::write(dir.path().join("pyproject.toml"), "[project]\nname = \"test\"\n").unwrap(); let toml = detect_components_toml(dir.path()); assert!(toml.contains("name = \"python\"")); assert!(toml.contains("pip install")); } #[test] fn detect_requirements_txt_generates_python_component() { let dir = tempdir().unwrap(); fs::write(dir.path().join("requirements.txt"), "flask\n").unwrap(); let toml = detect_components_toml(dir.path()); assert!(toml.contains("name = \"python\"")); assert!(toml.contains("pip install")); } #[test] fn detect_go_mod_generates_go_component() { let dir = tempdir().unwrap(); fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap(); let toml = detect_components_toml(dir.path()); assert!(toml.contains("name = \"go\"")); assert!(toml.contains("setup = [\"go build ./...\"]")); } #[test] fn detect_gemfile_generates_ruby_component() { let dir = tempdir().unwrap(); fs::write(dir.path().join("Gemfile"), "source \"https://rubygems.org\"\n").unwrap(); let toml = detect_components_toml(dir.path()); assert!(toml.contains("name = \"ruby\"")); assert!(toml.contains("setup = [\"bundle install\"]")); } #[test] fn detect_multiple_markers_generates_multiple_components() { let dir = tempdir().unwrap(); fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"server\"\n").unwrap(); fs::write(dir.path().join("package.json"), "{}").unwrap(); let toml = detect_components_toml(dir.path()); assert!(toml.contains("name = \"server\"")); assert!(toml.contains("name = \"frontend\"")); // Both component entries should be present let component_count = toml.matches("[[component]]").count(); assert_eq!(component_count, 2); } #[test] fn detect_no_fallback_when_markers_found() { let dir = tempdir().unwrap(); fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap(); let toml = detect_components_toml(dir.path()); // The fallback "app" component should NOT appear when a real stack is detected assert!(!toml.contains("name = \"app\"")); } // --- generate_project_toml --- #[test] fn generate_project_toml_includes_both_components_and_agents() { let dir = tempdir().unwrap(); fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap(); let toml = generate_project_toml(dir.path()); // Component section assert!(toml.contains("[[component]]")); assert!(toml.contains("name = \"server\"")); // Agent sections assert!(toml.contains("[[agent]]")); assert!(toml.contains("stage = \"coder\"")); assert!(toml.contains("stage = \"qa\"")); assert!(toml.contains("stage = \"mergemaster\"")); } #[test] fn scaffold_project_toml_contains_detected_components() { let dir = tempdir().unwrap(); // Place a Cargo.toml in the project root before scaffolding fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"myapp\"\n").unwrap(); scaffold_story_kit(dir.path()).unwrap(); let content = fs::read_to_string(dir.path().join(".story_kit/project.toml")).unwrap(); assert!( content.contains("[[component]]"), "project.toml should contain a component entry" ); assert!( content.contains("name = \"server\""), "Rust project should have a 'server' component" ); assert!( content.contains("cargo check"), "Rust component should have cargo check setup" ); } #[test] fn scaffold_project_toml_fallback_when_no_stack_detected() { let dir = tempdir().unwrap(); scaffold_story_kit(dir.path()).unwrap(); let content = fs::read_to_string(dir.path().join(".story_kit/project.toml")).unwrap(); assert!( content.contains("[[component]]"), "project.toml should always have at least one component" ); // Fallback emits example components so the scaffold is immediately usable assert!( content.contains("name = \"backend\"") || content.contains("name = \"frontend\""), "fallback should include example component entries" ); } #[test] fn scaffold_does_not_overwrite_existing_project_toml_with_components() { let dir = tempdir().unwrap(); let sk_dir = dir.path().join(".story_kit"); fs::create_dir_all(&sk_dir).unwrap(); let existing = "[[component]]\nname = \"custom\"\npath = \".\"\nsetup = [\"make build\"]\n"; fs::write(sk_dir.join("project.toml"), existing).unwrap(); scaffold_story_kit(dir.path()).unwrap(); let content = fs::read_to_string(sk_dir.join("project.toml")).unwrap(); assert_eq!( content, existing, "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()); } }