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