1use std::path::{Path, PathBuf};
24
25const COORDINATION_DEFAULT: &str = include_str!("../assets/agent-skills/coordination.md");
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Source {
34 Embedded,
36 User,
38}
39
40#[derive(Debug, Clone)]
42pub struct SkillTemplate {
43 pub name: String,
45 pub content: String,
47 pub source: Source,
49}
50
51#[derive(Debug, thiserror::Error)]
53pub enum SkillError {
54 #[error("unknown skill '{name}' — no embedded default or user override exists")]
56 UnknownSkill {
57 name: String,
59 },
60
61 #[error("cannot read skill override at '{}' — check file permissions and encoding", path.display())]
63 UserOverrideRead {
64 path: PathBuf,
66 source: std::io::Error,
68 },
69}
70
71fn embedded_default(skill_name: &str) -> Option<&'static str> {
77 match skill_name {
78 "coordination" => Some(COORDINATION_DEFAULT),
79 _ => None,
80 }
81}
82
83fn 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
119pub fn resolve(skill_name: &str) -> Result<SkillTemplate, SkillError> {
124 resolve_with_config_dir(skill_name, None)
125}
126
127fn 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
153fn slugify_branch(branch: &str) -> String {
156 crate::broker::messages::slugify_branch(branch)
157}
158
159pub 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 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 #[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 #[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 #[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 #[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 #[test]
240 fn missing_agent_skills_subdir_falls_through() {
241 let dir = tempfile::tempdir().unwrap();
242 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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}