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        if !content.starts_with("+++") {
57            continue;
58        }
59        let dummy_path = root.join(rel_path);
60        let t = match ticket::Ticket::parse(&dummy_path, &content) {
61            Ok(t) => t,
62            Err(e) => {
63                warnings.push(format!("warning: could not parse {rel_path}: {e} — skipping"));
64                continue;
65            }
66        };
67
68        let (content, t) = if terminal_states.contains(&t.frontmatter.state) {
69            (content, t)
70        } else if let Some(ticket_branch) = t.frontmatter.branch.clone() {
71            match git::read_from_branch(root, &ticket_branch, rel_path) {
72                Ok(branch_content) => match ticket::Ticket::parse(&dummy_path, &branch_content) {
73                    Ok(branch_t) if terminal_states.contains(&branch_t.frontmatter.state) => {
74                        (branch_content, branch_t)
75                    }
76                    Ok(branch_t) => {
77                        warnings.push(format!(
78                            "warning: {} is in non-terminal state '{}' — skipping",
79                            rel_path, branch_t.frontmatter.state
80                        ));
81                        continue;
82                    }
83                    Err(_) => {
84                        warnings.push(format!(
85                            "warning: {} is in non-terminal state '{}' — skipping",
86                            rel_path, t.frontmatter.state
87                        ));
88                        continue;
89                    }
90                },
91                Err(_) => {
92                    warnings.push(format!(
93                        "warning: {} is in non-terminal state '{}' — skipping",
94                        rel_path, t.frontmatter.state
95                    ));
96                    continue;
97                }
98            }
99        } else {
100            warnings.push(format!(
101                "warning: {} is in non-terminal state '{}' — skipping",
102                rel_path, t.frontmatter.state
103            ));
104            continue;
105        };
106
107        if let Some(threshold) = older_than {
108            if let Some(updated_at) = t.frontmatter.updated_at {
109                if updated_at >= threshold {
110                    continue;
111                }
112            }
113        }
114
115        let filename = rel_path
116            .split('/')
117            .next_back()
118            .unwrap_or(rel_path.as_str());
119        let new_rel_path = format!("{archive_dir_str}/{filename}");
120
121        if dry_run {
122            dry_run_moves.push((rel_path.clone(), new_rel_path));
123        } else {
124            moves.push((rel_path.clone(), new_rel_path, content));
125        }
126    }
127
128    if dry_run {
129        // Original behavior: in dry_run mode, "nothing to archive" is always printed
130        // because the moves vec is always empty in dry_run (bug preserved intentionally).
131        return Ok(ArchiveOutput { moves: vec![], dry_run_moves, archived_count: 0, warnings });
132    }
133
134    if moves.is_empty() {
135        return Ok(ArchiveOutput { moves: vec![], dry_run_moves: vec![], archived_count: 0, warnings });
136    }
137
138    let move_refs: Vec<(&str, &str, &str)> = moves
139        .iter()
140        .map(|(o, n, c)| (o.as_str(), n.as_str(), c.as_str()))
141        .collect();
142
143    git::move_files_on_branch(root, default_branch, &move_refs, "archive: move closed tickets")?;
144
145    let archived_count = moves.len();
146    let actual_moves: Vec<(String, String)> = moves.into_iter().map(|(o, n, _)| (o, n)).collect();
147
148    Ok(ArchiveOutput { moves: actual_moves, dry_run_moves: vec![], archived_count, warnings })
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::config::Config;
155
156    fn make_config(archive_dir: Option<&str>) -> Config {
157        let archive_line = match archive_dir {
158            Some(d) => format!("archive_dir = \"{d}\"\n"),
159            None => String::new(),
160        };
161        let toml = format!(
162            r#"[project]
163name = "test"
164default_branch = "main"
165
166[tickets]
167dir = "tickets"
168{archive_line}
169[[workflow.states]]
170id = "new"
171label = "New"
172
173[[workflow.states]]
174id = "closed"
175label = "Closed"
176terminal = true
177"#
178        );
179        toml::from_str(&toml).unwrap()
180    }
181
182    #[test]
183    fn archive_errors_when_no_archive_dir() {
184        let tmp = tempfile::tempdir().unwrap();
185        let config = make_config(None);
186        let result = archive(tmp.path(), &config, false, None);
187        assert!(result.is_err());
188        let msg = format!("{}", result.unwrap_err());
189        assert!(msg.contains("archive_dir is not set"), "error was: {msg}");
190    }
191
192    #[test]
193    fn archive_dir_config_accepted() {
194        let config = make_config(Some("archive/tickets"));
195        assert_eq!(
196            config.tickets.archive_dir.as_deref(),
197            Some(std::path::Path::new("archive/tickets"))
198        );
199    }
200}