Something went wrong. Try again.
A Rust clone of doing by Brett Terpstra — a command line tool for remembering what you were doing and tracking what you've done. doing.aaronmallen.dev
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432use std::{fs, path::Path};
use clap::Args;use doing_plugins::default_registry;
use crate::{Error, Result, cli::AppContext};
/// Manage export format templates.////// Lists, displays, and saves export templates used by output format/// plugins (HTML, CSS, etc.). Use `--list` to see available template/// names and `--path` to locate the templates directory.////// # Examples////// ```text/// doing template --list # list available export template names/// doing template --path # show the templates directory path/// doing template css # show the CSS export template/// doing template --save myhtml # save a custom template/// ```#[derive(Args, Clone, Debug)]pub struct Command { /// Output in a single column (for scripting) #[arg(short, long, action = clap::ArgAction::SetTrue, overrides_with = "no_column")] column: bool,
#[arg(long = "no-column", action = clap::ArgAction::SetTrue, hide = true, overrides_with = "column")] no_column: bool,
/// List available export template names #[arg(short, long)] list: bool,
/// Template name to display #[arg(index = 1, value_name = "NAME")] name: Option<String>,
/// Show the path to the templates directory #[arg(long)] path: bool,
/// Save a custom template with the given name #[arg(short, long)] save: Option<String>,}
impl Command { pub fn call(&self, ctx: &mut AppContext) -> Result<()> { if self.path { return show_path(ctx); }
if let Some(ref name) = self.save { validate_template_name(name)?; return save_template(name, ctx); }
let use_column = self.column && !self.no_column;
if self.list || self.name.is_none() { return list_templates(use_column, ctx); }
if let Some(ref name) = self.name { validate_template_name(name)?; return show_template(name, ctx); }
list_templates(use_column, ctx) }}
fn builtin_css() -> &'static str { // The default CSS used by the HTML export plugin doing_plugins::html::DEFAULT_CSS}
fn list_templates(column: bool, ctx: &AppContext) -> Result<()> { let registry = default_registry()?; let formats = registry.available_formats();
// Also check for user-saved templates on disk let mut names: Vec<String> = formats.iter().map(|s| s.to_string()).collect();
let template_dir = &ctx.config.template_path; if template_dir.is_dir() && let Ok(entries) = fs::read_dir(template_dir) { for entry in entries.flatten() { let file_name = entry.file_name(); let name = file_name.to_string_lossy(); let stem = name .strip_suffix(".css") .or_else(|| name.strip_suffix(".erb")) .unwrap_or(&name); if !names.contains(&stem.to_string()) { names.push(stem.to_string()); } } }
names.sort();
if column { for name in &names { println!("{name}"); } } else { let label_width = names.iter().map(|n| n.len()).max().unwrap_or(0) + 2; for name in &names { let source = template_source(name, template_dir); println!("{name:<label_width$}{source}"); } }
Ok(())}
fn save_template(name: &str, ctx: &AppContext) -> Result<()> { validate_template_name(name)?; let template_dir = &ctx.config.template_path; fs::create_dir_all(template_dir) .map_err(|e| crate::Error::Config(format!("failed to create templates directory: {e}")))?;
let dest = template_dir.join(name); if dest.exists() { ctx.status(format!("Template already exists: {}", dest.display())); return Ok(()); }
// Write a placeholder template file fs::write(&dest, "").map_err(|e| crate::Error::Config(format!("failed to save template \"{name}\": {e}")))?;
ctx.status(format!("Saved template to {}", dest.display())); Ok(())}
fn show_path(ctx: &AppContext) -> Result<()> { println!("{}", ctx.config.template_path.display()); Ok(())}
fn show_template(name: &str, ctx: &AppContext) -> Result<()> { validate_template_name(name)?; // Check for a user-saved template file first let template_dir = &ctx.config.template_path; let candidates = [ template_dir.join(name), template_dir.join(format!("{name}.css")), template_dir.join(format!("{name}.erb")), ];
for candidate in &candidates { if candidate.is_file() { let content = fs::read_to_string(candidate) .map_err(|e| crate::Error::Config(format!("failed to read template \"{name}\": {e}")))?; if !content.is_empty() { print!("{content}"); return Ok(()); } } }
// Fall back to built-in export template content match name { "css" => { println!("{}", builtin_css()); } _ => { let registry = default_registry()?; if registry.resolve(name).is_some() { println!("Built-in export format: {name}"); println!("No editable template file. Use --save {name} to create one."); } else { return Err(crate::Error::Config(format!("unknown template: \"{name}\""))); } } }
Ok(())}
/// Describe where a template comes from.fn template_source(name: &str, template_dir: &Path) -> &'static str { let candidates = [ template_dir.join(name), template_dir.join(format!("{name}.css")), template_dir.join(format!("{name}.erb")), ];
for candidate in &candidates { if candidate.is_file() { return "(custom)"; } }
"(built-in)"}
/// Validate that a template name is a plain identifier without path traversal.////// Rejects names containing path separators (`/`, `\`), parent-directory/// references (`..`), or absolute paths.fn validate_template_name(name: &str) -> Result<()> { if name.is_empty() { return Err(Error::Config("template name must not be empty".into())); }
if name.contains('/') || name.contains('\\') || name.contains("..") || Path::new(name).is_absolute() { return Err(Error::Config(format!( "invalid template name \"{name}\": must be a plain name without path separators" ))); }
Ok(())}
#[cfg(test)]mod test {
use super::*;
fn sample_ctx() -> AppContext { AppContext::for_test(std::path::PathBuf::from("/tmp/test_doing.md")) }
mod call { use super::*;
#[test] fn it_defaults_to_list() { let cmd = Command { column: false, list: false, name: None, no_column: false, path: false, save: None, }; let mut ctx = sample_ctx();
let result = cmd.call(&mut ctx);
assert!(result.is_ok()); }
#[test] fn it_lists_when_flag_set() { let cmd = Command { column: false, list: true, name: None, no_column: false, path: false, save: None, }; let mut ctx = sample_ctx();
let result = cmd.call(&mut ctx);
assert!(result.is_ok()); }
#[test] fn it_shows_css_template() { let cmd = Command { column: false, list: false, name: Some("css".into()), no_column: false, path: false, save: None, }; let mut ctx = sample_ctx();
let result = cmd.call(&mut ctx);
assert!(result.is_ok()); }
#[test] fn it_shows_path() { let cmd = Command { column: false, list: false, name: None, no_column: false, path: true, save: None, }; let mut ctx = sample_ctx();
let result = cmd.call(&mut ctx);
assert!(result.is_ok()); } }
mod list_templates { use super::*;
#[test] fn it_lists_builtin_formats() { let ctx = sample_ctx();
let result = super::super::list_templates(false, &ctx);
assert!(result.is_ok()); }
#[test] fn it_lists_in_column_mode() { let ctx = sample_ctx();
let result = super::super::list_templates(true, &ctx);
assert!(result.is_ok()); } }
mod show_path { use super::*;
#[test] fn it_runs_without_error() { let ctx = sample_ctx();
let result = super::super::show_path(&ctx);
assert!(result.is_ok()); } }
mod show_template { use super::*;
#[test] fn it_returns_error_for_unknown_template() { let ctx = sample_ctx();
let result = super::super::show_template("nonexistent", &ctx);
assert!(result.is_err()); }
#[test] fn it_shows_builtin_css() { let ctx = sample_ctx();
let result = super::super::show_template("css", &ctx);
assert!(result.is_ok()); }
#[test] fn it_shows_known_export_format() { let ctx = sample_ctx();
let result = super::super::show_template("json", &ctx);
assert!(result.is_ok()); } }
mod template_source { use std::path::Path;
use pretty_assertions::assert_eq;
#[test] fn it_returns_builtin_for_known_format() { let dir = Path::new("/nonexistent");
assert_eq!(super::super::template_source("json", dir), "(built-in)"); } }
mod validate_template_name { #[test] fn it_accepts_plain_names() { assert!(super::super::validate_template_name("css").is_ok()); assert!(super::super::validate_template_name("my-template").is_ok()); assert!(super::super::validate_template_name("custom_html").is_ok()); }
#[test] fn it_rejects_absolute_paths() { let result = super::super::validate_template_name("/etc/passwd");
assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("invalid template name")); }
#[test] fn it_rejects_empty_names() { let result = super::super::validate_template_name("");
assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("must not be empty")); }
#[test] fn it_rejects_forward_slash_traversal() { let result = super::super::validate_template_name("../secret");
assert!(result.is_err()); }
#[test] fn it_rejects_nested_path_segments() { let result = super::super::validate_template_name("foo/bar");
assert!(result.is_err()); }
#[test] fn it_rejects_parent_directory_references() { let result = super::super::validate_template_name("templates/../secret");
assert!(result.is_err()); }
#[test] fn it_rejects_windows_backslash_traversal() { let result = super::super::validate_template_name("..\\secret");
assert!(result.is_err()); } }}