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 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}