Files
storkit/server/src/io/fs.rs

1210 lines
40 KiB
Rust
Raw Normal View History

use crate::state::SessionState;
use crate::store::StoreOps;
use crate::worktree::write_mcp_json as worktree_write_mcp_json;
use serde::Serialize;
use serde_json::json;
use std::fs;
2026-02-16 20:34:03 +00:00
use std::path::{Path, PathBuf};
const KEY_LAST_PROJECT: &str = "last_project_path";
const KEY_SELECTED_MODEL: &str = "selected_model";
2026-02-16 18:57:39 +00:00
const KEY_KNOWN_PROJECTS: &str = "known_projects";
const STORY_KIT_README: &str = include_str!("../../../.story_kit/README.md");
const STORY_KIT_CONTEXT: &str = "<!-- story-kit:scaffold-template -->\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 = "<!-- story-kit:scaffold-template -->\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";
2026-02-16 20:34:03 +00:00
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";
2026-02-16 20:34:03 +00:00
const STORY_KIT_CLAUDE_MD: &str = "<!-- story-kit:scaffold-template -->\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 DEFAULT_PROJECT_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}}') via the MCP tool to trigger the full merge pipeline. Report the result to the human. If the merge fails, call report_merge_failure."
system_prompt = "You are the mergemaster agent. Trigger merge_agent_work via MCP and report results. Never manually move story files. Call report_merge_failure when merges fail."
2026-02-16 20:34:03 +00:00
"#;
/// 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<PathBuf> {
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<String, String> {
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<PathBuf, String> {
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<PathBuf, String> {
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))?
}
2026-02-16 20:34:03 +00:00
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(())
}
/// Append Story Kit entries to `.gitignore` (or create one if missing).
/// Does not duplicate entries already present.
fn append_gitignore_entries(root: &Path) -> Result<(), String> {
let entries = [
".story_kit/worktrees/",
".story_kit/merge_workspace/",
".story_kit/coverage/",
".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(())
}
2026-02-16 20:34:03 +00:00
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");
2026-02-16 20:34:03 +00:00
// Create the work/ pipeline directories, each with a .gitkeep so empty dirs survive git clone
let work_stages = [
"1_upcoming",
"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"), "")?;
}
2026-02-16 20:34:03 +00:00
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))?;
2026-02-16 20:34:03 +00:00
write_file_if_missing(&story_kit_root.join("README.md"), STORY_KIT_README)?;
write_file_if_missing(&story_kit_root.join("project.toml"), DEFAULT_PROJECT_TOML)?;
2026-02-16 20:34:03 +00:00
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)?;
2026-02-16 20:34:03 +00:00
append_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"])
.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)
));
}
}
2026-02-16 20:34:03 +00:00
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() {
2026-02-16 20:34:03 +00:00
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,
port: u16,
) -> Result<String, String> {
let p = PathBuf::from(&path);
2026-02-16 20:34:03 +00:00
ensure_project_root_with_story_kit(p.clone()).await?;
validate_project_path(p.clone()).await?;
// Write .mcp.json so that claude-code can connect to the MCP server.
// Best-effort: failure should not prevent the project from opening.
let _ = worktree_write_mcp_json(&p, port);
{
// 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));
2026-02-16 18:57:39 +00:00
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<Option<String>, 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)
}
2026-02-16 18:57:39 +00:00
pub fn get_known_projects(store: &dyn StoreOps) -> Result<Vec<String>, 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)
}
2026-02-16 19:53:31 +00:00
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<Option<String>, 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<String, String> {
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<String, String> {
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<Vec<FileEntry>, 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<Vec<FileEntry>, String> {
let full_path = resolve_path(state, &path)?;
list_directory_impl(full_path).await
}
pub async fn list_directory_absolute(path: String) -> Result<Vec<FileEntry>, String> {
let full_path = PathBuf::from(path);
list_directory_impl(full_path).await
}
2026-02-16 20:34:03 +00:00
pub async fn create_directory_absolute(path: String) -> Result<bool, String> {
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))?
}
#[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_writes_mcp_json_to_project_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 = SessionState::default();
open_project(
project_dir.to_string_lossy().to_string(),
&state,
&store,
4242,
)
.await
.unwrap();
let mcp_path = project_dir.join(".mcp.json");
assert!(mcp_path.exists(), ".mcp.json should be written to project root");
let content = fs::read_to_string(&mcp_path).unwrap();
assert!(
content.contains("http://localhost:4242/mcp"),
".mcp.json should contain the correct 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,
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(".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_upcoming", "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("<!-- story-kit:scaffold-template -->"));
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("<!-- story-kit:scaffold-template -->"));
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 gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
let count = gitignore
.lines()
.filter(|l| l.trim() == ".story_kit/worktrees/")
.count();
assert_eq!(count, 1, ".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_gitignore_with_story_kit_entries() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path()).unwrap();
let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(content.contains(".story_kit/worktrees/"));
assert!(content.contains(".story_kit/merge_workspace/"));
assert!(content.contains(".story_kit/coverage/"));
assert!(content.contains(".story_kit_port"));
assert!(content.contains("store.json"));
}
#[test]
fn scaffold_gitignore_does_not_duplicate_existing_entries() {
let dir = tempdir().unwrap();
// Pre-create .gitignore with some Story Kit entries already present
fs::write(
dir.path().join(".gitignore"),
".story_kit/worktrees/\n.story_kit/coverage/\n",
)
.unwrap();
scaffold_story_kit(dir.path()).unwrap();
let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
let worktrees_count = content
.lines()
.filter(|l| l.trim() == ".story_kit/worktrees/")
.count();
assert_eq!(
worktrees_count,
1,
".story_kit/worktrees/ should not be duplicated"
);
let coverage_count = content
.lines()
.filter(|l| l.trim() == ".story_kit/coverage/")
.count();
assert_eq!(
coverage_count,
1,
".story_kit/coverage/ should not be duplicated"
);
// The missing entries must have been added
assert!(content.contains(".story_kit/merge_workspace/"));
assert!(content.contains(".story_kit_port"));
assert!(content.contains("store.json"));
}
// --- 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("<!-- story-kit:scaffold-template -->"),
"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,
0,
)
.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,
0,
)
.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"));
}
}