Skip to main content

hematite/agent/
instructions.rs

1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use serde::Deserialize;
6
7use crate::agent::config::WorkspaceTrustConfig;
8use crate::agent::trust_resolver::{resolve_workspace_trust, WorkspaceTrustPolicy};
9
10pub const PROJECT_GUIDANCE_FILES: &[&str] = &[
11    "AGENTS.md",
12    "agents.md",
13    "CLAUDE.md",
14    ".claude.md",
15    "CLAUDE.local.md",
16    "HEMATITE.md",
17    "HEMATITE.local.md",
18    ".hematite/rules.md",
19    ".hematite/rules.local.md",
20    "SKILLS.md",
21    "SKILL.md",
22    ".hematite/instructions.md",
23];
24
25pub const AGENT_SKILL_DIRS: &[&str] = &[".agents/skills", ".hematite/skills"];
26
27#[derive(Debug, Clone)]
28pub struct InstructionFile {
29    pub path: PathBuf,
30    pub content: String,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum SkillScope {
35    User,
36    Project,
37}
38
39impl SkillScope {
40    pub fn label(self) -> &'static str {
41        match self {
42            SkillScope::User => "user",
43            SkillScope::Project => "project",
44        }
45    }
46}
47
48#[derive(Debug, Clone)]
49pub struct AgentSkill {
50    pub name: String,
51    pub description: String,
52    pub compatibility: Option<String>,
53    /// Glob patterns that auto-activate this skill based on file context.
54    /// Examples: `["*.py"]`, `["*.rs"]`, `["Cargo.toml"]`
55    pub triggers: Vec<String>,
56    pub skill_md_path: PathBuf,
57    pub scope: SkillScope,
58    pub body: String,
59}
60
61#[derive(Debug, Clone)]
62pub struct SkillDiscovery {
63    pub skills: Vec<AgentSkill>,
64    pub project_skills_loaded: bool,
65    pub project_skills_note: Option<String>,
66}
67
68#[derive(Debug, Default, Deserialize)]
69struct SkillFrontmatter {
70    name: Option<String>,
71    description: Option<String>,
72    compatibility: Option<String>,
73    // Comma-separated glob patterns, e.g. "*.py, *.pyx" or "Cargo.toml"
74    triggers: Option<String>,
75}
76
77pub fn resolve_guidance_path(dir: &Path, candidate_name: &str) -> PathBuf {
78    candidate_name
79        .split('/')
80        .fold(dir.to_path_buf(), |acc, part| acc.join(part))
81}
82
83pub fn guidance_section_title(candidate_name: &str) -> &'static str {
84    match candidate_name {
85        "SKILLS.md" | "SKILL.md" => "PROJECT GUIDANCE",
86        _ => "PROJECT RULES",
87    }
88}
89
90pub fn guidance_status_label(candidate_name: &str) -> &'static str {
91    match candidate_name {
92        "SKILLS.md" | "SKILL.md" => "(workspace guidance)",
93        _ if candidate_name.contains(".local") || candidate_name.ends_with(".local.md") => {
94            "(local override)"
95        }
96        _ => "(shared asset)",
97    }
98}
99
100pub fn discover_agent_skills(
101    workspace_root: &Path,
102    trust_config: &WorkspaceTrustConfig,
103) -> SkillDiscovery {
104    let mut discovered: Vec<AgentSkill> = Vec::new();
105
106    if let Some(home) = dirs::home_dir() {
107        let user_roots = AGENT_SKILL_DIRS
108            .iter()
109            .map(|relative| resolve_guidance_path(&home, relative))
110            .collect::<Vec<_>>();
111        load_skills_from_roots(&mut discovered, &user_roots, SkillScope::User);
112    }
113
114    let trust = resolve_workspace_trust(workspace_root, trust_config);
115    let (project_skills_loaded, project_skills_note) = match trust.policy {
116        WorkspaceTrustPolicy::Trusted => {
117            let project_roots = AGENT_SKILL_DIRS
118                .iter()
119                .map(|relative| resolve_guidance_path(workspace_root, relative))
120                .collect::<Vec<_>>();
121            load_skills_from_roots(&mut discovered, &project_roots, SkillScope::Project);
122            (true, None)
123        }
124        WorkspaceTrustPolicy::RequireApproval => (
125            false,
126            Some(format!(
127                "Project skill directories were skipped because `{}` is not trust-allowlisted.",
128                trust.workspace_display
129            )),
130        ),
131        WorkspaceTrustPolicy::Denied => (
132            false,
133            Some(format!(
134                "Project skill directories were skipped because `{}` is denied by trust policy.",
135                trust.workspace_display
136            )),
137        ),
138    };
139
140    SkillDiscovery {
141        skills: dedupe_skills(discovered),
142        project_skills_loaded,
143        project_skills_note,
144    }
145}
146
147pub fn render_skill_catalog(discovery: &SkillDiscovery, max_chars: usize) -> Option<String> {
148    if discovery.skills.is_empty() && discovery.project_skills_note.is_none() {
149        return None;
150    }
151
152    let mut output = Vec::new();
153    output.push("# Agent Skills Catalog".to_string());
154    output.push(
155        "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(),
156    );
157    if let Some(note) = &discovery.project_skills_note {
158        output.push(format!("- {}", note));
159    }
160
161    let mut remaining = max_chars;
162    for skill in &discovery.skills {
163        if remaining < 150 {
164            output.push("\n... [further skills omitted due to context limit]".to_string());
165            break;
166        }
167        let mut line = format!(
168            "- {} [{}] — {} | SKILL.md: {}",
169            skill.name,
170            skill.scope.label(),
171            skill.description,
172            skill.skill_md_path.display()
173        );
174        if !skill.triggers.is_empty() {
175            line.push_str(&format!(" | auto-activates: {}", skill.triggers.join(", ")));
176        }
177        if let Some(compatibility) = &skill.compatibility {
178            line.push_str(&format!(" | compatibility: {}", compatibility));
179        }
180        remaining = remaining.saturating_sub(line.len());
181        output.push(line);
182    }
183
184    Some(output.join("\n"))
185}
186
187/// Returns skills whose names (or hyphenated name parts) appear in `query`,
188/// or whose `triggers` glob patterns match files referenced in the query or
189/// the active workspace stack.
190pub fn activate_matching_skills<'a>(
191    discovery: &'a SkillDiscovery,
192    query: &str,
193) -> Vec<&'a AgentSkill> {
194    let q = query.to_lowercase();
195    let workspace_root = crate::tools::file_ops::workspace_root();
196    let ws_exts = workspace_stack_extensions(&workspace_root);
197    let query_paths = extract_query_paths(query);
198
199    let mut matched = Vec::new();
200    for skill in &discovery.skills {
201        // 1. Direct name match (e.g. "use the pdf-processing skill")
202        let name_lower = skill.name.to_lowercase();
203        if q.contains(&name_lower) {
204            matched.push(skill);
205            continue;
206        }
207
208        // 2. All significant hyphen/underscore parts appear in query.
209        // Split the already-lowercased name to avoid a per-part allocation.
210        let parts: Vec<&str> = name_lower
211            .split(['-', '_', ' '])
212            .filter(|p| p.len() > 3)
213            .collect();
214        if parts.len() >= 2 && parts.iter().all(|p| q.contains(*p)) {
215            matched.push(skill);
216            continue;
217        }
218
219        // 3. Trigger glob matches a file path mentioned in the query
220        if !skill.triggers.is_empty() {
221            let trigger_hit = skill
222                .triggers
223                .iter()
224                .any(|pattern| query_paths.iter().any(|path| glob_matches(pattern, path)));
225            if trigger_hit {
226                matched.push(skill);
227                continue;
228            }
229
230            // 4. Trigger glob matches the workspace stack (e.g. "*.rs" when Cargo.toml exists)
231            let ws_hit = skill
232                .triggers
233                .iter()
234                .any(|pattern| ws_exts.iter().any(|ext| glob_matches(pattern, ext)));
235            if ws_hit {
236                matched.push(skill);
237            }
238        }
239    }
240    matched
241}
242
243/// Simple glob matcher supporting `*.ext`, `prefix*`, and exact-name patterns.
244fn glob_matches(pattern: &str, name: &str) -> bool {
245    if let Some(ext_pattern) = pattern.strip_prefix("*.") {
246        // *.ext — match the file extension
247        name.ends_with(&format!(".{}", ext_pattern)) || name == ext_pattern
248    } else if let Some(prefix) = pattern.strip_suffix('*') {
249        name.starts_with(prefix)
250    } else if pattern.contains('*') {
251        // mid-pattern wildcard: split on first * and check prefix + suffix
252        let (pre, suf) = pattern.split_once('*').unwrap();
253        name.starts_with(pre) && name.ends_with(suf)
254    } else {
255        // exact match (e.g. "Cargo.toml")
256        name == pattern
257    }
258}
259
260/// Returns synthetic "file extension" strings that represent the active workspace stack,
261/// derived from presence of stack marker files. Used to match trigger patterns like `*.rs`.
262fn workspace_stack_extensions(root: &std::path::Path) -> Vec<String> {
263    let mut exts: Vec<String> = Vec::new();
264    let markers: &[(&str, &[&str])] = &[
265        ("Cargo.toml", &["x.rs"]),
266        ("go.mod", &["x.go"]),
267        ("CMakeLists.txt", &["x.cpp", "x.c", "x.h"]),
268        ("package.json", &["x.ts", "x.js", "x.tsx", "x.jsx"]),
269        ("tsconfig.json", &["x.ts", "x.tsx"]),
270        ("pyproject.toml", &["x.py"]),
271        ("setup.py", &["x.py"]),
272        ("requirements.txt", &["x.py"]),
273        ("Gemfile", &["x.rb"]),
274        ("pom.xml", &["x.java"]),
275        ("build.gradle", &["x.java", "x.kt"]),
276        ("composer.json", &["x.php"]),
277    ];
278    for (marker, file_exts) in markers {
279        if root.join(marker).exists() {
280            exts.extend(file_exts.iter().map(|s| s.to_string()));
281        }
282    }
283    exts
284}
285
286/// Extracts token-like file paths from the query (words containing a `.` and a known extension,
287/// plus `@mention` paths).
288fn extract_query_paths(query: &str) -> Vec<String> {
289    let known_exts = [
290        "rs", "py", "ts", "js", "tsx", "jsx", "go", "cpp", "c", "h", "java", "kt", "rb", "php",
291        "swift", "cs", "md", "toml", "yaml", "yml", "json", "html", "css", "scss", "sh", "pdf",
292        "txt",
293    ];
294    let mut paths = Vec::new();
295    for token in query.split_whitespace() {
296        let token = token.trim_matches(|c: char| {
297            !c.is_alphanumeric() && c != '.' && c != '/' && c != '_' && c != '-' && c != '@'
298        });
299        let effective = if token.starts_with('@') {
300            &token[1..]
301        } else {
302            token
303        };
304        if let Some(ext) = effective.rsplit('.').next() {
305            if known_exts.contains(&ext.to_lowercase().as_str()) {
306                paths.push(effective.to_string());
307            }
308        }
309    }
310    paths
311}
312
313/// Renders the full body text of every skill that matches `query`.
314/// Returns `None` when no skills are activated or all bodies are empty.
315pub fn render_active_skill_bodies(
316    discovery: &SkillDiscovery,
317    query: &str,
318    max_chars: usize,
319) -> Option<String> {
320    let matches = activate_matching_skills(discovery, query);
321    if matches.is_empty() {
322        return None;
323    }
324    let mut sections: Vec<String> = vec!["# Active Skill Instructions".to_string()];
325    let mut remaining = max_chars;
326    for skill in matches {
327        if remaining < 200 {
328            sections.push("... [further skill bodies omitted — context limit]".to_string());
329            break;
330        }
331        let body = skill.body.trim();
332        if body.is_empty() {
333            continue;
334        }
335        let section = format!("## Skill: {}\n{}", skill.name, body);
336        let entry = if section.len() > remaining {
337            format!(
338                "{}\n... [skill body truncated]",
339                &section[..remaining.saturating_sub(30)]
340            )
341        } else {
342            section
343        };
344        remaining = remaining.saturating_sub(entry.len());
345        sections.push(entry);
346    }
347    if sections.len() <= 1 {
348        return None;
349    }
350    Some(sections.join("\n\n"))
351}
352
353pub fn render_skills_report(discovery: &SkillDiscovery) -> String {
354    let mut report = String::from("## Agent Skills\n\n");
355    report.push_str(&format!(
356        "Project skill directories: {}\n\n",
357        if discovery.project_skills_loaded {
358            "loaded"
359        } else {
360            "skipped"
361        }
362    ));
363    if let Some(note) = &discovery.project_skills_note {
364        report.push_str(note);
365        report.push_str("\n\n");
366    }
367    if discovery.skills.is_empty() {
368        report.push_str("No Agent Skills were discovered.\n\n");
369        report.push_str("Scanned locations:\n");
370        report.push_str("- `<project>/.agents/skills/`\n");
371        report.push_str("- `<project>/.hematite/skills/`\n");
372        report.push_str("- `~/.agents/skills/`\n");
373        report.push_str("- `~/.hematite/skills/`\n");
374        report.push_str(
375            "\nAgent Skills are directory-based and require a `SKILL.md` file at the skill root.",
376        );
377        return report;
378    }
379
380    report.push_str("Discovered skills:\n");
381    for skill in &discovery.skills {
382        report.push_str(&format!(
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            report.push_str(&format!(
391                "  auto-activates: {}\n",
392                skill.triggers.join(", ")
393            ));
394        }
395        if let Some(compatibility) = &skill.compatibility {
396            report.push_str(&format!("  compatibility: {}\n", compatibility));
397        }
398    }
399    report
400}
401
402/// Discovers project guidance files from the current directory up to the root.
403pub fn discover_instruction_files(cwd: &Path) -> Vec<InstructionFile> {
404    let mut directories = Vec::new();
405    let mut cursor = Some(cwd);
406    while let Some(dir) = cursor {
407        directories.push(dir.to_path_buf());
408        cursor = dir.parent();
409    }
410    directories.reverse();
411
412    let mut files = Vec::new();
413    let mut seen_hashes = HashSet::new();
414
415    for dir in directories {
416        for candidate_name in PROJECT_GUIDANCE_FILES {
417            let candidate_path = resolve_guidance_path(&dir, candidate_name);
418
419            if let Ok(content) = fs::read_to_string(&candidate_path) {
420                let trimmed = content.trim();
421                if !trimmed.is_empty() {
422                    // Simple hash/dedupe based on content to ignore shadowed files.
423                    let hash = stable_hash(trimmed);
424                    if seen_hashes.contains(&hash) {
425                        continue;
426                    }
427                    seen_hashes.insert(hash);
428                    files.push(InstructionFile {
429                        path: candidate_path,
430                        content: trimmed.to_string(),
431                    });
432                }
433            }
434        }
435    }
436    files
437}
438
439fn stable_hash(s: &str) -> u64 {
440    use std::collections::hash_map::DefaultHasher;
441    use std::hash::{Hash, Hasher};
442    let mut hasher = DefaultHasher::new();
443    s.hash(&mut hasher);
444    hasher.finish()
445}
446
447/// Renders instruction files into a prompt section with a limit on characters.
448pub fn render_instructions(files: &[InstructionFile], max_chars: usize) -> Option<String> {
449    if files.is_empty() {
450        return None;
451    }
452
453    let mut output = Vec::new();
454    output.push("# Project Instructions And Skills".to_string());
455    output.push(
456        "These guidance files were discovered in the directory tree for the current repository:"
457            .to_string(),
458    );
459
460    let mut remaining = max_chars;
461    for file in files {
462        if remaining < 100 {
463            output.push("\n... [further instructions omitted due to context limit]".to_string());
464            break;
465        }
466
467        let content = if file.content.len() > remaining {
468            format!("{}\n... [truncated]", &file.content[..remaining - 20])
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::new();
612    let mut indexes: HashMap<String, usize> = HashMap::new();
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}