Skip to main content

zenith_cli/commands/
new.rs

1//! Pure-ish logic for `zenith new`.
2//!
3//! Scaffolds a minimal, valid `.zen` document at a fresh path, with a `doc-id`
4//! already minted + stamped and its initial Tier-2 history version recorded.
5//! This gives an agent or GUI "File > New" a valid starting point without
6//! hand-authoring boilerplate.
7//!
8//! The template is synthesized from a slug (derived from `--name`, else from the
9//! target path's file stem), canonicalized through the engine formatter
10//! ([`crate::commands::fmt::run`]), then run through the shared history pipeline
11//! ([`crate::history::record_edit_in`]) with op_kind `"document.new"` so the
12//! written bytes carry the stamped identity and the first version is durable.
13
14use std::path::{Path, PathBuf};
15
16use zenith_session::StorePaths;
17
18use crate::history::record_edit_in;
19
20// ── Result / error types ────────────────────────────────────────────────────────
21
22/// Error from `zenith new`: a message plus a process exit code.
23#[derive(Debug)]
24pub struct NewErr {
25    /// Human-readable message.
26    pub message: String,
27    /// Exit code (2 for refusal / input / internal failure).
28    pub exit_code: u8,
29}
30
31/// The outcome of a successful `zenith new` run.
32#[derive(Debug)]
33pub struct NewResult {
34    /// The path the document was actually written to (may differ from the
35    /// requested path when a default `.zen` extension was appended).
36    pub path: PathBuf,
37    /// The stamped `doc-id` of the freshly created document.
38    pub doc_id: String,
39    /// Non-fatal warning produced during history recording, if any.
40    pub warning: Option<String>,
41}
42
43// ── Public API ──────────────────────────────────────────────────────────────────
44
45/// Scaffold a new minimal valid `.zen` document at `path`, resolving the real
46/// data directory for history recording.
47///
48/// Refuses to overwrite an existing `path` (returns `Err` without touching it).
49/// `name` is the optional display name; when absent, "Untitled" is used and the
50/// slug is derived from `path`'s file stem.
51pub fn run(path: &Path, name: Option<&str>) -> Result<NewResult, NewErr> {
52    let paths = match zenith_session::resolve_data_dir() {
53        Ok(data_dir) => StorePaths::new(data_dir),
54        Err(e) => {
55            return Err(NewErr {
56                message: format!("cannot resolve data directory: {}", e.message),
57                exit_code: 2,
58            });
59        }
60    };
61    run_in(&paths, path, name)
62}
63
64/// Same as [`run`] but with an explicit store root (used by tests).
65///
66/// Refuses to overwrite an existing `path`. On success, the file at `path` has
67/// been written with a freshly minted + stamped `doc-id`, and the initial
68/// version has been recorded into `paths`.
69pub fn run_in(paths: &StorePaths, path: &Path, name: Option<&str>) -> Result<NewResult, NewErr> {
70    // A directory can't be a document target.
71    if path.is_dir() {
72        return Err(NewErr {
73            message: format!("'{}' is a directory; provide a file path", path.display()),
74            exit_code: 2,
75        });
76    }
77
78    // Default the `.zen` extension when the path has none (`poster` → `poster.zen`);
79    // an explicit extension is respected as given.
80    let target = target_path(path);
81
82    // Bright-line safety: never overwrite an existing file.
83    if target.exists() {
84        return Err(NewErr {
85            message: format!("refusing to overwrite existing file '{}'", target.display()),
86            exit_code: 2,
87        });
88    }
89
90    let display_name = name.unwrap_or("Untitled");
91    let slug = slug_for(name, &target);
92
93    // Synthesize the template, then canonicalize through the engine formatter so
94    // the output is valid + canonical and any emission bug surfaces as an error.
95    let raw = emit(&slug, display_name);
96    let canonical = crate::commands::fmt::run(&raw).map_err(|e| NewErr {
97        message: format!(
98            "internal: scaffolded document failed to format: {}",
99            e.message
100        ),
101        exit_code: 2,
102    })?;
103
104    // Mint + stamp the doc-id and record the initial version through the shared
105    // history pipeline. The returned bytes carry the stamped identity.
106    let recorded = record_edit_in(paths, &canonical.formatted, &target, "document.new");
107
108    if recorded.doc_id.is_empty() {
109        return Err(NewErr {
110            message: "internal: no doc-id present after recording".to_string(),
111            exit_code: 2,
112        });
113    }
114
115    // Create any missing parent directories so `new sub/dir/doc.zen` just works.
116    if let Some(parent) = target.parent()
117        && !parent.as_os_str().is_empty()
118        && !parent.exists()
119    {
120        std::fs::create_dir_all(parent).map_err(|e| NewErr {
121            message: format!("cannot create directory '{}': {}", parent.display(), e),
122            exit_code: 2,
123        })?;
124    }
125
126    std::fs::write(&target, &recorded.bytes).map_err(|e| NewErr {
127        message: format!("error writing '{}': {}", target.display(), e),
128        exit_code: 2,
129    })?;
130
131    Ok(NewResult {
132        path: target,
133        doc_id: recorded.doc_id,
134        warning: recorded.warning,
135    })
136}
137
138// ── Helpers ─────────────────────────────────────────────────────────────────────
139
140/// The on-disk target path: append a default `.zen` extension when `path` has
141/// none, else respect the path exactly as given.
142///
143/// This relies only on `std::path`, which treats `/` as a separator on every OS
144/// (including Windows) — so a Unix-style path typed by an agent on Windows
145/// parses and extends correctly.
146fn target_path(path: &Path) -> PathBuf {
147    if path.extension().is_none() {
148        path.with_extension("zen")
149    } else {
150        path.to_path_buf()
151    }
152}
153
154/// Derive an id slug from the explicit `name` if present, else from `path`'s
155/// file stem, falling back to `"untitled"` when neither yields usable text.
156fn slug_for(name: Option<&str>, path: &Path) -> String {
157    if let Some(n) = name {
158        let s = slugify(n);
159        if !s.is_empty() {
160            return s;
161        }
162    }
163    if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
164        let s = slugify(stem);
165        if !s.is_empty() {
166            return s;
167        }
168    }
169    "untitled".to_string()
170}
171
172/// Reduce arbitrary text to a lowercase `[a-z0-9-]` slug: alphanumerics are
173/// lowercased, every other run collapses to a single `-`, and leading/trailing
174/// `-` are trimmed.
175fn slugify(input: &str) -> String {
176    let mut out = String::with_capacity(input.len());
177    let mut prev_dash = false;
178    for ch in input.chars() {
179        if ch.is_ascii_alphanumeric() {
180            out.push(ch.to_ascii_lowercase());
181            prev_dash = false;
182        } else if !prev_dash && !out.is_empty() {
183            out.push('-');
184            prev_dash = true;
185        }
186    }
187    while out.ends_with('-') {
188        out.pop();
189    }
190    out
191}
192
193/// Emit the minimal valid `.zen` source for `slug` / `name`.
194fn emit(slug: &str, name: &str) -> String {
195    // `name` may contain characters that need escaping inside a KDL string; the
196    // formatter re-quotes canonically, so a backslash/quote escape here is enough
197    // to keep the intermediate source parseable.
198    let esc = escape_kdl_string(name);
199    format!(
200        r##"zenith version=1 {{
201  project id="proj.{slug}" name="{esc}"
202  tokens format="zenith-token-v1" {{
203    token id="color.bg" type="color" value="#ffffff"
204  }}
205  document id="doc.{slug}" title="{esc}" {{
206    page id="page.1" w=(px)1080 h=(px)1080 background=(token)"color.bg" {{}}
207  }}
208}}
209"##
210    )
211}
212
213/// Escape characters that require backslash encoding inside a double-quoted KDL string.
214fn escape_kdl_string(s: &str) -> String {
215    let mut out = String::with_capacity(s.len());
216    for ch in s.chars() {
217        match ch {
218            '\\' => out.push_str("\\\\"),
219            '"' => out.push_str("\\\""),
220            '\n' => out.push_str("\\n"),
221            '\r' => out.push_str("\\r"),
222            '\t' => out.push_str("\\t"),
223            other => out.push(other),
224        }
225    }
226    out
227}
228
229// ── Tests ───────────────────────────────────────────────────────────────────────
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn slug_from_name_takes_precedence() {
237        let p = Path::new("/x/poster.zen");
238        assert_eq!(slug_for(Some("Launch Poster!"), p), "launch-poster");
239    }
240
241    #[test]
242    fn slug_from_stem_when_no_name() {
243        let p = Path::new("/x/My Cool Doc.zen");
244        assert_eq!(slug_for(None, p), "my-cool-doc");
245    }
246
247    #[test]
248    fn slug_falls_back_to_untitled() {
249        let p = Path::new("/x/___.zen");
250        assert_eq!(slug_for(Some("!!!"), p), "untitled");
251    }
252
253    #[test]
254    fn template_formats_clean() {
255        let raw = emit("demo", "Demo");
256        let r = crate::commands::fmt::run(&raw).expect("template must format");
257        let s = String::from_utf8(r.formatted).unwrap();
258        assert!(s.contains("doc.demo"));
259        assert!(s.contains("page.1"));
260    }
261
262    #[test]
263    fn target_appends_zen_when_extension_absent() {
264        assert_eq!(
265            target_path(Path::new("poster")),
266            PathBuf::from("poster.zen")
267        );
268        // An explicit extension (even a non-`.zen` one) is respected as given.
269        assert_eq!(
270            target_path(Path::new("poster.zen")),
271            PathBuf::from("poster.zen")
272        );
273        assert_eq!(
274            target_path(Path::new("notes.txt")),
275            PathBuf::from("notes.txt")
276        );
277    }
278
279    // `/` is a path separator on EVERY OS, including Windows. These assertions
280    // lock the cross-platform property the command relies on: a Unix-style path
281    // an agent types on Windows still parses into the right parent + extension.
282    // (A backslash is only a separator on Windows, so it cannot be asserted
283    // portably here — it would be a literal filename char on Unix.)
284    #[test]
285    fn forward_slash_path_parses_cross_platform() {
286        let p = Path::new("made/by/agent/poster");
287        assert_eq!(p.extension(), None, "no extension on the slashed path");
288        assert_eq!(
289            p.parent(),
290            Some(Path::new("made/by/agent")),
291            "`/` splits into parent components on every OS"
292        );
293        // `.zen` is appended onto the final component only, parents preserved.
294        assert_eq!(target_path(p), PathBuf::from("made/by/agent/poster.zen"));
295    }
296}