Skip to main content

autom8/
self_test.rs

1//! Self-test spec for testing autom8 itself.
2//!
3//! Provides a hardcoded trivial spec that exercises the normal autom8 flow
4//! without modifying any real code. The spec creates and modifies a dummy
5//! file (`test_output.txt`) in the repo root.
6
7use std::fs;
8use std::process::Command;
9
10use crate::config::spec_dir;
11use crate::error::Result;
12use crate::spec::{Spec, UserStory};
13use crate::state::StateManager;
14use crate::worktree::get_main_repo_root;
15
16/// Branch name used for self-test runs.
17pub const SELF_TEST_BRANCH: &str = "autom8/self-test";
18
19/// Target file for self-test operations.
20pub const SELF_TEST_FILE: &str = "test_output.txt";
21
22/// Filename for the self-test spec in the config directory.
23pub const SELF_TEST_SPEC_FILENAME: &str = "test_spec.json";
24
25/// Creates the hardcoded self-test spec.
26///
27/// The spec contains 3 trivial user stories that:
28/// 1. Create `test_output.txt` with a greeting
29/// 2. Add a separator and timestamp placeholder line
30/// 3. Add a completion message
31///
32/// These stories exercise multiple iterations of the autom8 loop
33/// without touching any real code.
34pub fn create_self_test_spec() -> Spec {
35    Spec {
36        project: "autom8-self-test".to_string(),
37        branch_name: SELF_TEST_BRANCH.to_string(),
38        description: "Self-test spec for validating autom8 functionality. Creates and modifies a dummy test_output.txt file.".to_string(),
39        user_stories: vec![
40            UserStory {
41                id: "ST-001".to_string(),
42                title: "Create test output file".to_string(),
43                description: "Create the test_output.txt file in the repository root with an initial greeting message.".to_string(),
44                acceptance_criteria: vec![
45                    "File test_output.txt exists in the repo root".to_string(),
46                    "File contains the text 'Hello from autom8 self-test!'".to_string(),
47                ],
48                priority: 1,
49                passes: false,
50                notes: "This is the first step - just create the file with a simple greeting.".to_string(),
51            },
52            UserStory {
53                id: "ST-002".to_string(),
54                title: "Add separator and status line".to_string(),
55                description: "Add a separator line and a status line to test_output.txt.".to_string(),
56                acceptance_criteria: vec![
57                    "File contains a separator line (e.g., '---')".to_string(),
58                    "File contains a status line with 'Status: Running'".to_string(),
59                ],
60                priority: 2,
61                passes: false,
62                notes: "Appends content to the existing file.".to_string(),
63            },
64            UserStory {
65                id: "ST-003".to_string(),
66                title: "Add completion message".to_string(),
67                description: "Add a final completion message to test_output.txt indicating the self-test finished successfully.".to_string(),
68                acceptance_criteria: vec![
69                    "File contains a completion message".to_string(),
70                    "Message includes 'Self-test complete!'".to_string(),
71                ],
72                priority: 3,
73                passes: false,
74                notes: "Final step - adds the completion marker.".to_string(),
75            },
76        ],
77    }
78}
79
80/// Result of a cleanup operation with details about what was cleaned.
81#[derive(Debug, Default)]
82pub struct CleanupResult {
83    /// Whether the test output file was deleted
84    pub test_file_deleted: bool,
85    /// Whether the spec file was deleted
86    pub spec_file_deleted: bool,
87    /// Whether the session state was cleared
88    pub session_cleared: bool,
89    /// Whether the test branch was deleted
90    pub branch_deleted: bool,
91    /// Whether the worktree was deleted (if running in worktree mode)
92    pub worktree_deleted: bool,
93    /// Errors encountered during cleanup (non-fatal)
94    pub errors: Vec<String>,
95}
96
97impl CleanupResult {
98    /// Returns true if all cleanup operations succeeded without errors
99    pub fn is_complete(&self) -> bool {
100        self.errors.is_empty()
101    }
102}
103
104/// Clean up all self-test artifacts.
105///
106/// This function removes:
107/// 1. `test_output.txt` from the current working directory (or main repo root)
108/// 2. Test spec file from `~/.config/autom8/<project>/spec/`
109/// 3. Session state from `~/.config/autom8/<project>/sessions/`
110/// 4. The test branch (`autom8/self-test`)
111/// 5. The worktree directory (if running in worktree mode)
112///
113/// Cleanup failures are collected but don't cause the function to fail,
114/// allowing as much cleanup as possible to complete.
115pub fn cleanup_self_test() -> CleanupResult {
116    let mut result = CleanupResult::default();
117
118    // Capture worktree info before changing directories
119    let worktree_info = get_worktree_info_for_cleanup();
120
121    // 1. Delete the test output file from current directory (where Claude created it)
122    result.test_file_deleted = cleanup_test_file(&mut result.errors);
123
124    // 2. Delete the spec file
125    result.spec_file_deleted = cleanup_spec_file(&mut result.errors);
126
127    // 3. Clear session state
128    result.session_cleared = cleanup_session_state(&mut result.errors);
129
130    // 4. Delete the worktree (must happen before branch deletion, requires leaving the worktree first)
131    if let Some((worktree_path, main_repo_path)) = worktree_info {
132        result.worktree_deleted =
133            cleanup_worktree(&worktree_path, &main_repo_path, &mut result.errors);
134    }
135
136    // 5. Delete the test branch (after checkout to another branch)
137    result.branch_deleted = cleanup_test_branch(&mut result.errors);
138
139    result
140}
141
142/// Get worktree info if we're running in a linked worktree.
143/// Returns (worktree_path, main_repo_path) if in a worktree, None otherwise.
144fn get_worktree_info_for_cleanup() -> Option<(std::path::PathBuf, std::path::PathBuf)> {
145    use crate::worktree::{get_main_repo_root, is_in_worktree};
146
147    // Check if we're in a linked worktree
148    if is_in_worktree().unwrap_or(false) {
149        let worktree_path = std::env::current_dir().ok()?;
150        let main_repo_path = get_main_repo_root().ok()?;
151        Some((worktree_path, main_repo_path))
152    } else {
153        None
154    }
155}
156
157/// Clean up a worktree created during self-test.
158fn cleanup_worktree(
159    worktree_path: &std::path::Path,
160    main_repo_path: &std::path::Path,
161    errors: &mut Vec<String>,
162) -> bool {
163    use crate::worktree::remove_worktree;
164
165    // First, change to the main repo so we can remove the worktree
166    if let Err(e) = std::env::set_current_dir(main_repo_path) {
167        errors.push(format!(
168            "Failed to change to main repo '{}': {}",
169            main_repo_path.display(),
170            e
171        ));
172        return false;
173    }
174
175    // Now remove the worktree (force removal since we may have uncommitted test changes)
176    if let Err(e) = remove_worktree(worktree_path, true) {
177        errors.push(format!(
178            "Failed to remove worktree '{}': {}",
179            worktree_path.display(),
180            e
181        ));
182        return false;
183    }
184
185    true
186}
187
188/// Delete the test output file from the current working directory.
189///
190/// When running with --self-test, Claude creates the test file in the current
191/// working directory. In worktree mode, this is the worktree directory, not
192/// the main repo root. We try the current directory first, then fall back to
193/// the main repo root for compatibility.
194fn cleanup_test_file(errors: &mut Vec<String>) -> bool {
195    // First, try the current working directory (where Claude runs)
196    if let Ok(cwd) = std::env::current_dir() {
197        let test_file = cwd.join(SELF_TEST_FILE);
198        if test_file.exists() {
199            if let Err(e) = fs::remove_file(&test_file) {
200                errors.push(format!("Failed to delete {}: {}", test_file.display(), e));
201                return false;
202            }
203            return true;
204        }
205    }
206
207    // Fall back to main repo root (in case cleanup is called from a different directory)
208    let repo_root = match get_main_repo_root() {
209        Ok(root) => root,
210        Err(e) => {
211            // If we can't get repo root and file wasn't in CWD, it may not exist
212            // This is not necessarily an error - the file may have already been cleaned
213            errors.push(format!(
214                "Could not locate {}: not in CWD and failed to get repo root: {}",
215                SELF_TEST_FILE, e
216            ));
217            return false;
218        }
219    };
220
221    let test_file = repo_root.join(SELF_TEST_FILE);
222    if test_file.exists() {
223        if let Err(e) = fs::remove_file(&test_file) {
224            errors.push(format!("Failed to delete {}: {}", test_file.display(), e));
225            return false;
226        }
227    }
228    true
229}
230
231/// Delete the spec file from the config directory.
232fn cleanup_spec_file(errors: &mut Vec<String>) -> bool {
233    let spec_path = match spec_dir() {
234        Ok(dir) => dir.join(SELF_TEST_SPEC_FILENAME),
235        Err(e) => {
236            errors.push(format!("Failed to get spec directory: {}", e));
237            return false;
238        }
239    };
240
241    if spec_path.exists() {
242        if let Err(e) = fs::remove_file(&spec_path) {
243            errors.push(format!("Failed to delete {}: {}", spec_path.display(), e));
244            return false;
245        }
246    }
247    true
248}
249
250/// Clear the session state.
251fn cleanup_session_state(errors: &mut Vec<String>) -> bool {
252    let state_manager = match StateManager::new() {
253        Ok(sm) => sm,
254        Err(e) => {
255            errors.push(format!("Failed to create state manager: {}", e));
256            return false;
257        }
258    };
259
260    if let Err(e) = state_manager.clear_current() {
261        errors.push(format!("Failed to clear session state: {}", e));
262        return false;
263    }
264    true
265}
266
267/// Delete the test branch after checking out to main/master.
268fn cleanup_test_branch(errors: &mut Vec<String>) -> bool {
269    // First, check if we're on the test branch
270    let current_branch = match get_current_branch() {
271        Ok(branch) => branch,
272        Err(e) => {
273            errors.push(format!("Failed to get current branch: {}", e));
274            return false;
275        }
276    };
277
278    // If on test branch, switch to main/master first
279    if current_branch == SELF_TEST_BRANCH {
280        let base_branch = detect_base_branch_for_cleanup();
281        if let Err(e) = checkout_branch(&base_branch) {
282            errors.push(format!("Failed to checkout {}: {}", base_branch, e));
283            return false;
284        }
285    }
286
287    // Now delete the test branch
288    if branch_exists_local(SELF_TEST_BRANCH) {
289        if let Err(e) = delete_branch(SELF_TEST_BRANCH) {
290            errors.push(format!(
291                "Failed to delete branch '{}': {}",
292                SELF_TEST_BRANCH, e
293            ));
294            return false;
295        }
296    }
297    true
298}
299
300/// Get the current branch name (internal helper).
301fn get_current_branch() -> Result<String> {
302    let output = Command::new("git")
303        .args(["rev-parse", "--abbrev-ref", "HEAD"])
304        .output()?;
305
306    if !output.status.success() {
307        return Err(crate::error::Autom8Error::GitError(
308            String::from_utf8_lossy(&output.stderr).to_string(),
309        ));
310    }
311
312    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
313}
314
315/// Detect base branch (main or master) for checkout.
316fn detect_base_branch_for_cleanup() -> String {
317    // Try main first, then master
318    if branch_exists_local("main") {
319        "main".to_string()
320    } else if branch_exists_local("master") {
321        "master".to_string()
322    } else {
323        // Default to main if neither exists (git checkout will fail gracefully)
324        "main".to_string()
325    }
326}
327
328/// Check if a local branch exists.
329fn branch_exists_local(branch: &str) -> bool {
330    Command::new("git")
331        .args([
332            "show-ref",
333            "--verify",
334            "--quiet",
335            &format!("refs/heads/{}", branch),
336        ])
337        .output()
338        .map(|o| o.status.success())
339        .unwrap_or(false)
340}
341
342/// Checkout a branch.
343fn checkout_branch(branch: &str) -> Result<()> {
344    let output = Command::new("git").args(["checkout", branch]).output()?;
345
346    if !output.status.success() {
347        return Err(crate::error::Autom8Error::GitError(format!(
348            "Failed to checkout branch '{}': {}",
349            branch,
350            String::from_utf8_lossy(&output.stderr)
351        )));
352    }
353
354    Ok(())
355}
356
357/// Delete a local branch.
358fn delete_branch(branch: &str) -> Result<()> {
359    let output = Command::new("git")
360        .args(["branch", "-D", branch])
361        .output()?;
362
363    if !output.status.success() {
364        return Err(crate::error::Autom8Error::GitError(format!(
365            "Failed to delete branch '{}': {}",
366            branch,
367            String::from_utf8_lossy(&output.stderr)
368        )));
369    }
370
371    Ok(())
372}
373
374/// Print detailed error information before cleanup (for failure cases).
375pub fn print_failure_details(run_error: &crate::error::Autom8Error) {
376    use crate::output::{print_error, print_warning};
377
378    println!(); // Add spacing
379    print_error(&format!("Self-test failed: {}", run_error));
380
381    // Print additional context based on error type
382    match run_error {
383        crate::error::Autom8Error::ClaudeError(msg) => {
384            print_warning(&format!("Claude error details: {}", msg));
385        }
386        crate::error::Autom8Error::ClaudeTimeout(secs) => {
387            print_warning(&format!("Claude timed out after {} seconds", secs));
388        }
389        crate::error::Autom8Error::MaxReviewIterationsReached => {
390            print_warning("Review failed after maximum iterations");
391        }
392        crate::error::Autom8Error::Interrupted => {
393            print_warning("Run was interrupted by user");
394        }
395        _ => {}
396    }
397}
398
399/// Print cleanup results.
400pub fn print_cleanup_results(result: &CleanupResult) {
401    use crate::output::{print_info, print_warning, GREEN, RESET};
402
403    println!(); // Add spacing
404    print_info("Cleaning up self-test artifacts...");
405
406    if result.test_file_deleted {
407        println!("  {GREEN}✓{RESET} Deleted {}", SELF_TEST_FILE);
408    }
409    if result.spec_file_deleted {
410        println!(
411            "  {GREEN}✓{RESET} Deleted spec file ({})",
412            SELF_TEST_SPEC_FILENAME
413        );
414    }
415    if result.session_cleared {
416        println!("  {GREEN}✓{RESET} Cleared session state");
417    }
418    if result.worktree_deleted {
419        println!("  {GREEN}✓{RESET} Removed worktree");
420    }
421    if result.branch_deleted {
422        println!("  {GREEN}✓{RESET} Deleted branch '{}'", SELF_TEST_BRANCH);
423    }
424
425    if !result.errors.is_empty() {
426        println!();
427        print_warning("Some cleanup operations failed:");
428        for error in &result.errors {
429            print_warning(&format!("  - {}", error));
430        }
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn test_create_self_test_spec_returns_valid_spec() {
440        let spec = create_self_test_spec();
441
442        assert_eq!(spec.project, "autom8-self-test");
443        assert_eq!(spec.branch_name, SELF_TEST_BRANCH);
444        assert!(!spec.description.is_empty());
445    }
446
447    #[test]
448    fn test_self_test_spec_has_three_stories() {
449        let spec = create_self_test_spec();
450
451        assert_eq!(spec.user_stories.len(), 3);
452    }
453
454    #[test]
455    fn test_self_test_spec_stories_are_not_passing() {
456        let spec = create_self_test_spec();
457
458        for story in &spec.user_stories {
459            assert!(
460                !story.passes,
461                "Story {} should not be passing initially",
462                story.id
463            );
464        }
465    }
466
467    #[test]
468    fn test_self_test_spec_stories_have_correct_priorities() {
469        let spec = create_self_test_spec();
470
471        assert_eq!(spec.user_stories[0].priority, 1);
472        assert_eq!(spec.user_stories[1].priority, 2);
473        assert_eq!(spec.user_stories[2].priority, 3);
474    }
475
476    #[test]
477    fn test_self_test_spec_stories_have_ids() {
478        let spec = create_self_test_spec();
479
480        assert_eq!(spec.user_stories[0].id, "ST-001");
481        assert_eq!(spec.user_stories[1].id, "ST-002");
482        assert_eq!(spec.user_stories[2].id, "ST-003");
483    }
484
485    #[test]
486    fn test_self_test_spec_stories_have_acceptance_criteria() {
487        let spec = create_self_test_spec();
488
489        for story in &spec.user_stories {
490            assert!(
491                !story.acceptance_criteria.is_empty(),
492                "Story {} should have acceptance criteria",
493                story.id
494            );
495        }
496    }
497
498    #[test]
499    fn test_self_test_spec_can_be_serialized_to_json() {
500        let spec = create_self_test_spec();
501
502        let json = serde_json::to_string_pretty(&spec);
503        assert!(json.is_ok(), "Spec should serialize to JSON");
504
505        let json_str = json.unwrap();
506        assert!(json_str.contains("autom8-self-test"));
507        assert!(json_str.contains("ST-001"));
508        assert!(json_str.contains("test_output.txt"));
509    }
510
511    #[test]
512    fn test_self_test_spec_round_trips_through_json() {
513        let spec = create_self_test_spec();
514
515        let json = serde_json::to_string(&spec).unwrap();
516        let parsed: Spec = serde_json::from_str(&json).unwrap();
517
518        assert_eq!(parsed.project, spec.project);
519        assert_eq!(parsed.branch_name, spec.branch_name);
520        assert_eq!(parsed.user_stories.len(), spec.user_stories.len());
521    }
522
523    #[test]
524    fn test_self_test_branch_constant() {
525        assert_eq!(SELF_TEST_BRANCH, "autom8/self-test");
526    }
527
528    #[test]
529    fn test_self_test_file_constant() {
530        assert_eq!(SELF_TEST_FILE, "test_output.txt");
531    }
532
533    #[test]
534    fn test_self_test_spec_filename_constant() {
535        assert_eq!(SELF_TEST_SPEC_FILENAME, "test_spec.json");
536    }
537
538    // ========================================================================
539    // US-004: Cleanup tests
540    // ========================================================================
541
542    #[test]
543    fn test_cleanup_result_default_is_empty() {
544        let result = CleanupResult::default();
545
546        assert!(!result.test_file_deleted);
547        assert!(!result.spec_file_deleted);
548        assert!(!result.session_cleared);
549        assert!(!result.branch_deleted);
550        assert!(!result.worktree_deleted);
551        assert!(result.errors.is_empty());
552    }
553
554    #[test]
555    fn test_cleanup_result_is_complete_when_no_errors() {
556        let mut result = CleanupResult::default();
557        result.test_file_deleted = true;
558        result.spec_file_deleted = true;
559        result.session_cleared = true;
560        result.branch_deleted = true;
561        result.worktree_deleted = true;
562
563        assert!(result.is_complete());
564    }
565
566    #[test]
567    fn test_cleanup_result_is_not_complete_with_errors() {
568        let mut result = CleanupResult::default();
569        result.test_file_deleted = true;
570        result.errors.push("Failed to delete something".to_string());
571
572        assert!(!result.is_complete());
573    }
574
575    #[test]
576    fn test_cleanup_result_collects_multiple_errors() {
577        let mut result = CleanupResult::default();
578        result.errors.push("Error 1".to_string());
579        result.errors.push("Error 2".to_string());
580
581        assert_eq!(result.errors.len(), 2);
582        assert!(!result.is_complete());
583    }
584
585    #[test]
586    fn test_branch_exists_local_returns_bool() {
587        // This test just verifies the function doesn't panic and returns a bool.
588        // In the test environment, the branch may or may not exist.
589        let exists = branch_exists_local("main");
590        // Result is either true or false - just verify it's a valid bool
591        assert!(exists || !exists);
592    }
593
594    #[test]
595    fn test_branch_exists_local_nonexistent_branch() {
596        // A branch that definitely doesn't exist
597        let exists = branch_exists_local("nonexistent-branch-xyz-123456789");
598        assert!(!exists);
599    }
600
601    #[test]
602    fn test_detect_base_branch_for_cleanup_returns_string() {
603        // Should return either "main" or "master"
604        let branch = detect_base_branch_for_cleanup();
605        assert!(!branch.is_empty());
606        // Should be a valid branch name
607        assert!(
608            branch == "main" || branch == "master",
609            "Expected 'main' or 'master', got '{}'",
610            branch
611        );
612    }
613
614    #[test]
615    fn test_get_current_branch_returns_result() {
616        // We should be in a git repo during tests
617        let result = get_current_branch();
618        assert!(result.is_ok(), "Should be able to get current branch");
619        let branch = result.unwrap();
620        assert!(!branch.is_empty(), "Branch name should not be empty");
621    }
622
623    // ========================================================================
624    // Worktree cleanup tests
625    // ========================================================================
626
627    #[test]
628    fn test_get_worktree_info_for_cleanup_returns_correct_value() {
629        // get_worktree_info_for_cleanup should return Some if in a worktree, None otherwise
630        use crate::worktree::is_in_worktree;
631
632        let info = get_worktree_info_for_cleanup();
633        let in_worktree = is_in_worktree().unwrap_or(false);
634
635        if in_worktree {
636            // If we're in a worktree, we should get Some with valid paths
637            let (worktree_path, main_repo_path) =
638                info.expect("get_worktree_info_for_cleanup should return Some when in a worktree");
639            assert!(worktree_path.exists(), "worktree_path should exist");
640            assert!(main_repo_path.exists(), "main_repo_path should exist");
641            assert_ne!(
642                worktree_path, main_repo_path,
643                "worktree_path and main_repo_path should be different"
644            );
645        } else {
646            // If we're not in a worktree, we should get None
647            assert!(
648                info.is_none(),
649                "get_worktree_info_for_cleanup should return None when not in a worktree"
650            );
651        }
652    }
653
654    #[test]
655    fn test_cleanup_result_worktree_deleted_field() {
656        let mut result = CleanupResult::default();
657        assert!(
658            !result.worktree_deleted,
659            "worktree_deleted should default to false"
660        );
661
662        result.worktree_deleted = true;
663        assert!(
664            result.worktree_deleted,
665            "worktree_deleted should be settable to true"
666        );
667    }
668}