2026-02-13 12:31:36 +00:00
use crate ::state ::SessionState ;
use crate ::store ::StoreOps ;
use serde ::Serialize ;
use serde_json ::json ;
use std ::fs ;
2026-02-16 20:34:03 +00:00
use std ::path ::{ Path , PathBuf } ;
2026-02-13 12:31:36 +00:00
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 " ;
2026-02-13 12:31:36 +00:00
2026-02-26 17:17:11 +00:00
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
2026-02-26 17:17:11 +00:00
const STORY_KIT_SCRIPT_TEST : & str = " #!/usr/bin/env bash \n set -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. \n echo \" No tests configured \" \n " ;
2026-02-16 20:34:03 +00:00
2026-02-26 18:29:32 +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 " ;
2026-02-26 19:02:11 +00:00
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 "
]
}
" #;
2026-02-27 16:41:20 +00:00
const DEFAULT_PROJECT_AGENTS_TOML : & str = r #" [[agent]]
2026-02-26 17:17:11 +00:00
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 \n IMPORTANT: 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
2026-03-17 12:15:42 +00: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. "
2026-02-16 20:34:03 +00:00
" #;
2026-02-27 16:41:20 +00:00
/// 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]] \n name = \" server \" \n path = \" . \" \n setup = [ \" 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]] \n name = \" frontend \" \n path = \" . \" \n setup = [ \" {setup_cmd} \" ] \n "
) ) ;
}
if root . join ( " pyproject.toml " ) . exists ( ) | | root . join ( " requirements.txt " ) . exists ( ) {
sections . push (
" [[component]] \n name = \" python \" \n path = \" . \" \n setup = [ \" pip install -r requirements.txt \" ] \n "
. to_string ( ) ,
) ;
}
if root . join ( " go.mod " ) . exists ( ) {
sections . push (
" [[component]] \n name = \" go \" \n path = \" . \" \n setup = [ \" go build ./... \" ] \n "
. to_string ( ) ,
) ;
}
if root . join ( " Gemfile " ) . exists ( ) {
sections . push (
" [[component]] \n name = \" ruby \" \n path = \" . \" \n setup = [ \" 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} " )
}
2026-02-26 15:17:40 +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 )
}
2026-02-23 11:39:22 +00:00
/// 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 ;
}
}
}
2026-02-16 19:44:29 +00:00
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 ( ) )
}
2026-02-13 12:31:36 +00:00
/// 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 ( ( ) )
}
2026-02-23 12:59:55 +00:00
/// 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 ( ( ) )
}
2026-03-17 13:13:01 +00:00
/// 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/).
2026-02-26 17:17:11 +00:00
let entries = [
2026-03-17 13:13:01 +00:00
" bot.toml " ,
" matrix_store/ " ,
" matrix_device_id " ,
" worktrees/ " ,
" merge_workspace/ " ,
" coverage/ " ,
2026-02-26 17:17:11 +00:00
] ;
2026-03-17 13:13:01 +00:00
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 " ] ;
2026-02-26 17:17:11 +00:00
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 " ) ;
2026-02-23 12:59:55 +00:00
let script_root = root . join ( " script " ) ;
2026-02-16 20:34:03 +00:00
2026-02-26 17:17:11 +00:00
// Create the work/ pipeline directories, each with a .gitkeep so empty dirs survive git clone
let work_stages = [
2026-03-18 14:31:12 +00:00
" 1_backlog " ,
2026-02-26 17:17:11 +00:00
" 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 ) ) ? ;
2026-02-23 12:59:55 +00:00
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 ) ? ;
2026-02-27 16:41:20 +00:00
let project_toml_content = generate_project_toml ( root ) ;
write_file_if_missing ( & story_kit_root . join ( " project.toml " ) , & project_toml_content ) ? ;
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 ) ? ;
2026-02-23 12:59:55 +00:00
write_script_if_missing ( & script_root . join ( " test " ) , STORY_KIT_SCRIPT_TEST ) ? ;
2026-02-26 18:29:32 +00:00
write_file_if_missing ( & root . join ( " CLAUDE.md " ) , STORY_KIT_CLAUDE_MD ) ? ;
2026-02-16 20:34:03 +00:00
2026-02-26 19:02:11 +00:00
// 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 ) ? ;
2026-03-17 13:13:01 +00:00
write_story_kit_gitignore ( root ) ? ;
append_root_gitignore_entries ( root ) ? ;
2026-02-26 17:17:11 +00:00
// 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 " )
2026-02-26 19:02:11 +00:00
. args ( [ " add " , " .story_kit " , " script " , " .gitignore " , " CLAUDE.md " , " .claude " ] )
2026-02-26 17:17:11 +00:00
. 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 ) ) ? ;
2026-02-26 15:17:40 +00:00
}
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 ) ) ?
}
2026-02-13 12:31:36 +00:00
pub async fn open_project (
path : String ,
state : & SessionState ,
store : & dyn StoreOps ,
) -> 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 ? ;
2026-02-13 12:31:36 +00:00
validate_project_path ( p . clone ( ) ) . await ? ;
{
2026-02-26 17:01:38 +00:00
// TRACE:MERGE-DEBUG — remove once root cause is found
crate ::slog! ( " [MERGE-DEBUG] open_project: setting project_root to {:?} " , p ) ;
2026-02-13 12:31:36 +00:00
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 ) ) ;
2026-02-13 12:31:36 +00:00
store . save ( ) ? ;
Ok ( path )
}
pub fn close_project ( state : & SessionState , store : & dyn StoreOps ) -> Result < ( ) , String > {
{
2026-02-26 17:01:38 +00:00
// TRACE:MERGE-DEBUG — remove once root cause is found
crate ::slog! ( " [MERGE-DEBUG] close_project: setting project_root to None " ) ;
2026-02-13 12:31:36 +00:00
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 ( ) {
2026-02-26 17:01:38 +00:00
// 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
) ;
2026-02-13 12:31:36 +00:00
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 ( ( ) )
}
2026-02-13 12:31:36 +00:00
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 > {
2026-02-19 12:54:04 +00:00
let root = state . get_project_root ( ) ? ;
let full_path = resolve_path_impl ( root , & path ) ? ;
2026-02-13 12:31:36 +00:00
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
}
2026-02-16 19:44:29 +00:00
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 ) ) ?
}
2026-02-19 12:54:04 +00:00
2026-03-17 17:56:24 +00:00
/// 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 < Vec < String > , String > {
let root = state . get_project_root ( ) ? ;
list_project_files_impl ( root ) . await
}
pub async fn list_project_files_impl ( root : PathBuf ) -> Result < Vec < String > , 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 )
}
2026-02-19 12:54:04 +00:00
#[ cfg(test) ]
mod tests {
use super ::* ;
WIP: Batch 2 — backfill tests for fs, shell, and http/workflow
- io/fs.rs: 20 tests (path resolution, project open/close/get, known projects,
model prefs, file read/write, list dir, validate path, scaffold)
- io/shell.rs: 4 new tests (allowlist, command execution, stdout capture, exit codes)
- http/workflow.rs: 8 tests (parse_test_status, to_test_case, to_review_story)
Coverage: 28.6% → 48.1%
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:52:19 +00:00
use crate ::store ::JsonFileStore ;
2026-02-19 12:54:04 +00:00
use tempfile ::tempdir ;
WIP: Batch 2 — backfill tests for fs, shell, and http/workflow
- io/fs.rs: 20 tests (path resolution, project open/close/get, known projects,
model prefs, file read/write, list dir, validate path, scaffold)
- io/shell.rs: 4 new tests (allowlist, command execution, stdout capture, exit codes)
- http/workflow.rs: 8 tests (parse_test_status, to_test_case, to_review_story)
Coverage: 28.6% → 48.1%
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:52:19 +00:00
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 ) ;
}
2026-02-26 14:57:14 +00:00
#[ tokio::test ]
2026-03-17 18:09:28 +00:00
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.
2026-02-26 14:57:14 +00:00
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! (
2026-03-17 18:09:28 +00:00
! mcp_path . exists ( ) ,
" open_project must not write .mcp.json — that would overwrite the root with the wrong port "
2026-02-26 14:57:14 +00:00
) ;
}
WIP: Batch 2 — backfill tests for fs, shell, and http/workflow
- io/fs.rs: 20 tests (path resolution, project open/close/get, known projects,
model prefs, file read/write, list dir, validate path, scaffold)
- io/shell.rs: 4 new tests (allowlist, command execution, stdout capture, exit codes)
- http/workflow.rs: 8 tests (parse_test_status, to_test_case, to_review_story)
Coverage: 28.6% → 48.1%
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:52:19 +00:00
#[ 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 ( ) ) ;
}
2026-02-23 11:39:22 +00:00
// --- 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 ) ) ;
}
WIP: Batch 2 — backfill tests for fs, shell, and http/workflow
- io/fs.rs: 20 tests (path resolution, project open/close/get, known projects,
model prefs, file read/write, list dir, validate path, scaffold)
- io/shell.rs: 4 new tests (allowlist, command execution, stdout capture, exit codes)
- http/workflow.rs: 8 tests (parse_test_status, to_test_case, to_review_story)
Coverage: 28.6% → 48.1%
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:52:19 +00:00
// --- 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 ( ) ) ;
2026-02-26 17:17:11 +00:00
assert! ( dir . path ( ) . join ( " .story_kit/project.toml " ) . exists ( ) ) ;
WIP: Batch 2 — backfill tests for fs, shell, and http/workflow
- io/fs.rs: 20 tests (path resolution, project open/close/get, known projects,
model prefs, file read/write, list dir, validate path, scaffold)
- io/shell.rs: 4 new tests (allowlist, command execution, stdout capture, exit codes)
- http/workflow.rs: 8 tests (parse_test_status, to_test_case, to_review_story)
Coverage: 28.6% → 48.1%
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:52:19 +00:00
assert! ( dir . path ( ) . join ( " .story_kit/specs/00_CONTEXT.md " ) . exists ( ) ) ;
assert! ( dir . path ( ) . join ( " .story_kit/specs/tech/STACK.md " ) . exists ( ) ) ;
2026-02-26 17:17:11 +00:00
// Old stories/ dirs should NOT be created
assert! ( ! dir . path ( ) . join ( " .story_kit/stories " ) . exists ( ) ) ;
2026-02-23 12:59:55 +00:00
assert! ( dir . path ( ) . join ( " script/test " ) . exists ( ) ) ;
}
2026-02-26 17:17:11 +00:00
#[ test ]
fn scaffold_story_kit_creates_work_pipeline_dirs ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
scaffold_story_kit ( dir . path ( ) ) . unwrap ( ) ;
2026-03-18 14:31:12 +00:00
let stages = [ " 1_backlog " , " 2_current " , " 3_qa " , " 4_merge " , " 5_done " , " 6_archived " ] ;
2026-02-26 17:17:11 +00:00
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 " ) ) ;
}
2026-02-23 12:59:55 +00:00
#[ 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 "
) ;
WIP: Batch 2 — backfill tests for fs, shell, and http/workflow
- io/fs.rs: 20 tests (path resolution, project open/close/get, known projects,
model prefs, file read/write, list dir, validate path, scaffold)
- io/shell.rs: 4 new tests (allowlist, command execution, stdout capture, exit codes)
- http/workflow.rs: 8 tests (parse_test_status, to_test_case, to_review_story)
Coverage: 28.6% → 48.1%
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:52:19 +00:00
}
#[ 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 " ) ;
}
2026-02-26 15:17:40 +00:00
2026-02-26 17:17:11 +00:00
#[ 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
) ;
2026-03-17 13:13:01 +00:00
let story_kit_gitignore =
fs ::read_to_string ( dir . path ( ) . join ( " .story_kit/.gitignore " ) ) . unwrap ( ) ;
let count = story_kit_gitignore
2026-02-26 17:17:11 +00:00
. lines ( )
2026-03-17 13:13:01 +00:00
. filter ( | l | l . trim ( ) = = " worktrees/ " )
2026-02-26 17:17:11 +00:00
. count ( ) ;
2026-03-17 13:13:01 +00:00
assert_eq! (
count ,
1 ,
" .story_kit/.gitignore should not have duplicate entries "
) ;
2026-02-26 17:17:11 +00:00
}
#[ 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 ]
2026-03-17 13:13:01 +00:00
fn scaffold_creates_story_kit_gitignore_with_relative_entries ( ) {
2026-02-26 17:17:11 +00:00
let dir = tempdir ( ) . unwrap ( ) ;
scaffold_story_kit ( dir . path ( ) ) . unwrap ( ) ;
2026-03-17 13:13:01 +00:00
// .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/ " ) ) ;
2026-02-26 17:17:11 +00:00
}
#[ test ]
2026-03-17 13:13:01 +00:00
fn scaffold_story_kit_gitignore_does_not_duplicate_existing_entries ( ) {
2026-02-26 17:17:11 +00:00
let dir = tempdir ( ) . unwrap ( ) ;
2026-03-17 13:13:01 +00:00
// Pre-create .story_kit dir and .gitignore with some entries already present
fs ::create_dir_all ( dir . path ( ) . join ( " .story_kit " ) ) . unwrap ( ) ;
2026-02-26 17:17:11 +00:00
fs ::write (
2026-03-17 13:13:01 +00:00
dir . path ( ) . join ( " .story_kit/.gitignore " ) ,
" worktrees/ \n coverage/ \n " ,
2026-02-26 17:17:11 +00:00
)
. unwrap ( ) ;
scaffold_story_kit ( dir . path ( ) ) . unwrap ( ) ;
2026-03-17 13:13:01 +00:00
let content =
fs ::read_to_string ( dir . path ( ) . join ( " .story_kit/.gitignore " ) ) . unwrap ( ) ;
2026-02-26 17:17:11 +00:00
let worktrees_count = content
. lines ( )
2026-03-17 13:13:01 +00:00
. filter ( | l | l . trim ( ) = = " worktrees/ " )
2026-02-26 17:17:11 +00:00
. count ( ) ;
2026-03-17 13:13:01 +00:00
assert_eq! ( worktrees_count , 1 , " worktrees/ should not be duplicated " ) ;
2026-02-26 17:17:11 +00:00
let coverage_count = content
. lines ( )
2026-03-17 13:13:01 +00:00
. filter ( | l | l . trim ( ) = = " coverage/ " )
2026-02-26 17:17:11 +00:00
. count ( ) ;
2026-03-17 13:13:01 +00:00
assert_eq! ( coverage_count , 1 , " coverage/ should not be duplicated " ) ;
// The missing entry must have been added
assert! ( content . contains ( " merge_workspace/ " ) ) ;
2026-02-26 17:17:11 +00:00
}
2026-02-26 18:29:32 +00:00
// --- 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 "
) ;
}
2026-02-26 15:17:40 +00:00
// --- 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 " ) ) ;
}
2026-02-27 16:41:20 +00:00
// --- 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] \n name = \" 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] \n name = \" 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] \n name = \" 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] \n name = \" 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] \n name = \" 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] \n name = \" 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]] \n name = \" custom \" \n path = \" . \" \n setup = [ \" 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 "
) ;
}
2026-03-17 17:56:24 +00:00
// --- 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 ( ) ) ;
}
2026-02-19 12:54:04 +00:00
}