1use std::path::{Path, PathBuf};
23
24use serde::Deserialize;
25
26use crate::error::NikaError;
27use crate::serde_yaml;
28
29#[derive(Debug, Clone)]
31pub struct LoadedDefinition {
32 pub name: String,
34
35 pub description: Option<String>,
37
38 pub system: String,
40
41 pub provider: Option<String>,
43
44 pub model: Option<String>,
46
47 pub max_turns: Option<u32>,
49
50 pub temperature: Option<f32>,
52
53 pub source_path: PathBuf,
55}
56
57#[derive(Debug, Deserialize)]
59struct Frontmatter {
60 name: Option<String>,
61 description: Option<String>,
62 provider: Option<String>,
63 model: Option<String>,
64 max_turns: Option<u32>,
65 temperature: Option<f32>,
66}
67
68#[derive(Debug, Deserialize)]
70struct YamlDefinition {
71 name: Option<String>,
72 description: Option<String>,
73 system: String,
74 provider: Option<String>,
75 model: Option<String>,
76 max_turns: Option<u32>,
77 temperature: Option<f32>,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum DefinitionKind {
83 Agent,
84 Skill,
85}
86
87impl DefinitionKind {
88 pub fn extensions(&self) -> &[&str] {
90 match self {
91 DefinitionKind::Agent => &[".agent.yaml", ".agent.yml", ".md"],
92 DefinitionKind::Skill => &[".skill.yaml", ".skill.yml", ".md"],
93 }
94 }
95
96 pub fn standard_filename(&self) -> &str {
98 match self {
99 DefinitionKind::Agent => "AGENT.md",
100 DefinitionKind::Skill => "SKILL.md",
101 }
102 }
103}
104
105pub fn load_definition(path: &Path, kind: DefinitionKind) -> Result<LoadedDefinition, NikaError> {
109 if path.is_dir() {
110 load_from_folder(path, kind)
111 } else if path.is_file() {
112 load_from_file(path, kind)
113 } else {
114 try_load_with_extensions(path, kind)
116 }
117}
118
119fn try_load_with_extensions(
121 path: &Path,
122 kind: DefinitionKind,
123) -> Result<LoadedDefinition, NikaError> {
124 let base = path.to_string_lossy();
125
126 for ext in kind.extensions() {
127 let with_ext = PathBuf::from(format!("{}{}", base, ext));
128 if with_ext.is_file() {
129 return load_from_file(&with_ext, kind);
130 }
131 }
132
133 if path.is_dir() {
135 return load_from_folder(path, kind);
136 }
137
138 let as_folder = path.to_path_buf();
140 if as_folder.is_dir() {
141 return load_from_folder(&as_folder, kind);
142 }
143
144 Err(NikaError::WorkflowNotFound {
145 path: path.to_string_lossy().to_string(),
146 })
147}
148
149fn load_from_folder(path: &Path, kind: DefinitionKind) -> Result<LoadedDefinition, NikaError> {
151 let standard = path.join(kind.standard_filename());
153 if standard.is_file() {
154 return load_from_file(&standard, kind);
155 }
156
157 if let Ok(entries) = std::fs::read_dir(path) {
159 for entry in entries.flatten() {
160 let entry_path = entry.path();
161 if entry_path.is_file() {
162 let filename = entry_path
163 .file_name()
164 .and_then(|s| s.to_str())
165 .unwrap_or("");
166 for ext in kind.extensions() {
167 if filename.ends_with(ext) {
168 return load_from_file(&entry_path, kind);
169 }
170 }
171 }
172 }
173 }
174
175 for name in &["index.md", "README.md", "main.md"] {
177 let index = path.join(name);
178 if index.is_file() {
179 return load_from_file(&index, kind);
180 }
181 }
182
183 Err(NikaError::WorkflowNotFound {
184 path: path.to_string_lossy().to_string(),
185 })
186}
187
188fn load_from_file(path: &Path, kind: DefinitionKind) -> Result<LoadedDefinition, NikaError> {
190 let content = std::fs::read_to_string(path).map_err(|_| NikaError::WorkflowNotFound {
191 path: path.to_string_lossy().to_string(),
192 })?;
193
194 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
195
196 match ext {
197 "yaml" | "yml" => parse_yaml(&content, path, kind),
198 "md" => parse_markdown(&content, path, kind),
199 _ => {
200 parse_markdown(&content, path, kind).or_else(|_| parse_yaml(&content, path, kind))
202 }
203 }
204}
205
206fn parse_yaml(
208 content: &str,
209 path: &Path,
210 _kind: DefinitionKind,
211) -> Result<LoadedDefinition, NikaError> {
212 let def: YamlDefinition = serde_yaml::from_str(content).map_err(|e| NikaError::ParseError {
213 details: format!("{}: {}", path.display(), e),
214 })?;
215
216 let name = def.name.unwrap_or_else(|| extract_name_from_path(path));
217
218 Ok(LoadedDefinition {
219 name,
220 description: def.description,
221 system: def.system,
222 provider: def.provider,
223 model: def.model,
224 max_turns: def.max_turns,
225 temperature: def.temperature,
226 source_path: path.to_path_buf(),
227 })
228}
229
230fn parse_markdown(
232 content: &str,
233 path: &Path,
234 _kind: DefinitionKind,
235) -> Result<LoadedDefinition, NikaError> {
236 let (frontmatter, body) = extract_frontmatter(content)?;
237
238 let fm: Frontmatter = if let Some(fm_str) = frontmatter {
239 serde_yaml::from_str(&fm_str).map_err(|e| NikaError::ParseError {
240 details: format!("{}: Invalid frontmatter: {}", path.display(), e),
241 })?
242 } else {
243 Frontmatter {
244 name: None,
245 description: None,
246 provider: None,
247 model: None,
248 max_turns: None,
249 temperature: None,
250 }
251 };
252
253 let name = fm.name.unwrap_or_else(|| extract_name_from_path(path));
254
255 let description = fm.description.or_else(|| extract_first_paragraph(&body));
256
257 Ok(LoadedDefinition {
258 name,
259 description,
260 system: body.trim().to_string(),
261 provider: fm.provider,
262 model: fm.model,
263 max_turns: fm.max_turns,
264 temperature: fm.temperature,
265 source_path: path.to_path_buf(),
266 })
267}
268
269fn extract_frontmatter(content: &str) -> Result<(Option<String>, String), NikaError> {
271 let content = content.trim_start();
272
273 if !content.starts_with("---") {
274 return Ok((None, content.to_string()));
275 }
276
277 let after_start = &content[3..];
279 if let Some(end_pos) = after_start.find("\n---") {
280 let frontmatter = after_start[..end_pos].trim().to_string();
281 let body = after_start[end_pos + 4..].trim().to_string();
282 Ok((Some(frontmatter), body))
283 } else {
284 Err(NikaError::ParseError {
285 details: "Unterminated YAML frontmatter (missing closing ---)".to_string(),
286 })
287 }
288}
289
290fn extract_name_from_path(path: &Path) -> String {
292 let stem = path
293 .file_stem()
294 .and_then(|s| s.to_str())
295 .unwrap_or("unknown");
296
297 let name = stem
299 .strip_suffix(".agent")
300 .or_else(|| stem.strip_suffix(".skill"))
301 .unwrap_or(stem);
302
303 if name.eq_ignore_ascii_case("agent") || name.eq_ignore_ascii_case("skill") {
305 path.parent()
307 .and_then(|p| p.file_name())
308 .and_then(|s| s.to_str())
309 .unwrap_or(name)
310 .to_string()
311 } else {
312 name.to_string()
313 }
314}
315
316fn extract_first_paragraph(content: &str) -> Option<String> {
318 let content = content.trim();
319 if content.is_empty() {
320 return None;
321 }
322
323 let content = content
325 .lines()
326 .skip_while(|l| l.starts_with('#') || l.is_empty())
327 .collect::<Vec<_>>()
328 .join("\n");
329
330 let paragraph: String = content
332 .lines()
333 .take_while(|l| !l.is_empty())
334 .collect::<Vec<_>>()
335 .join(" ");
336
337 if paragraph.is_empty() {
338 None
339 } else {
340 Some(paragraph)
341 }
342}
343
344pub fn discover_definitions(
346 dir: &Path,
347 kind: DefinitionKind,
348) -> Result<Vec<LoadedDefinition>, NikaError> {
349 if !dir.is_dir() {
350 return Ok(vec![]);
351 }
352
353 let mut definitions = Vec::new();
354
355 let entries = std::fs::read_dir(dir).map_err(|_| NikaError::WorkflowNotFound {
356 path: dir.to_string_lossy().to_string(),
357 })?;
358
359 for entry in entries.flatten() {
360 let path = entry.path();
361
362 match load_definition(&path, kind) {
364 Ok(def) => definitions.push(def),
365 Err(e) => {
366 tracing::warn!(
367 path = %path.display(),
368 error = %e,
369 "Skipping unparseable definition file"
370 );
371 }
372 }
373 }
374
375 Ok(definitions)
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[test]
383 fn test_extract_frontmatter_with_frontmatter() {
384 let content = r#"---
385name: test-agent
386description: A test agent
387model: claude
388---
389
390This is the body content.
391"#;
392 let (fm, body) = extract_frontmatter(content).unwrap();
393 assert!(fm.is_some());
394 let fm = fm.unwrap();
395 assert!(fm.contains("name: test-agent"));
396 assert!(body.contains("This is the body content"));
397 }
398
399 #[test]
400 fn test_extract_frontmatter_without_frontmatter() {
401 let content = "Just plain markdown content.";
402 let (fm, body) = extract_frontmatter(content).unwrap();
403 assert!(fm.is_none());
404 assert_eq!(body, content);
405 }
406
407 #[test]
408 fn test_extract_frontmatter_unterminated() {
409 let content = "---\nname: test\nNo closing delimiter";
410 let result = extract_frontmatter(content);
411 assert!(result.is_err());
412 }
413
414 #[test]
415 fn test_extract_name_from_path_agent_yaml() {
416 let path = Path::new("/foo/bar/researcher.agent.yaml");
417 assert_eq!(extract_name_from_path(path), "researcher");
418 }
419
420 #[test]
421 fn test_extract_name_from_path_skill_yaml() {
422 let path = Path::new("/foo/bar/brainstorm.skill.yaml");
423 assert_eq!(extract_name_from_path(path), "brainstorm");
424 }
425
426 #[test]
427 fn test_extract_name_from_path_md() {
428 let path = Path::new("/foo/bar/reviewer.md");
429 assert_eq!(extract_name_from_path(path), "reviewer");
430 }
431
432 #[test]
433 fn test_extract_name_from_path_standard_file() {
434 let path = Path::new("/foo/my-agent/AGENT.md");
435 assert_eq!(extract_name_from_path(path), "my-agent");
436 }
437
438 #[test]
439 fn test_extract_first_paragraph() {
440 let content = r#"# Header
441
442First paragraph content here.
443More of the same paragraph.
444
445Second paragraph.
446"#;
447 let result = extract_first_paragraph(content);
448 assert_eq!(
449 result,
450 Some("First paragraph content here. More of the same paragraph.".to_string())
451 );
452 }
453
454 #[test]
455 fn test_extract_first_paragraph_empty() {
456 assert_eq!(extract_first_paragraph(""), None);
457 assert_eq!(extract_first_paragraph("# Just a header"), None);
458 }
459
460 #[test]
461 fn test_definition_kind_extensions() {
462 assert!(DefinitionKind::Agent.extensions().contains(&".agent.yaml"));
463 assert!(DefinitionKind::Agent.extensions().contains(&".md"));
464 assert!(DefinitionKind::Skill.extensions().contains(&".skill.yaml"));
465 }
466
467 #[test]
468 fn test_definition_kind_standard_filename() {
469 assert_eq!(DefinitionKind::Agent.standard_filename(), "AGENT.md");
470 assert_eq!(DefinitionKind::Skill.standard_filename(), "SKILL.md");
471 }
472
473 #[test]
474 fn test_parse_yaml_definition() {
475 let yaml = r#"
476name: test-agent
477description: A test agent
478system: You are a helpful assistant.
479provider: claude
480model: claude-sonnet-4-6
481max_turns: 5
482"#;
483 let path = Path::new("test.agent.yaml");
484 let def = parse_yaml(yaml, path, DefinitionKind::Agent).unwrap();
485
486 assert_eq!(def.name, "test-agent");
487 assert_eq!(def.description, Some("A test agent".to_string()));
488 assert_eq!(def.system, "You are a helpful assistant.");
489 assert_eq!(def.provider, Some("claude".to_string()));
490 assert_eq!(def.model, Some("claude-sonnet-4-6".to_string()));
491 assert_eq!(def.max_turns, Some(5));
492 }
493
494 #[test]
495 fn test_parse_markdown_with_frontmatter() {
496 let md = r#"---
497name: code-reviewer
498description: Reviews code quality
499model: sonnet
500---
501
502You are a Senior Code Reviewer with expertise in software architecture.
503
504Your role is to review completed project steps.
505"#;
506 let path = Path::new("code-reviewer.md");
507 let def = parse_markdown(md, path, DefinitionKind::Agent).unwrap();
508
509 assert_eq!(def.name, "code-reviewer");
510 assert_eq!(def.description, Some("Reviews code quality".to_string()));
511 assert!(def.system.contains("Senior Code Reviewer"));
512 assert_eq!(def.model, Some("sonnet".to_string()));
513 }
514
515 #[test]
516 fn test_parse_markdown_without_frontmatter() {
517 let md = "You are a simple assistant.\n\nHelp the user.";
518 let path = Path::new("simple-agent.md");
519 let def = parse_markdown(md, path, DefinitionKind::Agent).unwrap();
520
521 assert_eq!(def.name, "simple-agent");
522 assert!(def.system.contains("simple assistant"));
523 }
524
525 #[test]
526 fn test_loaded_definition_has_source_path() {
527 let md = "---\nname: test\n---\nBody";
528 let path = Path::new("/foo/bar/test.md");
529 let def = parse_markdown(md, path, DefinitionKind::Agent).unwrap();
530
531 assert_eq!(def.source_path, path);
532 }
533}