use std::path::{Path, PathBuf};
use zenith_session::StorePaths;
use crate::history::record_edit_in;
#[derive(Debug)]
pub struct NewErr {
pub message: String,
pub exit_code: u8,
}
#[derive(Debug)]
pub struct NewResult {
pub path: PathBuf,
pub doc_id: String,
pub warning: Option<String>,
}
pub fn run(path: &Path, name: Option<&str>) -> Result<NewResult, NewErr> {
let paths = match zenith_session::resolve_data_dir() {
Ok(data_dir) => StorePaths::new(data_dir),
Err(e) => {
return Err(NewErr {
message: format!("cannot resolve data directory: {}", e.message),
exit_code: 2,
});
}
};
run_in(&paths, path, name)
}
pub fn run_in(paths: &StorePaths, path: &Path, name: Option<&str>) -> Result<NewResult, NewErr> {
if path.is_dir() {
return Err(NewErr {
message: format!("'{}' is a directory; provide a file path", path.display()),
exit_code: 2,
});
}
let target = target_path(path);
if target.exists() {
return Err(NewErr {
message: format!("refusing to overwrite existing file '{}'", target.display()),
exit_code: 2,
});
}
let display_name = name.unwrap_or("Untitled");
let slug = slug_for(name, &target);
let raw = emit(&slug, display_name);
let canonical = crate::commands::fmt::run(&raw).map_err(|e| NewErr {
message: format!(
"internal: scaffolded document failed to format: {}",
e.message
),
exit_code: 2,
})?;
let recorded = record_edit_in(paths, &canonical.formatted, &target, "document.new");
if recorded.doc_id.is_empty() {
return Err(NewErr {
message: "internal: no doc-id present after recording".to_string(),
exit_code: 2,
});
}
if let Some(parent) = target.parent()
&& !parent.as_os_str().is_empty()
&& !parent.exists()
{
std::fs::create_dir_all(parent).map_err(|e| NewErr {
message: format!("cannot create directory '{}': {}", parent.display(), e),
exit_code: 2,
})?;
}
std::fs::write(&target, &recorded.bytes).map_err(|e| NewErr {
message: format!("error writing '{}': {}", target.display(), e),
exit_code: 2,
})?;
Ok(NewResult {
path: target,
doc_id: recorded.doc_id,
warning: recorded.warning,
})
}
fn target_path(path: &Path) -> PathBuf {
if path.extension().is_none() {
path.with_extension("zen")
} else {
path.to_path_buf()
}
}
fn slug_for(name: Option<&str>, path: &Path) -> String {
if let Some(n) = name {
let s = slugify(n);
if !s.is_empty() {
return s;
}
}
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
let s = slugify(stem);
if !s.is_empty() {
return s;
}
}
"untitled".to_string()
}
fn slugify(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut prev_dash = false;
for ch in input.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
prev_dash = false;
} else if !prev_dash && !out.is_empty() {
out.push('-');
prev_dash = true;
}
}
while out.ends_with('-') {
out.pop();
}
out
}
fn emit(slug: &str, name: &str) -> String {
let esc = escape_kdl_string(name);
format!(
r##"zenith version=1 {{
project id="proj.{slug}" name="{esc}"
tokens format="zenith-token-v1" {{
token id="color.bg" type="color" value="#ffffff"
}}
document id="doc.{slug}" title="{esc}" {{
page id="page.1" w=(px)1080 h=(px)1080 background=(token)"color.bg" {{}}
}}
}}
"##
)
}
fn escape_kdl_string(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
other => out.push(other),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slug_from_name_takes_precedence() {
let p = Path::new("/x/poster.zen");
assert_eq!(slug_for(Some("Launch Poster!"), p), "launch-poster");
}
#[test]
fn slug_from_stem_when_no_name() {
let p = Path::new("/x/My Cool Doc.zen");
assert_eq!(slug_for(None, p), "my-cool-doc");
}
#[test]
fn slug_falls_back_to_untitled() {
let p = Path::new("/x/___.zen");
assert_eq!(slug_for(Some("!!!"), p), "untitled");
}
#[test]
fn template_formats_clean() {
let raw = emit("demo", "Demo");
let r = crate::commands::fmt::run(&raw).expect("template must format");
let s = String::from_utf8(r.formatted).unwrap();
assert!(s.contains("doc.demo"));
assert!(s.contains("page.1"));
}
#[test]
fn target_appends_zen_when_extension_absent() {
assert_eq!(
target_path(Path::new("poster")),
PathBuf::from("poster.zen")
);
assert_eq!(
target_path(Path::new("poster.zen")),
PathBuf::from("poster.zen")
);
assert_eq!(
target_path(Path::new("notes.txt")),
PathBuf::from("notes.txt")
);
}
#[test]
fn forward_slash_path_parses_cross_platform() {
let p = Path::new("made/by/agent/poster");
assert_eq!(p.extension(), None, "no extension on the slashed path");
assert_eq!(
p.parent(),
Some(Path::new("made/by/agent")),
"`/` splits into parent components on every OS"
);
assert_eq!(target_path(p), PathBuf::from("made/by/agent/poster.zen"));
}
}