zenith_cli/commands/
new.rs1use std::path::{Path, PathBuf};
15
16use zenith_session::StorePaths;
17
18use crate::history::record_edit_in;
19
20#[derive(Debug)]
24pub struct NewErr {
25 pub message: String,
27 pub exit_code: u8,
29}
30
31#[derive(Debug)]
33pub struct NewResult {
34 pub path: PathBuf,
37 pub doc_id: String,
39 pub warning: Option<String>,
41}
42
43pub 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
64pub fn run_in(paths: &StorePaths, path: &Path, name: Option<&str>) -> Result<NewResult, NewErr> {
70 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 let target = target_path(path);
81
82 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 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 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 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
138fn 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
154fn 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
172fn 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
193fn emit(slug: &str, name: &str) -> String {
195 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
213fn 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#[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 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 #[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 assert_eq!(target_path(p), PathBuf::from("made/by/agent/poster.zen"));
295 }
296}