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}