Skip to main content

pi/
resources.rs

1//! Resource loading for skills, prompt templates, themes, and extensions.
2//!
3//! Implements a subset of pi-mono's resource discovery behavior:
4//! - Skills (Agent Skills spec)
5//! - Prompt templates (markdown files with optional frontmatter)
6//! - Package-based resource discovery
7
8use crate::config::Config;
9use crate::error::{Error, Result};
10use crate::package_manager::{PackageManager, ResolveExtensionSourcesOptions};
11use crate::theme::Theme;
12use serde_json::{Value, json};
13use std::collections::{HashMap, HashSet};
14use std::fs;
15use std::path::{Path, PathBuf};
16
17fn panic_payload_message(payload: Box<dyn std::any::Any + Send + 'static>) -> String {
18    payload.downcast::<String>().map_or_else(
19        |payload| {
20            payload.downcast::<&'static str>().map_or_else(
21                |_| "unknown panic payload".to_string(),
22                |message| (*message).to_string(),
23            )
24        },
25        |message| *message,
26    )
27}
28
29// ============================================================================
30// Diagnostics
31// ============================================================================
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum DiagnosticKind {
35    Warning,
36    Collision,
37}
38
39#[derive(Debug, Clone)]
40pub struct CollisionInfo {
41    pub resource_type: String,
42    pub name: String,
43    pub winner_path: PathBuf,
44    pub loser_path: PathBuf,
45}
46
47#[derive(Debug, Clone)]
48pub struct ResourceDiagnostic {
49    pub kind: DiagnosticKind,
50    pub message: String,
51    pub path: PathBuf,
52    pub collision: Option<CollisionInfo>,
53}
54
55// ============================================================================
56// Skills
57// ============================================================================
58
59const MAX_SKILL_NAME_LEN: usize = 64;
60const MAX_SKILL_DESC_LEN: usize = 1024;
61
62const ALLOWED_SKILL_FRONTMATTER: [&str; 7] = [
63    "name",
64    "description",
65    "license",
66    "compatibility",
67    "metadata",
68    "allowed-tools",
69    "disable-model-invocation",
70];
71
72#[derive(Debug, Clone)]
73pub struct Skill {
74    pub name: String,
75    pub description: String,
76    pub file_path: PathBuf,
77    pub base_dir: PathBuf,
78    pub source: String,
79    pub disable_model_invocation: bool,
80}
81
82#[derive(Debug, Clone)]
83pub struct LoadSkillsResult {
84    pub skills: Vec<Skill>,
85    pub diagnostics: Vec<ResourceDiagnostic>,
86}
87
88#[derive(Debug, Clone)]
89pub struct LoadSkillsOptions {
90    pub cwd: PathBuf,
91    pub agent_dir: PathBuf,
92    pub skill_paths: Vec<PathBuf>,
93    pub include_defaults: bool,
94}
95
96// ============================================================================
97// Prompt templates
98// ============================================================================
99
100#[derive(Debug, Clone)]
101pub struct PromptTemplate {
102    pub name: String,
103    pub description: String,
104    pub content: String,
105    pub source: String,
106    pub file_path: PathBuf,
107}
108
109#[derive(Debug, Clone)]
110pub struct LoadPromptTemplatesOptions {
111    pub cwd: PathBuf,
112    pub agent_dir: PathBuf,
113    pub prompt_paths: Vec<PathBuf>,
114    pub include_defaults: bool,
115}
116
117// ============================================================================
118// Themes
119// ============================================================================
120
121#[derive(Debug, Clone)]
122pub struct ThemeResource {
123    pub name: String,
124    pub theme: Theme,
125    pub source: String,
126    pub file_path: PathBuf,
127}
128
129#[derive(Debug, Clone)]
130pub struct LoadThemesOptions {
131    pub cwd: PathBuf,
132    pub agent_dir: PathBuf,
133    pub theme_paths: Vec<PathBuf>,
134    pub include_defaults: bool,
135}
136
137#[derive(Debug, Clone)]
138pub struct LoadThemesResult {
139    pub themes: Vec<ThemeResource>,
140    pub diagnostics: Vec<ResourceDiagnostic>,
141}
142
143// ============================================================================
144// Resource Loader
145// ============================================================================
146
147#[derive(Debug, Clone)]
148#[allow(clippy::struct_excessive_bools)]
149pub struct ResourceCliOptions {
150    pub no_skills: bool,
151    pub no_prompt_templates: bool,
152    pub no_extensions: bool,
153    pub no_themes: bool,
154    pub skill_paths: Vec<String>,
155    pub prompt_paths: Vec<String>,
156    pub extension_paths: Vec<String>,
157    pub theme_paths: Vec<String>,
158}
159
160#[derive(Debug, Clone, Default)]
161pub struct PackageResources {
162    pub extensions: Vec<PathBuf>,
163    pub skills: Vec<PathBuf>,
164    pub prompts: Vec<PathBuf>,
165    pub themes: Vec<PathBuf>,
166}
167
168#[derive(Debug, Clone)]
169pub struct ResourceLoader {
170    skills: Vec<Skill>,
171    skill_diagnostics: Vec<ResourceDiagnostic>,
172    prompts: Vec<PromptTemplate>,
173    prompt_diagnostics: Vec<ResourceDiagnostic>,
174    themes: Vec<ThemeResource>,
175    theme_diagnostics: Vec<ResourceDiagnostic>,
176    extensions: Vec<PathBuf>,
177    enable_skill_commands: bool,
178}
179
180impl ResourceLoader {
181    pub const fn empty(enable_skill_commands: bool) -> Self {
182        Self {
183            skills: Vec::new(),
184            skill_diagnostics: Vec::new(),
185            prompts: Vec::new(),
186            prompt_diagnostics: Vec::new(),
187            themes: Vec::new(),
188            theme_diagnostics: Vec::new(),
189            extensions: Vec::new(),
190            enable_skill_commands,
191        }
192    }
193
194    #[allow(clippy::too_many_lines)]
195    pub async fn load(
196        manager: &PackageManager,
197        cwd: &Path,
198        config: &Config,
199        cli: &ResourceCliOptions,
200    ) -> Result<Self> {
201        let enable_skill_commands = config.enable_skill_commands();
202
203        // Resolve configured resources (settings + auto-discovery + packages) and CLI `-e` sources.
204        let resolved = Box::pin(manager.resolve()).await?;
205        let cli_extensions = Box::pin(manager.resolve_extension_sources(
206            &cli.extension_paths,
207            ResolveExtensionSourcesOptions {
208                local: false,
209                temporary: true,
210            },
211        ))
212        .await?;
213
214        // Helper: extract enabled paths from a resolved resource set.
215        let enabled_paths = |v: Vec<crate::package_manager::ResolvedResource>| {
216            v.into_iter().filter(|r| r.enabled).map(|r| r.path)
217        };
218
219        // Merge paths with pi-mono semantics:
220        // - `--no-skills` disables configured + auto skills, but still loads CLI `-e` and explicit `--skill`
221        // - `--no-prompt-templates` disables configured + auto prompts, but still loads CLI `-e` and explicit `--prompt-template`
222        let mut skill_paths = Vec::new();
223        if !cli.no_skills {
224            skill_paths.extend(enabled_paths(resolved.skills));
225        }
226        skill_paths.extend(enabled_paths(cli_extensions.skills));
227        skill_paths.extend(cli.skill_paths.iter().map(|p| resolve_path(p, cwd)));
228        let skill_paths = dedupe_paths(skill_paths);
229
230        let mut prompt_paths = Vec::new();
231        if !cli.no_prompt_templates {
232            prompt_paths.extend(enabled_paths(resolved.prompts));
233        }
234        prompt_paths.extend(enabled_paths(cli_extensions.prompts));
235        prompt_paths.extend(cli.prompt_paths.iter().map(|p| resolve_path(p, cwd)));
236        let prompt_paths = dedupe_paths(prompt_paths);
237
238        let mut theme_paths = Vec::new();
239        if !cli.no_themes {
240            theme_paths.extend(enabled_paths(resolved.themes));
241        }
242        theme_paths.extend(enabled_paths(cli_extensions.themes));
243        theme_paths.extend(cli.theme_paths.iter().map(|p| resolve_path(p, cwd)));
244        let theme_paths = dedupe_paths(theme_paths);
245
246        // Extension entries:
247        // - `--no-extensions` disables configured + auto discovery but still allows CLI `-e` sources.
248        let mut extension_entries = Vec::new();
249        if !cli.no_extensions {
250            extension_entries.extend(enabled_paths(resolved.extensions));
251        }
252        extension_entries.extend(enabled_paths(cli_extensions.extensions));
253        let extension_entries = dedupe_paths(extension_entries);
254
255        // Load skills, prompt templates, and themes in parallel — they are independent
256        // filesystem walks that benefit from overlapped I/O on multi-core machines.
257        let agent_dir = Config::global_dir();
258        let cwd_buf = cwd.to_path_buf();
259        let (skills_join, prompts_join, themes_join) = std::thread::scope(|s| {
260            let cwd_s = &cwd_buf;
261            let agent_s = &agent_dir;
262            let skills_handle = s.spawn(move || {
263                load_skills(LoadSkillsOptions {
264                    cwd: cwd_s.clone(),
265                    agent_dir: agent_s.clone(),
266                    skill_paths,
267                    include_defaults: false,
268                })
269            });
270            let prompts_handle = s.spawn(move || {
271                load_prompt_templates(LoadPromptTemplatesOptions {
272                    cwd: cwd_s.clone(),
273                    agent_dir: agent_s.clone(),
274                    prompt_paths,
275                    include_defaults: false,
276                })
277            });
278            let themes_handle = s.spawn(move || {
279                load_themes(LoadThemesOptions {
280                    cwd: cwd_s.clone(),
281                    agent_dir: agent_s.clone(),
282                    theme_paths,
283                    include_defaults: false,
284                })
285            });
286            (
287                skills_handle.join(),
288                prompts_handle.join(),
289                themes_handle.join(),
290            )
291        });
292        let skills_result = skills_join.map_err(|payload| {
293            Error::config(format!(
294                "Skills loader thread panicked: {}",
295                panic_payload_message(payload)
296            ))
297        })?;
298        let prompt_templates = prompts_join.map_err(|payload| {
299            Error::config(format!(
300                "Prompt loader thread panicked: {}",
301                panic_payload_message(payload)
302            ))
303        })?;
304        let themes_result = themes_join.map_err(|payload| {
305            Error::config(format!(
306                "Theme loader thread panicked: {}",
307                panic_payload_message(payload)
308            ))
309        })?;
310        let (prompts, prompt_diagnostics) = dedupe_prompts(prompt_templates);
311        let (themes, theme_diagnostics) = dedupe_themes(themes_result.themes);
312        let mut theme_diags = themes_result.diagnostics;
313        theme_diags.extend(theme_diagnostics);
314
315        Ok(Self {
316            skills: skills_result.skills,
317            skill_diagnostics: skills_result.diagnostics,
318            prompts,
319            prompt_diagnostics,
320            themes,
321            theme_diagnostics: theme_diags,
322            extensions: extension_entries,
323            enable_skill_commands,
324        })
325    }
326
327    pub fn extensions(&self) -> &[PathBuf] {
328        &self.extensions
329    }
330
331    pub fn skills(&self) -> &[Skill] {
332        &self.skills
333    }
334
335    pub fn prompts(&self) -> &[PromptTemplate] {
336        &self.prompts
337    }
338
339    pub fn skill_diagnostics(&self) -> &[ResourceDiagnostic] {
340        &self.skill_diagnostics
341    }
342
343    pub fn prompt_diagnostics(&self) -> &[ResourceDiagnostic] {
344        &self.prompt_diagnostics
345    }
346
347    pub fn themes(&self) -> &[ThemeResource] {
348        &self.themes
349    }
350
351    pub fn theme_diagnostics(&self) -> &[ResourceDiagnostic] {
352        &self.theme_diagnostics
353    }
354
355    pub fn resolve_theme(&self, selected: Option<&str>) -> Option<Theme> {
356        let selected = selected?;
357        let trimmed = selected.trim();
358        if trimmed.is_empty() {
359            return None;
360        }
361
362        let path = Path::new(trimmed);
363        if path.exists() {
364            let ext = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
365            let theme = match ext {
366                "json" => Theme::load(path),
367                "ini" | "theme" => load_legacy_ini_theme(path),
368                _ => Err(Error::config(format!(
369                    "Unsupported theme format: {}",
370                    path.display()
371                ))),
372            };
373            if let Ok(theme) = theme {
374                return Some(theme);
375            }
376        }
377
378        self.themes
379            .iter()
380            .find(|theme| theme.name.eq_ignore_ascii_case(trimmed))
381            .map(|theme| theme.theme.clone())
382    }
383
384    pub const fn enable_skill_commands(&self) -> bool {
385        self.enable_skill_commands
386    }
387
388    pub fn format_skills_for_prompt(&self) -> String {
389        format_skills_for_prompt(&self.skills)
390    }
391
392    pub fn list_commands(&self) -> Vec<Value> {
393        let mut commands = Vec::new();
394
395        for template in &self.prompts {
396            commands.push(json!({
397                "name": template.name,
398                "description": template.description,
399                "source": "template",
400                "location": template.source,
401                "path": template.file_path.display().to_string(),
402            }));
403        }
404
405        for skill in &self.skills {
406            commands.push(json!({
407                "name": format!("skill:{}", skill.name),
408                "description": skill.description,
409                "source": "skill",
410                "location": skill.source,
411                "path": skill.file_path.display().to_string(),
412            }));
413        }
414
415        commands
416    }
417
418    pub fn expand_input(&self, text: &str) -> String {
419        let mut expanded = text.to_string();
420        if self.enable_skill_commands {
421            expanded = expand_skill_command(&expanded, &self.skills);
422        }
423        expand_prompt_template(&expanded, &self.prompts)
424    }
425}
426
427// ============================================================================
428// Package resources
429// ============================================================================
430
431pub async fn discover_package_resources(manager: &PackageManager) -> Result<PackageResources> {
432    let entries = manager.list_packages().await.unwrap_or_default();
433    let mut resources = PackageResources::default();
434
435    for entry in entries {
436        let Some(root) = manager.installed_path(&entry.source, entry.scope).await? else {
437            continue;
438        };
439        if !root.exists() {
440            if let Err(err) = manager.install(&entry.source, entry.scope).await {
441                eprintln!("Warning: Failed to install {}: {err}", entry.source);
442                continue;
443            }
444        }
445
446        if !root.exists() {
447            continue;
448        }
449
450        if let Some(pi) = read_pi_manifest(&root) {
451            append_resources_from_manifest(&mut resources, &root, &pi);
452        } else {
453            append_resources_from_defaults(&mut resources, &root);
454        }
455    }
456
457    Ok(resources)
458}
459
460fn read_pi_manifest(root: &Path) -> Option<Value> {
461    let manifest_path = root.join("package.json");
462    if !manifest_path.exists() {
463        return None;
464    }
465    let raw = fs::read_to_string(&manifest_path).ok()?;
466    let json: Value = serde_json::from_str(&raw).ok()?;
467    json.get("pi").cloned()
468}
469
470fn append_resources_from_manifest(resources: &mut PackageResources, root: &Path, pi: &Value) {
471    let Some(obj) = pi.as_object() else {
472        return;
473    };
474    append_resource_paths(
475        resources,
476        root,
477        obj.get("extensions"),
478        ResourceKind::Extensions,
479    );
480    append_resource_paths(resources, root, obj.get("skills"), ResourceKind::Skills);
481    append_resource_paths(resources, root, obj.get("prompts"), ResourceKind::Prompts);
482    append_resource_paths(resources, root, obj.get("themes"), ResourceKind::Themes);
483}
484
485fn append_resources_from_defaults(resources: &mut PackageResources, root: &Path) {
486    let candidates = [
487        ("extensions", ResourceKind::Extensions),
488        ("skills", ResourceKind::Skills),
489        ("prompts", ResourceKind::Prompts),
490        ("themes", ResourceKind::Themes),
491    ];
492
493    for (dir, kind) in candidates {
494        let path = root.join(dir);
495        if path.exists() {
496            match kind {
497                ResourceKind::Extensions => resources.extensions.push(path),
498                ResourceKind::Skills => resources.skills.push(path),
499                ResourceKind::Prompts => resources.prompts.push(path),
500                ResourceKind::Themes => resources.themes.push(path),
501            }
502        }
503    }
504}
505
506#[derive(Clone, Copy)]
507enum ResourceKind {
508    Extensions,
509    Skills,
510    Prompts,
511    Themes,
512}
513
514fn append_resource_paths(
515    resources: &mut PackageResources,
516    root: &Path,
517    value: Option<&Value>,
518    kind: ResourceKind,
519) {
520    let Some(value) = value else {
521        return;
522    };
523    let paths = extract_string_list(value);
524    if paths.is_empty() {
525        return;
526    }
527
528    for path in paths {
529        let resolved = if Path::new(&path).is_absolute() {
530            PathBuf::from(path)
531        } else {
532            root.join(path)
533        };
534        match kind {
535            ResourceKind::Extensions => resources.extensions.push(resolved),
536            ResourceKind::Skills => resources.skills.push(resolved),
537            ResourceKind::Prompts => resources.prompts.push(resolved),
538            ResourceKind::Themes => resources.themes.push(resolved),
539        }
540    }
541}
542
543fn extract_string_list(value: &Value) -> Vec<String> {
544    match value {
545        Value::String(s) => vec![s.clone()],
546        Value::Array(items) => items
547            .iter()
548            .filter_map(Value::as_str)
549            .map(str::to_string)
550            .collect(),
551        _ => Vec::new(),
552    }
553}
554
555// ============================================================================
556// Skills loader
557// ============================================================================
558
559#[allow(clippy::too_many_lines, clippy::items_after_statements)]
560pub fn load_skills(options: LoadSkillsOptions) -> LoadSkillsResult {
561    let mut skill_map: HashMap<String, Skill> = HashMap::new();
562    let mut real_paths: HashSet<PathBuf> = HashSet::new();
563    let mut diagnostics = Vec::new();
564    let mut collisions = Vec::new();
565
566    // Helper to merge skills into the map, tracking collisions
567    fn merge_skills(
568        result: LoadSkillsResult,
569        skill_map: &mut HashMap<String, Skill>,
570        real_paths: &mut HashSet<PathBuf>,
571        diagnostics: &mut Vec<ResourceDiagnostic>,
572        collisions: &mut Vec<ResourceDiagnostic>,
573    ) {
574        diagnostics.extend(result.diagnostics);
575        for skill in result.skills {
576            let real_path =
577                fs::canonicalize(&skill.file_path).unwrap_or_else(|_| skill.file_path.clone());
578            if real_paths.contains(&real_path) {
579                continue;
580            }
581
582            if let Some(existing) = skill_map.get(&skill.name) {
583                collisions.push(ResourceDiagnostic {
584                    kind: DiagnosticKind::Collision,
585                    message: format!("name \"{}\" collision", skill.name),
586                    path: skill.file_path.clone(),
587                    collision: Some(CollisionInfo {
588                        resource_type: "skill".to_string(),
589                        name: skill.name.clone(),
590                        winner_path: existing.file_path.clone(),
591                        loser_path: skill.file_path.clone(),
592                    }),
593                });
594            } else {
595                real_paths.insert(real_path);
596                skill_map.insert(skill.name.clone(), skill);
597            }
598        }
599    }
600
601    if options.include_defaults {
602        merge_skills(
603            load_skills_from_dir(options.agent_dir.join("skills"), "user".to_string(), true),
604            &mut skill_map,
605            &mut real_paths,
606            &mut diagnostics,
607            &mut collisions,
608        );
609        merge_skills(
610            load_skills_from_dir(
611                options.cwd.join(Config::project_dir()).join("skills"),
612                "project".to_string(),
613                true,
614            ),
615            &mut skill_map,
616            &mut real_paths,
617            &mut diagnostics,
618            &mut collisions,
619        );
620    }
621
622    for path in options.skill_paths {
623        let resolved = path.clone();
624        if !resolved.exists() {
625            diagnostics.push(ResourceDiagnostic {
626                kind: DiagnosticKind::Warning,
627                message: "skill path does not exist".to_string(),
628                path: resolved,
629                collision: None,
630            });
631            continue;
632        }
633
634        let source = if options.include_defaults {
635            "path".to_string()
636        } else if is_under_path(&resolved, &options.agent_dir.join("skills")) {
637            "user".to_string()
638        } else if is_under_path(
639            &resolved,
640            &options.cwd.join(Config::project_dir()).join("skills"),
641        ) {
642            "project".to_string()
643        } else {
644            "path".to_string()
645        };
646
647        match fs::metadata(&resolved) {
648            Ok(meta) if meta.is_dir() => {
649                merge_skills(
650                    load_skills_from_dir(resolved, source, true),
651                    &mut skill_map,
652                    &mut real_paths,
653                    &mut diagnostics,
654                    &mut collisions,
655                );
656            }
657            Ok(meta) if meta.is_file() && resolved.extension().is_some_and(|ext| ext == "md") => {
658                let result = load_skill_from_file(&resolved, source);
659                if let Some(skill) = result.skill {
660                    merge_skills(
661                        LoadSkillsResult {
662                            skills: vec![skill],
663                            diagnostics: result.diagnostics,
664                        },
665                        &mut skill_map,
666                        &mut real_paths,
667                        &mut diagnostics,
668                        &mut collisions,
669                    );
670                } else {
671                    diagnostics.extend(result.diagnostics);
672                }
673            }
674            Ok(_) => {
675                diagnostics.push(ResourceDiagnostic {
676                    kind: DiagnosticKind::Warning,
677                    message: "skill path is not a markdown file".to_string(),
678                    path: resolved,
679                    collision: None,
680                });
681            }
682            Err(err) => diagnostics.push(ResourceDiagnostic {
683                kind: DiagnosticKind::Warning,
684                message: format!("failed to read skill path: {err}"),
685                path: resolved,
686                collision: None,
687            }),
688        }
689    }
690
691    diagnostics.extend(collisions);
692
693    let mut skills: Vec<Skill> = skill_map.into_values().collect();
694    skills.sort_by(|a, b| a.name.cmp(&b.name));
695
696    LoadSkillsResult {
697        skills,
698        diagnostics,
699    }
700}
701
702fn load_skills_from_dir(
703    dir: PathBuf,
704    source: String,
705    include_root_files: bool,
706) -> LoadSkillsResult {
707    let mut skills = Vec::new();
708    let mut diagnostics = Vec::new();
709    let mut visited_dirs = HashSet::new();
710    let mut stack = vec![(dir, source, include_root_files)];
711
712    while let Some((current_dir, current_source, current_include_root)) = stack.pop() {
713        if !current_dir.exists() {
714            continue;
715        }
716
717        // Prevent unbounded recursion for symlink cycles.
718        let canonical_dir = fs::canonicalize(&current_dir).unwrap_or_else(|_| current_dir.clone());
719        if !visited_dirs.insert(canonical_dir) {
720            continue;
721        }
722
723        let Ok(entries) = fs::read_dir(&current_dir) else {
724            continue;
725        };
726
727        for entry in entries.flatten() {
728            let file_name = entry.file_name();
729            let file_name = file_name.to_string_lossy();
730
731            if file_name.starts_with('.') || file_name == "node_modules" {
732                continue;
733            }
734
735            let full_path = entry.path();
736            let file_type = entry.file_type();
737
738            let (is_dir, is_file) = match file_type {
739                Ok(ft) if ft.is_symlink() => match fs::metadata(&full_path) {
740                    Ok(meta) => (meta.is_dir(), meta.is_file()),
741                    Err(_) => continue,
742                },
743                Ok(ft) => (ft.is_dir(), ft.is_file()),
744                Err(_) => continue,
745            };
746
747            if is_dir {
748                stack.push((full_path, current_source.clone(), false));
749                continue;
750            }
751
752            if !is_file {
753                continue;
754            }
755
756            let is_root_md = current_include_root && file_name.ends_with(".md");
757            let is_skill_md = !current_include_root && file_name == "SKILL.md";
758            if !is_root_md && !is_skill_md {
759                continue;
760            }
761
762            let result = load_skill_from_file(&full_path, current_source.clone());
763            if let Some(skill) = result.skill {
764                skills.push(skill);
765            }
766            diagnostics.extend(result.diagnostics);
767        }
768    }
769
770    LoadSkillsResult {
771        skills,
772        diagnostics,
773    }
774}
775
776struct LoadSkillFileResult {
777    skill: Option<Skill>,
778    diagnostics: Vec<ResourceDiagnostic>,
779}
780
781fn load_skill_from_file(path: &Path, source: String) -> LoadSkillFileResult {
782    let mut diagnostics = Vec::new();
783
784    let Ok(raw) = fs::read_to_string(path) else {
785        diagnostics.push(ResourceDiagnostic {
786            kind: DiagnosticKind::Warning,
787            message: "failed to parse skill file".to_string(),
788            path: path.to_path_buf(),
789            collision: None,
790        });
791        return LoadSkillFileResult {
792            skill: None,
793            diagnostics,
794        };
795    };
796
797    let parsed = parse_frontmatter(&raw);
798    let frontmatter = &parsed.frontmatter;
799
800    let field_errors = validate_frontmatter_fields(frontmatter.keys());
801    for error in field_errors {
802        diagnostics.push(ResourceDiagnostic {
803            kind: DiagnosticKind::Warning,
804            message: error,
805            path: path.to_path_buf(),
806            collision: None,
807        });
808    }
809
810    let description = frontmatter.get("description").cloned().unwrap_or_default();
811    let desc_errors = validate_description(&description);
812    for error in desc_errors {
813        diagnostics.push(ResourceDiagnostic {
814            kind: DiagnosticKind::Warning,
815            message: error,
816            path: path.to_path_buf(),
817            collision: None,
818        });
819    }
820
821    if description.trim().is_empty() {
822        return LoadSkillFileResult {
823            skill: None,
824            diagnostics,
825        };
826    }
827
828    let base_dir = path
829        .parent()
830        .unwrap_or_else(|| Path::new("."))
831        .to_path_buf();
832    let parent_dir = base_dir
833        .file_name()
834        .and_then(|s| s.to_str())
835        .unwrap_or("")
836        .to_string();
837    let name = frontmatter
838        .get("name")
839        .cloned()
840        .unwrap_or_else(|| parent_dir.clone());
841
842    let name_errors = validate_name(&name, &parent_dir);
843    for error in name_errors {
844        diagnostics.push(ResourceDiagnostic {
845            kind: DiagnosticKind::Warning,
846            message: error,
847            path: path.to_path_buf(),
848            collision: None,
849        });
850    }
851
852    let disable_model_invocation = frontmatter
853        .get("disable-model-invocation")
854        .is_some_and(|v| v.eq_ignore_ascii_case("true"));
855
856    LoadSkillFileResult {
857        skill: Some(Skill {
858            name,
859            description,
860            file_path: path.to_path_buf(),
861            base_dir,
862            source,
863            disable_model_invocation,
864        }),
865        diagnostics,
866    }
867}
868
869fn validate_name(name: &str, parent_dir: &str) -> Vec<String> {
870    let mut errors = Vec::new();
871
872    if name != parent_dir {
873        errors.push(format!(
874            "name \"{name}\" does not match parent directory \"{parent_dir}\""
875        ));
876    }
877
878    if name.len() > MAX_SKILL_NAME_LEN {
879        errors.push(format!(
880            "name exceeds {MAX_SKILL_NAME_LEN} characters ({})",
881            name.len()
882        ));
883    }
884
885    if !name
886        .chars()
887        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
888    {
889        errors.push(
890            "name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)"
891                .to_string(),
892        );
893    }
894
895    if name.starts_with('-') || name.ends_with('-') {
896        errors.push("name must not start or end with a hyphen".to_string());
897    }
898
899    if name.contains("--") {
900        errors.push("name must not contain consecutive hyphens".to_string());
901    }
902
903    errors
904}
905
906fn validate_description(description: &str) -> Vec<String> {
907    let mut errors = Vec::new();
908    if description.trim().is_empty() {
909        errors.push("description is required".to_string());
910    } else if description.len() > MAX_SKILL_DESC_LEN {
911        errors.push(format!(
912            "description exceeds {MAX_SKILL_DESC_LEN} characters ({})",
913            description.len()
914        ));
915    }
916    errors
917}
918
919fn validate_frontmatter_fields<'a, I>(keys: I) -> Vec<String>
920where
921    I: IntoIterator<Item = &'a String>,
922{
923    let allowed: HashSet<&str> = ALLOWED_SKILL_FRONTMATTER.into_iter().collect();
924    let mut errors = Vec::new();
925    for key in keys {
926        if !allowed.contains(key.as_str()) {
927            errors.push(format!("unknown frontmatter field \"{key}\""));
928        }
929    }
930    errors
931}
932
933pub fn format_skills_for_prompt(skills: &[Skill]) -> String {
934    let visible: Vec<&Skill> = skills
935        .iter()
936        .filter(|s| !s.disable_model_invocation)
937        .collect();
938    if visible.is_empty() {
939        return String::new();
940    }
941
942    let mut lines = vec![
943        "\n\nThe following skills provide specialized instructions for specific tasks.".to_string(),
944        "Use the read tool to load a skill's file when the task matches its description."
945            .to_string(),
946        "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.".to_string(),
947        String::new(),
948        "<available_skills>".to_string(),
949    ];
950
951    for skill in visible {
952        lines.push("  <skill>".to_string());
953        lines.push(format!("    <name>{}</name>", escape_xml(&skill.name)));
954        lines.push(format!(
955            "    <description>{}</description>",
956            escape_xml(&skill.description)
957        ));
958        lines.push(format!(
959            "    <location>{}</location>",
960            escape_xml(&skill.file_path.display().to_string())
961        ));
962        lines.push("  </skill>".to_string());
963    }
964
965    lines.push("</available_skills>".to_string());
966    lines.join("\n")
967}
968
969fn escape_xml(input: &str) -> String {
970    input
971        .replace('&', "&amp;")
972        .replace('<', "&lt;")
973        .replace('>', "&gt;")
974        .replace('"', "&quot;")
975        .replace('\'', "&apos;")
976}
977
978// ============================================================================
979// Prompt templates loader and expansion
980// ============================================================================
981
982pub fn load_prompt_templates(options: LoadPromptTemplatesOptions) -> Vec<PromptTemplate> {
983    let mut templates = Vec::new();
984    let user_dir = options.agent_dir.join("prompts");
985    let project_dir = options.cwd.join(Config::project_dir()).join("prompts");
986
987    if options.include_defaults {
988        templates.extend(load_templates_from_dir(&user_dir, "user", "(user)"));
989        templates.extend(load_templates_from_dir(
990            &project_dir,
991            "project",
992            "(project)",
993        ));
994    }
995
996    for path in options.prompt_paths {
997        if !path.exists() {
998            continue;
999        }
1000
1001        let source_info = if options.include_defaults {
1002            ("path", build_path_source_label(&path))
1003        } else if is_under_path(&path, &user_dir) {
1004            ("user", "(user)".to_string())
1005        } else if is_under_path(&path, &project_dir) {
1006            ("project", "(project)".to_string())
1007        } else {
1008            ("path", build_path_source_label(&path))
1009        };
1010
1011        let (source, label) = source_info;
1012
1013        match fs::metadata(&path) {
1014            Ok(meta) if meta.is_dir() => {
1015                templates.extend(load_templates_from_dir(&path, source, &label));
1016            }
1017            Ok(meta) if meta.is_file() && path.extension().is_some_and(|ext| ext == "md") => {
1018                if let Some(template) = load_template_from_file(&path, source, &label) {
1019                    templates.push(template);
1020                }
1021            }
1022            _ => {}
1023        }
1024    }
1025
1026    templates
1027}
1028
1029fn load_templates_from_dir(dir: &Path, source: &str, label: &str) -> Vec<PromptTemplate> {
1030    let mut templates = Vec::new();
1031    if !dir.exists() {
1032        return templates;
1033    }
1034    let Ok(entries) = fs::read_dir(dir) else {
1035        return templates;
1036    };
1037
1038    for entry in entries.flatten() {
1039        let full_path = entry.path();
1040        let file_type = entry.file_type();
1041        let is_file = match file_type {
1042            Ok(ft) if ft.is_symlink() => fs::metadata(&full_path).is_ok_and(|m| m.is_file()),
1043            Ok(ft) => ft.is_file(),
1044            Err(_) => false,
1045        };
1046
1047        if is_file && full_path.extension().is_some_and(|ext| ext == "md") {
1048            if let Some(template) = load_template_from_file(&full_path, source, label) {
1049                templates.push(template);
1050            }
1051        }
1052    }
1053
1054    templates
1055}
1056
1057fn load_template_from_file(path: &Path, source: &str, label: &str) -> Option<PromptTemplate> {
1058    let raw = fs::read_to_string(path).ok()?;
1059    let parsed = parse_frontmatter(&raw);
1060    let mut description = parsed
1061        .frontmatter
1062        .get("description")
1063        .cloned()
1064        .unwrap_or_default();
1065
1066    if description.is_empty() {
1067        if let Some(first_line) = parsed.body.lines().find(|line| !line.trim().is_empty()) {
1068            let trimmed = first_line.trim();
1069            let truncated = if trimmed.chars().count() > 60 {
1070                let s: String = trimmed.chars().take(57).collect();
1071                format!("{s}...")
1072            } else {
1073                trimmed.to_string()
1074            };
1075            description = truncated;
1076        }
1077    }
1078
1079    if description.is_empty() {
1080        description = label.to_string();
1081    } else {
1082        description = format!("{description} {label}");
1083    }
1084
1085    let name = path
1086        .file_stem()
1087        .and_then(|s| s.to_str())
1088        .unwrap_or("template")
1089        .to_string();
1090
1091    Some(PromptTemplate {
1092        name,
1093        description,
1094        content: parsed.body,
1095        source: source.to_string(),
1096        file_path: path.to_path_buf(),
1097    })
1098}
1099
1100// ============================================================================
1101// Themes loader
1102// ============================================================================
1103
1104pub fn load_themes(options: LoadThemesOptions) -> LoadThemesResult {
1105    let mut themes = Vec::new();
1106    let mut diagnostics = Vec::new();
1107
1108    let user_dir = options.agent_dir.join("themes");
1109    let project_dir = options.cwd.join(Config::project_dir()).join("themes");
1110
1111    if options.include_defaults {
1112        themes.extend(load_themes_from_dir(
1113            &user_dir,
1114            "user",
1115            "(user)",
1116            &mut diagnostics,
1117        ));
1118        themes.extend(load_themes_from_dir(
1119            &project_dir,
1120            "project",
1121            "(project)",
1122            &mut diagnostics,
1123        ));
1124    }
1125
1126    for path in options.theme_paths {
1127        if !path.exists() {
1128            continue;
1129        }
1130
1131        let source_info = if options.include_defaults {
1132            ("path", build_path_source_label(&path))
1133        } else if is_under_path(&path, &user_dir) {
1134            ("user", "(user)".to_string())
1135        } else if is_under_path(&path, &project_dir) {
1136            ("project", "(project)".to_string())
1137        } else {
1138            ("path", build_path_source_label(&path))
1139        };
1140
1141        let (source, label) = source_info;
1142
1143        match fs::metadata(&path) {
1144            Ok(meta) if meta.is_dir() => {
1145                themes.extend(load_themes_from_dir(
1146                    &path,
1147                    source,
1148                    &label,
1149                    &mut diagnostics,
1150                ));
1151            }
1152            Ok(meta) if meta.is_file() && is_theme_file(&path) => {
1153                if let Some(theme) = load_theme_from_file(&path, source, &label, &mut diagnostics) {
1154                    themes.push(theme);
1155                }
1156            }
1157            _ => {}
1158        }
1159    }
1160
1161    LoadThemesResult {
1162        themes,
1163        diagnostics,
1164    }
1165}
1166
1167fn load_themes_from_dir(
1168    dir: &Path,
1169    source: &str,
1170    label: &str,
1171    diagnostics: &mut Vec<ResourceDiagnostic>,
1172) -> Vec<ThemeResource> {
1173    let mut themes = Vec::new();
1174    if !dir.exists() {
1175        return themes;
1176    }
1177    let Ok(entries) = fs::read_dir(dir) else {
1178        return themes;
1179    };
1180
1181    for entry in entries.flatten() {
1182        let full_path = entry.path();
1183        let file_type = entry.file_type();
1184        let is_file = match file_type {
1185            Ok(ft) if ft.is_symlink() => fs::metadata(&full_path).is_ok_and(|m| m.is_file()),
1186            Ok(ft) => ft.is_file(),
1187            Err(_) => false,
1188        };
1189
1190        if is_file && is_theme_file(&full_path) {
1191            if let Some(theme) = load_theme_from_file(&full_path, source, label, diagnostics) {
1192                themes.push(theme);
1193            }
1194        }
1195    }
1196
1197    themes
1198}
1199
1200fn is_theme_file(path: &Path) -> bool {
1201    matches!(
1202        path.extension().and_then(|ext| ext.to_str()),
1203        Some("json" | "ini" | "theme")
1204    )
1205}
1206
1207fn load_theme_from_file(
1208    path: &Path,
1209    source: &str,
1210    label: &str,
1211    diagnostics: &mut Vec<ResourceDiagnostic>,
1212) -> Option<ThemeResource> {
1213    let name = path
1214        .file_stem()
1215        .and_then(|s| s.to_str())
1216        .unwrap_or("theme")
1217        .to_string();
1218
1219    let ext = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
1220    let theme = match ext {
1221        "json" => Theme::load(path),
1222        "ini" | "theme" => load_legacy_ini_theme(path),
1223        _ => return None,
1224    };
1225
1226    match theme {
1227        Ok(theme) => Some(ThemeResource {
1228            name,
1229            theme,
1230            source: format!("{source}:{label}"),
1231            file_path: path.to_path_buf(),
1232        }),
1233        Err(err) => {
1234            diagnostics.push(ResourceDiagnostic {
1235                kind: DiagnosticKind::Warning,
1236                message: format!(
1237                    "Failed to load theme \"{name}\" ({}): {err}",
1238                    path.display()
1239                ),
1240                path: path.to_path_buf(),
1241                collision: None,
1242            });
1243            None
1244        }
1245    }
1246}
1247
1248fn load_legacy_ini_theme(path: &Path) -> Result<Theme> {
1249    let content = fs::read_to_string(path)?;
1250    let mut theme = Theme::dark();
1251    if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
1252        theme.name = name.to_string();
1253    }
1254
1255    let mut first_color = None;
1256    for token in content.split_whitespace() {
1257        let Some(raw) = token.strip_prefix('#') else {
1258            continue;
1259        };
1260        let trimmed = raw.trim_end_matches(|c: char| !c.is_ascii_hexdigit());
1261        if trimmed.len() != 6 || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
1262            return Err(Error::config(format!(
1263                "Invalid color '{token}' in theme file {}",
1264                path.display()
1265            )));
1266        }
1267        if first_color.is_none() {
1268            first_color = Some(format!("#{trimmed}"));
1269        }
1270    }
1271
1272    if let Some(accent) = first_color {
1273        theme.colors.accent = accent;
1274    }
1275
1276    Ok(theme)
1277}
1278
1279fn build_path_source_label(path: &Path) -> String {
1280    let base = path.file_stem().and_then(|s| s.to_str()).unwrap_or("path");
1281    format!("(path:{base})")
1282}
1283
1284pub fn dedupe_prompts(
1285    prompts: Vec<PromptTemplate>,
1286) -> (Vec<PromptTemplate>, Vec<ResourceDiagnostic>) {
1287    let mut seen: HashMap<String, PromptTemplate> = HashMap::new();
1288    let mut diagnostics = Vec::new();
1289
1290    for prompt in prompts {
1291        if let Some(existing) = seen.get(&prompt.name) {
1292            diagnostics.push(ResourceDiagnostic {
1293                kind: DiagnosticKind::Collision,
1294                message: format!("name \"/{}\" collision", prompt.name),
1295                path: prompt.file_path.clone(),
1296                collision: Some(CollisionInfo {
1297                    resource_type: "prompt".to_string(),
1298                    name: prompt.name.clone(),
1299                    winner_path: existing.file_path.clone(),
1300                    loser_path: prompt.file_path.clone(),
1301                }),
1302            });
1303            continue;
1304        }
1305        seen.insert(prompt.name.clone(), prompt);
1306    }
1307
1308    let mut prompts: Vec<PromptTemplate> = seen.into_values().collect();
1309    prompts.sort_by(|a, b| a.name.cmp(&b.name));
1310    (prompts, diagnostics)
1311}
1312
1313pub fn dedupe_themes(themes: Vec<ThemeResource>) -> (Vec<ThemeResource>, Vec<ResourceDiagnostic>) {
1314    let mut seen: HashMap<String, ThemeResource> = HashMap::new();
1315    let mut diagnostics = Vec::new();
1316
1317    for theme in themes {
1318        let key = theme.name.to_ascii_lowercase();
1319        if let Some(existing) = seen.get(&key) {
1320            diagnostics.push(ResourceDiagnostic {
1321                kind: DiagnosticKind::Collision,
1322                message: format!("theme \"{}\" collision", theme.name),
1323                path: theme.file_path.clone(),
1324                collision: Some(CollisionInfo {
1325                    resource_type: "theme".to_string(),
1326                    name: theme.name.clone(),
1327                    winner_path: existing.file_path.clone(),
1328                    loser_path: theme.file_path.clone(),
1329                }),
1330            });
1331            continue;
1332        }
1333        seen.insert(key, theme);
1334    }
1335
1336    let mut themes: Vec<ThemeResource> = seen.into_values().collect();
1337    themes.sort_by(|a, b| {
1338        a.name
1339            .to_ascii_lowercase()
1340            .cmp(&b.name.to_ascii_lowercase())
1341    });
1342    (themes, diagnostics)
1343}
1344
1345pub fn parse_command_args(args: &str) -> Vec<String> {
1346    let mut out = Vec::new();
1347    let mut current = String::new();
1348    let mut in_quote: Option<char> = None;
1349
1350    for ch in args.chars() {
1351        if let Some(quote) = in_quote {
1352            if ch == quote {
1353                in_quote = None;
1354            } else {
1355                current.push(ch);
1356            }
1357            continue;
1358        }
1359
1360        if ch == '"' || ch == '\'' {
1361            in_quote = Some(ch);
1362        } else if ch == ' ' || ch == '\t' {
1363            if !current.is_empty() {
1364                out.push(current.clone());
1365                current.clear();
1366            }
1367        } else {
1368            current.push(ch);
1369        }
1370    }
1371
1372    if !current.is_empty() {
1373        out.push(current);
1374    }
1375
1376    out
1377}
1378
1379/// Cached regex for positional `$1`, `$2`, … substitution.
1380fn positional_arg_regex() -> &'static regex::Regex {
1381    static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
1382    RE.get_or_init(|| regex::Regex::new(r"\$(\d+)").expect("positional arg regex"))
1383}
1384
1385/// Cached regex for `${@:start}` or `${@:start:length}` substitution.
1386fn slice_arg_regex() -> &'static regex::Regex {
1387    static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
1388    RE.get_or_init(|| regex::Regex::new(r"\$\{@:(\d+)(?::(\d+))?\}").expect("slice arg regex"))
1389}
1390
1391#[allow(clippy::option_if_let_else)] // Clearer with if-let than map_or_else in the closure
1392pub fn substitute_args(content: &str, args: &[String]) -> String {
1393    let mut result = content.to_string();
1394
1395    // Positional $1, $2, ...
1396    result = replace_regex(&result, positional_arg_regex(), |caps| {
1397        let idx = caps[1].parse::<usize>().unwrap_or(0);
1398        if idx == 0 {
1399            String::new()
1400        } else {
1401            args.get(idx.saturating_sub(1)).cloned().unwrap_or_default()
1402        }
1403    });
1404
1405    // ${@:start} or ${@:start:length}
1406    result = replace_regex(&result, slice_arg_regex(), |caps| {
1407        let mut start = caps[1].parse::<usize>().unwrap_or(1);
1408        if start == 0 {
1409            start = 1;
1410        }
1411        let start_idx = start.saturating_sub(1);
1412        let maybe_len = caps.get(2).and_then(|m| m.as_str().parse::<usize>().ok());
1413        let slice = maybe_len.map_or_else(
1414            || args.get(start_idx..).unwrap_or(&[]).to_vec(),
1415            |len| {
1416                let end = start_idx.saturating_add(len).min(args.len());
1417                args.get(start_idx..end).unwrap_or(&[]).to_vec()
1418            },
1419        );
1420        slice.join(" ")
1421    });
1422
1423    let all_args = args.join(" ");
1424    result = result.replace("$ARGUMENTS", &all_args);
1425    result = result.replace("$@", &all_args);
1426    result
1427}
1428
1429pub fn expand_prompt_template(text: &str, templates: &[PromptTemplate]) -> String {
1430    if !text.starts_with('/') {
1431        return text.to_string();
1432    }
1433    let space_index = text.find(' ');
1434    let name = space_index.map_or(&text[1..], |idx| &text[1..idx]);
1435    let args = space_index.map_or("", |idx| &text[idx + 1..]);
1436
1437    if let Some(template) = templates.iter().find(|t| t.name == name) {
1438        let args = parse_command_args(args);
1439        return substitute_args(&template.content, &args);
1440    }
1441
1442    text.to_string()
1443}
1444
1445fn expand_skill_command(text: &str, skills: &[Skill]) -> String {
1446    if !text.starts_with("/skill:") {
1447        return text.to_string();
1448    }
1449
1450    let space_index = text.find(' ');
1451    let name = space_index.map_or(&text[7..], |idx| &text[7..idx]);
1452    let args = space_index.map_or("", |idx| text[idx + 1..].trim());
1453
1454    let Some(skill) = skills.iter().find(|s| s.name == name) else {
1455        return text.to_string();
1456    };
1457
1458    match fs::read_to_string(&skill.file_path) {
1459        Ok(content) => {
1460            let body = strip_frontmatter(&content).trim().to_string();
1461            let block = format!(
1462                "<skill name=\"{}\" location=\"{}\">\nReferences are relative to {}.\n\n{}\n</skill>",
1463                skill.name,
1464                skill.file_path.display(),
1465                skill.base_dir.display(),
1466                body
1467            );
1468            if args.is_empty() {
1469                block
1470            } else {
1471                format!("{block}\n\n{args}")
1472            }
1473        }
1474        Err(err) => {
1475            eprintln!(
1476                "Warning: Failed to read skill {}: {err}",
1477                skill.file_path.display()
1478            );
1479            text.to_string()
1480        }
1481    }
1482}
1483
1484// ============================================================================
1485// Frontmatter parsing helpers
1486// ============================================================================
1487
1488struct ParsedFrontmatter {
1489    frontmatter: HashMap<String, String>,
1490    body: String,
1491}
1492
1493fn parse_frontmatter(raw: &str) -> ParsedFrontmatter {
1494    let mut lines = raw.lines();
1495    let Some(first) = lines.next() else {
1496        return ParsedFrontmatter {
1497            frontmatter: HashMap::new(),
1498            body: String::new(),
1499        };
1500    };
1501
1502    if first.trim() != "---" {
1503        return ParsedFrontmatter {
1504            frontmatter: HashMap::new(),
1505            body: raw.to_string(),
1506        };
1507    }
1508
1509    let mut front_lines = Vec::new();
1510    let mut body_lines = Vec::new();
1511    let mut in_frontmatter = true;
1512    for line in lines {
1513        if in_frontmatter {
1514            if line.trim() == "---" {
1515                in_frontmatter = false;
1516                continue;
1517            }
1518            front_lines.push(line);
1519        } else {
1520            body_lines.push(line);
1521        }
1522    }
1523
1524    if in_frontmatter {
1525        return ParsedFrontmatter {
1526            frontmatter: HashMap::new(),
1527            body: raw.to_string(),
1528        };
1529    }
1530
1531    ParsedFrontmatter {
1532        frontmatter: parse_frontmatter_lines(&front_lines),
1533        body: body_lines.join("\n"),
1534    }
1535}
1536
1537fn parse_frontmatter_lines(lines: &[&str]) -> HashMap<String, String> {
1538    let mut map = HashMap::new();
1539    for line in lines {
1540        let trimmed = line.trim();
1541        if trimmed.is_empty() || trimmed.starts_with('#') {
1542            continue;
1543        }
1544        let Some((key, value)) = trimmed.split_once(':') else {
1545            continue;
1546        };
1547        let key = key.trim();
1548        if key.is_empty() {
1549            continue;
1550        }
1551        let value = value.trim().trim_matches('"').trim_matches('\'');
1552        map.insert(key.to_string(), value.to_string());
1553    }
1554    map
1555}
1556
1557fn strip_frontmatter(raw: &str) -> String {
1558    parse_frontmatter(raw).body
1559}
1560
1561// ============================================================================
1562// Misc helpers
1563// ============================================================================
1564
1565fn resolve_path(input: &str, cwd: &Path) -> PathBuf {
1566    let trimmed = input.trim();
1567    if trimmed == "~" {
1568        return dirs::home_dir().unwrap_or_else(|| cwd.to_path_buf());
1569    }
1570    if let Some(rest) = trimmed.strip_prefix("~/") {
1571        return dirs::home_dir()
1572            .unwrap_or_else(|| cwd.to_path_buf())
1573            .join(rest);
1574    }
1575    if trimmed.starts_with('~') {
1576        return dirs::home_dir()
1577            .unwrap_or_else(|| cwd.to_path_buf())
1578            .join(trimmed.trim_start_matches('~'));
1579    }
1580    let path = PathBuf::from(trimmed);
1581    if path.is_absolute() {
1582        path
1583    } else {
1584        cwd.join(path)
1585    }
1586}
1587
1588fn is_under_path(target: &Path, root: &Path) -> bool {
1589    let Ok(root) = root.canonicalize() else {
1590        return false;
1591    };
1592    let Ok(target) = target.canonicalize() else {
1593        return false;
1594    };
1595    if target == root {
1596        return true;
1597    }
1598    target.starts_with(root)
1599}
1600
1601fn dedupe_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
1602    let mut seen = HashSet::new();
1603    let mut out = Vec::new();
1604    for path in paths {
1605        let key = path.to_string_lossy().to_string();
1606        if seen.insert(key) {
1607            out.push(path);
1608        }
1609    }
1610    out
1611}
1612
1613fn replace_regex<F>(input: &str, regex: &regex::Regex, mut replacer: F) -> String
1614where
1615    F: FnMut(&regex::Captures<'_>) -> String,
1616{
1617    regex
1618        .replace_all(input, |caps: &regex::Captures<'_>| replacer(caps))
1619        .to_string()
1620}
1621
1622// ============================================================================
1623// Tests
1624// ============================================================================
1625
1626#[cfg(test)]
1627mod tests {
1628    use super::*;
1629    use asupersync::runtime::RuntimeBuilder;
1630    use std::fs;
1631    use std::future::Future;
1632
1633    fn run_async<T>(future: impl Future<Output = T>) -> T {
1634        let runtime = RuntimeBuilder::current_thread()
1635            .build()
1636            .expect("build runtime");
1637        runtime.block_on(future)
1638    }
1639
1640    #[test]
1641    fn test_parse_command_args() {
1642        assert_eq!(parse_command_args("foo bar"), vec!["foo", "bar"]);
1643        assert_eq!(
1644            parse_command_args("foo \"bar baz\" qux"),
1645            vec!["foo", "bar baz", "qux"]
1646        );
1647        assert_eq!(parse_command_args("foo 'bar baz'"), vec!["foo", "bar baz"]);
1648    }
1649
1650    #[test]
1651    fn test_substitute_args() {
1652        let args = vec!["one".to_string(), "two".to_string(), "three".to_string()];
1653        assert_eq!(substitute_args("hello $1", &args), "hello one");
1654        assert_eq!(substitute_args("$@", &args), "one two three");
1655        assert_eq!(substitute_args("$ARGUMENTS", &args), "one two three");
1656        assert_eq!(substitute_args("${@:2}", &args), "two three");
1657        assert_eq!(substitute_args("${@:2:1}", &args), "two");
1658    }
1659
1660    #[test]
1661    fn test_expand_prompt_template() {
1662        let template = PromptTemplate {
1663            name: "review".to_string(),
1664            description: "Review code".to_string(),
1665            content: "Review $1".to_string(),
1666            source: "user".to_string(),
1667            file_path: PathBuf::from("/tmp/review.md"),
1668        };
1669        let out = expand_prompt_template("/review foo", &[template]);
1670        assert_eq!(out, "Review foo");
1671    }
1672
1673    #[test]
1674    fn test_format_skills_for_prompt() {
1675        let skills = vec![
1676            Skill {
1677                name: "a".to_string(),
1678                description: "desc".to_string(),
1679                file_path: PathBuf::from("/tmp/a/SKILL.md"),
1680                base_dir: PathBuf::from("/tmp/a"),
1681                source: "user".to_string(),
1682                disable_model_invocation: false,
1683            },
1684            Skill {
1685                name: "b".to_string(),
1686                description: "desc".to_string(),
1687                file_path: PathBuf::from("/tmp/b/SKILL.md"),
1688                base_dir: PathBuf::from("/tmp/b"),
1689                source: "user".to_string(),
1690                disable_model_invocation: true,
1691            },
1692        ];
1693        let prompt = format_skills_for_prompt(&skills);
1694        assert!(prompt.contains("<available_skills>"));
1695        assert!(prompt.contains("<name>a</name>"));
1696        assert!(!prompt.contains("<name>b</name>"));
1697    }
1698
1699    #[test]
1700    fn test_cli_extensions_load_when_no_extensions_flag_set() {
1701        run_async(async {
1702            let temp_dir = tempfile::tempdir().expect("tempdir");
1703            let extension_path = temp_dir.path().join("ext.native.json");
1704            fs::write(&extension_path, "{}").expect("write extension");
1705
1706            let manager = PackageManager::new(temp_dir.path().to_path_buf());
1707            let config = Config::default();
1708            let cli = ResourceCliOptions {
1709                no_skills: true,
1710                no_prompt_templates: true,
1711                no_extensions: true,
1712                no_themes: true,
1713                skill_paths: Vec::new(),
1714                prompt_paths: Vec::new(),
1715                extension_paths: vec![extension_path.to_string_lossy().to_string()],
1716                theme_paths: Vec::new(),
1717            };
1718
1719            let loader = ResourceLoader::load(&manager, temp_dir.path(), &config, &cli)
1720                .await
1721                .expect("load resources");
1722            assert!(loader.extensions().contains(&extension_path));
1723        });
1724    }
1725
1726    #[test]
1727    fn test_extension_paths_deduped_between_settings_and_cli() {
1728        run_async(async {
1729            let temp_dir = tempfile::tempdir().expect("tempdir");
1730            let extension_path = temp_dir.path().join("ext.native.json");
1731            fs::write(&extension_path, "{}").expect("write extension");
1732
1733            let settings_dir = temp_dir.path().join(".pi");
1734            fs::create_dir_all(&settings_dir).expect("create settings dir");
1735            let settings_path = settings_dir.join("settings.json");
1736            let settings = json!({
1737                "extensions": [extension_path.to_string_lossy().to_string()]
1738            });
1739            fs::write(
1740                &settings_path,
1741                serde_json::to_string_pretty(&settings).expect("serialize settings"),
1742            )
1743            .expect("write settings");
1744
1745            let manager = PackageManager::new(temp_dir.path().to_path_buf());
1746            let config = Config::default();
1747            let cli = ResourceCliOptions {
1748                no_skills: true,
1749                no_prompt_templates: true,
1750                no_extensions: false,
1751                no_themes: true,
1752                skill_paths: Vec::new(),
1753                prompt_paths: Vec::new(),
1754                extension_paths: vec![extension_path.to_string_lossy().to_string()],
1755                theme_paths: Vec::new(),
1756            };
1757
1758            let loader = ResourceLoader::load(&manager, temp_dir.path(), &config, &cli)
1759                .await
1760                .expect("load resources");
1761            let matches = loader
1762                .extensions()
1763                .iter()
1764                .filter(|path| *path == &extension_path)
1765                .count();
1766            assert_eq!(matches, 1);
1767        });
1768    }
1769
1770    #[test]
1771    fn test_dedupe_themes_is_case_insensitive() {
1772        let (themes, diagnostics) = dedupe_themes(vec![
1773            ThemeResource {
1774                name: "Dark".to_string(),
1775                theme: Theme::dark(),
1776                source: "test:first".to_string(),
1777                file_path: PathBuf::from("/tmp/Dark.ini"),
1778            },
1779            ThemeResource {
1780                name: "dark".to_string(),
1781                theme: Theme::dark(),
1782                source: "test:second".to_string(),
1783                file_path: PathBuf::from("/tmp/dark.ini"),
1784            },
1785        ]);
1786
1787        assert_eq!(themes.len(), 1);
1788        assert_eq!(diagnostics.len(), 1);
1789        assert_eq!(diagnostics[0].kind, DiagnosticKind::Collision);
1790        assert!(
1791            diagnostics[0].message.contains("theme"),
1792            "unexpected diagnostic: {:?}",
1793            diagnostics[0]
1794        );
1795    }
1796
1797    #[test]
1798    fn test_extract_string_list_variants() {
1799        assert_eq!(
1800            extract_string_list(&Value::String("one".to_string())),
1801            vec!["one".to_string()]
1802        );
1803        assert_eq!(
1804            extract_string_list(&json!(["one", 2, "three", true, null])),
1805            vec!["one".to_string(), "three".to_string()]
1806        );
1807        assert!(extract_string_list(&json!({"a": 1})).is_empty());
1808    }
1809
1810    #[test]
1811    fn test_validate_name_catches_all_error_categories() {
1812        let errors = validate_name("Bad--Name-", "parent");
1813        assert!(
1814            errors
1815                .iter()
1816                .any(|e| e.contains("does not match parent directory"))
1817        );
1818        assert!(errors.iter().any(|e| e.contains("invalid characters")));
1819        assert!(
1820            errors
1821                .iter()
1822                .any(|e| e.contains("must not start or end with a hyphen"))
1823        );
1824        assert!(
1825            errors
1826                .iter()
1827                .any(|e| e.contains("must not contain consecutive hyphens"))
1828        );
1829
1830        let too_long = "a".repeat(MAX_SKILL_NAME_LEN + 1);
1831        let too_long_errors = validate_name(&too_long, &too_long);
1832        assert!(
1833            too_long_errors
1834                .iter()
1835                .any(|e| e.contains(&format!("name exceeds {MAX_SKILL_NAME_LEN} characters")))
1836        );
1837    }
1838
1839    #[test]
1840    fn test_validate_description_rules() {
1841        let empty_errors = validate_description("   ");
1842        assert!(empty_errors.iter().any(|e| e == "description is required"));
1843
1844        let long = "x".repeat(MAX_SKILL_DESC_LEN + 1);
1845        let long_errors = validate_description(&long);
1846        assert!(long_errors.iter().any(|e| e.contains(&format!(
1847            "description exceeds {MAX_SKILL_DESC_LEN} characters"
1848        ))));
1849
1850        assert!(validate_description("ok").is_empty());
1851    }
1852
1853    #[test]
1854    fn test_validate_frontmatter_fields_allows_known_and_rejects_unknown() {
1855        let keys = [
1856            "name".to_string(),
1857            "description".to_string(),
1858            "unknown-field".to_string(),
1859        ];
1860        let errors = validate_frontmatter_fields(keys.iter());
1861        assert_eq!(errors.len(), 1);
1862        assert_eq!(errors[0], "unknown frontmatter field \"unknown-field\"");
1863    }
1864
1865    #[test]
1866    fn test_escape_xml_replaces_all_special_chars() {
1867        let escaped = escape_xml("& < > \" '");
1868        assert_eq!(escaped, "&amp; &lt; &gt; &quot; &apos;");
1869    }
1870
1871    #[test]
1872    fn test_parse_frontmatter_valid_and_unclosed() {
1873        let parsed = parse_frontmatter(
1874            r#"---
1875name: "skill-name"
1876description: 'demo'
1877# comment
1878metadata: keep
1879---
1880body line 1
1881body line 2"#,
1882        );
1883        assert_eq!(
1884            parsed.frontmatter.get("name"),
1885            Some(&"skill-name".to_string())
1886        );
1887        assert_eq!(
1888            parsed.frontmatter.get("description"),
1889            Some(&"demo".to_string())
1890        );
1891        assert_eq!(
1892            parsed.frontmatter.get("metadata"),
1893            Some(&"keep".to_string())
1894        );
1895        assert_eq!(parsed.body, "body line 1\nbody line 2");
1896
1897        let unclosed = parse_frontmatter(
1898            r"---
1899name: nope
1900still frontmatter",
1901        );
1902        assert!(unclosed.frontmatter.is_empty());
1903        assert!(unclosed.body.starts_with("---"));
1904    }
1905
1906    #[test]
1907    fn test_resolve_path_tilde_relative_absolute_and_trim() {
1908        let cwd = Path::new("/work/cwd");
1909        let home = dirs::home_dir().unwrap_or_else(|| cwd.to_path_buf());
1910
1911        assert_eq!(resolve_path("  rel/file  ", cwd), cwd.join("rel/file"));
1912        assert_eq!(resolve_path("/abs/file", cwd), PathBuf::from("/abs/file"));
1913        assert_eq!(resolve_path("~", cwd), home);
1914        assert_eq!(resolve_path("~/cfg", cwd), home.join("cfg"));
1915        assert_eq!(resolve_path("~custom", cwd), home.join("custom"));
1916    }
1917
1918    #[test]
1919    fn test_theme_path_helpers() {
1920        assert!(is_theme_file(Path::new("/tmp/theme.json")));
1921        assert!(is_theme_file(Path::new("/tmp/theme.ini")));
1922        assert!(is_theme_file(Path::new("/tmp/theme.theme")));
1923        assert!(!is_theme_file(Path::new("/tmp/theme.txt")));
1924
1925        assert_eq!(
1926            build_path_source_label(Path::new("/tmp/ocean.theme")),
1927            "(path:ocean)"
1928        );
1929        assert_eq!(build_path_source_label(Path::new("/")), "(path:path)");
1930    }
1931
1932    #[test]
1933    fn test_dedupe_paths_preserves_order_of_first_occurrence() {
1934        let paths = vec![
1935            PathBuf::from("/a"),
1936            PathBuf::from("/b"),
1937            PathBuf::from("/a"),
1938            PathBuf::from("/c"),
1939            PathBuf::from("/b"),
1940        ];
1941        let deduped = dedupe_paths(paths);
1942        assert_eq!(
1943            deduped,
1944            vec![
1945                PathBuf::from("/a"),
1946                PathBuf::from("/b"),
1947                PathBuf::from("/c"),
1948            ]
1949        );
1950    }
1951
1952    // ── strip_frontmatter ──────────────────────────────────────────────
1953
1954    #[test]
1955    fn test_strip_frontmatter_removes_yaml_header() {
1956        let raw = "---\nname: test\n---\nbody content";
1957        assert_eq!(strip_frontmatter(raw), "body content");
1958    }
1959
1960    #[test]
1961    fn test_strip_frontmatter_returns_body_when_no_frontmatter() {
1962        let raw = "just body content";
1963        assert_eq!(strip_frontmatter(raw), "just body content");
1964    }
1965
1966    // ── is_under_path ──────────────────────────────────────────────────
1967
1968    #[test]
1969    fn test_is_under_path_same_dir() {
1970        let tmp = tempfile::tempdir().expect("tempdir");
1971        assert!(is_under_path(tmp.path(), tmp.path()));
1972    }
1973
1974    #[test]
1975    fn test_is_under_path_child() {
1976        let tmp = tempfile::tempdir().expect("tempdir");
1977        let child = tmp.path().join("sub");
1978        fs::create_dir(&child).expect("mkdir");
1979        assert!(is_under_path(&child, tmp.path()));
1980    }
1981
1982    #[test]
1983    fn test_is_under_path_unrelated() {
1984        let tmp1 = tempfile::tempdir().expect("tmp1");
1985        let tmp2 = tempfile::tempdir().expect("tmp2");
1986        assert!(!is_under_path(tmp1.path(), tmp2.path()));
1987    }
1988
1989    #[test]
1990    fn test_is_under_path_nonexistent() {
1991        assert!(!is_under_path(
1992            Path::new("/nonexistent/a"),
1993            Path::new("/nonexistent/b")
1994        ));
1995    }
1996
1997    // ── dedupe_prompts ─────────────────────────────────────────────────
1998
1999    #[test]
2000    fn test_dedupe_prompts_removes_duplicates_keeps_first() {
2001        let prompts = vec![
2002            PromptTemplate {
2003                name: "review".to_string(),
2004                description: "first".to_string(),
2005                content: "content1".to_string(),
2006                source: "a".to_string(),
2007                file_path: PathBuf::from("/a/review.md"),
2008            },
2009            PromptTemplate {
2010                name: "review".to_string(),
2011                description: "second".to_string(),
2012                content: "content2".to_string(),
2013                source: "b".to_string(),
2014                file_path: PathBuf::from("/b/review.md"),
2015            },
2016            PromptTemplate {
2017                name: "unique".to_string(),
2018                description: "only one".to_string(),
2019                content: "content3".to_string(),
2020                source: "c".to_string(),
2021                file_path: PathBuf::from("/c/unique.md"),
2022            },
2023        ];
2024        let (deduped, diagnostics) = dedupe_prompts(prompts);
2025        assert_eq!(deduped.len(), 2);
2026        assert_eq!(diagnostics.len(), 1);
2027        assert_eq!(diagnostics[0].kind, DiagnosticKind::Collision);
2028        assert!(diagnostics[0].message.contains("review"));
2029    }
2030
2031    #[test]
2032    fn test_dedupe_prompts_sorts_by_name() {
2033        let prompts = vec![
2034            PromptTemplate {
2035                name: "z-prompt".to_string(),
2036                description: "z".to_string(),
2037                content: String::new(),
2038                source: "s".to_string(),
2039                file_path: PathBuf::from("/z.md"),
2040            },
2041            PromptTemplate {
2042                name: "a-prompt".to_string(),
2043                description: "a".to_string(),
2044                content: String::new(),
2045                source: "s".to_string(),
2046                file_path: PathBuf::from("/a.md"),
2047            },
2048        ];
2049        let (deduped, diagnostics) = dedupe_prompts(prompts);
2050        assert!(diagnostics.is_empty());
2051        assert_eq!(deduped[0].name, "a-prompt");
2052        assert_eq!(deduped[1].name, "z-prompt");
2053    }
2054
2055    // ── expand_skill_command ───────────────────────────────────────────
2056
2057    #[test]
2058    fn test_expand_skill_command_with_matching_skill() {
2059        let tmp = tempfile::tempdir().expect("tempdir");
2060        let skill_file = tmp.path().join("SKILL.md");
2061        fs::write(
2062            &skill_file,
2063            "---\nname: test-skill\ndescription: A test\n---\nDo the thing.",
2064        )
2065        .expect("write skill");
2066
2067        let skills = vec![Skill {
2068            name: "test-skill".to_string(),
2069            description: "A test".to_string(),
2070            file_path: skill_file,
2071            base_dir: tmp.path().to_path_buf(),
2072            source: "test".to_string(),
2073            disable_model_invocation: false,
2074        }];
2075        let result = expand_skill_command("/skill:test-skill extra args", &skills);
2076        assert!(result.contains("<skill name=\"test-skill\""));
2077        assert!(result.contains("Do the thing."));
2078        assert!(result.contains("extra args"));
2079    }
2080
2081    #[test]
2082    fn test_expand_skill_command_no_matching_skill_returns_input() {
2083        let result = expand_skill_command("/skill:nonexistent", &[]);
2084        assert_eq!(result, "/skill:nonexistent");
2085    }
2086
2087    #[test]
2088    fn test_expand_skill_command_non_skill_prefix_returns_input() {
2089        let result = expand_skill_command("plain text", &[]);
2090        assert_eq!(result, "plain text");
2091    }
2092
2093    // ── parse_command_args edge cases ──────────────────────────────────
2094
2095    #[test]
2096    fn test_parse_command_args_empty() {
2097        assert!(parse_command_args("").is_empty());
2098        assert!(parse_command_args("   ").is_empty());
2099    }
2100
2101    #[test]
2102    fn test_parse_command_args_tabs_as_separators() {
2103        assert_eq!(parse_command_args("a\tb\tc"), vec!["a", "b", "c"]);
2104    }
2105
2106    #[test]
2107    fn test_parse_command_args_unclosed_quote() {
2108        // Unclosed quote just includes chars up to end
2109        assert_eq!(parse_command_args("foo \"bar"), vec!["foo", "bar"]);
2110    }
2111
2112    // ── substitute_args edge cases ─────────────────────────────────────
2113
2114    #[test]
2115    fn test_substitute_args_out_of_range_positional() {
2116        let args = vec!["one".to_string()];
2117        assert_eq!(substitute_args("$2", &args), "");
2118    }
2119
2120    #[test]
2121    fn test_substitute_args_zero_positional() {
2122        let args = vec!["one".to_string(), "two".to_string()];
2123        let result = substitute_args("$0", &args);
2124        assert_eq!(result, "");
2125    }
2126
2127    #[test]
2128    fn test_substitute_args_empty_args() {
2129        let result = substitute_args("$1 $@ $ARGUMENTS", &[]);
2130        assert_eq!(result, "  ");
2131    }
2132
2133    #[test]
2134    fn panic_payload_message_handles_known_payload_types() {
2135        let string_payload: Box<dyn std::any::Any + Send + 'static> =
2136            Box::new("loader panic".to_string());
2137        assert_eq!(
2138            panic_payload_message(string_payload),
2139            "loader panic".to_string()
2140        );
2141
2142        let str_payload: Box<dyn std::any::Any + Send + 'static> = Box::new("panic str");
2143        assert_eq!(panic_payload_message(str_payload), "panic str".to_string());
2144    }
2145
2146    // ── expand_prompt_template edge cases ──────────────────────────────
2147
2148    #[test]
2149    fn test_expand_prompt_template_non_slash_returns_as_is() {
2150        let result = expand_prompt_template("plain text", &[]);
2151        assert_eq!(result, "plain text");
2152    }
2153
2154    #[test]
2155    fn test_expand_prompt_template_unknown_command_returns_as_is() {
2156        let result = expand_prompt_template("/nonexistent foo", &[]);
2157        assert_eq!(result, "/nonexistent foo");
2158    }
2159
2160    // ── parse_frontmatter edge cases ───────────────────────────────────
2161
2162    #[test]
2163    fn test_parse_frontmatter_empty_input() {
2164        let parsed = parse_frontmatter("");
2165        assert!(parsed.frontmatter.is_empty());
2166        assert!(parsed.body.is_empty());
2167    }
2168
2169    #[test]
2170    fn test_parse_frontmatter_only_body() {
2171        let parsed = parse_frontmatter("no frontmatter here\njust body");
2172        assert!(parsed.frontmatter.is_empty());
2173        assert_eq!(parsed.body, "no frontmatter here\njust body");
2174    }
2175
2176    #[test]
2177    fn test_parse_frontmatter_empty_key_ignored() {
2178        let parsed = parse_frontmatter("---\n: value\nname: test\n---\nbody");
2179        assert!(!parsed.frontmatter.contains_key(""));
2180        assert_eq!(parsed.frontmatter.get("name"), Some(&"test".to_string()));
2181    }
2182
2183    // ── validate_name edge cases ───────────────────────────────────────
2184
2185    #[test]
2186    fn test_validate_name_valid_name() {
2187        let errors = validate_name("good-name", "good-name");
2188        assert!(errors.is_empty());
2189    }
2190
2191    #[test]
2192    fn test_validate_name_single_char() {
2193        let errors = validate_name("a", "a");
2194        assert!(errors.is_empty());
2195    }
2196
2197    // ── CollisionInfo and DiagnosticKind ────────────────────────────────
2198
2199    #[test]
2200    fn test_diagnostic_kind_equality() {
2201        assert_eq!(DiagnosticKind::Warning, DiagnosticKind::Warning);
2202        assert_eq!(DiagnosticKind::Collision, DiagnosticKind::Collision);
2203        assert_ne!(DiagnosticKind::Warning, DiagnosticKind::Collision);
2204    }
2205
2206    // ── replace_regex ──────────────────────────────────────────────────
2207
2208    #[test]
2209    fn test_replace_regex_no_match_returns_input() {
2210        let re = regex::Regex::new(r"\d+").unwrap();
2211        let result = replace_regex("hello world", &re, |_| "num".to_string());
2212        assert_eq!(result, "hello world");
2213    }
2214
2215    #[test]
2216    fn test_replace_regex_replaces_all_matches() {
2217        let re = regex::Regex::new(r"\d").unwrap();
2218        let result = replace_regex("a1b2c3", &re, |caps| format!("[{}]", &caps[0]));
2219        assert_eq!(result, "a[1]b[2]c[3]");
2220    }
2221
2222    // ── load_skill_from_file with valid skill ──────────────────────────
2223
2224    #[test]
2225    fn test_load_skill_from_file_valid() {
2226        let tmp = tempfile::tempdir().expect("tempdir");
2227        let skill_dir = tmp.path().join("my-skill");
2228        fs::create_dir(&skill_dir).expect("mkdir");
2229        let skill_file = skill_dir.join("SKILL.md");
2230        fs::write(
2231            &skill_file,
2232            "---\nname: my-skill\ndescription: A great skill\n---\nDo something.",
2233        )
2234        .expect("write");
2235
2236        let result = load_skill_from_file(&skill_file, "test".to_string());
2237        assert!(result.skill.is_some());
2238        let skill = result.skill.unwrap();
2239        assert_eq!(skill.name, "my-skill");
2240        assert_eq!(skill.description, "A great skill");
2241    }
2242
2243    #[test]
2244    fn test_load_skill_from_file_missing_description() {
2245        let tmp = tempfile::tempdir().expect("tempdir");
2246        let skill_dir = tmp.path().join("bad-skill");
2247        fs::create_dir(&skill_dir).expect("mkdir");
2248        let skill_file = skill_dir.join("SKILL.md");
2249        fs::write(&skill_file, "---\nname: bad-skill\n---\nContent.").expect("write");
2250
2251        let result = load_skill_from_file(&skill_file, "test".to_string());
2252        assert!(!result.diagnostics.is_empty());
2253    }
2254
2255    #[cfg(unix)]
2256    #[test]
2257    fn test_load_skills_from_dir_ignores_symlink_cycles() {
2258        let tmp = tempfile::tempdir().expect("tempdir");
2259        let skills_root = tmp.path().join("skills");
2260        let skill_dir = skills_root.join("my-skill");
2261        fs::create_dir_all(&skill_dir).expect("mkdir");
2262        fs::write(
2263            skill_dir.join("SKILL.md"),
2264            "---\nname: my-skill\ndescription: Cyclic symlink guard test\n---\nBody",
2265        )
2266        .expect("write skill");
2267
2268        let loop_link = skill_dir.join("loop");
2269        std::os::unix::fs::symlink(&skill_dir, &loop_link).expect("create symlink loop");
2270
2271        let result = load_skills_from_dir(skills_root, "test".to_string(), true);
2272        assert_eq!(result.skills.len(), 1);
2273        assert_eq!(result.skills[0].name, "my-skill");
2274    }
2275
2276    // ── Property tests ──────────────────────────────────────────────────
2277
2278    mod proptest_resources {
2279        use super::*;
2280        use proptest::prelude::*;
2281
2282        fn arb_valid_name() -> impl Strategy<Value = String> {
2283            "[a-z0-9]([a-z0-9]|(-[a-z0-9])){0,20}"
2284                .prop_filter("no consecutive hyphens", |s| !s.contains("--"))
2285        }
2286
2287        proptest! {
2288            #[test]
2289            fn validate_name_accepts_valid_names(name in arb_valid_name()) {
2290                let errors = validate_name(&name, &name);
2291                assert!(
2292                    errors.is_empty(),
2293                    "valid name '{name}' should have no errors, got: {errors:?}"
2294                );
2295            }
2296
2297            #[test]
2298            fn validate_name_rejects_uppercase(
2299                prefix in "[a-z]{1,5}",
2300                upper in "[A-Z]{1,3}",
2301                suffix in "[a-z]{1,5}",
2302            ) {
2303                let name = format!("{prefix}{upper}{suffix}");
2304                let errors = validate_name(&name, &name);
2305                assert!(
2306                    errors.iter().any(|e| e.contains("invalid characters")),
2307                    "uppercase in '{name}' should be rejected, got: {errors:?}"
2308                );
2309            }
2310
2311            #[test]
2312            fn validate_name_rejects_leading_or_trailing_hyphen(
2313                core in "[a-z]{1,10}",
2314                leading in proptest::bool::ANY,
2315            ) {
2316                let name = if leading {
2317                    format!("-{core}")
2318                } else {
2319                    format!("{core}-")
2320                };
2321                let errors = validate_name(&name, &name);
2322                assert!(
2323                    errors.iter().any(|e| e.contains("must not start or end with a hyphen")),
2324                    "name '{name}' should fail hyphen check, got: {errors:?}"
2325                );
2326            }
2327
2328            #[test]
2329            fn validate_name_rejects_consecutive_hyphens(
2330                left in "[a-z]{1,8}",
2331                right in "[a-z]{1,8}",
2332            ) {
2333                let name = format!("{left}--{right}");
2334                let errors = validate_name(&name, &name);
2335                assert!(
2336                    errors.iter().any(|e| e.contains("consecutive hyphens")),
2337                    "name '{name}' should fail consecutive-hyphen check, got: {errors:?}"
2338                );
2339            }
2340
2341            #[test]
2342            fn validate_name_length_limit_enforced(extra_len in 1..100usize) {
2343                let name: String = "a".repeat(MAX_SKILL_NAME_LEN + extra_len);
2344                let errors = validate_name(&name, &name);
2345                assert!(
2346                    errors.iter().any(|e| e.contains("exceeds")),
2347                    "name of length {} should exceed limit, got: {errors:?}",
2348                    name.len()
2349                );
2350            }
2351
2352            #[test]
2353            fn validate_description_accepts_within_limit(
2354                desc in "[a-zA-Z]{1,5}[a-zA-Z ]{0,95}",
2355            ) {
2356                let errors = validate_description(&desc);
2357                assert!(
2358                    errors.is_empty(),
2359                    "short description should be valid, got: {errors:?}"
2360                );
2361            }
2362
2363            #[test]
2364            fn validate_description_rejects_over_limit(extra in 1..200usize) {
2365                let desc = "x".repeat(MAX_SKILL_DESC_LEN + extra);
2366                let errors = validate_description(&desc);
2367                assert!(
2368                    errors.iter().any(|e| e.contains("exceeds")),
2369                    "description of length {} should exceed limit",
2370                    desc.len()
2371                );
2372            }
2373
2374            #[test]
2375            fn escape_xml_idempotent_on_safe_strings(s in "[a-zA-Z0-9 ]{0,50}") {
2376                assert_eq!(
2377                    escape_xml(&s), s,
2378                    "safe string should pass through unchanged"
2379                );
2380            }
2381
2382            #[test]
2383            fn escape_xml_output_never_contains_raw_special_chars(s in ".*") {
2384                let escaped = escape_xml(&s);
2385                // After escaping, no raw `<`, `>`, `&` (except in escape sequences),
2386                // `"`, or `'` should remain unescaped.
2387                // We check that re-escaping is idempotent on the escaped output.
2388                // A simpler check: the escaped output, when re-escaped, should only
2389                // double-encode the `&` in existing entities.
2390                let double_escaped = escape_xml(&escaped);
2391                // If no raw specials in escaped, then double-escape only affects `&`
2392                // in entities like `&amp;` → `&amp;amp;`.
2393                // We just check the output doesn't contain bare `<` or `>`.
2394                assert!(
2395                    !escaped.contains('<') && !escaped.contains('>'),
2396                    "escaped output should not contain raw < or >: {escaped}"
2397                );
2398                let _ = double_escaped; // suppress unused warning
2399            }
2400
2401            #[test]
2402            fn parse_command_args_round_trip_simple_tokens(
2403                tokens in prop::collection::vec("[a-zA-Z0-9]{1,10}", 0..8),
2404            ) {
2405                let input = tokens.join(" ");
2406                let parsed = parse_command_args(&input);
2407                assert_eq!(
2408                    parsed, tokens,
2409                    "simple space-separated tokens should round-trip"
2410                );
2411            }
2412
2413            #[test]
2414            fn parse_command_args_quoted_preserves_spaces(
2415                before in "[a-z]{1,5}",
2416                inner in "[a-z ]{1,10}",
2417                after in "[a-z]{1,5}",
2418            ) {
2419                let input = format!("{before} \"{inner}\" {after}");
2420                let parsed = parse_command_args(&input);
2421                assert!(
2422                    parsed.contains(&inner),
2423                    "quoted token '{inner}' should appear in parsed output: {parsed:?}"
2424                );
2425            }
2426
2427            #[test]
2428            fn substitute_args_positional_in_range(
2429                idx in 1..10usize,
2430                values in prop::collection::vec("[a-z]{1,5}", 1..10),
2431            ) {
2432                let template = format!("${idx}");
2433                let result = substitute_args(&template, &values);
2434                let expected = values.get(idx.saturating_sub(1)).cloned().unwrap_or_default();
2435                assert_eq!(
2436                    result, expected,
2437                    "positional ${idx} should resolve correctly"
2438                );
2439            }
2440
2441            #[test]
2442            fn substitute_args_dollar_at_is_all_joined(
2443                values in prop::collection::vec("[a-z]{1,5}", 0..8),
2444            ) {
2445                let result = substitute_args("$@", &values);
2446                let expected = values.join(" ");
2447                assert_eq!(result, expected, "$@ should join all args");
2448            }
2449
2450            #[test]
2451            fn substitute_args_arguments_equals_dollar_at(
2452                values in prop::collection::vec("[a-z]{1,5}", 0..8),
2453            ) {
2454                let r1 = substitute_args("$@", &values);
2455                let r2 = substitute_args("$ARGUMENTS", &values);
2456                assert_eq!(r1, r2, "$@ and $ARGUMENTS should be equivalent");
2457            }
2458
2459            #[test]
2460            fn parse_frontmatter_no_dashes_returns_raw_body(
2461                body in "[a-zA-Z0-9 \n]{0,100}",
2462            ) {
2463                let parsed = parse_frontmatter(&body);
2464                assert!(
2465                    parsed.frontmatter.is_empty(),
2466                    "no --- means no frontmatter"
2467                );
2468                assert_eq!(parsed.body, body);
2469            }
2470
2471            #[test]
2472            fn parse_frontmatter_unclosed_returns_raw(
2473                key in "[a-z]{1,8}",
2474                val in "[a-z]{1,8}",
2475            ) {
2476                let raw = format!("---\n{key}: {val}\nmore stuff");
2477                let parsed = parse_frontmatter(&raw);
2478                assert!(
2479                    parsed.frontmatter.is_empty(),
2480                    "unclosed frontmatter should return empty map"
2481                );
2482                assert_eq!(parsed.body, raw);
2483            }
2484
2485            #[test]
2486            fn parse_frontmatter_closed_extracts_key_value(
2487                key in "[a-z]{1,8}",
2488                val in "[a-z]{1,8}",
2489                body in "[a-z ]{0,30}",
2490            ) {
2491                let raw = format!("---\n{key}: {val}\n---\n{body}");
2492                let parsed = parse_frontmatter(&raw);
2493                assert_eq!(
2494                    parsed.frontmatter.get(&key),
2495                    Some(&val),
2496                    "closed frontmatter should extract {key}: {val}"
2497                );
2498                assert_eq!(parsed.body, body);
2499            }
2500
2501            #[test]
2502            fn resolve_path_absolute_is_identity(
2503                suffix in "[a-z]{1,10}(/[a-z]{1,10}){0,3}",
2504            ) {
2505                let abs = format!("/{suffix}");
2506                let cwd = Path::new("/some/cwd");
2507                let resolved = resolve_path(&abs, cwd);
2508                assert_eq!(
2509                    resolved,
2510                    PathBuf::from(&abs),
2511                    "absolute path should pass through unchanged"
2512                );
2513            }
2514
2515            #[test]
2516            fn resolve_path_relative_is_under_cwd(
2517                rel in "[a-z]{1,10}(/[a-z]{1,10}){0,2}",
2518            ) {
2519                let cwd = Path::new("/work/dir");
2520                let resolved = resolve_path(&rel, cwd);
2521                assert!(
2522                    resolved.starts_with(cwd),
2523                    "relative path should resolve under cwd: {resolved:?}"
2524                );
2525            }
2526
2527            #[test]
2528            fn dedupe_paths_preserves_first_and_removes_dups(
2529                paths in prop::collection::vec("[a-z]{1,5}", 1..20),
2530            ) {
2531                let path_bufs: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
2532                let deduped = dedupe_paths(path_bufs.clone());
2533
2534                // All elements in deduped should be unique
2535                let unique: HashSet<String> = deduped.iter()
2536                    .map(|p| p.to_string_lossy().to_string())
2537                    .collect();
2538                assert_eq!(
2539                    deduped.len(), unique.len(),
2540                    "deduped output must contain no duplicates"
2541                );
2542
2543                // First occurrence order preserved
2544                let mut seen = HashSet::new();
2545                let expected: Vec<&PathBuf> = path_bufs.iter()
2546                    .filter(|p| seen.insert(p.to_string_lossy().to_string()))
2547                    .collect();
2548                assert_eq!(
2549                    deduped.iter().collect::<Vec<_>>(), expected,
2550                    "deduped must preserve first-occurrence order"
2551                );
2552            }
2553        }
2554    }
2555}