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 tracing::warn;
10use crate::error::{Error, Result};
11use crate::package_manager::{
12    PackageManager, PackageScope, ResolveExtensionSourcesOptions, ResolvedResource, ResourceOrigin,
13};
14use crate::theme::Theme;
15use serde_json::{Value, json};
16use std::collections::{HashMap, HashSet};
17use std::fs;
18use std::path::{Component, Path, PathBuf};
19
20fn panic_payload_message(payload: Box<dyn std::any::Any + Send + 'static>) -> String {
21    payload.downcast::<String>().map_or_else(
22        |payload| {
23            payload.downcast::<&'static str>().map_or_else(
24                |_| "unknown panic payload".to_string(),
25                |message| (*message).to_string(),
26            )
27        },
28        |message| *message,
29    )
30}
31
32fn read_dir_sorted_paths(dir: &Path) -> Vec<PathBuf> {
33    let Ok(entries) = fs::read_dir(dir) else {
34        return Vec::new();
35    };
36
37    let mut paths: Vec<PathBuf> = entries.flatten().map(|entry| entry.path()).collect();
38    paths.sort();
39    paths
40}
41
42fn canonical_identity_path(path: &Path) -> PathBuf {
43    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
44}
45
46fn resolved_path_kind(path: &Path) -> (bool, bool) {
47    match fs::symlink_metadata(path) {
48        Ok(meta) if meta.file_type().is_symlink() => {
49            fs::metadata(path).map_or((false, false), |meta| (meta.is_dir(), meta.is_file()))
50        }
51        Ok(meta) => (meta.is_dir(), meta.is_file()),
52        Err(_) => (false, false),
53    }
54}
55
56// ============================================================================
57// Diagnostics
58// ============================================================================
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum DiagnosticKind {
62    Warning,
63    Collision,
64}
65
66#[derive(Debug, Clone)]
67pub struct CollisionInfo {
68    pub resource_type: String,
69    pub name: String,
70    pub winner_path: PathBuf,
71    pub loser_path: PathBuf,
72}
73
74#[derive(Debug, Clone)]
75pub struct ResourceDiagnostic {
76    pub kind: DiagnosticKind,
77    pub message: String,
78    pub path: PathBuf,
79    pub collision: Option<CollisionInfo>,
80}
81
82// ============================================================================
83// Skills
84// ============================================================================
85
86const MAX_SKILL_NAME_LEN: usize = 64;
87const MAX_SKILL_DESC_LEN: usize = 1024;
88
89const ALLOWED_SKILL_FRONTMATTER: [&str; 7] = [
90    "name",
91    "description",
92    "license",
93    "compatibility",
94    "metadata",
95    "allowed-tools",
96    "disable-model-invocation",
97];
98
99#[derive(Debug, Clone)]
100pub struct Skill {
101    pub name: String,
102    pub description: String,
103    pub file_path: PathBuf,
104    pub base_dir: PathBuf,
105    pub source: String,
106    pub disable_model_invocation: bool,
107}
108
109#[derive(Debug, Clone)]
110pub struct LoadSkillsResult {
111    pub skills: Vec<Skill>,
112    pub diagnostics: Vec<ResourceDiagnostic>,
113}
114
115#[derive(Debug, Clone)]
116pub struct LoadSkillsOptions {
117    pub cwd: PathBuf,
118    pub agent_dir: PathBuf,
119    pub skill_paths: Vec<PathBuf>,
120    pub include_defaults: bool,
121}
122
123// ============================================================================
124// Prompt templates
125// ============================================================================
126
127#[derive(Debug, Clone)]
128pub struct PromptTemplate {
129    pub name: String,
130    pub description: String,
131    pub content: String,
132    pub source: String,
133    pub file_path: PathBuf,
134}
135
136#[derive(Debug, Clone)]
137pub struct LoadPromptTemplatesOptions {
138    pub cwd: PathBuf,
139    pub agent_dir: PathBuf,
140    pub prompt_paths: Vec<PathBuf>,
141    pub include_defaults: bool,
142}
143
144// ============================================================================
145// Themes
146// ============================================================================
147
148#[derive(Debug, Clone)]
149pub struct ThemeResource {
150    pub name: String,
151    pub theme: Theme,
152    pub source: String,
153    pub file_path: PathBuf,
154}
155
156#[derive(Debug, Clone)]
157pub struct LoadThemesOptions {
158    pub cwd: PathBuf,
159    pub agent_dir: PathBuf,
160    pub theme_paths: Vec<PathBuf>,
161    pub include_defaults: bool,
162}
163
164#[derive(Debug, Clone)]
165pub struct LoadThemesResult {
166    pub themes: Vec<ThemeResource>,
167    pub diagnostics: Vec<ResourceDiagnostic>,
168}
169
170// ============================================================================
171// Resource Loader
172// ============================================================================
173
174#[derive(Debug, Clone)]
175#[allow(clippy::struct_excessive_bools)]
176pub struct ResourceCliOptions {
177    pub no_skills: bool,
178    pub no_prompt_templates: bool,
179    pub no_extensions: bool,
180    pub no_themes: bool,
181    pub skill_paths: Vec<String>,
182    pub prompt_paths: Vec<String>,
183    pub extension_paths: Vec<String>,
184    pub theme_paths: Vec<String>,
185}
186
187impl ResourceCliOptions {
188    #[must_use]
189    pub fn has_explicit_paths(&self) -> bool {
190        !self.skill_paths.is_empty()
191            || !self.prompt_paths.is_empty()
192            || !self.extension_paths.is_empty()
193            || !self.theme_paths.is_empty()
194    }
195
196    /// Returns `true` when every resource category is disabled via `--no-*` flags
197    /// and there are no explicit CLI `-e` extension sources that would require
198    /// package resolution.
199    #[must_use]
200    pub const fn all_configured_resources_disabled(&self) -> bool {
201        self.no_skills && self.no_prompt_templates && self.no_extensions && self.no_themes
202    }
203}
204
205#[derive(Debug, Clone, Default)]
206pub struct PackageResources {
207    pub extensions: Vec<PathBuf>,
208    pub skills: Vec<PathBuf>,
209    pub prompts: Vec<PathBuf>,
210    pub themes: Vec<PathBuf>,
211}
212
213#[derive(Debug, Clone, Default)]
214pub struct ExtensionResourcePaths {
215    pub skill_paths: Vec<PathBuf>,
216    pub prompt_paths: Vec<PathBuf>,
217    pub theme_paths: Vec<PathBuf>,
218}
219
220impl ExtensionResourcePaths {
221    pub fn is_empty(&self) -> bool {
222        self.skill_paths.is_empty() && self.prompt_paths.is_empty() && self.theme_paths.is_empty()
223    }
224}
225
226#[derive(Debug, Clone)]
227pub struct ResourceLoader {
228    skills: Vec<Skill>,
229    skill_diagnostics: Vec<ResourceDiagnostic>,
230    prompts: Vec<PromptTemplate>,
231    prompt_diagnostics: Vec<ResourceDiagnostic>,
232    themes: Vec<ThemeResource>,
233    theme_diagnostics: Vec<ResourceDiagnostic>,
234    extensions: Vec<PathBuf>,
235    enable_skill_commands: bool,
236}
237
238impl ResourceLoader {
239    pub const fn empty(enable_skill_commands: bool) -> Self {
240        Self {
241            skills: Vec::new(),
242            skill_diagnostics: Vec::new(),
243            prompts: Vec::new(),
244            prompt_diagnostics: Vec::new(),
245            themes: Vec::new(),
246            theme_diagnostics: Vec::new(),
247            extensions: Vec::new(),
248            enable_skill_commands,
249        }
250    }
251
252    #[allow(clippy::too_many_lines)]
253    pub async fn load(
254        manager: &PackageManager,
255        cwd: &Path,
256        config: &Config,
257        cli: &ResourceCliOptions,
258    ) -> Result<Self> {
259        let enable_skill_commands = config.enable_skill_commands();
260
261        // Skip the expensive package/settings resolution when every resource
262        // category is disabled (--no-extensions --no-skills etc.) and there are
263        // no explicit CLI `-e` sources.  This avoids reading settings.json,
264        // running npm lookups, and hitting the network on startup when the user
265        // explicitly opted out of all configured resources (Issue #38).
266        let skip_configured_resolution =
267            cli.all_configured_resources_disabled() && cli.extension_paths.is_empty();
268
269        let resolved = if skip_configured_resolution {
270            crate::package_manager::ResolvedPaths::default()
271        } else {
272            Box::pin(manager.resolve()).await?
273        };
274
275        let cli_extensions = if cli.extension_paths.is_empty() {
276            crate::package_manager::ResolvedPaths::default()
277        } else {
278            validate_non_empty_cli_inputs(&cli.extension_paths, "extension source")?;
279            Box::pin(manager.resolve_extension_sources(
280                &cli.extension_paths,
281                ResolveExtensionSourcesOptions {
282                    local: false,
283                    temporary: true,
284                },
285            ))
286            .await?
287        };
288
289        validate_non_empty_cli_inputs(&cli.skill_paths, "skill path")?;
290        let explicit_skill_paths = dedupe_paths(
291            cli.skill_paths
292                .iter()
293                .map(|path| resolve_path(path, cwd))
294                .collect(),
295        );
296        validate_explicit_resource_paths(&explicit_skill_paths, ExplicitResourceKind::Skill)?;
297
298        validate_non_empty_cli_inputs(&cli.prompt_paths, "prompt template path")?;
299        let explicit_prompt_paths = dedupe_paths(
300            cli.prompt_paths
301                .iter()
302                .map(|path| resolve_path(path, cwd))
303                .collect(),
304        );
305        validate_explicit_resource_paths(&explicit_prompt_paths, ExplicitResourceKind::Prompt)?;
306
307        validate_non_empty_cli_inputs(&cli.theme_paths, "theme path")?;
308        let explicit_theme_paths = dedupe_paths(
309            cli.theme_paths
310                .iter()
311                .map(|path| resolve_path(path, cwd))
312                .collect(),
313        );
314        validate_explicit_resource_paths(&explicit_theme_paths, ExplicitResourceKind::Theme)?;
315
316        // Merge paths with documented precedence semantics:
317        // - explicit CLI resources win over everything else
318        // - CLI `-e` resources outrank configured/project/global/package resources
319        // - project directories outrank global directories, which outrank installed packages
320        // - `--no-skills` / `--no-prompt-templates` / `--no-themes` only disable configured
321        //   resources; explicit CLI paths and CLI `-e` resources still participate
322        let skill_paths = merge_resource_paths(
323            &explicit_skill_paths,
324            cli_extensions.skills,
325            resolved.skills,
326            !cli.no_skills,
327        );
328
329        let prompt_paths = merge_resource_paths(
330            &explicit_prompt_paths,
331            cli_extensions.prompts,
332            resolved.prompts,
333            !cli.no_prompt_templates,
334        );
335
336        let theme_paths = merge_resource_paths(
337            &explicit_theme_paths,
338            cli_extensions.themes,
339            resolved.themes,
340            !cli.no_themes,
341        );
342
343        // Extension entries:
344        // - `--no-extensions` disables configured + auto discovery but still allows CLI `-e` sources.
345        // - Deduplicate by canonical extension ID so that transpiled cache copies
346        //   in `~/.pi/agent/cache/modules/` don't cause command collisions with
347        //   the original source `.ts` extensions (Issue #37).
348        let extension_entries = dedupe_extension_entries_by_id(merge_resource_paths(
349            &[],
350            cli_extensions.extensions,
351            resolved.extensions,
352            !cli.no_extensions,
353        ));
354
355        // Load skills, prompt templates, and themes in parallel — they are independent
356        // filesystem walks that benefit from overlapped I/O on multi-core machines.
357        let agent_dir = Config::global_dir();
358        let cwd_buf = cwd.to_path_buf();
359        let (skills_join, prompts_join, themes_join) = std::thread::scope(|s| {
360            let cwd_s = &cwd_buf;
361            let agent_s = &agent_dir;
362            let skills_handle = s.spawn(move || {
363                load_skills(LoadSkillsOptions {
364                    cwd: cwd_s.clone(),
365                    agent_dir: agent_s.clone(),
366                    skill_paths,
367                    include_defaults: false,
368                })
369            });
370            let prompts_handle = s.spawn(move || {
371                load_prompt_templates(LoadPromptTemplatesOptions {
372                    cwd: cwd_s.clone(),
373                    agent_dir: agent_s.clone(),
374                    prompt_paths,
375                    include_defaults: false,
376                })
377            });
378            let themes_handle = s.spawn(move || {
379                load_themes(LoadThemesOptions {
380                    cwd: cwd_s.clone(),
381                    agent_dir: agent_s.clone(),
382                    theme_paths,
383                    include_defaults: false,
384                })
385            });
386            (
387                skills_handle.join(),
388                prompts_handle.join(),
389                themes_handle.join(),
390            )
391        });
392        let skills_result = skills_join.map_err(|payload| {
393            Error::config(format!(
394                "Skills loader thread panicked: {}",
395                panic_payload_message(payload)
396            ))
397        })?;
398        let prompt_templates = prompts_join.map_err(|payload| {
399            Error::config(format!(
400                "Prompt loader thread panicked: {}",
401                panic_payload_message(payload)
402            ))
403        })?;
404        let themes_result = themes_join.map_err(|payload| {
405            Error::config(format!(
406                "Theme loader thread panicked: {}",
407                panic_payload_message(payload)
408            ))
409        })?;
410        let (prompts, prompt_diagnostics) = dedupe_prompts(prompt_templates);
411        let (themes, theme_diagnostics) = dedupe_themes(themes_result.themes);
412        let mut theme_diags = themes_result.diagnostics;
413        theme_diags.extend(theme_diagnostics);
414        ensure_explicit_file_paths_loaded(
415            &explicit_skill_paths,
416            skills_result
417                .skills
418                .iter()
419                .map(|skill| skill.file_path.clone())
420                .collect(),
421            &skills_result.diagnostics,
422            ExplicitResourceKind::Skill,
423        )?;
424        ensure_explicit_file_paths_loaded(
425            &explicit_prompt_paths,
426            prompts
427                .iter()
428                .map(|prompt| prompt.file_path.clone())
429                .collect(),
430            &prompt_diagnostics,
431            ExplicitResourceKind::Prompt,
432        )?;
433        ensure_explicit_file_paths_loaded(
434            &explicit_theme_paths,
435            themes.iter().map(|theme| theme.file_path.clone()).collect(),
436            &theme_diags,
437            ExplicitResourceKind::Theme,
438        )?;
439
440        Ok(Self {
441            skills: skills_result.skills,
442            skill_diagnostics: skills_result.diagnostics,
443            prompts,
444            prompt_diagnostics,
445            themes,
446            theme_diagnostics: theme_diags,
447            extensions: extension_entries,
448            enable_skill_commands,
449        })
450    }
451
452    pub fn extend_with_paths(&mut self, cwd: &Path, paths: &ExtensionResourcePaths) -> Result<()> {
453        if paths.is_empty() {
454            return Ok(());
455        }
456
457        let agent_dir = Config::global_dir();
458        let cwd_buf = cwd.to_path_buf();
459
460        if !paths.skill_paths.is_empty() {
461            let skill_paths = dedupe_paths(paths.skill_paths.clone());
462            if !skill_paths.is_empty() {
463                let result = load_skills(LoadSkillsOptions {
464                    cwd: cwd_buf.clone(),
465                    agent_dir: agent_dir.clone(),
466                    skill_paths,
467                    include_defaults: false,
468                });
469
470                let mut existing_names: HashMap<String, PathBuf> = HashMap::new();
471                let mut existing_paths: HashSet<PathBuf> = HashSet::new();
472                for skill in &self.skills {
473                    existing_names.insert(skill.name.clone(), skill.file_path.clone());
474                    existing_paths.insert(canonical_identity_path(&skill.file_path));
475                }
476
477                let mut collisions = Vec::new();
478                for skill in result.skills {
479                    let real_path = canonical_identity_path(&skill.file_path);
480                    if existing_paths.contains(&real_path) {
481                        continue;
482                    }
483                    if let Some(winner_path) = existing_names.get(&skill.name) {
484                        collisions.push(ResourceDiagnostic {
485                            kind: DiagnosticKind::Collision,
486                            message: format!("name \"{}\" collision", skill.name),
487                            path: skill.file_path.clone(),
488                            collision: Some(CollisionInfo {
489                                resource_type: "skill".to_string(),
490                                name: skill.name.clone(),
491                                winner_path: winner_path.clone(),
492                                loser_path: skill.file_path.clone(),
493                            }),
494                        });
495                    } else {
496                        existing_names.insert(skill.name.clone(), skill.file_path.clone());
497                        existing_paths.insert(real_path);
498                        self.skills.push(skill);
499                    }
500                }
501
502                self.skill_diagnostics.extend(result.diagnostics);
503                self.skill_diagnostics.extend(collisions);
504            }
505        }
506
507        if !paths.prompt_paths.is_empty() {
508            let prompt_paths = dedupe_paths(paths.prompt_paths.clone());
509            if !prompt_paths.is_empty() {
510                let new_prompts = load_prompt_templates(LoadPromptTemplatesOptions {
511                    cwd: cwd_buf.clone(),
512                    agent_dir: agent_dir.clone(),
513                    prompt_paths,
514                    include_defaults: false,
515                });
516                if !new_prompts.is_empty() {
517                    let mut merged = self.prompts.clone();
518                    merged.extend(new_prompts);
519                    let (deduped, diagnostics) = dedupe_prompts(merged);
520                    self.prompts = deduped;
521                    self.prompt_diagnostics.extend(diagnostics);
522                }
523            }
524        }
525
526        if !paths.theme_paths.is_empty() {
527            let theme_paths = dedupe_paths(paths.theme_paths.clone());
528            if !theme_paths.is_empty() {
529                let themes_result = load_themes(LoadThemesOptions {
530                    cwd: cwd_buf,
531                    agent_dir,
532                    theme_paths,
533                    include_defaults: false,
534                });
535                let mut merged = self.themes.clone();
536                merged.extend(themes_result.themes);
537                let (deduped, diagnostics) = dedupe_themes(merged);
538                self.themes = deduped;
539                self.theme_diagnostics.extend(themes_result.diagnostics);
540                self.theme_diagnostics.extend(diagnostics);
541            }
542        }
543
544        Ok(())
545    }
546
547    pub fn extensions(&self) -> &[PathBuf] {
548        &self.extensions
549    }
550
551    pub fn skills(&self) -> &[Skill] {
552        &self.skills
553    }
554
555    pub fn prompts(&self) -> &[PromptTemplate] {
556        &self.prompts
557    }
558
559    pub fn skill_diagnostics(&self) -> &[ResourceDiagnostic] {
560        &self.skill_diagnostics
561    }
562
563    pub fn prompt_diagnostics(&self) -> &[ResourceDiagnostic] {
564        &self.prompt_diagnostics
565    }
566
567    pub fn themes(&self) -> &[ThemeResource] {
568        &self.themes
569    }
570
571    pub fn theme_diagnostics(&self) -> &[ResourceDiagnostic] {
572        &self.theme_diagnostics
573    }
574
575    pub fn resolve_theme(&self, selected: Option<&str>) -> Option<Theme> {
576        let selected = selected?;
577        let trimmed = selected.trim();
578        if trimmed.is_empty() {
579            return None;
580        }
581
582        let path = Path::new(trimmed);
583        if path.exists() {
584            let ext = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
585            let theme = match ext {
586                "json" => Theme::load(path),
587                "ini" | "theme" => load_legacy_ini_theme(path),
588                _ => Err(Error::config(format!(
589                    "Unsupported theme format: {}",
590                    path.display()
591                ))),
592            };
593            if let Ok(theme) = theme {
594                return Some(theme);
595            }
596        }
597
598        self.themes
599            .iter()
600            .find(|theme| theme.name.eq_ignore_ascii_case(trimmed))
601            .map(|theme| theme.theme.clone())
602    }
603
604    pub const fn enable_skill_commands(&self) -> bool {
605        self.enable_skill_commands
606    }
607
608    pub fn format_skills_for_prompt(&self) -> String {
609        format_skills_for_prompt(&self.skills)
610    }
611
612    pub fn list_commands(&self) -> Vec<Value> {
613        let mut commands = Vec::new();
614
615        for template in &self.prompts {
616            commands.push(json!({
617                "name": template.name,
618                "description": template.description,
619                "source": "template",
620                "location": template.source,
621                "path": template.file_path.display().to_string(),
622            }));
623        }
624
625        for skill in &self.skills {
626            commands.push(json!({
627                "name": format!("skill:{}", skill.name),
628                "description": skill.description,
629                "source": "skill",
630                "location": skill.source,
631                "path": skill.file_path.display().to_string(),
632            }));
633        }
634
635        commands
636    }
637
638    pub fn expand_input(&self, text: &str) -> String {
639        let mut expanded = text.to_string();
640        if self.enable_skill_commands {
641            expanded = expand_skill_command(&expanded, &self.skills);
642        }
643        expand_prompt_template(&expanded, &self.prompts)
644    }
645}
646
647// ============================================================================
648// Package resources
649// ============================================================================
650
651pub async fn discover_package_resources(manager: &PackageManager) -> Result<PackageResources> {
652    let entries = manager.list_packages().await.unwrap_or_default();
653    let mut resources = PackageResources::default();
654
655    for entry in entries {
656        let Some(root) = manager.installed_path(&entry.source, entry.scope).await? else {
657            continue;
658        };
659        if !root.exists() {
660            if let Err(err) = manager.install(&entry.source, entry.scope).await {
661                warn!("Failed to install {}: {err}", entry.source);
662                continue;
663            }
664        }
665
666        if !root.exists() {
667            continue;
668        }
669
670        if let Some(pi) = read_pi_manifest(&root)? {
671            append_resources_from_manifest(&mut resources, &root, &pi)?;
672        } else {
673            append_resources_from_defaults(&mut resources, &root);
674        }
675    }
676
677    Ok(resources)
678}
679
680fn read_pi_manifest(root: &Path) -> Result<Option<Value>> {
681    let manifest_path = root.join("package.json");
682    if !manifest_path.exists() {
683        return Ok(None);
684    }
685    let raw = fs::read_to_string(&manifest_path).map_err(|err| {
686        Error::config(format!(
687            "Failed to read package manifest {}: {err}",
688            manifest_path.display()
689        ))
690    })?;
691    let json: Value = serde_json::from_str(&raw).map_err(|err| {
692        Error::config(format!(
693            "Failed to parse package manifest {}: {err}",
694            manifest_path.display()
695        ))
696    })?;
697    match json.get("pi") {
698        Some(pi) if pi.is_object() => Ok(Some(pi.clone())),
699        Some(_) => Err(Error::config(format!(
700            "Invalid package manifest {}: `pi` must be an object",
701            manifest_path.display()
702        ))),
703        None => Ok(None),
704    }
705}
706
707fn append_resources_from_manifest(
708    resources: &mut PackageResources,
709    root: &Path,
710    pi: &Value,
711) -> Result<()> {
712    let Some(obj) = pi.as_object() else {
713        return Ok(());
714    };
715    append_resource_paths(
716        resources,
717        root,
718        obj.get("extensions"),
719        ResourceKind::Extensions,
720        "extensions",
721    )?;
722    append_resource_paths(
723        resources,
724        root,
725        obj.get("skills"),
726        ResourceKind::Skills,
727        "skills",
728    )?;
729    append_resource_paths(
730        resources,
731        root,
732        obj.get("prompts"),
733        ResourceKind::Prompts,
734        "prompts",
735    )?;
736    append_resource_paths(
737        resources,
738        root,
739        obj.get("themes"),
740        ResourceKind::Themes,
741        "themes",
742    )?;
743    Ok(())
744}
745
746fn append_resources_from_defaults(resources: &mut PackageResources, root: &Path) {
747    let candidates = [
748        ("extensions", ResourceKind::Extensions),
749        ("skills", ResourceKind::Skills),
750        ("prompts", ResourceKind::Prompts),
751        ("themes", ResourceKind::Themes),
752    ];
753
754    for (dir, kind) in candidates {
755        let path = root.join(dir);
756        if path.exists() {
757            match kind {
758                ResourceKind::Extensions => resources.extensions.push(path),
759                ResourceKind::Skills => resources.skills.push(path),
760                ResourceKind::Prompts => resources.prompts.push(path),
761                ResourceKind::Themes => resources.themes.push(path),
762            }
763        }
764    }
765}
766
767#[derive(Clone, Copy)]
768enum ResourceKind {
769    Extensions,
770    Skills,
771    Prompts,
772    Themes,
773}
774
775fn append_resource_paths(
776    resources: &mut PackageResources,
777    root: &Path,
778    value: Option<&Value>,
779    kind: ResourceKind,
780    field_name: &str,
781) -> Result<()> {
782    let Some(value) = value else {
783        return Ok(());
784    };
785    let manifest_path = root.join("package.json");
786    let paths = extract_manifest_string_list(&manifest_path, field_name, value)?;
787    if paths.is_empty() {
788        return Ok(());
789    }
790
791    for path in paths {
792        let resolved = resolve_manifest_resource_path(root, &manifest_path, field_name, &path)?;
793        match kind {
794            ResourceKind::Extensions => resources.extensions.push(resolved),
795            ResourceKind::Skills => resources.skills.push(resolved),
796            ResourceKind::Prompts => resources.prompts.push(resolved),
797            ResourceKind::Themes => resources.themes.push(resolved),
798        }
799    }
800    Ok(())
801}
802
803fn extract_manifest_string_list(
804    manifest_path: &Path,
805    field_name: &str,
806    value: &Value,
807) -> Result<Vec<String>> {
808    match value {
809        Value::String(s) => Ok(vec![validate_manifest_resource_string(
810            manifest_path,
811            field_name,
812            s,
813        )?]),
814        Value::Array(items) => items
815            .iter()
816            .map(|item| {
817                item.as_str().ok_or_else(|| {
818                    Error::config(format!(
819                        "Invalid package manifest {}: `pi.{field_name}` must be a string or array of strings",
820                        manifest_path.display()
821                    ))
822                }).and_then(|path| validate_manifest_resource_string(manifest_path, field_name, path))
823            })
824            .collect(),
825        _ => Err(Error::config(format!(
826            "Invalid package manifest {}: `pi.{field_name}` must be a string or array of strings",
827            manifest_path.display()
828        ))),
829    }
830}
831
832fn validate_manifest_resource_string(
833    manifest_path: &Path,
834    field_name: &str,
835    value: &str,
836) -> Result<String> {
837    let trimmed = value.trim();
838    if trimmed.is_empty() {
839        return Err(Error::config(format!(
840            "Invalid package manifest {}: `pi.{field_name}` entries must be non-empty paths",
841            manifest_path.display()
842        )));
843    }
844    Ok(trimmed.to_string())
845}
846
847fn resolve_manifest_resource_path(
848    root: &Path,
849    manifest_path: &Path,
850    field_name: &str,
851    raw_path: &str,
852) -> Result<PathBuf> {
853    let relative = Path::new(raw_path);
854    if relative.is_absolute() {
855        return Err(Error::config(format!(
856            "Invalid package manifest {}: `pi.{field_name}` paths must stay within the package root",
857            manifest_path.display()
858        )));
859    }
860
861    let mut depth = 0usize;
862    for component in relative.components() {
863        match component {
864            Component::CurDir => {}
865            Component::Normal(_) => depth = depth.saturating_add(1),
866            Component::ParentDir => {
867                if depth == 0 {
868                    return Err(Error::config(format!(
869                        "Invalid package manifest {}: `pi.{field_name}` paths must stay within the package root",
870                        manifest_path.display()
871                    )));
872                }
873                depth -= 1;
874            }
875            Component::RootDir | Component::Prefix(_) => {
876                return Err(Error::config(format!(
877                    "Invalid package manifest {}: `pi.{field_name}` paths must stay within the package root",
878                    manifest_path.display()
879                )));
880            }
881        }
882    }
883
884    let resolved = root.join(relative);
885    if resolved.exists() && !is_under_path(&resolved, root) {
886        return Err(Error::config(format!(
887            "Invalid package manifest {}: `pi.{field_name}` paths must stay within the package root",
888            manifest_path.display()
889        )));
890    }
891    Ok(resolved)
892}
893
894// ============================================================================
895// Skills loader
896// ============================================================================
897
898#[allow(clippy::too_many_lines, clippy::items_after_statements)]
899pub fn load_skills(options: LoadSkillsOptions) -> LoadSkillsResult {
900    let mut skill_map: HashMap<String, Skill> = HashMap::new();
901    let mut real_paths: HashSet<PathBuf> = HashSet::new();
902    let mut visited_dirs: HashSet<PathBuf> = HashSet::new();
903    let mut diagnostics = Vec::new();
904    let mut collisions = Vec::new();
905
906    // Helper to merge skills into the map, tracking collisions
907    fn merge_skills(
908        result: LoadSkillsResult,
909        skill_map: &mut HashMap<String, Skill>,
910        real_paths: &mut HashSet<PathBuf>,
911        diagnostics: &mut Vec<ResourceDiagnostic>,
912        collisions: &mut Vec<ResourceDiagnostic>,
913    ) {
914        diagnostics.extend(result.diagnostics);
915        for skill in result.skills {
916            let real_path = canonical_identity_path(&skill.file_path);
917            if real_paths.contains(&real_path) {
918                continue;
919            }
920
921            if let Some(existing) = skill_map.get(&skill.name) {
922                collisions.push(ResourceDiagnostic {
923                    kind: DiagnosticKind::Collision,
924                    message: format!("name \"{}\" collision", skill.name),
925                    path: skill.file_path.clone(),
926                    collision: Some(CollisionInfo {
927                        resource_type: "skill".to_string(),
928                        name: skill.name.clone(),
929                        winner_path: existing.file_path.clone(),
930                        loser_path: skill.file_path.clone(),
931                    }),
932                });
933            } else {
934                real_paths.insert(real_path);
935                skill_map.insert(skill.name.clone(), skill);
936            }
937        }
938    }
939
940    if options.include_defaults {
941        merge_skills(
942            load_skills_from_dir_with_visited(
943                options.cwd.join(Config::project_dir()).join("skills"),
944                "project".to_string(),
945                true,
946                &mut visited_dirs,
947            ),
948            &mut skill_map,
949            &mut real_paths,
950            &mut diagnostics,
951            &mut collisions,
952        );
953        merge_skills(
954            load_skills_from_dir_with_visited(
955                options.agent_dir.join("skills"),
956                "user".to_string(),
957                true,
958                &mut visited_dirs,
959            ),
960            &mut skill_map,
961            &mut real_paths,
962            &mut diagnostics,
963            &mut collisions,
964        );
965    }
966
967    for resolved in options.skill_paths {
968        if !resolved.exists() {
969            diagnostics.push(ResourceDiagnostic {
970                kind: DiagnosticKind::Warning,
971                message: "skill path does not exist".to_string(),
972                path: resolved,
973                collision: None,
974            });
975            continue;
976        }
977
978        let source = if options.include_defaults {
979            "path".to_string()
980        } else if is_under_path(&resolved, &options.agent_dir.join("skills")) {
981            "user".to_string()
982        } else if is_under_path(
983            &resolved,
984            &options.cwd.join(Config::project_dir()).join("skills"),
985        ) {
986            "project".to_string()
987        } else {
988            "path".to_string()
989        };
990
991        match fs::metadata(&resolved) {
992            Ok(meta) if meta.is_dir() => {
993                merge_skills(
994                    load_skills_from_dir_with_visited(resolved, source, true, &mut visited_dirs),
995                    &mut skill_map,
996                    &mut real_paths,
997                    &mut diagnostics,
998                    &mut collisions,
999                );
1000            }
1001            Ok(meta) if meta.is_file() && resolved.extension().is_some_and(|ext| ext == "md") => {
1002                let result = load_skill_from_file(&resolved, source);
1003                if let Some(skill) = result.skill {
1004                    merge_skills(
1005                        LoadSkillsResult {
1006                            skills: vec![skill],
1007                            diagnostics: result.diagnostics,
1008                        },
1009                        &mut skill_map,
1010                        &mut real_paths,
1011                        &mut diagnostics,
1012                        &mut collisions,
1013                    );
1014                } else {
1015                    diagnostics.extend(result.diagnostics);
1016                }
1017            }
1018            Ok(_) => {
1019                diagnostics.push(ResourceDiagnostic {
1020                    kind: DiagnosticKind::Warning,
1021                    message: "skill path is not a markdown file".to_string(),
1022                    path: resolved,
1023                    collision: None,
1024                });
1025            }
1026            Err(err) => diagnostics.push(ResourceDiagnostic {
1027                kind: DiagnosticKind::Warning,
1028                message: format!("failed to read skill path: {err}"),
1029                path: resolved,
1030                collision: None,
1031            }),
1032        }
1033    }
1034
1035    diagnostics.extend(collisions);
1036
1037    let mut skills: Vec<Skill> = skill_map.into_values().collect();
1038    skills.sort_by(|a, b| a.name.cmp(&b.name));
1039
1040    LoadSkillsResult {
1041        skills,
1042        diagnostics,
1043    }
1044}
1045
1046fn load_skills_from_dir(
1047    dir: PathBuf,
1048    source: String,
1049    include_root_files: bool,
1050) -> LoadSkillsResult {
1051    let mut visited_dirs = HashSet::new();
1052    load_skills_from_dir_with_visited(dir, source, include_root_files, &mut visited_dirs)
1053}
1054
1055fn load_skills_from_dir_with_visited(
1056    dir: PathBuf,
1057    source: String,
1058    include_root_files: bool,
1059    visited_dirs: &mut HashSet<PathBuf>,
1060) -> LoadSkillsResult {
1061    let mut skills = Vec::new();
1062    let mut diagnostics = Vec::new();
1063    let mut stack = vec![(dir, source, include_root_files)];
1064
1065    while let Some((current_dir, current_source, current_include_root)) = stack.pop() {
1066        if !current_dir.exists() {
1067            continue;
1068        }
1069
1070        // Prevent unbounded recursion for symlink cycles.
1071        let canonical_dir = fs::canonicalize(&current_dir).unwrap_or_else(|_| current_dir.clone());
1072        if !visited_dirs.insert(canonical_dir) {
1073            continue;
1074        }
1075
1076        let mut child_dirs = Vec::new();
1077
1078        for full_path in read_dir_sorted_paths(&current_dir) {
1079            let file_name = full_path.file_name().unwrap_or_default().to_string_lossy();
1080
1081            if file_name.starts_with('.') || file_name == "node_modules" {
1082                continue;
1083            }
1084
1085            let (is_dir, is_file) = resolved_path_kind(&full_path);
1086
1087            if is_dir {
1088                child_dirs.push(full_path);
1089                continue;
1090            }
1091
1092            if !is_file {
1093                continue;
1094            }
1095
1096            let is_root_md = current_include_root && file_name.ends_with(".md");
1097            let is_skill_md = !current_include_root && file_name == "SKILL.md";
1098            if !is_root_md && !is_skill_md {
1099                continue;
1100            }
1101
1102            let result = load_skill_from_file(&full_path, current_source.clone());
1103            if let Some(skill) = result.skill {
1104                skills.push(skill);
1105            }
1106            diagnostics.extend(result.diagnostics);
1107        }
1108
1109        for child_dir in child_dirs.into_iter().rev() {
1110            stack.push((child_dir, current_source.clone(), false));
1111        }
1112    }
1113
1114    LoadSkillsResult {
1115        skills,
1116        diagnostics,
1117    }
1118}
1119
1120struct LoadSkillFileResult {
1121    skill: Option<Skill>,
1122    diagnostics: Vec<ResourceDiagnostic>,
1123}
1124
1125fn load_skill_from_file(path: &Path, source: String) -> LoadSkillFileResult {
1126    let mut diagnostics = Vec::new();
1127
1128    let Ok(raw) = fs::read_to_string(path) else {
1129        diagnostics.push(ResourceDiagnostic {
1130            kind: DiagnosticKind::Warning,
1131            message: "failed to parse skill file".to_string(),
1132            path: path.to_path_buf(),
1133            collision: None,
1134        });
1135        return LoadSkillFileResult {
1136            skill: None,
1137            diagnostics,
1138        };
1139    };
1140
1141    let parsed = parse_frontmatter(&raw);
1142    let frontmatter = &parsed.frontmatter;
1143
1144    let field_errors = validate_frontmatter_fields(frontmatter.keys());
1145    for error in field_errors {
1146        diagnostics.push(ResourceDiagnostic {
1147            kind: DiagnosticKind::Warning,
1148            message: error,
1149            path: path.to_path_buf(),
1150            collision: None,
1151        });
1152    }
1153
1154    let description = frontmatter.get("description").cloned().unwrap_or_default();
1155    let desc_errors = validate_description(&description);
1156    for error in desc_errors {
1157        diagnostics.push(ResourceDiagnostic {
1158            kind: DiagnosticKind::Warning,
1159            message: error,
1160            path: path.to_path_buf(),
1161            collision: None,
1162        });
1163    }
1164
1165    if description.trim().is_empty() {
1166        return LoadSkillFileResult {
1167            skill: None,
1168            diagnostics,
1169        };
1170    }
1171
1172    let base_dir = path
1173        .parent()
1174        .unwrap_or_else(|| Path::new("."))
1175        .to_path_buf();
1176    let parent_dir = base_dir
1177        .file_name()
1178        .and_then(|s| s.to_str())
1179        .unwrap_or("")
1180        .to_string();
1181    let name = frontmatter
1182        .get("name")
1183        .cloned()
1184        .unwrap_or_else(|| parent_dir.clone());
1185
1186    let name_errors = validate_name(&name, &parent_dir);
1187    for error in name_errors {
1188        diagnostics.push(ResourceDiagnostic {
1189            kind: DiagnosticKind::Warning,
1190            message: error,
1191            path: path.to_path_buf(),
1192            collision: None,
1193        });
1194    }
1195
1196    let disable_model_invocation = frontmatter
1197        .get("disable-model-invocation")
1198        .is_some_and(|v| v.eq_ignore_ascii_case("true"));
1199
1200    LoadSkillFileResult {
1201        skill: Some(Skill {
1202            name,
1203            description,
1204            file_path: path.to_path_buf(),
1205            base_dir,
1206            source,
1207            disable_model_invocation,
1208        }),
1209        diagnostics,
1210    }
1211}
1212
1213fn validate_name(name: &str, parent_dir: &str) -> Vec<String> {
1214    let mut errors = Vec::new();
1215
1216    if name != parent_dir {
1217        errors.push(format!(
1218            "name \"{name}\" does not match parent directory \"{parent_dir}\""
1219        ));
1220    }
1221
1222    if name.len() > MAX_SKILL_NAME_LEN {
1223        errors.push(format!(
1224            "name exceeds {MAX_SKILL_NAME_LEN} characters ({})",
1225            name.len()
1226        ));
1227    }
1228
1229    if !name
1230        .chars()
1231        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1232    {
1233        errors.push(
1234            "name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)"
1235                .to_string(),
1236        );
1237    }
1238
1239    if name.starts_with('-') || name.ends_with('-') {
1240        errors.push("name must not start or end with a hyphen".to_string());
1241    }
1242
1243    if name.contains("--") {
1244        errors.push("name must not contain consecutive hyphens".to_string());
1245    }
1246
1247    errors
1248}
1249
1250fn validate_description(description: &str) -> Vec<String> {
1251    let mut errors = Vec::new();
1252    if description.trim().is_empty() {
1253        errors.push("description is required".to_string());
1254    } else if description.len() > MAX_SKILL_DESC_LEN {
1255        errors.push(format!(
1256            "description exceeds {MAX_SKILL_DESC_LEN} characters ({})",
1257            description.len()
1258        ));
1259    }
1260    errors
1261}
1262
1263fn validate_frontmatter_fields<'a, I>(keys: I) -> Vec<String>
1264where
1265    I: IntoIterator<Item = &'a String>,
1266{
1267    let allowed: HashSet<&str> = ALLOWED_SKILL_FRONTMATTER.into_iter().collect();
1268    let mut errors = Vec::new();
1269    for key in keys {
1270        if !allowed.contains(key.as_str()) {
1271            errors.push(format!("unknown frontmatter field \"{key}\""));
1272        }
1273    }
1274    errors
1275}
1276
1277pub fn format_skills_for_prompt(skills: &[Skill]) -> String {
1278    let visible: Vec<&Skill> = skills
1279        .iter()
1280        .filter(|s| !s.disable_model_invocation)
1281        .collect();
1282    if visible.is_empty() {
1283        return String::new();
1284    }
1285
1286    let mut lines = vec![
1287        "\n\nThe following skills provide specialized instructions for specific tasks.".to_string(),
1288        "Use the read tool to load a skill's file when the task matches its description."
1289            .to_string(),
1290        "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(),
1291        String::new(),
1292        "<available_skills>".to_string(),
1293    ];
1294
1295    for skill in visible {
1296        lines.push("  <skill>".to_string());
1297        lines.push(format!("    <name>{}</name>", escape_xml(&skill.name)));
1298        lines.push(format!(
1299            "    <description>{}</description>",
1300            escape_xml(&skill.description)
1301        ));
1302        lines.push(format!(
1303            "    <location>{}</location>",
1304            escape_xml(&skill.file_path.display().to_string())
1305        ));
1306        lines.push("  </skill>".to_string());
1307    }
1308
1309    lines.push("</available_skills>".to_string());
1310    lines.join("\n")
1311}
1312
1313fn escape_xml(input: &str) -> String {
1314    input
1315        .replace('&', "&amp;")
1316        .replace('<', "&lt;")
1317        .replace('>', "&gt;")
1318        .replace('"', "&quot;")
1319        .replace('\'', "&apos;")
1320}
1321
1322// ============================================================================
1323// Prompt templates loader and expansion
1324// ============================================================================
1325
1326pub fn load_prompt_templates(options: LoadPromptTemplatesOptions) -> Vec<PromptTemplate> {
1327    let mut templates = Vec::new();
1328    let user_dir = options.agent_dir.join("prompts");
1329    let project_dir = options.cwd.join(Config::project_dir()).join("prompts");
1330
1331    if options.include_defaults {
1332        templates.extend(load_templates_from_dir(
1333            &project_dir,
1334            "project",
1335            "(project)",
1336        ));
1337        templates.extend(load_templates_from_dir(&user_dir, "user", "(user)"));
1338    }
1339
1340    for path in options.prompt_paths {
1341        if !path.exists() {
1342            continue;
1343        }
1344
1345        let source_info = if options.include_defaults {
1346            ("path", build_path_source_label(&path))
1347        } else if is_under_path(&path, &user_dir) {
1348            ("user", "(user)".to_string())
1349        } else if is_under_path(&path, &project_dir) {
1350            ("project", "(project)".to_string())
1351        } else {
1352            ("path", build_path_source_label(&path))
1353        };
1354
1355        let (source, label) = source_info;
1356
1357        match fs::metadata(&path) {
1358            Ok(meta) if meta.is_dir() => {
1359                templates.extend(load_templates_from_dir(&path, source, &label));
1360            }
1361            Ok(meta) if meta.is_file() && path.extension().is_some_and(|ext| ext == "md") => {
1362                if let Some(template) = load_template_from_file(&path, source, &label) {
1363                    templates.push(template);
1364                }
1365            }
1366            _ => {}
1367        }
1368    }
1369
1370    templates
1371}
1372
1373fn load_templates_from_dir(dir: &Path, source: &str, label: &str) -> Vec<PromptTemplate> {
1374    let mut templates = Vec::new();
1375    if !dir.exists() {
1376        return templates;
1377    }
1378
1379    for full_path in read_dir_sorted_paths(dir) {
1380        let (_, is_file) = resolved_path_kind(&full_path);
1381
1382        if is_file && full_path.extension().is_some_and(|ext| ext == "md") {
1383            if let Some(template) = load_template_from_file(&full_path, source, label) {
1384                templates.push(template);
1385            }
1386        }
1387    }
1388
1389    templates
1390}
1391
1392fn load_template_from_file(path: &Path, source: &str, label: &str) -> Option<PromptTemplate> {
1393    let raw = fs::read_to_string(path).ok()?;
1394    let parsed = parse_frontmatter(&raw);
1395    let mut description = parsed
1396        .frontmatter
1397        .get("description")
1398        .cloned()
1399        .unwrap_or_default();
1400
1401    if description.is_empty() {
1402        if let Some(first_line) = parsed.body.lines().find(|line| !line.trim().is_empty()) {
1403            let trimmed = first_line.trim();
1404            let truncated = if trimmed.chars().count() > 60 {
1405                let s: String = trimmed.chars().take(57).collect();
1406                format!("{s}...")
1407            } else {
1408                trimmed.to_string()
1409            };
1410            description = truncated;
1411        }
1412    }
1413
1414    if description.is_empty() {
1415        description = label.to_string();
1416    } else {
1417        description = format!("{description} {label}");
1418    }
1419
1420    let name = path
1421        .file_stem()
1422        .and_then(|s| s.to_str())
1423        .unwrap_or("template")
1424        .to_string();
1425
1426    Some(PromptTemplate {
1427        name,
1428        description,
1429        content: parsed.body,
1430        source: source.to_string(),
1431        file_path: path.to_path_buf(),
1432    })
1433}
1434
1435// ============================================================================
1436// Themes loader
1437// ============================================================================
1438
1439pub fn load_themes(options: LoadThemesOptions) -> LoadThemesResult {
1440    let mut themes = Vec::new();
1441    let mut diagnostics = Vec::new();
1442
1443    let user_dir = options.agent_dir.join("themes");
1444    let project_dir = options.cwd.join(Config::project_dir()).join("themes");
1445
1446    if options.include_defaults {
1447        themes.extend(load_themes_from_dir(
1448            &project_dir,
1449            "project",
1450            "(project)",
1451            &mut diagnostics,
1452        ));
1453        themes.extend(load_themes_from_dir(
1454            &user_dir,
1455            "user",
1456            "(user)",
1457            &mut diagnostics,
1458        ));
1459    }
1460
1461    for path in options.theme_paths {
1462        if !path.exists() {
1463            continue;
1464        }
1465
1466        let source_info = if options.include_defaults {
1467            ("path", build_path_source_label(&path))
1468        } else if is_under_path(&path, &user_dir) {
1469            ("user", "(user)".to_string())
1470        } else if is_under_path(&path, &project_dir) {
1471            ("project", "(project)".to_string())
1472        } else {
1473            ("path", build_path_source_label(&path))
1474        };
1475
1476        let (source, label) = source_info;
1477
1478        match fs::metadata(&path) {
1479            Ok(meta) if meta.is_dir() => {
1480                themes.extend(load_themes_from_dir(
1481                    &path,
1482                    source,
1483                    &label,
1484                    &mut diagnostics,
1485                ));
1486            }
1487            Ok(meta) if meta.is_file() && is_theme_file(&path) => {
1488                if let Some(theme) = load_theme_from_file(&path, source, &label, &mut diagnostics) {
1489                    themes.push(theme);
1490                }
1491            }
1492            _ => {}
1493        }
1494    }
1495
1496    LoadThemesResult {
1497        themes,
1498        diagnostics,
1499    }
1500}
1501
1502fn load_themes_from_dir(
1503    dir: &Path,
1504    source: &str,
1505    label: &str,
1506    diagnostics: &mut Vec<ResourceDiagnostic>,
1507) -> Vec<ThemeResource> {
1508    let mut themes = Vec::new();
1509    if !dir.exists() {
1510        return themes;
1511    }
1512
1513    for full_path in read_dir_sorted_paths(dir) {
1514        let (_, is_file) = resolved_path_kind(&full_path);
1515
1516        if is_file && is_theme_file(&full_path) {
1517            if let Some(theme) = load_theme_from_file(&full_path, source, label, diagnostics) {
1518                themes.push(theme);
1519            }
1520        }
1521    }
1522
1523    themes
1524}
1525
1526fn is_theme_file(path: &Path) -> bool {
1527    matches!(
1528        path.extension().and_then(|ext| ext.to_str()),
1529        Some("json" | "ini" | "theme")
1530    )
1531}
1532
1533fn load_theme_from_file(
1534    path: &Path,
1535    source: &str,
1536    label: &str,
1537    diagnostics: &mut Vec<ResourceDiagnostic>,
1538) -> Option<ThemeResource> {
1539    let name = path
1540        .file_stem()
1541        .and_then(|s| s.to_str())
1542        .unwrap_or("theme")
1543        .to_string();
1544
1545    let ext = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
1546    let theme = match ext {
1547        "json" => Theme::load(path),
1548        "ini" | "theme" => load_legacy_ini_theme(path),
1549        _ => return None,
1550    };
1551
1552    match theme {
1553        Ok(theme) => Some(ThemeResource {
1554            name,
1555            theme,
1556            source: format!("{source}:{label}"),
1557            file_path: path.to_path_buf(),
1558        }),
1559        Err(err) => {
1560            diagnostics.push(ResourceDiagnostic {
1561                kind: DiagnosticKind::Warning,
1562                message: format!(
1563                    "Failed to load theme \"{name}\" ({}): {err}",
1564                    path.display()
1565                ),
1566                path: path.to_path_buf(),
1567                collision: None,
1568            });
1569            None
1570        }
1571    }
1572}
1573
1574fn load_legacy_ini_theme(path: &Path) -> Result<Theme> {
1575    let content = fs::read_to_string(path)?;
1576    let mut theme = Theme::dark();
1577    if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
1578        theme.name = name.to_string();
1579    }
1580
1581    let mut first_color = None;
1582    for token in content.split_whitespace() {
1583        let Some(raw) = token.strip_prefix('#') else {
1584            continue;
1585        };
1586        let trimmed = raw.trim_end_matches(|c: char| !c.is_ascii_hexdigit());
1587        if trimmed.len() != 6 || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
1588            return Err(Error::config(format!(
1589                "Invalid color '{token}' in theme file {}",
1590                path.display()
1591            )));
1592        }
1593        if first_color.is_none() {
1594            first_color = Some(format!("#{trimmed}"));
1595        }
1596    }
1597
1598    if let Some(accent) = first_color {
1599        theme.colors.accent = accent;
1600    }
1601
1602    Ok(theme)
1603}
1604
1605fn build_path_source_label(path: &Path) -> String {
1606    let base = path.file_stem().and_then(|s| s.to_str()).unwrap_or("path");
1607    format!("(path:{base})")
1608}
1609
1610pub fn dedupe_prompts(
1611    prompts: Vec<PromptTemplate>,
1612) -> (Vec<PromptTemplate>, Vec<ResourceDiagnostic>) {
1613    let mut seen: HashMap<String, PromptTemplate> = HashMap::new();
1614    let mut diagnostics = Vec::new();
1615
1616    for prompt in prompts {
1617        let real_path = canonical_identity_path(&prompt.file_path);
1618        if let Some(existing) = seen.get(&prompt.name) {
1619            if canonical_identity_path(&existing.file_path) == real_path {
1620                continue;
1621            }
1622            diagnostics.push(ResourceDiagnostic {
1623                kind: DiagnosticKind::Collision,
1624                message: format!("name \"/{}\" collision", prompt.name),
1625                path: prompt.file_path.clone(),
1626                collision: Some(CollisionInfo {
1627                    resource_type: "prompt".to_string(),
1628                    name: prompt.name.clone(),
1629                    winner_path: existing.file_path.clone(),
1630                    loser_path: prompt.file_path.clone(),
1631                }),
1632            });
1633            continue;
1634        }
1635        seen.insert(prompt.name.clone(), prompt);
1636    }
1637
1638    let mut prompts: Vec<PromptTemplate> = seen.into_values().collect();
1639    prompts.sort_by(|a, b| a.name.cmp(&b.name));
1640    (prompts, diagnostics)
1641}
1642
1643pub fn dedupe_themes(themes: Vec<ThemeResource>) -> (Vec<ThemeResource>, Vec<ResourceDiagnostic>) {
1644    let mut seen: HashMap<String, ThemeResource> = HashMap::new();
1645    let mut diagnostics = Vec::new();
1646
1647    for theme in themes {
1648        let key = theme.name.to_ascii_lowercase();
1649        let real_path = canonical_identity_path(&theme.file_path);
1650        if let Some(existing) = seen.get(&key) {
1651            if canonical_identity_path(&existing.file_path) == real_path {
1652                continue;
1653            }
1654            diagnostics.push(ResourceDiagnostic {
1655                kind: DiagnosticKind::Collision,
1656                message: format!("theme \"{}\" collision", theme.name),
1657                path: theme.file_path.clone(),
1658                collision: Some(CollisionInfo {
1659                    resource_type: "theme".to_string(),
1660                    name: theme.name.clone(),
1661                    winner_path: existing.file_path.clone(),
1662                    loser_path: theme.file_path.clone(),
1663                }),
1664            });
1665            continue;
1666        }
1667        seen.insert(key, theme);
1668    }
1669
1670    let mut themes: Vec<ThemeResource> = seen.into_values().collect();
1671    themes.sort_by(|a, b| {
1672        a.name
1673            .to_ascii_lowercase()
1674            .cmp(&b.name.to_ascii_lowercase())
1675    });
1676    (themes, diagnostics)
1677}
1678
1679pub fn parse_command_args(args: &str) -> Vec<String> {
1680    let mut out = Vec::new();
1681    let mut current = String::new();
1682    let mut in_quote: Option<char> = None;
1683    let mut just_closed_quote = false;
1684
1685    for ch in args.chars() {
1686        if let Some(quote) = in_quote {
1687            if ch == quote {
1688                in_quote = None;
1689                just_closed_quote = true;
1690            } else {
1691                current.push(ch);
1692            }
1693            continue;
1694        }
1695
1696        // Treat quotes as delimiters only at the start of a token so embedded
1697        // apostrophes in natural-language input stay literal.
1698        if (ch == '"' || ch == '\'') && current.is_empty() {
1699            in_quote = Some(ch);
1700        } else if ch.is_whitespace() {
1701            if !current.is_empty() || just_closed_quote {
1702                out.push(current.clone());
1703                current.clear();
1704            }
1705            just_closed_quote = false;
1706        } else {
1707            current.push(ch);
1708            just_closed_quote = false;
1709        }
1710    }
1711
1712    if !current.is_empty() || just_closed_quote {
1713        out.push(current);
1714    }
1715
1716    out
1717}
1718
1719fn split_command_name_and_args(text: &str, prefix_len: usize) -> (&str, &str) {
1720    let body = &text[prefix_len..];
1721    let Some((idx, _)) = body.char_indices().find(|(_, ch)| ch.is_whitespace()) else {
1722        return (body, "");
1723    };
1724
1725    let args_start = prefix_len + idx;
1726    let name = &text[prefix_len..args_start];
1727    let args = text[args_start..].trim_start_matches(char::is_whitespace);
1728    (name, args)
1729}
1730
1731/// Cached regex for positional `$1`, `$2`, … substitution.
1732fn positional_arg_regex() -> &'static regex::Regex {
1733    static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
1734    RE.get_or_init(|| regex::Regex::new(r"\$(\d+)").expect("positional arg regex"))
1735}
1736
1737/// Cached regex for `${@:start}` or `${@:start:length}` substitution.
1738fn slice_arg_regex() -> &'static regex::Regex {
1739    static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
1740    RE.get_or_init(|| regex::Regex::new(r"\$\{@:(\d+)(?::(\d+))?\}").expect("slice arg regex"))
1741}
1742
1743#[allow(clippy::option_if_let_else)] // Clearer with if-let than map_or_else in the closure
1744pub fn substitute_args(content: &str, args: &[String]) -> String {
1745    let mut result = content.to_string();
1746
1747    // Positional $1, $2, ...
1748    result = replace_regex(&result, positional_arg_regex(), |caps| {
1749        let idx = caps[1].parse::<usize>().unwrap_or(0);
1750        if idx == 0 {
1751            String::new()
1752        } else {
1753            args.get(idx.saturating_sub(1)).cloned().unwrap_or_default()
1754        }
1755    });
1756
1757    // ${@:start} or ${@:start:length}
1758    result = replace_regex(&result, slice_arg_regex(), |caps| {
1759        let mut start = caps[1].parse::<usize>().unwrap_or(1);
1760        if start == 0 {
1761            start = 1;
1762        }
1763        let start_idx = start.saturating_sub(1);
1764        let maybe_len = caps.get(2).and_then(|m| m.as_str().parse::<usize>().ok());
1765        let slice = maybe_len.map_or_else(
1766            || args.get(start_idx..).unwrap_or(&[]).to_vec(),
1767            |len| {
1768                let end = start_idx.saturating_add(len).min(args.len());
1769                args.get(start_idx..end).unwrap_or(&[]).to_vec()
1770            },
1771        );
1772        slice.join(" ")
1773    });
1774
1775    let all_args = args.join(" ");
1776    result = result.replace("$ARGUMENTS", &all_args);
1777    result = result.replace("$@", &all_args);
1778    result
1779}
1780
1781pub fn expand_prompt_template(text: &str, templates: &[PromptTemplate]) -> String {
1782    if !text.starts_with('/') {
1783        return text.to_string();
1784    }
1785    let (name, args) = split_command_name_and_args(text, 1);
1786
1787    if let Some(template) = templates.iter().find(|t| t.name == name) {
1788        let args = parse_command_args(args);
1789        return substitute_args(&template.content, &args);
1790    }
1791
1792    text.to_string()
1793}
1794
1795fn expand_skill_command(text: &str, skills: &[Skill]) -> String {
1796    if !text.starts_with("/skill:") {
1797        return text.to_string();
1798    }
1799
1800    let (name, args) = split_command_name_and_args(text, 7);
1801
1802    let Some(skill) = skills.iter().find(|s| s.name == name) else {
1803        return text.to_string();
1804    };
1805
1806    match fs::read_to_string(&skill.file_path) {
1807        Ok(content) => {
1808            let body = strip_frontmatter(&content).trim().to_string();
1809            let block = format!(
1810                "<skill name=\"{}\" location=\"{}\">\nReferences are relative to {}.\n\n{}\n</skill>",
1811                skill.name,
1812                skill.file_path.display(),
1813                skill.base_dir.display(),
1814                body
1815            );
1816            if args.is_empty() {
1817                block
1818            } else {
1819                format!("{block}\n\n{args}")
1820            }
1821        }
1822        Err(err) => {
1823            eprintln!(
1824                "Warning: Failed to read skill {}: {err}",
1825                skill.file_path.display()
1826            );
1827            text.to_string()
1828        }
1829    }
1830}
1831
1832// ============================================================================
1833// Frontmatter parsing helpers
1834// ============================================================================
1835
1836struct ParsedFrontmatter {
1837    frontmatter: HashMap<String, String>,
1838    body: String,
1839}
1840
1841fn parse_frontmatter(raw: &str) -> ParsedFrontmatter {
1842    let mut lines = raw.lines();
1843    let Some(first) = lines.next() else {
1844        return ParsedFrontmatter {
1845            frontmatter: HashMap::new(),
1846            body: String::new(),
1847        };
1848    };
1849
1850    if first.trim() != "---" {
1851        return ParsedFrontmatter {
1852            frontmatter: HashMap::new(),
1853            body: raw.to_string(),
1854        };
1855    }
1856
1857    let mut front_lines = Vec::new();
1858    let mut body_lines = Vec::new();
1859    let mut in_frontmatter = true;
1860    for line in lines {
1861        if in_frontmatter {
1862            if line.trim() == "---" {
1863                in_frontmatter = false;
1864                continue;
1865            }
1866            front_lines.push(line);
1867        } else {
1868            body_lines.push(line);
1869        }
1870    }
1871
1872    if in_frontmatter {
1873        return ParsedFrontmatter {
1874            frontmatter: HashMap::new(),
1875            body: raw.to_string(),
1876        };
1877    }
1878
1879    ParsedFrontmatter {
1880        frontmatter: parse_frontmatter_lines(&front_lines),
1881        body: body_lines.join("\n"),
1882    }
1883}
1884
1885fn parse_frontmatter_lines(lines: &[&str]) -> HashMap<String, String> {
1886    let mut map = HashMap::new();
1887    for line in lines {
1888        let trimmed = line.trim();
1889        if trimmed.is_empty() || trimmed.starts_with('#') {
1890            continue;
1891        }
1892        let Some((key, value)) = trimmed.split_once(':') else {
1893            continue;
1894        };
1895        let key = key.trim();
1896        if key.is_empty() {
1897            continue;
1898        }
1899        let value = value.trim().trim_matches('"').trim_matches('\'');
1900        map.insert(key.to_string(), value.to_string());
1901    }
1902    map
1903}
1904
1905fn strip_frontmatter(raw: &str) -> String {
1906    parse_frontmatter(raw).body
1907}
1908
1909// ============================================================================
1910// Misc helpers
1911// ============================================================================
1912
1913fn resolve_path(input: &str, cwd: &Path) -> PathBuf {
1914    let trimmed = input.trim();
1915    if trimmed == "~" {
1916        return dirs::home_dir().unwrap_or_else(|| cwd.to_path_buf());
1917    }
1918    if let Some(rest) = trimmed.strip_prefix("~/") {
1919        return dirs::home_dir()
1920            .unwrap_or_else(|| cwd.to_path_buf())
1921            .join(rest);
1922    }
1923    if trimmed.starts_with('~') {
1924        return dirs::home_dir()
1925            .unwrap_or_else(|| cwd.to_path_buf())
1926            .join(trimmed.trim_start_matches('~'));
1927    }
1928    let path = PathBuf::from(trimmed);
1929    if path.is_absolute() {
1930        path
1931    } else {
1932        cwd.join(path)
1933    }
1934}
1935
1936fn validate_non_empty_cli_inputs(inputs: &[String], label: &str) -> Result<()> {
1937    for input in inputs {
1938        if input.trim().is_empty() {
1939            return Err(Error::config(format!("Explicit {label} must be non-empty")));
1940        }
1941    }
1942    Ok(())
1943}
1944
1945fn is_under_path(target: &Path, root: &Path) -> bool {
1946    let Ok(root) = root.canonicalize() else {
1947        return false;
1948    };
1949    let Ok(target) = target.canonicalize() else {
1950        return false;
1951    };
1952    if target == root {
1953        return true;
1954    }
1955    target.starts_with(root)
1956}
1957
1958fn dedupe_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
1959    let mut seen = HashSet::new();
1960    let mut out = Vec::new();
1961    for path in paths {
1962        let key = canonical_identity_path(&path).to_string_lossy().to_string();
1963        if seen.insert(key) {
1964            out.push(path);
1965        }
1966    }
1967    out
1968}
1969
1970/// Resolve the module disk cache directory used by the JS extension runtime.
1971///
1972/// This mirrors the logic in `extensions_js::runtime_disk_cache_dir()` so that
1973/// we can detect cache/modules entries during discovery without depending on the
1974/// runtime module.
1975fn module_cache_dir() -> Option<PathBuf> {
1976    if let Some(raw) = std::env::var_os("PIJS_MODULE_CACHE_DIR") {
1977        return if raw.is_empty() {
1978            None
1979        } else {
1980            Some(PathBuf::from(raw))
1981        };
1982    }
1983    dirs::home_dir().map(|home| home.join(".pi").join("agent").join("cache").join("modules"))
1984}
1985
1986/// Returns `true` when `path` resides under the transpiled module cache directory
1987/// (`~/.pi/agent/cache/modules/` or `$PIJS_MODULE_CACHE_DIR`).
1988fn is_cache_module_path(path: &Path) -> bool {
1989    let cache_dir = module_cache_dir();
1990    is_cache_module_path_with_cache_dir(path, cache_dir.as_deref())
1991}
1992
1993fn is_cache_module_path_with_cache_dir(path: &Path, cache_dir: Option<&Path>) -> bool {
1994    let Some(cache_dir) = cache_dir else {
1995        return false;
1996    };
1997    let canonical = canonical_identity_path(path);
1998    let canonical_cache = canonical_identity_path(cache_dir);
1999    canonical.starts_with(&canonical_cache)
2000}
2001
2002/// Derive the canonical extension ID from a filesystem path.
2003///
2004/// Uses the same heuristic as `JsExtensionLoadSpec::from_entry_path`: if the
2005/// file stem is `index`, the parent directory name is the ID; otherwise the
2006/// file stem itself is the ID.
2007fn extension_id_from_path(path: &Path) -> Option<String> {
2008    let canonical = canonical_identity_path(path);
2009    let stem = canonical.file_stem().and_then(|s| s.to_str())?.trim();
2010    if stem.is_empty() {
2011        return None;
2012    }
2013    if stem.eq_ignore_ascii_case("index") {
2014        canonical
2015            .parent()
2016            .and_then(|p| p.file_name())
2017            .and_then(|s| s.to_str())
2018            .map(|s| s.trim().to_string())
2019            .filter(|s| !s.is_empty())
2020    } else {
2021        Some(stem.to_string())
2022    }
2023}
2024
2025fn extension_dedupe_key_from_path(path: &Path) -> Option<String> {
2026    extension_id_from_path(path).map(|id| id.to_ascii_lowercase())
2027}
2028
2029/// Deduplicate extension entries by canonical extension ID, preferring source
2030/// entries over transpiled cache copies (Issue #37).
2031///
2032/// When both a source `.ts` extension and its transpiled cache copy in
2033/// `~/.pi/agent/cache/modules/` are discovered, the cache entry is dropped to
2034/// prevent command collisions at load time.
2035fn dedupe_extension_entries_by_id(entries: Vec<PathBuf>) -> Vec<PathBuf> {
2036    let cache_dir = module_cache_dir();
2037    dedupe_extension_entries_by_id_with_cache_dir(entries, cache_dir.as_deref())
2038}
2039
2040fn dedupe_extension_entries_by_id_with_cache_dir(
2041    entries: Vec<PathBuf>,
2042    cache_dir: Option<&Path>,
2043) -> Vec<PathBuf> {
2044    // First pass: collect extension IDs and identify cache vs source entries.
2045    let mut id_to_source_idx: HashMap<String, usize> = HashMap::new();
2046    let mut is_cache = Vec::with_capacity(entries.len());
2047
2048    for (idx, path) in entries.iter().enumerate() {
2049        let cache = is_cache_module_path_with_cache_dir(path, cache_dir);
2050        is_cache.push(cache);
2051
2052        if let Some(id) = extension_dedupe_key_from_path(path) {
2053            if !cache {
2054                // Source entry wins; record its index.
2055                id_to_source_idx.entry(id).or_insert(idx);
2056            }
2057        }
2058    }
2059
2060    // Second pass: keep entries unless they are cache entries whose ID already
2061    // has a source entry.
2062    let mut out = Vec::with_capacity(entries.len());
2063    for (idx, path) in entries.into_iter().enumerate() {
2064        if is_cache[idx] {
2065            if let Some(id) = extension_dedupe_key_from_path(&path) {
2066                if id_to_source_idx.contains_key(&id) {
2067                    // Skip cache entry — source entry is preferred.
2068                    continue;
2069                }
2070            }
2071        }
2072        out.push(path);
2073    }
2074    out
2075}
2076
2077#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
2078enum ResourcePathPrecedence {
2079    CliExtension,
2080    ProjectDirectory,
2081    GlobalDirectory,
2082    ProjectPackage,
2083    GlobalPackage,
2084}
2085
2086fn precedence_sorted_enabled_paths(resources: Vec<ResolvedResource>) -> Vec<PathBuf> {
2087    let mut enabled = resources
2088        .into_iter()
2089        .filter(|resource| resource.enabled)
2090        .collect::<Vec<_>>();
2091    // Preserve source order within a precedence tier so CLI-specified
2092    // extension/resource ordering remains behaviorally significant.
2093    enabled.sort_by_key(resource_path_precedence);
2094    enabled.into_iter().map(|resource| resource.path).collect()
2095}
2096
2097fn merge_resource_paths(
2098    explicit_paths: &[PathBuf],
2099    cli_resources: Vec<ResolvedResource>,
2100    resolved_resources: Vec<ResolvedResource>,
2101    include_resolved: bool,
2102) -> Vec<PathBuf> {
2103    let mut merged = explicit_paths.to_vec();
2104    merged.extend(precedence_sorted_enabled_paths(cli_resources));
2105    if include_resolved {
2106        merged.extend(precedence_sorted_enabled_paths(resolved_resources));
2107    }
2108    dedupe_paths(merged)
2109}
2110
2111const fn resource_path_precedence(resource: &ResolvedResource) -> ResourcePathPrecedence {
2112    match (resource.metadata.scope, resource.metadata.origin) {
2113        (PackageScope::Temporary, _) => ResourcePathPrecedence::CliExtension,
2114        (PackageScope::Project, ResourceOrigin::TopLevel) => {
2115            ResourcePathPrecedence::ProjectDirectory
2116        }
2117        (PackageScope::User, ResourceOrigin::TopLevel) => ResourcePathPrecedence::GlobalDirectory,
2118        (PackageScope::Project, ResourceOrigin::Package) => ResourcePathPrecedence::ProjectPackage,
2119        (PackageScope::User, ResourceOrigin::Package) => ResourcePathPrecedence::GlobalPackage,
2120    }
2121}
2122
2123#[derive(Clone, Copy)]
2124enum ExplicitResourceKind {
2125    Skill,
2126    Prompt,
2127    Theme,
2128}
2129
2130impl ExplicitResourceKind {
2131    const fn label(self) -> &'static str {
2132        match self {
2133            Self::Skill => "skill",
2134            Self::Prompt => "prompt template",
2135            Self::Theme => "theme",
2136        }
2137    }
2138
2139    fn file_supported(self, path: &Path) -> bool {
2140        match self {
2141            Self::Skill | Self::Prompt => path.extension().is_some_and(|ext| ext == "md"),
2142            Self::Theme => is_theme_file(path),
2143        }
2144    }
2145
2146    const fn unsupported_file_message(self) -> &'static str {
2147        match self {
2148            Self::Skill | Self::Prompt => "is not a markdown file",
2149            Self::Theme => "is not a supported theme file (.json, .ini, or .theme)",
2150        }
2151    }
2152}
2153
2154fn validate_explicit_resource_paths(
2155    paths: &[PathBuf],
2156    resource_kind: ExplicitResourceKind,
2157) -> Result<()> {
2158    for path in paths {
2159        if !path.exists() {
2160            return Err(Error::config(format!(
2161                "Explicit {} path '{}' does not exist",
2162                resource_kind.label(),
2163                path.display()
2164            )));
2165        }
2166
2167        let metadata = fs::metadata(path).map_err(|err| {
2168            Error::config(format!(
2169                "Failed to inspect explicit {} path '{}': {err}",
2170                resource_kind.label(),
2171                path.display()
2172            ))
2173        })?;
2174
2175        if metadata.is_dir() {
2176            continue;
2177        }
2178
2179        if metadata.is_file() {
2180            if resource_kind.file_supported(path) {
2181                continue;
2182            }
2183
2184            return Err(Error::config(format!(
2185                "Explicit {} path '{}' {}",
2186                resource_kind.label(),
2187                path.display(),
2188                resource_kind.unsupported_file_message()
2189            )));
2190        }
2191
2192        return Err(Error::config(format!(
2193            "Explicit {} path '{}' is neither a file nor a directory",
2194            resource_kind.label(),
2195            path.display()
2196        )));
2197    }
2198
2199    Ok(())
2200}
2201
2202fn ensure_explicit_file_paths_loaded(
2203    explicit_paths: &[PathBuf],
2204    loaded_paths: Vec<PathBuf>,
2205    diagnostics: &[ResourceDiagnostic],
2206    resource_kind: ExplicitResourceKind,
2207) -> Result<()> {
2208    let loaded_paths = loaded_paths
2209        .into_iter()
2210        .map(|path| canonical_identity_path(&path))
2211        .collect::<HashSet<_>>();
2212
2213    for path in explicit_paths {
2214        let metadata = fs::metadata(path).map_err(|err| {
2215            Error::config(format!(
2216                "Failed to inspect explicit {} path '{}': {err}",
2217                resource_kind.label(),
2218                path.display()
2219            ))
2220        })?;
2221        if !metadata.is_file() {
2222            continue;
2223        }
2224
2225        let key = canonical_identity_path(path);
2226        if loaded_paths.contains(&key) {
2227            continue;
2228        }
2229
2230        let detail = diagnostics
2231            .iter()
2232            .find_map(|diagnostic| {
2233                if canonical_identity_path(&diagnostic.path) == key {
2234                    return Some(diagnostic.message.clone());
2235                }
2236                diagnostic.collision.as_ref().and_then(|collision| {
2237                    if canonical_identity_path(&collision.winner_path) == key
2238                        || canonical_identity_path(&collision.loser_path) == key
2239                    {
2240                        Some(diagnostic.message.clone())
2241                    } else {
2242                        None
2243                    }
2244                })
2245            })
2246            .unwrap_or_else(|| "file could not be loaded".to_string());
2247
2248        return Err(Error::config(format!(
2249            "Explicit {} path '{}' could not be loaded: {detail}",
2250            resource_kind.label(),
2251            path.display()
2252        )));
2253    }
2254
2255    Ok(())
2256}
2257
2258fn replace_regex<F>(input: &str, regex: &regex::Regex, mut replacer: F) -> String
2259where
2260    F: FnMut(&regex::Captures<'_>) -> String,
2261{
2262    regex
2263        .replace_all(input, |caps: &regex::Captures<'_>| replacer(caps))
2264        .to_string()
2265}
2266
2267// ============================================================================
2268// Tests
2269// ============================================================================
2270
2271#[cfg(test)]
2272mod tests {
2273    use super::*;
2274    use asupersync::runtime::RuntimeBuilder;
2275    use std::fs;
2276    use std::future::Future;
2277
2278    fn run_async<T>(future: impl Future<Output = T>) -> T {
2279        let runtime = RuntimeBuilder::current_thread()
2280            .build()
2281            .expect("build runtime");
2282        runtime.block_on(future)
2283    }
2284
2285    #[test]
2286    fn test_parse_command_args() {
2287        assert_eq!(parse_command_args("foo bar"), vec!["foo", "bar"]);
2288        assert_eq!(
2289            parse_command_args("foo \"bar baz\" qux"),
2290            vec!["foo", "bar baz", "qux"]
2291        );
2292        assert_eq!(parse_command_args("foo 'bar baz'"), vec!["foo", "bar baz"]);
2293        assert_eq!(
2294            parse_command_args("foo\tbar\n\"baz qux\"\r\n''"),
2295            vec!["foo", "bar", "baz qux", ""]
2296        );
2297    }
2298
2299    #[test]
2300    fn test_substitute_args() {
2301        let args = vec!["one".to_string(), "two".to_string(), "three".to_string()];
2302        assert_eq!(substitute_args("hello $1", &args), "hello one");
2303        assert_eq!(substitute_args("$@", &args), "one two three");
2304        assert_eq!(substitute_args("$ARGUMENTS", &args), "one two three");
2305        assert_eq!(substitute_args("${@:2}", &args), "two three");
2306        assert_eq!(substitute_args("${@:2:1}", &args), "two");
2307    }
2308
2309    #[test]
2310    fn test_expand_prompt_template() {
2311        let template = PromptTemplate {
2312            name: "review".to_string(),
2313            description: "Review code".to_string(),
2314            content: "Review $1".to_string(),
2315            source: "user".to_string(),
2316            file_path: PathBuf::from("/tmp/review.md"),
2317        };
2318        let out = expand_prompt_template("/review foo", std::slice::from_ref(&template));
2319        assert_eq!(out, "Review foo");
2320        let tab_out = expand_prompt_template("/review\tfoo", std::slice::from_ref(&template));
2321        assert_eq!(tab_out, "Review foo");
2322        let newline_out = expand_prompt_template("/review\nfoo", std::slice::from_ref(&template));
2323        assert_eq!(newline_out, "Review foo");
2324    }
2325
2326    #[test]
2327    fn test_expand_skill_command_accepts_non_space_whitespace_separator() {
2328        let dir = tempfile::tempdir().expect("tempdir");
2329        let skill_dir = dir.path().join("review");
2330        fs::create_dir_all(&skill_dir).expect("create skill dir");
2331        let skill_file = skill_dir.join("SKILL.md");
2332        fs::write(
2333            &skill_file,
2334            "---\nname: review\ndescription: Review code\n---\nSkill body.\n",
2335        )
2336        .expect("write skill");
2337
2338        let skill = Skill {
2339            name: "review".to_string(),
2340            description: "Review code".to_string(),
2341            file_path: skill_file,
2342            base_dir: skill_dir,
2343            source: "user".to_string(),
2344            disable_model_invocation: false,
2345        };
2346
2347        let tab_out = expand_skill_command(
2348            "/skill:review\tfocus this file",
2349            std::slice::from_ref(&skill),
2350        );
2351        assert!(tab_out.contains("Skill body."));
2352        assert!(tab_out.ends_with("focus this file"));
2353
2354        let newline_out = expand_skill_command("/skill:review\nfocus this file", &[skill]);
2355        assert!(newline_out.contains("Skill body."));
2356        assert!(newline_out.ends_with("focus this file"));
2357    }
2358
2359    #[test]
2360    fn test_format_skills_for_prompt() {
2361        let skills = vec![
2362            Skill {
2363                name: "a".to_string(),
2364                description: "desc".to_string(),
2365                file_path: PathBuf::from("/tmp/a/SKILL.md"),
2366                base_dir: PathBuf::from("/tmp/a"),
2367                source: "user".to_string(),
2368                disable_model_invocation: false,
2369            },
2370            Skill {
2371                name: "b".to_string(),
2372                description: "desc".to_string(),
2373                file_path: PathBuf::from("/tmp/b/SKILL.md"),
2374                base_dir: PathBuf::from("/tmp/b"),
2375                source: "user".to_string(),
2376                disable_model_invocation: true,
2377            },
2378        ];
2379        let prompt = format_skills_for_prompt(&skills);
2380        assert!(prompt.contains("<available_skills>"));
2381        assert!(prompt.contains("<name>a</name>"));
2382        assert!(!prompt.contains("<name>b</name>"));
2383    }
2384
2385    #[test]
2386    fn test_resource_cli_options_detect_explicit_paths() {
2387        let empty = ResourceCliOptions {
2388            no_skills: false,
2389            no_prompt_templates: false,
2390            no_extensions: false,
2391            no_themes: false,
2392            skill_paths: Vec::new(),
2393            prompt_paths: Vec::new(),
2394            extension_paths: Vec::new(),
2395            theme_paths: Vec::new(),
2396        };
2397        assert!(!empty.has_explicit_paths());
2398
2399        let with_extension = ResourceCliOptions {
2400            extension_paths: vec!["./ext.native.json".to_string()],
2401            ..empty
2402        };
2403        assert!(with_extension.has_explicit_paths());
2404    }
2405
2406    #[test]
2407    fn test_cli_extensions_load_when_no_extensions_flag_set() {
2408        run_async(async {
2409            let temp_dir = tempfile::tempdir().expect("tempdir");
2410            let extension_path = temp_dir.path().join("ext.native.json");
2411            fs::write(&extension_path, "{}").expect("write extension");
2412
2413            let manager = PackageManager::new(temp_dir.path().to_path_buf());
2414            let config = Config::default();
2415            let cli = ResourceCliOptions {
2416                no_skills: true,
2417                no_prompt_templates: true,
2418                no_extensions: true,
2419                no_themes: true,
2420                skill_paths: Vec::new(),
2421                prompt_paths: Vec::new(),
2422                extension_paths: vec![extension_path.to_string_lossy().to_string()],
2423                theme_paths: Vec::new(),
2424            };
2425
2426            let loader = ResourceLoader::load(&manager, temp_dir.path(), &config, &cli)
2427                .await
2428                .expect("load resources");
2429            assert!(loader.extensions().contains(&extension_path));
2430        });
2431    }
2432
2433    #[test]
2434    fn test_resource_loader_rejects_missing_cli_extension_path() {
2435        run_async(async {
2436            let temp_dir = tempfile::tempdir().expect("tempdir");
2437            let missing_path = temp_dir.path().join("missing.native.json");
2438
2439            let manager = PackageManager::new(temp_dir.path().to_path_buf());
2440            let config = Config::default();
2441            let cli = ResourceCliOptions {
2442                no_skills: true,
2443                no_prompt_templates: true,
2444                no_extensions: false,
2445                no_themes: true,
2446                skill_paths: Vec::new(),
2447                prompt_paths: Vec::new(),
2448                extension_paths: vec![missing_path.to_string_lossy().to_string()],
2449                theme_paths: Vec::new(),
2450            };
2451
2452            let err = ResourceLoader::load(&manager, temp_dir.path(), &config, &cli)
2453                .await
2454                .expect_err("missing explicit CLI extension path should fail");
2455            assert!(
2456                err.to_string().contains("does not exist"),
2457                "unexpected error: {err}"
2458            );
2459        });
2460    }
2461
2462    #[test]
2463    fn test_resource_loader_rejects_missing_cli_skill_path() {
2464        run_async(async {
2465            let temp_dir = tempfile::tempdir().expect("tempdir");
2466            let missing_path = temp_dir.path().join("missing-skill.md");
2467
2468            let manager = PackageManager::new(temp_dir.path().to_path_buf());
2469            let config = Config::default();
2470            let cli = ResourceCliOptions {
2471                no_skills: false,
2472                no_prompt_templates: true,
2473                no_extensions: true,
2474                no_themes: true,
2475                skill_paths: vec![missing_path.to_string_lossy().to_string()],
2476                prompt_paths: Vec::new(),
2477                extension_paths: Vec::new(),
2478                theme_paths: Vec::new(),
2479            };
2480
2481            let err = ResourceLoader::load(&manager, temp_dir.path(), &config, &cli)
2482                .await
2483                .expect_err("missing explicit CLI skill path should fail");
2484            assert!(
2485                err.to_string().contains("does not exist"),
2486                "unexpected error: {err}"
2487            );
2488        });
2489    }
2490
2491    #[test]
2492    fn test_resource_loader_rejects_blank_cli_skill_path() {
2493        run_async(async {
2494            let temp_dir = tempfile::tempdir().expect("tempdir");
2495
2496            let manager = PackageManager::new(temp_dir.path().to_path_buf());
2497            let config = Config::default();
2498            let cli = ResourceCliOptions {
2499                no_skills: false,
2500                no_prompt_templates: true,
2501                no_extensions: true,
2502                no_themes: true,
2503                skill_paths: vec!["   ".to_string()],
2504                prompt_paths: Vec::new(),
2505                extension_paths: Vec::new(),
2506                theme_paths: Vec::new(),
2507            };
2508
2509            let err = ResourceLoader::load(&manager, temp_dir.path(), &config, &cli)
2510                .await
2511                .expect_err("blank explicit CLI skill path should fail");
2512            assert!(
2513                err.to_string()
2514                    .contains("Explicit skill path must be non-empty"),
2515                "unexpected error: {err}"
2516            );
2517        });
2518    }
2519
2520    #[test]
2521    fn test_resource_loader_rejects_blank_cli_extension_source() {
2522        run_async(async {
2523            let temp_dir = tempfile::tempdir().expect("tempdir");
2524
2525            let manager = PackageManager::new(temp_dir.path().to_path_buf());
2526            let config = Config::default();
2527            let cli = ResourceCliOptions {
2528                no_skills: true,
2529                no_prompt_templates: true,
2530                no_extensions: false,
2531                no_themes: true,
2532                skill_paths: Vec::new(),
2533                prompt_paths: Vec::new(),
2534                extension_paths: vec![" \t ".to_string()],
2535                theme_paths: Vec::new(),
2536            };
2537
2538            let err = ResourceLoader::load(&manager, temp_dir.path(), &config, &cli)
2539                .await
2540                .expect_err("blank explicit CLI extension source should fail");
2541            assert!(
2542                err.to_string()
2543                    .contains("Explicit extension source must be non-empty"),
2544                "unexpected error: {err}"
2545            );
2546        });
2547    }
2548
2549    #[test]
2550    fn test_resource_loader_rejects_missing_cli_prompt_path() {
2551        run_async(async {
2552            let temp_dir = tempfile::tempdir().expect("tempdir");
2553            let missing_path = temp_dir.path().join("missing-prompt.md");
2554
2555            let manager = PackageManager::new(temp_dir.path().to_path_buf());
2556            let config = Config::default();
2557            let cli = ResourceCliOptions {
2558                no_skills: true,
2559                no_prompt_templates: false,
2560                no_extensions: true,
2561                no_themes: true,
2562                skill_paths: Vec::new(),
2563                prompt_paths: vec![missing_path.to_string_lossy().to_string()],
2564                extension_paths: Vec::new(),
2565                theme_paths: Vec::new(),
2566            };
2567
2568            let err = ResourceLoader::load(&manager, temp_dir.path(), &config, &cli)
2569                .await
2570                .expect_err("missing explicit CLI prompt path should fail");
2571            assert!(
2572                err.to_string().contains("does not exist"),
2573                "unexpected error: {err}"
2574            );
2575        });
2576    }
2577
2578    #[cfg(unix)]
2579    #[test]
2580    fn test_resource_loader_accepts_explicit_cli_prompt_alias_path() {
2581        run_async(async {
2582            let temp_dir = tempfile::tempdir().expect("tempdir");
2583            let prompt_dir = temp_dir.path().join("prompts");
2584            fs::create_dir_all(&prompt_dir).expect("create prompt dir");
2585            let prompt_path = prompt_dir.join("review.md");
2586            fs::write(
2587                &prompt_path,
2588                "---\ndescription: Review prompt\n---\nReview body\n",
2589            )
2590            .expect("write prompt");
2591            let alias_path = temp_dir.path().join("review-alias.md");
2592            std::os::unix::fs::symlink(&prompt_path, &alias_path).expect("create prompt alias");
2593
2594            let manager = PackageManager::new(temp_dir.path().to_path_buf());
2595            let config = Config::default();
2596            let cli = ResourceCliOptions {
2597                no_skills: true,
2598                no_prompt_templates: false,
2599                no_extensions: true,
2600                no_themes: true,
2601                skill_paths: Vec::new(),
2602                prompt_paths: vec![
2603                    prompt_path.to_string_lossy().to_string(),
2604                    alias_path.to_string_lossy().to_string(),
2605                ],
2606                extension_paths: Vec::new(),
2607                theme_paths: Vec::new(),
2608            };
2609
2610            let loader = ResourceLoader::load(&manager, temp_dir.path(), &config, &cli)
2611                .await
2612                .expect("load explicit prompt alias");
2613            assert_eq!(loader.prompts().len(), 1);
2614            assert_eq!(loader.prompts()[0].file_path, prompt_path);
2615            assert!(loader.prompt_diagnostics().is_empty());
2616        });
2617    }
2618
2619    #[test]
2620    fn test_resource_loader_rejects_invalid_cli_skill_file() {
2621        run_async(async {
2622            let temp_dir = tempfile::tempdir().expect("tempdir");
2623            let skill_dir = temp_dir.path().join("bad-skill");
2624            fs::create_dir_all(&skill_dir).expect("create skill dir");
2625            let skill_path = skill_dir.join("SKILL.md");
2626            fs::write(&skill_path, "# Missing frontmatter\n").expect("write skill");
2627
2628            let manager = PackageManager::new(temp_dir.path().to_path_buf());
2629            let config = Config::default();
2630            let cli = ResourceCliOptions {
2631                no_skills: false,
2632                no_prompt_templates: true,
2633                no_extensions: true,
2634                no_themes: true,
2635                skill_paths: vec![skill_path.to_string_lossy().to_string()],
2636                prompt_paths: Vec::new(),
2637                extension_paths: Vec::new(),
2638                theme_paths: Vec::new(),
2639            };
2640
2641            let err = ResourceLoader::load(&manager, temp_dir.path(), &config, &cli)
2642                .await
2643                .expect_err("invalid explicit CLI skill file should fail");
2644            assert!(
2645                err.to_string().contains("description is required"),
2646                "unexpected error: {err}"
2647            );
2648        });
2649    }
2650
2651    #[test]
2652    fn test_resource_loader_rejects_invalid_cli_theme_file() {
2653        run_async(async {
2654            let temp_dir = tempfile::tempdir().expect("tempdir");
2655            let theme_path = temp_dir.path().join("broken.json");
2656            fs::write(&theme_path, "{not-json").expect("write theme");
2657
2658            let manager = PackageManager::new(temp_dir.path().to_path_buf());
2659            let config = Config::default();
2660            let cli = ResourceCliOptions {
2661                no_skills: true,
2662                no_prompt_templates: true,
2663                no_extensions: true,
2664                no_themes: false,
2665                skill_paths: Vec::new(),
2666                prompt_paths: Vec::new(),
2667                extension_paths: Vec::new(),
2668                theme_paths: vec![theme_path.to_string_lossy().to_string()],
2669            };
2670
2671            let err = ResourceLoader::load(&manager, temp_dir.path(), &config, &cli)
2672                .await
2673                .expect_err("invalid explicit CLI theme file should fail");
2674            assert!(
2675                err.to_string().contains("could not be loaded"),
2676                "unexpected error: {err}"
2677            );
2678            assert!(
2679                err.to_string().contains("Failed to load theme"),
2680                "unexpected error: {err}"
2681            );
2682        });
2683    }
2684
2685    #[cfg(unix)]
2686    #[test]
2687    fn test_resource_loader_accepts_explicit_cli_theme_alias_path() {
2688        run_async(async {
2689            let temp_dir = tempfile::tempdir().expect("tempdir");
2690            let theme_dir = temp_dir.path().join("themes");
2691            fs::create_dir_all(&theme_dir).expect("create theme dir");
2692            let theme_path = theme_dir.join("dark.ini");
2693            fs::write(&theme_path, "[styles]\nbrand.accent = bold #38bdf8\n").expect("write theme");
2694            let alias_path = temp_dir.path().join("dark-alias.ini");
2695            std::os::unix::fs::symlink(&theme_path, &alias_path).expect("create theme alias");
2696
2697            let manager = PackageManager::new(temp_dir.path().to_path_buf());
2698            let config = Config::default();
2699            let cli = ResourceCliOptions {
2700                no_skills: true,
2701                no_prompt_templates: true,
2702                no_extensions: true,
2703                no_themes: false,
2704                skill_paths: Vec::new(),
2705                prompt_paths: Vec::new(),
2706                extension_paths: Vec::new(),
2707                theme_paths: vec![
2708                    theme_path.to_string_lossy().to_string(),
2709                    alias_path.to_string_lossy().to_string(),
2710                ],
2711            };
2712
2713            let loader = ResourceLoader::load(&manager, temp_dir.path(), &config, &cli)
2714                .await
2715                .expect("load explicit theme alias");
2716            assert_eq!(loader.themes().len(), 1);
2717            assert_eq!(loader.themes()[0].file_path, theme_path);
2718            assert!(loader.theme_diagnostics().is_empty());
2719        });
2720    }
2721
2722    #[test]
2723    fn test_extension_paths_deduped_between_settings_and_cli() {
2724        run_async(async {
2725            let temp_dir = tempfile::tempdir().expect("tempdir");
2726            let extension_path = temp_dir.path().join("ext.native.json");
2727            fs::write(&extension_path, "{}").expect("write extension");
2728
2729            let settings_dir = temp_dir.path().join(".pi");
2730            fs::create_dir_all(&settings_dir).expect("create settings dir");
2731            let settings_path = settings_dir.join("settings.json");
2732            let settings = json!({
2733                "extensions": [extension_path.to_string_lossy().to_string()]
2734            });
2735            fs::write(
2736                &settings_path,
2737                serde_json::to_string_pretty(&settings).expect("serialize settings"),
2738            )
2739            .expect("write settings");
2740
2741            let manager = PackageManager::new(temp_dir.path().to_path_buf());
2742            let config = Config::default();
2743            let cli = ResourceCliOptions {
2744                no_skills: true,
2745                no_prompt_templates: true,
2746                no_extensions: false,
2747                no_themes: true,
2748                skill_paths: Vec::new(),
2749                prompt_paths: Vec::new(),
2750                extension_paths: vec![extension_path.to_string_lossy().to_string()],
2751                theme_paths: Vec::new(),
2752            };
2753
2754            let loader = ResourceLoader::load(&manager, temp_dir.path(), &config, &cli)
2755                .await
2756                .expect("load resources");
2757            let matches = loader
2758                .extensions()
2759                .iter()
2760                .filter(|path| *path == &extension_path)
2761                .count();
2762            assert_eq!(matches, 1);
2763        });
2764    }
2765
2766    #[test]
2767    fn test_dedupe_extension_entries_by_id_casefolds_cache_source_pairs() {
2768        let temp_dir = tempfile::tempdir().expect("tempdir");
2769        let source_dir = temp_dir.path().join("source");
2770        let cache_dir = temp_dir.path().join("cache").join("modules");
2771        fs::create_dir_all(&source_dir).expect("create source dir");
2772        fs::create_dir_all(&cache_dir).expect("create cache dir");
2773
2774        let source_entry = source_dir.join("Foo.ts");
2775        let cache_entry = cache_dir.join("foo.js");
2776        fs::write(&source_entry, "export default function init() {}\n")
2777            .expect("write source entry");
2778        fs::write(&cache_entry, "export default function init() {}\n").expect("write cache entry");
2779
2780        let deduped = dedupe_extension_entries_by_id_with_cache_dir(
2781            vec![cache_entry, source_entry.clone()],
2782            Some(&cache_dir),
2783        );
2784
2785        assert_eq!(
2786            deduped,
2787            vec![source_entry],
2788            "case-variant cache copy should be dropped in favor of the source entry"
2789        );
2790    }
2791
2792    #[test]
2793    fn test_dedupe_themes_is_case_insensitive() {
2794        let (themes, diagnostics) = dedupe_themes(vec![
2795            ThemeResource {
2796                name: "Dark".to_string(),
2797                theme: Theme::dark(),
2798                source: "test:first".to_string(),
2799                file_path: PathBuf::from("/tmp/Dark.ini"),
2800            },
2801            ThemeResource {
2802                name: "dark".to_string(),
2803                theme: Theme::dark(),
2804                source: "test:second".to_string(),
2805                file_path: PathBuf::from("/tmp/dark.ini"),
2806            },
2807        ]);
2808
2809        assert_eq!(themes.len(), 1);
2810        assert_eq!(diagnostics.len(), 1);
2811        assert_eq!(diagnostics[0].kind, DiagnosticKind::Collision);
2812        assert!(
2813            diagnostics[0].message.contains("theme"),
2814            "unexpected diagnostic: {:?}",
2815            diagnostics[0]
2816        );
2817    }
2818
2819    #[test]
2820    fn test_extract_manifest_string_list_variants() {
2821        let temp = tempfile::tempdir().expect("tempdir");
2822        let manifest_path = temp.path().join("package.json");
2823        assert_eq!(
2824            extract_manifest_string_list(
2825                &manifest_path,
2826                "extensions",
2827                &Value::String("one".to_string())
2828            )
2829            .expect("single string should parse"),
2830            vec!["one".to_string()]
2831        );
2832        assert_eq!(
2833            extract_manifest_string_list(&manifest_path, "extensions", &json!(["one", "three"]))
2834                .expect("string arrays should parse"),
2835            vec!["one".to_string(), "three".to_string()]
2836        );
2837        let err = extract_manifest_string_list(&manifest_path, "extensions", &json!({"a": 1}))
2838            .expect_err("objects should be rejected");
2839        assert!(
2840            err.to_string()
2841                .contains("`pi.extensions` must be a string or array of strings")
2842        );
2843    }
2844
2845    #[test]
2846    fn test_validate_name_catches_all_error_categories() {
2847        let errors = validate_name("Bad--Name-", "parent");
2848        assert!(
2849            errors
2850                .iter()
2851                .any(|e| e.contains("does not match parent directory"))
2852        );
2853        assert!(errors.iter().any(|e| e.contains("invalid characters")));
2854        assert!(
2855            errors
2856                .iter()
2857                .any(|e| e.contains("must not start or end with a hyphen"))
2858        );
2859        assert!(
2860            errors
2861                .iter()
2862                .any(|e| e.contains("must not contain consecutive hyphens"))
2863        );
2864
2865        let too_long = "a".repeat(MAX_SKILL_NAME_LEN + 1);
2866        let too_long_errors = validate_name(&too_long, &too_long);
2867        assert!(
2868            too_long_errors
2869                .iter()
2870                .any(|e| e.contains(&format!("name exceeds {MAX_SKILL_NAME_LEN} characters")))
2871        );
2872    }
2873
2874    #[test]
2875    fn test_validate_description_rules() {
2876        let empty_errors = validate_description("   ");
2877        assert!(empty_errors.iter().any(|e| e == "description is required"));
2878
2879        let long = "x".repeat(MAX_SKILL_DESC_LEN + 1);
2880        let long_errors = validate_description(&long);
2881        assert!(long_errors.iter().any(|e| e.contains(&format!(
2882            "description exceeds {MAX_SKILL_DESC_LEN} characters"
2883        ))));
2884
2885        assert!(validate_description("ok").is_empty());
2886    }
2887
2888    #[test]
2889    fn test_validate_frontmatter_fields_allows_known_and_rejects_unknown() {
2890        let keys = [
2891            "name".to_string(),
2892            "description".to_string(),
2893            "unknown-field".to_string(),
2894        ];
2895        let errors = validate_frontmatter_fields(keys.iter());
2896        assert_eq!(errors.len(), 1);
2897        assert_eq!(errors[0], "unknown frontmatter field \"unknown-field\"");
2898    }
2899
2900    #[test]
2901    fn test_escape_xml_replaces_all_special_chars() {
2902        let escaped = escape_xml("& < > \" '");
2903        assert_eq!(escaped, "&amp; &lt; &gt; &quot; &apos;");
2904    }
2905
2906    #[test]
2907    fn test_parse_frontmatter_valid_and_unclosed() {
2908        let parsed = parse_frontmatter(
2909            r#"---
2910name: "skill-name"
2911description: 'demo'
2912# comment
2913metadata: keep
2914---
2915body line 1
2916body line 2"#,
2917        );
2918        assert_eq!(
2919            parsed.frontmatter.get("name"),
2920            Some(&"skill-name".to_string())
2921        );
2922        assert_eq!(
2923            parsed.frontmatter.get("description"),
2924            Some(&"demo".to_string())
2925        );
2926        assert_eq!(
2927            parsed.frontmatter.get("metadata"),
2928            Some(&"keep".to_string())
2929        );
2930        assert_eq!(parsed.body, "body line 1\nbody line 2");
2931
2932        let unclosed = parse_frontmatter(
2933            r"---
2934name: nope
2935still frontmatter",
2936        );
2937        assert!(unclosed.frontmatter.is_empty());
2938        assert!(unclosed.body.starts_with("---"));
2939    }
2940
2941    #[test]
2942    fn test_resolve_path_tilde_relative_absolute_and_trim() {
2943        let cwd = Path::new("/work/cwd");
2944        let home = dirs::home_dir().unwrap_or_else(|| cwd.to_path_buf());
2945
2946        assert_eq!(resolve_path("  rel/file  ", cwd), cwd.join("rel/file"));
2947        assert_eq!(resolve_path("/abs/file", cwd), PathBuf::from("/abs/file"));
2948        assert_eq!(resolve_path("~", cwd), home);
2949        assert_eq!(resolve_path("~/cfg", cwd), home.join("cfg"));
2950        assert_eq!(resolve_path("~custom", cwd), home.join("custom"));
2951    }
2952
2953    #[test]
2954    fn test_theme_path_helpers() {
2955        assert!(is_theme_file(Path::new("/tmp/theme.json")));
2956        assert!(is_theme_file(Path::new("/tmp/theme.ini")));
2957        assert!(is_theme_file(Path::new("/tmp/theme.theme")));
2958        assert!(!is_theme_file(Path::new("/tmp/theme.txt")));
2959
2960        assert_eq!(
2961            build_path_source_label(Path::new("/tmp/ocean.theme")),
2962            "(path:ocean)"
2963        );
2964        assert_eq!(build_path_source_label(Path::new("/")), "(path:path)");
2965    }
2966
2967    #[test]
2968    fn test_dedupe_paths_preserves_order_of_first_occurrence() {
2969        let paths = vec![
2970            PathBuf::from("/a"),
2971            PathBuf::from("/b"),
2972            PathBuf::from("/a"),
2973            PathBuf::from("/c"),
2974            PathBuf::from("/b"),
2975        ];
2976        let deduped = dedupe_paths(paths);
2977        assert_eq!(
2978            deduped,
2979            vec![
2980                PathBuf::from("/a"),
2981                PathBuf::from("/b"),
2982                PathBuf::from("/c"),
2983            ]
2984        );
2985    }
2986
2987    #[test]
2988    fn test_read_dir_sorted_paths_returns_lexicographic_paths() {
2989        let temp = tempfile::tempdir().expect("tempdir");
2990        fs::write(temp.path().join("z.md"), "z").expect("write z");
2991        fs::write(temp.path().join("a.md"), "a").expect("write a");
2992
2993        let names: Vec<String> = read_dir_sorted_paths(temp.path())
2994            .into_iter()
2995            .map(|path| {
2996                path.file_name()
2997                    .expect("file name")
2998                    .to_string_lossy()
2999                    .into_owned()
3000            })
3001            .collect();
3002        assert_eq!(names, vec!["a.md", "z.md"]);
3003    }
3004
3005    #[test]
3006    fn test_precedence_sorted_enabled_paths_orders_by_documented_resource_priority() {
3007        let resources = vec![
3008            ResolvedResource {
3009                path: PathBuf::from("/global/package/review.md"),
3010                enabled: true,
3011                metadata: crate::package_manager::PathMetadata {
3012                    source: "pkg:user".to_string(),
3013                    scope: PackageScope::User,
3014                    origin: ResourceOrigin::Package,
3015                    base_dir: None,
3016                },
3017            },
3018            ResolvedResource {
3019                path: PathBuf::from("/project/.pi/prompts/review.md"),
3020                enabled: true,
3021                metadata: crate::package_manager::PathMetadata {
3022                    source: "local:project".to_string(),
3023                    scope: PackageScope::Project,
3024                    origin: ResourceOrigin::TopLevel,
3025                    base_dir: None,
3026                },
3027            },
3028            ResolvedResource {
3029                path: PathBuf::from("/global/.pi/prompts/review.md"),
3030                enabled: true,
3031                metadata: crate::package_manager::PathMetadata {
3032                    source: "local:user".to_string(),
3033                    scope: PackageScope::User,
3034                    origin: ResourceOrigin::TopLevel,
3035                    base_dir: None,
3036                },
3037            },
3038            ResolvedResource {
3039                path: PathBuf::from("/project/package/review.md"),
3040                enabled: true,
3041                metadata: crate::package_manager::PathMetadata {
3042                    source: "pkg:project".to_string(),
3043                    scope: PackageScope::Project,
3044                    origin: ResourceOrigin::Package,
3045                    base_dir: None,
3046                },
3047            },
3048            ResolvedResource {
3049                path: PathBuf::from("/tmp/cli-ext/review.md"),
3050                enabled: true,
3051                metadata: crate::package_manager::PathMetadata {
3052                    source: "cli-extension".to_string(),
3053                    scope: PackageScope::Temporary,
3054                    origin: ResourceOrigin::Package,
3055                    base_dir: None,
3056                },
3057            },
3058            ResolvedResource {
3059                path: PathBuf::from("/disabled/ignored.md"),
3060                enabled: false,
3061                metadata: crate::package_manager::PathMetadata {
3062                    source: "ignored".to_string(),
3063                    scope: PackageScope::Project,
3064                    origin: ResourceOrigin::TopLevel,
3065                    base_dir: None,
3066                },
3067            },
3068        ];
3069
3070        let sorted = precedence_sorted_enabled_paths(resources);
3071        assert_eq!(
3072            sorted,
3073            vec![
3074                PathBuf::from("/tmp/cli-ext/review.md"),
3075                PathBuf::from("/project/.pi/prompts/review.md"),
3076                PathBuf::from("/global/.pi/prompts/review.md"),
3077                PathBuf::from("/project/package/review.md"),
3078                PathBuf::from("/global/package/review.md"),
3079            ]
3080        );
3081    }
3082
3083    #[test]
3084    fn test_precedence_sorted_enabled_paths_preserves_source_order_within_same_precedence() {
3085        let resources = vec![
3086            ResolvedResource {
3087                path: PathBuf::from("/tmp/cli-ext/zeta/review.md"),
3088                enabled: true,
3089                metadata: crate::package_manager::PathMetadata {
3090                    source: "cli-extension:zeta".to_string(),
3091                    scope: PackageScope::Temporary,
3092                    origin: ResourceOrigin::Package,
3093                    base_dir: None,
3094                },
3095            },
3096            ResolvedResource {
3097                path: PathBuf::from("/tmp/cli-ext/alpha/review.md"),
3098                enabled: true,
3099                metadata: crate::package_manager::PathMetadata {
3100                    source: "cli-extension:alpha".to_string(),
3101                    scope: PackageScope::Temporary,
3102                    origin: ResourceOrigin::Package,
3103                    base_dir: None,
3104                },
3105            },
3106            ResolvedResource {
3107                path: PathBuf::from("/project/.pi/prompts/review.md"),
3108                enabled: true,
3109                metadata: crate::package_manager::PathMetadata {
3110                    source: "local:project".to_string(),
3111                    scope: PackageScope::Project,
3112                    origin: ResourceOrigin::TopLevel,
3113                    base_dir: None,
3114                },
3115            },
3116        ];
3117
3118        let sorted = precedence_sorted_enabled_paths(resources);
3119        assert_eq!(
3120            sorted,
3121            vec![
3122                PathBuf::from("/tmp/cli-ext/zeta/review.md"),
3123                PathBuf::from("/tmp/cli-ext/alpha/review.md"),
3124                PathBuf::from("/project/.pi/prompts/review.md"),
3125            ],
3126            "same-tier resources should keep their original source order"
3127        );
3128    }
3129
3130    #[test]
3131    fn test_merge_resource_paths_keeps_explicit_cli_paths_first() {
3132        let explicit_path = PathBuf::from("/cli/direct/review.md");
3133        let merged = merge_resource_paths(
3134            std::slice::from_ref(&explicit_path),
3135            vec![ResolvedResource {
3136                path: PathBuf::from("/tmp/cli-ext/review.md"),
3137                enabled: true,
3138                metadata: crate::package_manager::PathMetadata {
3139                    source: "cli-extension".to_string(),
3140                    scope: PackageScope::Temporary,
3141                    origin: ResourceOrigin::Package,
3142                    base_dir: None,
3143                },
3144            }],
3145            vec![
3146                ResolvedResource {
3147                    path: PathBuf::from("/project/.pi/prompts/review.md"),
3148                    enabled: true,
3149                    metadata: crate::package_manager::PathMetadata {
3150                        source: "local:project".to_string(),
3151                        scope: PackageScope::Project,
3152                        origin: ResourceOrigin::TopLevel,
3153                        base_dir: None,
3154                    },
3155                },
3156                ResolvedResource {
3157                    path: PathBuf::from("/global/.pi/prompts/review.md"),
3158                    enabled: true,
3159                    metadata: crate::package_manager::PathMetadata {
3160                        source: "local:user".to_string(),
3161                        scope: PackageScope::User,
3162                        origin: ResourceOrigin::TopLevel,
3163                        base_dir: None,
3164                    },
3165                },
3166            ],
3167            true,
3168        );
3169
3170        assert_eq!(
3171            merged,
3172            vec![
3173                explicit_path,
3174                PathBuf::from("/tmp/cli-ext/review.md"),
3175                PathBuf::from("/project/.pi/prompts/review.md"),
3176                PathBuf::from("/global/.pi/prompts/review.md"),
3177            ]
3178        );
3179    }
3180
3181    // ── strip_frontmatter ──────────────────────────────────────────────
3182
3183    #[test]
3184    fn test_strip_frontmatter_removes_yaml_header() {
3185        let raw = "---\nname: test\n---\nbody content";
3186        assert_eq!(strip_frontmatter(raw), "body content");
3187    }
3188
3189    #[test]
3190    fn test_strip_frontmatter_returns_body_when_no_frontmatter() {
3191        let raw = "just body content";
3192        assert_eq!(strip_frontmatter(raw), "just body content");
3193    }
3194
3195    // ── is_under_path ──────────────────────────────────────────────────
3196
3197    #[test]
3198    fn test_is_under_path_same_dir() {
3199        let tmp = tempfile::tempdir().expect("tempdir");
3200        assert!(is_under_path(tmp.path(), tmp.path()));
3201    }
3202
3203    #[test]
3204    fn test_is_under_path_child() {
3205        let tmp = tempfile::tempdir().expect("tempdir");
3206        let child = tmp.path().join("sub");
3207        fs::create_dir(&child).expect("mkdir");
3208        assert!(is_under_path(&child, tmp.path()));
3209    }
3210
3211    #[test]
3212    fn test_is_under_path_unrelated() {
3213        let tmp1 = tempfile::tempdir().expect("tmp1");
3214        let tmp2 = tempfile::tempdir().expect("tmp2");
3215        assert!(!is_under_path(tmp1.path(), tmp2.path()));
3216    }
3217
3218    #[test]
3219    fn test_is_under_path_nonexistent() {
3220        assert!(!is_under_path(
3221            Path::new("/nonexistent/a"),
3222            Path::new("/nonexistent/b")
3223        ));
3224    }
3225
3226    // ── dedupe_prompts ─────────────────────────────────────────────────
3227
3228    #[test]
3229    fn test_dedupe_prompts_removes_duplicates_keeps_first() {
3230        let prompts = vec![
3231            PromptTemplate {
3232                name: "review".to_string(),
3233                description: "first".to_string(),
3234                content: "content1".to_string(),
3235                source: "a".to_string(),
3236                file_path: PathBuf::from("/a/review.md"),
3237            },
3238            PromptTemplate {
3239                name: "review".to_string(),
3240                description: "second".to_string(),
3241                content: "content2".to_string(),
3242                source: "b".to_string(),
3243                file_path: PathBuf::from("/b/review.md"),
3244            },
3245            PromptTemplate {
3246                name: "unique".to_string(),
3247                description: "only one".to_string(),
3248                content: "content3".to_string(),
3249                source: "c".to_string(),
3250                file_path: PathBuf::from("/c/unique.md"),
3251            },
3252        ];
3253        let (deduped, diagnostics) = dedupe_prompts(prompts);
3254        assert_eq!(deduped.len(), 2);
3255        assert_eq!(diagnostics.len(), 1);
3256        assert_eq!(diagnostics[0].kind, DiagnosticKind::Collision);
3257        assert!(diagnostics[0].message.contains("review"));
3258    }
3259
3260    #[test]
3261    fn test_dedupe_prompts_sorts_by_name() {
3262        let prompts = vec![
3263            PromptTemplate {
3264                name: "z-prompt".to_string(),
3265                description: "z".to_string(),
3266                content: String::new(),
3267                source: "s".to_string(),
3268                file_path: PathBuf::from("/z.md"),
3269            },
3270            PromptTemplate {
3271                name: "a-prompt".to_string(),
3272                description: "a".to_string(),
3273                content: String::new(),
3274                source: "s".to_string(),
3275                file_path: PathBuf::from("/a.md"),
3276            },
3277        ];
3278        let (deduped, diagnostics) = dedupe_prompts(prompts);
3279        assert!(diagnostics.is_empty());
3280        assert_eq!(deduped[0].name, "a-prompt");
3281        assert_eq!(deduped[1].name, "z-prompt");
3282    }
3283
3284    // ── expand_skill_command ───────────────────────────────────────────
3285
3286    #[test]
3287    fn test_expand_skill_command_with_matching_skill() {
3288        let tmp = tempfile::tempdir().expect("tempdir");
3289        let skill_file = tmp.path().join("SKILL.md");
3290        fs::write(
3291            &skill_file,
3292            "---\nname: test-skill\ndescription: A test\n---\nDo the thing.",
3293        )
3294        .expect("write skill");
3295
3296        let skills = vec![Skill {
3297            name: "test-skill".to_string(),
3298            description: "A test".to_string(),
3299            file_path: skill_file,
3300            base_dir: tmp.path().to_path_buf(),
3301            source: "test".to_string(),
3302            disable_model_invocation: false,
3303        }];
3304        let result = expand_skill_command("/skill:test-skill extra args", &skills);
3305        assert!(result.contains("<skill name=\"test-skill\""));
3306        assert!(result.contains("Do the thing."));
3307        assert!(result.contains("extra args"));
3308    }
3309
3310    #[test]
3311    fn test_expand_skill_command_no_matching_skill_returns_input() {
3312        let result = expand_skill_command("/skill:nonexistent", &[]);
3313        assert_eq!(result, "/skill:nonexistent");
3314    }
3315
3316    #[test]
3317    fn test_expand_skill_command_non_skill_prefix_returns_input() {
3318        let result = expand_skill_command("plain text", &[]);
3319        assert_eq!(result, "plain text");
3320    }
3321
3322    // ── parse_command_args edge cases ──────────────────────────────────
3323
3324    #[test]
3325    fn test_parse_command_args_empty() {
3326        assert!(parse_command_args("").is_empty());
3327        assert!(parse_command_args("   ").is_empty());
3328    }
3329
3330    #[test]
3331    fn test_parse_command_args_tabs_as_separators() {
3332        assert_eq!(parse_command_args("a\tb\tc"), vec!["a", "b", "c"]);
3333    }
3334
3335    #[test]
3336    fn test_parse_command_args_unclosed_quote() {
3337        // Unclosed quote just includes chars up to end
3338        assert_eq!(parse_command_args("foo \"bar"), vec!["foo", "bar"]);
3339    }
3340
3341    #[test]
3342    fn test_parse_command_args_preserves_empty_quoted_args() {
3343        assert_eq!(parse_command_args("\"\""), vec![""]);
3344        assert_eq!(parse_command_args("''"), vec![""]);
3345        assert_eq!(
3346            parse_command_args("foo \"\" bar ''"),
3347            vec!["foo", "", "bar", ""]
3348        );
3349    }
3350
3351    #[test]
3352    fn test_parse_command_args_preserves_apostrophes_inside_words() {
3353        assert_eq!(parse_command_args("it's fine"), vec!["it's", "fine"]);
3354        assert_eq!(
3355            parse_command_args("review o'brien's draft"),
3356            vec!["review", "o'brien's", "draft"]
3357        );
3358    }
3359
3360    // ── substitute_args edge cases ─────────────────────────────────────
3361
3362    #[test]
3363    fn test_substitute_args_out_of_range_positional() {
3364        let args = vec!["one".to_string()];
3365        assert_eq!(substitute_args("$2", &args), "");
3366    }
3367
3368    #[test]
3369    fn test_substitute_args_zero_positional() {
3370        let args = vec!["one".to_string(), "two".to_string()];
3371        let result = substitute_args("$0", &args);
3372        assert_eq!(result, "");
3373    }
3374
3375    #[test]
3376    fn test_substitute_args_empty_args() {
3377        let result = substitute_args("$1 $@ $ARGUMENTS", &[]);
3378        assert_eq!(result, "  ");
3379    }
3380
3381    #[test]
3382    fn panic_payload_message_handles_known_payload_types() {
3383        let string_payload: Box<dyn std::any::Any + Send + 'static> =
3384            Box::new("loader panic".to_string());
3385        assert_eq!(
3386            panic_payload_message(string_payload),
3387            "loader panic".to_string()
3388        );
3389
3390        let str_payload: Box<dyn std::any::Any + Send + 'static> = Box::new("panic str");
3391        assert_eq!(panic_payload_message(str_payload), "panic str".to_string());
3392    }
3393
3394    // ── expand_prompt_template edge cases ──────────────────────────────
3395
3396    #[test]
3397    fn test_expand_prompt_template_non_slash_returns_as_is() {
3398        let result = expand_prompt_template("plain text", &[]);
3399        assert_eq!(result, "plain text");
3400    }
3401
3402    #[test]
3403    fn test_expand_prompt_template_unknown_command_returns_as_is() {
3404        let result = expand_prompt_template("/nonexistent foo", &[]);
3405        assert_eq!(result, "/nonexistent foo");
3406    }
3407
3408    #[test]
3409    fn test_expand_prompt_template_preserves_empty_positional_arguments() {
3410        let template = PromptTemplate {
3411            name: "review".to_string(),
3412            description: "review prompt".to_string(),
3413            content: "first=[$1] second=[$2] rest=[${@:2}]".to_string(),
3414            source: "test".to_string(),
3415            file_path: PathBuf::from("/review.md"),
3416        };
3417
3418        let result = expand_prompt_template("/review \"\" foo", &[template]);
3419        assert_eq!(result, "first=[] second=[foo] rest=[foo]");
3420    }
3421
3422    #[test]
3423    fn test_expand_prompt_template_preserves_trailing_empty_positional_arguments() {
3424        let template = PromptTemplate {
3425            name: "review".to_string(),
3426            description: "review prompt".to_string(),
3427            content: "first=[$1] second=[$2] third=[$3]".to_string(),
3428            source: "test".to_string(),
3429            file_path: PathBuf::from("/review.md"),
3430        };
3431
3432        let result = expand_prompt_template("/review foo \"\"", &[template]);
3433        assert_eq!(result, "first=[foo] second=[] third=[]");
3434    }
3435
3436    #[test]
3437    fn test_expand_prompt_template_preserves_repeated_empty_quoted_arguments() {
3438        let template = PromptTemplate {
3439            name: "review".to_string(),
3440            description: "review prompt".to_string(),
3441            content: "first=[$1] second=[$2] third=[$3] fourth=[$4]".to_string(),
3442            source: "test".to_string(),
3443            file_path: PathBuf::from("/review.md"),
3444        };
3445
3446        let result = expand_prompt_template("/review foo \"\" \"\" bar", &[template]);
3447        assert_eq!(result, "first=[foo] second=[] third=[] fourth=[bar]");
3448    }
3449
3450    #[test]
3451    fn test_expand_prompt_template_preserves_apostrophes_in_arguments() {
3452        let template = PromptTemplate {
3453            name: "review".to_string(),
3454            description: "review prompt".to_string(),
3455            content: "first=[$1] second=[$2]".to_string(),
3456            source: "test".to_string(),
3457            file_path: PathBuf::from("/review.md"),
3458        };
3459
3460        let result = expand_prompt_template("/review it's fine", &[template]);
3461        assert_eq!(result, "first=[it's] second=[fine]");
3462    }
3463
3464    // ── parse_frontmatter edge cases ───────────────────────────────────
3465
3466    #[test]
3467    fn test_parse_frontmatter_empty_input() {
3468        let parsed = parse_frontmatter("");
3469        assert!(parsed.frontmatter.is_empty());
3470        assert!(parsed.body.is_empty());
3471    }
3472
3473    #[test]
3474    fn test_parse_frontmatter_only_body() {
3475        let parsed = parse_frontmatter("no frontmatter here\njust body");
3476        assert!(parsed.frontmatter.is_empty());
3477        assert_eq!(parsed.body, "no frontmatter here\njust body");
3478    }
3479
3480    #[test]
3481    fn test_parse_frontmatter_empty_key_ignored() {
3482        let parsed = parse_frontmatter("---\n: value\nname: test\n---\nbody");
3483        assert!(!parsed.frontmatter.contains_key(""));
3484        assert_eq!(parsed.frontmatter.get("name"), Some(&"test".to_string()));
3485    }
3486
3487    // ── validate_name edge cases ───────────────────────────────────────
3488
3489    #[test]
3490    fn test_validate_name_valid_name() {
3491        let errors = validate_name("good-name", "good-name");
3492        assert!(errors.is_empty());
3493    }
3494
3495    #[test]
3496    fn test_validate_name_single_char() {
3497        let errors = validate_name("a", "a");
3498        assert!(errors.is_empty());
3499    }
3500
3501    // ── CollisionInfo and DiagnosticKind ────────────────────────────────
3502
3503    #[test]
3504    fn test_diagnostic_kind_equality() {
3505        assert_eq!(DiagnosticKind::Warning, DiagnosticKind::Warning);
3506        assert_eq!(DiagnosticKind::Collision, DiagnosticKind::Collision);
3507        assert_ne!(DiagnosticKind::Warning, DiagnosticKind::Collision);
3508    }
3509
3510    // ── replace_regex ──────────────────────────────────────────────────
3511
3512    #[test]
3513    fn test_replace_regex_no_match_returns_input() {
3514        let re = regex::Regex::new(r"\d+").unwrap();
3515        let result = replace_regex("hello world", &re, |_| "num".to_string());
3516        assert_eq!(result, "hello world");
3517    }
3518
3519    #[test]
3520    fn test_replace_regex_replaces_all_matches() {
3521        let re = regex::Regex::new(r"\d").unwrap();
3522        let result = replace_regex("a1b2c3", &re, |caps| format!("[{}]", &caps[0]));
3523        assert_eq!(result, "a[1]b[2]c[3]");
3524    }
3525
3526    // ── package manifest parsing ──────────────────────────────────────
3527
3528    #[test]
3529    fn test_read_pi_manifest_returns_none_when_package_json_is_missing() {
3530        let tmp = tempfile::tempdir().expect("tempdir");
3531
3532        let pi = read_pi_manifest(tmp.path()).expect("missing package.json should not error");
3533        assert!(pi.is_none());
3534    }
3535
3536    #[test]
3537    fn test_read_pi_manifest_errors_on_malformed_package_json() {
3538        let tmp = tempfile::tempdir().expect("tempdir");
3539        let manifest_path = tmp.path().join("package.json");
3540        fs::write(&manifest_path, "{ not valid json").expect("write malformed package.json");
3541
3542        let err = read_pi_manifest(tmp.path()).expect_err("malformed package.json must error");
3543        let message = err.to_string();
3544        assert!(message.contains("Failed to parse package manifest"));
3545        assert!(message.contains(&manifest_path.display().to_string()));
3546    }
3547
3548    #[test]
3549    fn test_read_pi_manifest_errors_when_pi_field_is_not_object() {
3550        let tmp = tempfile::tempdir().expect("tempdir");
3551        let manifest_path = tmp.path().join("package.json");
3552        fs::write(&manifest_path, r#"{"name":"pkg","pi":"not-an-object"}"#)
3553            .expect("write invalid pi manifest");
3554
3555        let err = read_pi_manifest(tmp.path()).expect_err("non-object `pi` field must error");
3556        let message = err.to_string();
3557        assert!(message.contains("Invalid package manifest"));
3558        assert!(message.contains("`pi` must be an object"));
3559        assert!(message.contains(&manifest_path.display().to_string()));
3560    }
3561
3562    #[test]
3563    fn test_read_pi_manifest_allows_default_fallback_when_pi_key_is_absent() {
3564        let tmp = tempfile::tempdir().expect("tempdir");
3565        let manifest_path = tmp.path().join("package.json");
3566        fs::write(&manifest_path, r#"{"name":"pkg","version":"1.0.0"}"#)
3567            .expect("write package.json");
3568
3569        let pi = read_pi_manifest(tmp.path()).expect("missing `pi` key should not error");
3570        assert!(pi.is_none());
3571    }
3572
3573    #[test]
3574    fn test_append_resources_from_manifest_errors_on_invalid_resource_entry_type() {
3575        let tmp = tempfile::tempdir().expect("tempdir");
3576        let pi = json!({
3577            "extensions": ["ok", 7]
3578        });
3579
3580        let mut resources = PackageResources::default();
3581        let err = append_resources_from_manifest(&mut resources, tmp.path(), &pi)
3582            .expect_err("non-string manifest entries must error");
3583        assert!(
3584            err.to_string()
3585                .contains("`pi.extensions` must be a string or array of strings")
3586        );
3587    }
3588
3589    #[test]
3590    fn test_append_resources_from_manifest_errors_on_outside_root_path() {
3591        let tmp = tempfile::tempdir().expect("tempdir");
3592        let pi = json!({
3593            "skills": "../outside/skills"
3594        });
3595
3596        let mut resources = PackageResources::default();
3597        let err = append_resources_from_manifest(&mut resources, tmp.path(), &pi)
3598            .expect_err("outside-root manifest paths must error");
3599        assert!(
3600            err.to_string()
3601                .contains("`pi.skills` paths must stay within the package root")
3602        );
3603    }
3604
3605    // ── load_skill_from_file with valid skill ──────────────────────────
3606
3607    #[test]
3608    fn test_load_skill_from_file_valid() {
3609        let tmp = tempfile::tempdir().expect("tempdir");
3610        let skill_dir = tmp.path().join("my-skill");
3611        fs::create_dir(&skill_dir).expect("mkdir");
3612        let skill_file = skill_dir.join("SKILL.md");
3613        fs::write(
3614            &skill_file,
3615            "---\nname: my-skill\ndescription: A great skill\n---\nDo something.",
3616        )
3617        .expect("write");
3618
3619        let result = load_skill_from_file(&skill_file, "test".to_string());
3620        assert!(result.skill.is_some());
3621        let skill = result.skill.unwrap();
3622        assert_eq!(skill.name, "my-skill");
3623        assert_eq!(skill.description, "A great skill");
3624    }
3625
3626    #[test]
3627    fn test_load_skill_from_file_missing_description() {
3628        let tmp = tempfile::tempdir().expect("tempdir");
3629        let skill_dir = tmp.path().join("bad-skill");
3630        fs::create_dir(&skill_dir).expect("mkdir");
3631        let skill_file = skill_dir.join("SKILL.md");
3632        fs::write(&skill_file, "---\nname: bad-skill\n---\nContent.").expect("write");
3633
3634        let result = load_skill_from_file(&skill_file, "test".to_string());
3635        assert!(!result.diagnostics.is_empty());
3636    }
3637
3638    #[cfg(unix)]
3639    #[test]
3640    fn test_load_skills_from_dir_ignores_symlink_cycles() {
3641        let tmp = tempfile::tempdir().expect("tempdir");
3642        let skills_root = tmp.path().join("skills");
3643        let skill_dir = skills_root.join("my-skill");
3644        fs::create_dir_all(&skill_dir).expect("mkdir");
3645        fs::write(
3646            skill_dir.join("SKILL.md"),
3647            "---\nname: my-skill\ndescription: Cyclic symlink guard test\n---\nBody",
3648        )
3649        .expect("write skill");
3650
3651        let loop_link = skill_dir.join("loop");
3652        std::os::unix::fs::symlink(&skill_dir, &loop_link).expect("create symlink loop");
3653
3654        let result = load_skills_from_dir(skills_root, "test".to_string(), true);
3655        assert_eq!(result.skills.len(), 1);
3656        assert_eq!(result.skills[0].name, "my-skill");
3657    }
3658
3659    #[cfg(unix)]
3660    #[test]
3661    fn test_load_skills_ignores_alias_symlink_to_same_skill_tree() {
3662        let tmp = tempfile::tempdir().expect("tempdir");
3663        let skills_root = tmp.path().join("skills");
3664        let real_root = skills_root.join("real");
3665        let skill_dir = real_root.join("my-skill");
3666        fs::create_dir_all(&skill_dir).expect("mkdir");
3667        fs::write(
3668            skill_dir.join("SKILL.md"),
3669            "---\nname: my-skill\ndescription: Symlink alias guard test\n---\nBody",
3670        )
3671        .expect("write skill");
3672
3673        std::os::unix::fs::symlink(&real_root, skills_root.join("alias"))
3674            .expect("create alias symlink");
3675
3676        let result = load_skills(LoadSkillsOptions {
3677            cwd: tmp.path().to_path_buf(),
3678            agent_dir: tmp.path().join("agent"),
3679            skill_paths: vec![skills_root],
3680            include_defaults: false,
3681        });
3682
3683        assert_eq!(result.skills.len(), 1);
3684        assert_eq!(result.skills[0].name, "my-skill");
3685        assert!(result.diagnostics.is_empty());
3686    }
3687
3688    #[cfg(unix)]
3689    #[test]
3690    fn test_load_skills_dedupes_diagnostics_across_alias_roots() {
3691        let tmp = tempfile::tempdir().expect("tempdir");
3692        let real_root = tmp.path().join("skills-real");
3693        let alias_root = tmp.path().join("skills-alias");
3694        let skill_dir = real_root.join("my-skill");
3695        fs::create_dir_all(&skill_dir).expect("mkdir");
3696        fs::write(
3697            skill_dir.join("SKILL.md"),
3698            "---\nname: my-skill\ndescription: Alias diagnostic guard test\ninvalid-field: nope\n---\nBody",
3699        )
3700        .expect("write skill");
3701
3702        std::os::unix::fs::symlink(&real_root, &alias_root).expect("create alias root");
3703
3704        let result = load_skills(LoadSkillsOptions {
3705            cwd: tmp.path().to_path_buf(),
3706            agent_dir: tmp.path().join("agent"),
3707            skill_paths: vec![real_root, alias_root],
3708            include_defaults: false,
3709        });
3710
3711        assert_eq!(result.skills.len(), 1);
3712        assert_eq!(result.skills[0].name, "my-skill");
3713        assert_eq!(result.diagnostics.len(), 1);
3714        assert_eq!(result.diagnostics[0].path, skill_dir.join("SKILL.md"));
3715        assert!(
3716            result.diagnostics[0]
3717                .message
3718                .contains("unknown frontmatter field")
3719        );
3720    }
3721
3722    #[test]
3723    fn test_load_skills_prefers_lexicographically_first_duplicate_path() {
3724        let temp = tempfile::tempdir().expect("tempdir");
3725        let root = temp.path().join("skills");
3726        let z_skill = root.join("z").join("dup-skill");
3727        let a_skill = root.join("a").join("dup-skill");
3728        fs::create_dir_all(&z_skill).expect("create z skill dir");
3729        fs::create_dir_all(&a_skill).expect("create a skill dir");
3730        fs::write(
3731            z_skill.join("SKILL.md"),
3732            "---\nname: dup-skill\ndescription: z duplicate\n---\nZ body",
3733        )
3734        .expect("write z skill");
3735        fs::write(
3736            a_skill.join("SKILL.md"),
3737            "---\nname: dup-skill\ndescription: a duplicate\n---\nA body",
3738        )
3739        .expect("write a skill");
3740
3741        let result = load_skills(LoadSkillsOptions {
3742            cwd: temp.path().to_path_buf(),
3743            agent_dir: temp.path().join("agent"),
3744            skill_paths: vec![root],
3745            include_defaults: false,
3746        });
3747
3748        assert_eq!(result.skills.len(), 1);
3749        assert_eq!(result.skills[0].file_path, a_skill.join("SKILL.md"));
3750        assert_eq!(result.diagnostics.len(), 1);
3751        assert_eq!(
3752            result.diagnostics[0]
3753                .collision
3754                .as_ref()
3755                .expect("collision")
3756                .winner_path,
3757            a_skill.join("SKILL.md")
3758        );
3759    }
3760
3761    #[test]
3762    fn test_load_themes_prefers_lexicographically_first_duplicate_stem() {
3763        let temp = tempfile::tempdir().expect("tempdir");
3764        let themes_dir = temp.path().join("themes");
3765        let dark_theme = themes_dir.join("dark.theme");
3766        let dark_ini = themes_dir.join("dark.ini");
3767        fs::create_dir_all(&themes_dir).expect("create themes dir");
3768        fs::write(&dark_theme, "#445566").expect("write theme");
3769        fs::write(&dark_ini, "#112233").expect("write ini");
3770
3771        let loaded = load_themes(LoadThemesOptions {
3772            cwd: temp.path().to_path_buf(),
3773            agent_dir: temp.path().join("agent"),
3774            theme_paths: vec![themes_dir],
3775            include_defaults: false,
3776        });
3777        let (themes, diagnostics) = dedupe_themes(loaded.themes);
3778
3779        assert_eq!(themes.len(), 1);
3780        assert_eq!(diagnostics.len(), 1);
3781        assert_eq!(themes[0].file_path, dark_ini);
3782        assert_eq!(
3783            diagnostics[0]
3784                .collision
3785                .as_ref()
3786                .expect("collision")
3787                .winner_path,
3788            dark_ini
3789        );
3790    }
3791
3792    // ── Property tests ──────────────────────────────────────────────────
3793
3794    mod proptest_resources {
3795        use super::*;
3796        use proptest::prelude::*;
3797
3798        fn arb_valid_name() -> impl Strategy<Value = String> {
3799            "[a-z0-9]([a-z0-9]|(-[a-z0-9])){0,20}"
3800                .prop_filter("no consecutive hyphens", |s| !s.contains("--"))
3801        }
3802
3803        proptest! {
3804            #[test]
3805            fn validate_name_accepts_valid_names(name in arb_valid_name()) {
3806                let errors = validate_name(&name, &name);
3807                assert!(
3808                    errors.is_empty(),
3809                    "valid name '{name}' should have no errors, got: {errors:?}"
3810                );
3811            }
3812
3813            #[test]
3814            fn validate_name_rejects_uppercase(
3815                prefix in "[a-z]{1,5}",
3816                upper in "[A-Z]{1,3}",
3817                suffix in "[a-z]{1,5}",
3818            ) {
3819                let name = format!("{prefix}{upper}{suffix}");
3820                let errors = validate_name(&name, &name);
3821                assert!(
3822                    errors.iter().any(|e| e.contains("invalid characters")),
3823                    "uppercase in '{name}' should be rejected, got: {errors:?}"
3824                );
3825            }
3826
3827            #[test]
3828            fn validate_name_rejects_leading_or_trailing_hyphen(
3829                core in "[a-z]{1,10}",
3830                leading in proptest::bool::ANY,
3831            ) {
3832                let name = if leading {
3833                    format!("-{core}")
3834                } else {
3835                    format!("{core}-")
3836                };
3837                let errors = validate_name(&name, &name);
3838                assert!(
3839                    errors.iter().any(|e| e.contains("must not start or end with a hyphen")),
3840                    "name '{name}' should fail hyphen check, got: {errors:?}"
3841                );
3842            }
3843
3844            #[test]
3845            fn validate_name_rejects_consecutive_hyphens(
3846                left in "[a-z]{1,8}",
3847                right in "[a-z]{1,8}",
3848            ) {
3849                let name = format!("{left}--{right}");
3850                let errors = validate_name(&name, &name);
3851                assert!(
3852                    errors.iter().any(|e| e.contains("consecutive hyphens")),
3853                    "name '{name}' should fail consecutive-hyphen check, got: {errors:?}"
3854                );
3855            }
3856
3857            #[test]
3858            fn validate_name_length_limit_enforced(extra_len in 1..100usize) {
3859                let name: String = "a".repeat(MAX_SKILL_NAME_LEN + extra_len);
3860                let errors = validate_name(&name, &name);
3861                assert!(
3862                    errors.iter().any(|e| e.contains("exceeds")),
3863                    "name of length {} should exceed limit, got: {errors:?}",
3864                    name.len()
3865                );
3866            }
3867
3868            #[test]
3869            fn validate_description_accepts_within_limit(
3870                desc in "[a-zA-Z]{1,5}[a-zA-Z ]{0,95}",
3871            ) {
3872                let errors = validate_description(&desc);
3873                assert!(
3874                    errors.is_empty(),
3875                    "short description should be valid, got: {errors:?}"
3876                );
3877            }
3878
3879            #[test]
3880            fn validate_description_rejects_over_limit(extra in 1..200usize) {
3881                let desc = "x".repeat(MAX_SKILL_DESC_LEN + extra);
3882                let errors = validate_description(&desc);
3883                assert!(
3884                    errors.iter().any(|e| e.contains("exceeds")),
3885                    "description of length {} should exceed limit",
3886                    desc.len()
3887                );
3888            }
3889
3890            #[test]
3891            fn escape_xml_idempotent_on_safe_strings(s in "[a-zA-Z0-9 ]{0,50}") {
3892                assert_eq!(
3893                    escape_xml(&s), s,
3894                    "safe string should pass through unchanged"
3895                );
3896            }
3897
3898            #[test]
3899            fn escape_xml_output_never_contains_raw_special_chars(s in ".*") {
3900                let escaped = escape_xml(&s);
3901                // After escaping, no raw `<`, `>`, `&` (except in escape sequences),
3902                // `"`, or `'` should remain unescaped.
3903                // We check that re-escaping is idempotent on the escaped output.
3904                // A simpler check: the escaped output, when re-escaped, should only
3905                // double-encode the `&` in existing entities.
3906                let double_escaped = escape_xml(&escaped);
3907                // If no raw specials in escaped, then double-escape only affects `&`
3908                // in entities like `&amp;` → `&amp;amp;`.
3909                // We just check the output doesn't contain bare `<` or `>`.
3910                assert!(
3911                    !escaped.contains('<') && !escaped.contains('>'),
3912                    "escaped output should not contain raw < or >: {escaped}"
3913                );
3914                let _ = double_escaped; // suppress unused warning
3915            }
3916
3917            #[test]
3918            fn parse_command_args_round_trip_simple_tokens(
3919                tokens in prop::collection::vec("[a-zA-Z0-9]{1,10}", 0..8),
3920            ) {
3921                let input = tokens.join(" ");
3922                let parsed = parse_command_args(&input);
3923                assert_eq!(
3924                    parsed, tokens,
3925                    "simple space-separated tokens should round-trip"
3926                );
3927            }
3928
3929            #[test]
3930            fn parse_command_args_quoted_preserves_spaces(
3931                before in "[a-z]{1,5}",
3932                inner in "[a-z ]{1,10}",
3933                after in "[a-z]{1,5}",
3934            ) {
3935                let input = format!("{before} \"{inner}\" {after}");
3936                let parsed = parse_command_args(&input);
3937                assert!(
3938                    parsed.contains(&inner),
3939                    "quoted token '{inner}' should appear in parsed output: {parsed:?}"
3940                );
3941            }
3942
3943            #[test]
3944            fn substitute_args_positional_in_range(
3945                idx in 1..10usize,
3946                values in prop::collection::vec("[a-z]{1,5}", 1..10),
3947            ) {
3948                let template = format!("${idx}");
3949                let result = substitute_args(&template, &values);
3950                let expected = values.get(idx.saturating_sub(1)).cloned().unwrap_or_default();
3951                assert_eq!(
3952                    result, expected,
3953                    "positional ${idx} should resolve correctly"
3954                );
3955            }
3956
3957            #[test]
3958            fn substitute_args_dollar_at_is_all_joined(
3959                values in prop::collection::vec("[a-z]{1,5}", 0..8),
3960            ) {
3961                let result = substitute_args("$@", &values);
3962                let expected = values.join(" ");
3963                assert_eq!(result, expected, "$@ should join all args");
3964            }
3965
3966            #[test]
3967            fn substitute_args_arguments_equals_dollar_at(
3968                values in prop::collection::vec("[a-z]{1,5}", 0..8),
3969            ) {
3970                let r1 = substitute_args("$@", &values);
3971                let r2 = substitute_args("$ARGUMENTS", &values);
3972                assert_eq!(r1, r2, "$@ and $ARGUMENTS should be equivalent");
3973            }
3974
3975            #[test]
3976            fn parse_frontmatter_no_dashes_returns_raw_body(
3977                body in "[a-zA-Z0-9 \n]{0,100}",
3978            ) {
3979                let parsed = parse_frontmatter(&body);
3980                assert!(
3981                    parsed.frontmatter.is_empty(),
3982                    "no --- means no frontmatter"
3983                );
3984                assert_eq!(parsed.body, body);
3985            }
3986
3987            #[test]
3988            fn parse_frontmatter_unclosed_returns_raw(
3989                key in "[a-z]{1,8}",
3990                val in "[a-z]{1,8}",
3991            ) {
3992                let raw = format!("---\n{key}: {val}\nmore stuff");
3993                let parsed = parse_frontmatter(&raw);
3994                assert!(
3995                    parsed.frontmatter.is_empty(),
3996                    "unclosed frontmatter should return empty map"
3997                );
3998                assert_eq!(parsed.body, raw);
3999            }
4000
4001            #[test]
4002            fn parse_frontmatter_closed_extracts_key_value(
4003                key in "[a-z]{1,8}",
4004                val in "[a-z]{1,8}",
4005                body in "[a-z ]{0,30}",
4006            ) {
4007                let raw = format!("---\n{key}: {val}\n---\n{body}");
4008                let parsed = parse_frontmatter(&raw);
4009                assert_eq!(
4010                    parsed.frontmatter.get(&key),
4011                    Some(&val),
4012                    "closed frontmatter should extract {key}: {val}"
4013                );
4014                assert_eq!(parsed.body, body);
4015            }
4016
4017            #[test]
4018            fn resolve_path_absolute_is_identity(
4019                suffix in "[a-z]{1,10}(/[a-z]{1,10}){0,3}",
4020            ) {
4021                let abs = format!("/{suffix}");
4022                let cwd = Path::new("/some/cwd");
4023                let resolved = resolve_path(&abs, cwd);
4024                assert_eq!(
4025                    resolved,
4026                    PathBuf::from(&abs),
4027                    "absolute path should pass through unchanged"
4028                );
4029            }
4030
4031            #[test]
4032            fn resolve_path_relative_is_under_cwd(
4033                rel in "[a-z]{1,10}(/[a-z]{1,10}){0,2}",
4034            ) {
4035                let cwd = Path::new("/work/dir");
4036                let resolved = resolve_path(&rel, cwd);
4037                assert!(
4038                    resolved.starts_with(cwd),
4039                    "relative path should resolve under cwd: {resolved:?}"
4040                );
4041            }
4042
4043            #[test]
4044            fn dedupe_paths_preserves_first_and_removes_dups(
4045                paths in prop::collection::vec("[a-z]{1,5}", 1..20),
4046            ) {
4047                let path_bufs: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
4048                let deduped = dedupe_paths(path_bufs.clone());
4049
4050                // All elements in deduped should be unique
4051                let unique: HashSet<String> = deduped.iter()
4052                    .map(|p| p.to_string_lossy().to_string())
4053                    .collect();
4054                assert_eq!(
4055                    deduped.len(), unique.len(),
4056                    "deduped output must contain no duplicates"
4057                );
4058
4059                // First occurrence order preserved
4060                let mut seen = HashSet::new();
4061                let expected: Vec<&PathBuf> = path_bufs.iter()
4062                    .filter(|p| seen.insert(p.to_string_lossy().to_string()))
4063                    .collect();
4064                assert_eq!(
4065                    deduped.iter().collect::<Vec<_>>(), expected,
4066                    "deduped must preserve first-occurrence order"
4067                );
4068            }
4069        }
4070    }
4071}