1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
//! `tix archive <issue>…` / `tix restore <issue>…` (issue #1492).
//!
//! Archiving is a **rename**, not a delete: an issue's markdown file is
//! dot-prefixed in place (`docs/issues/<assignee>/x.md` →
//! `docs/issues/<assignee>/.x.md`), so its number, status and every other
//! frontmatter field survive and it never moves between directories. The
//! filename is the only marker; there is no `archived:` frontmatter key.
//!
//! A batch processes every selector independently. One item's failure — a
//! redundant operation, an ambiguous selector, a missing file — is reported on
//! its own and does **not** abort or roll back the others, which is why this
//! handler returns an [`ExitCode`] rather than a `Result<()>` and is routed by
//! `cli::run` rather than by `commands::dispatch`.
use crate::cli::ExitCode;
use crate::error::Result;
use crate::storage::Store;
/// Which direction of the operation a batch is performing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
Archive,
Restore,
}
impl Action {
fn past_tense(self) -> &'static str {
match self {
Action::Archive => "Archived",
Action::Restore => "Restored",
}
}
}
/// Run one archive/restore batch over `selectors` and report a process status.
///
/// Each selector produces exactly one outcome line on success (`Archived
/// alice/.2026-01-01-x.md`) or one diagnostic on stderr on failure. The
/// returned code is the first failure's, so a batch that partly succeeded still
/// exits non-zero; an unknown-error failure maps through
/// [`crate::cli::exit_code_for`] so a `NotFound` stays exit `2`.
pub fn run(store: &Store, action: Action, selectors: &[String]) -> Result<ExitCode> {
let mut code = ExitCode::Success;
for selector in selectors {
// One transaction **per selector** (#1580). The batch contract is one
// outcome per selector — a failure on one item must not roll back a
// sibling — so this is deliberately not a transaction over the whole
// batch. Within one selector, `set_archived` renames the file and then
// writes its History event, and a failure between the two must leave the
// file exactly where and as it was.
let outcome = store.transaction(|| match action {
Action::Archive => store.archive(selector),
Action::Restore => store.restore(selector),
});
match outcome {
Ok(issue) => println!("{} {}", action.past_tense(), issue.filename),
Err(e) => {
eprintln!("{}", crate::error::render_error(&e));
if code == ExitCode::Success {
code = crate::cli::exit_code_for(&e);
}
}
}
}
Ok(code)
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use std::fs;
use tempfile::TempDir;
fn make_store() -> (TempDir, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(dir.join("alice")).unwrap();
let mut config = crate::config::Config {
issues_dir: dir,
..crate::config::Config::default()
};
// The fixtures use bare filenames; keep the tests about archive semantics,
// not auto-migration.
config.layout.move_on_change = false;
(tmp, Store::open(&config).unwrap())
}
fn write_issue(store: &Store, name: &str, number: u32, title: &str) {
let contents = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\n\
priority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nbody\n"
);
fs::write(store.issues_dir.join(name), contents).unwrap();
}
#[test]
fn one_failure_does_not_roll_back_another_item() {
let (_tmp, store) = make_store();
write_issue(&store, "alice/2026-01-01-a.md", 1, "A");
write_issue(&store, "alice/2026-01-01-b.md", 2, "B");
// First selector is bogus, second is real: the batch must still archive #2.
let code = run(&store, Action::Archive, &["999".into(), "2".into()]).unwrap();
assert_ne!(code, ExitCode::Success, "the bogus item fails the batch");
assert!(
store.issues_dir.join("alice/.2026-01-01-b.md").is_file(),
"the valid item is archived despite the earlier failure"
);
assert!(store.issues_dir.join("alice/2026-01-01-a.md").is_file());
}
#[test]
fn redundant_operation_reports_but_other_items_proceed() {
let (_tmp, store) = make_store();
write_issue(&store, "alice/2026-01-01-a.md", 1, "A");
write_issue(&store, "alice/2026-01-01-b.md", 2, "B");
// Archive #1 twice, then #2: the second #1 is redundant, #2 still runs.
let code = run(
&store,
Action::Archive,
&["1".into(), "1".into(), "2".into()],
)
.unwrap();
assert_ne!(code, ExitCode::Success);
assert!(store.issues_dir.join("alice/.2026-01-01-a.md").is_file());
assert!(store.issues_dir.join("alice/.2026-01-01-b.md").is_file());
}
#[test]
fn all_success_returns_success() {
let (_tmp, store) = make_store();
write_issue(&store, "alice/2026-01-01-a.md", 1, "A");
let code = run(&store, Action::Archive, &["1".into()]).unwrap();
assert_eq!(code, ExitCode::Success);
let code = run(&store, Action::Restore, &["1".into()]).unwrap();
assert_eq!(code, ExitCode::Success);
assert!(store.issues_dir.join("alice/2026-01-01-a.md").is_file());
}
}