Skip to main content

blotter/commands/
archive.rs

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    // Before the plan, and so before any backup or sidecar: a refused log is
111    // byte-identical afterwards and gains no files beside it.
112    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    // A symlinked log is locked and read through the link; the swap must land
123    // on the target, not replace the link with a regular file.
124    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    // One parse pass: the fold carries the (line, id, ts) tuple of every
155    // record-carrying physical line, so the line groupings below cost a walk
156    // over those tuples instead of a second decode of the whole log.
157    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    // A resolved cut named in any promotion's `sources[]` is pinned, however
177    // old the group and however old the promotion (r48): severing provenance
178    // would turn a durable artifact's justification into a dangling ID.
179    // Promotions themselves have no state to close, so they are never in
180    // `closed_ids` and never archive.
181    //
182    // The set is built from every source ID with no kind check. r48 scopes the
183    // pin to a resolved cut, and only a hand-written promotion can name a
184    // dogear — `doctor` already reports that as `dangling_source` — so the one
185    // divergence is over-retaining that dogear's group, which is the
186    // conservative direction and costs a rule the code would otherwise state
187    // twice.
188    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    // A file holding only "\n" has zero physical lines under the scan
215    // contract; split_inclusive would otherwise count one kept line.
216    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            // A leading empty segment has zero physical lines under the scan
227            // contract (r33/TASK-42): its byte survives, its count does not.
228            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}