Skip to main content

chant/operations/
archive.rs

1//! Archive operation for specs.
2//!
3//! Provides the canonical implementation for archiving completed specs.
4
5use anyhow::{Context, Result};
6use std::path::{Path, PathBuf};
7
8use crate::paths::ARCHIVE_DIR;
9use crate::spec::SpecStatus;
10
11/// Options for the archive operation.
12#[derive(Debug, Clone, Default)]
13pub struct ArchiveOptions {
14    /// Whether to skip git staging (use fs::rename instead of git mv).
15    pub no_stage: bool,
16    /// Whether to allow archiving non-completed specs.
17    pub allow_non_completed: bool,
18}
19
20/// Check if we're in a git repository.
21fn is_git_repo() -> bool {
22    std::process::Command::new("git")
23        .args(["rev-parse", "--git-dir"])
24        .output()
25        .map(|output| output.status.success())
26        .unwrap_or(false)
27}
28
29/// Move a file using git mv, falling back to fs::rename if not in a git repo or if no_stage is true.
30pub fn move_spec_file(src: &PathBuf, dst: &PathBuf, no_stage: bool) -> Result<()> {
31    let use_git = !no_stage && is_git_repo();
32
33    if use_git {
34        // Try git mv to stage the move
35        let status = std::process::Command::new("git")
36            .args(["mv", &src.to_string_lossy(), &dst.to_string_lossy()])
37            .status()
38            .context("Failed to run git mv")?;
39
40        if !status.success() {
41            // git mv failed (likely untracked file) - fall back to filesystem rename
42            eprintln!(
43                "Warning: git mv failed for {} (file may be untracked), using filesystem move",
44                src.display()
45            );
46            std::fs::rename(src, dst).context(format!(
47                "Failed to move file from {} to {}",
48                src.display(),
49                dst.display()
50            ))?;
51        }
52    } else {
53        // Fall back to filesystem rename
54        std::fs::rename(src, dst).context(format!(
55            "Failed to move file from {} to {}",
56            src.display(),
57            dst.display()
58        ))?;
59    }
60
61    Ok(())
62}
63
64/// Archive a completed spec by moving it to the archive directory.
65///
66/// This operation:
67/// - Verifies the spec is completed
68/// - Checks for unmerged feature branches (unless allow_non_completed is set)
69/// - Creates date-based subdirectory in archive (YYYY-MM-DD)
70/// - Moves the spec file using git mv (or fs::rename if no_stage is true)
71///
72/// # Arguments
73/// * `specs_dir` - Path to the specs directory
74/// * `spec_id` - ID of the spec to archive
75/// * `options` - Archive operation options
76///
77/// # Returns
78/// * `Ok(PathBuf)` with the destination path if the spec was successfully archived
79/// * `Err(_)` if the spec doesn't exist, is not completed, or can't be moved
80pub fn archive_spec(specs_dir: &Path, spec_id: &str, options: &ArchiveOptions) -> Result<PathBuf> {
81    use crate::spec;
82
83    // Resolve and load the spec
84    let spec = spec::resolve_spec(specs_dir, spec_id)?;
85
86    // Check if completed (unless allow_non_completed is set)
87    if spec.frontmatter.status != SpecStatus::Completed && !options.allow_non_completed {
88        anyhow::bail!(
89            "Spec '{}' must be completed to archive (current: {:?})",
90            spec.id,
91            spec.frontmatter.status
92        );
93    }
94
95    // Check for unmerged feature branch (unless allow_non_completed is set as force flag)
96    if !options.allow_non_completed {
97        if let Some(ref branch_name) = spec.frontmatter.branch {
98            use crate::git_ops;
99
100            // Load config to get main branch name
101            let main_branch = {
102                let config = crate::config::Config::load()?;
103                crate::merge::load_main_branch(&config)
104            };
105
106            // Only check if this is not the main branch
107            if branch_name != &main_branch {
108                // Check if the branch exists and is merged
109                let branch_exists = git_ops::branch_exists(branch_name)?;
110
111                if branch_exists {
112                    let is_merged = git_ops::is_branch_merged(branch_name, &main_branch)?;
113
114                    if !is_merged {
115                        anyhow::bail!(
116                            "Cannot archive spec '{}': feature branch '{}' has not been merged to {}\n\
117                             \n\
118                             Options:\n\
119                             - Merge the branch: chant merge {} or chant finalize {} --merge\n\
120                             - Force archive anyway: chant archive {} --force",
121                            spec.id,
122                            branch_name,
123                            main_branch,
124                            spec.id,
125                            spec.id,
126                            spec.id
127                        );
128                    }
129                }
130            }
131        }
132    }
133
134    let archive_dir = PathBuf::from(ARCHIVE_DIR);
135
136    // Extract date from spec ID (format: YYYY-MM-DD-XXX-abc)
137    let date_part = &spec.id[..10]; // First 10 chars: YYYY-MM-DD
138    let date_dir = archive_dir.join(date_part);
139
140    // Create date-based subdirectory if it doesn't exist
141    if !date_dir.exists() {
142        std::fs::create_dir_all(&date_dir)?;
143    }
144
145    let source_path = specs_dir.join(format!("{}.md", spec.id));
146    let dest_path = date_dir.join(format!("{}.md", spec.id));
147
148    // Move the spec file
149    move_spec_file(&source_path, &dest_path, options.no_stage)?;
150
151    Ok(dest_path)
152}