rigger 0.16.0

One seat for all your projects and tasks: a local record of what is done, what is next and when it ships - read by you and your coding assistant
//! A thin project skill, written from a template and the record.
//!
//! A skill file is what an assistant reads before it reads anything else
//! about a project. The ones this line kept by hand grew to sixteen
//! kilobytes each: a product summary, four rituals, a table of where things
//! are, the commands to run. Seventeen of them, nearly identical, and every
//! change to the ritual was seventeen edits - or, more often, one edit and
//! sixteen skills quietly out of date.
//!
//! Most of what they held is now held better elsewhere. The state of the
//! work is the context packet. The rituals are the same for every project
//! and belong in one place. What is left for the skill to say is how to
//! ask the record - and that is a template with the project's name in it.
//!
//! So the skill is generated: one template, the fields of the record, and
//! whatever a project's hub says only about itself. rigger ships a template
//! in English; a line that writes its own puts it in the data directory and
//! every skill follows it from then on.

use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};

use crate::paths;

/// The marker that says a skill file is generated.
///
/// Placed after the front matter, because that is where the assistant stops
/// parsing metadata and starts reading. It carries no timestamp: a stamp
/// would make an unchanged skill a diff on every run.
pub const MARK: &str = "<!-- generated by rigger skill; edit the template, not this file -->";

/// The template a line writes for itself, in the data directory.
pub const TEMPLATE_FILE: &str = "skill.md";

/// Overrides where `--install` writes.
pub const SKILLS_DIR_ENV: &str = "RIGGER_SKILLS_DIR";

/// The template rigger ships.
pub const DEFAULT_TEMPLATE: &str = include_str!("skill.template.md");

/// What fills the placeholders.
pub struct Fields<'a> {
    pub name: &'a str,
    pub path: &'a str,
    pub remote: Option<&'a str>,
    /// Where the hub is, when the record knows. `{{file:...}}` reads from it.
    pub hub: Option<&'a Path>,
    /// One line about the project, from its manifest.
    pub about: Option<&'a str>,
}

/// A rendered skill, and what the template asked for that was not there.
#[derive(Debug)]
pub struct Rendered {
    pub text: String,
    /// Said rather than failed: a hub file the template includes may not
    /// exist for every project, and a skill with the section empty is
    /// still a skill.
    pub notes: Vec<String>,
}

/// Where the template is read from.
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"),
        }
    }
}

/// The template of the data directory.
pub fn template_path() -> Result<PathBuf> {
    Ok(paths::data_dir()?.join(TEMPLATE_FILE))
}

/// The template named, else the one in the data directory, else the
/// built-in one.
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())),
    }
}

/// Where skills are installed: `RIGGER_SKILLS_DIR`, else the directory the
/// default assistant reads, `~/.claude/skills`.
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"))
}

/// Whether a skill file was generated by rigger.
pub fn is_generated(text: &str) -> bool {
    text.lines().take(40).any(|l| l.trim() == MARK)
}

/// Fills the template.
///
/// Placeholders are `{{name}}`, `{{path}}`, `{{remote}}`, `{{hub}}`,
/// `{{about}}`, and `{{file:NAME}}` for the contents of a file in the hub.
/// A placeholder the template spells that rigger does not know is an
/// error, not an empty string: a skill with a hole in it would be read by
/// every session before anyone noticed.
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>"),
        },
    })
}

/// A file of the hub, for the part of a skill only that project can say.
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()
        }
    }
}

/// Puts the mark where the front matter ends, or at the top when there is
/// none. A file that already carries it is left as it is.
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---")
    {
        // Past the closing rule and its line break, then the mark, then the
        // body as the template wrote it.
        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);
    }
}