use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use crate::paths;
pub const MARK: &str = "<!-- generated by rigger skill; edit the template, not this file -->";
pub const TEMPLATE_FILE: &str = "skill.md";
pub const SKILLS_DIR_ENV: &str = "RIGGER_SKILLS_DIR";
pub const DEFAULT_TEMPLATE: &str = include_str!("skill.template.md");
pub struct Fields<'a> {
pub name: &'a str,
pub path: &'a str,
pub remote: Option<&'a str>,
pub hub: Option<&'a Path>,
pub about: Option<&'a str>,
}
#[derive(Debug)]
pub struct Rendered {
pub text: String,
pub notes: Vec<String>,
}
pub enum Source {
File(PathBuf),
BuiltIn,
}
impl std::fmt::Display for Source {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Source::File(path) => write!(f, "{}", path.display()),
Source::BuiltIn => write!(f, "the built-in template"),
}
}
}
pub fn template_path() -> Result<PathBuf> {
Ok(paths::data_dir()?.join(TEMPLATE_FILE))
}
pub fn load_template(explicit: Option<&Path>) -> Result<(String, Source)> {
if let Some(path) = explicit {
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
return Ok((text, Source::File(path.to_path_buf())));
}
let path = template_path()?;
match std::fs::read_to_string(&path) {
Ok(text) => Ok((text, Source::File(path))),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok((DEFAULT_TEMPLATE.to_string(), Source::BuiltIn)),
Err(e) => Err(e).with_context(|| format!("cannot read {}", path.display())),
}
}
pub fn skills_dir() -> Result<PathBuf> {
if let Some(dir) = std::env::var_os(SKILLS_DIR_ENV) {
return Ok(PathBuf::from(dir));
}
let home = directories::BaseDirs::new().context("cannot determine the home directory")?;
Ok(home.home_dir().join(".claude").join("skills"))
}
pub fn is_generated(text: &str) -> bool {
text.lines().take(40).any(|l| l.trim() == MARK)
}
pub fn render(template: &str, fields: &Fields) -> Result<Rendered> {
let mut out = String::with_capacity(template.len());
let mut notes = Vec::new();
let mut rest = template;
while let Some(start) = rest.find("{{") {
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
let Some(end) = after.find("}}") else {
bail!("the template opens a placeholder with `{{{{` and never closes it");
};
let key = after[..end].trim();
out.push_str(&value(key, fields, &mut notes)?);
rest = &after[end + 2..];
}
out.push_str(rest);
Ok(Rendered { text: with_mark(&out), notes })
}
fn value(key: &str, fields: &Fields, notes: &mut Vec<String>) -> Result<String> {
Ok(match key {
"name" => fields.name.to_string(),
"path" => fields.path.to_string(),
"remote" => fields.remote.map(|r| format!(" - {r}")).unwrap_or_default(),
"hub" => fields.hub.map(|h| h.display().to_string()).unwrap_or_else(|| "not recorded yet".to_string()),
"about" => fields.about.unwrap_or("a project recorded in rigger").to_string(),
_ => match key.strip_prefix("file:") {
Some(file) => included(file.trim(), fields.hub, notes),
None => bail!("the template names {{{{{key}}}}}, which rigger does not know; it knows name, path, remote, hub, about and file:<name>"),
},
})
}
fn included(file: &str, hub: Option<&Path>, notes: &mut Vec<String>) -> String {
let Some(hub) = hub else {
notes.push(format!(
"{{{{file:{file}}}}} left empty: the record does not know where the hub is; import or export one"
));
return String::new();
};
let path = hub.join(file);
match std::fs::read_to_string(&path) {
Ok(text) => text.trim().to_string(),
Err(_) => {
notes.push(format!("{{{{file:{file}}}}} left empty: {} does not exist", path.display()));
String::new()
}
}
}
fn with_mark(text: &str) -> String {
if is_generated(text) {
return text.to_string();
}
if let Some(rest) = text.strip_prefix("---\n").or_else(|| text.strip_prefix("---\r\n"))
&& let Some(end) = rest.find("\n---")
{
let after_rule = &rest[end + 1..];
let rule_len = after_rule.find('\n').map(|i| i + 1).unwrap_or(after_rule.len());
let head_len = text.len() - rest.len() + end + 1 + rule_len;
let (head, body) = text.split_at(head_len);
return format!("{head}\n{MARK}\n{body}");
}
format!("{MARK}\n\n{text}")
}
#[cfg(test)]
mod tests {
use super::*;
fn fields(hub: Option<&Path>) -> Fields<'_> {
Fields {
name: "sample",
path: "C:\\dev\\sample",
remote: Some("https://example.com/sample.git"),
hub,
about: Some("a sample"),
}
}
#[test]
fn every_field_is_filled() {
let r = render("{{name}} at {{path}}{{remote}}: {{about}}; hub {{hub}}", &fields(None)).unwrap();
assert!(
r.text
.ends_with("sample at C:\\dev\\sample - https://example.com/sample.git: a sample; hub not recorded yet"),
"{}",
r.text
);
}
#[test]
fn the_mark_lands_after_the_front_matter() {
let r = render("---\nname: {{name}}\n---\n\n# {{name}}\n", &fields(None)).unwrap();
assert_eq!(r.text, format!("---\nname: sample\n---\n\n{MARK}\n\n# sample\n"));
assert!(is_generated(&r.text));
}
#[test]
fn without_front_matter_the_mark_comes_first() {
let r = render("# {{name}}\n", &fields(None)).unwrap();
assert!(r.text.starts_with(MARK), "{}", r.text);
}
#[test]
fn an_unknown_placeholder_is_an_error_not_a_hole() {
let err = render("{{nope}}", &fields(None)).unwrap_err().to_string();
assert!(err.contains("{{nope}}"), "{err}");
}
#[test]
fn a_hub_file_is_included_and_a_missing_one_is_noted() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Rituals.md"), " deploy after the tag\n").unwrap();
let r = render("{{file:Rituals.md}}|{{file:Other.md}}", &fields(Some(dir.path()))).unwrap();
assert!(r.text.ends_with("deploy after the tag|"), "{}", r.text);
assert_eq!(r.notes.len(), 1, "{:?}", r.notes);
assert!(r.notes[0].contains("Other.md"), "{:?}", r.notes);
}
#[test]
fn the_built_in_template_renders() {
let r = render(DEFAULT_TEMPLATE, &fields(None)).unwrap();
assert!(r.text.contains("rigger context sample"), "{}", r.text);
assert!(r.text.starts_with("---\nname: sample\n"), "{}", r.text);
assert!(r.notes.is_empty(), "{:?}", r.notes);
}
}