Skip to main content

apm_core/
archive.rs

1use crate::{config::Config, git, ticket};
2use anyhow::Result;
3use chrono::{DateTime, Utc};
4use std::path::Path;
5
6#[derive(Debug)]
7pub struct ArchiveOutput {
8    pub moves: Vec<(String, String)>,
9    pub dry_run_moves: Vec<(String, String)>,
10    pub archived_count: usize,
11    pub warnings: Vec<String>,
12}
13
14pub fn archive(
15    root: &Path,
16    config: &Config,
17    dry_run: bool,
18    older_than: Option<DateTime<Utc>>,
19) -> Result<ArchiveOutput> {
20    let mut warnings: Vec<String> = Vec::new();
21    let mut dry_run_moves: Vec<(String, String)> = Vec::new();
22
23    let archive_dir = config.tickets.archive_dir.as_ref()
24        .ok_or_else(|| anyhow::anyhow!(
25            "archive_dir is not set in [tickets] config; add `archive_dir = \"archive/tickets\"` to .apm/config.toml"
26        ))?;
27
28    let terminal_states = config.terminal_state_ids();
29
30    let default_branch = &config.project.default_branch;
31    let tickets_dir = config.tickets.dir.to_string_lossy().into_owned();
32    let archive_dir_str = archive_dir.to_string_lossy().into_owned();
33
34    let files = match git::list_files_on_branch(root, default_branch, &tickets_dir) {
35        Ok(f) => f,
36        Err(_) => {
37            return Ok(ArchiveOutput { moves: vec![], dry_run_moves, archived_count: 0, warnings });
38        }
39    };
40
41    let mut moves: Vec<(String, String, String)> = Vec::new();
42
43    for rel_path in &files {
44        if !rel_path.ends_with(".md") {
45            continue;
46        }
47
48        let content = match git::read_from_branch(root, default_branch, rel_path) {
49            Ok(c) => c,
50            Err(_) => {
51                warnings.push(format!("warning: could not read {rel_path} on {default_branch} — skipping"));
52                continue;
53            }
54        };
55
56        let dummy_path = root.join(rel_path);
57        let t = match ticket::Ticket::parse(&dummy_path, &content) {
58            Ok(t) => t,
59            Err(e) => {
60                warnings.push(format!("warning: could not parse {rel_path}: {e} — skipping"));
61                continue;
62            }
63        };
64
65        if !terminal_states.contains(&t.frontmatter.state) {
66            warnings.push(format!(
67                "warning: {} is in non-terminal state '{}' — skipping",
68                rel_path, t.frontmatter.state
69            ));
70            continue;
71        }
72
73        if let Some(threshold) = older_than {
74            if let Some(updated_at) = t.frontmatter.updated_at {
75                if updated_at >= threshold {
76                    continue;
77                }
78            }
79        }
80
81        let filename = rel_path
82            .split('/')
83            .next_back()
84            .unwrap_or(rel_path.as_str());
85        let new_rel_path = format!("{archive_dir_str}/{filename}");
86
87        if dry_run {
88            dry_run_moves.push((rel_path.clone(), new_rel_path));
89        } else {
90            moves.push((rel_path.clone(), new_rel_path, content));
91        }
92    }
93
94    if dry_run {
95        // Original behavior: in dry_run mode, "nothing to archive" is always printed
96        // because the moves vec is always empty in dry_run (bug preserved intentionally).
97        return Ok(ArchiveOutput { moves: vec![], dry_run_moves, archived_count: 0, warnings });
98    }
99
100    if moves.is_empty() {
101        return Ok(ArchiveOutput { moves: vec![], dry_run_moves: vec![], archived_count: 0, warnings });
102    }
103
104    let move_refs: Vec<(&str, &str, &str)> = moves
105        .iter()
106        .map(|(o, n, c)| (o.as_str(), n.as_str(), c.as_str()))
107        .collect();
108
109    git::move_files_on_branch(root, default_branch, &move_refs, "archive: move closed tickets")?;
110
111    let archived_count = moves.len();
112    let actual_moves: Vec<(String, String)> = moves.into_iter().map(|(o, n, _)| (o, n)).collect();
113
114    Ok(ArchiveOutput { moves: actual_moves, dry_run_moves: vec![], archived_count, warnings })
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::config::Config;
121
122    fn make_config(archive_dir: Option<&str>) -> Config {
123        let archive_line = match archive_dir {
124            Some(d) => format!("archive_dir = \"{d}\"\n"),
125            None => String::new(),
126        };
127        let toml = format!(
128            r#"[project]
129name = "test"
130default_branch = "main"
131
132[tickets]
133dir = "tickets"
134{archive_line}
135[[workflow.states]]
136id = "new"
137label = "New"
138
139[[workflow.states]]
140id = "closed"
141label = "Closed"
142terminal = true
143"#
144        );
145        toml::from_str(&toml).unwrap()
146    }
147
148    #[test]
149    fn archive_errors_when_no_archive_dir() {
150        let tmp = tempfile::tempdir().unwrap();
151        let config = make_config(None);
152        let result = archive(tmp.path(), &config, false, None);
153        assert!(result.is_err());
154        let msg = format!("{}", result.unwrap_err());
155        assert!(msg.contains("archive_dir is not set"), "error was: {msg}");
156    }
157
158    #[test]
159    fn archive_dir_config_accepted() {
160        let config = make_config(Some("archive/tickets"));
161        assert_eq!(
162            config.tickets.archive_dir.as_deref(),
163            Some(std::path::Path::new("archive/tickets"))
164        );
165    }
166}