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