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