1use crate::cli::ArchiveArgs;
2use crate::error::{AppError, AppResult};
3use crate::output::{self, Meta};
4use crate::store;
5use crate::{ItemStatus, is_bl_id, parse_before};
6use jiff::Timestamp;
7use serde::{Deserialize, Serialize};
8use std::collections::{HashMap, HashSet};
9use std::fs::{self, File};
10use std::path::{Path, PathBuf};
11
12const EMPTY_WARNING: &str = "no blotter file yet; archive has nothing to remove";
13const EMPTY_FIX: &str = "Pass an existing --file PATH or omit --file to archive discovered state.";
14
15#[derive(Debug, Serialize, Deserialize)]
16pub struct ArchiveData {
17 pub changed: bool,
18 pub archived: usize,
19 pub kept: usize,
20 pub archive_file: Option<String>,
21 pub backup: Option<String>,
22 pub restore_hint: Option<String>,
23}
24
25struct ArchivePlan {
26 data: ArchiveData,
27 kept_bytes: Vec<u8>,
28 archived_bytes: Vec<u8>,
29 warnings: Vec<String>,
30}
31
32pub fn run(
33 args: ArchiveArgs,
34 file: Option<PathBuf>,
35 pretty: bool,
36 now: Timestamp,
37) -> AppResult<i32> {
38 let cutoff = parse_before(&args.before, now)?;
39 let resolved = store::discover(file)?;
40 let mut warnings = resolved.warnings.clone();
41 let data = if args.dry_run {
42 dry_run(&resolved, &mut warnings, cutoff)?
43 } else {
44 apply(&resolved, &mut warnings, cutoff, now)?
45 };
46 let mut meta = Meta::new();
47 meta.file = Some(resolved.path.to_string_lossy().into_owned());
48 meta.warnings = warnings;
49 output::write_success(data, pretty, meta)
50 .map_err(|error| AppError::from_io(error, Path::new("stdout")))?;
51 Ok(0)
52}
53
54fn dry_run(
55 resolved: &store::ResolvedFile,
56 warnings: &mut Vec<String>,
57 cutoff: Timestamp,
58) -> AppResult<ArchiveData> {
59 let (plan, _) = store::read_or_empty(
60 &resolved.path,
61 resolved.explicit,
62 warnings,
63 EMPTY_WARNING,
64 EMPTY_FIX,
65 empty_plan,
66 |log| {
67 let bytes = store::read_bytes(log, &resolved.path)?;
68 store::check_version(&bytes, &resolved.path)?;
69 Ok(plan_archive(&bytes, cutoff))
70 },
71 )?;
72 warnings.extend(plan.warnings);
73 Ok(plan.data)
74}
75
76fn apply(
77 resolved: &store::ResolvedFile,
78 warnings: &mut Vec<String>,
79 cutoff: Timestamp,
80 now: Timestamp,
81) -> AppResult<ArchiveData> {
82 match store::with_exclusive(&resolved.path, false, |log| {
83 apply_archive(log, &resolved.path, cutoff, now)
84 }) {
85 Ok((data, plan_warnings)) => {
86 warnings.extend(plan_warnings);
87 Ok(data)
88 }
89 Err(error) if error.code == "not_found" && error.exit_code == 66 && !resolved.explicit => {
90 warnings.push(EMPTY_WARNING.into());
91 Ok(empty_plan().data)
92 }
93 Err(error) if error.code == "not_found" && error.exit_code == 66 => {
94 Err(AppError::not_found(
95 format!("blotter file not found: {}", resolved.path.display()),
96 EMPTY_FIX,
97 ))
98 }
99 Err(error) => Err(error),
100 }
101}
102
103fn apply_archive(
104 log: &mut File,
105 path: &Path,
106 cutoff: Timestamp,
107 now: Timestamp,
108) -> AppResult<(ArchiveData, Vec<String>)> {
109 let original = store::read_bytes(log, path)?;
110 store::check_version(&original, path)?;
113 let mut plan = plan_archive(&original, cutoff);
114 if plan.data.archived == 0 {
115 return Ok((plan.data, plan.warnings));
116 }
117
118 let permissions = log
119 .metadata()
120 .map_err(|error| AppError::from_io(error, path))?
121 .permissions();
122 let path = &store::resolve_symlinked_log(path)?;
125 let timestamp = store::backup_timestamp(now);
126 let backup_path = store::suffixed_path(path, &format!(".bak-{timestamp}"));
127 let archive_path = store::suffixed_path(path, &format!(".archive-{timestamp}.jsonl"));
128 let backup = store::write_new_file(&backup_path, &original, &permissions)?;
129 let archive = match store::write_new_file(&archive_path, &plan.archived_bytes, &permissions) {
130 Ok(archive) => archive,
131 Err(error) => {
132 remove_created_outputs(&[backup.as_path()]);
133 return Err(error);
134 }
135 };
136 if let Err(error) = store::replace_log(
137 path,
138 &plan.kept_bytes,
139 &permissions,
140 &format!(".tmp-archive-{}", std::process::id()),
141 ) {
142 remove_created_outputs(&[backup.as_path(), archive.as_path()]);
143 return Err(error);
144 }
145
146 plan.data.changed = true;
147 plan.data.backup = Some(backup.to_string_lossy().into_owned());
148 plan.data.archive_file = Some(archive.to_string_lossy().into_owned());
149 plan.data.restore_hint = Some(store::restore_hint(&backup, path));
150 Ok((plan.data, plan.warnings))
151}
152
153fn plan_archive(bytes: &[u8], cutoff: Timestamp) -> ArchivePlan {
154 let folded = store::fold_bytes_with_lines(bytes);
158 let closed_ids = folded
159 .items
160 .iter()
161 .filter(|item| item.status == ItemStatus::Resolved && is_bl_id(&item.id))
162 .map(|item| item.id.clone())
163 .collect::<HashSet<_>>();
164
165 let mut group_lines = HashMap::<&str, Vec<(usize, bool)>>::new();
166 for folded_line in folded.lines() {
167 if !is_bl_id(&folded_line.id) {
168 continue;
169 }
170 group_lines
171 .entry(folded_line.id.as_str())
172 .or_default()
173 .push((folded_line.line, folded_line.ts < cutoff));
174 }
175
176 let pinned = folded
189 .promotions
190 .iter()
191 .flat_map(|promotion| promotion.sources.iter().cloned())
192 .collect::<HashSet<_>>();
193
194 let eligible_ids = closed_ids
195 .iter()
196 .filter(|id| !pinned.contains(*id))
197 .map(String::as_str)
198 .filter(|id| {
199 group_lines
200 .get(id)
201 .is_some_and(|events| events.iter().all(|(_, is_old)| *is_old))
202 })
203 .collect::<HashSet<_>>();
204 let removed_lines = group_lines
205 .into_iter()
206 .filter(|(id, _)| eligible_ids.contains(id))
207 .flat_map(|(_, lines)| lines.into_iter().map(|(line, _)| line))
208 .collect::<HashSet<_>>();
209
210 let mut kept_bytes = Vec::new();
211 let mut archived_bytes = Vec::new();
212 let mut archived = 0;
213 let mut kept = 0;
214 let body: &[u8] = if bytes == b"\n" { b"" } else { bytes };
217 for (index, raw) in body.split_inclusive(|byte| *byte == b'\n').enumerate() {
218 if removed_lines.contains(&(index + 1)) {
219 archived_bytes.extend_from_slice(raw);
220 if !raw.ends_with(b"\n") {
221 archived_bytes.push(b'\n');
222 }
223 archived += 1;
224 } else {
225 kept_bytes.extend_from_slice(raw);
226 if !(index == 0 && raw == b"\n") {
229 kept += 1;
230 }
231 }
232 }
233
234 ArchivePlan {
235 data: ArchiveData {
236 changed: false,
237 archived,
238 kept,
239 archive_file: None,
240 backup: None,
241 restore_hint: None,
242 },
243 kept_bytes,
244 archived_bytes,
245 warnings: folded.warnings,
246 }
247}
248
249fn empty_plan() -> ArchivePlan {
250 ArchivePlan {
251 data: ArchiveData {
252 changed: false,
253 archived: 0,
254 kept: 0,
255 archive_file: None,
256 backup: None,
257 restore_hint: None,
258 },
259 kept_bytes: Vec::new(),
260 archived_bytes: Vec::new(),
261 warnings: Vec::new(),
262 }
263}
264
265fn remove_created_outputs(paths: &[&Path]) {
266 for path in paths {
267 let _ = fs::remove_file(path);
268 }
269}