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 && (current == *git_r || !current.starts_with(git_r))
912 {
913 break;
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 && let Ok(modified) = metadata.modified()
1127 && modified > *last_time
1128 {
1129 return true;
1130 }
1131 }
1132
1133 false
1134 }
1135
1136 pub fn load_if_stale(&self) -> Result<LoadedResources, anyhow::Error> {
1138 if self.is_cache_stale() {
1139 self.reload()
1140 } else if let Some(cached) = self.cached() {
1141 Ok(cached)
1142 } else {
1143 self.load_all()
1144 }
1145 }
1146
1147 fn update_modification_times(&self, result: &LoadedResources) {
1149 let mut mtimes = self.modification_times.write();
1150 mtimes.clear();
1151
1152 let paths: Vec<PathBuf> = {
1153 let mut p = Vec::new();
1154 for s in &result.skills {
1155 p.push(s.path.clone());
1156 }
1157 for t in &result.themes {
1158 p.push(t.path.clone());
1159 }
1160 for pr in &result.prompts {
1161 p.push(pr.path.clone());
1162 }
1163 for cf in &result.context_files {
1164 p.push(cf.path.clone());
1165 }
1166 p
1167 };
1168
1169 for path in paths {
1170 if let Ok(metadata) = fs::metadata(&path)
1171 && let Ok(modified) = metadata.modified()
1172 {
1173 mtimes.insert(path, modified);
1174 }
1175 }
1176 }
1177
1178 pub fn detect_resource_type(path: &Path) -> Option<ResourceType> {
1184 if !path.exists() {
1185 return None;
1186 }
1187
1188 if path.is_dir() {
1189 if path.join("SKILL.md").exists() {
1191 return Some(ResourceType::Skill);
1192 }
1193 if path.join("package.json").exists() || path.join("extension.json").exists() {
1194 return Some(ResourceType::Extension);
1195 }
1196 return Some(ResourceType::Skill);
1198 }
1199
1200 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
1202 match ext {
1203 "md" => Some(ResourceType::Skill),
1204 "json" => Some(ResourceType::Theme),
1205 "js" | "ts" => Some(ResourceType::Extension),
1206 _ => None,
1207 }
1208 }
1209
1210 pub fn is_valid_resource_path(path: &Path, resource_type: ResourceType) -> bool {
1212 if !path.exists() {
1213 return false;
1214 }
1215 match resource_type {
1216 ResourceType::Skill => {
1217 path.is_dir() || path.extension().map(|e| e == "md").unwrap_or(false)
1218 }
1219 ResourceType::Theme => path.extension().map(|e| e == "json").unwrap_or(false),
1220 ResourceType::Prompt => path.extension().map(|e| e == "md").unwrap_or(false),
1221 ResourceType::Extension => path
1222 .extension()
1223 .map(|e| e == "js" || e == "ts")
1224 .unwrap_or(false),
1225 }
1226 }
1227
1228 pub fn validate_resource_path(path: &Path) -> Result<ResourceType, String> {
1230 if !path.exists() {
1231 return Err(format!("Path does not exist: {}", path.display()));
1232 }
1233
1234 Self::detect_resource_type(path)
1235 .ok_or_else(|| format!("Cannot determine resource type for: {}", path.display()))
1236 }
1237
1238 pub fn cwd(&self) -> &Path {
1244 &self.options.cwd
1245 }
1246
1247 pub fn agent_dir(&self) -> &Path {
1249 &self.options.agent_dir
1250 }
1251
1252 pub fn get_skills(&self) -> Vec<Skill> {
1254 self.cache
1255 .read()
1256 .as_ref()
1257 .map(|c| c.skills.clone())
1258 .unwrap_or_default()
1259 }
1260
1261 pub fn get_themes(&self) -> Vec<Theme> {
1263 self.cache
1264 .read()
1265 .as_ref()
1266 .map(|c| c.themes.clone())
1267 .unwrap_or_default()
1268 }
1269
1270 pub fn get_prompts(&self) -> Vec<Prompt> {
1272 self.cache
1273 .read()
1274 .as_ref()
1275 .map(|c| c.prompts.clone())
1276 .unwrap_or_default()
1277 }
1278
1279 pub fn get_context_files(&self) -> Vec<ContextFile> {
1281 self.cache
1282 .read()
1283 .as_ref()
1284 .map(|c| c.context_files.clone())
1285 .unwrap_or_default()
1286 }
1287
1288 pub fn get_system_prompt(&self) -> Option<String> {
1290 self.cache
1291 .read()
1292 .as_ref()
1293 .and_then(|c| c.system_prompt.clone())
1294 }
1295
1296 pub fn get_append_system_prompt(&self) -> Vec<String> {
1298 self.cache
1299 .read()
1300 .as_ref()
1301 .map(|c| c.append_system_prompt.clone())
1302 .unwrap_or_default()
1303 }
1304
1305 pub fn get_agents_files(&self) -> Vec<(PathBuf, String)> {
1307 self.cache
1308 .read()
1309 .as_ref()
1310 .map(|c| {
1311 c.context_files
1312 .iter()
1313 .map(|cf| (cf.path.clone(), cf.content.clone()))
1314 .collect()
1315 })
1316 .unwrap_or_default()
1317 }
1318}
1319
1320pub fn load_context_file_from_dir(dir: &Path) -> Option<(PathBuf, String)> {
1326 let candidates = ["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
1327 for filename in &candidates {
1328 let file_path = dir.join(filename);
1329 if file_path.exists() {
1330 match fs::read_to_string(&file_path) {
1331 Ok(content) => return Some((file_path, content)),
1332 Err(e) => {
1333 tracing::warn!("Warning: Could not read {}: {}", file_path.display(), e);
1334 }
1335 }
1336 }
1337 }
1338 None
1339}
1340
1341pub fn find_git_root(dir: &Path) -> Option<PathBuf> {
1343 let mut current = dir.to_path_buf();
1344 let root = PathBuf::from("/");
1345
1346 let max_iterations = 20;
1347 let mut iterations = 0;
1348
1349 while current != root && iterations < max_iterations {
1350 if current.join(".git").exists() {
1351 return Some(current);
1352 }
1353 if let Some(parent) = current.parent() {
1354 current = parent.to_path_buf();
1355 } else {
1356 break;
1357 }
1358 iterations += 1;
1359 }
1360
1361 None
1362}
1363
1364pub fn resolve_prompt_input(input: &str, description: &str) -> Option<String> {
1366 if input.is_empty() {
1367 return None;
1368 }
1369
1370 let path = Path::new(input);
1371 if path.exists() {
1372 match fs::read_to_string(path) {
1373 Ok(content) => Some(content),
1374 Err(e) => {
1375 tracing::warn!(
1376 "Warning: Could not read {} file {}: {}",
1377 description,
1378 input,
1379 e
1380 );
1381 Some(input.to_string())
1382 }
1383 }
1384 } else {
1385 Some(input.to_string())
1386 }
1387}
1388
1389pub fn default_resource_dir() -> std::path::PathBuf {
1391 dirs::config_dir()
1392 .unwrap_or_else(|| std::path::PathBuf::from("."))
1393 .join("oxi")
1394}
1395
1396pub fn skills_dir(base: &std::path::Path) -> std::path::PathBuf {
1398 base.join("skills")
1399}
1400
1401#[allow(dead_code)]
1403pub fn extensions_dir(base: &std::path::Path) -> std::path::PathBuf {
1404 base.join("extensions")
1405}
1406
1407pub fn themes_dir(base: &std::path::Path) -> std::path::PathBuf {
1409 base.join("themes")
1410}
1411
1412pub fn prompts_dir(base: &std::path::Path) -> std::path::PathBuf {
1414 base.join("prompts")
1415}
1416
1417pub fn load_skills_from_dir(dir: &std::path::Path) -> LoadResult<Skill> {
1419 let mut items = Vec::new();
1420 let mut errors = Vec::new();
1421 let mut diagnostics = Vec::new();
1422
1423 if !dir.exists() {
1424 return LoadResult {
1425 items,
1426 errors,
1427 diagnostics,
1428 };
1429 }
1430
1431 if let Ok(entries) = fs::read_dir(dir) {
1432 for entry in entries.flatten() {
1433 let path = entry.path();
1434 if path.is_dir() || path.extension().map(|e| e == "md").unwrap_or(false) {
1435 match load_skill(&path) {
1436 Ok(skill) => items.push(skill),
1437 Err(e) => {
1438 errors.push(LoadError {
1439 path: path.clone(),
1440 error: e.clone(),
1441 });
1442 diagnostics.push(ResourceDiagnostic {
1443 severity: DiagnosticSeverity::Error,
1444 message: e,
1445 path: Some(path),
1446 });
1447 }
1448 }
1449 }
1450 }
1451 }
1452
1453 LoadResult {
1454 items,
1455 errors,
1456 diagnostics,
1457 }
1458}
1459
1460pub fn load_skill(path: &std::path::Path) -> Result<Skill, String> {
1462 let content = if path.is_file() {
1463 fs::read_to_string(path).map_err(|e| e.to_string())?
1464 } else if path.is_dir() {
1465 let skill_md = path.join("SKILL.md");
1466 if skill_md.exists() {
1467 fs::read_to_string(&skill_md).map_err(|e| e.to_string())?
1468 } else {
1469 return Err("No SKILL.md found in directory".to_string());
1470 }
1471 } else {
1472 return Err("Invalid skill path".to_string());
1473 };
1474
1475 let id = path
1476 .file_stem()
1477 .and_then(|s| s.to_str())
1478 .unwrap_or("unknown")
1479 .to_string();
1480
1481 let name = extract_yaml_field(&content, "name").or_else(|| Some(id.clone()));
1482 let description = extract_yaml_field(&content, "description");
1483
1484 Ok(Skill {
1485 id,
1486 path: path.to_path_buf(),
1487 content,
1488 name,
1489 description,
1490 source: "local".to_string(),
1491 })
1492}
1493
1494pub fn load_themes_from_dir(dir: &std::path::Path) -> LoadResult<Theme> {
1496 let mut items = Vec::new();
1497 let mut errors = Vec::new();
1498 let mut diagnostics = Vec::new();
1499
1500 if !dir.exists() {
1501 return LoadResult {
1502 items,
1503 errors,
1504 diagnostics,
1505 };
1506 }
1507
1508 if let Ok(entries) = fs::read_dir(dir) {
1509 for entry in entries.flatten() {
1510 let path = entry.path();
1511 if path.extension().map(|e| e == "json").unwrap_or(false) {
1512 match load_theme(&path) {
1513 Ok(theme) => items.push(theme),
1514 Err(e) => {
1515 errors.push(LoadError {
1516 path: path.clone(),
1517 error: e.clone(),
1518 });
1519 diagnostics.push(ResourceDiagnostic {
1520 severity: DiagnosticSeverity::Warning,
1521 message: e,
1522 path: Some(path),
1523 });
1524 }
1525 }
1526 }
1527 }
1528 }
1529
1530 LoadResult {
1531 items,
1532 errors,
1533 diagnostics,
1534 }
1535}
1536
1537pub fn load_theme(path: &std::path::Path) -> Result<Theme, String> {
1539 let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
1540 let json: serde_json::Value = serde_json::from_str(&content).map_err(|e| e.to_string())?;
1541
1542 let name = json
1543 .get("name")
1544 .and_then(|v| v.as_str())
1545 .map(String::from)
1546 .unwrap_or_else(|| {
1547 path.file_stem()
1548 .and_then(|s| s.to_str())
1549 .unwrap_or("unnamed")
1550 .to_string()
1551 });
1552
1553 Ok(Theme {
1554 id: name.to_lowercase().replace(' ', "_"),
1555 name,
1556 path: path.to_path_buf(),
1557 content: json,
1558 source: "local".to_string(),
1559 })
1560}
1561
1562pub fn load_prompts_from_dir(dir: &std::path::Path) -> LoadResult<Prompt> {
1564 let mut items = Vec::new();
1565 let mut errors = Vec::new();
1566 let mut diagnostics = Vec::new();
1567
1568 if !dir.exists() {
1569 return LoadResult {
1570 items,
1571 errors,
1572 diagnostics,
1573 };
1574 }
1575
1576 if let Ok(entries) = fs::read_dir(dir) {
1577 for entry in entries.flatten() {
1578 let path = entry.path();
1579 if path.is_file() && path.extension().map(|e| e == "md").unwrap_or(false) {
1580 match load_prompt(&path) {
1581 Ok(prompt) => items.push(prompt),
1582 Err(e) => {
1583 errors.push(LoadError {
1584 path: path.clone(),
1585 error: e.clone(),
1586 });
1587 diagnostics.push(ResourceDiagnostic {
1588 severity: DiagnosticSeverity::Warning,
1589 message: e,
1590 path: Some(path),
1591 });
1592 }
1593 }
1594 }
1595 }
1596 }
1597
1598 LoadResult {
1599 items,
1600 errors,
1601 diagnostics,
1602 }
1603}
1604
1605pub fn load_prompt(path: &std::path::Path) -> Result<Prompt, String> {
1607 let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
1608
1609 let name = path
1610 .file_stem()
1611 .and_then(|s| s.to_str())
1612 .unwrap_or("unknown")
1613 .to_string();
1614
1615 Ok(Prompt {
1616 id: name.clone(),
1617 name,
1618 path: path.to_path_buf(),
1619 content,
1620 description: None,
1621 source: "local".to_string(),
1622 })
1623}
1624
1625pub fn resolve_path(path: &std::path::Path) -> std::path::PathBuf {
1627 let path_str = path.to_string_lossy();
1628 if path_str.starts_with("~/")
1629 && let Some(home) = dirs::home_dir()
1630 {
1631 return home.join(path_str.strip_prefix("~/").expect("starts_with checked"));
1633 }
1634 path.to_path_buf()
1635}
1636
1637fn extract_yaml_field(content: &str, field: &str) -> Option<String> {
1639 if !content.starts_with("---") {
1640 return None;
1641 }
1642
1643 if let Some(end) = content[3..].find("---") {
1644 let frontmatter = &content[3..end + 3];
1645 for line in frontmatter.lines() {
1646 if let Some(value) = line.strip_prefix(&format!("{}:", field)) {
1647 let value = value.trim();
1648 let value = value.trim_matches('"').trim_matches('\'');
1649 return Some(value.to_string());
1650 }
1651 }
1652 }
1653
1654 None
1655}
1656
1657fn dedupe_skills(skills: Vec<Skill>) -> (Vec<Skill>, Vec<ResourceCollision>) {
1663 let mut seen: HashMap<String, usize> = HashMap::new();
1664 let mut result: Vec<Skill> = Vec::new();
1665 let mut collisions = Vec::new();
1666
1667 for skill in skills {
1668 if let Some(&existing_idx) = seen.get(&skill.id) {
1669 collisions.push(ResourceCollision {
1670 resource_type: "skill".to_string(),
1671 name: skill.id.clone(),
1672 winner_path: result[existing_idx].path.clone(),
1673 loser_path: skill.path.clone(),
1674 });
1675 } else {
1676 seen.insert(skill.id.clone(), result.len());
1677 result.push(skill);
1678 }
1679 }
1680
1681 (result, collisions)
1682}
1683
1684fn dedupe_themes(themes: Vec<Theme>) -> (Vec<Theme>, Vec<ResourceCollision>) {
1686 let mut seen: HashMap<String, usize> = HashMap::new();
1687 let mut result: Vec<Theme> = Vec::new();
1688 let mut collisions = Vec::new();
1689
1690 for theme in themes {
1691 let name = theme.name.clone();
1692 if let Some(&existing_idx) = seen.get(&name) {
1693 collisions.push(ResourceCollision {
1694 resource_type: "theme".to_string(),
1695 name: name.clone(),
1696 winner_path: result[existing_idx].path.clone(),
1697 loser_path: theme.path.clone(),
1698 });
1699 } else {
1700 seen.insert(name, result.len());
1701 result.push(theme);
1702 }
1703 }
1704
1705 (result, collisions)
1706}
1707
1708fn dedupe_prompts(prompts: Vec<Prompt>) -> (Vec<Prompt>, Vec<ResourceCollision>) {
1710 let mut seen: HashMap<String, usize> = HashMap::new();
1711 let mut result: Vec<Prompt> = Vec::new();
1712 let mut collisions = Vec::new();
1713
1714 for prompt in prompts {
1715 if let Some(&existing_idx) = seen.get(&prompt.name) {
1716 collisions.push(ResourceCollision {
1717 resource_type: "prompt".to_string(),
1718 name: prompt.name.clone(),
1719 winner_path: result[existing_idx].path.clone(),
1720 loser_path: prompt.path.clone(),
1721 });
1722 } else {
1723 seen.insert(prompt.name.clone(), result.len());
1724 result.push(prompt);
1725 }
1726 }
1727
1728 (result, collisions)
1729}
1730
1731#[cfg(test)]
1738mod tests {
1739 use super::*;
1740 use tempfile::tempdir;
1741
1742 #[test]
1743 fn test_context_file_creation() {
1744 let cf = ContextFile::new(
1745 PathBuf::from("/project/AGENTS.md"),
1746 "AGENTS.md",
1747 100,
1748 "# Agent Instructions\n".to_string(),
1749 );
1750 assert_eq!(cf.name, "AGENTS.md");
1751 assert_eq!(cf.priority, 100);
1752 assert_eq!(cf.extension(), Some("md".to_string()));
1753 }
1754
1755 #[test]
1756 fn test_context_file_type_priority() {
1757 assert!(ContextFileType::Agents.priority() > ContextFileType::Claude.priority());
1758 }
1759
1760 #[test]
1761 fn test_context_file_type_variants() {
1762 let agents_variants = ContextFileType::Agents.variants();
1763 assert!(agents_variants.contains(&"AGENTS.md"));
1764 assert!(agents_variants.contains(&"AGENTS.MD"));
1765 }
1766
1767 #[test]
1768 fn test_context_file_type_from_filename() {
1769 assert_eq!(
1770 ContextFileType::from_filename("AGENTS.md"),
1771 Some(ContextFileType::Agents)
1772 );
1773 assert_eq!(
1774 ContextFileType::from_filename("CLAUDE.md"),
1775 Some(ContextFileType::Claude)
1776 );
1777 assert_eq!(ContextFileType::from_filename("unknown.md"), None);
1778 }
1779
1780 #[test]
1781 fn test_source_type_display() {
1782 assert_eq!(SourceType::Default.to_string(), "default");
1783 assert_eq!(SourceType::Project.to_string(), "project");
1784 assert_eq!(SourceType::Cli.to_string(), "cli");
1785 }
1786
1787 #[test]
1788 fn test_resource_loader_default() {
1789 let loader = ResourceLoader::new();
1790 assert!(loader.cached().is_none());
1791 }
1792
1793 #[test]
1794 fn test_resource_loader_with_paths() {
1795 let temp = tempdir().unwrap();
1796 let loader = ResourceLoader::with_paths(temp.path().join("oxi"), temp.path().to_path_buf());
1797 assert_eq!(loader.cwd(), temp.path());
1798 }
1799
1800 #[test]
1801 fn test_add_sources() {
1802 let mut loader = ResourceLoader::new();
1803 loader.add_extension(PathBuf::from("/extensions/my-ext"));
1804 loader.add_skill(PathBuf::from("/skills/my-skill"));
1805 loader.add_theme(PathBuf::from("/themes/my-theme"));
1806 loader.add_prompt(PathBuf::from("/prompts/my-prompt"));
1807
1808 assert_eq!(loader.extensions.len(), 1);
1809 assert_eq!(loader.skills.len(), 1);
1810 assert_eq!(loader.themes.len(), 1);
1811 assert_eq!(loader.prompts.len(), 1);
1812 }
1813
1814 #[test]
1815 fn test_load_all_empty() {
1816 let temp = tempdir().unwrap();
1817 let loader = ResourceLoader::with_paths(temp.path().join("oxi"), temp.path().to_path_buf());
1818
1819 let result = loader.try_load_all();
1820 assert!(result.collisions.is_empty());
1821 }
1822
1823 #[test]
1824 fn test_discover_context_files_empty_dir() {
1825 let temp = tempdir().unwrap();
1826 let loader = ResourceLoader::new();
1827
1828 let discovered = loader.discover_context_files(temp.path());
1829 assert!(discovered.is_empty());
1830 }
1831
1832 #[test]
1833 fn test_discover_context_files_ancestor() {
1834 let temp = tempdir().unwrap();
1835 let subdir = temp.path().join("sub").join("project");
1836 fs::create_dir_all(&subdir).unwrap();
1837
1838 fs::write(temp.path().join("AGENTS.md"), "# Parent agents").unwrap();
1840
1841 let loader = ResourceLoader::new();
1842 let discovered = loader.discover_context_files(&subdir);
1843
1844 assert!(!discovered.is_empty());
1845 }
1846
1847 #[test]
1848 fn test_load_system_prompt_not_found() {
1849 let temp = tempdir().unwrap();
1850 let loader = ResourceLoader::with_paths(temp.path().join("oxi"), temp.path().to_path_buf());
1851
1852 let result = loader.load_system_prompt().unwrap();
1853 assert!(result.is_none());
1854 }
1855
1856 #[test]
1857 fn test_load_system_prompt_from_file() {
1858 let temp = tempdir().unwrap();
1859 let agent_dir = temp.path().join("oxi");
1860 fs::create_dir_all(&agent_dir).unwrap();
1861 fs::write(agent_dir.join("SYSTEM.md"), "System prompt content").unwrap();
1862
1863 let loader = ResourceLoader::with_paths(agent_dir.clone(), temp.path().to_path_buf());
1864
1865 let result = loader.load_system_prompt().unwrap();
1866 assert!(result.is_some());
1867 assert_eq!(result.unwrap(), "System prompt content");
1868 }
1869
1870 #[test]
1871 fn test_load_system_prompt_explicit() {
1872 let temp = tempdir().unwrap();
1873 let mut opts = ResourceLoaderOptions::new();
1874 opts.agent_dir = temp.path().join("oxi");
1875 opts.cwd = temp.path().to_path_buf();
1876 opts.system_prompt = Some("Explicit prompt".to_string());
1877
1878 let loader = ResourceLoader::with_options(opts);
1879 let result = loader.load_system_prompt().unwrap();
1880 assert_eq!(result, Some("Explicit prompt".to_string()));
1881 }
1882
1883 #[test]
1884 fn test_load_append_system_prompt() {
1885 let temp = tempdir().unwrap();
1886 let agent_dir = temp.path().join("oxi");
1887 fs::create_dir_all(&agent_dir).unwrap();
1888 fs::write(agent_dir.join("APPEND_SYSTEM.md"), "Append content").unwrap();
1889
1890 let loader = ResourceLoader::with_paths(agent_dir.clone(), temp.path().to_path_buf());
1891
1892 let result = loader.load_append_system_prompt().unwrap();
1893 assert_eq!(result, vec!["Append content".to_string()]);
1894 }
1895
1896 #[test]
1897 fn test_cache_round_trip() {
1898 let temp = tempdir().unwrap();
1899 let loader = ResourceLoader::with_paths(temp.path().join("oxi"), temp.path().to_path_buf());
1900
1901 assert!(loader.cached().is_none());
1902
1903 let _ = loader.try_load_all();
1904 assert!(loader.cached().is_some());
1905
1906 loader.clear_cache();
1907 assert!(loader.cached().is_none());
1908 }
1909
1910 #[test]
1911 fn test_path_metadata_defaults() {
1912 let meta = PathMetadata::default();
1913 assert_eq!(meta.source, "local");
1914 assert_eq!(meta.scope, "user");
1915 assert_eq!(meta.origin, "top-level");
1916 }
1917
1918 #[test]
1919 fn test_path_metadata_shortcuts() {
1920 let cli = PathMetadata::cli();
1921 assert_eq!(cli.source, "cli");
1922 assert_eq!(cli.scope, "temporary");
1923
1924 let project = PathMetadata::project();
1925 assert_eq!(project.scope, "project");
1926
1927 let user = PathMetadata::user();
1928 assert_eq!(user.scope, "user");
1929 }
1930
1931 #[test]
1932 fn test_source_helper_methods() {
1933 let temp = tempdir().unwrap();
1934 let source = Source::new(temp.path().to_path_buf(), SourceType::Default);
1935
1936 assert!(source.exists());
1937 assert!(source.is_dir());
1938 assert_eq!(source.source_type, SourceType::Default);
1939 }
1940
1941 #[test]
1942 fn test_loader_builder_pattern() {
1943 let mut loader = ResourceLoader::new();
1944 loader.with_base_dir(PathBuf::from("/base"));
1945 loader.with_cwd(PathBuf::from("/cwd"));
1946 loader.add_extension(PathBuf::from("/ext"));
1947 loader.add_skill(PathBuf::from("/skill"));
1948
1949 assert_eq!(loader.extensions.len(), 1);
1950 assert_eq!(loader.skills.len(), 1);
1951 }
1952
1953 #[test]
1954 fn test_find_git_root_no_git() {
1955 let temp = tempdir().unwrap();
1956 let result = find_git_root(temp.path());
1957 assert!(result.is_none());
1958 }
1959
1960 #[test]
1961 fn test_find_git_root() {
1962 let temp = tempdir().unwrap();
1963 fs::create_dir_all(temp.path().join("sub").join("deep")).unwrap();
1964 fs::write(temp.path().join(".git"), "gitdir: somewhere").unwrap();
1965
1966 let result = find_git_root(&temp.path().join("sub").join("deep"));
1967 assert!(result.is_some());
1968 assert_eq!(result.unwrap(), temp.path());
1969 }
1970
1971 #[test]
1972 fn test_resolve_prompt_input_text() {
1973 let result = resolve_prompt_input("hello world", "test");
1974 assert_eq!(result, Some("hello world".to_string()));
1975 }
1976
1977 #[test]
1978 fn test_resolve_prompt_input_empty() {
1979 let result = resolve_prompt_input("", "test");
1980 assert!(result.is_none());
1981 }
1982
1983 #[test]
1984 fn test_resolve_prompt_input_from_file() {
1985 let temp = tempdir().unwrap();
1986 let file_path = temp.path().join("prompt.txt");
1987 fs::write(&file_path, "file content").unwrap();
1988
1989 let result = resolve_prompt_input(file_path.to_str().unwrap(), "test");
1990 assert_eq!(result, Some("file content".to_string()));
1991 }
1992
1993 #[test]
1994 fn test_resource_collision_display() {
1995 let collision = ResourceCollision {
1996 resource_type: "skill".to_string(),
1997 name: "my-skill".to_string(),
1998 winner_path: PathBuf::from("/a/skill.md"),
1999 loser_path: PathBuf::from("/b/skill.md"),
2000 };
2001 let display = collision.to_string();
2002 assert!(display.contains("skill"));
2003 assert!(display.contains("my-skill"));
2004 }
2005
2006 #[test]
2007 fn test_load_all_creates_cache() {
2008 let temp = tempdir().unwrap();
2009 let loader = ResourceLoader::with_paths(temp.path().join("oxi"), temp.path().to_path_buf());
2010
2011 let result = loader.load_all().unwrap();
2012
2013 let cached = loader.cached();
2014 assert!(cached.is_some());
2015
2016 let cached = cached.unwrap();
2017 assert_eq!(cached.skills.len(), result.skills.len());
2018 }
2019
2020 #[test]
2021 fn test_deduplication_in_discover() {
2022 let temp = tempdir().unwrap();
2023 fs::write(temp.path().join("AGENTS.md"), "# Agents").unwrap();
2024
2025 let loader = ResourceLoader::new();
2026 let discovered = loader.discover_context_files(temp.path());
2027
2028 let paths: Vec<_> = discovered.iter().map(|(p, _)| p.clone()).collect();
2029 let unique: HashSet<_> = paths
2030 .iter()
2031 .map(|p| p.to_string_lossy().to_string())
2032 .collect();
2033 assert_eq!(paths.len(), unique.len());
2034 }
2035
2036 #[test]
2037 fn test_resource_loader_options_default() {
2038 let opts = ResourceLoaderOptions::default();
2039 assert!(!opts.no_extensions);
2040 assert!(!opts.no_skills);
2041 assert!(!opts.no_prompts);
2042 assert!(!opts.no_themes);
2043 assert!(!opts.no_context_files);
2044 }
2045
2046 #[test]
2047 fn test_extend_resources() {
2048 let mut loader = ResourceLoader::new();
2049 loader.extend_resources(
2050 vec![(PathBuf::from("/skill1"), PathMetadata::cli())],
2051 vec![(PathBuf::from("/prompt1"), PathMetadata::cli())],
2052 vec![(PathBuf::from("/theme1"), PathMetadata::cli())],
2053 );
2054
2055 assert_eq!(loader.skills.len(), 1);
2056 assert_eq!(loader.prompts.len(), 1);
2057 assert_eq!(loader.themes.len(), 1);
2058 }
2059
2060 #[test]
2061 fn test_detect_resource_type() {
2062 let temp = tempdir().unwrap();
2063
2064 let skill_dir = temp.path().join("my-skill");
2066 fs::create_dir_all(&skill_dir).unwrap();
2067 fs::write(skill_dir.join("SKILL.md"), "# My Skill").unwrap();
2068 assert_eq!(
2069 ResourceLoader::detect_resource_type(&skill_dir),
2070 Some(ResourceType::Skill)
2071 );
2072
2073 let theme_file = temp.path().join("theme.json");
2075 fs::write(&theme_file, r#"{"name": "test"}"#).unwrap();
2076 assert_eq!(
2077 ResourceLoader::detect_resource_type(&theme_file),
2078 Some(ResourceType::Theme)
2079 );
2080 }
2081
2082 #[test]
2083 fn test_validate_resource_path() {
2084 let temp = tempdir().unwrap();
2085
2086 let skill_file = temp.path().join("skill.md");
2087 fs::write(&skill_file, "# Skill").unwrap();
2088
2089 let result = ResourceLoader::validate_resource_path(&skill_file);
2090 assert!(result.is_ok());
2091
2092 let nonexistent = temp.path().join("nonexistent");
2093 let result = ResourceLoader::validate_resource_path(&nonexistent);
2094 assert!(result.is_err());
2095 }
2096
2097 #[test]
2098 fn test_getters_without_cache() {
2099 let loader = ResourceLoader::new();
2100 assert!(loader.get_skills().is_empty());
2101 assert!(loader.get_themes().is_empty());
2102 assert!(loader.get_prompts().is_empty());
2103 assert!(loader.get_context_files().is_empty());
2104 assert!(loader.get_system_prompt().is_none());
2105 assert!(loader.get_append_system_prompt().is_empty());
2106 assert!(loader.get_agents_files().is_empty());
2107 }
2108
2109 #[test]
2110 fn test_load_project_context_files_order() {
2111 let temp = tempdir().unwrap();
2112
2113 fs::write(temp.path().join("CLAUDE.md"), "# Claude").unwrap();
2115 fs::write(temp.path().join("AGENTS.md"), "# Agents").unwrap();
2116
2117 let loader = ResourceLoader::with_paths(temp.path().join("oxi"), temp.path().to_path_buf());
2118
2119 let files = loader.load_project_context_files(temp.path()).unwrap();
2120
2121 if files.len() >= 2 {
2123 assert!(files[0].priority >= files[1].priority);
2124 }
2125 }
2126
2127 #[test]
2128 fn test_source_info_serialization() {
2129 let info = SourceInfo {
2130 path: PathBuf::from("/test"),
2131 source: "local".to_string(),
2132 scope: "user".to_string(),
2133 origin: "top-level".to_string(),
2134 base_dir: Some(PathBuf::from("/base")),
2135 };
2136 let json = serde_json::to_string(&info).unwrap();
2137 let deserialized: SourceInfo = serde_json::from_str(&json).unwrap();
2138 assert_eq!(deserialized.source, "local");
2139 assert_eq!(deserialized.base_dir, Some(PathBuf::from("/base")));
2140 }
2141}