Skip to main content

zenith_cli/commands/workspace/
scratch.rs

1//! Logic for `zenith workspace scratch new`, `list`, and `show`.
2
3use std::path::Path;
4
5use zenith_session::adapter::{OsClock, OsFs};
6use zenith_session::{
7    CandidateEntry, CandidateMeta, CandidateStatus, NewCandidate, StorePaths, list_scratch,
8    put_scratch, resolve_data_dir,
9};
10
11use crate::cli::ScratchNewArgs;
12use crate::commands::serialize_pretty;
13use crate::history::{ensure_doc_id_in, read_doc_id};
14
15// ── status parsing ────────────────────────────────────────────────────────────
16
17/// Parse a status string into [`CandidateStatus`].
18///
19/// Returns `Err` with a human-readable message on unrecognised input.
20pub(crate) fn parse_status(s: &str) -> Result<CandidateStatus, String> {
21    match s {
22        "draft" => Ok(CandidateStatus::Draft),
23        "selected" => Ok(CandidateStatus::Selected),
24        "rejected" => Ok(CandidateStatus::Rejected),
25        other => Err(format!(
26            "unknown status '{other}'; expected one of: draft, selected, rejected"
27        )),
28    }
29}
30
31// ── store helpers ─────────────────────────────────────────────────────────────
32
33pub(crate) fn open_store() -> Result<StorePaths, String> {
34    resolve_data_dir()
35        .map(StorePaths::new)
36        .map_err(|e| e.message)
37}
38
39// ── scratch new ───────────────────────────────────────────────────────────────
40
41/// Outcome of `scratch new`: the created candidate id plus any non-fatal
42/// `warning` surfaced while transparently attaching a `doc-id` (e.g. the file
43/// was stamped but the initial history version could not be recorded).
44#[derive(Debug)]
45pub struct ScratchNewOutcome {
46    pub id: String,
47    pub warning: Option<String>,
48}
49
50/// Record the document bytes as a new scratch candidate.
51///
52/// If the document has no `doc-id` yet, one is transparently minted, stamped
53/// into the file, and recorded as the initial history version before the
54/// candidate is created (see [`ensure_doc_id_in`]). The candidate snapshot is
55/// always the caller-supplied `doc_bytes`; identity comes from the file at
56/// `doc_path` (in production these are the same content).
57pub fn scratch_new(
58    doc_bytes: &[u8],
59    doc_path: &Path,
60    args: &ScratchNewArgs,
61) -> Result<ScratchNewOutcome, String> {
62    let paths = open_store()?;
63    scratch_new_in(&paths, doc_bytes, doc_path, args)
64}
65
66/// Testable variant with an explicit store root.
67pub fn scratch_new_in(
68    paths: &StorePaths,
69    doc_bytes: &[u8],
70    doc_path: &Path,
71    args: &ScratchNewArgs,
72) -> Result<ScratchNewOutcome, String> {
73    // Attach a doc-id on first use (no-op + no history when one already exists).
74    // Identity comes from the file at `doc_path`; the snapshot is always the
75    // caller-supplied `doc_bytes` (in production these are the same content).
76    let ensured = ensure_doc_id_in(paths, doc_path)?;
77    let fs = OsFs;
78    let clock = OsClock;
79    let status = parse_status(&args.status)?;
80    let meta = CandidateMeta {
81        workspace_role: args.workspace_role.as_deref(),
82        promotion_target: args.promotion_target.as_deref(),
83        cleanup_policy: args.cleanup_policy.as_deref(),
84        notes: args.notes.as_deref(),
85    };
86    let entry = put_scratch(
87        &fs,
88        paths,
89        &clock,
90        &ensured.doc_id,
91        NewCandidate {
92            page_id: args.page.as_deref().unwrap_or("*"),
93            snapshot: doc_bytes,
94            status,
95            meta,
96        },
97    )
98    .map_err(|e| e.message)?;
99    Ok(ScratchNewOutcome {
100        id: entry.id,
101        warning: ensured.warning,
102    })
103}
104
105// ── scratch list ──────────────────────────────────────────────────────────────
106
107/// List all scratch candidates for the document at `doc_path`.
108///
109/// Returns a human-readable listing or a JSON array depending on `json`.
110pub fn scratch_list(doc_path: &Path, json: bool) -> Result<String, String> {
111    let paths = open_store()?;
112    scratch_list_in(&paths, doc_path, json)
113}
114
115/// Testable variant with an explicit store root.
116pub fn scratch_list_in(paths: &StorePaths, doc_path: &Path, json: bool) -> Result<String, String> {
117    let doc_id = read_doc_id(doc_path)?;
118    let fs = OsFs;
119    let entries = list_scratch(&fs, paths, &doc_id).map_err(|e| e.message)?;
120
121    if json {
122        Ok(serialize_pretty(&entries))
123    } else if entries.is_empty() {
124        Ok("(no scratch candidates recorded yet)".to_owned())
125    } else {
126        let mut lines = Vec::with_capacity(entries.len());
127        for e in &entries {
128            let status = status_label(e.status);
129            let notes = e.notes.as_deref().unwrap_or("");
130            let notes_part = if notes.is_empty() {
131                String::new()
132            } else {
133                format!("  notes={notes}")
134            };
135            lines.push(format!(
136                "{}  {}  page={}{}",
137                e.id, status, e.page_id, notes_part
138            ));
139        }
140        Ok(lines.join("\n"))
141    }
142}
143
144// ── scratch show ──────────────────────────────────────────────────────────────
145
146/// Show detail for the candidate with `cand_id` in the document at `doc_path`.
147pub fn scratch_show(doc_path: &Path, cand_id: &str, json: bool) -> Result<String, String> {
148    let paths = open_store()?;
149    scratch_show_in(&paths, doc_path, cand_id, json)
150}
151
152/// Testable variant with an explicit store root.
153pub fn scratch_show_in(
154    paths: &StorePaths,
155    doc_path: &Path,
156    cand_id: &str,
157    json: bool,
158) -> Result<String, String> {
159    let doc_id = read_doc_id(doc_path)?;
160    let fs = OsFs;
161    let entries = list_scratch(&fs, paths, &doc_id).map_err(|e| e.message)?;
162    let entry = entries
163        .iter()
164        .find(|e| e.id == cand_id)
165        .ok_or_else(|| format!("candidate not found: {cand_id}"))?;
166
167    if json {
168        Ok(serialize_pretty(entry))
169    } else {
170        Ok(format_entry_detail(entry))
171    }
172}
173
174// ── formatting helpers ────────────────────────────────────────────────────────
175
176fn status_label(s: CandidateStatus) -> &'static str {
177    match s {
178        CandidateStatus::Draft => "draft",
179        CandidateStatus::Selected => "selected",
180        CandidateStatus::Rejected => "rejected",
181    }
182}
183
184fn format_entry_detail(e: &CandidateEntry) -> String {
185    let mut out = format!(
186        "id:      {}\nseq:     {}\npage:    {}\nstatus:  {}\nhash:    {}",
187        e.id,
188        e.seq,
189        e.page_id,
190        status_label(e.status),
191        e.snapshot_hash,
192    );
193    if let Some(r) = &e.workspace_role {
194        out.push_str(&format!("\nrole:    {r}"));
195    }
196    if let Some(t) = &e.promotion_target {
197        out.push_str(&format!("\ntarget:  {t}"));
198    }
199    if let Some(p) = &e.cleanup_policy {
200        out.push_str(&format!("\npolicy:  {p}"));
201    }
202    if let Some(n) = &e.notes {
203        out.push_str(&format!("\nnotes:   {n}"));
204    }
205    if let Some(ts) = e.timestamp_ms {
206        out.push_str(&format!("\nts_ms:   {ts}"));
207    }
208    out
209}