zenith_cli/commands/workspace/
scratch.rs1use 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
15pub(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
31pub(crate) fn open_store() -> Result<StorePaths, String> {
34 resolve_data_dir()
35 .map(StorePaths::new)
36 .map_err(|e| e.message)
37}
38
39#[derive(Debug)]
45pub struct ScratchNewOutcome {
46 pub id: String,
47 pub warning: Option<String>,
48}
49
50pub 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
66pub fn scratch_new_in(
68 paths: &StorePaths,
69 doc_bytes: &[u8],
70 doc_path: &Path,
71 args: &ScratchNewArgs,
72) -> Result<ScratchNewOutcome, String> {
73 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
105pub 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
115pub 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
144pub 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
152pub 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
174fn 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}