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 const DEFAULT_LINE_TEMPLATE: &str = include_str!("skill.line.template.md");
pub const LINE_TEMPLATE_FILE: &str = "skill.line.md";
pub const DESCRIPTION_LIMIT: usize = 1024;
pub const FORBIDDEN_IN_DESCRIPTION: [char; 2] = ['<', '>'];
fn named(line: &str) -> String {
if line.eq_ignore_ascii_case("line") || line.to_lowercase().ends_with(" line") {
format!("the {line}")
} else {
format!("the {line} line")
}
}
pub fn line_description(line: &str, projects: &[Listed]) -> String {
let line = named(line);
let mut out = format!(
"Work on any project of {line}: the record says where a project stands, what the current stage is, and how the work is done there. Name the project in the request, or work in its directory - the path is recorded. Triggers on \"work on NAME\", \"continue NAME\", \"what is next for NAME\", \"wishes for NAME\", and any change under a recorded project's directory."
);
let names: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect();
if !names.is_empty() {
let tail = format!(" The projects: {}.", names.join(", "));
if out.chars().count() + tail.chars().count() <= DESCRIPTION_LIMIT {
out.push_str(&tail);
}
}
out
}
pub fn check_description(description: &str) -> Result<()> {
let length = description.chars().count();
if length > DESCRIPTION_LIMIT {
bail!(
"the description is {length} characters, over the {DESCRIPTION_LIMIT} an assistant accepts; a skill whose description is refused never matches anything, so the list of projects belongs in the body"
);
}
if let Some(c) = description.chars().find(|c| FORBIDDEN_IN_DESCRIPTION.contains(c)) {
bail!("the description contains `{c}`, which breaks the catalogue an assistant parses; write the path without angle brackets");
}
Ok(())
}
pub struct Listed {
pub name: String,
pub path: String,
pub about: Option<String>,
}
pub fn projects_table(projects: &[Listed]) -> String {
if projects.is_empty() {
return "No projects are recorded yet; `rigger project add <path>` records one.".to_string();
}
let mut out = String::from("| Project | What it is | Where |\n| --- | --- | --- |\n");
for p in projects {
let about = p.about.as_deref().unwrap_or("a project recorded in rigger");
out.push_str(&format!("| `{}` | {} | `{}` |\n", p.name, escape_cell(about), p.path));
}
out.pop();
out
}
fn escape_cell(text: &str) -> String {
text.replace('|', "\\|").replace('\n', " ")
}
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_paths() -> Result<Vec<PathBuf>> {
Ok(vec![crate::profile::current_dir()?.join(TEMPLATE_FILE), 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())));
}
for path in template_paths()? {
match std::fs::read_to_string(&path) {
Ok(text) => return Ok((text, Source::File(path))),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(e).with_context(|| format!("cannot read {}", path.display())),
}
}
Ok((DEFAULT_TEMPLATE.to_string(), Source::BuiltIn))
}
pub fn may_refresh_installed() -> bool {
std::env::var_os(SKILLS_DIR_ENV).is_some() || std::env::var_os(paths::DATA_DIR_ENV).is_none()
}
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 })
}
pub fn render_line(template: &str, line: &str, description: &str, projects: &[Listed]) -> Result<Rendered> {
check_description(description)?;
let table = projects_table(projects);
let mut out = String::with_capacity(template.len());
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(match key {
"line" => line,
"description" => description,
"projects" => &table,
_ => bail!("the line template names {{{{{key}}}}}, which rigger does not know; a skill for the whole line knows line, description and projects"),
});
rest = &after[end + 2..];
}
out.push_str(rest);
Ok(Rendered {
text: with_mark(&out),
notes: Vec::new(),
})
}
pub fn line_template_paths() -> Result<Vec<PathBuf>> {
Ok(vec![
crate::profile::current_dir()?.join(LINE_TEMPLATE_FILE),
paths::data_dir()?.join(LINE_TEMPLATE_FILE),
])
}
pub fn load_line_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())));
}
for path in line_template_paths()? {
match std::fs::read_to_string(&path) {
Ok(text) => return Ok((text, Source::File(path))),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(e).with_context(|| format!("cannot read {}", path.display())),
}
}
Ok((DEFAULT_LINE_TEMPLATE.to_string(), Source::BuiltIn))
}
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);
}
fn listed(names: &[&str]) -> Vec<Listed> {
names
.iter()
.map(|n| Listed {
name: (*n).to_string(),
path: format!("/dev/{n}"),
about: None,
})
.collect()
}
#[test]
fn a_description_over_the_limit_is_refused_rather_than_written() {
let long = "x".repeat(DESCRIPTION_LIMIT + 1);
let err = check_description(&long).unwrap_err().to_string();
assert!(err.contains(&(DESCRIPTION_LIMIT + 1).to_string()), "the error says how long it was: {err}");
assert!(err.contains("body"), "and where the list belongs instead: {err}");
check_description(&"x".repeat(DESCRIPTION_LIMIT)).unwrap();
let template = "---
name: {{line}}
description: {{description}}
---
";
assert!(render_line(template, "line", &long, &[]).is_err());
}
#[test]
fn angle_brackets_are_refused_because_they_break_the_whole_catalogue() {
let err = check_description("work in C:/Projects/<project>").unwrap_err().to_string();
assert!(err.contains("catalogue"), "{err}");
check_description("work in C:/Projects/, named in the request").unwrap();
}
#[test]
fn a_line_too_long_to_name_keeps_the_description_within_the_limit() {
let many: Vec<String> = (0..60).map(|i| format!("a-rather-long-product-name-{i}")).collect();
let refs: Vec<&str> = many.iter().map(String::as_str).collect();
let description = line_description("line", &listed(&refs));
check_description(&description).expect("a long line still yields a usable description");
assert!(!description.contains("a-rather-long-product-name-59"), "the tail is left to the body");
let few = line_description("line", &listed(&["sample", "other"]));
assert!(few.contains("sample, other"), "{few}");
check_description(&few).unwrap();
}
#[test]
fn the_line_template_knows_its_own_placeholders_and_no_others() {
let r = render_line("{{line}} | {{description}} | {{projects}}", "line", "about it", &listed(&["sample"])).unwrap();
assert!(r.text.contains("line | about it |"), "{}", r.text);
assert!(r.text.contains("| `sample` |"), "{}", r.text);
let err = render_line("{{name}}", "line", "about it", &[]).unwrap_err().to_string();
assert!(err.contains("{{name}}"), "{err}");
}
#[test]
fn a_line_with_no_projects_says_so_rather_than_printing_an_empty_table() {
let table = projects_table(&[]);
assert!(table.contains("project add"), "{table}");
}
#[test]
fn a_pipe_in_a_description_does_not_end_the_cell_it_sits_in() {
let listed = vec![Listed {
name: "sample".into(),
path: "/dev/sample".into(),
about: Some("reads a | writes b".into()),
}];
let table = projects_table(&listed);
assert!(table.contains("reads a \\| writes b"), "{table}");
assert_eq!(table.lines().filter(|l| l.contains("sample")).count(), 1, "{table}");
}
#[test]
fn the_built_in_line_template_renders_and_fits() {
let listed = listed(&["sample", "other"]);
let description = line_description("line", &listed);
let r = render_line(DEFAULT_LINE_TEMPLATE, "line", &description, &listed).unwrap();
assert!(
r.text.starts_with(
"---
name: line
"
),
"{}",
r.text
);
assert!(r.text.contains("rigger context <project>"), "{}", r.text);
assert!(r.text.contains("| `sample` |"), "{}", r.text);
assert!(is_generated(&r.text));
}
#[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);
}
}