Skip to main content

zenith_cli/
history.rs

1//! Local document-history recording (best-effort attach hook).
2//!
3//! Wraps the `zenith-session` subsystem with the real OS adapters and the
4//! resolved data directory. Every successful write-through edit (`tx --apply`,
5//! `library add`) calls [`record_edit`]: it reconciles the document's identity
6//! (minting and stamping a `doc-id` on first edit) and records the new state as
7//! a Tier-1 session snapshot and a Tier-2 version. Recording is best-effort:
8//! failures become a warning message on `Recorded::warning` — they never block
9//! the edit.
10//!
11//! Navigation functions ([`history_view`], [`undo_edit`], [`redo_edit`]) expose
12//! the session history to the CLI subcommands.
13
14use std::path::Path;
15
16use zenith_core::{KdlAdapter, KdlSource as _};
17use zenith_session::adapter::{OsClock, OsFs, OsRng};
18use zenith_session::{
19    Outcome, RecordOutcome, StorePaths, VersionMeta, VersionOutcome, current_content,
20    list_versions, reconcile, record_state, record_version, resolve_data_dir, resolve_version,
21    version_content,
22};
23
24// ── Public result type ────────────────────────────────────────────────────────
25
26/// The result of a best-effort history recording.
27///
28/// `bytes` are ALWAYS the bytes that should be written to disk — identical to
29/// the input unless a fresh `doc-id` was minted or forked, in which case they
30/// carry the stamped id. `warning` carries a human-readable description of any
31/// non-fatal failure that occurred during recording.
32pub struct Recorded {
33    /// Bytes to write to the `.zen` file (may have a stamped `doc-id`).
34    pub bytes: Vec<u8>,
35    /// The document's stable `doc-id` (existing or freshly minted).
36    pub doc_id: String,
37    /// Non-fatal warning produced during history recording, if any.
38    pub warning: Option<String>,
39}
40
41// ── Public API ────────────────────────────────────────────────────────────────
42
43/// Resolve the data directory and record `content` (the about-to-be-written
44/// `.zen` bytes) as history for the document at `doc_path`.
45///
46/// Returns a [`Recorded`] value: `bytes` are always the correct bytes to write
47/// (possibly with a freshly minted `doc-id` stamped in), and `warning` carries
48/// any non-fatal error message. This function never panics and never returns an
49/// error — all failures are surfaced as `warning: Some(...)`.
50pub fn record_edit(content: &[u8], doc_path: &Path, op_kind: &str) -> Recorded {
51    let paths = match resolve_data_dir() {
52        Ok(data_dir) => StorePaths::new(data_dir),
53        Err(e) => {
54            return Recorded {
55                bytes: content.to_vec(),
56                doc_id: String::new(),
57                warning: Some(format!("history: resolve_data_dir failed: {}", e.message)),
58            };
59        }
60    };
61    record_edit_in(&paths, content, doc_path, op_kind)
62}
63
64/// Same as [`record_edit`] but with an explicit store root (used by tests).
65///
66/// Returns a [`Recorded`] value: `bytes` are always the correct bytes to write,
67/// and `warning` carries any non-fatal error message. Never panics.
68pub fn record_edit_in(
69    paths: &StorePaths,
70    content: &[u8],
71    doc_path: &Path,
72    op_kind: &str,
73) -> Recorded {
74    let fs = OsFs;
75    let clock = OsClock;
76    let rng = OsRng;
77
78    // Parse to read the embedded doc-id (if any).
79    let mut doc = match KdlAdapter.parse(content) {
80        Ok(d) => d,
81        Err(e) => {
82            return Recorded {
83                bytes: content.to_vec(),
84                doc_id: String::new(),
85                warning: Some(format!("history: parse failed: {}", e.message)),
86            };
87        }
88    };
89
90    let reconciled = match reconcile(&fs, paths, &clock, &rng, doc.doc_id.as_deref(), doc_path) {
91        Ok(r) => r,
92        Err(e) => {
93            return Recorded {
94                bytes: content.to_vec(),
95                doc_id: doc.doc_id.unwrap_or_default(),
96                warning: Some(format!("history: reconcile failed: {}", e.message)),
97            };
98        }
99    };
100
101    // If a new id was minted or forked, stamp it into the document and re-format
102    // so the written file carries the identity.
103    let final_bytes: Vec<u8> = match reconciled.outcome {
104        Outcome::Minted | Outcome::Copied { .. } => {
105            doc.doc_id = Some(reconciled.doc_id.clone());
106            match KdlAdapter.format(&doc) {
107                Ok(b) => b,
108                Err(e) => {
109                    return Recorded {
110                        bytes: content.to_vec(),
111                        doc_id: reconciled.doc_id,
112                        warning: Some(format!("history: format failed: {}", e.message)),
113                    };
114                }
115            }
116        }
117        Outcome::Matched | Outcome::Moved { .. } | Outcome::Adopted => content.to_vec(),
118    };
119
120    // Record Tier-1 session snapshot. Best-effort: on failure, return the
121    // (possibly stamped) bytes with a warning so the write still proceeds.
122    if let Err(e) = record_state(
123        &fs,
124        paths,
125        &clock,
126        &rng,
127        &reconciled.doc_id,
128        &final_bytes,
129        Some(op_kind),
130    ) {
131        return Recorded {
132            bytes: final_bytes,
133            doc_id: reconciled.doc_id,
134            warning: Some(format!(
135                "history: record_state failed: {} (file will still be written)",
136                e.message
137            )),
138        };
139    }
140
141    // Record Tier-2 durable version. Best-effort for the same reason.
142    if let Err(e) = record_version(
143        &fs,
144        paths,
145        &clock,
146        &reconciled.doc_id,
147        &final_bytes,
148        VersionMeta {
149            op_kind: Some(op_kind),
150            ..Default::default()
151        },
152    ) {
153        return Recorded {
154            bytes: final_bytes,
155            doc_id: reconciled.doc_id,
156            warning: Some(format!(
157                "history: record_version failed: {} (file will still be written)",
158                e.message
159            )),
160        };
161    }
162
163    Recorded {
164        bytes: final_bytes,
165        doc_id: reconciled.doc_id,
166        warning: None,
167    }
168}
169
170// ── Navigation types ──────────────────────────────────────────────────────────
171
172/// One line in a history listing (maps 1-to-1 to a Tier-2
173/// [`HistoryRecord`](zenith_session::HistoryRecord)).
174#[derive(Debug, Clone, PartialEq)]
175pub struct HistoryLine {
176    /// Stable record id.
177    pub id: String,
178    /// Monotonic sequence number (0-based).
179    pub seq: u64,
180    /// Optional human-facing label / version name.
181    pub label: Option<String>,
182    /// Optional operation-kind tag (e.g. `"tx.apply"`, `"library.add"`).
183    pub op_kind: Option<String>,
184    /// Optional unix-ms timestamp.
185    pub timestamp_ms: Option<u128>,
186}
187
188/// History listing for a single document.
189#[derive(Debug, Clone, PartialEq)]
190pub struct HistoryView {
191    /// The document's stable `doc-id`.
192    pub doc_id: String,
193    /// All Tier-2 version records, oldest first.
194    pub versions: Vec<HistoryLine>,
195    /// `true` when the Tier-1 session has a current HEAD (unsaved session
196    /// content exists), `false` when the session is empty or absent.
197    pub has_session: bool,
198}
199
200/// Outcome of a [`undo_edit`] or [`redo_edit`] navigation call.
201#[derive(Debug, Clone, PartialEq)]
202pub enum NavOutcome {
203    /// Navigation succeeded; the document was rewritten with the restored content.
204    Moved,
205    /// Nothing to undo/redo (already at the boundary, or no session exists yet).
206    NothingToDo,
207}
208
209// ── Navigation helpers ────────────────────────────────────────────────────────
210
211/// Read `doc_path` and return its raw bytes plus its embedded `doc-id`.
212///
213/// Returns a human-readable error if the file cannot be read, cannot be parsed,
214/// or has no `doc-id` attribute yet (meaning it has never been edited through
215/// zenith's history pipeline).
216fn read_doc_with_id(doc_path: &Path) -> Result<(Vec<u8>, String), String> {
217    let bytes = std::fs::read(doc_path)
218        .map_err(|e| format!("cannot read '{}': {e}", doc_path.display()))?;
219    let doc = KdlAdapter
220        .parse(&bytes)
221        .map_err(|e| format!("cannot parse '{}': {}", doc_path.display(), e.message))?;
222    let id = doc.doc_id.ok_or_else(|| {
223        format!(
224            "'{}' has no history yet (no doc-id); edit it with `zenith tx --apply` or \
225             `zenith library add` first",
226            doc_path.display()
227        )
228    })?;
229    Ok((bytes, id))
230}
231
232/// Read `doc_path` and extract its embedded `doc-id`.
233///
234/// Delegates to [`read_doc_with_id`]; returns only the id.
235fn doc_id_at(doc_path: &Path) -> Result<String, String> {
236    read_doc_with_id(doc_path).map(|(_, id)| id)
237}
238
239/// Public thin wrapper around `doc_id_at` for use by sibling command modules.
240///
241/// Returns a human-readable error if the file cannot be read, cannot be
242/// parsed, or has no `doc-id` attribute yet (meaning it has never been
243/// recorded through the history pipeline).
244pub fn read_doc_id(doc_path: &Path) -> Result<String, String> {
245    doc_id_at(doc_path)
246}
247
248// ── ensure_doc_id_in ─────────────────────────────────────────────────────────
249
250/// The result of an [`ensure_doc_id_in`] call.
251pub struct EnsuredDocId {
252    /// The document's stable `doc-id` (existing or freshly minted).
253    pub doc_id: String,
254    /// Non-fatal warning from the recording pipeline when a new id was attached.
255    /// `None` when the doc already had an id (no recording is performed in that case).
256    pub warning: Option<String>,
257}
258
259/// Ensure the document at `doc_path` carries a `doc-id`, attaching one if absent.
260///
261/// If the document already has a `doc-id`, returns it immediately without
262/// recording any history. If it has none, mints + stamps an id through the
263/// same pipeline `tx --apply` uses ([`record_edit_in`]), writes the stamped
264/// bytes back to `doc_path`, and returns the new id.
265///
266/// Use this variant in tests where you want a tempdir-rooted store. The
267/// production call site (`scratch_new`) resolves its own [`StorePaths`] via
268/// `open_store`.
269pub fn ensure_doc_id_in(paths: &StorePaths, doc_path: &Path) -> Result<EnsuredDocId, String> {
270    let bytes = std::fs::read(doc_path)
271        .map_err(|e| format!("cannot read '{}': {e}", doc_path.display()))?;
272
273    // Parse once to check for an existing id.
274    let parsed = KdlAdapter
275        .parse(&bytes)
276        .map_err(|e| format!("cannot parse '{}': {}", doc_path.display(), e.message))?;
277
278    // Already has an id: return immediately, record nothing.
279    if let Some(doc_id) = parsed.doc_id {
280        return Ok(EnsuredDocId {
281            doc_id,
282            warning: None,
283        });
284    }
285
286    // No id yet: mint + stamp + record via the shared edit pipeline, write the
287    // stamped bytes back, then return the now-attached id from the recorded result.
288    let recorded = record_edit_in(paths, &bytes, doc_path, "document.attach");
289    std::fs::write(doc_path, &recorded.bytes)
290        .map_err(|e| format!("cannot write '{}': {e}", doc_path.display()))?;
291    if recorded.doc_id.is_empty() {
292        return Err(format!(
293            "failed to attach a doc-id to '{}' (no id present after recording)",
294            doc_path.display()
295        ));
296    }
297    Ok(EnsuredDocId {
298        doc_id: recorded.doc_id,
299        warning: recorded.warning,
300    })
301}
302
303// ── history_view ──────────────────────────────────────────────────────────────
304
305/// Build the history view for the document at `doc_path`.
306///
307/// Resolves the real data directory automatically. Use [`history_view_in`] in
308/// tests where you want a tempdir-rooted store.
309pub fn history_view(doc_path: &Path) -> Result<HistoryView, String> {
310    let data_dir = resolve_data_dir().map_err(|e| e.message)?;
311    let paths = StorePaths::new(data_dir);
312    history_view_in(&paths, doc_path)
313}
314
315/// Same as [`history_view`] but with an explicit store root (used by tests).
316pub fn history_view_in(paths: &StorePaths, doc_path: &Path) -> Result<HistoryView, String> {
317    let doc_id = doc_id_at(doc_path)?;
318    let fs = OsFs;
319    let versions = list_versions(&fs, paths, &doc_id)
320        .map_err(|e| e.message)?
321        .into_iter()
322        .map(|r| HistoryLine {
323            id: r.id,
324            seq: r.seq,
325            label: r.label,
326            op_kind: r.op_kind,
327            timestamp_ms: r.timestamp_ms,
328        })
329        .collect();
330    let has_session = current_content(&fs, paths, &doc_id)
331        .map_err(|e| e.message)?
332        .is_some();
333    Ok(HistoryView {
334        doc_id,
335        versions,
336        has_session,
337    })
338}
339
340// ── undo_edit ─────────────────────────────────────────────────────────────────
341
342/// Undo the last edit for the document at `doc_path`, rewriting the file in place.
343///
344/// Resolves the real data directory automatically. Use [`undo_edit_in`] in tests.
345pub fn undo_edit(doc_path: &Path) -> Result<NavOutcome, String> {
346    let data_dir = resolve_data_dir().map_err(|e| e.message)?;
347    let paths = StorePaths::new(data_dir);
348    undo_edit_in(&paths, doc_path)
349}
350
351/// Same as [`undo_edit`] but with an explicit store root (used by tests).
352pub fn undo_edit_in(paths: &StorePaths, doc_path: &Path) -> Result<NavOutcome, String> {
353    let doc_id = doc_id_at(doc_path)?;
354    let fs = OsFs;
355    match zenith_session::undo(&fs, paths, &doc_id).map_err(|e| e.message)? {
356        Some(content) => {
357            std::fs::write(doc_path, &content)
358                .map_err(|e| format!("cannot write '{}': {e}", doc_path.display()))?;
359            Ok(NavOutcome::Moved)
360        }
361        None => Ok(NavOutcome::NothingToDo),
362    }
363}
364
365// ── redo_edit ─────────────────────────────────────────────────────────────────
366
367/// Redo the last undone edit for the document at `doc_path`, rewriting the file in place.
368///
369/// Resolves the real data directory automatically. Use [`redo_edit_in`] in tests.
370pub fn redo_edit(doc_path: &Path) -> Result<NavOutcome, String> {
371    let data_dir = resolve_data_dir().map_err(|e| e.message)?;
372    let paths = StorePaths::new(data_dir);
373    redo_edit_in(&paths, doc_path)
374}
375
376/// Same as [`redo_edit`] but with an explicit store root (used by tests).
377pub fn redo_edit_in(paths: &StorePaths, doc_path: &Path) -> Result<NavOutcome, String> {
378    let doc_id = doc_id_at(doc_path)?;
379    let fs = OsFs;
380    match zenith_session::redo(&fs, paths, &doc_id).map_err(|e| e.message)? {
381        Some(content) => {
382            std::fs::write(doc_path, &content)
383                .map_err(|e| format!("cannot write '{}': {e}", doc_path.display()))?;
384            Ok(NavOutcome::Moved)
385        }
386        None => Ok(NavOutcome::NothingToDo),
387    }
388}
389
390// ── name_version ──────────────────────────────────────────────────────────────
391
392/// Save the current on-disk content of `doc_path` as a NAMED Tier-2 version.
393///
394/// Resolves the real data directory automatically. Use [`name_version_in`] in
395/// tests where you want a tempdir-rooted store.
396///
397/// Returns the new (or existing latest) version id (e.g. `"v3"`), or a
398/// human-readable error.
399pub fn name_version(doc_path: &Path, name: &str) -> Result<String, String> {
400    let data_dir = resolve_data_dir().map_err(|e| e.message)?;
401    let paths = StorePaths::new(data_dir);
402    name_version_in(&paths, doc_path, name)
403}
404
405/// Same as [`name_version`] but with an explicit store root (used by tests).
406pub fn name_version_in(paths: &StorePaths, doc_path: &Path, name: &str) -> Result<String, String> {
407    let (bytes, doc_id) = read_doc_with_id(doc_path)?;
408    let fs = OsFs;
409    let clock = OsClock;
410    match record_version(
411        &fs,
412        paths,
413        &clock,
414        &doc_id,
415        &bytes,
416        VersionMeta {
417            label: Some(name),
418            op_kind: Some("named"),
419            ..Default::default()
420        },
421    ) {
422        Ok(VersionOutcome::Recorded { id }) => Ok(id),
423        Ok(VersionOutcome::Unchanged) => {
424            // No content change since the last version: still report success by
425            // returning the latest version id via a fresh resolve of "@head".
426            resolve_version(&fs, paths, &doc_id, "@head").map_err(|e| e.message)
427        }
428        Err(e) => Err(e.message),
429    }
430}
431
432// ── sync_external ─────────────────────────────────────────────────────────────
433
434/// Outcome of a [`sync_external`] or [`sync_external_in`] call.
435#[derive(Debug, Clone, PartialEq)]
436pub enum SyncOutcome {
437    /// The on-disk state differed from HEAD and was captured as a new record.
438    Captured { id: String },
439    /// The on-disk state already matched HEAD; nothing to capture.
440    AlreadyInSync,
441}
442
443/// Capture the current on-disk content of `doc_path` into Tier-1 history as an
444/// external change, if it differs from the session HEAD. Resolves the real data dir.
445pub fn sync_external(doc_path: &Path) -> Result<SyncOutcome, String> {
446    let data_dir = resolve_data_dir().map_err(|e| e.message)?;
447    let paths = StorePaths::new(data_dir);
448    sync_external_in(&paths, doc_path)
449}
450
451/// Testable variant with an explicit store root.
452pub fn sync_external_in(paths: &StorePaths, doc_path: &Path) -> Result<SyncOutcome, String> {
453    let (bytes, doc_id) = read_doc_with_id(doc_path)?;
454    let fs = OsFs;
455    let clock = OsClock;
456    let rng = OsRng;
457    match record_state(&fs, paths, &clock, &rng, &doc_id, &bytes, Some("external")) {
458        Ok(RecordOutcome::Recorded { id }) => Ok(SyncOutcome::Captured { id }),
459        Ok(RecordOutcome::Unchanged) => Ok(SyncOutcome::AlreadyInSync),
460        Err(e) => Err(e.message),
461    }
462}
463
464// ── restore ───────────────────────────────────────────────────────────────────
465
466/// Outcome of a [`restore`] or [`restore_in`] call.
467pub struct RestoreOutcome {
468    /// The resolved version id that was restored (e.g. `"v2"`).
469    pub version_id: String,
470    /// Non-fatal warning from recording the restore as a new edit, if any.
471    pub warning: Option<String>,
472}
473
474/// Resolve `spec` to a past version, write its content back to `doc_path`, and
475/// record the restore as a new (undoable) edit.
476///
477/// Resolves the real data directory automatically. Use [`restore_in`] in tests
478/// where you want a tempdir-rooted store.
479pub fn restore(doc_path: &Path, spec: &str) -> Result<RestoreOutcome, String> {
480    let data_dir = resolve_data_dir().map_err(|e| e.message)?;
481    let paths = StorePaths::new(data_dir);
482    restore_in(&paths, doc_path, spec)
483}
484
485/// Same as [`restore`] but with an explicit store root (used by tests).
486pub fn restore_in(
487    paths: &StorePaths,
488    doc_path: &Path,
489    spec: &str,
490) -> Result<RestoreOutcome, String> {
491    let doc_id = doc_id_at(doc_path)?;
492    let fs = OsFs;
493    let version_id = resolve_version(&fs, paths, &doc_id, spec).map_err(|e| e.message)?;
494    let content = version_content(&fs, paths, &doc_id, &version_id).map_err(|e| e.message)?;
495    // Record the restore as a new write-through edit (Tier-1 + Tier-2), then write.
496    let recorded = record_edit_in(paths, &content, doc_path, "restore");
497    std::fs::write(doc_path, &recorded.bytes)
498        .map_err(|e| format!("cannot write '{}': {e}", doc_path.display()))?;
499    Ok(RestoreOutcome {
500        version_id,
501        warning: recorded.warning,
502    })
503}