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