Skip to main content

autom8/
config.rs

1use crate::error::{Autom8Error, Result};
2use serde::{Deserialize, Serialize};
3use std::env;
4use std::fs;
5use std::path::PathBuf;
6
7/// The base config directory name under ~/.config/
8const CONFIG_DIR_NAME: &str = "autom8";
9
10// ============================================================================
11// State Machine Configuration
12// ============================================================================
13
14/// Configuration for controlling which states are executed in the autom8 state machine.
15///
16/// This struct represents the user's preferences for which steps of the automation
17/// pipeline should be executed. Each field corresponds to a state in the state machine.
18///
19/// # Default Behavior
20///
21/// By default, all states are enabled (`true`), meaning the full pipeline runs:
22/// review → commit → pull request.
23///
24/// # Serialization
25///
26/// This struct supports TOML serialization via serde. Missing fields in a config file
27/// will default to `true`, allowing partial configs to work correctly.
28///
29/// # Example
30///
31/// ```toml
32/// # Enable/disable the review state (code review before committing)
33/// review = true
34///
35/// # Enable/disable the commit state (creating git commits)
36/// commit = true
37///
38/// # Enable/disable the pull request state (creating PRs)
39/// pull_request = true
40/// ```
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct Config {
43    /// Whether to run the review state.
44    ///
45    /// When `true`, code changes are reviewed before committing.
46    /// When `false`, the review step is skipped.
47    #[serde(default = "default_true")]
48    pub review: bool,
49
50    /// Whether to run the commit state.
51    ///
52    /// When `true`, changes are committed to git.
53    /// When `false`, changes are left uncommitted.
54    #[serde(default = "default_true")]
55    pub commit: bool,
56
57    /// Whether to run the pull request state.
58    ///
59    /// When `true`, a pull request is created after committing.
60    /// When `false`, no PR is created.
61    #[serde(default = "default_true")]
62    pub pull_request: bool,
63
64    /// Whether to create pull requests in draft mode.
65    ///
66    /// When `true`, PRs are created as drafts (not ready for review).
67    /// When `false`, PRs are created as regular (ready for review) PRs.
68    ///
69    /// Note: Only applies when `pull_request = true`. Has no effect otherwise.
70    #[serde(default = "default_false")]
71    pub pull_request_draft: bool,
72
73    /// Whether to automatically create worktrees for runs.
74    ///
75    /// When `true`, autom8 creates a dedicated worktree for each run,
76    /// enabling multiple parallel sessions for the same project.
77    /// When `false`, autom8 runs on the current branch (default behavior).
78    ///
79    /// Note: Requires a git repository. Has no effect outside of git repos.
80    #[serde(default = "default_true")]
81    pub worktree: bool,
82
83    /// Pattern for worktree directory names.
84    ///
85    /// Placeholders:
86    /// - `{repo}` - The repository name
87    /// - `{branch}` - The branch name (slugified: slashes replaced with dashes)
88    ///
89    /// Default: `{repo}-wt-{branch}`
90    /// Example: For repo "myproject" and branch "feature/login", creates "myproject-wt-feature-login"
91    #[serde(default = "default_worktree_path_pattern")]
92    pub worktree_path_pattern: String,
93
94    /// Whether to remove worktrees after successful completion.
95    ///
96    /// When `true`, autom8 automatically removes the worktree directory after
97    /// a successful run (Completed state). Failed runs keep their worktrees.
98    /// When `false`, worktrees are preserved for manual inspection/cleanup.
99    ///
100    /// Note: Only applies when `worktree = true`. Has no effect otherwise.
101    #[serde(default = "default_false")]
102    pub worktree_cleanup: bool,
103}
104
105/// Default worktree path pattern.
106fn default_worktree_path_pattern() -> String {
107    "{repo}-wt-{branch}".to_string()
108}
109
110/// Helper function for serde default values (true).
111fn default_true() -> bool {
112    true
113}
114
115/// Helper function for serde default values (false).
116fn default_false() -> bool {
117    false
118}
119
120impl Default for Config {
121    fn default() -> Self {
122        Self {
123            review: true,
124            commit: true,
125            pull_request: true,
126            pull_request_draft: false,
127            worktree: true,
128            worktree_path_pattern: default_worktree_path_pattern(),
129            worktree_cleanup: false,
130        }
131    }
132}
133
134// ============================================================================
135// Config Validation
136// ============================================================================
137
138use std::error::Error;
139use std::fmt;
140
141/// Error type for configuration validation failures.
142///
143/// This enum represents specific validation errors that can occur when
144/// validating configuration settings. Each variant provides a clear,
145/// actionable error message.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum ConfigError {
148    /// Pull request is enabled but commit is disabled.
149    ///
150    /// Creating a pull request requires commits to exist, so this
151    /// configuration combination is invalid.
152    PullRequestWithoutCommit,
153}
154
155impl fmt::Display for ConfigError {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        match self {
158            ConfigError::PullRequestWithoutCommit => {
159                write!(
160                    f,
161                    "Cannot create pull request without commits. \
162                    Either set `commit = true` or set `pull_request = false`"
163                )
164            }
165        }
166    }
167}
168
169impl Error for ConfigError {}
170
171/// Validate a configuration for logical consistency.
172///
173/// This function checks that the configuration settings are valid and
174/// consistent with each other. It should be called after loading a config
175/// and before the state machine starts.
176///
177/// # Validation Rules
178///
179/// - `pull_request = true` requires `commit = true`
180///   (Cannot create a PR without commits)
181///
182/// # Arguments
183///
184/// * `config` - The configuration to validate
185///
186/// # Returns
187///
188/// * `Ok(())` if the configuration is valid
189/// * `Err(ConfigError)` if the configuration is invalid, with a clear error message
190///
191/// # Example
192///
193/// ```
194/// use autom8::config::{Config, validate_config};
195///
196/// let valid_config = Config::default();
197/// assert!(validate_config(&valid_config).is_ok());
198///
199/// let invalid_config = Config {
200///     review: true,
201///     commit: false,
202///     pull_request: true, // Invalid: PR without commit
203///     ..Default::default()
204/// };
205/// assert!(validate_config(&invalid_config).is_err());
206/// ```
207pub fn validate_config(config: &Config) -> std::result::Result<(), ConfigError> {
208    // Rule: pull_request = true requires commit = true
209    if config.pull_request && !config.commit {
210        return Err(ConfigError::PullRequestWithoutCommit);
211    }
212
213    Ok(())
214}
215
216// ============================================================================
217// Global Config File Management
218// ============================================================================
219
220/// The filename for the global configuration file.
221const GLOBAL_CONFIG_FILENAME: &str = "config.toml";
222
223/// Default config file content with explanatory comments.
224///
225/// This is written when creating a new config file to help users understand
226/// each option without needing to reference documentation.
227const DEFAULT_CONFIG_WITH_COMMENTS: &str = r#"# Autom8 Configuration
228# This file controls which states in the autom8 state machine are executed.
229
230# Review state: Code review before committing
231# - true: Run code review step to check implementation quality
232# - false: Skip code review and proceed directly to commit
233review = true
234
235# Commit state: Creating git commits
236# - true: Automatically commit changes after implementation
237# - false: Leave changes uncommitted (manual commit required)
238commit = true
239
240# Pull request state: Creating pull requests
241# - true: Automatically create a PR after committing
242# - false: Skip PR creation (commits remain on local branch)
243# Note: Requires commit = true to work
244pull_request = true
245
246# Pull request draft mode: Create PRs as drafts
247# - true: Create PRs as drafts (not ready for review)
248# - false: Create PRs as regular (ready for review) PRs (default)
249# Note: Only applies when pull_request = true. Has no effect otherwise.
250pull_request_draft = false
251
252# Worktree mode: Automatic worktree creation for parallel runs
253# - true: Create a dedicated worktree for each run (enables parallel sessions, default)
254# - false: Run on the current branch (single session per project)
255# Note: Requires a git repository. Has no effect outside of git repos.
256worktree = true
257
258# Worktree path pattern: Pattern for naming worktree directories
259# Placeholders: {repo} = repository name, {branch} = branch name (slugified)
260# Default: {repo}-wt-{branch} (e.g., "myproject-wt-feature-login")
261worktree_path_pattern = "{repo}-wt-{branch}"
262
263# Worktree cleanup: Automatically remove worktrees after successful completion
264# - true: Remove worktree directory after run completes successfully
265# - false: Preserve worktrees for manual inspection/cleanup (default)
266# Note: Failed runs always keep their worktrees. Only applies when worktree = true.
267worktree_cleanup = false
268"#;
269
270/// Get the path to the global config file.
271///
272/// Returns the path to `~/.config/autom8/config.toml`.
273pub fn global_config_path() -> Result<PathBuf> {
274    Ok(config_dir()?.join(GLOBAL_CONFIG_FILENAME))
275}
276
277/// Load the global configuration from `~/.config/autom8/config.toml`.
278///
279/// If the config file doesn't exist, it creates one with default values
280/// and helpful comments explaining each option.
281///
282/// # Returns
283///
284/// The loaded or newly-created default configuration.
285///
286/// # Errors
287///
288/// Returns an error if:
289/// - The home directory cannot be determined
290/// - The config directory cannot be created
291/// - The config file cannot be read (other than not existing)
292/// - The config file contains invalid TOML
293pub fn load_global_config() -> Result<Config> {
294    let config_path = global_config_path()?;
295
296    if !config_path.exists() {
297        // Ensure the config directory exists
298        ensure_config_dir()?;
299
300        // Create the config file with default values and comments
301        fs::write(&config_path, DEFAULT_CONFIG_WITH_COMMENTS)?;
302
303        return Ok(Config::default());
304    }
305
306    // Read and parse the existing config file
307    let content = fs::read_to_string(&config_path)?;
308    let config: Config = toml::from_str(&content).map_err(|e| {
309        Autom8Error::Config(format!(
310            "Failed to parse config file at {:?}: {}",
311            config_path, e
312        ))
313    })?;
314
315    Ok(config)
316}
317
318/// Save the global configuration to `~/.config/autom8/config.toml`.
319///
320/// This writes the configuration with explanatory comments. Note that this
321/// will overwrite any existing file, including any user-added comments.
322///
323/// # Arguments
324///
325/// * `config` - The configuration to save
326///
327/// # Errors
328///
329/// Returns an error if:
330/// - The home directory cannot be determined
331/// - The config directory cannot be created
332/// - The config file cannot be written
333pub fn save_global_config(config: &Config) -> Result<()> {
334    let config_path = global_config_path()?;
335
336    // Ensure the config directory exists
337    ensure_config_dir()?;
338
339    // Generate config content with comments
340    let content = generate_config_with_comments(config);
341
342    fs::write(&config_path, content)?;
343
344    Ok(())
345}
346
347/// Generate config file content with explanatory comments.
348///
349/// Creates a TOML string that includes comments explaining each option,
350/// using the actual values from the provided config.
351fn generate_config_with_comments(config: &Config) -> String {
352    format!(
353        r#"# Autom8 Configuration
354# This file controls which states in the autom8 state machine are executed.
355
356# Review state: Code review before committing
357# - true: Run code review step to check implementation quality
358# - false: Skip code review and proceed directly to commit
359review = {}
360
361# Commit state: Creating git commits
362# - true: Automatically commit changes after implementation
363# - false: Leave changes uncommitted (manual commit required)
364commit = {}
365
366# Pull request state: Creating pull requests
367# - true: Automatically create a PR after committing
368# - false: Skip PR creation (commits remain on local branch)
369# Note: Requires commit = true to work
370pull_request = {}
371
372# Pull request draft mode: Create PRs as drafts
373# - true: Create PRs as drafts (not ready for review)
374# - false: Create PRs as regular (ready for review) PRs (default)
375# Note: Only applies when pull_request = true. Has no effect otherwise.
376pull_request_draft = {}
377
378# Worktree mode: Automatic worktree creation for parallel runs
379# - true: Create a dedicated worktree for each run (enables parallel sessions, default)
380# - false: Run on the current branch (single session per project)
381# Note: Requires a git repository. Has no effect outside of git repos.
382worktree = {}
383
384# Worktree path pattern: Pattern for naming worktree directories
385# Placeholders: {{repo}} = repository name, {{branch}} = branch name (slugified)
386# Default: {{repo}}-wt-{{branch}} (e.g., "myproject-wt-feature-login")
387worktree_path_pattern = "{}"
388
389# Worktree cleanup: Automatically remove worktrees after successful completion
390# - true: Remove worktree directory after run completes successfully
391# - false: Preserve worktrees for manual inspection/cleanup (default)
392# Note: Failed runs always keep their worktrees. Only applies when worktree = true.
393worktree_cleanup = {}
394"#,
395        config.review,
396        config.commit,
397        config.pull_request,
398        config.pull_request_draft,
399        config.worktree,
400        config.worktree_path_pattern,
401        config.worktree_cleanup
402    )
403}
404
405// ============================================================================
406// Project Config File Management
407// ============================================================================
408
409/// The filename for project-specific configuration files.
410const PROJECT_CONFIG_FILENAME: &str = "config.toml";
411
412/// Get the path to a project's config file.
413///
414/// Returns the path to `~/.config/autom8/<project>/config.toml`.
415pub fn project_config_path() -> Result<PathBuf> {
416    Ok(project_config_dir()?.join(PROJECT_CONFIG_FILENAME))
417}
418
419/// Get the path to a specific project's config file by name.
420///
421/// Returns the path to `~/.config/autom8/<project_name>/config.toml`.
422pub fn project_config_path_for(project_name: &str) -> Result<PathBuf> {
423    Ok(project_config_dir_for(project_name)?.join(PROJECT_CONFIG_FILENAME))
424}
425
426/// Load the project-specific configuration from `~/.config/autom8/<project>/config.toml`.
427///
428/// If the project config file doesn't exist, it copies the global config (with comments)
429/// to the project config directory and returns the global config values.
430///
431/// # Returns
432///
433/// The loaded or inherited configuration.
434///
435/// # Errors
436///
437/// Returns an error if:
438/// - The home directory cannot be determined
439/// - The project config directory cannot be created
440/// - The config file cannot be read (other than not existing)
441/// - The config file contains invalid TOML
442pub fn load_project_config() -> Result<Config> {
443    let config_path = project_config_path()?;
444
445    if !config_path.exists() {
446        // Ensure the project config directory exists
447        ensure_project_config_dir()?;
448
449        // Copy global config (with comments) to project config
450        let global_config = load_global_config()?;
451        let content = generate_config_with_comments(&global_config);
452        fs::write(&config_path, content)?;
453
454        return Ok(global_config);
455    }
456
457    // Read and parse the existing project config file
458    let content = fs::read_to_string(&config_path)?;
459    let config: Config = toml::from_str(&content).map_err(|e| {
460        Autom8Error::Config(format!(
461            "Failed to parse project config file at {:?}: {}",
462            config_path, e
463        ))
464    })?;
465
466    Ok(config)
467}
468
469/// Save a project-specific configuration to `~/.config/autom8/<project>/config.toml`.
470///
471/// This writes the configuration with explanatory comments. Note that this
472/// will overwrite any existing file, including any user-added comments.
473///
474/// # Arguments
475///
476/// * `config` - The configuration to save
477///
478/// # Errors
479///
480/// Returns an error if:
481/// - The home directory cannot be determined
482/// - The project config directory cannot be created
483/// - The config file cannot be written
484pub fn save_project_config(config: &Config) -> Result<()> {
485    let config_path = project_config_path()?;
486
487    // Ensure the project config directory exists
488    ensure_project_config_dir()?;
489
490    // Generate config content with comments
491    let content = generate_config_with_comments(config);
492
493    fs::write(&config_path, content)?;
494
495    Ok(())
496}
497
498/// Save configuration to a specific project's config file by name.
499///
500/// This writes the configuration with explanatory comments to
501/// `~/.config/autom8/<project_name>/config.toml`.
502///
503/// # Arguments
504///
505/// * `project_name` - The name of the project
506/// * `config` - The configuration to save
507///
508/// # Errors
509///
510/// Returns an error if:
511/// - The home directory cannot be determined
512/// - The project config directory cannot be created
513/// - The config file cannot be written
514pub fn save_project_config_for(project_name: &str, config: &Config) -> Result<()> {
515    let config_path = project_config_path_for(project_name)?;
516
517    // Ensure the project config directory exists
518    let config_dir = project_config_dir_for(project_name)?;
519    fs::create_dir_all(&config_dir)?;
520
521    // Generate config content with comments
522    let content = generate_config_with_comments(config);
523
524    fs::write(&config_path, content)?;
525
526    Ok(())
527}
528
529/// Get the effective configuration for the current project.
530///
531/// This function returns the resolved configuration by checking:
532/// 1. If a project config exists at `~/.config/autom8/<project>/config.toml`, return it
533/// 2. Otherwise, return the global config from `~/.config/autom8/config.toml`
534///
535/// Unlike `load_project_config()`, this function does NOT create a project config
536/// if one doesn't exist. It simply returns whichever config is applicable.
537///
538/// **Important:** This function validates the configuration before returning it.
539/// Invalid configurations will result in an error.
540///
541/// # Returns
542///
543/// The effective configuration (project config if exists, else global config).
544///
545/// # Errors
546///
547/// Returns an error if:
548/// - The home directory cannot be determined
549/// - The config file cannot be read
550/// - The config file contains invalid TOML
551/// - The configuration is invalid (e.g., pull_request=true with commit=false)
552pub fn get_effective_config() -> Result<Config> {
553    let project_config_path = project_config_path()?;
554
555    let config = if project_config_path.exists() {
556        // Project config exists, load it directly (no auto-creation)
557        let content = fs::read_to_string(&project_config_path)?;
558        toml::from_str(&content).map_err(|e| {
559            Autom8Error::Config(format!(
560                "Failed to parse project config file at {:?}: {}",
561                project_config_path, e
562            ))
563        })?
564    } else {
565        // No project config, load global config
566        load_global_config()?
567    };
568
569    // Validate the configuration before returning
570    validate_config(&config).map_err(|e| Autom8Error::Config(e.to_string()))?;
571
572    Ok(config)
573}
574
575// ============================================================================
576// Directory Management
577// ============================================================================
578
579/// Subdirectory names within a project config directory
580const SPEC_SUBDIR: &str = "spec";
581const RUNS_SUBDIR: &str = "runs";
582const SESSIONS_SUBDIR: &str = "sessions";
583
584/// Filename for project metadata
585const PROJECT_METADATA_FILENAME: &str = "project.json";
586
587/// Project metadata stored in `~/.config/autom8/<project>/project.json`.
588///
589/// Contains persistent information about the project that doesn't change
590/// between runs, such as the path to the git repository.
591#[derive(Debug, Clone, Serialize, Deserialize)]
592pub struct ProjectMetadata {
593    /// The absolute path to the git repository root.
594    pub repo_path: PathBuf,
595}
596
597/// Get the autom8 config directory path (~/.config/autom8/).
598///
599/// Returns the path to the config directory. Does not create the directory.
600pub fn config_dir() -> Result<PathBuf> {
601    let home = dirs::home_dir()
602        .ok_or_else(|| Autom8Error::Config("Could not determine home directory".to_string()))?;
603    Ok(home.join(".config").join(CONFIG_DIR_NAME))
604}
605
606/// Ensure the autom8 config directory exists (~/.config/autom8/).
607///
608/// Creates the directory if it doesn't exist. Returns whether the directory
609/// was newly created (true) or already existed (false).
610pub fn ensure_config_dir() -> Result<(PathBuf, bool)> {
611    let dir = config_dir()?;
612    let created = !dir.exists();
613    fs::create_dir_all(&dir)?;
614    Ok((dir, created))
615}
616
617/// Get the current project name.
618///
619/// Uses the git repository name (basename of the main repo root) when in a git
620/// repository, ensuring consistent project identification across all worktrees.
621/// Falls back to the current working directory basename if not in a git repo.
622pub fn current_project_name() -> Result<String> {
623    // Try to get the git repository name first
624    if let Ok(Some(repo_name)) = crate::worktree::get_git_repo_name() {
625        return Ok(repo_name);
626    }
627
628    // Fallback: use CWD basename for non-git directories
629    let cwd = env::current_dir().map_err(|e| {
630        Autom8Error::Config(format!("Could not determine current directory: {}", e))
631    })?;
632    cwd.file_name()
633        .and_then(|n| n.to_str())
634        .map(|s| s.to_string())
635        .ok_or_else(|| {
636            Autom8Error::Config("Could not determine project name from path".to_string())
637        })
638}
639
640/// Get the project-specific config directory path (~/.config/autom8/<project-name>/).
641///
642/// Returns the path to the project config directory. Does not create the directory.
643pub fn project_config_dir() -> Result<PathBuf> {
644    let base = config_dir()?;
645    let project_name = current_project_name()?;
646    Ok(base.join(project_name))
647}
648
649/// Get the project-specific config directory path for a given project name.
650pub fn project_config_dir_for(project_name: &str) -> Result<PathBuf> {
651    let base = config_dir()?;
652    Ok(base.join(project_name))
653}
654
655/// Ensure the project-specific config directory and its subdirectories exist.
656///
657/// Creates:
658/// - `~/.config/autom8/<project-name>/`
659/// - `~/.config/autom8/<project-name>/spec/`
660/// - `~/.config/autom8/<project-name>/runs/`
661/// - `~/.config/autom8/<project-name>/project.json` (with repo path)
662///
663/// Returns the project config directory path and whether it was newly created.
664pub fn ensure_project_config_dir() -> Result<(PathBuf, bool)> {
665    let dir = project_config_dir()?;
666    let created = !dir.exists();
667
668    // Create all subdirectories
669    fs::create_dir_all(dir.join(SPEC_SUBDIR))?;
670    fs::create_dir_all(dir.join(RUNS_SUBDIR))?;
671
672    // Save project metadata with repo path (only if it doesn't exist yet)
673    let metadata_path = dir.join(PROJECT_METADATA_FILENAME);
674    if !metadata_path.exists() {
675        if let Ok(repo_path) = crate::worktree::get_main_repo_root() {
676            let metadata = ProjectMetadata { repo_path };
677            if let Ok(content) = serde_json::to_string_pretty(&metadata) {
678                let _ = fs::write(&metadata_path, content);
679            }
680        }
681    }
682
683    Ok((dir, created))
684}
685
686/// Get the repository path for a project by name.
687///
688/// Reads the `project.json` file from the project's config directory
689/// and returns the stored `repo_path`.
690///
691/// Returns `None` if the project doesn't exist or has no metadata.
692pub fn get_project_repo_path(project_name: &str) -> Option<PathBuf> {
693    let project_dir = project_config_dir_for(project_name).ok()?;
694    let metadata_path = project_dir.join(PROJECT_METADATA_FILENAME);
695
696    let content = fs::read_to_string(&metadata_path).ok()?;
697    let metadata: ProjectMetadata = serde_json::from_str(&content).ok()?;
698
699    // Only return if the path still exists
700    if metadata.repo_path.exists() {
701        Some(metadata.repo_path)
702    } else {
703        None
704    }
705}
706
707/// Get the spec subdirectory path for the current project.
708pub fn spec_dir() -> Result<PathBuf> {
709    Ok(project_config_dir()?.join(SPEC_SUBDIR))
710}
711
712/// Get the runs subdirectory path for the current project.
713pub fn runs_dir() -> Result<PathBuf> {
714    Ok(project_config_dir()?.join(RUNS_SUBDIR))
715}
716
717/// List all project directories in the config directory.
718///
719/// Returns a sorted list of project names (directory basenames) from `~/.config/autom8/`.
720/// Only includes directories, not files.
721pub fn list_projects() -> Result<Vec<String>> {
722    let base = config_dir()?;
723
724    if !base.exists() {
725        return Ok(Vec::new());
726    }
727
728    let mut projects = Vec::new();
729
730    let entries = fs::read_dir(&base)
731        .map_err(|e| Autom8Error::Config(format!("Could not read config directory: {}", e)))?;
732
733    for entry in entries {
734        let entry = entry
735            .map_err(|e| Autom8Error::Config(format!("Could not read directory entry: {}", e)))?;
736
737        let path = entry.path();
738        if path.is_dir() {
739            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
740                projects.push(name.to_string());
741            }
742        }
743    }
744
745    projects.sort();
746    Ok(projects)
747}
748
749/// Check if a file is already inside the autom8 config directory.
750///
751/// Returns true if the file path is inside `~/.config/autom8/` (any project).
752/// This prevents moving files that are already in the config area, even if they're
753/// in a different project's directory (e.g., a worktree-named project).
754pub fn is_in_config_dir(file_path: &std::path::Path) -> Result<bool> {
755    let base_config = config_dir()?;
756
757    // Canonicalize both paths to handle relative paths and symlinks
758    let canonical_file = file_path
759        .canonicalize()
760        .unwrap_or_else(|_| file_path.to_path_buf());
761    let canonical_config = base_config.canonicalize().unwrap_or(base_config);
762
763    Ok(canonical_file.starts_with(&canonical_config))
764}
765
766/// Result of moving a file to the config directory.
767#[derive(Debug)]
768pub struct MoveResult {
769    /// The destination path where the file was moved.
770    pub dest_path: PathBuf,
771    /// Whether the file was actually moved (false if already in config dir).
772    pub was_moved: bool,
773}
774
775/// Move a file to the appropriate config subdirectory if it's not already there.
776///
777/// Both markdown (`.md`) and JSON (`.json`) files are moved to `~/.config/autom8/<project-name>/spec/`
778///
779/// Uses `fs::rename()` when possible, falls back to copy+delete for cross-filesystem moves.
780///
781/// Returns the path to use for processing (either the original or the moved location).
782pub fn move_to_config_dir(file_path: &std::path::Path) -> Result<MoveResult> {
783    // If already in config directory, return original path
784    if is_in_config_dir(file_path)? {
785        let canonical = file_path
786            .canonicalize()
787            .unwrap_or_else(|_| file_path.to_path_buf());
788        return Ok(MoveResult {
789            dest_path: canonical,
790            was_moved: false,
791        });
792    }
793
794    // All files go to spec/ directory
795    let dest_dir = spec_dir()?;
796
797    // Ensure destination directory exists
798    fs::create_dir_all(&dest_dir)?;
799
800    // Get filename and create destination path
801    let filename = file_path
802        .file_name()
803        .ok_or_else(|| Autom8Error::Config("Could not determine filename".to_string()))?;
804    let dest_path = dest_dir.join(filename);
805
806    // Try rename first (fast, atomic), fall back to copy+delete for cross-filesystem
807    if fs::rename(file_path, &dest_path).is_err() {
808        // Cross-filesystem move: copy then delete original
809        fs::copy(file_path, &dest_path)?;
810        fs::remove_file(file_path)?;
811    }
812
813    Ok(MoveResult {
814        dest_path,
815        was_moved: true,
816    })
817}
818
819/// Status information for a single project.
820#[derive(Debug, Clone)]
821pub struct ProjectStatus {
822    /// The project name (directory basename).
823    pub name: String,
824    /// Whether there is an active or failed run.
825    pub has_active_run: bool,
826    /// The run status (if any run exists).
827    pub run_status: Option<crate::state::RunStatus>,
828    /// Count of incomplete specs.
829    pub incomplete_spec_count: usize,
830    /// Total spec count.
831    pub total_spec_count: usize,
832}
833
834impl ProjectStatus {
835    /// Returns true if this project needs attention (active/failed run or incomplete specs).
836    pub fn needs_attention(&self) -> bool {
837        self.has_active_run
838            || self.run_status == Some(crate::state::RunStatus::Failed)
839            || self.incomplete_spec_count > 0
840    }
841
842    /// Returns true if this project is idle (no active work).
843    pub fn is_idle(&self) -> bool {
844        !self.needs_attention()
845    }
846}
847
848/// Information about a project's directory contents for tree display.
849#[derive(Debug, Clone)]
850pub struct ProjectTreeInfo {
851    /// The project name (directory basename).
852    pub name: String,
853    /// Whether there is an active run.
854    pub has_active_run: bool,
855    /// The run status (if any run exists).
856    pub run_status: Option<crate::state::RunStatus>,
857    /// Number of spec files in spec/ directory.
858    pub spec_count: usize,
859    /// Number of incomplete specs.
860    pub incomplete_spec_count: usize,
861    /// Number of markdown spec files in spec/ directory.
862    pub spec_md_count: usize,
863    /// Number of archived runs in runs/ directory.
864    pub runs_count: usize,
865    /// The date of the most recent run (archived or current).
866    pub last_run_date: Option<chrono::DateTime<chrono::Utc>>,
867}
868
869impl ProjectTreeInfo {
870    /// Returns a status label for the project.
871    pub fn status_label(&self) -> &'static str {
872        if self.has_active_run {
873            "running"
874        } else if self.run_status == Some(crate::state::RunStatus::Failed) {
875            "failed"
876        } else if self.incomplete_spec_count > 0 {
877            "incomplete"
878        } else if self.spec_count > 0 {
879            "complete"
880        } else {
881            "empty"
882        }
883    }
884
885    /// Returns true if this project has any content.
886    pub fn has_content(&self) -> bool {
887        self.spec_count > 0 || self.spec_md_count > 0 || self.runs_count > 0 || self.has_active_run
888    }
889}
890
891/// Get detailed tree information for all projects.
892///
893/// Returns a list of `ProjectTreeInfo` for each project in `~/.config/autom8/`.
894/// Projects are sorted alphabetically by name.
895pub fn list_projects_tree() -> Result<Vec<ProjectTreeInfo>> {
896    use crate::spec::Spec;
897    use crate::state::{RunState, RunStatus, SessionMetadata};
898
899    let projects = list_projects()?;
900    let mut tree_info = Vec::new();
901
902    for project_name in projects {
903        let project_dir = project_config_dir_for(&project_name)?;
904
905        // Check for active run by scanning all session metadata files directly
906        // This avoids spawning git subprocess that StateManager::for_project() does
907        let sessions_dir = project_dir.join(SESSIONS_SUBDIR);
908        let mut has_active_run = false;
909        let mut run_status: Option<RunStatus> = None;
910        let mut active_run_started_at: Option<chrono::DateTime<chrono::Utc>> = None;
911
912        if sessions_dir.exists() {
913            if let Ok(entries) = fs::read_dir(&sessions_dir) {
914                for entry in entries.filter_map(|e| e.ok()) {
915                    let session_path = entry.path();
916                    if !session_path.is_dir() {
917                        continue;
918                    }
919
920                    // Check metadata.json for is_running flag
921                    let metadata_path = session_path.join("metadata.json");
922                    if let Ok(content) = fs::read_to_string(&metadata_path) {
923                        if let Ok(metadata) = serde_json::from_str::<SessionMetadata>(&content) {
924                            if metadata.is_running {
925                                // Also load state.json to get the RunStatus
926                                let state_path = session_path.join("state.json");
927                                if let Ok(state_content) = fs::read_to_string(&state_path) {
928                                    if let Ok(state) =
929                                        serde_json::from_str::<RunState>(&state_content)
930                                    {
931                                        if state.status == RunStatus::Running {
932                                            has_active_run = true;
933                                            run_status = Some(state.status);
934                                            active_run_started_at = Some(state.started_at);
935                                            break;
936                                        }
937                                    }
938                                }
939                            }
940                        }
941                    }
942                }
943            }
944        }
945
946        // Count specs and incomplete specs by reading spec directory directly
947        let spec_dir = project_dir.join(SPEC_SUBDIR);
948        let mut specs: Vec<PathBuf> = Vec::new();
949        let mut incomplete_count = 0;
950
951        if spec_dir.exists() {
952            if let Ok(entries) = fs::read_dir(&spec_dir) {
953                for entry in entries.filter_map(|e| e.ok()) {
954                    let path = entry.path();
955                    if path.extension().is_some_and(|e| e == "json") {
956                        specs.push(path.clone());
957                        if let Ok(spec) = Spec::load(&path) {
958                            if spec.is_incomplete() {
959                                incomplete_count += 1;
960                            }
961                        }
962                    }
963                }
964            }
965        }
966
967        // Count spec files (markdown specs)
968        let spec_md_count = if spec_dir.exists() {
969            fs::read_dir(&spec_dir)
970                .map(|entries| {
971                    entries
972                        .filter_map(|e| e.ok())
973                        .filter(|e| {
974                            e.path().is_file()
975                                && e.path().extension().is_some_and(|ext| ext == "md")
976                        })
977                        .count()
978                })
979                .unwrap_or(0)
980        } else {
981            0
982        };
983
984        // Get archived runs by reading runs directory directly
985        let runs_dir = project_dir.join(RUNS_SUBDIR);
986        let mut archived_runs: Vec<RunState> = Vec::new();
987
988        if runs_dir.exists() {
989            if let Ok(entries) = fs::read_dir(&runs_dir) {
990                for entry in entries.filter_map(|e| e.ok()) {
991                    let path = entry.path();
992                    if path.extension().is_some_and(|e| e == "json") {
993                        if let Ok(content) = fs::read_to_string(&path) {
994                            if let Ok(state) = serde_json::from_str::<RunState>(&content) {
995                                archived_runs.push(state);
996                            }
997                        }
998                    }
999                }
1000            }
1001        }
1002        // Sort by start date, newest first
1003        archived_runs.sort_by(|a, b| b.started_at.cmp(&a.started_at));
1004        let runs_count = archived_runs.len();
1005
1006        // Determine last run date from archived runs or current run.
1007        // For active runs: use started_at (shows how long it's been running).
1008        // For completed runs: use finished_at (shows when it finished), falling back to started_at.
1009        let last_run_date = if has_active_run {
1010            // Active run: show when it started
1011            active_run_started_at
1012        } else {
1013            // No active run: fall back to most recent archived run
1014            archived_runs
1015                .first()
1016                .and_then(|r| r.finished_at.or(Some(r.started_at)))
1017        };
1018
1019        tree_info.push(ProjectTreeInfo {
1020            name: project_name,
1021            has_active_run,
1022            run_status,
1023            spec_count: specs.len(),
1024            incomplete_spec_count: incomplete_count,
1025            spec_md_count,
1026            runs_count,
1027            last_run_date,
1028        });
1029    }
1030
1031    Ok(tree_info)
1032}
1033
1034/// Detailed information about a project for the describe command.
1035#[derive(Debug, Clone)]
1036pub struct ProjectDescription {
1037    /// The project name.
1038    pub name: String,
1039    /// Path to the project config directory.
1040    pub path: PathBuf,
1041    /// Whether there is an active run.
1042    pub has_active_run: bool,
1043    /// The run status (if any run exists).
1044    pub run_status: Option<crate::state::RunStatus>,
1045    /// Current story being worked on (if any).
1046    pub current_story: Option<String>,
1047    /// Current branch from state (if any).
1048    pub current_branch: Option<String>,
1049    /// List of specs with their details.
1050    pub specs: Vec<SpecSummary>,
1051    /// Number of markdown spec files.
1052    pub spec_md_count: usize,
1053    /// Number of archived runs.
1054    pub runs_count: usize,
1055}
1056
1057/// Summary of a single spec.
1058#[derive(Debug, Clone)]
1059pub struct SpecSummary {
1060    /// The spec filename.
1061    pub filename: String,
1062    /// Full path to the spec file.
1063    pub path: PathBuf,
1064    /// Project name from the spec.
1065    pub project_name: String,
1066    /// Branch name from the spec.
1067    pub branch_name: String,
1068    /// Description from the spec.
1069    pub description: String,
1070    /// All user stories with their status.
1071    pub stories: Vec<StorySummary>,
1072    /// Number of completed stories.
1073    pub completed_count: usize,
1074    /// Total number of stories.
1075    pub total_count: usize,
1076    /// Whether this spec is currently being executed (has an active run).
1077    pub is_active: bool,
1078}
1079
1080/// Summary of a user story.
1081#[derive(Debug, Clone)]
1082pub struct StorySummary {
1083    /// Story ID (e.g., "US-001").
1084    pub id: String,
1085    /// Story title.
1086    pub title: String,
1087    /// Whether the story passes.
1088    pub passes: bool,
1089}
1090
1091/// Check if a project exists in the config directory.
1092pub fn project_exists(project_name: &str) -> Result<bool> {
1093    let project_dir = project_config_dir_for(project_name)?;
1094    Ok(project_dir.exists())
1095}
1096
1097/// Get detailed description of a project.
1098///
1099/// Returns `None` if the project doesn't exist.
1100pub fn get_project_description(project_name: &str) -> Result<Option<ProjectDescription>> {
1101    use crate::spec::Spec;
1102    use crate::state::StateManager;
1103
1104    let project_dir = project_config_dir_for(project_name)?;
1105
1106    if !project_dir.exists() {
1107        return Ok(None);
1108    }
1109
1110    let sm = StateManager::for_project(project_name)?;
1111
1112    // Check for active run
1113    let run_state = sm.load_current().ok().flatten();
1114    let has_active_run = run_state
1115        .as_ref()
1116        .map(|s| s.status == crate::state::RunStatus::Running)
1117        .unwrap_or(false);
1118    let run_status = run_state.as_ref().map(|s| s.status);
1119    let current_story = run_state.as_ref().and_then(|s| s.current_story.clone());
1120    let current_branch = run_state.map(|s| s.branch);
1121
1122    // Load specs with details
1123    let spec_paths = sm.list_specs().unwrap_or_default();
1124    let mut specs = Vec::new();
1125
1126    for spec_path in spec_paths {
1127        if let Ok(spec) = Spec::load(&spec_path) {
1128            let stories: Vec<StorySummary> = spec
1129                .user_stories
1130                .iter()
1131                .map(|s| StorySummary {
1132                    id: s.id.clone(),
1133                    title: s.title.clone(),
1134                    passes: s.passes,
1135                })
1136                .collect();
1137
1138            let completed_count = stories.iter().filter(|s| s.passes).count();
1139            let total_count = stories.len();
1140
1141            let filename = spec_path
1142                .file_name()
1143                .and_then(|n| n.to_str())
1144                .unwrap_or("unknown")
1145                .to_string();
1146
1147            // A spec is active if there's an active run and the spec's branch matches the current branch
1148            let is_active = has_active_run
1149                && current_branch
1150                    .as_ref()
1151                    .is_some_and(|b| b == &spec.branch_name);
1152
1153            specs.push(SpecSummary {
1154                filename,
1155                path: spec_path,
1156                project_name: spec.project,
1157                branch_name: spec.branch_name.clone(),
1158                description: spec.description,
1159                stories,
1160                completed_count,
1161                total_count,
1162                is_active,
1163            });
1164        }
1165    }
1166
1167    // Count spec files (markdown specs)
1168    let spec_dir = project_dir.join(SPEC_SUBDIR);
1169    let spec_md_count = if spec_dir.exists() {
1170        fs::read_dir(&spec_dir)
1171            .map(|entries| {
1172                entries
1173                    .filter_map(|e| e.ok())
1174                    .filter(|e| {
1175                        e.path().is_file() && e.path().extension().is_some_and(|ext| ext == "md")
1176                    })
1177                    .count()
1178            })
1179            .unwrap_or(0)
1180    } else {
1181        0
1182    };
1183
1184    // Count archived runs
1185    let runs_count = sm.list_archived().unwrap_or_default().len();
1186
1187    Ok(Some(ProjectDescription {
1188        name: project_name.to_string(),
1189        path: project_dir,
1190        has_active_run,
1191        run_status,
1192        current_story,
1193        current_branch,
1194        specs,
1195        spec_md_count,
1196        runs_count,
1197    }))
1198}
1199
1200/// Get status for all projects across the config directory.
1201///
1202/// Returns a list of `ProjectStatus` for each project in `~/.config/autom8/`.
1203/// Projects are sorted alphabetically by name.
1204pub fn global_status() -> Result<Vec<ProjectStatus>> {
1205    use crate::spec::Spec;
1206    use crate::state::StateManager;
1207
1208    let projects = list_projects()?;
1209    let mut statuses = Vec::new();
1210
1211    for project_name in projects {
1212        let sm = StateManager::for_project(&project_name)?;
1213
1214        // Check for active run
1215        let run_state = sm.load_current().ok().flatten();
1216        let has_active_run = run_state
1217            .as_ref()
1218            .map(|s| s.status == crate::state::RunStatus::Running)
1219            .unwrap_or(false);
1220        let run_status = run_state.map(|s| s.status);
1221
1222        // Count incomplete specs
1223        let specs = sm.list_specs().unwrap_or_default();
1224        let mut incomplete_count = 0;
1225        let mut total_count = 0;
1226
1227        for spec_path in &specs {
1228            if let Ok(spec) = Spec::load(spec_path) {
1229                total_count += 1;
1230                if spec.is_incomplete() {
1231                    incomplete_count += 1;
1232                }
1233            }
1234        }
1235
1236        statuses.push(ProjectStatus {
1237            name: project_name,
1238            has_active_run,
1239            run_status,
1240            incomplete_spec_count: incomplete_count,
1241            total_spec_count: total_count,
1242        });
1243    }
1244
1245    Ok(statuses)
1246}
1247
1248/// Get status for all projects at a given config directory (for testing).
1249#[cfg(test)]
1250fn global_status_at(base_config_dir: &std::path::Path) -> Result<Vec<ProjectStatus>> {
1251    use crate::spec::Spec;
1252    use crate::state::StateManager;
1253
1254    let projects = list_projects_at(base_config_dir)?;
1255    let mut statuses = Vec::new();
1256
1257    for project_name in projects {
1258        let project_dir = base_config_dir.join(&project_name);
1259        let sm = StateManager::with_dir(project_dir);
1260
1261        // Check for active run
1262        let run_state = sm.load_current().ok().flatten();
1263        let has_active_run = run_state
1264            .as_ref()
1265            .map(|s| s.status == crate::state::RunStatus::Running)
1266            .unwrap_or(false);
1267        let run_status = run_state.map(|s| s.status);
1268
1269        // Count incomplete specs
1270        let specs = sm.list_specs().unwrap_or_default();
1271        let mut incomplete_count = 0;
1272        let mut total_count = 0;
1273
1274        for spec_path in &specs {
1275            if let Ok(spec) = Spec::load(spec_path) {
1276                total_count += 1;
1277                if spec.is_incomplete() {
1278                    incomplete_count += 1;
1279                }
1280            }
1281        }
1282
1283        statuses.push(ProjectStatus {
1284            name: project_name,
1285            has_active_run,
1286            run_status,
1287            incomplete_spec_count: incomplete_count,
1288            total_spec_count: total_count,
1289        });
1290    }
1291
1292    Ok(statuses)
1293}
1294
1295/// List all project directories at a given base config path.
1296///
1297/// This is a testable version that allows specifying a custom base path.
1298/// Returns a sorted list of project names (directory basenames).
1299#[cfg(test)]
1300fn list_projects_at(base_config_dir: &std::path::Path) -> Result<Vec<String>> {
1301    if !base_config_dir.exists() {
1302        return Ok(Vec::new());
1303    }
1304
1305    let mut projects = Vec::new();
1306
1307    let entries = fs::read_dir(base_config_dir)
1308        .map_err(|e| Autom8Error::Config(format!("Could not read config directory: {}", e)))?;
1309
1310    for entry in entries {
1311        let entry = entry
1312            .map_err(|e| Autom8Error::Config(format!("Could not read directory entry: {}", e)))?;
1313
1314        let path = entry.path();
1315        if path.is_dir() {
1316            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
1317                projects.push(name.to_string());
1318            }
1319        }
1320    }
1321
1322    projects.sort();
1323    Ok(projects)
1324}
1325
1326/// Ensure a config directory exists at the given base path.
1327///
1328/// This is a testable version that allows specifying a custom base path.
1329/// Creates `<base>/.config/autom8/` if it doesn't exist.
1330///
1331/// Returns the full path and whether the directory was newly created.
1332#[cfg(test)]
1333fn ensure_config_dir_at(base: &std::path::Path) -> Result<(PathBuf, bool)> {
1334    let dir = base.join(".config").join(CONFIG_DIR_NAME);
1335    let created = !dir.exists();
1336    fs::create_dir_all(&dir)?;
1337    Ok((dir, created))
1338}
1339
1340/// Ensure a project config directory with subdirectories exists at the given base path.
1341///
1342/// This is a testable version that allows specifying a custom base path and project name.
1343/// Creates:
1344/// - `<base>/.config/autom8/<project-name>/`
1345/// - `<base>/.config/autom8/<project-name>/spec/`
1346/// - `<base>/.config/autom8/<project-name>/runs/`
1347///
1348/// Returns the full project path and whether it was newly created.
1349#[cfg(test)]
1350fn ensure_project_config_dir_at(
1351    base: &std::path::Path,
1352    project_name: &str,
1353) -> Result<(PathBuf, bool)> {
1354    let dir = base
1355        .join(".config")
1356        .join(CONFIG_DIR_NAME)
1357        .join(project_name);
1358    let created = !dir.exists();
1359
1360    fs::create_dir_all(dir.join(SPEC_SUBDIR))?;
1361    fs::create_dir_all(dir.join(RUNS_SUBDIR))?;
1362
1363    Ok((dir, created))
1364}
1365
1366/// Get the spec subdirectory path for a given project config directory.
1367///
1368/// This is a testable version that allows specifying a custom project config directory path.
1369/// Unlike the real `spec_dir()`, this doesn't perform filesystem operations or require
1370/// the directory to exist.
1371#[cfg(test)]
1372fn spec_dir_at(project_config_dir: &std::path::Path) -> PathBuf {
1373    project_config_dir.join(SPEC_SUBDIR)
1374}
1375
1376/// Check if a file path is within a given config directory.
1377///
1378/// This is a testable version of `is_in_config_dir` that allows specifying a custom
1379/// base config directory path instead of using the real `~/.config/autom8` directory.
1380/// Handles path canonicalization like the original function.
1381#[cfg(test)]
1382fn is_in_config_dir_at(
1383    base_config_dir: &std::path::Path,
1384    file_path: &std::path::Path,
1385) -> Result<bool> {
1386    // Canonicalize both paths to handle relative paths and symlinks
1387    let canonical_file = file_path
1388        .canonicalize()
1389        .unwrap_or_else(|_| file_path.to_path_buf());
1390    let canonical_config = base_config_dir
1391        .canonicalize()
1392        .unwrap_or_else(|_| base_config_dir.to_path_buf());
1393
1394    Ok(canonical_file.starts_with(&canonical_config))
1395}
1396
1397/// Move a file to a specified spec directory if it's not already there.
1398///
1399/// This is a testable version of `move_to_config_dir` that allows specifying a custom
1400/// destination spec directory instead of using the real `~/.config/autom8/<project>/spec/` directory.
1401///
1402/// Uses `fs::rename()` when possible, falls back to copy+delete for cross-filesystem moves.
1403///
1404/// Returns `MoveResult` with the destination path and whether the file was moved.
1405#[cfg(test)]
1406fn move_to_config_dir_at(
1407    dest_spec_dir: &std::path::Path,
1408    file_path: &std::path::Path,
1409) -> Result<MoveResult> {
1410    // If already in the destination spec directory, return original path
1411    if is_in_config_dir_at(dest_spec_dir, file_path)? {
1412        let canonical = file_path
1413            .canonicalize()
1414            .unwrap_or_else(|_| file_path.to_path_buf());
1415        return Ok(MoveResult {
1416            dest_path: canonical,
1417            was_moved: false,
1418        });
1419    }
1420
1421    // Ensure destination directory exists
1422    fs::create_dir_all(dest_spec_dir)?;
1423
1424    // Get filename and create destination path
1425    let filename = file_path
1426        .file_name()
1427        .ok_or_else(|| Autom8Error::Config("Could not determine filename".to_string()))?;
1428    let dest_path = dest_spec_dir.join(filename);
1429
1430    // Try rename first (fast, atomic), fall back to copy+delete for cross-filesystem
1431    if fs::rename(file_path, &dest_path).is_err() {
1432        // Cross-filesystem move: copy then delete original
1433        fs::copy(file_path, &dest_path)?;
1434        fs::remove_file(file_path)?;
1435    }
1436
1437    Ok(MoveResult {
1438        dest_path,
1439        was_moved: true,
1440    })
1441}
1442
1443#[cfg(test)]
1444mod tests {
1445    use super::*;
1446    use tempfile::TempDir;
1447
1448    #[test]
1449    fn test_ensure_config_dir_at_creates_directory() {
1450        let temp_dir = TempDir::new().unwrap();
1451        let expected_path = temp_dir.path().join(".config").join("autom8");
1452        assert!(!expected_path.exists());
1453
1454        let (path, created) = ensure_config_dir_at(temp_dir.path()).unwrap();
1455
1456        assert_eq!(path, expected_path);
1457        assert!(created);
1458        assert!(expected_path.exists());
1459        assert!(expected_path.is_dir());
1460    }
1461
1462    #[test]
1463    fn test_ensure_config_dir_at_reports_existing_directory() {
1464        let temp_dir = TempDir::new().unwrap();
1465        let expected_path = temp_dir.path().join(".config").join("autom8");
1466
1467        // Create the directory first
1468        fs::create_dir_all(&expected_path).unwrap();
1469        assert!(expected_path.exists());
1470
1471        let (path, created) = ensure_config_dir_at(temp_dir.path()).unwrap();
1472
1473        assert_eq!(path, expected_path);
1474        assert!(!created); // Directory already existed
1475        assert!(expected_path.exists());
1476    }
1477
1478    #[test]
1479    fn test_ensure_config_dir_at_creates_parent_directories() {
1480        let temp_dir = TempDir::new().unwrap();
1481
1482        // Neither .config nor .config/autom8 should exist initially
1483        let config_path = temp_dir.path().join(".config");
1484        assert!(!config_path.exists());
1485
1486        let (path, created) = ensure_config_dir_at(temp_dir.path()).unwrap();
1487
1488        assert!(created);
1489        assert!(path.exists());
1490        assert!(config_path.exists()); // Parent was also created
1491    }
1492
1493    #[test]
1494    fn test_spec_dir_at_returns_spec_subdirectory() {
1495        let project_config_dir = PathBuf::from("/some/project/config/dir");
1496        let result = spec_dir_at(&project_config_dir);
1497        assert_eq!(result, PathBuf::from("/some/project/config/dir/spec"));
1498    }
1499
1500    #[test]
1501    fn test_spec_dir_at_with_temp_dir() {
1502        let temp_dir = TempDir::new().unwrap();
1503        let (project_dir, _) =
1504            ensure_project_config_dir_at(temp_dir.path(), "test-project").unwrap();
1505
1506        let spec_dir = spec_dir_at(&project_dir);
1507
1508        // Verify it points to the spec subdirectory
1509        assert_eq!(spec_dir, project_dir.join("spec"));
1510        // Since ensure_project_config_dir_at creates the spec dir, it should exist
1511        assert!(spec_dir.exists());
1512        assert!(spec_dir.is_dir());
1513    }
1514
1515    #[test]
1516    fn test_is_in_config_dir_at_returns_true_for_file_inside_config() {
1517        let temp_dir = TempDir::new().unwrap();
1518        let config_dir = temp_dir.path().join("config");
1519        fs::create_dir_all(&config_dir).unwrap();
1520
1521        let file_inside = config_dir.join("subdir").join("file.txt");
1522        fs::create_dir_all(file_inside.parent().unwrap()).unwrap();
1523        fs::write(&file_inside, "test").unwrap();
1524
1525        let result = is_in_config_dir_at(&config_dir, &file_inside).unwrap();
1526        assert!(result);
1527    }
1528
1529    #[test]
1530    fn test_is_in_config_dir_at_returns_false_for_file_outside_config() {
1531        let temp_dir = TempDir::new().unwrap();
1532        let config_dir = temp_dir.path().join("config");
1533        let other_dir = temp_dir.path().join("other");
1534        fs::create_dir_all(&config_dir).unwrap();
1535        fs::create_dir_all(&other_dir).unwrap();
1536
1537        let file_outside = other_dir.join("file.txt");
1538        fs::write(&file_outside, "test").unwrap();
1539
1540        let result = is_in_config_dir_at(&config_dir, &file_outside).unwrap();
1541        assert!(!result);
1542    }
1543
1544    #[test]
1545    fn test_is_in_config_dir_at_handles_nonexistent_path() {
1546        let temp_dir = TempDir::new().unwrap();
1547        let config_dir = temp_dir.path().join("config");
1548        fs::create_dir_all(&config_dir).unwrap();
1549
1550        // Use canonicalized config_dir to construct path - mimics real usage
1551        // where paths are typically derived from the config dir itself
1552        let canonical_config = config_dir.canonicalize().unwrap();
1553        let nonexistent_file = canonical_config.join("does_not_exist.txt");
1554
1555        // Should return true because the path starts with canonicalized config_dir
1556        let result = is_in_config_dir_at(&config_dir, &nonexistent_file).unwrap();
1557        assert!(result);
1558    }
1559
1560    #[test]
1561    fn test_move_to_config_dir_at_moves_file_to_dest_dir() {
1562        let temp_dir = TempDir::new().unwrap();
1563        let source_dir = temp_dir.path().join("source");
1564        let dest_spec_dir = temp_dir.path().join("dest_config").join("spec");
1565        fs::create_dir_all(&source_dir).unwrap();
1566
1567        let source_file = source_dir.join("test-file.json");
1568        let content = r#"{"test": "data"}"#;
1569        fs::write(&source_file, content).unwrap();
1570
1571        let result = move_to_config_dir_at(&dest_spec_dir, &source_file).unwrap();
1572
1573        assert!(result.was_moved, "File should have been moved");
1574        assert!(result.dest_path.exists(), "Destination file should exist");
1575        assert!(
1576            !source_file.exists(),
1577            "Source file should be deleted after move"
1578        );
1579        assert!(
1580            result.dest_path.starts_with(&dest_spec_dir),
1581            "File should be in the specified dest_spec_dir"
1582        );
1583        assert_eq!(
1584            fs::read_to_string(&result.dest_path).unwrap(),
1585            content,
1586            "Content should match"
1587        );
1588    }
1589
1590    #[test]
1591    fn test_move_to_config_dir_at_returns_unchanged_if_already_in_dest() {
1592        let temp_dir = TempDir::new().unwrap();
1593        let dest_spec_dir = temp_dir.path().join("config").join("spec");
1594        fs::create_dir_all(&dest_spec_dir).unwrap();
1595
1596        let existing_file = dest_spec_dir.join("already-here.md");
1597        fs::write(&existing_file, "# Already here").unwrap();
1598
1599        let result = move_to_config_dir_at(&dest_spec_dir, &existing_file).unwrap();
1600
1601        assert!(!result.was_moved, "File should not have been moved");
1602        assert!(
1603            existing_file.exists(),
1604            "File should still exist in original location"
1605        );
1606        assert_eq!(
1607            result.dest_path.canonicalize().unwrap(),
1608            existing_file.canonicalize().unwrap(),
1609            "Path should be the canonical original"
1610        );
1611    }
1612
1613    #[test]
1614    fn test_move_to_config_dir_at_preserves_filename() {
1615        let temp_dir = TempDir::new().unwrap();
1616        let source_dir = temp_dir.path().join("source");
1617        let dest_spec_dir = temp_dir.path().join("config").join("spec");
1618        fs::create_dir_all(&source_dir).unwrap();
1619
1620        let source_file = source_dir.join("my-custom-filename.txt");
1621        fs::write(&source_file, "test content").unwrap();
1622
1623        let result = move_to_config_dir_at(&dest_spec_dir, &source_file).unwrap();
1624
1625        assert_eq!(
1626            result.dest_path.file_name().unwrap().to_str().unwrap(),
1627            "my-custom-filename.txt",
1628            "Filename should be preserved"
1629        );
1630    }
1631
1632    #[test]
1633    fn test_move_to_config_dir_at_creates_dest_dir_if_missing() {
1634        let temp_dir = TempDir::new().unwrap();
1635        let source_dir = temp_dir.path().join("source");
1636        let dest_spec_dir = temp_dir
1637            .path()
1638            .join("nonexistent")
1639            .join("nested")
1640            .join("spec");
1641        fs::create_dir_all(&source_dir).unwrap();
1642        // Note: dest_spec_dir does not exist yet
1643
1644        let source_file = source_dir.join("test.md");
1645        fs::write(&source_file, "# Test").unwrap();
1646
1647        let result = move_to_config_dir_at(&dest_spec_dir, &source_file).unwrap();
1648
1649        assert!(result.was_moved, "File should have been moved");
1650        assert!(
1651            dest_spec_dir.exists(),
1652            "Destination directory should be created"
1653        );
1654        assert!(result.dest_path.exists(), "Destination file should exist");
1655    }
1656
1657    #[test]
1658    fn test_ensure_project_config_dir_at_creates_all_subdirs() {
1659        let temp_dir = TempDir::new().unwrap();
1660        let project_name = "test-project";
1661
1662        let (path, created) = ensure_project_config_dir_at(temp_dir.path(), project_name).unwrap();
1663
1664        assert!(created);
1665        assert!(path.exists());
1666        assert!(path.ends_with(project_name));
1667
1668        // Verify all subdirectories were created
1669        assert!(path.join("spec").exists());
1670        assert!(path.join("spec").is_dir());
1671        assert!(path.join("runs").exists());
1672        assert!(path.join("runs").is_dir());
1673    }
1674
1675    #[test]
1676    fn test_ensure_project_config_dir_at_reports_existing() {
1677        let temp_dir = TempDir::new().unwrap();
1678        let project_name = "existing-project";
1679
1680        // Create the directory first
1681        let (path1, created1) =
1682            ensure_project_config_dir_at(temp_dir.path(), project_name).unwrap();
1683        assert!(created1);
1684
1685        // Call again - should report as existing
1686        let (path2, created2) =
1687            ensure_project_config_dir_at(temp_dir.path(), project_name).unwrap();
1688        assert!(!created2);
1689        assert_eq!(path1, path2);
1690    }
1691
1692    #[test]
1693    fn test_ensure_project_config_dir_at_different_projects_share_nothing() {
1694        let temp_dir = TempDir::new().unwrap();
1695
1696        let (path1, _) = ensure_project_config_dir_at(temp_dir.path(), "project-a").unwrap();
1697        let (path2, _) = ensure_project_config_dir_at(temp_dir.path(), "project-b").unwrap();
1698
1699        // Each project has its own directory
1700        assert_ne!(path1, path2);
1701        assert!(path1.exists());
1702        assert!(path2.exists());
1703
1704        // Each has its own subdirs
1705        assert!(path1.join("spec").exists());
1706        assert!(path2.join("spec").exists());
1707    }
1708
1709    #[test]
1710    fn test_ensure_project_config_dir_creates_directory_structure() {
1711        // Use TempDir for isolation - does not touch ~/.config/autom8/
1712        let temp_dir = TempDir::new().unwrap();
1713
1714        let result = ensure_project_config_dir_at(temp_dir.path(), "test-project");
1715        assert!(result.is_ok());
1716        let (path, created) = result.unwrap();
1717
1718        // Verify the directory was created
1719        assert!(created);
1720
1721        // Verify structure
1722        assert!(path.exists());
1723        assert!(path.join("spec").exists());
1724        assert!(path.join("runs").exists());
1725    }
1726
1727    #[test]
1728    fn test_is_in_config_dir_true_for_file_in_config() {
1729        // Create a file inside a temp config directory
1730        let temp_dir = TempDir::new().unwrap();
1731        let config_dir = temp_dir.path().join("config");
1732        fs::create_dir_all(&config_dir).unwrap();
1733        let test_file = config_dir.join("test.json");
1734        fs::write(&test_file, "{}").unwrap();
1735
1736        let result = is_in_config_dir_at(&config_dir, &test_file).unwrap();
1737        assert!(result, "File in config dir should return true");
1738    }
1739
1740    #[test]
1741    fn test_is_in_config_dir_false_for_file_outside_config() {
1742        let temp_dir = TempDir::new().unwrap();
1743        let test_file = temp_dir.path().join("test.json");
1744        fs::write(&test_file, "{}").unwrap();
1745
1746        let result = is_in_config_dir(&test_file).unwrap();
1747        assert!(!result, "File outside config dir should return false");
1748    }
1749
1750    #[test]
1751    fn test_is_in_config_dir_true_for_file_in_subdirectory() {
1752        // Create a file in a subdirectory of config
1753        let temp_dir = TempDir::new().unwrap();
1754        let config_dir = temp_dir.path().join("config");
1755        let spec_dir = config_dir.join("spec");
1756        fs::create_dir_all(&spec_dir).unwrap();
1757        let test_file = spec_dir.join("test.md");
1758        fs::write(&test_file, "# Test").unwrap();
1759
1760        let result = is_in_config_dir_at(&config_dir, &test_file).unwrap();
1761        assert!(result, "File in config subdirectory should return true");
1762    }
1763
1764    #[test]
1765    fn test_is_in_config_dir_true_for_file_in_different_project() {
1766        // Create a file in a different project's directory within the config area
1767        // This simulates a file in a worktree-named project directory
1768        let temp_dir = TempDir::new().unwrap();
1769        let config_dir = temp_dir.path().join("config");
1770        let other_project_spec_dir = config_dir
1771            .join("some-other-project-wt-feature")
1772            .join("spec");
1773        fs::create_dir_all(&other_project_spec_dir).unwrap();
1774        let test_file = other_project_spec_dir.join("test.md");
1775        fs::write(&test_file, "# Test").unwrap();
1776
1777        let result = is_in_config_dir_at(&config_dir, &test_file).unwrap();
1778        assert!(
1779            result,
1780            "File in different project's config dir should return true"
1781        );
1782    }
1783
1784    #[test]
1785    fn test_move_to_config_dir_moves_md_to_spec() {
1786        let temp_dir = TempDir::new().unwrap();
1787        let source_dir = temp_dir.path().join("source");
1788        let dest_spec_dir = temp_dir.path().join("config").join("spec");
1789        fs::create_dir_all(&source_dir).unwrap();
1790
1791        let source_file = source_dir.join("test-spec.md");
1792        let content = "# Test Spec\n\nThis is a test.";
1793        fs::write(&source_file, content).unwrap();
1794
1795        let result = move_to_config_dir_at(&dest_spec_dir, &source_file).unwrap();
1796
1797        assert!(result.was_moved, "File should have been moved");
1798        assert!(result.dest_path.exists(), "Destination file should exist");
1799        assert!(
1800            !source_file.exists(),
1801            "Source file should be deleted after move"
1802        );
1803        assert!(
1804            result.dest_path.parent().unwrap().ends_with("spec"),
1805            "MD files should go to spec/ directory"
1806        );
1807        assert_eq!(
1808            fs::read_to_string(&result.dest_path).unwrap(),
1809            content,
1810            "Content should match"
1811        );
1812        // No cleanup needed - TempDir handles it
1813    }
1814
1815    #[test]
1816    fn test_move_to_config_dir_no_move_if_already_in_config() {
1817        // Create a file already in the destination spec directory
1818        let temp_dir = TempDir::new().unwrap();
1819        let dest_spec_dir = temp_dir.path().join("config").join("spec");
1820        fs::create_dir_all(&dest_spec_dir).unwrap();
1821
1822        let existing_file = dest_spec_dir.join("existing-test.md");
1823        fs::write(&existing_file, "# Already here").unwrap();
1824
1825        let result = move_to_config_dir_at(&dest_spec_dir, &existing_file).unwrap();
1826
1827        assert!(!result.was_moved, "File should not have been moved");
1828        assert!(
1829            existing_file.exists(),
1830            "File should still exist in original location"
1831        );
1832        assert_eq!(
1833            result.dest_path.canonicalize().unwrap(),
1834            existing_file.canonicalize().unwrap(),
1835            "Path should be the original"
1836        );
1837        // No cleanup needed - TempDir handles it
1838    }
1839
1840    #[test]
1841    fn test_move_to_config_dir_unknown_extension_goes_to_spec() {
1842        let temp_dir = TempDir::new().unwrap();
1843        let source_dir = temp_dir.path().join("source");
1844        let dest_spec_dir = temp_dir.path().join("config").join("spec");
1845        fs::create_dir_all(&source_dir).unwrap();
1846
1847        let source_file = source_dir.join("test-file.txt");
1848        fs::write(&source_file, "Some content").unwrap();
1849
1850        let result = move_to_config_dir_at(&dest_spec_dir, &source_file).unwrap();
1851
1852        assert!(result.was_moved, "File should have been moved");
1853        assert!(
1854            !source_file.exists(),
1855            "Source file should be deleted after move"
1856        );
1857        assert!(
1858            result.dest_path.parent().unwrap().ends_with("spec"),
1859            "Unknown extensions should default to spec/ directory"
1860        );
1861        // No cleanup needed - TempDir handles it
1862    }
1863
1864    #[test]
1865    fn test_move_result_struct() {
1866        // Verify MoveResult fields work correctly
1867        let result = MoveResult {
1868            dest_path: PathBuf::from("/test/path"),
1869            was_moved: true,
1870        };
1871        assert_eq!(result.dest_path, PathBuf::from("/test/path"));
1872        assert!(result.was_moved);
1873    }
1874
1875    #[test]
1876    fn test_list_projects_empty_when_no_projects() {
1877        let temp_dir = TempDir::new().unwrap();
1878        let config_dir = temp_dir.path().join(".config").join("autom8");
1879        fs::create_dir_all(&config_dir).unwrap();
1880
1881        let projects = list_projects_at(&config_dir).unwrap();
1882        assert!(
1883            projects.is_empty(),
1884            "Should return empty list when no projects exist"
1885        );
1886    }
1887
1888    #[test]
1889    fn test_list_projects_returns_sorted_list() {
1890        let temp_dir = TempDir::new().unwrap();
1891        let config_dir = temp_dir.path().join(".config").join("autom8");
1892
1893        // Create projects in non-alphabetical order
1894        fs::create_dir_all(config_dir.join("zebra")).unwrap();
1895        fs::create_dir_all(config_dir.join("alpha")).unwrap();
1896        fs::create_dir_all(config_dir.join("mango")).unwrap();
1897
1898        let projects = list_projects_at(&config_dir).unwrap();
1899
1900        assert_eq!(projects.len(), 3);
1901        assert_eq!(projects[0], "alpha", "First project should be 'alpha'");
1902        assert_eq!(projects[1], "mango", "Second project should be 'mango'");
1903        assert_eq!(projects[2], "zebra", "Third project should be 'zebra'");
1904    }
1905
1906    #[test]
1907    fn test_list_projects_ignores_files() {
1908        let temp_dir = TempDir::new().unwrap();
1909        let config_dir = temp_dir.path().join(".config").join("autom8");
1910        fs::create_dir_all(&config_dir).unwrap();
1911
1912        // Create a project directory and a file
1913        fs::create_dir_all(config_dir.join("my-project")).unwrap();
1914        fs::write(config_dir.join("some-file.txt"), "not a project").unwrap();
1915
1916        let projects = list_projects_at(&config_dir).unwrap();
1917
1918        assert_eq!(projects.len(), 1, "Should only include directories");
1919        assert_eq!(projects[0], "my-project");
1920    }
1921
1922    #[test]
1923    fn test_list_projects_empty_when_dir_does_not_exist() {
1924        let temp_dir = TempDir::new().unwrap();
1925        let non_existent_dir = temp_dir.path().join("does-not-exist");
1926
1927        let projects = list_projects_at(&non_existent_dir).unwrap();
1928        assert!(
1929            projects.is_empty(),
1930            "Should return empty list for non-existent directory"
1931        );
1932    }
1933
1934    // ========================================================================
1935    // US-010: Global status tests
1936    // ========================================================================
1937
1938    #[test]
1939    fn test_project_status_needs_attention_with_active_run() {
1940        let status = ProjectStatus {
1941            name: "test-project".to_string(),
1942            has_active_run: true,
1943            run_status: Some(crate::state::RunStatus::Running),
1944            incomplete_spec_count: 0,
1945            total_spec_count: 0,
1946        };
1947        assert!(status.needs_attention(), "Active run should need attention");
1948        assert!(!status.is_idle());
1949    }
1950
1951    #[test]
1952    fn test_project_status_needs_attention_with_failed_run() {
1953        let status = ProjectStatus {
1954            name: "test-project".to_string(),
1955            has_active_run: false,
1956            run_status: Some(crate::state::RunStatus::Failed),
1957            incomplete_spec_count: 0,
1958            total_spec_count: 0,
1959        };
1960        assert!(status.needs_attention(), "Failed run should need attention");
1961        assert!(!status.is_idle());
1962    }
1963
1964    #[test]
1965    fn test_project_status_needs_attention_with_incomplete_specs() {
1966        let status = ProjectStatus {
1967            name: "test-project".to_string(),
1968            has_active_run: false,
1969            run_status: None,
1970            incomplete_spec_count: 2,
1971            total_spec_count: 3,
1972        };
1973        assert!(
1974            status.needs_attention(),
1975            "Incomplete specs should need attention"
1976        );
1977        assert!(!status.is_idle());
1978    }
1979
1980    #[test]
1981    fn test_project_status_idle_when_no_work() {
1982        let status = ProjectStatus {
1983            name: "test-project".to_string(),
1984            has_active_run: false,
1985            run_status: Some(crate::state::RunStatus::Completed),
1986            incomplete_spec_count: 0,
1987            total_spec_count: 1,
1988        };
1989        assert!(
1990            !status.needs_attention(),
1991            "Completed project should not need attention"
1992        );
1993        assert!(status.is_idle());
1994    }
1995
1996    #[test]
1997    fn test_project_status_idle_when_no_runs_no_specs() {
1998        let status = ProjectStatus {
1999            name: "test-project".to_string(),
2000            has_active_run: false,
2001            run_status: None,
2002            incomplete_spec_count: 0,
2003            total_spec_count: 0,
2004        };
2005        assert!(!status.needs_attention());
2006        assert!(status.is_idle());
2007    }
2008
2009    #[test]
2010    fn test_global_status_empty_when_no_projects() {
2011        let temp_dir = TempDir::new().unwrap();
2012        let config_dir = temp_dir.path().join(".config").join("autom8");
2013        fs::create_dir_all(&config_dir).unwrap();
2014
2015        let statuses = global_status_at(&config_dir).unwrap();
2016        assert!(
2017            statuses.is_empty(),
2018            "Should return empty list when no projects exist"
2019        );
2020    }
2021
2022    #[test]
2023    fn test_global_status_returns_all_projects() {
2024        let temp_dir = TempDir::new().unwrap();
2025        let config_dir = temp_dir.path().join(".config").join("autom8");
2026
2027        // Create project directories with spec subdirs
2028        fs::create_dir_all(config_dir.join("project-a").join("spec")).unwrap();
2029        fs::create_dir_all(config_dir.join("project-b").join("spec")).unwrap();
2030
2031        let statuses = global_status_at(&config_dir).unwrap();
2032
2033        assert_eq!(statuses.len(), 2);
2034        assert_eq!(statuses[0].name, "project-a");
2035        assert_eq!(statuses[1].name, "project-b");
2036    }
2037
2038    #[test]
2039    fn test_global_status_detects_active_run() {
2040        use crate::state::{RunState, StateManager};
2041
2042        let temp_dir = TempDir::new().unwrap();
2043        let config_dir = temp_dir.path().join(".config").join("autom8");
2044        let project_dir = config_dir.join("active-project");
2045        fs::create_dir_all(project_dir.join("spec")).unwrap();
2046
2047        // Create an active run
2048        let sm = StateManager::with_dir(project_dir);
2049        let run_state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
2050        sm.save(&run_state).unwrap();
2051
2052        let statuses = global_status_at(&config_dir).unwrap();
2053
2054        assert_eq!(statuses.len(), 1);
2055        assert!(statuses[0].has_active_run);
2056        assert_eq!(
2057            statuses[0].run_status,
2058            Some(crate::state::RunStatus::Running)
2059        );
2060    }
2061
2062    #[test]
2063    fn test_global_status_counts_incomplete_specs() {
2064        let temp_dir = TempDir::new().unwrap();
2065        let config_dir = temp_dir.path().join(".config").join("autom8");
2066        let project_dir = config_dir.join("spec-project");
2067        let spec_dir = project_dir.join("spec");
2068        fs::create_dir_all(&spec_dir).unwrap();
2069
2070        // Create an incomplete PRD
2071        let incomplete_prd = r#"{
2072            "project": "Test Project",
2073            "branchName": "test",
2074            "description": "Test",
2075            "userStories": [
2076                {"id": "US-001", "title": "Story 1", "description": "Desc", "acceptanceCriteria": [], "priority": 1, "passes": false}
2077            ]
2078        }"#;
2079        fs::write(spec_dir.join("spec-test.json"), incomplete_prd).unwrap();
2080
2081        // Create a complete PRD
2082        let complete_prd = r#"{
2083            "project": "Complete Project",
2084            "branchName": "test",
2085            "description": "Test",
2086            "userStories": [
2087                {"id": "US-001", "title": "Story 1", "description": "Desc", "acceptanceCriteria": [], "priority": 1, "passes": true}
2088            ]
2089        }"#;
2090        fs::write(spec_dir.join("spec-complete.json"), complete_prd).unwrap();
2091
2092        let statuses = global_status_at(&config_dir).unwrap();
2093
2094        assert_eq!(statuses.len(), 1);
2095        assert_eq!(statuses[0].incomplete_spec_count, 1);
2096        assert_eq!(statuses[0].total_spec_count, 2);
2097    }
2098
2099    // ========================================================================
2100    // US-007: Project tree view tests
2101    // ========================================================================
2102
2103    #[test]
2104    fn test_project_tree_info_status_label_running() {
2105        let info = ProjectTreeInfo {
2106            name: "test".to_string(),
2107            has_active_run: true,
2108            run_status: Some(crate::state::RunStatus::Running),
2109            spec_count: 1,
2110            incomplete_spec_count: 0,
2111            spec_md_count: 0,
2112            runs_count: 0,
2113            last_run_date: None,
2114        };
2115        assert_eq!(info.status_label(), "running");
2116    }
2117
2118    #[test]
2119    fn test_project_tree_info_status_label_failed() {
2120        let info = ProjectTreeInfo {
2121            name: "test".to_string(),
2122            has_active_run: false,
2123            run_status: Some(crate::state::RunStatus::Failed),
2124            spec_count: 1,
2125            incomplete_spec_count: 0,
2126            spec_md_count: 0,
2127            runs_count: 0,
2128            last_run_date: None,
2129        };
2130        assert_eq!(info.status_label(), "failed");
2131    }
2132
2133    #[test]
2134    fn test_project_tree_info_status_label_incomplete() {
2135        let info = ProjectTreeInfo {
2136            name: "test".to_string(),
2137            has_active_run: false,
2138            run_status: None,
2139            spec_count: 2,
2140            incomplete_spec_count: 1,
2141            spec_md_count: 0,
2142            runs_count: 0,
2143            last_run_date: None,
2144        };
2145        assert_eq!(info.status_label(), "incomplete");
2146    }
2147
2148    #[test]
2149    fn test_project_tree_info_status_label_complete() {
2150        let info = ProjectTreeInfo {
2151            name: "test".to_string(),
2152            has_active_run: false,
2153            run_status: None,
2154            spec_count: 2,
2155            incomplete_spec_count: 0,
2156            spec_md_count: 1,
2157            runs_count: 0,
2158            last_run_date: None,
2159        };
2160        assert_eq!(info.status_label(), "complete");
2161    }
2162
2163    #[test]
2164    fn test_project_tree_info_status_label_empty() {
2165        let info = ProjectTreeInfo {
2166            name: "test".to_string(),
2167            has_active_run: false,
2168            run_status: None,
2169            spec_count: 0,
2170            incomplete_spec_count: 0,
2171            spec_md_count: 0,
2172            runs_count: 0,
2173            last_run_date: None,
2174        };
2175        assert_eq!(info.status_label(), "empty");
2176    }
2177
2178    #[test]
2179    fn test_project_tree_info_has_content_true() {
2180        let info = ProjectTreeInfo {
2181            name: "test".to_string(),
2182            has_active_run: false,
2183            run_status: None,
2184            spec_count: 1,
2185            incomplete_spec_count: 0,
2186            spec_md_count: 0,
2187            runs_count: 0,
2188            last_run_date: None,
2189        };
2190        assert!(info.has_content());
2191    }
2192
2193    #[test]
2194    fn test_project_tree_info_has_content_false() {
2195        let info = ProjectTreeInfo {
2196            name: "test".to_string(),
2197            has_active_run: false,
2198            run_status: None,
2199            spec_count: 0,
2200            incomplete_spec_count: 0,
2201            spec_md_count: 0,
2202            runs_count: 0,
2203            last_run_date: None,
2204        };
2205        assert!(!info.has_content());
2206    }
2207
2208    #[test]
2209    fn test_project_tree_info_has_content_with_active_run() {
2210        let info = ProjectTreeInfo {
2211            name: "test".to_string(),
2212            has_active_run: true,
2213            run_status: Some(crate::state::RunStatus::Running),
2214            spec_count: 0,
2215            incomplete_spec_count: 0,
2216            spec_md_count: 0,
2217            runs_count: 0,
2218            last_run_date: None,
2219        };
2220        assert!(info.has_content());
2221    }
2222
2223    // ========================================================================
2224    // US-008: Describe command tests
2225    // ========================================================================
2226
2227    #[test]
2228    fn test_us008_project_exists_false_for_nonexistent() {
2229        let result = project_exists("nonexistent-project-xyz-12345");
2230        assert!(result.is_ok());
2231        assert!(!result.unwrap(), "nonexistent project should return false");
2232    }
2233
2234    #[test]
2235    fn test_us008_get_project_description_nonexistent_project() {
2236        // Test getting description for a nonexistent project
2237        let result = get_project_description("nonexistent-project-xyz-12345");
2238        assert!(result.is_ok());
2239        assert!(
2240            result.unwrap().is_none(),
2241            "nonexistent project should return None"
2242        );
2243    }
2244
2245    #[test]
2246    fn test_us008_spec_summary_struct_fields() {
2247        // Verify SpecSummary struct has all fields
2248        let summary = SpecSummary {
2249            filename: "test.json".to_string(),
2250            path: PathBuf::from("/test"),
2251            project_name: "Test Project".to_string(),
2252            branch_name: "feature/test".to_string(),
2253            description: "Test description".to_string(),
2254            stories: vec![StorySummary {
2255                id: "US-001".to_string(),
2256                title: "Test Story".to_string(),
2257                passes: true,
2258            }],
2259            completed_count: 1,
2260            total_count: 1,
2261            is_active: false,
2262        };
2263
2264        assert_eq!(summary.filename, "test.json");
2265        assert_eq!(summary.project_name, "Test Project");
2266        assert_eq!(summary.branch_name, "feature/test");
2267        assert_eq!(summary.completed_count, 1);
2268        assert_eq!(summary.total_count, 1);
2269        assert!(!summary.is_active);
2270    }
2271
2272    #[test]
2273    fn test_us008_story_summary_struct_fields() {
2274        // Verify StorySummary struct has all fields
2275        let story = StorySummary {
2276            id: "US-001".to_string(),
2277            title: "Test Story".to_string(),
2278            passes: false,
2279        };
2280
2281        assert_eq!(story.id, "US-001");
2282        assert_eq!(story.title, "Test Story");
2283        assert!(!story.passes);
2284    }
2285
2286    // ========================================================================
2287    // US-001: Config struct tests
2288    // ========================================================================
2289
2290    #[test]
2291    fn test_config_default_all_true() {
2292        let config = Config::default();
2293        assert!(config.review, "review should default to true");
2294        assert!(config.commit, "commit should default to true");
2295        assert!(config.pull_request, "pull_request should default to true");
2296        assert!(config.worktree, "worktree should default to true");
2297    }
2298
2299    #[test]
2300    fn test_config_serialize_to_toml() {
2301        let config = Config::default();
2302        let toml_str = toml::to_string(&config).unwrap();
2303
2304        assert!(toml_str.contains("review = true"));
2305        assert!(toml_str.contains("commit = true"));
2306        assert!(toml_str.contains("pull_request = true"));
2307        assert!(toml_str.contains("worktree = true"));
2308    }
2309
2310    #[test]
2311    fn test_config_deserialize_from_toml() {
2312        let toml_str = r#"
2313            review = false
2314            commit = true
2315            pull_request = false
2316            worktree = true
2317        "#;
2318
2319        let config: Config = toml::from_str(toml_str).unwrap();
2320
2321        assert!(!config.review);
2322        assert!(config.commit);
2323        assert!(!config.pull_request);
2324        assert!(config.worktree);
2325    }
2326
2327    #[test]
2328    fn test_config_deserialize_partial_toml_uses_defaults() {
2329        // Only specify one field - others should default to their respective defaults
2330        let toml_str = r#"
2331            commit = false
2332        "#;
2333
2334        let config: Config = toml::from_str(toml_str).unwrap();
2335
2336        assert!(config.review, "missing review should default to true");
2337        assert!(!config.commit, "commit should be false as specified");
2338        assert!(
2339            config.pull_request,
2340            "missing pull_request should default to true"
2341        );
2342        assert!(config.worktree, "missing worktree should default to true");
2343    }
2344
2345    #[test]
2346    fn test_config_deserialize_empty_toml_uses_all_defaults() {
2347        let toml_str = "";
2348
2349        let config: Config = toml::from_str(toml_str).unwrap();
2350
2351        assert!(config.review);
2352        assert!(config.commit);
2353        assert!(config.pull_request);
2354        assert!(config.worktree);
2355    }
2356
2357    #[test]
2358    fn test_config_roundtrip() {
2359        let original = Config {
2360            review: false,
2361            commit: true,
2362            pull_request: false,
2363            worktree: true,
2364            ..Default::default()
2365        };
2366
2367        let toml_str = toml::to_string(&original).unwrap();
2368        let deserialized: Config = toml::from_str(&toml_str).unwrap();
2369
2370        assert_eq!(original, deserialized);
2371    }
2372
2373    #[test]
2374    fn test_config_equality() {
2375        let config1 = Config::default();
2376        let config2 = Config::default();
2377        assert_eq!(config1, config2);
2378
2379        let config3 = Config {
2380            review: false,
2381            ..Default::default()
2382        };
2383        assert_ne!(config1, config3);
2384    }
2385
2386    #[test]
2387    fn test_config_clone() {
2388        let original = Config {
2389            review: false,
2390            commit: true,
2391            pull_request: false,
2392            worktree: true,
2393            ..Default::default()
2394        };
2395
2396        let cloned = original.clone();
2397        assert_eq!(original, cloned);
2398    }
2399
2400    #[test]
2401    fn test_config_debug_format() {
2402        let config = Config::default();
2403        let debug_str = format!("{:?}", config);
2404
2405        assert!(debug_str.contains("Config"));
2406        assert!(debug_str.contains("review"));
2407        assert!(debug_str.contains("commit"));
2408        assert!(debug_str.contains("pull_request"));
2409        assert!(debug_str.contains("worktree"));
2410    }
2411
2412    // ========================================================================
2413    // US-002: Global Config File Management tests
2414    // ========================================================================
2415
2416    #[test]
2417    fn test_generate_config_with_comments_includes_all_fields() {
2418        let config = Config::default();
2419        let content = generate_config_with_comments(&config);
2420
2421        // Check that all field values are present
2422        assert!(content.contains("review = true"));
2423        assert!(content.contains("commit = true"));
2424        assert!(content.contains("pull_request = true"));
2425        assert!(content.contains("worktree = true"));
2426    }
2427
2428    #[test]
2429    fn test_generate_config_with_comments_has_explanatory_comments() {
2430        let config = Config::default();
2431        let content = generate_config_with_comments(&config);
2432
2433        // Check that comments explain each option
2434        assert!(content.contains("# Review state"));
2435        assert!(content.contains("# Commit state"));
2436        assert!(content.contains("# Pull request state"));
2437        assert!(content.contains("# Worktree mode"));
2438
2439        // Check that true/false meanings are explained
2440        assert!(content.contains("- true:"));
2441        assert!(content.contains("- false:"));
2442    }
2443
2444    #[test]
2445    fn test_generate_config_with_comments_preserves_custom_values() {
2446        let config = Config {
2447            review: false,
2448            commit: true,
2449            pull_request: false,
2450            worktree: true,
2451            ..Default::default()
2452        };
2453        let content = generate_config_with_comments(&config);
2454
2455        assert!(content.contains("review = false"));
2456        assert!(content.contains("commit = true"));
2457        assert!(content.contains("pull_request = false"));
2458        assert!(content.contains("worktree = true"));
2459    }
2460
2461    #[test]
2462    fn test_default_config_with_comments_is_valid_toml() {
2463        // Verify the default config string can be parsed
2464        let config: Config = toml::from_str(DEFAULT_CONFIG_WITH_COMMENTS).unwrap();
2465
2466        assert!(config.review);
2467        assert!(config.commit);
2468        assert!(config.pull_request);
2469        assert!(config.worktree);
2470    }
2471
2472    #[test]
2473    fn test_load_global_config_creates_file_when_missing() {
2474        let temp_dir = TempDir::new().unwrap();
2475        let config_dir = temp_dir.path().join(".config").join("autom8");
2476        fs::create_dir_all(&config_dir).unwrap();
2477
2478        let config_path = config_dir.join("config.toml");
2479        assert!(
2480            !config_path.exists(),
2481            "Config file should not exist initially"
2482        );
2483
2484        // We can't easily test the real load_global_config because it uses the real home dir,
2485        // but we can test the underlying logic by simulating it
2486        let content = DEFAULT_CONFIG_WITH_COMMENTS;
2487        fs::write(&config_path, content).unwrap();
2488
2489        let loaded: Config = toml::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap();
2490        assert_eq!(loaded, Config::default());
2491    }
2492
2493    #[test]
2494    fn test_save_and_load_global_config_roundtrip() {
2495        let temp_dir = TempDir::new().unwrap();
2496        let config_dir = temp_dir.path().join(".config").join("autom8");
2497        fs::create_dir_all(&config_dir).unwrap();
2498
2499        let config_path = config_dir.join("config.toml");
2500
2501        // Create a custom config
2502        let custom_config = Config {
2503            review: false,
2504            commit: true,
2505            pull_request: false,
2506            ..Default::default()
2507        };
2508
2509        // Write it
2510        let content = generate_config_with_comments(&custom_config);
2511        fs::write(&config_path, content).unwrap();
2512
2513        // Read it back
2514        let loaded: Config = toml::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap();
2515
2516        assert_eq!(loaded, custom_config);
2517    }
2518
2519    #[test]
2520    fn test_load_global_config_handles_partial_config() {
2521        let temp_dir = TempDir::new().unwrap();
2522        let config_dir = temp_dir.path().join(".config").join("autom8");
2523        fs::create_dir_all(&config_dir).unwrap();
2524
2525        let config_path = config_dir.join("config.toml");
2526
2527        // Write a partial config (missing pull_request)
2528        let partial_content = r#"
2529# Partial config
2530review = false
2531commit = true
2532"#;
2533        fs::write(&config_path, partial_content).unwrap();
2534
2535        // Read it back - missing fields should use defaults
2536        let loaded: Config = toml::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap();
2537
2538        assert!(!loaded.review);
2539        assert!(loaded.commit);
2540        assert!(
2541            loaded.pull_request,
2542            "Missing pull_request should default to true"
2543        );
2544    }
2545
2546    #[test]
2547    fn test_generated_config_includes_note_about_pr_requiring_commit() {
2548        let config = Config::default();
2549        let content = generate_config_with_comments(&config);
2550
2551        // The config should mention that PR requires commit
2552        assert!(
2553            content.contains("Requires commit = true"),
2554            "Config should note that PR requires commit"
2555        );
2556    }
2557
2558    #[test]
2559    fn test_global_config_file_has_comments_after_save() {
2560        let temp_dir = TempDir::new().unwrap();
2561        let config_dir = temp_dir.path().join(".config").join("autom8");
2562        fs::create_dir_all(&config_dir).unwrap();
2563
2564        let config_path = config_dir.join("config.toml");
2565
2566        // Save a config
2567        let config = Config::default();
2568        let content = generate_config_with_comments(&config);
2569        fs::write(&config_path, content).unwrap();
2570
2571        // Read raw content and verify comments are present
2572        let raw_content = fs::read_to_string(&config_path).unwrap();
2573        assert!(
2574            raw_content.contains("#"),
2575            "Config file should contain comments"
2576        );
2577        assert!(
2578            raw_content.contains("# Autom8 Configuration"),
2579            "Config file should have header comment"
2580        );
2581    }
2582
2583    // ========================================================================
2584    // US-003: Per-Project Config Inheritance tests
2585    // ========================================================================
2586
2587    #[test]
2588    fn test_us003_project_config_path_for_returns_correct_path() {
2589        let path = project_config_path_for("my-test-project").unwrap();
2590        assert!(path.ends_with("config.toml"));
2591        assert!(path.parent().unwrap().ends_with("my-test-project"));
2592    }
2593
2594    #[test]
2595    fn test_us003_load_project_config_creates_from_global_when_missing() {
2596        // Use temp directory to avoid race conditions
2597        let temp_dir = TempDir::new().unwrap();
2598        let config_dir = temp_dir.path().join(".config").join("autom8");
2599        fs::create_dir_all(&config_dir).unwrap();
2600
2601        // Create global config
2602        let global_config = Config {
2603            review: true,
2604            commit: false,
2605            pull_request: false,
2606            ..Default::default()
2607        };
2608        let global_path = config_dir.join("config.toml");
2609        let global_content = generate_config_with_comments(&global_config);
2610        fs::write(&global_path, &global_content).unwrap();
2611
2612        // Create project directory (no config file yet)
2613        let project_dir = config_dir.join("test-project");
2614        fs::create_dir_all(project_dir.join("spec")).unwrap();
2615        fs::create_dir_all(project_dir.join("runs")).unwrap();
2616
2617        let project_config_path = project_dir.join("config.toml");
2618        assert!(
2619            !project_config_path.exists(),
2620            "Project config should not exist initially"
2621        );
2622
2623        // Simulate load_project_config: when project config doesn't exist,
2624        // copy global config content to project config
2625        fs::write(&project_config_path, &global_content).unwrap();
2626
2627        // Verify project config was created
2628        assert!(
2629            project_config_path.exists(),
2630            "Project config should be created when missing"
2631        );
2632
2633        // Verify it matches global config
2634        let loaded: Config =
2635            toml::from_str(&fs::read_to_string(&project_config_path).unwrap()).unwrap();
2636        assert_eq!(
2637            loaded, global_config,
2638            "Project config should match global config"
2639        );
2640    }
2641
2642    #[test]
2643    fn test_us003_load_project_config_preserves_comments() {
2644        // Use temp directory to avoid race conditions
2645        let temp_dir = TempDir::new().unwrap();
2646        let config_dir = temp_dir.path().join(".config").join("autom8");
2647        let project_dir = config_dir.join("test-project");
2648        fs::create_dir_all(&project_dir).unwrap();
2649
2650        // Create global config (used as source for project config creation)
2651        let global_config = Config::default();
2652        let global_path = config_dir.join("config.toml");
2653        let global_content = generate_config_with_comments(&global_config);
2654        fs::write(&global_path, &global_content).unwrap();
2655
2656        // Simulate load_project_config: copy global to project when missing
2657        let project_config_path = project_dir.join("config.toml");
2658        assert!(!project_config_path.exists());
2659
2660        // Copy global config content to project config (as load_project_config does)
2661        fs::write(&project_config_path, &global_content).unwrap();
2662
2663        // Verify comments are present
2664        let raw_content = fs::read_to_string(&project_config_path).unwrap();
2665
2666        assert!(
2667            raw_content.contains("#"),
2668            "Project config should contain comments"
2669        );
2670        assert!(
2671            raw_content.contains("# Autom8 Configuration"),
2672            "Project config should have header comment"
2673        );
2674        assert!(
2675            raw_content.contains("# Review state"),
2676            "Project config should have review state comment"
2677        );
2678    }
2679
2680    #[test]
2681    fn test_us003_save_project_config_creates_file() {
2682        // Use temp directory to avoid race conditions
2683        let temp_dir = TempDir::new().unwrap();
2684        let config_dir = temp_dir.path().join(".config").join("autom8");
2685        let project_dir = config_dir.join("test-project");
2686        fs::create_dir_all(project_dir.join("spec")).unwrap();
2687        fs::create_dir_all(project_dir.join("runs")).unwrap();
2688
2689        let config = Config {
2690            review: false,
2691            commit: true,
2692            pull_request: true,
2693            ..Default::default()
2694        };
2695
2696        // Simulate save_project_config
2697        let project_config_path = project_dir.join("config.toml");
2698        let content = generate_config_with_comments(&config);
2699        fs::write(&project_config_path, &content).unwrap();
2700
2701        // Verify file exists and can be loaded
2702        assert!(project_config_path.exists());
2703
2704        let loaded: Config =
2705            toml::from_str(&fs::read_to_string(&project_config_path).unwrap()).unwrap();
2706        assert_eq!(loaded, config);
2707    }
2708
2709    #[test]
2710    fn test_us003_save_project_config_preserves_comments() {
2711        // Use temp directory to avoid race conditions
2712        let temp_dir = TempDir::new().unwrap();
2713        let config_dir = temp_dir.path().join(".config").join("autom8");
2714        let project_dir = config_dir.join("test-project");
2715        fs::create_dir_all(&project_dir).unwrap();
2716
2717        let config = Config::default();
2718        let project_config_path = project_dir.join("config.toml");
2719        let content = generate_config_with_comments(&config);
2720        fs::write(&project_config_path, &content).unwrap();
2721
2722        let raw_content = fs::read_to_string(&project_config_path).unwrap();
2723
2724        assert!(
2725            raw_content.contains("#"),
2726            "Saved config should contain comments"
2727        );
2728        assert!(
2729            raw_content.contains("# Autom8 Configuration"),
2730            "Saved config should have header comment"
2731        );
2732    }
2733
2734    #[test]
2735    fn test_us003_get_effective_config_returns_project_if_exists() {
2736        // Use temp directory to avoid race conditions
2737        let temp_dir = TempDir::new().unwrap();
2738        let config_dir = temp_dir.path().join(".config").join("autom8");
2739        fs::create_dir_all(&config_dir).unwrap();
2740
2741        // Create global config first
2742        let global_config = Config::default();
2743        let global_path = config_dir.join("config.toml");
2744        fs::write(&global_path, generate_config_with_comments(&global_config)).unwrap();
2745
2746        // Create project config with distinct values
2747        let project_config = Config {
2748            review: false,
2749            commit: false,
2750            pull_request: false,
2751            ..Default::default()
2752        };
2753        let project_dir = config_dir.join("test-project");
2754        fs::create_dir_all(&project_dir).unwrap();
2755        let project_path = project_dir.join("config.toml");
2756        fs::write(
2757            &project_path,
2758            generate_config_with_comments(&project_config),
2759        )
2760        .unwrap();
2761
2762        // Simulate get_effective_config logic
2763        let effective_path = if project_path.exists() {
2764            &project_path
2765        } else {
2766            &global_path
2767        };
2768
2769        let effective: Config =
2770            toml::from_str(&fs::read_to_string(effective_path).unwrap()).unwrap();
2771        assert_eq!(
2772            effective, project_config,
2773            "Should return project config when it exists"
2774        );
2775    }
2776
2777    #[test]
2778    fn test_us003_get_effective_config_returns_global_when_project_missing() {
2779        let temp_dir = TempDir::new().unwrap();
2780        let config_dir = temp_dir.path().join(".config").join("autom8");
2781        fs::create_dir_all(&config_dir).unwrap();
2782
2783        // Create global config
2784        let global_config = Config {
2785            review: true,
2786            commit: true,
2787            pull_request: false,
2788            ..Default::default()
2789        };
2790        let global_path = config_dir.join("config.toml");
2791        let content = generate_config_with_comments(&global_config);
2792        fs::write(&global_path, content).unwrap();
2793
2794        // Create project dir but NOT project config
2795        let project_dir = config_dir.join("test-project");
2796        fs::create_dir_all(&project_dir).unwrap();
2797
2798        // We can't directly test get_effective_config with temp dirs,
2799        // but we can verify the logic by checking path existence
2800        let project_config_path = project_dir.join("config.toml");
2801        assert!(
2802            !project_config_path.exists(),
2803            "Project config should not exist"
2804        );
2805        assert!(global_path.exists(), "Global config should exist");
2806
2807        // Load global config to verify
2808        let loaded: Config = toml::from_str(&fs::read_to_string(&global_path).unwrap()).unwrap();
2809        assert_eq!(loaded, global_config);
2810    }
2811
2812    #[test]
2813    fn test_us003_project_config_takes_precedence_over_global() {
2814        // Simulate project config overriding global with temp directories
2815        // to avoid race conditions with other tests
2816        let temp_dir = TempDir::new().unwrap();
2817        let config_dir = temp_dir.path().join(".config").join("autom8");
2818        fs::create_dir_all(&config_dir).unwrap();
2819
2820        // Create global config
2821        let global_config = Config {
2822            review: true,
2823            commit: true,
2824            pull_request: true,
2825            ..Default::default()
2826        };
2827        let global_path = config_dir.join("config.toml");
2828        fs::write(&global_path, generate_config_with_comments(&global_config)).unwrap();
2829
2830        // Create project config with different values
2831        let project_config = Config {
2832            review: false,
2833            commit: true,
2834            pull_request: false,
2835            ..Default::default()
2836        };
2837        let project_dir = config_dir.join("my-project");
2838        fs::create_dir_all(&project_dir).unwrap();
2839        let project_path = project_dir.join("config.toml");
2840        fs::write(
2841            &project_path,
2842            generate_config_with_comments(&project_config),
2843        )
2844        .unwrap();
2845
2846        // Simulate get_effective_config logic: prefer project if exists
2847        let effective_path = if project_path.exists() {
2848            &project_path
2849        } else {
2850            &global_path
2851        };
2852
2853        let effective: Config =
2854            toml::from_str(&fs::read_to_string(effective_path).unwrap()).unwrap();
2855        assert_eq!(
2856            effective, project_config,
2857            "Project config should take precedence over global"
2858        );
2859        assert_ne!(
2860            effective, global_config,
2861            "Should not return global config when project config exists"
2862        );
2863    }
2864
2865    #[test]
2866    fn test_us003_get_effective_config_does_not_create_project_config() {
2867        // Use temp directory to test the logic
2868        let temp_dir = TempDir::new().unwrap();
2869        let config_dir = temp_dir.path().join(".config").join("autom8");
2870        fs::create_dir_all(&config_dir).unwrap();
2871
2872        // Create global config
2873        let global_config = Config::default();
2874        let global_path = config_dir.join("config.toml");
2875        fs::write(&global_path, generate_config_with_comments(&global_config)).unwrap();
2876
2877        // Create project dir but NOT project config
2878        let project_dir = config_dir.join("test-project");
2879        fs::create_dir_all(&project_dir).unwrap();
2880        let project_config_path = project_dir.join("config.toml");
2881
2882        // Simulate get_effective_config: it should NOT create project config
2883        assert!(
2884            !project_config_path.exists(),
2885            "Project config should not exist before"
2886        );
2887
2888        // Simulate reading effective config (prefer project if exists, else global)
2889        let effective_path = if project_config_path.exists() {
2890            &project_config_path
2891        } else {
2892            &global_path
2893        };
2894        let _effective: Config =
2895            toml::from_str(&fs::read_to_string(effective_path).unwrap()).unwrap();
2896
2897        // get_effective_config should NOT have created the project config
2898        assert!(
2899            !project_config_path.exists(),
2900            "get_effective_config should NOT create project config"
2901        );
2902    }
2903
2904    #[test]
2905    fn test_us003_project_config_roundtrip() {
2906        // Use temp directory to avoid race conditions
2907        let temp_dir = TempDir::new().unwrap();
2908        let config_dir = temp_dir.path().join(".config").join("autom8");
2909        let project_dir = config_dir.join("test-project");
2910        fs::create_dir_all(&project_dir).unwrap();
2911
2912        let original = Config {
2913            review: false,
2914            commit: true,
2915            pull_request: false,
2916            ..Default::default()
2917        };
2918
2919        // Save
2920        let project_config_path = project_dir.join("config.toml");
2921        let content = generate_config_with_comments(&original);
2922        fs::write(&project_config_path, &content).unwrap();
2923
2924        // Load
2925        let loaded: Config =
2926            toml::from_str(&fs::read_to_string(&project_config_path).unwrap()).unwrap();
2927
2928        assert_eq!(original, loaded, "Config should survive save/load cycle");
2929    }
2930
2931    #[test]
2932    fn test_us003_project_config_handles_partial_config() {
2933        // Use temp directory to avoid race conditions
2934        let temp_dir = TempDir::new().unwrap();
2935        let config_dir = temp_dir.path().join(".config").join("autom8");
2936        let project_dir = config_dir.join("test-project");
2937        fs::create_dir_all(&project_dir).unwrap();
2938
2939        let project_config_path = project_dir.join("config.toml");
2940
2941        // Write a partial config (missing some fields)
2942        let partial_content = r#"
2943# Partial project config
2944review = false
2945"#;
2946        fs::write(&project_config_path, partial_content).unwrap();
2947
2948        // Load should fill in defaults for missing fields
2949        let loaded: Config =
2950            toml::from_str(&fs::read_to_string(&project_config_path).unwrap()).unwrap();
2951
2952        assert!(!loaded.review, "review should be false as specified");
2953        assert!(loaded.commit, "missing commit should default to true");
2954        assert!(
2955            loaded.pull_request,
2956            "missing pull_request should default to true"
2957        );
2958    }
2959
2960    #[test]
2961    fn test_us003_inheritance_simulation_with_temp_dirs() {
2962        // Simulate the full inheritance flow with temp directories
2963        let temp_dir = TempDir::new().unwrap();
2964        let config_dir = temp_dir.path().join(".config").join("autom8");
2965        fs::create_dir_all(&config_dir).unwrap();
2966
2967        // Create global config
2968        let global_config = Config {
2969            review: true,
2970            commit: false,
2971            pull_request: false,
2972            ..Default::default()
2973        };
2974        let global_content = generate_config_with_comments(&global_config);
2975        let global_path = config_dir.join("config.toml");
2976        fs::write(&global_path, &global_content).unwrap();
2977
2978        // Create project directory
2979        let project_dir = config_dir.join("test-project");
2980        fs::create_dir_all(project_dir.join("spec")).unwrap();
2981        fs::create_dir_all(project_dir.join("runs")).unwrap();
2982
2983        // Simulate load_project_config behavior: copy global to project
2984        let project_config_path = project_dir.join("config.toml");
2985        assert!(!project_config_path.exists());
2986
2987        // Copy global config content to project config
2988        fs::write(&project_config_path, &global_content).unwrap();
2989
2990        // Verify project config exists and matches global
2991        assert!(project_config_path.exists());
2992        let loaded: Config =
2993            toml::from_str(&fs::read_to_string(&project_config_path).unwrap()).unwrap();
2994        assert_eq!(
2995            loaded, global_config,
2996            "Project config should inherit from global"
2997        );
2998
2999        // Verify comments were preserved
3000        let project_content = fs::read_to_string(&project_config_path).unwrap();
3001        assert!(project_content.contains("# Autom8 Configuration"));
3002        assert!(project_content.contains("# Review state"));
3003    }
3004
3005    #[test]
3006    fn test_us003_project_config_override_simulation() {
3007        // Simulate project config overriding global with different values
3008        let temp_dir = TempDir::new().unwrap();
3009        let config_dir = temp_dir.path().join(".config").join("autom8");
3010        fs::create_dir_all(&config_dir).unwrap();
3011
3012        // Create global config
3013        let global_config = Config {
3014            review: true,
3015            commit: true,
3016            pull_request: true,
3017            ..Default::default()
3018        };
3019        let global_path = config_dir.join("config.toml");
3020        fs::write(&global_path, generate_config_with_comments(&global_config)).unwrap();
3021
3022        // Create project config with different values
3023        let project_config = Config {
3024            review: false,
3025            commit: true,
3026            pull_request: false,
3027            ..Default::default()
3028        };
3029        let project_dir = config_dir.join("my-project");
3030        fs::create_dir_all(&project_dir).unwrap();
3031        let project_path = project_dir.join("config.toml");
3032        fs::write(
3033            &project_path,
3034            generate_config_with_comments(&project_config),
3035        )
3036        .unwrap();
3037
3038        // Simulate get_effective_config logic: prefer project if exists
3039        let effective_path = if project_path.exists() {
3040            &project_path
3041        } else {
3042            &global_path
3043        };
3044
3045        let effective: Config =
3046            toml::from_str(&fs::read_to_string(effective_path).unwrap()).unwrap();
3047        assert_eq!(
3048            effective, project_config,
3049            "Project config should take precedence"
3050        );
3051        assert_ne!(effective.review, global_config.review);
3052        assert_ne!(effective.pull_request, global_config.pull_request);
3053    }
3054
3055    // =========================================================================
3056    // US-004: Config Validation Tests
3057    // =========================================================================
3058
3059    #[test]
3060    fn test_us004_validate_config_accepts_default_config() {
3061        let config = Config::default();
3062        assert!(validate_config(&config).is_ok());
3063    }
3064
3065    #[test]
3066    fn test_us004_validate_config_accepts_all_true() {
3067        let config = Config {
3068            review: true,
3069            commit: true,
3070            pull_request: true,
3071            ..Default::default()
3072        };
3073        assert!(validate_config(&config).is_ok());
3074    }
3075
3076    #[test]
3077    fn test_us004_validate_config_accepts_all_false() {
3078        let config = Config {
3079            review: false,
3080            commit: false,
3081            pull_request: false,
3082            ..Default::default()
3083        };
3084        assert!(validate_config(&config).is_ok());
3085    }
3086
3087    #[test]
3088    fn test_us004_validate_config_accepts_commit_true_pr_false() {
3089        let config = Config {
3090            review: true,
3091            commit: true,
3092            pull_request: false,
3093            ..Default::default()
3094        };
3095        assert!(validate_config(&config).is_ok());
3096    }
3097
3098    #[test]
3099    fn test_us004_validate_config_accepts_commit_false_pr_false() {
3100        let config = Config {
3101            review: true,
3102            commit: false,
3103            pull_request: false,
3104            ..Default::default()
3105        };
3106        assert!(validate_config(&config).is_ok());
3107    }
3108
3109    #[test]
3110    fn test_us004_validate_config_rejects_pr_true_commit_false() {
3111        let config = Config {
3112            review: true,
3113            commit: false,
3114            pull_request: true,
3115            ..Default::default()
3116        };
3117        let result = validate_config(&config);
3118        assert!(result.is_err());
3119        assert_eq!(result.unwrap_err(), ConfigError::PullRequestWithoutCommit);
3120    }
3121
3122    #[test]
3123    fn test_us004_config_error_message_is_actionable() {
3124        let error = ConfigError::PullRequestWithoutCommit;
3125        let message = error.to_string();
3126
3127        // Verify the error message contains the exact required text
3128        assert_eq!(
3129            message,
3130            "Cannot create pull request without commits. \
3131            Either set `commit = true` or set `pull_request = false`"
3132        );
3133    }
3134
3135    #[test]
3136    fn test_us004_config_error_implements_error_trait() {
3137        let error = ConfigError::PullRequestWithoutCommit;
3138        // Verify it implements std::error::Error
3139        let _: &dyn std::error::Error = &error;
3140    }
3141
3142    #[test]
3143    fn test_us004_config_error_debug_format() {
3144        let error = ConfigError::PullRequestWithoutCommit;
3145        let debug_str = format!("{:?}", error);
3146        assert!(debug_str.contains("PullRequestWithoutCommit"));
3147    }
3148
3149    #[test]
3150    fn test_us004_config_error_clone() {
3151        let error = ConfigError::PullRequestWithoutCommit;
3152        let cloned = error.clone();
3153        assert_eq!(error, cloned);
3154    }
3155
3156    #[test]
3157    fn test_us004_validate_config_accepts_review_false_with_valid_pr_commit() {
3158        // Review state doesn't affect PR/commit validation
3159        let config = Config {
3160            review: false,
3161            commit: true,
3162            pull_request: true,
3163            ..Default::default()
3164        };
3165        assert!(validate_config(&config).is_ok());
3166    }
3167
3168    #[test]
3169    fn test_us004_validate_config_all_combinations() {
3170        // Test all 8 possible boolean combinations (for review, commit, pull_request)
3171        let combinations = [
3172            (false, false, false, true), // all false - valid
3173            (false, false, true, false), // pr=true, commit=false - invalid
3174            (false, true, false, true),  // commit=true, pr=false - valid
3175            (false, true, true, true),   // commit=true, pr=true - valid
3176            (true, false, false, true),  // review=true, commit=false, pr=false - valid
3177            (true, false, true, false),  // review=true, pr=true, commit=false - invalid
3178            (true, true, false, true),   // review=true, commit=true, pr=false - valid
3179            (true, true, true, true),    // all true - valid
3180        ];
3181
3182        for (review, commit, pull_request, should_be_valid) in combinations {
3183            let config = Config {
3184                review,
3185                commit,
3186                pull_request,
3187                ..Default::default()
3188            };
3189            let result = validate_config(&config);
3190            assert_eq!(
3191                result.is_ok(),
3192                should_be_valid,
3193                "Config (review={}, commit={}, pull_request={}) expected valid={}, got valid={}",
3194                review,
3195                commit,
3196                pull_request,
3197                should_be_valid,
3198                result.is_ok()
3199            );
3200        }
3201    }
3202
3203    #[test]
3204    fn test_us004_get_effective_config_validates_before_returning() {
3205        // This test verifies that get_effective_config validates the loaded config
3206        // We can't easily test this with real files in a unit test, but we can
3207        // verify the validation function is called by testing with a simulated scenario
3208
3209        // Create an invalid config directly and validate it
3210        let invalid_config = Config {
3211            review: true,
3212            commit: false,
3213            pull_request: true,
3214            ..Default::default()
3215        };
3216        let validation_result = validate_config(&invalid_config);
3217        assert!(validation_result.is_err());
3218
3219        // Verify the error message contains actionable information
3220        let error = validation_result.unwrap_err();
3221        let message = error.to_string();
3222        assert!(message.contains("commit = true"));
3223        assert!(message.contains("pull_request = false"));
3224    }
3225
3226    #[test]
3227    fn test_us004_validation_integration_with_autom8_error() {
3228        // Verify ConfigError can be converted to Autom8Error::Config
3229        let config_error = ConfigError::PullRequestWithoutCommit;
3230        let autom8_error = Autom8Error::Config(config_error.to_string());
3231
3232        // The error message should be preserved
3233        let error_string = format!("{}", autom8_error);
3234        assert!(error_string.contains("Cannot create pull request without commits"));
3235    }
3236
3237    // =========================================================================
3238    // Test: config files with use_tui should still parse (backwards compat)
3239    // =========================================================================
3240
3241    #[test]
3242    fn test_config_with_use_tui_field_still_parses() {
3243        // Old config files may still have use_tui field - ensure they parse without error
3244        let toml_str = r#"
3245            review = true
3246            commit = true
3247            pull_request = true
3248            use_tui = true
3249        "#;
3250        // This should parse successfully (use_tui is ignored)
3251        let config: Config = toml::from_str(toml_str).unwrap();
3252        assert!(config.review);
3253        assert!(config.commit);
3254        assert!(config.pull_request);
3255    }
3256
3257    // ========================================================================
3258    // US-005: Worktree Configuration Option tests
3259    // ========================================================================
3260
3261    #[test]
3262    fn test_worktree_config_defaults_to_true() {
3263        let config = Config::default();
3264        assert!(config.worktree, "worktree should default to true");
3265    }
3266
3267    #[test]
3268    fn test_worktree_config_can_be_enabled() {
3269        let toml_str = r#"
3270            worktree = true
3271        "#;
3272        let config: Config = toml::from_str(toml_str).unwrap();
3273        assert!(
3274            config.worktree,
3275            "worktree should be true when set in config"
3276        );
3277    }
3278
3279    #[test]
3280    fn test_worktree_config_missing_defaults_to_true() {
3281        // Config files without worktree field should default to true
3282        let toml_str = r#"
3283            review = true
3284            commit = true
3285            pull_request = true
3286        "#;
3287        let config: Config = toml::from_str(toml_str).unwrap();
3288        assert!(
3289            config.worktree,
3290            "missing worktree field should default to true"
3291        );
3292    }
3293
3294    #[test]
3295    fn test_worktree_config_explicit_false() {
3296        let toml_str = r#"
3297            worktree = false
3298        "#;
3299        let config: Config = toml::from_str(toml_str).unwrap();
3300        assert!(
3301            !config.worktree,
3302            "explicit worktree = false should be respected"
3303        );
3304    }
3305
3306    #[test]
3307    fn test_worktree_config_with_all_other_fields() {
3308        let toml_str = r#"
3309            review = false
3310            commit = true
3311            pull_request = false
3312            worktree = true
3313        "#;
3314        let config: Config = toml::from_str(toml_str).unwrap();
3315        assert!(!config.review);
3316        assert!(config.commit);
3317        assert!(!config.pull_request);
3318        assert!(config.worktree);
3319    }
3320
3321    #[test]
3322    fn test_worktree_config_documentation_note_in_generated_comments() {
3323        let config = Config::default();
3324        let content = generate_config_with_comments(&config);
3325
3326        // Verify the git repository requirement note is documented
3327        assert!(
3328            content.contains("Requires a git repository"),
3329            "config comments should document git repo requirement"
3330        );
3331    }
3332
3333    // ========================================================================
3334    // US-008: worktree_cleanup configuration tests
3335    // ========================================================================
3336
3337    #[test]
3338    fn test_worktree_cleanup_config_defaults_to_false() {
3339        let config = Config::default();
3340        assert!(
3341            !config.worktree_cleanup,
3342            "worktree_cleanup should default to false for backward compatibility"
3343        );
3344    }
3345
3346    #[test]
3347    fn test_worktree_cleanup_config_can_be_enabled() {
3348        let toml_str = r#"
3349            worktree_cleanup = true
3350        "#;
3351        let config: Config = toml::from_str(toml_str).unwrap();
3352        assert!(
3353            config.worktree_cleanup,
3354            "worktree_cleanup should be true when set in config"
3355        );
3356    }
3357
3358    #[test]
3359    fn test_worktree_cleanup_config_missing_defaults_to_false() {
3360        // Old config files without worktree_cleanup field should still work
3361        let toml_str = r#"
3362            review = true
3363            commit = true
3364            worktree = true
3365        "#;
3366        let config: Config = toml::from_str(toml_str).unwrap();
3367        assert!(
3368            !config.worktree_cleanup,
3369            "missing worktree_cleanup field should default to false"
3370        );
3371    }
3372
3373    #[test]
3374    fn test_worktree_cleanup_config_explicit_false() {
3375        let toml_str = r#"
3376            worktree_cleanup = false
3377        "#;
3378        let config: Config = toml::from_str(toml_str).unwrap();
3379        assert!(
3380            !config.worktree_cleanup,
3381            "explicit worktree_cleanup = false should be respected"
3382        );
3383    }
3384
3385    #[test]
3386    fn test_worktree_cleanup_config_with_all_worktree_fields() {
3387        let toml_str = r#"
3388            worktree = true
3389            worktree_path_pattern = "{repo}-test-{branch}"
3390            worktree_cleanup = true
3391        "#;
3392        let config: Config = toml::from_str(toml_str).unwrap();
3393        assert!(config.worktree);
3394        assert_eq!(config.worktree_path_pattern, "{repo}-test-{branch}");
3395        assert!(config.worktree_cleanup);
3396    }
3397
3398    #[test]
3399    fn test_worktree_cleanup_in_generated_comments() {
3400        let config = Config {
3401            worktree_cleanup: true,
3402            ..Default::default()
3403        };
3404        let content = generate_config_with_comments(&config);
3405
3406        // Verify worktree_cleanup is documented
3407        assert!(
3408            content.contains("worktree_cleanup = true"),
3409            "generated config should include worktree_cleanup setting"
3410        );
3411        assert!(
3412            content.contains("successful completion"),
3413            "config comments should document cleanup behavior"
3414        );
3415    }
3416
3417    #[test]
3418    fn test_worktree_cleanup_in_default_config_with_comments() {
3419        // Verify DEFAULT_CONFIG_WITH_COMMENTS includes worktree_cleanup
3420        assert!(
3421            DEFAULT_CONFIG_WITH_COMMENTS.contains("worktree_cleanup"),
3422            "DEFAULT_CONFIG_WITH_COMMENTS should include worktree_cleanup"
3423        );
3424        assert!(
3425            DEFAULT_CONFIG_WITH_COMMENTS.contains("worktree_cleanup = false"),
3426            "DEFAULT_CONFIG_WITH_COMMENTS should have worktree_cleanup = false"
3427        );
3428    }
3429
3430    #[test]
3431    fn test_worktree_cleanup_serialization_roundtrip() {
3432        let config = Config {
3433            worktree: true,
3434            worktree_cleanup: true,
3435            ..Default::default()
3436        };
3437
3438        // Serialize to TOML
3439        let toml_str = toml::to_string(&config).unwrap();
3440        assert!(toml_str.contains("worktree_cleanup = true"));
3441
3442        // Deserialize back
3443        let parsed: Config = toml::from_str(&toml_str).unwrap();
3444        assert_eq!(parsed.worktree_cleanup, config.worktree_cleanup);
3445    }
3446
3447    // ========================================================================
3448    // US-001 (draft-pr-config): pull_request_draft configuration tests
3449    // ========================================================================
3450
3451    #[test]
3452    fn test_pull_request_draft_config_defaults_to_false() {
3453        let config = Config::default();
3454        assert!(
3455            !config.pull_request_draft,
3456            "pull_request_draft should default to false for backward compatibility"
3457        );
3458    }
3459
3460    #[test]
3461    fn test_pull_request_draft_config_can_be_enabled() {
3462        let toml_str = r#"
3463            pull_request_draft = true
3464        "#;
3465        let config: Config = toml::from_str(toml_str).unwrap();
3466        assert!(
3467            config.pull_request_draft,
3468            "pull_request_draft should be true when set in config"
3469        );
3470    }
3471
3472    #[test]
3473    fn test_pull_request_draft_config_missing_defaults_to_false() {
3474        // Old config files without pull_request_draft field should still work
3475        let toml_str = r#"
3476            review = true
3477            commit = true
3478            pull_request = true
3479        "#;
3480        let config: Config = toml::from_str(toml_str).unwrap();
3481        assert!(
3482            !config.pull_request_draft,
3483            "missing pull_request_draft field should default to false"
3484        );
3485    }
3486
3487    #[test]
3488    fn test_pull_request_draft_config_explicit_false() {
3489        let toml_str = r#"
3490            pull_request_draft = false
3491        "#;
3492        let config: Config = toml::from_str(toml_str).unwrap();
3493        assert!(
3494            !config.pull_request_draft,
3495            "explicit pull_request_draft = false should be respected"
3496        );
3497    }
3498
3499    #[test]
3500    fn test_pull_request_draft_config_with_all_pr_fields() {
3501        let toml_str = r#"
3502            pull_request = true
3503            pull_request_draft = true
3504        "#;
3505        let config: Config = toml::from_str(toml_str).unwrap();
3506        assert!(config.pull_request);
3507        assert!(config.pull_request_draft);
3508    }
3509
3510    #[test]
3511    fn test_pull_request_draft_in_generated_comments() {
3512        let config = Config {
3513            pull_request_draft: true,
3514            ..Default::default()
3515        };
3516        let content = generate_config_with_comments(&config);
3517
3518        // Verify pull_request_draft is documented
3519        assert!(
3520            content.contains("pull_request_draft = true"),
3521            "generated config should include pull_request_draft setting"
3522        );
3523        assert!(
3524            content.contains("draft mode"),
3525            "config comments should document draft mode behavior"
3526        );
3527    }
3528
3529    #[test]
3530    fn test_pull_request_draft_in_default_config_with_comments() {
3531        // Verify DEFAULT_CONFIG_WITH_COMMENTS includes pull_request_draft
3532        assert!(
3533            DEFAULT_CONFIG_WITH_COMMENTS.contains("pull_request_draft"),
3534            "DEFAULT_CONFIG_WITH_COMMENTS should include pull_request_draft"
3535        );
3536        assert!(
3537            DEFAULT_CONFIG_WITH_COMMENTS.contains("pull_request_draft = false"),
3538            "DEFAULT_CONFIG_WITH_COMMENTS should have pull_request_draft = false"
3539        );
3540    }
3541
3542    #[test]
3543    fn test_pull_request_draft_serialization_roundtrip() {
3544        let config = Config {
3545            pull_request: true,
3546            pull_request_draft: true,
3547            ..Default::default()
3548        };
3549
3550        // Serialize to TOML
3551        let toml_str = toml::to_string(&config).unwrap();
3552        assert!(toml_str.contains("pull_request_draft = true"));
3553
3554        // Deserialize back
3555        let parsed: Config = toml::from_str(&toml_str).unwrap();
3556        assert_eq!(parsed.pull_request_draft, config.pull_request_draft);
3557    }
3558
3559    // US-001: Conditional Story Display in Describe View
3560
3561    #[test]
3562    fn test_us001_spec_summary_is_active_field() {
3563        // Verify SpecSummary has is_active field
3564        let spec_active = SpecSummary {
3565            filename: "spec-active.json".to_string(),
3566            path: PathBuf::from("/test/spec-active.json"),
3567            project_name: "test".to_string(),
3568            branch_name: "feature/active".to_string(),
3569            description: "Active spec".to_string(),
3570            stories: vec![],
3571            completed_count: 0,
3572            total_count: 0,
3573            is_active: true,
3574        };
3575
3576        let spec_inactive = SpecSummary {
3577            filename: "spec-inactive.json".to_string(),
3578            path: PathBuf::from("/test/spec-inactive.json"),
3579            project_name: "test".to_string(),
3580            branch_name: "feature/inactive".to_string(),
3581            description: "Inactive spec".to_string(),
3582            stories: vec![],
3583            completed_count: 0,
3584            total_count: 0,
3585            is_active: false,
3586        };
3587
3588        assert!(spec_active.is_active);
3589        assert!(!spec_inactive.is_active);
3590    }
3591
3592    // ========================================================================
3593    // US-004: Last Run Time Accuracy Tests
3594    // ========================================================================
3595
3596    /// Helper function to compute the most meaningful timestamp for a run.
3597    /// This mirrors the logic in `list_projects_tree()` for testability.
3598    fn compute_last_run_timestamp(
3599        has_active_run: bool,
3600        run_started_at: Option<chrono::DateTime<chrono::Utc>>,
3601        run_finished_at: Option<chrono::DateTime<chrono::Utc>>,
3602        archived_started_at: Option<chrono::DateTime<chrono::Utc>>,
3603        archived_finished_at: Option<chrono::DateTime<chrono::Utc>>,
3604    ) -> Option<chrono::DateTime<chrono::Utc>> {
3605        if has_active_run {
3606            // Active run: show when it started
3607            run_started_at
3608        } else {
3609            // No active run: prefer finished_at over started_at
3610            run_finished_at
3611                .or(run_started_at)
3612                .or(archived_finished_at)
3613                .or(archived_started_at)
3614        }
3615    }
3616
3617    #[test]
3618    fn test_us004_last_run_date_active_run_uses_started_at() {
3619        use chrono::{Duration, Utc};
3620
3621        let started_at = Utc::now() - Duration::minutes(30);
3622        let finished_at = None; // Active runs don't have finished_at
3623
3624        let result = compute_last_run_timestamp(
3625            true,             // has_active_run
3626            Some(started_at), // run_started_at
3627            finished_at,      // run_finished_at
3628            None,             // archived_started_at
3629            None,             // archived_finished_at
3630        );
3631
3632        assert_eq!(result, Some(started_at));
3633    }
3634
3635    #[test]
3636    fn test_us004_last_run_date_completed_run_uses_finished_at() {
3637        use chrono::{Duration, Utc};
3638
3639        let started_at = Utc::now() - Duration::hours(2);
3640        let finished_at = Utc::now() - Duration::minutes(30);
3641
3642        let result = compute_last_run_timestamp(
3643            false,             // has_active_run (completed)
3644            Some(started_at),  // run_started_at
3645            Some(finished_at), // run_finished_at
3646            None,              // archived_started_at
3647            None,              // archived_finished_at
3648        );
3649
3650        // Should use finished_at, not started_at
3651        assert_eq!(result, Some(finished_at));
3652    }
3653
3654    #[test]
3655    fn test_us004_last_run_date_completed_run_fallback_to_started_at() {
3656        use chrono::{Duration, Utc};
3657
3658        let started_at = Utc::now() - Duration::hours(2);
3659        // finished_at is None (run may have been interrupted before completion)
3660
3661        let result = compute_last_run_timestamp(
3662            false,            // has_active_run (completed)
3663            Some(started_at), // run_started_at
3664            None,             // run_finished_at (missing)
3665            None,             // archived_started_at
3666            None,             // archived_finished_at
3667        );
3668
3669        // Should fall back to started_at when finished_at is missing
3670        assert_eq!(result, Some(started_at));
3671    }
3672
3673    #[test]
3674    fn test_us004_last_run_date_archived_run_uses_finished_at() {
3675        use chrono::{Duration, Utc};
3676
3677        let archived_started_at = Utc::now() - Duration::days(1);
3678        let archived_finished_at = Utc::now() - Duration::hours(23);
3679
3680        let result = compute_last_run_timestamp(
3681            false,                      // has_active_run
3682            None,                       // run_started_at (no current run)
3683            None,                       // run_finished_at
3684            Some(archived_started_at),  // archived_started_at
3685            Some(archived_finished_at), // archived_finished_at
3686        );
3687
3688        // Should use archived finished_at
3689        assert_eq!(result, Some(archived_finished_at));
3690    }
3691
3692    #[test]
3693    fn test_us004_last_run_date_no_runs_returns_none() {
3694        let result = compute_last_run_timestamp(
3695            false, // has_active_run
3696            None,  // run_started_at
3697            None,  // run_finished_at
3698            None,  // archived_started_at
3699            None,  // archived_finished_at
3700        );
3701
3702        assert_eq!(result, None);
3703    }
3704
3705    #[test]
3706    fn test_us004_last_run_date_prefers_current_over_archived() {
3707        use chrono::{Duration, Utc};
3708
3709        // Current run is more recent but completed
3710        let current_started_at = Utc::now() - Duration::hours(1);
3711        let current_finished_at = Utc::now() - Duration::minutes(30);
3712
3713        // Older archived run
3714        let archived_started_at = Utc::now() - Duration::days(7);
3715        let archived_finished_at = Utc::now() - Duration::days(7) + Duration::hours(2);
3716
3717        let result = compute_last_run_timestamp(
3718            false,                      // has_active_run
3719            Some(current_started_at),   // run_started_at
3720            Some(current_finished_at),  // run_finished_at
3721            Some(archived_started_at),  // archived_started_at
3722            Some(archived_finished_at), // archived_finished_at
3723        );
3724
3725        // Should use current run's finished_at, not archived
3726        assert_eq!(result, Some(current_finished_at));
3727    }
3728}