use std::path::{Path, PathBuf};
const COORDINATION_DEFAULT: &str = include_str!("../assets/agent-skills/coordination.md");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
Embedded,
User,
}
#[derive(Debug, Clone)]
pub struct SkillTemplate {
pub name: String,
pub content: String,
pub source: Source,
}
#[derive(Debug, thiserror::Error)]
pub enum SkillError {
#[error("unknown skill '{name}' — no embedded default or user override exists")]
UnknownSkill {
name: String,
},
#[error("cannot read skill override at '{}' — check file permissions and encoding", path.display())]
UserOverrideRead {
path: PathBuf,
source: std::io::Error,
},
}
fn embedded_default(skill_name: &str) -> Option<&'static str> {
match skill_name {
"coordination" => Some(COORDINATION_DEFAULT),
_ => None,
}
}
fn try_load_user_override(
skill_name: &str,
config_dir_override: Option<&Path>,
) -> Result<Option<String>, SkillError> {
let config_dir = match config_dir_override {
Some(dir) => dir.to_path_buf(),
None => match crate::dirs::config_dir() {
Some(dir) => dir,
None => return Ok(None),
},
};
let path = config_dir
.join("git-paw")
.join("agent-skills")
.join(format!("{skill_name}.md"));
match std::fs::read_to_string(&path) {
Ok(content) => Ok(Some(content)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(source) => Err(SkillError::UserOverrideRead { path, source }),
}
}
pub fn resolve(skill_name: &str) -> Result<SkillTemplate, SkillError> {
resolve_with_config_dir(skill_name, None)
}
fn resolve_with_config_dir(
skill_name: &str,
config_dir: Option<&Path>,
) -> Result<SkillTemplate, SkillError> {
if let Some(content) = try_load_user_override(skill_name, config_dir)? {
return Ok(SkillTemplate {
name: skill_name.to_string(),
content,
source: Source::User,
});
}
if let Some(content) = embedded_default(skill_name) {
return Ok(SkillTemplate {
name: skill_name.to_string(),
content: content.to_string(),
source: Source::Embedded,
});
}
Err(SkillError::UnknownSkill {
name: skill_name.to_string(),
})
}
fn slugify_branch(branch: &str) -> String {
crate::broker::messages::slugify_branch(branch)
}
pub fn render(template: &SkillTemplate, branch: &str, _broker_url: &str) -> String {
let branch_id = slugify_branch(branch);
let output = template.content.replace("{{BRANCH_ID}}", &branch_id);
let mut start = 0;
while let Some(open) = output[start..].find("{{") {
let abs_open = start + open;
if let Some(close) = output[abs_open..].find("}}") {
let placeholder = &output[abs_open..abs_open + close + 2];
eprintln!(
"warning: unsubstituted placeholder {placeholder} in skill '{}'",
template.name
);
start = abs_open + close + 2;
} else {
break;
}
}
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn embedded_coordination_is_reachable() {
let tmpl = resolve("coordination").expect("should resolve coordination");
assert_eq!(tmpl.source, Source::Embedded);
assert!(!tmpl.content.is_empty());
}
#[test]
fn embedded_coordination_contains_all_operations() {
let tmpl = resolve("coordination").unwrap();
assert!(tmpl.content.contains("agent.status"));
assert!(tmpl.content.contains("agent.artifact"));
assert!(tmpl.content.contains("agent.blocked"));
assert!(
tmpl.content
.contains("${GIT_PAW_BROKER_URL}/messages/{{BRANCH_ID}}")
);
}
#[test]
fn user_override_is_preferred() {
let dir = tempfile::tempdir().unwrap();
let skills_dir = dir.path().join("git-paw").join("agent-skills");
std::fs::create_dir_all(&skills_dir).unwrap();
std::fs::write(skills_dir.join("coordination.md"), "custom user content").unwrap();
let tmpl =
resolve_with_config_dir("coordination", Some(dir.path())).expect("should resolve");
assert_eq!(tmpl.source, Source::User);
assert_eq!(tmpl.content, "custom user content");
}
#[test]
fn missing_config_dir_falls_through() {
let nonexistent = PathBuf::from("/tmp/git-paw-test-nonexistent-dir-abc123");
let result = try_load_user_override("coordination", Some(&nonexistent)).unwrap();
assert!(result.is_none());
}
#[test]
fn missing_agent_skills_subdir_falls_through() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("git-paw")).unwrap();
let result = try_load_user_override("coordination", Some(dir.path())).unwrap();
assert!(result.is_none());
}
#[test]
fn missing_skill_file_falls_through() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("git-paw").join("agent-skills")).unwrap();
let result = try_load_user_override("coordination", Some(dir.path())).unwrap();
assert!(result.is_none());
}
#[cfg(unix)]
#[test]
fn unreadable_override_returns_hard_error() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let skills_dir = dir.path().join("git-paw").join("agent-skills");
std::fs::create_dir_all(&skills_dir).unwrap();
let file_path = skills_dir.join("coordination.md");
std::fs::write(&file_path, "secret").unwrap();
std::fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o000)).unwrap();
let result = try_load_user_override("coordination", Some(dir.path()));
assert!(
matches!(result, Err(SkillError::UserOverrideRead { .. })),
"expected UserOverrideRead error, got {result:?}"
);
}
#[test]
fn unknown_skill_returns_error() {
let result = resolve("nonexistent");
assert!(
matches!(result, Err(SkillError::UnknownSkill { ref name }) if name == "nonexistent"),
"expected UnknownSkill error, got {result:?}"
);
}
#[test]
fn branch_id_is_substituted() {
let tmpl = SkillTemplate {
name: "test".into(),
content: "agent_id:\"{{BRANCH_ID}}\"".into(),
source: Source::Embedded,
};
let output = render(&tmpl, "feat/http-broker", "http://127.0.0.1:9119");
assert!(output.contains("feat-http-broker"));
assert!(!output.contains("{{BRANCH_ID}}"));
}
#[test]
fn broker_url_placeholder_preserved() {
let tmpl = SkillTemplate {
name: "test".into(),
content: "curl ${GIT_PAW_BROKER_URL}/status".into(),
source: Source::Embedded,
};
let output = render(&tmpl, "feat/x", "http://127.0.0.1:9119");
assert!(output.contains("${GIT_PAW_BROKER_URL}"));
}
#[test]
fn slug_substitution_matches_slugify_branch() {
let tmpl = SkillTemplate {
name: "test".into(),
content: "id={{BRANCH_ID}}".into(),
source: Source::Embedded,
};
let output = render(&tmpl, "Feature/HTTP_Broker", "http://127.0.0.1:9119");
let expected = slugify_branch("Feature/HTTP_Broker");
assert_eq!(output, format!("id={expected}"));
}
#[test]
fn render_is_deterministic() {
let tmpl = resolve("coordination").unwrap();
let a = render(&tmpl, "feat/x", "http://127.0.0.1:9119");
let b = render(&tmpl, "feat/x", "http://127.0.0.1:9119");
assert_eq!(a, b);
}
#[test]
fn render_performs_no_io() {
let dir = tempfile::tempdir().unwrap();
let skills_dir = dir.path().join("git-paw").join("agent-skills");
std::fs::create_dir_all(&skills_dir).unwrap();
std::fs::write(skills_dir.join("coordination.md"), "user {{BRANCH_ID}}").unwrap();
let tmpl = resolve_with_config_dir("coordination", Some(dir.path())).unwrap();
assert_eq!(tmpl.source, Source::User);
std::fs::remove_file(skills_dir.join("coordination.md")).unwrap();
let output = render(&tmpl, "feat/x", "http://127.0.0.1:9119");
assert!(output.contains("feat-x"));
}
#[test]
fn unknown_placeholder_survives() {
let tmpl = SkillTemplate {
name: "test".into(),
content: "url={{UNKNOWN_THING}}".into(),
source: Source::Embedded,
};
let output = render(&tmpl, "feat/x", "http://127.0.0.1:9119");
assert!(
output.contains("{{UNKNOWN_THING}}"),
"unknown placeholder should survive in output"
);
}
#[test]
fn no_unknown_placeholders_after_render() {
let tmpl = resolve("coordination").unwrap();
let output = render(&tmpl, "feat/x", "http://127.0.0.1:9119");
assert!(
!output.contains("{{"),
"no double-curly placeholders should remain: {output}"
);
}
#[test]
fn skill_template_is_cloneable() {
let tmpl = resolve("coordination").unwrap();
let cloned = tmpl.clone();
assert_eq!(tmpl.name, cloned.name);
assert_eq!(tmpl.content, cloned.content);
assert_eq!(tmpl.source, cloned.source);
}
}