Skip to main content

git_paw/
skills.rs

1//! Agent skill template loading and rendering.
2//!
3//! Skills are markdown instruction files embedded into each worktree's `AGENTS.md`
4//! to teach AI agents how to use git-paw capabilities (e.g. the coordination broker).
5//!
6//! ## Resolution order
7//!
8//! When a skill is requested by name, the system checks two locations in order:
9//!
10//! 1. **User override** — `<config_dir>/git-paw/agent-skills/<name>.md`
11//! 2. **Embedded default** — compiled into the binary via `include_str!`
12//!
13//! The first match wins. If neither exists, resolution fails with
14//! [`SkillError::UnknownSkill`].
15//!
16//! ## Substitution rules
17//!
18//! During [`render`], the template content undergoes placeholder substitution:
19//!
20//! - `{{BRANCH_ID}}` is replaced with the slugified branch name
21//! - `${GIT_PAW_BROKER_URL}` is left untouched for shell-time expansion
22
23use std::path::{Path, PathBuf};
24
25/// The embedded coordination skill, compiled into the binary.
26///
27/// New embedded skills are added by adding a new `include_str!` constant
28/// and a corresponding match arm in [`embedded_default`].
29const COORDINATION_DEFAULT: &str = include_str!("../assets/agent-skills/coordination.md");
30
31/// Indicates where a resolved skill's content originated.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Source {
34    /// Content came from the binary's compiled-in default.
35    Embedded,
36    /// Content came from a user override file in the config directory.
37    User,
38}
39
40/// A loaded skill template ready for rendering.
41#[derive(Debug, Clone)]
42pub struct SkillTemplate {
43    /// The skill name (e.g. `"coordination"`).
44    pub name: String,
45    /// The unrendered template content with placeholders.
46    pub content: String,
47    /// Where the content was loaded from.
48    pub source: Source,
49}
50
51/// Errors that can occur during skill loading.
52#[derive(Debug, thiserror::Error)]
53pub enum SkillError {
54    /// No embedded or user override found for the requested skill name.
55    #[error("unknown skill '{name}' — no embedded default or user override exists")]
56    UnknownSkill {
57        /// The skill name that was requested.
58        name: String,
59    },
60
61    /// A user override file exists but cannot be read.
62    #[error("cannot read skill override at '{}' — check file permissions and encoding", path.display())]
63    UserOverrideRead {
64        /// The path that could not be read.
65        path: PathBuf,
66        /// The underlying I/O error.
67        source: std::io::Error,
68    },
69}
70
71/// Looks up the embedded default for a skill by name.
72///
73/// Returns `Some(content)` if an embedded skill exists with that name,
74/// or `None` otherwise. New embedded skills are added by introducing a
75/// new `include_str!` constant and a new match arm here.
76fn embedded_default(skill_name: &str) -> Option<&'static str> {
77    match skill_name {
78        "coordination" => Some(COORDINATION_DEFAULT),
79        _ => None,
80    }
81}
82
83/// Attempts to load a user override file for the given skill name.
84///
85/// The override path is `<config_dir>/git-paw/agent-skills/<skill_name>.md`.
86///
87/// ## Error handling contract
88///
89/// - Missing config directory → `Ok(None)` (normal, no override available)
90/// - Missing `agent-skills/` subdirectory → `Ok(None)`
91/// - Missing skill file → `Ok(None)`
92/// - File exists but is unreadable (permissions, invalid UTF-8) →
93///   `Err(SkillError::UserOverrideRead)` — this is a hard error to make
94///   misconfiguration visible rather than silently falling back to defaults.
95fn try_load_user_override(
96    skill_name: &str,
97    config_dir_override: Option<&Path>,
98) -> Result<Option<String>, SkillError> {
99    let config_dir = match config_dir_override {
100        Some(dir) => dir.to_path_buf(),
101        None => match crate::dirs::config_dir() {
102            Some(dir) => dir,
103            None => return Ok(None),
104        },
105    };
106
107    let path = config_dir
108        .join("git-paw")
109        .join("agent-skills")
110        .join(format!("{skill_name}.md"));
111
112    match std::fs::read_to_string(&path) {
113        Ok(content) => Ok(Some(content)),
114        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
115        Err(source) => Err(SkillError::UserOverrideRead { path, source }),
116    }
117}
118
119/// Resolves a skill template by name.
120///
121/// Checks for a user override first, then falls back to the embedded default.
122/// Returns [`SkillError::UnknownSkill`] if neither source has the skill.
123pub fn resolve(skill_name: &str) -> Result<SkillTemplate, SkillError> {
124    resolve_with_config_dir(skill_name, None)
125}
126
127/// Internal resolver that accepts an optional config directory override for testing.
128fn resolve_with_config_dir(
129    skill_name: &str,
130    config_dir: Option<&Path>,
131) -> Result<SkillTemplate, SkillError> {
132    if let Some(content) = try_load_user_override(skill_name, config_dir)? {
133        return Ok(SkillTemplate {
134            name: skill_name.to_string(),
135            content,
136            source: Source::User,
137        });
138    }
139
140    if let Some(content) = embedded_default(skill_name) {
141        return Ok(SkillTemplate {
142            name: skill_name.to_string(),
143            content: content.to_string(),
144            source: Source::Embedded,
145        });
146    }
147
148    Err(SkillError::UnknownSkill {
149        name: skill_name.to_string(),
150    })
151}
152
153/// Re-export of [`crate::broker::messages::slugify_branch`] to ensure skill
154/// template rendering uses the exact same slug algorithm as the broker.
155fn slugify_branch(branch: &str) -> String {
156    crate::broker::messages::slugify_branch(branch)
157}
158
159/// Renders a skill template for a specific worktree.
160///
161/// Substitutes `{{BRANCH_ID}}` with the slugified branch name. The
162/// `${GIT_PAW_BROKER_URL}` placeholder is left untouched so the agent's
163/// shell expands it at command-execution time.
164///
165/// The `broker_url` parameter is accepted for forward compatibility (e.g.
166/// embedding the URL at render time as an alternative mode) but is **not**
167/// substituted into the output in v0.3.0.
168pub fn render(template: &SkillTemplate, branch: &str, _broker_url: &str) -> String {
169    let branch_id = slugify_branch(branch);
170    let output = template.content.replace("{{BRANCH_ID}}", &branch_id);
171
172    // Warn about any remaining {{...}} placeholders that were not consumed.
173    let mut start = 0;
174    while let Some(open) = output[start..].find("{{") {
175        let abs_open = start + open;
176        if let Some(close) = output[abs_open..].find("}}") {
177            let placeholder = &output[abs_open..abs_open + close + 2];
178            eprintln!(
179                "warning: unsubstituted placeholder {placeholder} in skill '{}'",
180                template.name
181            );
182            start = abs_open + close + 2;
183        } else {
184            break;
185        }
186    }
187
188    output
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    // 9.2: Embedded coordination skill is reachable without any user files
196    #[test]
197    fn embedded_coordination_is_reachable() {
198        let tmpl = resolve("coordination").expect("should resolve coordination");
199        assert_eq!(tmpl.source, Source::Embedded);
200        assert!(!tmpl.content.is_empty());
201    }
202
203    // 9.3: Embedded coordination skill contains all four operations
204    #[test]
205    fn embedded_coordination_contains_all_operations() {
206        let tmpl = resolve("coordination").unwrap();
207        assert!(tmpl.content.contains("agent.status"));
208        assert!(tmpl.content.contains("agent.artifact"));
209        assert!(tmpl.content.contains("agent.blocked"));
210        assert!(
211            tmpl.content
212                .contains("${GIT_PAW_BROKER_URL}/messages/{{BRANCH_ID}}")
213        );
214    }
215
216    // 9.4: User override is preferred
217    #[test]
218    fn user_override_is_preferred() {
219        let dir = tempfile::tempdir().unwrap();
220        let skills_dir = dir.path().join("git-paw").join("agent-skills");
221        std::fs::create_dir_all(&skills_dir).unwrap();
222        std::fs::write(skills_dir.join("coordination.md"), "custom user content").unwrap();
223
224        let tmpl =
225            resolve_with_config_dir("coordination", Some(dir.path())).expect("should resolve");
226        assert_eq!(tmpl.source, Source::User);
227        assert_eq!(tmpl.content, "custom user content");
228    }
229
230    // 9.5: Missing user config directory falls through
231    #[test]
232    fn missing_config_dir_falls_through() {
233        let nonexistent = PathBuf::from("/tmp/git-paw-test-nonexistent-dir-abc123");
234        let result = try_load_user_override("coordination", Some(&nonexistent)).unwrap();
235        assert!(result.is_none());
236    }
237
238    // 9.6: Missing agent-skills subdirectory falls through
239    #[test]
240    fn missing_agent_skills_subdir_falls_through() {
241        let dir = tempfile::tempdir().unwrap();
242        // Create git-paw/ but not git-paw/agent-skills/
243        std::fs::create_dir_all(dir.path().join("git-paw")).unwrap();
244        let result = try_load_user_override("coordination", Some(dir.path())).unwrap();
245        assert!(result.is_none());
246    }
247
248    // 9.7: Missing skill file falls through
249    #[test]
250    fn missing_skill_file_falls_through() {
251        let dir = tempfile::tempdir().unwrap();
252        std::fs::create_dir_all(dir.path().join("git-paw").join("agent-skills")).unwrap();
253        let result = try_load_user_override("coordination", Some(dir.path())).unwrap();
254        assert!(result.is_none());
255    }
256
257    // 9.8: Unreadable user override returns hard error
258    #[cfg(unix)]
259    #[test]
260    fn unreadable_override_returns_hard_error() {
261        use std::os::unix::fs::PermissionsExt;
262
263        let dir = tempfile::tempdir().unwrap();
264        let skills_dir = dir.path().join("git-paw").join("agent-skills");
265        std::fs::create_dir_all(&skills_dir).unwrap();
266        let file_path = skills_dir.join("coordination.md");
267        std::fs::write(&file_path, "secret").unwrap();
268        std::fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o000)).unwrap();
269
270        let result = try_load_user_override("coordination", Some(dir.path()));
271        assert!(
272            matches!(result, Err(SkillError::UserOverrideRead { .. })),
273            "expected UserOverrideRead error, got {result:?}"
274        );
275    }
276
277    // 9.9: Unknown skill name returns error
278    #[test]
279    fn unknown_skill_returns_error() {
280        let result = resolve("nonexistent");
281        assert!(
282            matches!(result, Err(SkillError::UnknownSkill { ref name }) if name == "nonexistent"),
283            "expected UnknownSkill error, got {result:?}"
284        );
285    }
286
287    // 9.10: {{BRANCH_ID}} is substituted
288    #[test]
289    fn branch_id_is_substituted() {
290        let tmpl = SkillTemplate {
291            name: "test".into(),
292            content: "agent_id:\"{{BRANCH_ID}}\"".into(),
293            source: Source::Embedded,
294        };
295        let output = render(&tmpl, "feat/http-broker", "http://127.0.0.1:9119");
296        assert!(output.contains("feat-http-broker"));
297        assert!(!output.contains("{{BRANCH_ID}}"));
298    }
299
300    // 9.11: ${GIT_PAW_BROKER_URL} is preserved verbatim
301    #[test]
302    fn broker_url_placeholder_preserved() {
303        let tmpl = SkillTemplate {
304            name: "test".into(),
305            content: "curl ${GIT_PAW_BROKER_URL}/status".into(),
306            source: Source::Embedded,
307        };
308        let output = render(&tmpl, "feat/x", "http://127.0.0.1:9119");
309        assert!(output.contains("${GIT_PAW_BROKER_URL}"));
310    }
311
312    // 9.12: Slug substitution matches slugify_branch
313    #[test]
314    fn slug_substitution_matches_slugify_branch() {
315        let tmpl = SkillTemplate {
316            name: "test".into(),
317            content: "id={{BRANCH_ID}}".into(),
318            source: Source::Embedded,
319        };
320        let output = render(&tmpl, "Feature/HTTP_Broker", "http://127.0.0.1:9119");
321        let expected = slugify_branch("Feature/HTTP_Broker");
322        assert_eq!(output, format!("id={expected}"));
323    }
324
325    // 9.13: Render is deterministic
326    #[test]
327    fn render_is_deterministic() {
328        let tmpl = resolve("coordination").unwrap();
329        let a = render(&tmpl, "feat/x", "http://127.0.0.1:9119");
330        let b = render(&tmpl, "feat/x", "http://127.0.0.1:9119");
331        assert_eq!(a, b);
332    }
333
334    // 9.14: Render performs no I/O (resolve then render after "deletion")
335    #[test]
336    fn render_performs_no_io() {
337        let dir = tempfile::tempdir().unwrap();
338        let skills_dir = dir.path().join("git-paw").join("agent-skills");
339        std::fs::create_dir_all(&skills_dir).unwrap();
340        std::fs::write(skills_dir.join("coordination.md"), "user {{BRANCH_ID}}").unwrap();
341
342        let tmpl = resolve_with_config_dir("coordination", Some(dir.path())).unwrap();
343        assert_eq!(tmpl.source, Source::User);
344
345        // Delete the override file — render must still succeed from in-memory content
346        std::fs::remove_file(skills_dir.join("coordination.md")).unwrap();
347        let output = render(&tmpl, "feat/x", "http://127.0.0.1:9119");
348        assert!(output.contains("feat-x"));
349    }
350
351    // 9.15: Unknown placeholder survives in output (warning is emitted to stderr)
352    #[test]
353    fn unknown_placeholder_survives() {
354        let tmpl = SkillTemplate {
355            name: "test".into(),
356            content: "url={{UNKNOWN_THING}}".into(),
357            source: Source::Embedded,
358        };
359        let output = render(&tmpl, "feat/x", "http://127.0.0.1:9119");
360        assert!(
361            output.contains("{{UNKNOWN_THING}}"),
362            "unknown placeholder should survive in output"
363        );
364    }
365
366    // 9.16: No {{...}} remains after rendering the embedded coordination template
367    #[test]
368    fn no_unknown_placeholders_after_render() {
369        let tmpl = resolve("coordination").unwrap();
370        let output = render(&tmpl, "feat/x", "http://127.0.0.1:9119");
371        assert!(
372            !output.contains("{{"),
373            "no double-curly placeholders should remain: {output}"
374        );
375    }
376
377    // 9.17: SkillTemplate is cloneable
378    #[test]
379    fn skill_template_is_cloneable() {
380        let tmpl = resolve("coordination").unwrap();
381        let cloned = tmpl.clone();
382        assert_eq!(tmpl.name, cloned.name);
383        assert_eq!(tmpl.content, cloned.content);
384        assert_eq!(tmpl.source, cloned.source);
385    }
386}