1use std::collections::{HashMap, HashSet};
16use std::fs;
17use std::path::{Path, PathBuf};
18use std::time::Instant;
19
20use parking_lot::RwLock;
21
22#[derive(Debug, Clone, PartialEq, Eq, Hash)]
26pub enum ResourceType {
27 Skill,
29 Extension,
31 Theme,
33 Prompt,
35}
36
37#[derive(Debug)]
39pub struct LoadResult<T> {
40 pub items: Vec<T>,
42 pub errors: Vec<LoadError>,
44 pub diagnostics: Vec<ResourceDiagnostic>,
46}
47
48#[derive(Debug, Clone)]
50pub struct LoadError {
51 pub path: PathBuf,
53 pub error: String,
55}
56
57#[derive(Debug, Clone)]
59pub struct ResourceDiagnostic {
60 pub severity: DiagnosticSeverity,
62 pub message: String,
64 pub path: Option<PathBuf>,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum DiagnosticSeverity {
71 Warning,
73 Error,
75 Info,
77}
78
79#[derive(Debug, Clone)]
81pub struct Skill {
82 pub id: String,
84 pub path: PathBuf,
86 pub content: String,
88 pub name: Option<String>,
90 pub description: Option<String>,
92 pub source: String,
94}
95
96#[derive(Debug, Clone)]
98pub struct Theme {
99 pub id: String,
101 pub name: String,
103 pub path: PathBuf,
105 pub content: serde_json::Value,
107 pub source: String,
109}
110
111#[derive(Debug, Clone)]
113pub struct Prompt {
114 pub id: String,
116 pub name: String,
118 pub path: PathBuf,
120 pub content: String,
122 pub description: Option<String>,
124 pub source: String,
126}
127
128#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
134pub struct ContextFile {
135 pub path: PathBuf,
137 pub name: String,
139 pub priority: u8,
141 pub content: String,
143}
144
145impl ContextFile {
146 pub fn new(path: PathBuf, name: impl Into<String>, priority: u8, content: String) -> Self {
148 Self {
149 path,
150 name: name.into(),
151 priority,
152 content,
153 }
154 }
155
156 pub fn extension(&self) -> Option<String> {
158 self.path
159 .extension()
160 .and_then(|e| e.to_str())
161 .map(|s| s.to_lowercase())
162 }
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum ContextFileType {
168 Agents,
170 Claude,
172}
173
174impl ContextFileType {
175 pub fn filename(&self) -> &'static str {
177 match self {
178 ContextFileType::Agents => "AGENTS.md",
179 ContextFileType::Claude => "CLAUDE.md",
180 }
181 }
182
183 pub fn priority(&self) -> u8 {
185 match self {
186 ContextFileType::Agents => 100,
187 ContextFileType::Claude => 90,
188 }
189 }
190
191 pub fn variants(&self) -> Vec<&'static str> {
193 match self {
194 ContextFileType::Agents => vec!["AGENTS.md", "AGENTS.MD"],
195 ContextFileType::Claude => vec!["CLAUDE.md", "CLAUDE.MD"],
196 }
197 }
198
199 pub fn from_filename(name: &str) -> Option<Self> {
201 let upper = name.to_uppercase();
202 match upper.as_str() {
203 "AGENTS.md" | "AGENTS.MD" => Some(ContextFileType::Agents),
204 "CLAUDE.md" | "CLAUDE.MD" => Some(ContextFileType::Claude),
205 _ => None,
206 }
207 }
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
216#[allow(dead_code)]
217pub enum SourceType {
218 Default,
220 Project,
222 Cli,
224 Inline,
226 Package,
228 Git,
230}
231
232impl std::fmt::Display for SourceType {
233 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234 match self {
235 SourceType::Default => write!(f, "default"),
236 SourceType::Project => write!(f, "project"),
237 SourceType::Cli => write!(f, "cli"),
238 SourceType::Inline => write!(f, "inline"),
239 SourceType::Package => write!(f, "package"),
240 SourceType::Git => write!(f, "git"),
241 }
242 }
243}
244
245#[derive(Debug, Clone)]
247#[allow(dead_code)]
248pub struct Source {
249 pub path: PathBuf,
251 pub source_type: SourceType,
253 pub enabled: bool,
255}
256
257impl Source {
258 #[allow(dead_code)]
260 pub fn new(path: PathBuf, source_type: SourceType) -> Self {
261 Self {
262 path,
263 source_type,
264 enabled: true,
265 }
266 }
267
268 #[allow(dead_code)]
270 pub fn exists(&self) -> bool {
271 self.path.exists()
272 }
273
274 #[allow(dead_code)]
276 pub fn is_dir(&self) -> bool {
277 self.path.is_dir()
278 }
279}
280
281#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
287#[allow(dead_code)]
288pub struct SourceInfo {
289 pub path: PathBuf,
291 pub source: String,
293 pub scope: String,
295 pub origin: String,
297 #[serde(skip_serializing_if = "Option::is_none")]
299 pub base_dir: Option<PathBuf>,
300}
301
302#[derive(Debug, Clone)]
308#[allow(dead_code)]
309pub struct ExtensionSource {
310 #[allow(dead_code)]
312 pub path: PathBuf,
313 #[allow(dead_code)]
315 pub metadata: PathMetadata,
316 #[allow(dead_code)]
318 pub source_info: Option<SourceInfo>,
319}
320
321#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
323pub struct PathMetadata {
324 pub source: String,
326 pub scope: String,
328 pub origin: String,
330}
331
332impl Default for PathMetadata {
333 fn default() -> Self {
334 Self {
335 source: "local".to_string(),
336 scope: "user".to_string(),
337 origin: "top-level".to_string(),
338 }
339 }
340}
341
342impl PathMetadata {
343 pub fn cli() -> Self {
345 Self {
346 source: "cli".to_string(),
347 scope: "temporary".to_string(),
348 origin: "top-level".to_string(),
349 }
350 }
351
352 pub fn project() -> Self {
354 Self {
355 source: "local".to_string(),
356 scope: "project".to_string(),
357 origin: "top-level".to_string(),
358 }
359 }
360
361 pub fn user() -> Self {
363 Self {
364 source: "local".to_string(),
365 scope: "user".to_string(),
366 origin: "top-level".to_string(),
367 }
368 }
369}
370
371#[derive(Debug, Clone)]
373pub struct SkillSource {
374 pub path: PathBuf,
376 #[allow(dead_code)]
378 pub metadata: PathMetadata,
379 pub enabled: bool,
381}
382
383#[derive(Debug, Clone)]
385pub struct ThemeSource {
386 pub path: PathBuf,
388 #[allow(dead_code)]
390 pub metadata: PathMetadata,
391 pub enabled: bool,
393}
394
395#[derive(Debug, Clone)]
397pub struct PromptSource {
398 pub path: PathBuf,
400 #[allow(dead_code)]
402 pub metadata: PathMetadata,
403 pub enabled: bool,
405}
406
407#[derive(Debug, Clone)]
413pub struct ResourceCollision {
414 pub resource_type: String,
416 pub name: String,
418 pub winner_path: PathBuf,
420 pub loser_path: PathBuf,
422}
423
424impl std::fmt::Display for ResourceCollision {
425 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
426 write!(
427 f,
428 "{} '{}' collision: {} vs {}",
429 self.resource_type,
430 self.name,
431 self.winner_path.display(),
432 self.loser_path.display()
433 )
434 }
435}
436
437#[derive(Debug, Clone)]
443pub struct LoadedResources {
444 pub skills: Vec<Skill>,
446 pub themes: Vec<Theme>,
448 pub prompts: Vec<Prompt>,
450 pub context_files: Vec<ContextFile>,
452 pub system_prompt: Option<String>,
454 pub append_system_prompt: Vec<String>,
456 pub errors: Vec<LoadError>,
458 pub diagnostics: Vec<ResourceDiagnostic>,
460 pub collisions: Vec<ResourceCollision>,
462 pub loaded_at: Instant,
464}
465
466impl Default for LoadedResources {
467 fn default() -> Self {
468 Self {
469 skills: Vec::new(),
470 themes: Vec::new(),
471 prompts: Vec::new(),
472 context_files: Vec::new(),
473 system_prompt: None,
474 append_system_prompt: Vec::new(),
475 errors: Vec::new(),
476 diagnostics: Vec::new(),
477 collisions: Vec::new(),
478 loaded_at: Instant::now(),
479 }
480 }
481}
482
483#[derive(Debug, Clone)]
489pub struct ResourceLoaderOptions {
490 pub cwd: PathBuf,
492 pub agent_dir: PathBuf,
494 pub additional_extension_paths: Vec<PathBuf>,
496 pub additional_skill_paths: Vec<PathBuf>,
498 pub additional_prompt_paths: Vec<PathBuf>,
500 pub additional_theme_paths: Vec<PathBuf>,
502 pub no_extensions: bool,
504 pub no_skills: bool,
506 pub no_prompts: bool,
508 pub no_themes: bool,
510 pub no_context_files: bool,
512 pub system_prompt: Option<String>,
514 pub append_system_prompt: Vec<String>,
516}
517
518impl ResourceLoaderOptions {
519 pub fn new() -> Self {
521 let agent_dir = default_resource_dir();
522 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
523
524 Self {
525 cwd,
526 agent_dir,
527 additional_extension_paths: Vec::new(),
528 additional_skill_paths: Vec::new(),
529 additional_prompt_paths: Vec::new(),
530 additional_theme_paths: Vec::new(),
531 no_extensions: false,
532 no_skills: false,
533 no_prompts: false,
534 no_themes: false,
535 no_context_files: false,
536 system_prompt: None,
537 append_system_prompt: Vec::new(),
538 }
539 }
540}
541
542impl Default for ResourceLoaderOptions {
543 fn default() -> Self {
544 Self::new()
545 }
546}
547
548pub struct ResourceLoader {
558 options: ResourceLoaderOptions,
560 extensions: Vec<ExtensionSource>,
562 skills: Vec<SkillSource>,
564 themes: Vec<ThemeSource>,
566 prompts: Vec<PromptSource>,
568 cache: RwLock<Option<LoadedResources>>,
570 modification_times: RwLock<HashMap<PathBuf, std::time::SystemTime>>,
572}
573
574impl Default for ResourceLoader {
575 fn default() -> Self {
576 Self::new()
577 }
578}
579
580impl ResourceLoader {
581 pub fn new() -> Self {
583 Self::with_options(ResourceLoaderOptions::new())
584 }
585
586 pub fn with_options(options: ResourceLoaderOptions) -> Self {
588 Self {
589 options,
590 extensions: Vec::new(),
591 skills: Vec::new(),
592 themes: Vec::new(),
593 prompts: Vec::new(),
594 cache: RwLock::new(None),
595 modification_times: RwLock::new(HashMap::new()),
596 }
597 }
598
599 pub fn with_paths(base_dir: PathBuf, cwd: PathBuf) -> Self {
601 let options = ResourceLoaderOptions {
602 cwd,
603 agent_dir: base_dir,
604 ..ResourceLoaderOptions::default()
605 };
606 Self::with_options(options)
607 }
608
609 pub fn with_base_dir(&mut self, base_dir: PathBuf) -> &mut Self {
615 self.options.agent_dir = base_dir;
616 self
617 }
618
619 pub fn with_cwd(&mut self, cwd: PathBuf) -> &mut Self {
621 self.options.cwd = cwd;
622 self
623 }
624
625 pub fn add_extension(&mut self, path: PathBuf) -> &mut Self {
627 self.extensions.push(ExtensionSource {
628 path: resolve_path(&path),
629 metadata: PathMetadata::cli(),
630 source_info: None,
631 });
632 self
633 }
634
635 pub fn add_skill(&mut self, path: PathBuf) -> &mut Self {
637 self.skills.push(SkillSource {
638 path: resolve_path(&path),
639 metadata: PathMetadata::cli(),
640 enabled: true,
641 });
642 self
643 }
644
645 pub fn add_theme(&mut self, path: PathBuf) -> &mut Self {
647 self.themes.push(ThemeSource {
648 path: resolve_path(&path),
649 metadata: PathMetadata::cli(),
650 enabled: true,
651 });
652 self
653 }
654
655 pub fn add_prompt(&mut self, path: PathBuf) -> &mut Self {
657 self.prompts.push(PromptSource {
658 path: resolve_path(&path),
659 metadata: PathMetadata::cli(),
660 enabled: true,
661 });
662 self
663 }
664
665 pub fn extend_resources(
667 &mut self,
668 skill_paths: Vec<(PathBuf, PathMetadata)>,
669 prompt_paths: Vec<(PathBuf, PathMetadata)>,
670 theme_paths: Vec<(PathBuf, PathMetadata)>,
671 ) {
672 for (path, meta) in skill_paths {
673 self.skills.push(SkillSource {
674 path,
675 metadata: meta,
676 enabled: true,
677 });
678 }
679 for (path, meta) in prompt_paths {
680 self.prompts.push(PromptSource {
681 path,
682 metadata: meta,
683 enabled: true,
684 });
685 }
686 for (path, meta) in theme_paths {
687 self.themes.push(ThemeSource {
688 path,
689 metadata: meta,
690 enabled: true,
691 });
692 }
693 }
694
695 pub fn load_all(&self) -> Result<LoadedResources, anyhow::Error> {
701 let mut result = LoadedResources::default();
702
703 let skills = self.load_skills_internal();
705 result.skills = skills.items;
706 result.errors.extend(skills.errors);
707 result.diagnostics.extend(skills.diagnostics);
708
709 let themes = self.load_themes_internal();
711 result.themes = themes.items;
712 result.errors.extend(themes.errors);
713 result.diagnostics.extend(themes.diagnostics);
714
715 let prompts = self.load_prompts_internal();
717 result.prompts = prompts.items;
718 result.errors.extend(prompts.errors);
719 result.diagnostics.extend(prompts.diagnostics);
720
721 let (deduped_skills, skill_collisions) = dedupe_skills(result.skills);
723 result.skills = deduped_skills;
724 result.collisions.extend(skill_collisions);
725
726 let (deduped_themes, theme_collisions) = dedupe_themes(result.themes);
727 result.themes = deduped_themes;
728 result.collisions.extend(theme_collisions);
729
730 let (deduped_prompts, prompt_collisions) = dedupe_prompts(result.prompts);
731 result.prompts = deduped_prompts;
732 result.collisions.extend(prompt_collisions);
733
734 if !self.options.no_context_files {
736 result.context_files = self.load_project_context_files(&self.options.cwd)?;
737 }
738
739 result.system_prompt = self.load_system_prompt()?;
741 result.append_system_prompt = self.load_append_system_prompt()?;
742
743 self.update_modification_times(&result);
745
746 *self.cache.write() = Some(result.clone());
748
749 Ok(result)
750 }
751
752 pub fn try_load_all(&self) -> LoadedResources {
754 self.load_all().unwrap_or_else(|e| LoadedResources {
755 errors: vec![LoadError {
756 path: PathBuf::from("."),
757 error: e.to_string(),
758 }],
759 ..LoadedResources::default()
760 })
761 }
762
763 pub fn reload(&self) -> Result<LoadedResources, anyhow::Error> {
765 self.clear_cache();
766 self.load_all()
767 }
768
769 pub fn load_system_prompt(&self) -> Result<Option<String>, anyhow::Error> {
775 if let Some(ref prompt) = self.options.system_prompt {
777 return Ok(resolve_prompt_input(prompt, "system prompt"));
778 }
779
780 let candidates = vec![
782 self.options.cwd.join(".oxi").join("SYSTEM.md"),
784 self.options.agent_dir.join("SYSTEM.md"),
786 ];
787
788 for path in candidates {
789 if path.exists() && path.is_file() {
790 match fs::read_to_string(&path) {
791 Ok(content) => return Ok(Some(content)),
792 Err(e) => {
793 tracing::warn!("Failed to read system prompt {}: {}", path.display(), e);
794 }
795 }
796 }
797 }
798
799 Ok(None)
800 }
801
802 pub fn load_append_system_prompt(&self) -> Result<Vec<String>, anyhow::Error> {
804 if !self.options.append_system_prompt.is_empty() {
806 return Ok(self
807 .options
808 .append_system_prompt
809 .iter()
810 .filter_map(|s| resolve_prompt_input(s, "append system prompt"))
811 .collect());
812 }
813
814 let mut result = Vec::new();
815
816 let candidates = vec![
817 self.options.cwd.join(".oxi").join("APPEND_SYSTEM.md"),
819 self.options.agent_dir.join("APPEND_SYSTEM.md"),
821 ];
822
823 for path in candidates {
824 if path.exists() && path.is_file() {
825 match fs::read_to_string(&path) {
826 Ok(content) => result.push(content),
827 Err(e) => {
828 tracing::warn!(
829 "Failed to read append system prompt {}: {}",
830 path.display(),
831 e
832 );
833 }
834 }
835 }
836 }
837
838 Ok(result)
839 }
840
841 pub fn load_project_context_files(
847 &self,
848 cwd: &Path,
849 ) -> Result<Vec<ContextFile>, anyhow::Error> {
850 let mut context_files = Vec::new();
851 let mut seen_paths: HashMap<String, bool> = HashMap::new();
852
853 let global_context = load_context_file_from_dir(&self.options.agent_dir);
855 if let Some((path, content)) = global_context {
856 let name = path
857 .file_name()
858 .and_then(|n| n.to_str())
859 .unwrap_or("unknown")
860 .to_string();
861 let file_type = ContextFileType::from_filename(&name);
862 let priority = file_type.map(|ft| ft.priority()).unwrap_or(80);
863 let path_str = path.to_string_lossy().to_string();
864 seen_paths.insert(path_str, true);
865 context_files.push(ContextFile::new(path, name, priority, content));
866 }
867
868 let discovered = self.discover_context_files(cwd);
870
871 for (path, file_type) in discovered {
872 let path_str = path.to_string_lossy().to_string();
873 if seen_paths.contains_key(&path_str) {
874 continue;
875 }
876
877 if let Some(content) = self.read_context_file(&path)? {
878 seen_paths.insert(path_str, true);
879 let name = path
880 .file_name()
881 .and_then(|n| n.to_str())
882 .unwrap_or("unknown")
883 .to_string();
884 context_files.push(ContextFile::new(path, name, file_type.priority(), content));
885 }
886 }
887
888 context_files.sort_by_key(|b| std::cmp::Reverse(b.priority));
890
891 Ok(context_files)
892 }
893
894 pub fn discover_context_files(&self, dir: &Path) -> Vec<(PathBuf, ContextFileType)> {
896 let mut discovered = Vec::new();
897 let file_types = [ContextFileType::Agents, ContextFileType::Claude];
898
899 let git_root = find_git_root(dir);
901
902 let mut current = dir.to_path_buf();
903 let root = PathBuf::from("/");
904
905 let max_iterations = 50;
906 let mut iterations = 0;
907
908 while current != root && iterations < max_iterations {
909 if let Some(ref git_r) = git_root {
911 if current == *git_r || !current.starts_with(git_r) {
912 break;
913 }
914 }
915
916 for file_type in &file_types {
917 for variant in file_type.variants() {
918 let candidate = current.join(variant);
919 if candidate.exists() && candidate.is_file() {
920 discovered.push((candidate, *file_type));
921 }
922 }
923 }
924
925 if let Some(parent) = current.parent() {
927 current = parent.to_path_buf();
928 } else {
929 break;
930 }
931 iterations += 1;
932 }
933
934 let mut seen = HashSet::new();
936 discovered.retain(|(path, _)| {
937 let path_str = path.to_string_lossy().to_string();
938 if seen.contains(&path_str) {
939 false
940 } else {
941 seen.insert(path_str);
942 true
943 }
944 });
945
946 discovered
947 }
948
949 fn read_context_file(&self, path: &Path) -> Result<Option<String>, anyhow::Error> {
951 match fs::read_to_string(path) {
952 Ok(content) => Ok(Some(content)),
953 Err(e) => {
954 tracing::warn!("Failed to read context file {}: {}", path.display(), e);
955 Ok(None)
956 }
957 }
958 }
959
960 fn load_skills_internal(&self) -> LoadResult<Skill> {
966 let mut items = Vec::new();
967 let mut errors = Vec::new();
968 let mut diagnostics = Vec::new();
969
970 if !self.options.no_skills {
972 let skills_base = skills_dir(&self.options.agent_dir);
973 let project_skills = self.options.cwd.join(".oxi").join("skills");
974
975 for dir in &[skills_base, project_skills] {
976 if dir.exists() {
977 let result = load_skills_from_dir(dir);
978 items.extend(result.items);
979 errors.extend(result.errors);
980 diagnostics.extend(result.diagnostics);
981 }
982 }
983 }
984
985 for source in &self.skills {
987 if !source.enabled {
988 continue;
989 }
990 if source.path.exists() {
991 match load_skill(&source.path) {
992 Ok(skill) => items.push(skill),
993 Err(e) => {
994 errors.push(LoadError {
995 path: source.path.clone(),
996 error: e,
997 });
998 }
999 }
1000 }
1001 }
1002
1003 LoadResult {
1004 items,
1005 errors,
1006 diagnostics,
1007 }
1008 }
1009
1010 fn load_themes_internal(&self) -> LoadResult<Theme> {
1012 let mut items = Vec::new();
1013 let mut errors = Vec::new();
1014 let mut diagnostics = Vec::new();
1015
1016 if !self.options.no_themes {
1017 let themes_base = themes_dir(&self.options.agent_dir);
1018 let project_themes = self.options.cwd.join(".oxi").join("themes");
1019
1020 for dir in &[themes_base, project_themes] {
1021 if dir.exists() {
1022 let result = load_themes_from_dir(dir);
1023 items.extend(result.items);
1024 errors.extend(result.errors);
1025 diagnostics.extend(result.diagnostics);
1026 }
1027 }
1028 }
1029
1030 for source in &self.themes {
1031 if !source.enabled {
1032 continue;
1033 }
1034 if source.path.exists() {
1035 match load_theme(&source.path) {
1036 Ok(theme) => items.push(theme),
1037 Err(e) => {
1038 errors.push(LoadError {
1039 path: source.path.clone(),
1040 error: e,
1041 });
1042 }
1043 }
1044 }
1045 }
1046
1047 LoadResult {
1048 items,
1049 errors,
1050 diagnostics,
1051 }
1052 }
1053
1054 fn load_prompts_internal(&self) -> LoadResult<Prompt> {
1056 let mut items = Vec::new();
1057 let mut errors = Vec::new();
1058 let mut diagnostics = Vec::new();
1059
1060 if !self.options.no_prompts {
1061 let prompts_base = prompts_dir(&self.options.agent_dir);
1062 let project_prompts = self.options.cwd.join(".oxi").join("prompts");
1063
1064 for dir in &[prompts_base, project_prompts] {
1065 if dir.exists() {
1066 let result = load_prompts_from_dir(dir);
1067 items.extend(result.items);
1068 errors.extend(result.errors);
1069 diagnostics.extend(result.diagnostics);
1070 }
1071 }
1072 }
1073
1074 for source in &self.prompts {
1075 if !source.enabled {
1076 continue;
1077 }
1078 if source.path.exists() {
1079 match load_prompt(&source.path) {
1080 Ok(prompt) => items.push(prompt),
1081 Err(e) => {
1082 errors.push(LoadError {
1083 path: source.path.clone(),
1084 error: e,
1085 });
1086 }
1087 }
1088 }
1089 }
1090
1091 LoadResult {
1092 items,
1093 errors,
1094 diagnostics,
1095 }
1096 }
1097
1098 pub fn cached(&self) -> Option<LoadedResources> {
1104 self.cache.read().clone()
1105 }
1106
1107 pub fn clear_cache(&self) {
1109 *self.cache.write() = None;
1110 }
1111
1112 pub fn is_cache_stale(&self) -> bool {
1114 let cache = self.cache.read();
1115 if cache.is_none() {
1116 return true; }
1118
1119 let mtimes = self.modification_times.read();
1120 if mtimes.is_empty() {
1121 return false; }
1123
1124 for (path, last_time) in mtimes.iter() {
1125 if let Ok(metadata) = fs::metadata(path) {
1126 if let Ok(modified) = metadata.modified() {
1127 if modified > *last_time {
1128 return true;
1129 }
1130 }
1131 }
1132 }
1133
1134 false
1135 }
1136
1137 pub fn load_if_stale(&self) -> Result<LoadedResources, anyhow::Error> {
1139 if self.is_cache_stale() {
1140 self.reload()
1141 } else if let Some(cached) = self.cached() {
1142 Ok(cached)
1143 } else {
1144 self.load_all()
1145 }
1146 }
1147
1148 fn update_modification_times(&self, result: &LoadedResources) {
1150 let mut mtimes = self.modification_times.write();
1151 mtimes.clear();
1152
1153 let paths: Vec<PathBuf> = {
1154 let mut p = Vec::new();
1155 for s in &result.skills {
1156 p.push(s.path.clone());
1157 }
1158 for t in &result.themes {
1159 p.push(t.path.clone());
1160 }
1161 for pr in &result.prompts {
1162 p.push(pr.path.clone());
1163 }
1164 for cf in &result.context_files {
1165 p.push(cf.path.clone());
1166 }
1167 p
1168 };
1169
1170 for path in paths {
1171 if let Ok(metadata) = fs::metadata(&path) {
1172 if let Ok(modified) = metadata.modified() {
1173 mtimes.insert(path, modified);
1174 }
1175 }
1176 }
1177 }
1178
1179 pub fn detect_resource_type(path: &Path) -> Option<ResourceType> {
1185 if !path.exists() {
1186 return None;
1187 }
1188
1189 if path.is_dir() {
1190 if path.join("SKILL.md").exists() {
1192 return Some(ResourceType::Skill);
1193 }
1194 if path.join("package.json").exists() || path.join("extension.json").exists() {
1195 return Some(ResourceType::Extension);
1196 }
1197 return Some(ResourceType::Skill);
1199 }
1200
1201 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
1203 match ext {
1204 "md" => Some(ResourceType::Skill),
1205 "json" => Some(ResourceType::Theme),
1206 "js" | "ts" => Some(ResourceType::Extension),
1207 _ => None,
1208 }
1209 }
1210
1211 pub fn is_valid_resource_path(path: &Path, resource_type: ResourceType) -> bool {
1213 if !path.exists() {
1214 return false;
1215 }
1216 match resource_type {
1217 ResourceType::Skill => {
1218 path.is_dir() || path.extension().map(|e| e == "md").unwrap_or(false)
1219 }
1220 ResourceType::Theme => path.extension().map(|e| e == "json").unwrap_or(false),
1221 ResourceType::Prompt => path.extension().map(|e| e == "md").unwrap_or(false),
1222 ResourceType::Extension => path
1223 .extension()
1224 .map(|e| e == "js" || e == "ts")
1225 .unwrap_or(false),
1226 }
1227 }
1228
1229 pub fn validate_resource_path(path: &Path) -> Result<ResourceType, String> {
1231 if !path.exists() {
1232 return Err(format!("Path does not exist: {}", path.display()));
1233 }
1234
1235 Self::detect_resource_type(path)
1236 .ok_or_else(|| format!("Cannot determine resource type for: {}", path.display()))
1237 }
1238
1239 pub fn cwd(&self) -> &Path {
1245 &self.options.cwd
1246 }
1247
1248 pub fn agent_dir(&self) -> &Path {
1250 &self.options.agent_dir
1251 }
1252
1253 pub fn get_skills(&self) -> Vec<Skill> {
1255 self.cache
1256 .read()
1257 .as_ref()
1258 .map(|c| c.skills.clone())
1259 .unwrap_or_default()
1260 }
1261
1262 pub fn get_themes(&self) -> Vec<Theme> {
1264 self.cache
1265 .read()
1266 .as_ref()
1267 .map(|c| c.themes.clone())
1268 .unwrap_or_default()
1269 }
1270
1271 pub fn get_prompts(&self) -> Vec<Prompt> {
1273 self.cache
1274 .read()
1275 .as_ref()
1276 .map(|c| c.prompts.clone())
1277 .unwrap_or_default()
1278 }
1279
1280 pub fn get_context_files(&self) -> Vec<ContextFile> {
1282 self.cache
1283 .read()
1284 .as_ref()
1285 .map(|c| c.context_files.clone())
1286 .unwrap_or_default()
1287 }
1288
1289 pub fn get_system_prompt(&self) -> Option<String> {
1291 self.cache
1292 .read()
1293 .as_ref()
1294 .and_then(|c| c.system_prompt.clone())
1295 }
1296
1297 pub fn get_append_system_prompt(&self) -> Vec<String> {
1299 self.cache
1300 .read()
1301 .as_ref()
1302 .map(|c| c.append_system_prompt.clone())
1303 .unwrap_or_default()
1304 }
1305
1306 pub fn get_agents_files(&self) -> Vec<(PathBuf, String)> {
1308 self.cache
1309 .read()
1310 .as_ref()
1311 .map(|c| {
1312 c.context_files
1313 .iter()
1314 .map(|cf| (cf.path.clone(), cf.content.clone()))
1315 .collect()
1316 })
1317 .unwrap_or_default()
1318 }
1319}
1320
1321pub fn load_context_file_from_dir(dir: &Path) -> Option<(PathBuf, String)> {
1327 let candidates = ["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
1328 for filename in &candidates {
1329 let file_path = dir.join(filename);
1330 if file_path.exists() {
1331 match fs::read_to_string(&file_path) {
1332 Ok(content) => return Some((file_path, content)),
1333 Err(e) => {
1334 tracing::warn!("Warning: Could not read {}: {}", file_path.display(), e);
1335 }
1336 }
1337 }
1338 }
1339 None
1340}
1341
1342pub fn find_git_root(dir: &Path) -> Option<PathBuf> {
1344 let mut current = dir.to_path_buf();
1345 let root = PathBuf::from("/");
1346
1347 let max_iterations = 20;
1348 let mut iterations = 0;
1349
1350 while current != root && iterations < max_iterations {
1351 if current.join(".git").exists() {
1352 return Some(current);
1353 }
1354 if let Some(parent) = current.parent() {
1355 current = parent.to_path_buf();
1356 } else {
1357 break;
1358 }
1359 iterations += 1;
1360 }
1361
1362 None
1363}
1364
1365pub fn resolve_prompt_input(input: &str, description: &str) -> Option<String> {
1367 if input.is_empty() {
1368 return None;
1369 }
1370
1371 let path = Path::new(input);
1372 if path.exists() {
1373 match fs::read_to_string(path) {
1374 Ok(content) => Some(content),
1375 Err(e) => {
1376 tracing::warn!(
1377 "Warning: Could not read {} file {}: {}",
1378 description,
1379 input,
1380 e
1381 );
1382 Some(input.to_string())
1383 }
1384 }
1385 } else {
1386 Some(input.to_string())
1387 }
1388}
1389
1390pub fn default_resource_dir() -> std::path::PathBuf {
1392 dirs::config_dir()
1393 .unwrap_or_else(|| std::path::PathBuf::from("."))
1394 .join("oxi")
1395}
1396
1397pub fn skills_dir(base: &std::path::Path) -> std::path::PathBuf {
1399 base.join("skills")
1400}
1401
1402#[allow(dead_code)]
1404pub fn extensions_dir(base: &std::path::Path) -> std::path::PathBuf {
1405 base.join("extensions")
1406}
1407
1408pub fn themes_dir(base: &std::path::Path) -> std::path::PathBuf {
1410 base.join("themes")
1411}
1412
1413pub fn prompts_dir(base: &std::path::Path) -> std::path::PathBuf {
1415 base.join("prompts")
1416}
1417
1418pub fn load_skills_from_dir(dir: &std::path::Path) -> LoadResult<Skill> {
1420 let mut items = Vec::new();
1421 let mut errors = Vec::new();
1422 let mut diagnostics = Vec::new();
1423
1424 if !dir.exists() {
1425 return LoadResult {
1426 items,
1427 errors,
1428 diagnostics,
1429 };
1430 }
1431
1432 if let Ok(entries) = fs::read_dir(dir) {
1433 for entry in entries.flatten() {
1434 let path = entry.path();
1435 if path.is_dir() || path.extension().map(|e| e == "md").unwrap_or(false) {
1436 match load_skill(&path) {
1437 Ok(skill) => items.push(skill),
1438 Err(e) => {
1439 errors.push(LoadError {
1440 path: path.clone(),
1441 error: e.clone(),
1442 });
1443 diagnostics.push(ResourceDiagnostic {
1444 severity: DiagnosticSeverity::Error,
1445 message: e,
1446 path: Some(path),
1447 });
1448 }
1449 }
1450 }
1451 }
1452 }
1453
1454 LoadResult {
1455 items,
1456 errors,
1457 diagnostics,
1458 }
1459}
1460
1461pub fn load_skill(path: &std::path::Path) -> Result<Skill, String> {
1463 let content = if path.is_file() {
1464 fs::read_to_string(path).map_err(|e| e.to_string())?
1465 } else if path.is_dir() {
1466 let skill_md = path.join("SKILL.md");
1467 if skill_md.exists() {
1468 fs::read_to_string(&skill_md).map_err(|e| e.to_string())?
1469 } else {
1470 return Err("No SKILL.md found in directory".to_string());
1471 }
1472 } else {
1473 return Err("Invalid skill path".to_string());
1474 };
1475
1476 let id = path
1477 .file_stem()
1478 .and_then(|s| s.to_str())
1479 .unwrap_or("unknown")
1480 .to_string();
1481
1482 let name = extract_yaml_field(&content, "name").or_else(|| Some(id.clone()));
1483 let description = extract_yaml_field(&content, "description");
1484
1485 Ok(Skill {
1486 id,
1487 path: path.to_path_buf(),
1488 content,
1489 name,
1490 description,
1491 source: "local".to_string(),
1492 })
1493}
1494
1495pub fn load_themes_from_dir(dir: &std::path::Path) -> LoadResult<Theme> {
1497 let mut items = Vec::new();
1498 let mut errors = Vec::new();
1499 let mut diagnostics = Vec::new();
1500
1501 if !dir.exists() {
1502 return LoadResult {
1503 items,
1504 errors,
1505 diagnostics,
1506 };
1507 }
1508
1509 if let Ok(entries) = fs::read_dir(dir) {
1510 for entry in entries.flatten() {
1511 let path = entry.path();
1512 if path.extension().map(|e| e == "json").unwrap_or(false) {
1513 match load_theme(&path) {
1514 Ok(theme) => items.push(theme),
1515 Err(e) => {
1516 errors.push(LoadError {
1517 path: path.clone(),
1518 error: e.clone(),
1519 });
1520 diagnostics.push(ResourceDiagnostic {
1521 severity: DiagnosticSeverity::Warning,
1522 message: e,
1523 path: Some(path),
1524 });
1525 }
1526 }
1527 }
1528 }
1529 }
1530
1531 LoadResult {
1532 items,
1533 errors,
1534 diagnostics,
1535 }
1536}
1537
1538pub fn load_theme(path: &std::path::Path) -> Result<Theme, String> {
1540 let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
1541 let json: serde_json::Value = serde_json::from_str(&content).map_err(|e| e.to_string())?;
1542
1543 let name = json
1544 .get("name")
1545 .and_then(|v| v.as_str())
1546 .map(String::from)
1547 .unwrap_or_else(|| {
1548 path.file_stem()
1549 .and_then(|s| s.to_str())
1550 .unwrap_or("unnamed")
1551 .to_string()
1552 });
1553
1554 Ok(Theme {
1555 id: name.to_lowercase().replace(' ', "_"),
1556 name,
1557 path: path.to_path_buf(),
1558 content: json,
1559 source: "local".to_string(),
1560 })
1561}
1562
1563pub fn load_prompts_from_dir(dir: &std::path::Path) -> LoadResult<Prompt> {
1565 let mut items = Vec::new();
1566 let mut errors = Vec::new();
1567 let mut diagnostics = Vec::new();
1568
1569 if !dir.exists() {
1570 return LoadResult {
1571 items,
1572 errors,
1573 diagnostics,
1574 };
1575 }
1576
1577 if let Ok(entries) = fs::read_dir(dir) {
1578 for entry in entries.flatten() {
1579 let path = entry.path();
1580 if path.is_file() && path.extension().map(|e| e == "md").unwrap_or(false) {
1581 match load_prompt(&path) {
1582 Ok(prompt) => items.push(prompt),
1583 Err(e) => {
1584 errors.push(LoadError {
1585 path: path.clone(),
1586 error: e.clone(),
1587 });
1588 diagnostics.push(ResourceDiagnostic {
1589 severity: DiagnosticSeverity::Warning,
1590 message: e,
1591 path: Some(path),
1592 });
1593 }
1594 }
1595 }
1596 }
1597 }
1598
1599 LoadResult {
1600 items,
1601 errors,
1602 diagnostics,
1603 }
1604}
1605
1606pub fn load_prompt(path: &std::path::Path) -> Result<Prompt, String> {
1608 let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
1609
1610 let name = path
1611 .file_stem()
1612 .and_then(|s| s.to_str())
1613 .unwrap_or("unknown")
1614 .to_string();
1615
1616 Ok(Prompt {
1617 id: name.clone(),
1618 name,
1619 path: path.to_path_buf(),
1620 content,
1621 description: None,
1622 source: "local".to_string(),
1623 })
1624}
1625
1626pub fn resolve_path(path: &std::path::Path) -> std::path::PathBuf {
1628 let path_str = path.to_string_lossy();
1629 if path_str.starts_with("~/") {
1630 if let Some(home) = dirs::home_dir() {
1631 return home.join(path_str.strip_prefix("~/").expect("starts_with checked"));
1633 }
1634 }
1635 path.to_path_buf()
1636}
1637
1638fn extract_yaml_field(content: &str, field: &str) -> Option<String> {
1640 if !content.starts_with("---") {
1641 return None;
1642 }
1643
1644 if let Some(end) = content[3..].find("---") {
1645 let frontmatter = &content[3..end + 3];
1646 for line in frontmatter.lines() {
1647 if let Some(value) = line.strip_prefix(&format!("{}:", field)) {
1648 let value = value.trim();
1649 let value = value.trim_matches('"').trim_matches('\'');
1650 return Some(value.to_string());
1651 }
1652 }
1653 }
1654
1655 None
1656}
1657
1658fn dedupe_skills(skills: Vec<Skill>) -> (Vec<Skill>, Vec<ResourceCollision>) {
1664 let mut seen: HashMap<String, usize> = HashMap::new();
1665 let mut result: Vec<Skill> = Vec::new();
1666 let mut collisions = Vec::new();
1667
1668 for skill in skills {
1669 if let Some(&existing_idx) = seen.get(&skill.id) {
1670 collisions.push(ResourceCollision {
1671 resource_type: "skill".to_string(),
1672 name: skill.id.clone(),
1673 winner_path: result[existing_idx].path.clone(),
1674 loser_path: skill.path.clone(),
1675 });
1676 } else {
1677 seen.insert(skill.id.clone(), result.len());
1678 result.push(skill);
1679 }
1680 }
1681
1682 (result, collisions)
1683}
1684
1685fn dedupe_themes(themes: Vec<Theme>) -> (Vec<Theme>, Vec<ResourceCollision>) {
1687 let mut seen: HashMap<String, usize> = HashMap::new();
1688 let mut result: Vec<Theme> = Vec::new();
1689 let mut collisions = Vec::new();
1690
1691 for theme in themes {
1692 let name = theme.name.clone();
1693 if let Some(&existing_idx) = seen.get(&name) {
1694 collisions.push(ResourceCollision {
1695 resource_type: "theme".to_string(),
1696 name: name.clone(),
1697 winner_path: result[existing_idx].path.clone(),
1698 loser_path: theme.path.clone(),
1699 });
1700 } else {
1701 seen.insert(name, result.len());
1702 result.push(theme);
1703 }
1704 }
1705
1706 (result, collisions)
1707}
1708
1709fn dedupe_prompts(prompts: Vec<Prompt>) -> (Vec<Prompt>, Vec<ResourceCollision>) {
1711 let mut seen: HashMap<String, usize> = HashMap::new();
1712 let mut result: Vec<Prompt> = Vec::new();
1713 let mut collisions = Vec::new();
1714
1715 for prompt in prompts {
1716 if let Some(&existing_idx) = seen.get(&prompt.name) {
1717 collisions.push(ResourceCollision {
1718 resource_type: "prompt".to_string(),
1719 name: prompt.name.clone(),
1720 winner_path: result[existing_idx].path.clone(),
1721 loser_path: prompt.path.clone(),
1722 });
1723 } else {
1724 seen.insert(prompt.name.clone(), result.len());
1725 result.push(prompt);
1726 }
1727 }
1728
1729 (result, collisions)
1730}
1731
1732#[cfg(test)]
1739mod tests {
1740 use super::*;
1741 use tempfile::tempdir;
1742
1743 #[test]
1744 fn test_context_file_creation() {
1745 let cf = ContextFile::new(
1746 PathBuf::from("/project/AGENTS.md"),
1747 "AGENTS.md",
1748 100,
1749 "# Agent Instructions\n".to_string(),
1750 );
1751 assert_eq!(cf.name, "AGENTS.md");
1752 assert_eq!(cf.priority, 100);
1753 assert_eq!(cf.extension(), Some("md".to_string()));
1754 }
1755
1756 #[test]
1757 fn test_context_file_type_priority() {
1758 assert!(ContextFileType::Agents.priority() > ContextFileType::Claude.priority());
1759 }
1760
1761 #[test]
1762 fn test_context_file_type_variants() {
1763 let agents_variants = ContextFileType::Agents.variants();
1764 assert!(agents_variants.contains(&"AGENTS.md"));
1765 assert!(agents_variants.contains(&"AGENTS.MD"));
1766 }
1767
1768 #[test]
1769 fn test_context_file_type_from_filename() {
1770 assert_eq!(
1771 ContextFileType::from_filename("AGENTS.md"),
1772 Some(ContextFileType::Agents)
1773 );
1774 assert_eq!(
1775 ContextFileType::from_filename("CLAUDE.md"),
1776 Some(ContextFileType::Claude)
1777 );
1778 assert_eq!(ContextFileType::from_filename("unknown.md"), None);
1779 }
1780
1781 #[test]
1782 fn test_source_type_display() {
1783 assert_eq!(SourceType::Default.to_string(), "default");
1784 assert_eq!(SourceType::Project.to_string(), "project");
1785 assert_eq!(SourceType::Cli.to_string(), "cli");
1786 }
1787
1788 #[test]
1789 fn test_resource_loader_default() {
1790 let loader = ResourceLoader::new();
1791 assert!(loader.cached().is_none());
1792 }
1793
1794 #[test]
1795 fn test_resource_loader_with_paths() {
1796 let temp = tempdir().unwrap();
1797 let loader = ResourceLoader::with_paths(temp.path().join("oxi"), temp.path().to_path_buf());
1798 assert_eq!(loader.cwd(), temp.path());
1799 }
1800
1801 #[test]
1802 fn test_add_sources() {
1803 let mut loader = ResourceLoader::new();
1804 loader.add_extension(PathBuf::from("/extensions/my-ext"));
1805 loader.add_skill(PathBuf::from("/skills/my-skill"));
1806 loader.add_theme(PathBuf::from("/themes/my-theme"));
1807 loader.add_prompt(PathBuf::from("/prompts/my-prompt"));
1808
1809 assert_eq!(loader.extensions.len(), 1);
1810 assert_eq!(loader.skills.len(), 1);
1811 assert_eq!(loader.themes.len(), 1);
1812 assert_eq!(loader.prompts.len(), 1);
1813 }
1814
1815 #[test]
1816 fn test_load_all_empty() {
1817 let temp = tempdir().unwrap();
1818 let loader = ResourceLoader::with_paths(temp.path().join("oxi"), temp.path().to_path_buf());
1819
1820 let result = loader.try_load_all();
1821 assert!(result.collisions.is_empty());
1822 }
1823
1824 #[test]
1825 fn test_discover_context_files_empty_dir() {
1826 let temp = tempdir().unwrap();
1827 let loader = ResourceLoader::new();
1828
1829 let discovered = loader.discover_context_files(temp.path());
1830 assert!(discovered.is_empty());
1831 }
1832
1833 #[test]
1834 fn test_discover_context_files_ancestor() {
1835 let temp = tempdir().unwrap();
1836 let subdir = temp.path().join("sub").join("project");
1837 fs::create_dir_all(&subdir).unwrap();
1838
1839 fs::write(temp.path().join("AGENTS.md"), "# Parent agents").unwrap();
1841
1842 let loader = ResourceLoader::new();
1843 let discovered = loader.discover_context_files(&subdir);
1844
1845 assert!(!discovered.is_empty());
1846 }
1847
1848 #[test]
1849 fn test_load_system_prompt_not_found() {
1850 let temp = tempdir().unwrap();
1851 let loader = ResourceLoader::with_paths(temp.path().join("oxi"), temp.path().to_path_buf());
1852
1853 let result = loader.load_system_prompt().unwrap();
1854 assert!(result.is_none());
1855 }
1856
1857 #[test]
1858 fn test_load_system_prompt_from_file() {
1859 let temp = tempdir().unwrap();
1860 let agent_dir = temp.path().join("oxi");
1861 fs::create_dir_all(&agent_dir).unwrap();
1862 fs::write(agent_dir.join("SYSTEM.md"), "System prompt content").unwrap();
1863
1864 let loader = ResourceLoader::with_paths(agent_dir.clone(), temp.path().to_path_buf());
1865
1866 let result = loader.load_system_prompt().unwrap();
1867 assert!(result.is_some());
1868 assert_eq!(result.unwrap(), "System prompt content");
1869 }
1870
1871 #[test]
1872 fn test_load_system_prompt_explicit() {
1873 let temp = tempdir().unwrap();
1874 let mut opts = ResourceLoaderOptions::new();
1875 opts.agent_dir = temp.path().join("oxi");
1876 opts.cwd = temp.path().to_path_buf();
1877 opts.system_prompt = Some("Explicit prompt".to_string());
1878
1879 let loader = ResourceLoader::with_options(opts);
1880 let result = loader.load_system_prompt().unwrap();
1881 assert_eq!(result, Some("Explicit prompt".to_string()));
1882 }
1883
1884 #[test]
1885 fn test_load_append_system_prompt() {
1886 let temp = tempdir().unwrap();
1887 let agent_dir = temp.path().join("oxi");
1888 fs::create_dir_all(&agent_dir).unwrap();
1889 fs::write(agent_dir.join("APPEND_SYSTEM.md"), "Append content").unwrap();
1890
1891 let loader = ResourceLoader::with_paths(agent_dir.clone(), temp.path().to_path_buf());
1892
1893 let result = loader.load_append_system_prompt().unwrap();
1894 assert_eq!(result, vec!["Append content".to_string()]);
1895 }
1896
1897 #[test]
1898 fn test_cache_round_trip() {
1899 let temp = tempdir().unwrap();
1900 let loader = ResourceLoader::with_paths(temp.path().join("oxi"), temp.path().to_path_buf());
1901
1902 assert!(loader.cached().is_none());
1903
1904 let _ = loader.try_load_all();
1905 assert!(loader.cached().is_some());
1906
1907 loader.clear_cache();
1908 assert!(loader.cached().is_none());
1909 }
1910
1911 #[test]
1912 fn test_path_metadata_defaults() {
1913 let meta = PathMetadata::default();
1914 assert_eq!(meta.source, "local");
1915 assert_eq!(meta.scope, "user");
1916 assert_eq!(meta.origin, "top-level");
1917 }
1918
1919 #[test]
1920 fn test_path_metadata_shortcuts() {
1921 let cli = PathMetadata::cli();
1922 assert_eq!(cli.source, "cli");
1923 assert_eq!(cli.scope, "temporary");
1924
1925 let project = PathMetadata::project();
1926 assert_eq!(project.scope, "project");
1927
1928 let user = PathMetadata::user();
1929 assert_eq!(user.scope, "user");
1930 }
1931
1932 #[test]
1933 fn test_source_helper_methods() {
1934 let temp = tempdir().unwrap();
1935 let source = Source::new(temp.path().to_path_buf(), SourceType::Default);
1936
1937 assert!(source.exists());
1938 assert!(source.is_dir());
1939 assert_eq!(source.source_type, SourceType::Default);
1940 }
1941
1942 #[test]
1943 fn test_loader_builder_pattern() {
1944 let mut loader = ResourceLoader::new();
1945 loader.with_base_dir(PathBuf::from("/base"));
1946 loader.with_cwd(PathBuf::from("/cwd"));
1947 loader.add_extension(PathBuf::from("/ext"));
1948 loader.add_skill(PathBuf::from("/skill"));
1949
1950 assert_eq!(loader.extensions.len(), 1);
1951 assert_eq!(loader.skills.len(), 1);
1952 }
1953
1954 #[test]
1955 fn test_find_git_root_no_git() {
1956 let temp = tempdir().unwrap();
1957 let result = find_git_root(temp.path());
1958 assert!(result.is_none());
1959 }
1960
1961 #[test]
1962 fn test_find_git_root() {
1963 let temp = tempdir().unwrap();
1964 fs::create_dir_all(temp.path().join("sub").join("deep")).unwrap();
1965 fs::write(temp.path().join(".git"), "gitdir: somewhere").unwrap();
1966
1967 let result = find_git_root(&temp.path().join("sub").join("deep"));
1968 assert!(result.is_some());
1969 assert_eq!(result.unwrap(), temp.path());
1970 }
1971
1972 #[test]
1973 fn test_resolve_prompt_input_text() {
1974 let result = resolve_prompt_input("hello world", "test");
1975 assert_eq!(result, Some("hello world".to_string()));
1976 }
1977
1978 #[test]
1979 fn test_resolve_prompt_input_empty() {
1980 let result = resolve_prompt_input("", "test");
1981 assert!(result.is_none());
1982 }
1983
1984 #[test]
1985 fn test_resolve_prompt_input_from_file() {
1986 let temp = tempdir().unwrap();
1987 let file_path = temp.path().join("prompt.txt");
1988 fs::write(&file_path, "file content").unwrap();
1989
1990 let result = resolve_prompt_input(file_path.to_str().unwrap(), "test");
1991 assert_eq!(result, Some("file content".to_string()));
1992 }
1993
1994 #[test]
1995 fn test_resource_collision_display() {
1996 let collision = ResourceCollision {
1997 resource_type: "skill".to_string(),
1998 name: "my-skill".to_string(),
1999 winner_path: PathBuf::from("/a/skill.md"),
2000 loser_path: PathBuf::from("/b/skill.md"),
2001 };
2002 let display = collision.to_string();
2003 assert!(display.contains("skill"));
2004 assert!(display.contains("my-skill"));
2005 }
2006
2007 #[test]
2008 fn test_load_all_creates_cache() {
2009 let temp = tempdir().unwrap();
2010 let loader = ResourceLoader::with_paths(temp.path().join("oxi"), temp.path().to_path_buf());
2011
2012 let result = loader.load_all().unwrap();
2013
2014 let cached = loader.cached();
2015 assert!(cached.is_some());
2016
2017 let cached = cached.unwrap();
2018 assert_eq!(cached.skills.len(), result.skills.len());
2019 }
2020
2021 #[test]
2022 fn test_deduplication_in_discover() {
2023 let temp = tempdir().unwrap();
2024 fs::write(temp.path().join("AGENTS.md"), "# Agents").unwrap();
2025
2026 let loader = ResourceLoader::new();
2027 let discovered = loader.discover_context_files(temp.path());
2028
2029 let paths: Vec<_> = discovered.iter().map(|(p, _)| p.clone()).collect();
2030 let unique: HashSet<_> = paths
2031 .iter()
2032 .map(|p| p.to_string_lossy().to_string())
2033 .collect();
2034 assert_eq!(paths.len(), unique.len());
2035 }
2036
2037 #[test]
2038 fn test_resource_loader_options_default() {
2039 let opts = ResourceLoaderOptions::default();
2040 assert!(!opts.no_extensions);
2041 assert!(!opts.no_skills);
2042 assert!(!opts.no_prompts);
2043 assert!(!opts.no_themes);
2044 assert!(!opts.no_context_files);
2045 }
2046
2047 #[test]
2048 fn test_extend_resources() {
2049 let mut loader = ResourceLoader::new();
2050 loader.extend_resources(
2051 vec![(PathBuf::from("/skill1"), PathMetadata::cli())],
2052 vec![(PathBuf::from("/prompt1"), PathMetadata::cli())],
2053 vec![(PathBuf::from("/theme1"), PathMetadata::cli())],
2054 );
2055
2056 assert_eq!(loader.skills.len(), 1);
2057 assert_eq!(loader.prompts.len(), 1);
2058 assert_eq!(loader.themes.len(), 1);
2059 }
2060
2061 #[test]
2062 fn test_detect_resource_type() {
2063 let temp = tempdir().unwrap();
2064
2065 let skill_dir = temp.path().join("my-skill");
2067 fs::create_dir_all(&skill_dir).unwrap();
2068 fs::write(skill_dir.join("SKILL.md"), "# My Skill").unwrap();
2069 assert_eq!(
2070 ResourceLoader::detect_resource_type(&skill_dir),
2071 Some(ResourceType::Skill)
2072 );
2073
2074 let theme_file = temp.path().join("theme.json");
2076 fs::write(&theme_file, r#"{"name": "test"}"#).unwrap();
2077 assert_eq!(
2078 ResourceLoader::detect_resource_type(&theme_file),
2079 Some(ResourceType::Theme)
2080 );
2081 }
2082
2083 #[test]
2084 fn test_validate_resource_path() {
2085 let temp = tempdir().unwrap();
2086
2087 let skill_file = temp.path().join("skill.md");
2088 fs::write(&skill_file, "# Skill").unwrap();
2089
2090 let result = ResourceLoader::validate_resource_path(&skill_file);
2091 assert!(result.is_ok());
2092
2093 let nonexistent = temp.path().join("nonexistent");
2094 let result = ResourceLoader::validate_resource_path(&nonexistent);
2095 assert!(result.is_err());
2096 }
2097
2098 #[test]
2099 fn test_getters_without_cache() {
2100 let loader = ResourceLoader::new();
2101 assert!(loader.get_skills().is_empty());
2102 assert!(loader.get_themes().is_empty());
2103 assert!(loader.get_prompts().is_empty());
2104 assert!(loader.get_context_files().is_empty());
2105 assert!(loader.get_system_prompt().is_none());
2106 assert!(loader.get_append_system_prompt().is_empty());
2107 assert!(loader.get_agents_files().is_empty());
2108 }
2109
2110 #[test]
2111 fn test_load_project_context_files_order() {
2112 let temp = tempdir().unwrap();
2113
2114 fs::write(temp.path().join("CLAUDE.md"), "# Claude").unwrap();
2116 fs::write(temp.path().join("AGENTS.md"), "# Agents").unwrap();
2117
2118 let loader = ResourceLoader::with_paths(temp.path().join("oxi"), temp.path().to_path_buf());
2119
2120 let files = loader.load_project_context_files(temp.path()).unwrap();
2121
2122 if files.len() >= 2 {
2124 assert!(files[0].priority >= files[1].priority);
2125 }
2126 }
2127
2128 #[test]
2129 fn test_source_info_serialization() {
2130 let info = SourceInfo {
2131 path: PathBuf::from("/test"),
2132 source: "local".to_string(),
2133 scope: "user".to_string(),
2134 origin: "top-level".to_string(),
2135 base_dir: Some(PathBuf::from("/base")),
2136 };
2137 let json = serde_json::to_string(&info).unwrap();
2138 let deserialized: SourceInfo = serde_json::from_str(&json).unwrap();
2139 assert_eq!(deserialized.source, "local");
2140 assert_eq!(deserialized.base_dir, Some(PathBuf::from("/base")));
2141 }
2142}