use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
use crate::skills::{clamp, first_prose_line, split_front_matter};
pub const MAX_TEMPLATES: usize = 64;
const DESCRIPTION_CAP: usize = 240;
const ARGUMENTS: &str = "$ARGUMENTS";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Template {
pub name: String,
pub description: String,
pub body: String,
}
#[derive(Debug, Clone, Default)]
pub struct Templates {
templates: Vec<Template>,
}
impl Templates {
pub fn none() -> Self {
Self::default()
}
pub fn discover(dir: impl AsRef<Path>) -> Result<Self> {
let dir = dir.as_ref();
if !dir.exists() {
return Err(Error::Config(format!(
"templates directory {} does not exist",
dir.display()
)));
}
if !dir.is_dir() {
return Err(Error::Config(format!(
"templates path {} is not a directory; point it at a directory of markdown files, \
not at one file",
dir.display()
)));
}
let mut entries: Vec<PathBuf> = std::fs::read_dir(dir)?
.filter_map(|e| e.ok())
.map(|e| e.path())
.collect();
entries.sort();
let mut templates: Vec<(Template, PathBuf)> = Vec::new();
for path in entries {
let file = if path.is_dir() {
let candidate = path.join("TEMPLATE.md");
if !candidate.is_file() {
continue;
}
candidate
} else if path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("md"))
{
path.clone()
} else {
continue;
};
let text = std::fs::read_to_string(&file)?;
let (front_name, front_desc, body) = split_front_matter(&text);
let name = front_name.unwrap_or_else(|| default_name(&file));
let description = front_desc
.or_else(|| first_prose_line(body))
.unwrap_or_else(|| "(no description)".to_string());
templates.push((
Template {
name,
description: clamp(&description, DESCRIPTION_CAP),
body: body.trim().to_string(),
},
file,
));
if templates.len() > MAX_TEMPLATES {
return Err(Error::Config(format!(
"templates directory {} holds more than {MAX_TEMPLATES} templates. The set is \
rejected rather than silently reduced — split the directory or point at a \
smaller one",
dir.display()
)));
}
}
templates.sort_by(|a, b| a.0.name.cmp(&b.0.name));
for pair in templates.windows(2) {
if pair[0].0.name == pair[1].0.name {
return Err(Error::Config(format!(
"two templates are both named {:?} ({} and {}); a template is addressed by \
name, so the set is ambiguous",
pair[0].0.name,
pair[0].1.display(),
pair[1].1.display()
)));
}
}
Ok(Self {
templates: templates.into_iter().map(|(t, _)| t).collect(),
})
}
pub fn is_empty(&self) -> bool {
self.templates.is_empty()
}
pub fn len(&self) -> usize {
self.templates.len()
}
pub fn get(&self, name: &str) -> Option<&Template> {
self.templates.iter().find(|t| t.name == name)
}
pub fn iter(&self) -> impl Iterator<Item = &Template> {
self.templates.iter()
}
pub fn names(&self) -> Vec<&str> {
self.templates.iter().map(|t| t.name.as_str()).collect()
}
pub fn catalog(&self) -> String {
self.templates
.iter()
.map(|t| format!("- {}: {}", t.name, t.description))
.collect::<Vec<_>>()
.join("\n")
}
pub fn render(&self, name: &str, args: &[(&str, &str)]) -> Result<String> {
let template = self.get(name).ok_or_else(|| {
Error::Config(format!(
"there is no template named {name:?}; the templates are: {}",
if self.is_empty() {
"(none)".to_string()
} else {
self.names().join(", ")
}
))
})?;
let named = placeholders(&template.body);
let remainder = args
.iter()
.filter(|(k, _)| !named.contains(k))
.map(|(_, v)| *v)
.collect::<Vec<_>>()
.join(" ");
let mut out = String::new();
let mut rest = template.body.as_str();
loop {
let open = rest.find("{{");
let dollar = rest.find(ARGUMENTS);
let (at, is_placeholder) = match (open, dollar) {
(None, None) => {
out.push_str(rest);
return Ok(out);
}
(Some(o), None) => (o, true),
(None, Some(d)) => (d, false),
(Some(o), Some(d)) => (o.min(d), o < d),
};
out.push_str(&rest[..at]);
if !is_placeholder {
out.push_str(&remainder);
rest = &rest[at + ARGUMENTS.len()..];
continue;
}
let after = &rest[at + 2..];
let Some(end) = after.find("}}") else {
return Err(Error::Config(format!(
"template {name:?} has a `{{{{` that is never closed; a placeholder is \
`{{{{name}}}}` and there is no other syntax"
)));
};
let key = after[..end].trim();
let value = args
.iter()
.find(|(k, _)| *k == key)
.map(|(_, v)| *v)
.ok_or_else(|| {
Error::Config(format!(
"template {name:?} has no argument for `{{{{{key}}}}}`; a placeholder \
resolves or fails and is never empty, because a goal with a hole in it \
still reads like a goal — pass {key:?} or take it out of the template"
))
})?;
out.push_str(value);
rest = &after[end + 2..];
}
}
}
fn placeholders(body: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut rest = body;
while let Some(at) = rest.find("{{") {
let after = &rest[at + 2..];
let Some(end) = after.find("}}") else { break };
out.push(after[..end].trim());
rest = &after[end + 2..];
}
out
}
fn default_name(file: &Path) -> String {
let stem = file
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
if stem.eq_ignore_ascii_case("TEMPLATE") {
if let Some(parent) = file.parent().and_then(|p| p.file_name()) {
return parent.to_string_lossy().to_string();
}
}
stem
}
#[cfg(test)]
mod tests {
use super::*;
fn one(body: &str) -> Templates {
Templates {
templates: vec![Template {
name: "t".into(),
description: "d".into(),
body: body.into(),
}],
}
}
#[test]
fn a_placeholder_and_arguments_are_the_whole_grammar() {
let t = one("a {{x}} b $ARGUMENTS c\n");
assert_eq!(
t.render("t", &[("x", "X"), ("y", "Y"), ("z", "Z")])
.unwrap(),
"a X b Y Z c\n"
);
}
#[test]
fn arguments_before_the_placeholder_that_claims_one_still_excludes_it() {
let t = one("$ARGUMENTS then {{x}}");
assert_eq!(
t.render("t", &[("x", "X"), ("y", "Y")]).unwrap(),
"Y then X"
);
}
#[test]
fn a_repeated_placeholder_is_substituted_every_time() {
let t = one("{{x}} and {{x}}");
assert_eq!(t.render("t", &[("x", "X")]).unwrap(), "X and X");
}
#[test]
fn spaces_inside_the_braces_are_tolerated() {
let t = one("{{ x }}");
assert_eq!(t.render("t", &[("x", "X")]).unwrap(), "X");
}
#[test]
fn a_value_is_not_re_substituted() {
let t = one("{{x}} {{y}}");
assert_eq!(
t.render("t", &[("x", "{{y}}"), ("y", "Y")]).unwrap(),
"{{y}} Y"
);
}
#[test]
fn an_unfilled_placeholder_and_an_unclosed_brace_are_both_errors() {
let t = one("{{x}}");
assert!(matches!(t.render("t", &[]), Err(Error::Config(m)) if m.contains("x")));
let t = one("{{x");
assert!(
matches!(t.render("t", &[("x", "X")]), Err(Error::Config(m)) if m.contains("closed"))
);
}
#[test]
fn an_unknown_name_lists_what_exists() {
let t = one("body");
assert!(matches!(t.render("nope", &[]), Err(Error::Config(m)) if m.contains("t")));
assert!(
matches!(Templates::none().render("nope", &[]), Err(Error::Config(m)) if m.contains("(none)"))
);
}
#[test]
fn a_body_with_no_marker_renders_to_itself() {
let t = one("plain prose, {braces} and $ARGS but no marker\n");
assert_eq!(
t.render("t", &[("x", "X")]).unwrap(),
"plain prose, {braces} and $ARGS but no marker\n",
"an argument nobody claimed and no $ARGUMENTS to collect it is dropped"
);
}
#[test]
fn template_md_takes_its_directory_name_and_a_plain_file_its_stem() {
assert_eq!(default_name(Path::new("/t/review/TEMPLATE.md")), "review");
assert_eq!(default_name(Path::new("/t/bugfix.md")), "bugfix");
}
#[test]
fn frontmatter_is_stripped_from_the_body() {
let (name, desc, body) = split_front_matter("---\nname: a\ndescription: d\n---\nB {{x}}\n");
assert_eq!(name.as_deref(), Some("a"));
assert_eq!(desc.as_deref(), Some("d"));
assert_eq!(body, "B {{x}}\n");
assert_eq!(first_prose_line("# H\n\nx").as_deref(), Some("H"));
assert_eq!(clamp(" a b ", DESCRIPTION_CAP), "a b");
}
}