1use 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#[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
82const 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#[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#[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#[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 #[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 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 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 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 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
647pub 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#[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 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 let canonical_dir = fs::canonicalize(¤t_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(¤t_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('&', "&")
1316 .replace('<', "<")
1317 .replace('>', ">")
1318 .replace('"', """)
1319 .replace('\'', "'")
1320}
1321
1322pub 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
1435pub 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 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
1731fn 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
1737fn 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)] pub fn substitute_args(content: &str, args: &[String]) -> String {
1745 let mut result = content.to_string();
1746
1747 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 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
1832struct 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
1909fn 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
1970fn 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
1986fn 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
2002fn 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
2029fn 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 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 id_to_source_idx.entry(id).or_insert(idx);
2056 }
2057 }
2058 }
2059
2060 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 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 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: ®ex::Regex, mut replacer: F) -> String
2259where
2260 F: FnMut(®ex::Captures<'_>) -> String,
2261{
2262 regex
2263 .replace_all(input, |caps: ®ex::Captures<'_>| replacer(caps))
2264 .to_string()
2265}
2266
2267#[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, "& < > " '");
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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 let double_escaped = escape_xml(&escaped);
3907 assert!(
3911 !escaped.contains('<') && !escaped.contains('>'),
3912 "escaped output should not contain raw < or >: {escaped}"
3913 );
3914 let _ = double_escaped; }
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 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 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}