Skip to main content

hematite/agent/
instructions.rs

1use std::collections::{HashMap, HashSet};
2use std::fmt::Write as _;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use serde::Deserialize;
7
8use crate::agent::config::WorkspaceTrustConfig;
9use crate::agent::truncation::safe_head;
10use crate::agent::trust_resolver::{resolve_workspace_trust, WorkspaceTrustPolicy};
11
12pub const PROJECT_GUIDANCE_FILES: &[&str] = &[
13    "AGENTS.md",
14    "agents.md",
15    "CLAUDE.md",
16    ".claude.md",
17    "CLAUDE.local.md",
18    "HEMATITE.md",
19    "HEMATITE.local.md",
20    ".hematite/rules.md",
21    ".hematite/rules.local.md",
22    "SKILLS.md",
23    "SKILL.md",
24    ".hematite/instructions.md",
25];
26
27pub const AGENT_SKILL_DIRS: &[&str] = &[".agents/skills", ".hematite/skills"];
28
29#[derive(Debug, Clone)]
30pub struct InstructionFile {
31    pub path: PathBuf,
32    pub content: String,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum SkillScope {
37    User,
38    Project,
39}
40
41impl SkillScope {
42    pub fn label(self) -> &'static str {
43        match self {
44            SkillScope::User => "user",
45            SkillScope::Project => "project",
46        }
47    }
48}
49
50#[derive(Debug, Clone)]
51pub struct AgentSkill {
52    pub name: String,
53    pub description: String,
54    pub compatibility: Option<String>,
55    /// Glob patterns that auto-activate this skill based on file context.
56    /// Examples: `["*.py"]`, `["*.rs"]`, `["Cargo.toml"]`
57    pub triggers: Vec<String>,
58    pub skill_md_path: PathBuf,
59    pub scope: SkillScope,
60    pub body: String,
61}
62
63#[derive(Debug, Clone)]
64pub struct SkillDiscovery {
65    pub skills: Vec<AgentSkill>,
66    pub project_skills_loaded: bool,
67    pub project_skills_note: Option<String>,
68}
69
70#[derive(Debug, Default, Deserialize)]
71struct SkillFrontmatter {
72    name: Option<String>,
73    description: Option<String>,
74    compatibility: Option<String>,
75    // Comma-separated glob patterns, e.g. "*.py, *.pyx" or "Cargo.toml"
76    triggers: Option<String>,
77}
78
79pub fn resolve_guidance_path(dir: &Path, candidate_name: &str) -> PathBuf {
80    candidate_name
81        .split('/')
82        .fold(dir.to_path_buf(), |acc, part| acc.join(part))
83}
84
85pub fn guidance_section_title(candidate_name: &str) -> &'static str {
86    match candidate_name {
87        "SKILLS.md" | "SKILL.md" => "PROJECT GUIDANCE",
88        _ => "PROJECT RULES",
89    }
90}
91
92pub fn guidance_status_label(candidate_name: &str) -> &'static str {
93    match candidate_name {
94        "SKILLS.md" | "SKILL.md" => "(workspace guidance)",
95        _ if candidate_name.contains(".local") || candidate_name.ends_with(".local.md") => {
96            "(local override)"
97        }
98        _ => "(shared asset)",
99    }
100}
101
102pub fn discover_agent_skills(
103    workspace_root: &Path,
104    trust_config: &WorkspaceTrustConfig,
105) -> SkillDiscovery {
106    let mut discovered: Vec<AgentSkill> = Vec::new();
107
108    if let Some(home) = dirs::home_dir() {
109        let user_roots = AGENT_SKILL_DIRS
110            .iter()
111            .map(|relative| resolve_guidance_path(&home, relative))
112            .collect::<Vec<_>>();
113        load_skills_from_roots(&mut discovered, &user_roots, SkillScope::User);
114    }
115
116    let trust = resolve_workspace_trust(workspace_root, trust_config);
117    let (project_skills_loaded, project_skills_note) = match trust.policy {
118        WorkspaceTrustPolicy::Trusted => {
119            let project_roots = AGENT_SKILL_DIRS
120                .iter()
121                .map(|relative| resolve_guidance_path(workspace_root, relative))
122                .collect::<Vec<_>>();
123            load_skills_from_roots(&mut discovered, &project_roots, SkillScope::Project);
124            (true, None)
125        }
126        WorkspaceTrustPolicy::RequireApproval => (
127            false,
128            Some(format!(
129                "Project skill directories were skipped because `{}` is not trust-allowlisted.",
130                trust.workspace_display
131            )),
132        ),
133        WorkspaceTrustPolicy::Denied => (
134            false,
135            Some(format!(
136                "Project skill directories were skipped because `{}` is denied by trust policy.",
137                trust.workspace_display
138            )),
139        ),
140    };
141
142    SkillDiscovery {
143        skills: dedupe_skills(discovered),
144        project_skills_loaded,
145        project_skills_note,
146    }
147}
148
149pub fn render_skill_catalog(discovery: &SkillDiscovery, max_chars: usize) -> Option<String> {
150    if discovery.skills.is_empty() && discovery.project_skills_note.is_none() {
151        return None;
152    }
153
154    let mut output = Vec::with_capacity(discovery.skills.len() + 2);
155    output.push("# Agent Skills Catalog".to_string());
156    output.push(
157        "These skills use progressive disclosure. Read a skill's SKILL.md before following it; only load scripts, references, or assets when the skill calls for them.".to_string(),
158    );
159    if let Some(note) = &discovery.project_skills_note {
160        output.push(format!("- {}", note));
161    }
162
163    let mut remaining = max_chars;
164    for skill in &discovery.skills {
165        if remaining < 150 {
166            output.push("\n... [further skills omitted due to context limit]".to_string());
167            break;
168        }
169        let mut line = format!(
170            "- {} [{}] — {} | SKILL.md: {}",
171            skill.name,
172            skill.scope.label(),
173            skill.description,
174            skill.skill_md_path.display()
175        );
176        if !skill.triggers.is_empty() {
177            let _ = write!(line, " | auto-activates: {}", skill.triggers.join(", "));
178        }
179        if let Some(compatibility) = &skill.compatibility {
180            let _ = write!(line, " | compatibility: {}", compatibility);
181        }
182        remaining = remaining.saturating_sub(line.len());
183        output.push(line);
184    }
185
186    Some(output.join("\n"))
187}
188
189/// Returns skills whose names (or hyphenated name parts) appear in `query`,
190/// or whose `triggers` glob patterns match files referenced in the query or
191/// the active workspace stack.
192pub fn activate_matching_skills<'a>(
193    discovery: &'a SkillDiscovery,
194    query: &str,
195) -> Vec<&'a AgentSkill> {
196    let q = query.to_lowercase();
197    let workspace_root = crate::tools::file_ops::workspace_root();
198    let ws_exts = workspace_stack_extensions(&workspace_root);
199    let query_paths = extract_query_paths(query);
200
201    let mut matched = Vec::with_capacity(discovery.skills.len());
202    for skill in &discovery.skills {
203        // 1. Direct name match (e.g. "use the pdf-processing skill")
204        let name_lower = skill.name.to_lowercase();
205        if q.contains(&name_lower) {
206            matched.push(skill);
207            continue;
208        }
209
210        // 2. All significant hyphen/underscore parts appear in query.
211        // Split the already-lowercased name to avoid a per-part allocation.
212        let parts: Vec<&str> = name_lower
213            .split(['-', '_', ' '])
214            .filter(|p| p.len() > 3)
215            .collect();
216        if parts.len() >= 2 && parts.iter().all(|p| q.contains(*p)) {
217            matched.push(skill);
218            continue;
219        }
220
221        // 3. Trigger glob matches a file path mentioned in the query
222        if !skill.triggers.is_empty() {
223            let trigger_hit = skill
224                .triggers
225                .iter()
226                .any(|pattern| query_paths.iter().any(|path| glob_matches(pattern, path)));
227            if trigger_hit {
228                matched.push(skill);
229                continue;
230            }
231
232            // 4. Trigger glob matches the workspace stack (e.g. "*.rs" when Cargo.toml exists)
233            let ws_hit = skill
234                .triggers
235                .iter()
236                .any(|pattern| ws_exts.iter().any(|ext| glob_matches(pattern, ext)));
237            if ws_hit {
238                matched.push(skill);
239            }
240        }
241    }
242    matched
243}
244
245/// Simple glob matcher supporting `*.ext`, `prefix*`, and exact-name patterns.
246fn glob_matches(pattern: &str, name: &str) -> bool {
247    if let Some(ext_pattern) = pattern.strip_prefix("*.") {
248        // *.ext — match the file extension
249        name.ends_with(&format!(".{}", ext_pattern)) || name == ext_pattern
250    } else if let Some(prefix) = pattern.strip_suffix('*') {
251        name.starts_with(prefix)
252    } else if pattern.contains('*') {
253        // mid-pattern wildcard: split on first * and check prefix + suffix
254        let (pre, suf) = pattern.split_once('*').unwrap();
255        name.starts_with(pre) && name.ends_with(suf)
256    } else {
257        // exact match (e.g. "Cargo.toml")
258        name == pattern
259    }
260}
261
262/// Returns synthetic "file extension" strings that represent the active workspace stack,
263/// derived from presence of stack marker files. Used to match trigger patterns like `*.rs`.
264fn workspace_stack_extensions(root: &std::path::Path) -> Vec<String> {
265    let mut exts: Vec<String> = Vec::with_capacity(8);
266    let markers: &[(&str, &[&str])] = &[
267        ("Cargo.toml", &["x.rs"]),
268        ("go.mod", &["x.go"]),
269        ("CMakeLists.txt", &["x.cpp", "x.c", "x.h"]),
270        ("package.json", &["x.ts", "x.js", "x.tsx", "x.jsx"]),
271        ("tsconfig.json", &["x.ts", "x.tsx"]),
272        ("pyproject.toml", &["x.py"]),
273        ("setup.py", &["x.py"]),
274        ("requirements.txt", &["x.py"]),
275        ("Gemfile", &["x.rb"]),
276        ("pom.xml", &["x.java"]),
277        ("build.gradle", &["x.java", "x.kt"]),
278        ("composer.json", &["x.php"]),
279    ];
280    for (marker, file_exts) in markers {
281        if root.join(marker).exists() {
282            exts.extend(file_exts.iter().map(|s| s.to_string()));
283        }
284    }
285    exts
286}
287
288/// Extracts token-like file paths from the query (words containing a `.` and a known extension,
289/// plus `@mention` paths).
290fn extract_query_paths(query: &str) -> Vec<String> {
291    let known_exts = [
292        "rs", "py", "ts", "js", "tsx", "jsx", "go", "cpp", "c", "h", "java", "kt", "rb", "php",
293        "swift", "cs", "md", "toml", "yaml", "yml", "json", "html", "css", "scss", "sh", "pdf",
294        "txt",
295    ];
296    let mut paths = Vec::new();
297    for token in query.split_whitespace() {
298        let token = token.trim_matches(|c: char| {
299            !c.is_alphanumeric() && c != '.' && c != '/' && c != '_' && c != '-' && c != '@'
300        });
301        let effective = token.strip_prefix('@').unwrap_or(token);
302        if let Some(ext) = effective.rsplit('.').next() {
303            if known_exts.contains(&ext.to_lowercase().as_str()) {
304                paths.push(effective.to_string());
305            }
306        }
307    }
308    paths
309}
310
311/// Renders the full body text of every skill that matches `query`.
312/// Returns `None` when no skills are activated or all bodies are empty.
313pub fn render_active_skill_bodies(
314    discovery: &SkillDiscovery,
315    query: &str,
316    max_chars: usize,
317) -> Option<String> {
318    let matches = activate_matching_skills(discovery, query);
319    if matches.is_empty() {
320        return None;
321    }
322    let mut sections: Vec<String> = vec!["# Active Skill Instructions".to_string()];
323    let mut remaining = max_chars;
324    for skill in matches {
325        if remaining < 200 {
326            sections.push("... [further skill bodies omitted — context limit]".to_string());
327            break;
328        }
329        let body = skill.body.trim();
330        if body.is_empty() {
331            continue;
332        }
333        let section = format!("## Skill: {}\n{}", skill.name, body);
334        let entry = if section.len() > remaining {
335            format!(
336                "{}\n... [skill body truncated]",
337                &section[..remaining.saturating_sub(30)]
338            )
339        } else {
340            section
341        };
342        remaining = remaining.saturating_sub(entry.len());
343        sections.push(entry);
344    }
345    if sections.len() <= 1 {
346        return None;
347    }
348    Some(sections.join("\n\n"))
349}
350
351pub fn render_skills_report(discovery: &SkillDiscovery) -> String {
352    let mut report = String::from("## Agent Skills\n\n");
353    let _ = write!(
354        report,
355        "Project skill directories: {}\n\n",
356        if discovery.project_skills_loaded {
357            "loaded"
358        } else {
359            "skipped"
360        }
361    );
362    if let Some(note) = &discovery.project_skills_note {
363        report.push_str(note);
364        report.push_str("\n\n");
365    }
366    if discovery.skills.is_empty() {
367        report.push_str("No Agent Skills were discovered.\n\n");
368        report.push_str("Scanned locations:\n");
369        report.push_str("- `<project>/.agents/skills/`\n");
370        report.push_str("- `<project>/.hematite/skills/`\n");
371        report.push_str("- `~/.agents/skills/`\n");
372        report.push_str("- `~/.hematite/skills/`\n");
373        report.push_str(
374            "\nAgent Skills are directory-based and require a `SKILL.md` file at the skill root.",
375        );
376        return report;
377    }
378
379    report.push_str("Discovered skills:\n");
380    for skill in &discovery.skills {
381        let _ = write!(
382            report,
383            "- `{}` [{}] — {}\n  SKILL.md: {}\n",
384            skill.name,
385            skill.scope.label(),
386            skill.description,
387            skill.skill_md_path.display()
388        );
389        if !skill.triggers.is_empty() {
390            let _ = writeln!(report, "  auto-activates: {}", skill.triggers.join(", "));
391        }
392        if let Some(compatibility) = &skill.compatibility {
393            let _ = writeln!(report, "  compatibility: {}", compatibility);
394        }
395    }
396    report
397}
398
399/// Discovers project guidance files from the current directory up to the root.
400pub fn discover_instruction_files(cwd: &Path) -> Vec<InstructionFile> {
401    let mut directories = Vec::new();
402    let mut cursor = Some(cwd);
403    while let Some(dir) = cursor {
404        directories.push(dir.to_path_buf());
405        cursor = dir.parent();
406    }
407    directories.reverse();
408
409    let mut files = Vec::new();
410    let mut seen_hashes = HashSet::new();
411
412    for dir in directories {
413        for candidate_name in PROJECT_GUIDANCE_FILES {
414            let candidate_path = resolve_guidance_path(&dir, candidate_name);
415
416            if let Ok(content) = fs::read_to_string(&candidate_path) {
417                let trimmed = content.trim();
418                if !trimmed.is_empty() {
419                    // Simple hash/dedupe based on content to ignore shadowed files.
420                    let hash = stable_hash(trimmed);
421                    if seen_hashes.contains(&hash) {
422                        continue;
423                    }
424                    seen_hashes.insert(hash);
425                    files.push(InstructionFile {
426                        path: candidate_path,
427                        content: trimmed.to_string(),
428                    });
429                }
430            }
431        }
432    }
433    files
434}
435
436fn stable_hash(s: &str) -> u64 {
437    use std::collections::hash_map::DefaultHasher;
438    use std::hash::{Hash, Hasher};
439    let mut hasher = DefaultHasher::new();
440    s.hash(&mut hasher);
441    hasher.finish()
442}
443
444/// Renders instruction files into a prompt section with a limit on characters.
445pub fn render_instructions(files: &[InstructionFile], max_chars: usize) -> Option<String> {
446    if files.is_empty() {
447        return None;
448    }
449
450    let mut output = Vec::with_capacity(files.len() + 2);
451    output.push("# Project Instructions And Skills".to_string());
452    output.push(
453        "These guidance files were discovered in the directory tree for the current repository:"
454            .to_string(),
455    );
456
457    let mut remaining = max_chars;
458    for file in files {
459        if remaining < 100 {
460            output.push("\n... [further instructions omitted due to context limit]".to_string());
461            break;
462        }
463
464        let content = if file.content.len() > remaining {
465            format!(
466                "{}\n... [truncated]",
467                safe_head(&file.content, remaining.saturating_sub(20))
468            )
469        } else {
470            file.content.clone()
471        };
472
473        remaining = remaining.saturating_sub(content.len());
474        output.push(format!("\n## Source: {}\n{}", file.path.display(), content));
475    }
476
477    Some(output.join("\n"))
478}
479
480fn load_skills_from_roots(into: &mut Vec<AgentSkill>, roots: &[PathBuf], scope: SkillScope) {
481    for root in roots {
482        if !root.exists() || !root.is_dir() {
483            continue;
484        }
485        for skill_md in discover_skill_markdown_files(root) {
486            if let Some(skill) = parse_agent_skill(&skill_md, scope) {
487                into.push(skill);
488            }
489        }
490    }
491}
492
493fn discover_skill_markdown_files(root: &Path) -> Vec<PathBuf> {
494    let mut files = Vec::new();
495    for entry in walkdir::WalkDir::new(root)
496        .min_depth(2)
497        .max_depth(4)
498        .into_iter()
499        .filter_map(Result::ok)
500    {
501        if !entry.file_type().is_file() {
502            continue;
503        }
504        if entry.file_name() != "SKILL.md" {
505            continue;
506        }
507        files.push(entry.into_path());
508    }
509    files
510}
511
512fn parse_agent_skill(skill_md_path: &Path, scope: SkillScope) -> Option<AgentSkill> {
513    let content = fs::read_to_string(skill_md_path).ok()?;
514    let (frontmatter, body) = split_frontmatter(&content)?;
515    let parsed = parse_frontmatter(&frontmatter)?;
516    let name = parsed.name?.trim().to_string();
517    let description = parsed.description?.trim().to_string();
518    if name.is_empty() || description.is_empty() {
519        return None;
520    }
521    let triggers = parsed
522        .triggers
523        .map(|t| {
524            t.split(',')
525                .map(|p| p.trim().to_string())
526                .filter(|p| !p.is_empty())
527                .collect()
528        })
529        .unwrap_or_default();
530    Some(AgentSkill {
531        name,
532        description,
533        compatibility: parsed
534            .compatibility
535            .map(|value| value.trim().to_string())
536            .filter(|value| !value.is_empty()),
537        triggers,
538        skill_md_path: skill_md_path.to_path_buf(),
539        scope,
540        body: body.trim().to_string(),
541    })
542}
543
544fn split_frontmatter(content: &str) -> Option<(String, String)> {
545    let mut lines = content.lines();
546    if lines.next()?.trim() != "---" {
547        return None;
548    }
549    let mut frontmatter = Vec::new();
550    let mut body = Vec::new();
551    let mut in_frontmatter = true;
552    for line in lines {
553        if in_frontmatter && line.trim() == "---" {
554            in_frontmatter = false;
555            continue;
556        }
557        if in_frontmatter {
558            frontmatter.push(line);
559        } else {
560            body.push(line);
561        }
562    }
563    if in_frontmatter {
564        return None;
565    }
566    Some((frontmatter.join("\n"), body.join("\n")))
567}
568
569fn parse_frontmatter(frontmatter: &str) -> Option<SkillFrontmatter> {
570    serde_yaml::from_str::<SkillFrontmatter>(frontmatter)
571        .ok()
572        .or_else(|| parse_frontmatter_fallback(frontmatter))
573}
574
575fn parse_frontmatter_fallback(frontmatter: &str) -> Option<SkillFrontmatter> {
576    let mut parsed = SkillFrontmatter::default();
577    for line in frontmatter.lines() {
578        let trimmed = line.trim();
579        if trimmed.is_empty() || trimmed.starts_with('#') {
580            continue;
581        }
582        let Some((key, value)) = trimmed.split_once(':') else {
583            continue;
584        };
585        let value = value.trim();
586        let value = strip_matching_quotes(value);
587        match key.trim() {
588            "name" => parsed.name = Some(value.to_string()),
589            "description" => parsed.description = Some(value.to_string()),
590            "compatibility" => parsed.compatibility = Some(value.to_string()),
591            "triggers" => parsed.triggers = Some(value.to_string()),
592            _ => {}
593        }
594    }
595    (parsed.name.is_some() || parsed.description.is_some()).then_some(parsed)
596}
597
598fn strip_matching_quotes(value: &str) -> &str {
599    if value.len() >= 2 {
600        let bytes = value.as_bytes();
601        let first = bytes[0] as char;
602        let last = bytes[value.len() - 1] as char;
603        if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
604            return &value[1..value.len() - 1];
605        }
606    }
607    value
608}
609
610fn dedupe_skills(skills: Vec<AgentSkill>) -> Vec<AgentSkill> {
611    let mut deduped = Vec::with_capacity(skills.len());
612    let mut indexes: HashMap<String, usize> = HashMap::with_capacity(skills.len());
613    for skill in skills {
614        if let Some(index) = indexes.get(&skill.name).copied() {
615            deduped[index] = skill;
616        } else {
617            indexes.insert(skill.name.clone(), deduped.len());
618            deduped.push(skill);
619        }
620    }
621    deduped.sort_by(|left, right| left.name.cmp(&right.name));
622    deduped
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628    use std::path::PathBuf;
629
630    #[test]
631    fn fallback_frontmatter_handles_unquoted_colons() {
632        let parsed = parse_frontmatter(
633            "name: pdf-processing\ndescription: Use when: PDFs, forms, or extraction are involved\ncompatibility: Requires Python 3.11+: tested locally",
634        )
635        .unwrap();
636
637        assert_eq!(parsed.name.as_deref(), Some("pdf-processing"));
638        assert_eq!(
639            parsed.description.as_deref(),
640            Some("Use when: PDFs, forms, or extraction are involved")
641        );
642        assert_eq!(
643            parsed.compatibility.as_deref(),
644            Some("Requires Python 3.11+: tested locally")
645        );
646    }
647
648    #[test]
649    fn project_skill_overrides_user_skill_on_name_collision() {
650        let temp = tempfile::tempdir().unwrap();
651        let user_root = temp.path().join("user");
652        let project_root = temp.path().join("project");
653
654        fs::create_dir_all(user_root.join(".agents/skills/review")).unwrap();
655        fs::create_dir_all(project_root.join(".agents/skills/review")).unwrap();
656
657        fs::write(
658            user_root.join(".agents/skills/review/SKILL.md"),
659            "---\nname: review\ndescription: User skill.\n---\n",
660        )
661        .unwrap();
662        fs::write(
663            project_root.join(".agents/skills/review/SKILL.md"),
664            "---\nname: review\ndescription: Project skill.\n---\n",
665        )
666        .unwrap();
667
668        let mut discovered = Vec::new();
669        load_skills_from_roots(
670            &mut discovered,
671            &[user_root.join(".agents/skills")],
672            SkillScope::User,
673        );
674        load_skills_from_roots(
675            &mut discovered,
676            &[project_root.join(".agents/skills")],
677            SkillScope::Project,
678        );
679
680        let deduped = dedupe_skills(discovered);
681        assert_eq!(deduped.len(), 1);
682        assert_eq!(deduped[0].description, "Project skill.");
683        assert_eq!(deduped[0].scope, SkillScope::Project);
684    }
685
686    #[test]
687    fn trusted_workspace_discovers_project_skill_dirs() {
688        let temp = tempfile::tempdir().unwrap();
689        let workspace = temp.path().join("workspace");
690        let user_home = temp.path().join("home");
691
692        fs::create_dir_all(workspace.join(".agents/skills/code-review")).unwrap();
693        fs::create_dir_all(user_home.join(".agents/skills/global-review")).unwrap();
694        fs::write(
695            workspace.join(".agents/skills/code-review/SKILL.md"),
696            "---\nname: code-review\ndescription: Review diffs.\n---\n",
697        )
698        .unwrap();
699        fs::write(
700            user_home.join(".agents/skills/global-review/SKILL.md"),
701            "---\nname: global-review\ndescription: Global review skill.\n---\n",
702        )
703        .unwrap();
704
705        let mut discovered = Vec::new();
706        load_skills_from_roots(
707            &mut discovered,
708            &[user_home.join(".agents/skills")],
709            SkillScope::User,
710        );
711        load_skills_from_roots(
712            &mut discovered,
713            &[workspace.join(".agents/skills")],
714            SkillScope::Project,
715        );
716        let deduped = dedupe_skills(discovered);
717
718        let names = deduped
719            .into_iter()
720            .map(|skill| skill.name)
721            .collect::<Vec<_>>();
722        assert_eq!(
723            names,
724            vec!["code-review".to_string(), "global-review".to_string()]
725        );
726    }
727
728    #[test]
729    fn activate_matching_skills_finds_by_name() {
730        let discovery = SkillDiscovery {
731            skills: vec![
732                AgentSkill {
733                    name: "pdf-processing".to_string(),
734                    description: "Use when PDFs are involved.".to_string(),
735                    compatibility: None,
736                    triggers: vec![],
737                    skill_md_path: PathBuf::from("/tmp/pdf-processing/SKILL.md"),
738                    scope: SkillScope::User,
739                    body: "Step 1: extract text.".to_string(),
740                },
741                AgentSkill {
742                    name: "code-review".to_string(),
743                    description: "Review diffs.".to_string(),
744                    compatibility: None,
745                    triggers: vec![],
746                    skill_md_path: PathBuf::from("/tmp/code-review/SKILL.md"),
747                    scope: SkillScope::Project,
748                    body: "Review all changed files.".to_string(),
749                },
750            ],
751            project_skills_loaded: true,
752            project_skills_note: None,
753        };
754
755        // Direct name match
756        let m = activate_matching_skills(&discovery, "please use the pdf-processing skill");
757        assert_eq!(m.len(), 1);
758        assert_eq!(m[0].name, "pdf-processing");
759
760        // Part match (both "code" and "review" in query, each >3 chars)
761        let m2 = activate_matching_skills(&discovery, "can you do a code review of this PR?");
762        assert_eq!(m2.len(), 1);
763        assert_eq!(m2[0].name, "code-review");
764
765        // No match
766        let m3 = activate_matching_skills(&discovery, "what is the weather today?");
767        assert!(m3.is_empty());
768    }
769
770    #[test]
771    fn activate_matching_skills_triggers_on_file_extension() {
772        let discovery = SkillDiscovery {
773            skills: vec![AgentSkill {
774                name: "python-style".to_string(),
775                description: "Python style guide.".to_string(),
776                compatibility: None,
777                triggers: vec!["*.py".to_string()],
778                skill_md_path: PathBuf::from("/tmp/python-style/SKILL.md"),
779                scope: SkillScope::User,
780                body: "Use ruff for linting.".to_string(),
781            }],
782            project_skills_loaded: true,
783            project_skills_note: None,
784        };
785
786        // File extension in query activates skill
787        let m = activate_matching_skills(&discovery, "fix the type hints in src/parser.py");
788        assert_eq!(m.len(), 1, "should activate via *.py trigger");
789
790        // @mention path
791        let m2 = activate_matching_skills(&discovery, "refactor @src/utils.py");
792        assert_eq!(m2.len(), 1, "should activate via @mention .py path");
793
794        // Unrelated query — no file extension match
795        let m3 = activate_matching_skills(&discovery, "how does the network stack work?");
796        assert!(m3.is_empty());
797    }
798
799    #[test]
800    fn glob_matches_patterns() {
801        assert!(glob_matches("*.rs", "main.rs"));
802        assert!(glob_matches("*.rs", "src/lib.rs"));
803        assert!(!glob_matches("*.rs", "main.py"));
804        assert!(glob_matches("Cargo.toml", "Cargo.toml"));
805        assert!(!glob_matches("Cargo.toml", "cargo.toml"));
806        assert!(glob_matches("test*", "test_utils.rs"));
807        assert!(!glob_matches("test*", "unit_test.rs"));
808        assert!(glob_matches("*.py", "x.py")); // exact ext sentinel
809    }
810
811    #[test]
812    fn triggers_parsed_from_frontmatter() {
813        let temp = tempfile::tempdir().unwrap();
814        fs::create_dir_all(temp.path().join("py-skill")).unwrap();
815        fs::write(
816            temp.path().join("py-skill/SKILL.md"),
817            "---\nname: py-skill\ndescription: Python helper.\ntriggers: \"*.py, *.pyx\"\n---\n\nDo python things.\n",
818        )
819        .unwrap();
820
821        let skill =
822            parse_agent_skill(&temp.path().join("py-skill/SKILL.md"), SkillScope::User).unwrap();
823        assert_eq!(skill.triggers, vec!["*.py", "*.pyx"]);
824        assert!(skill.body.contains("Do python things."));
825    }
826
827    #[test]
828    fn render_active_skill_bodies_injects_body() {
829        let discovery = SkillDiscovery {
830            skills: vec![AgentSkill {
831                name: "pdf-processing".to_string(),
832                description: "Use when PDFs are involved.".to_string(),
833                compatibility: None,
834                triggers: vec![],
835                skill_md_path: PathBuf::from("/tmp/pdf-processing/SKILL.md"),
836                scope: SkillScope::User,
837                body: "## Instructions\nRun pdftotext first.".to_string(),
838            }],
839            project_skills_loaded: true,
840            project_skills_note: None,
841        };
842
843        let rendered =
844            render_active_skill_bodies(&discovery, "process this pdf-processing task", 8_000);
845        assert!(rendered.is_some());
846        let text = rendered.unwrap();
847        assert!(text.contains("Active Skill Instructions"));
848        assert!(text.contains("Skill: pdf-processing"));
849        assert!(text.contains("pdftotext"));
850
851        // No match → None
852        let none = render_active_skill_bodies(&discovery, "unrelated query about network", 8_000);
853        assert!(none.is_none());
854    }
855
856    #[test]
857    fn skill_body_captured_from_skill_md() {
858        let temp = tempfile::tempdir().unwrap();
859        fs::create_dir_all(temp.path().join("my-skill")).unwrap();
860        fs::write(
861            temp.path().join("my-skill/SKILL.md"),
862            "---\nname: my-skill\ndescription: A test skill.\n---\n\n## How to use\nDo the thing.\n",
863        )
864        .unwrap();
865
866        let skill =
867            parse_agent_skill(&temp.path().join("my-skill/SKILL.md"), SkillScope::User).unwrap();
868        assert_eq!(skill.name, "my-skill");
869        assert!(skill.body.contains("Do the thing."));
870    }
871
872    #[test]
873    fn guidance_catalog_renders_skill_paths() {
874        let discovery = SkillDiscovery {
875            skills: vec![AgentSkill {
876                name: "code-review".to_string(),
877                description: "Review diffs.".to_string(),
878                compatibility: Some("Requires git".to_string()),
879                triggers: vec![],
880                skill_md_path: PathBuf::from("/tmp/code-review/SKILL.md"),
881                scope: SkillScope::Project,
882                body: String::new(),
883            }],
884            project_skills_loaded: true,
885            project_skills_note: None,
886        };
887
888        let rendered = render_skill_catalog(&discovery, 2_000).unwrap();
889        assert!(rendered.contains("code-review"));
890        assert!(rendered.contains("/tmp/code-review/SKILL.md"));
891        assert!(rendered.contains("Requires git"));
892    }
893}