1use std::collections::{HashMap, HashSet};
16use std::fs;
17use std::path::{Path, PathBuf};
18use std::time::Instant;
19
20use parking_lot::RwLock;
21
22pub use super::resource_loader_compat::{Prompt, Skill, Theme};
24
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
31pub struct ContextFile {
32 pub path: PathBuf,
34 pub name: String,
36 pub priority: u8,
38 pub content: String,
40}
41
42impl ContextFile {
43 pub fn new(path: PathBuf, name: impl Into<String>, priority: u8, content: String) -> Self {
45 Self {
46 path,
47 name: name.into(),
48 priority,
49 content,
50 }
51 }
52
53 pub fn extension(&self) -> Option<String> {
55 self.path
56 .extension()
57 .and_then(|e| e.to_str())
58 .map(|s| s.to_lowercase())
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum ContextFileType {
65 Agents,
67 Claude,
69}
70
71impl ContextFileType {
72 pub fn filename(&self) -> &'static str {
74 match self {
75 ContextFileType::Agents => "AGENTS.md",
76 ContextFileType::Claude => "CLAUDE.md",
77 }
78 }
79
80 pub fn priority(&self) -> u8 {
82 match self {
83 ContextFileType::Agents => 100,
84 ContextFileType::Claude => 90,
85 }
86 }
87
88 pub fn variants(&self) -> Vec<&'static str> {
90 match self {
91 ContextFileType::Agents => vec!["AGENTS.md", "AGENTS.MD"],
92 ContextFileType::Claude => vec!["CLAUDE.md", "CLAUDE.MD"],
93 }
94 }
95
96 pub fn from_filename(name: &str) -> Option<Self> {
98 let upper = name.to_uppercase();
99 match upper.as_str() {
100 "AGENTS.md" | "AGENTS.MD" => Some(ContextFileType::Agents),
101 "CLAUDE.md" | "CLAUDE.MD" => Some(ContextFileType::Claude),
102 _ => None,
103 }
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
113#[allow(dead_code)]
114pub enum SourceType {
115 Default,
117 Project,
119 Cli,
121 Inline,
123 Package,
125 Git,
127}
128
129impl std::fmt::Display for SourceType {
130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 match self {
132 SourceType::Default => write!(f, "default"),
133 SourceType::Project => write!(f, "project"),
134 SourceType::Cli => write!(f, "cli"),
135 SourceType::Inline => write!(f, "inline"),
136 SourceType::Package => write!(f, "package"),
137 SourceType::Git => write!(f, "git"),
138 }
139 }
140}
141
142#[derive(Debug, Clone)]
144#[allow(dead_code)]
145pub struct Source {
146 pub path: PathBuf,
148 pub source_type: SourceType,
150 pub enabled: bool,
152}
153
154impl Source {
155 #[allow(dead_code)]
157 pub fn new(path: PathBuf, source_type: SourceType) -> Self {
158 Self {
159 path,
160 source_type,
161 enabled: true,
162 }
163 }
164
165 #[allow(dead_code)]
167 pub fn exists(&self) -> bool {
168 self.path.exists()
169 }
170
171 #[allow(dead_code)]
173 pub fn is_dir(&self) -> bool {
174 self.path.is_dir()
175 }
176}
177
178#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
184#[allow(dead_code)]
185pub struct SourceInfo {
186 pub path: PathBuf,
188 pub source: String,
190 pub scope: String,
192 pub origin: String,
194 #[serde(skip_serializing_if = "Option::is_none")]
196 pub base_dir: Option<PathBuf>,
197}
198
199#[derive(Debug, Clone)]
205#[allow(dead_code)]
206pub struct ExtensionSource {
207 #[allow(dead_code)]
209 pub path: PathBuf,
210 #[allow(dead_code)]
212 pub metadata: PathMetadata,
213 #[allow(dead_code)]
215 pub source_info: Option<SourceInfo>,
216}
217
218#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
220pub struct PathMetadata {
221 pub source: String,
223 pub scope: String,
225 pub origin: String,
227}
228
229impl Default for PathMetadata {
230 fn default() -> Self {
231 Self {
232 source: "local".to_string(),
233 scope: "user".to_string(),
234 origin: "top-level".to_string(),
235 }
236 }
237}
238
239impl PathMetadata {
240 pub fn cli() -> Self {
242 Self {
243 source: "cli".to_string(),
244 scope: "temporary".to_string(),
245 origin: "top-level".to_string(),
246 }
247 }
248
249 pub fn project() -> Self {
251 Self {
252 source: "local".to_string(),
253 scope: "project".to_string(),
254 origin: "top-level".to_string(),
255 }
256 }
257
258 pub fn user() -> Self {
260 Self {
261 source: "local".to_string(),
262 scope: "user".to_string(),
263 origin: "top-level".to_string(),
264 }
265 }
266}
267
268#[derive(Debug, Clone)]
270pub struct SkillSource {
271 pub path: PathBuf,
273 #[allow(dead_code)]
275 pub metadata: PathMetadata,
276 pub enabled: bool,
278}
279
280#[derive(Debug, Clone)]
282pub struct ThemeSource {
283 pub path: PathBuf,
285 #[allow(dead_code)]
287 pub metadata: PathMetadata,
288 pub enabled: bool,
290}
291
292#[derive(Debug, Clone)]
294pub struct PromptSource {
295 pub path: PathBuf,
297 #[allow(dead_code)]
299 pub metadata: PathMetadata,
300 pub enabled: bool,
302}
303
304#[derive(Debug, Clone)]
310pub struct ResourceCollision {
311 pub resource_type: String,
313 pub name: String,
315 pub winner_path: PathBuf,
317 pub loser_path: PathBuf,
319}
320
321impl std::fmt::Display for ResourceCollision {
322 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
323 write!(
324 f,
325 "{} '{}' collision: {} vs {}",
326 self.resource_type,
327 self.name,
328 self.winner_path.display(),
329 self.loser_path.display()
330 )
331 }
332}
333
334#[derive(Debug, Clone)]
340pub struct LoadedResources {
341 pub skills: Vec<Skill>,
343 pub themes: Vec<Theme>,
345 pub prompts: Vec<Prompt>,
347 pub context_files: Vec<ContextFile>,
349 pub system_prompt: Option<String>,
351 pub append_system_prompt: Vec<String>,
353 pub errors: Vec<LoadError>,
355 pub diagnostics: Vec<ResourceDiagnostic>,
357 pub collisions: Vec<ResourceCollision>,
359 pub loaded_at: Instant,
361}
362
363impl Default for LoadedResources {
364 fn default() -> Self {
365 Self {
366 skills: Vec::new(),
367 themes: Vec::new(),
368 prompts: Vec::new(),
369 context_files: Vec::new(),
370 system_prompt: None,
371 append_system_prompt: Vec::new(),
372 errors: Vec::new(),
373 diagnostics: Vec::new(),
374 collisions: Vec::new(),
375 loaded_at: Instant::now(),
376 }
377 }
378}
379
380#[derive(Debug, Clone)]
386pub struct ResourceLoaderOptions {
387 pub cwd: PathBuf,
389 pub agent_dir: PathBuf,
391 pub additional_extension_paths: Vec<PathBuf>,
393 pub additional_skill_paths: Vec<PathBuf>,
395 pub additional_prompt_paths: Vec<PathBuf>,
397 pub additional_theme_paths: Vec<PathBuf>,
399 pub no_extensions: bool,
401 pub no_skills: bool,
403 pub no_prompts: bool,
405 pub no_themes: bool,
407 pub no_context_files: bool,
409 pub system_prompt: Option<String>,
411 pub append_system_prompt: Vec<String>,
413}
414
415impl ResourceLoaderOptions {
416 pub fn new() -> Self {
418 let agent_dir = default_resource_dir();
419 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
420
421 Self {
422 cwd,
423 agent_dir,
424 additional_extension_paths: Vec::new(),
425 additional_skill_paths: Vec::new(),
426 additional_prompt_paths: Vec::new(),
427 additional_theme_paths: Vec::new(),
428 no_extensions: false,
429 no_skills: false,
430 no_prompts: false,
431 no_themes: false,
432 no_context_files: false,
433 system_prompt: None,
434 append_system_prompt: Vec::new(),
435 }
436 }
437}
438
439impl Default for ResourceLoaderOptions {
440 fn default() -> Self {
441 Self::new()
442 }
443}
444
445pub struct ResourceLoader {
455 options: ResourceLoaderOptions,
457 extensions: Vec<ExtensionSource>,
459 skills: Vec<SkillSource>,
461 themes: Vec<ThemeSource>,
463 prompts: Vec<PromptSource>,
465 cache: RwLock<Option<LoadedResources>>,
467 modification_times: RwLock<HashMap<PathBuf, std::time::SystemTime>>,
469}
470
471impl Default for ResourceLoader {
472 fn default() -> Self {
473 Self::new()
474 }
475}
476
477impl ResourceLoader {
478 pub fn new() -> Self {
480 Self::with_options(ResourceLoaderOptions::new())
481 }
482
483 pub fn with_options(options: ResourceLoaderOptions) -> Self {
485 Self {
486 options,
487 extensions: Vec::new(),
488 skills: Vec::new(),
489 themes: Vec::new(),
490 prompts: Vec::new(),
491 cache: RwLock::new(None),
492 modification_times: RwLock::new(HashMap::new()),
493 }
494 }
495
496 pub fn with_paths(base_dir: PathBuf, cwd: PathBuf) -> Self {
498 let options = ResourceLoaderOptions {
499 cwd,
500 agent_dir: base_dir,
501 ..ResourceLoaderOptions::default()
502 };
503 Self::with_options(options)
504 }
505
506 pub fn with_base_dir(&mut self, base_dir: PathBuf) -> &mut Self {
512 self.options.agent_dir = base_dir;
513 self
514 }
515
516 pub fn with_cwd(&mut self, cwd: PathBuf) -> &mut Self {
518 self.options.cwd = cwd;
519 self
520 }
521
522 pub fn add_extension(&mut self, path: PathBuf) -> &mut Self {
524 self.extensions.push(ExtensionSource {
525 path: resolve_path(&path),
526 metadata: PathMetadata::cli(),
527 source_info: None,
528 });
529 self
530 }
531
532 pub fn add_skill(&mut self, path: PathBuf) -> &mut Self {
534 self.skills.push(SkillSource {
535 path: resolve_path(&path),
536 metadata: PathMetadata::cli(),
537 enabled: true,
538 });
539 self
540 }
541
542 pub fn add_theme(&mut self, path: PathBuf) -> &mut Self {
544 self.themes.push(ThemeSource {
545 path: resolve_path(&path),
546 metadata: PathMetadata::cli(),
547 enabled: true,
548 });
549 self
550 }
551
552 pub fn add_prompt(&mut self, path: PathBuf) -> &mut Self {
554 self.prompts.push(PromptSource {
555 path: resolve_path(&path),
556 metadata: PathMetadata::cli(),
557 enabled: true,
558 });
559 self
560 }
561
562 pub fn extend_resources(
564 &mut self,
565 skill_paths: Vec<(PathBuf, PathMetadata)>,
566 prompt_paths: Vec<(PathBuf, PathMetadata)>,
567 theme_paths: Vec<(PathBuf, PathMetadata)>,
568 ) {
569 for (path, meta) in skill_paths {
570 self.skills.push(SkillSource {
571 path,
572 metadata: meta,
573 enabled: true,
574 });
575 }
576 for (path, meta) in prompt_paths {
577 self.prompts.push(PromptSource {
578 path,
579 metadata: meta,
580 enabled: true,
581 });
582 }
583 for (path, meta) in theme_paths {
584 self.themes.push(ThemeSource {
585 path,
586 metadata: meta,
587 enabled: true,
588 });
589 }
590 }
591
592 pub fn load_all(&self) -> Result<LoadedResources, anyhow::Error> {
598 let mut result = LoadedResources::default();
599
600 let skills = self.load_skills_internal();
602 result.skills = skills.items;
603 result.errors.extend(skills.errors);
604 result.diagnostics.extend(skills.diagnostics);
605
606 let themes = self.load_themes_internal();
608 result.themes = themes.items;
609 result.errors.extend(themes.errors);
610 result.diagnostics.extend(themes.diagnostics);
611
612 let prompts = self.load_prompts_internal();
614 result.prompts = prompts.items;
615 result.errors.extend(prompts.errors);
616 result.diagnostics.extend(prompts.diagnostics);
617
618 let (deduped_skills, skill_collisions) = dedupe_skills(result.skills);
620 result.skills = deduped_skills;
621 result.collisions.extend(skill_collisions);
622
623 let (deduped_themes, theme_collisions) = dedupe_themes(result.themes);
624 result.themes = deduped_themes;
625 result.collisions.extend(theme_collisions);
626
627 let (deduped_prompts, prompt_collisions) = dedupe_prompts(result.prompts);
628 result.prompts = deduped_prompts;
629 result.collisions.extend(prompt_collisions);
630
631 if !self.options.no_context_files {
633 result.context_files = self.load_project_context_files(&self.options.cwd)?;
634 }
635
636 result.system_prompt = self.load_system_prompt()?;
638 result.append_system_prompt = self.load_append_system_prompt()?;
639
640 self.update_modification_times(&result);
642
643 *self.cache.write() = Some(result.clone());
645
646 Ok(result)
647 }
648
649 pub fn try_load_all(&self) -> LoadedResources {
651 self.load_all().unwrap_or_else(|e| LoadedResources {
652 errors: vec![LoadError {
653 path: PathBuf::from("."),
654 error: e.to_string(),
655 }],
656 ..LoadedResources::default()
657 })
658 }
659
660 pub fn reload(&self) -> Result<LoadedResources, anyhow::Error> {
662 self.clear_cache();
663 self.load_all()
664 }
665
666 pub fn load_system_prompt(&self) -> Result<Option<String>, anyhow::Error> {
672 if let Some(ref prompt) = self.options.system_prompt {
674 return Ok(resolve_prompt_input(prompt, "system prompt"));
675 }
676
677 let candidates = vec![
679 self.options.cwd.join(".oxi").join("SYSTEM.md"),
681 self.options.agent_dir.join("SYSTEM.md"),
683 ];
684
685 for path in candidates {
686 if path.exists() && path.is_file() {
687 match fs::read_to_string(&path) {
688 Ok(content) => return Ok(Some(content)),
689 Err(e) => {
690 tracing::warn!("Failed to read system prompt {}: {}", path.display(), e);
691 }
692 }
693 }
694 }
695
696 Ok(None)
697 }
698
699 pub fn load_append_system_prompt(&self) -> Result<Vec<String>, anyhow::Error> {
701 if !self.options.append_system_prompt.is_empty() {
703 return Ok(self
704 .options
705 .append_system_prompt
706 .iter()
707 .filter_map(|s| resolve_prompt_input(s, "append system prompt"))
708 .collect());
709 }
710
711 let mut result = Vec::new();
712
713 let candidates = vec![
714 self.options.cwd.join(".oxi").join("APPEND_SYSTEM.md"),
716 self.options.agent_dir.join("APPEND_SYSTEM.md"),
718 ];
719
720 for path in candidates {
721 if path.exists() && path.is_file() {
722 match fs::read_to_string(&path) {
723 Ok(content) => result.push(content),
724 Err(e) => {
725 tracing::warn!(
726 "Failed to read append system prompt {}: {}",
727 path.display(),
728 e
729 );
730 }
731 }
732 }
733 }
734
735 Ok(result)
736 }
737
738 pub fn load_project_context_files(
744 &self,
745 cwd: &Path,
746 ) -> Result<Vec<ContextFile>, anyhow::Error> {
747 let mut context_files = Vec::new();
748 let mut seen_paths: HashMap<String, bool> = HashMap::new();
749
750 let global_context = load_context_file_from_dir(&self.options.agent_dir);
752 if let Some((path, content)) = global_context {
753 let name = path
754 .file_name()
755 .and_then(|n| n.to_str())
756 .unwrap_or("unknown")
757 .to_string();
758 let file_type = ContextFileType::from_filename(&name);
759 let priority = file_type.map(|ft| ft.priority()).unwrap_or(80);
760 let path_str = path.to_string_lossy().to_string();
761 seen_paths.insert(path_str, true);
762 context_files.push(ContextFile::new(path, name, priority, content));
763 }
764
765 let discovered = self.discover_context_files(cwd);
767
768 for (path, file_type) in discovered {
769 let path_str = path.to_string_lossy().to_string();
770 if seen_paths.contains_key(&path_str) {
771 continue;
772 }
773
774 if let Some(content) = self.read_context_file(&path)? {
775 seen_paths.insert(path_str, true);
776 let name = path
777 .file_name()
778 .and_then(|n| n.to_str())
779 .unwrap_or("unknown")
780 .to_string();
781 context_files.push(ContextFile::new(path, name, file_type.priority(), content));
782 }
783 }
784
785 context_files.sort_by_key(|b| std::cmp::Reverse(b.priority));
787
788 Ok(context_files)
789 }
790
791 pub fn discover_context_files(&self, dir: &Path) -> Vec<(PathBuf, ContextFileType)> {
793 let mut discovered = Vec::new();
794 let file_types = [ContextFileType::Agents, ContextFileType::Claude];
795
796 let git_root = find_git_root(dir);
798
799 let mut current = dir.to_path_buf();
800 let root = PathBuf::from("/");
801
802 let max_iterations = 50;
803 let mut iterations = 0;
804
805 while current != root && iterations < max_iterations {
806 if let Some(ref git_r) = git_root {
808 if current == *git_r || !current.starts_with(git_r) {
809 break;
810 }
811 }
812
813 for file_type in &file_types {
814 for variant in file_type.variants() {
815 let candidate = current.join(variant);
816 if candidate.exists() && candidate.is_file() {
817 discovered.push((candidate, *file_type));
818 }
819 }
820 }
821
822 if let Some(parent) = current.parent() {
824 current = parent.to_path_buf();
825 } else {
826 break;
827 }
828 iterations += 1;
829 }
830
831 let mut seen = HashSet::new();
833 discovered.retain(|(path, _)| {
834 let path_str = path.to_string_lossy().to_string();
835 if seen.contains(&path_str) {
836 false
837 } else {
838 seen.insert(path_str);
839 true
840 }
841 });
842
843 discovered
844 }
845
846 fn read_context_file(&self, path: &Path) -> Result<Option<String>, anyhow::Error> {
848 match fs::read_to_string(path) {
849 Ok(content) => Ok(Some(content)),
850 Err(e) => {
851 tracing::warn!("Failed to read context file {}: {}", path.display(), e);
852 Ok(None)
853 }
854 }
855 }
856
857 fn load_skills_internal(&self) -> LoadResult<Skill> {
863 let mut items = Vec::new();
864 let mut errors = Vec::new();
865 let mut diagnostics = Vec::new();
866
867 if !self.options.no_skills {
869 let skills_base = skills_dir(&self.options.agent_dir);
870 let project_skills = self.options.cwd.join(".oxi").join("skills");
871
872 for dir in &[skills_base, project_skills] {
873 if dir.exists() {
874 let result = load_skills_from_dir(dir);
875 items.extend(result.items);
876 errors.extend(result.errors);
877 diagnostics.extend(result.diagnostics);
878 }
879 }
880 }
881
882 for source in &self.skills {
884 if !source.enabled {
885 continue;
886 }
887 if source.path.exists() {
888 match load_skill(&source.path) {
889 Ok(skill) => items.push(skill),
890 Err(e) => {
891 errors.push(LoadError {
892 path: source.path.clone(),
893 error: e,
894 });
895 }
896 }
897 }
898 }
899
900 LoadResult {
901 items,
902 errors,
903 diagnostics,
904 }
905 }
906
907 fn load_themes_internal(&self) -> LoadResult<Theme> {
909 let mut items = Vec::new();
910 let mut errors = Vec::new();
911 let mut diagnostics = Vec::new();
912
913 if !self.options.no_themes {
914 let themes_base = themes_dir(&self.options.agent_dir);
915 let project_themes = self.options.cwd.join(".oxi").join("themes");
916
917 for dir in &[themes_base, project_themes] {
918 if dir.exists() {
919 let result = load_themes_from_dir(dir);
920 items.extend(result.items);
921 errors.extend(result.errors);
922 diagnostics.extend(result.diagnostics);
923 }
924 }
925 }
926
927 for source in &self.themes {
928 if !source.enabled {
929 continue;
930 }
931 if source.path.exists() {
932 match load_theme(&source.path) {
933 Ok(theme) => items.push(theme),
934 Err(e) => {
935 errors.push(LoadError {
936 path: source.path.clone(),
937 error: e,
938 });
939 }
940 }
941 }
942 }
943
944 LoadResult {
945 items,
946 errors,
947 diagnostics,
948 }
949 }
950
951 fn load_prompts_internal(&self) -> LoadResult<Prompt> {
953 let mut items = Vec::new();
954 let mut errors = Vec::new();
955 let mut diagnostics = Vec::new();
956
957 if !self.options.no_prompts {
958 let prompts_base = prompts_dir(&self.options.agent_dir);
959 let project_prompts = self.options.cwd.join(".oxi").join("prompts");
960
961 for dir in &[prompts_base, project_prompts] {
962 if dir.exists() {
963 let result = load_prompts_from_dir(dir);
964 items.extend(result.items);
965 errors.extend(result.errors);
966 diagnostics.extend(result.diagnostics);
967 }
968 }
969 }
970
971 for source in &self.prompts {
972 if !source.enabled {
973 continue;
974 }
975 if source.path.exists() {
976 match load_prompt(&source.path) {
977 Ok(prompt) => items.push(prompt),
978 Err(e) => {
979 errors.push(LoadError {
980 path: source.path.clone(),
981 error: e,
982 });
983 }
984 }
985 }
986 }
987
988 LoadResult {
989 items,
990 errors,
991 diagnostics,
992 }
993 }
994
995 pub fn cached(&self) -> Option<LoadedResources> {
1001 self.cache.read().clone()
1002 }
1003
1004 pub fn clear_cache(&self) {
1006 *self.cache.write() = None;
1007 }
1008
1009 pub fn is_cache_stale(&self) -> bool {
1011 let cache = self.cache.read();
1012 if cache.is_none() {
1013 return true; }
1015
1016 let mtimes = self.modification_times.read();
1017 if mtimes.is_empty() {
1018 return false; }
1020
1021 for (path, last_time) in mtimes.iter() {
1022 if let Ok(metadata) = fs::metadata(path) {
1023 if let Ok(modified) = metadata.modified() {
1024 if modified > *last_time {
1025 return true;
1026 }
1027 }
1028 }
1029 }
1030
1031 false
1032 }
1033
1034 pub fn load_if_stale(&self) -> Result<LoadedResources, anyhow::Error> {
1036 if self.is_cache_stale() {
1037 self.reload()
1038 } else if let Some(cached) = self.cached() {
1039 Ok(cached)
1040 } else {
1041 self.load_all()
1042 }
1043 }
1044
1045 fn update_modification_times(&self, result: &LoadedResources) {
1047 let mut mtimes = self.modification_times.write();
1048 mtimes.clear();
1049
1050 let paths: Vec<PathBuf> = {
1051 let mut p = Vec::new();
1052 for s in &result.skills {
1053 p.push(s.path.clone());
1054 }
1055 for t in &result.themes {
1056 p.push(t.path.clone());
1057 }
1058 for pr in &result.prompts {
1059 p.push(pr.path.clone());
1060 }
1061 for cf in &result.context_files {
1062 p.push(cf.path.clone());
1063 }
1064 p
1065 };
1066
1067 for path in paths {
1068 if let Ok(metadata) = fs::metadata(&path) {
1069 if let Ok(modified) = metadata.modified() {
1070 mtimes.insert(path, modified);
1071 }
1072 }
1073 }
1074 }
1075
1076 pub fn detect_resource_type(path: &Path) -> Option<ResourceType> {
1082 if !path.exists() {
1083 return None;
1084 }
1085
1086 if path.is_dir() {
1087 if path.join("SKILL.md").exists() {
1089 return Some(ResourceType::Skill);
1090 }
1091 if path.join("package.json").exists() || path.join("extension.json").exists() {
1092 return Some(ResourceType::Extension);
1093 }
1094 return Some(ResourceType::Skill);
1096 }
1097
1098 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
1100 match ext {
1101 "md" => Some(ResourceType::Skill),
1102 "json" => Some(ResourceType::Theme),
1103 "js" | "ts" => Some(ResourceType::Extension),
1104 _ => None,
1105 }
1106 }
1107
1108 pub fn is_valid_resource_path(path: &Path, resource_type: ResourceType) -> bool {
1110 if !path.exists() {
1111 return false;
1112 }
1113 match resource_type {
1114 ResourceType::Skill => {
1115 path.is_dir() || path.extension().map(|e| e == "md").unwrap_or(false)
1116 }
1117 ResourceType::Theme => path.extension().map(|e| e == "json").unwrap_or(false),
1118 ResourceType::Prompt => path.extension().map(|e| e == "md").unwrap_or(false),
1119 ResourceType::Extension => path
1120 .extension()
1121 .map(|e| e == "js" || e == "ts")
1122 .unwrap_or(false),
1123 }
1124 }
1125
1126 pub fn validate_resource_path(path: &Path) -> Result<ResourceType, String> {
1128 if !path.exists() {
1129 return Err(format!("Path does not exist: {}", path.display()));
1130 }
1131
1132 Self::detect_resource_type(path)
1133 .ok_or_else(|| format!("Cannot determine resource type for: {}", path.display()))
1134 }
1135
1136 pub fn cwd(&self) -> &Path {
1142 &self.options.cwd
1143 }
1144
1145 pub fn agent_dir(&self) -> &Path {
1147 &self.options.agent_dir
1148 }
1149
1150 pub fn get_skills(&self) -> Vec<Skill> {
1152 self.cache
1153 .read()
1154 .as_ref()
1155 .map(|c| c.skills.clone())
1156 .unwrap_or_default()
1157 }
1158
1159 pub fn get_themes(&self) -> Vec<Theme> {
1161 self.cache
1162 .read()
1163 .as_ref()
1164 .map(|c| c.themes.clone())
1165 .unwrap_or_default()
1166 }
1167
1168 pub fn get_prompts(&self) -> Vec<Prompt> {
1170 self.cache
1171 .read()
1172 .as_ref()
1173 .map(|c| c.prompts.clone())
1174 .unwrap_or_default()
1175 }
1176
1177 pub fn get_context_files(&self) -> Vec<ContextFile> {
1179 self.cache
1180 .read()
1181 .as_ref()
1182 .map(|c| c.context_files.clone())
1183 .unwrap_or_default()
1184 }
1185
1186 pub fn get_system_prompt(&self) -> Option<String> {
1188 self.cache
1189 .read()
1190 .as_ref()
1191 .and_then(|c| c.system_prompt.clone())
1192 }
1193
1194 pub fn get_append_system_prompt(&self) -> Vec<String> {
1196 self.cache
1197 .read()
1198 .as_ref()
1199 .map(|c| c.append_system_prompt.clone())
1200 .unwrap_or_default()
1201 }
1202
1203 pub fn get_agents_files(&self) -> Vec<(PathBuf, String)> {
1205 self.cache
1206 .read()
1207 .as_ref()
1208 .map(|c| {
1209 c.context_files
1210 .iter()
1211 .map(|cf| (cf.path.clone(), cf.content.clone()))
1212 .collect()
1213 })
1214 .unwrap_or_default()
1215 }
1216}
1217
1218pub fn load_context_file_from_dir(dir: &Path) -> Option<(PathBuf, String)> {
1224 let candidates = ["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
1225 for filename in &candidates {
1226 let file_path = dir.join(filename);
1227 if file_path.exists() {
1228 match fs::read_to_string(&file_path) {
1229 Ok(content) => return Some((file_path, content)),
1230 Err(e) => {
1231 tracing::warn!("Warning: Could not read {}: {}", file_path.display(), e);
1232 }
1233 }
1234 }
1235 }
1236 None
1237}
1238
1239pub fn find_git_root(dir: &Path) -> Option<PathBuf> {
1241 let mut current = dir.to_path_buf();
1242 let root = PathBuf::from("/");
1243
1244 let max_iterations = 20;
1245 let mut iterations = 0;
1246
1247 while current != root && iterations < max_iterations {
1248 if current.join(".git").exists() {
1249 return Some(current);
1250 }
1251 if let Some(parent) = current.parent() {
1252 current = parent.to_path_buf();
1253 } else {
1254 break;
1255 }
1256 iterations += 1;
1257 }
1258
1259 None
1260}
1261
1262pub fn resolve_prompt_input(input: &str, description: &str) -> Option<String> {
1264 if input.is_empty() {
1265 return None;
1266 }
1267
1268 let path = Path::new(input);
1269 if path.exists() {
1270 match fs::read_to_string(path) {
1271 Ok(content) => Some(content),
1272 Err(e) => {
1273 tracing::warn!(
1274 "Warning: Could not read {} file {}: {}",
1275 description,
1276 input,
1277 e
1278 );
1279 Some(input.to_string())
1280 }
1281 }
1282 } else {
1283 Some(input.to_string())
1284 }
1285}
1286
1287pub fn default_resource_dir() -> std::path::PathBuf {
1289 dirs::config_dir()
1290 .unwrap_or_else(|| std::path::PathBuf::from("."))
1291 .join("oxi")
1292}
1293
1294pub fn skills_dir(base: &std::path::Path) -> std::path::PathBuf {
1296 base.join("skills")
1297}
1298
1299#[allow(dead_code)]
1301pub fn extensions_dir(base: &std::path::Path) -> std::path::PathBuf {
1302 base.join("extensions")
1303}
1304
1305pub fn themes_dir(base: &std::path::Path) -> std::path::PathBuf {
1307 base.join("themes")
1308}
1309
1310pub fn prompts_dir(base: &std::path::Path) -> std::path::PathBuf {
1312 base.join("prompts")
1313}
1314
1315pub fn load_skills_from_dir(dir: &std::path::Path) -> LoadResult<Skill> {
1317 super::resource_loader_compat::load_skills_from_dir_impl(dir)
1318}
1319
1320pub fn load_skill(path: &std::path::Path) -> Result<Skill, String> {
1322 super::resource_loader_compat::load_skill_impl(path)
1323}
1324
1325pub fn load_themes_from_dir(dir: &std::path::Path) -> LoadResult<Theme> {
1327 super::resource_loader_compat::load_themes_from_dir_impl(dir)
1328}
1329
1330pub fn load_theme(path: &std::path::Path) -> Result<Theme, String> {
1332 super::resource_loader_compat::load_theme_impl(path)
1333}
1334
1335pub fn load_prompts_from_dir(dir: &std::path::Path) -> LoadResult<Prompt> {
1337 super::resource_loader_compat::load_prompts_from_dir_impl(dir)
1338}
1339
1340pub fn load_prompt(path: &std::path::Path) -> Result<Prompt, String> {
1342 super::resource_loader_compat::load_prompt_impl(path)
1343}
1344
1345#[allow(dead_code)]
1347pub fn load_all_resources(base_dir: &std::path::Path) -> LoadAllResourcesResult {
1348 super::resource_loader_compat::load_all_resources_impl(base_dir)
1349}
1350
1351pub fn resolve_path(path: &std::path::Path) -> std::path::PathBuf {
1353 let path_str = path.to_string_lossy();
1354 if path_str.starts_with("~/") {
1355 if let Some(home) = dirs::home_dir() {
1356 return home.join(path_str.strip_prefix("~/").expect("starts_with checked"));
1358 }
1359 }
1360 path.to_path_buf()
1361}
1362
1363fn dedupe_skills(skills: Vec<Skill>) -> (Vec<Skill>, Vec<ResourceCollision>) {
1369 let mut seen: HashMap<String, usize> = HashMap::new();
1370 let mut result: Vec<Skill> = Vec::new();
1371 let mut collisions = Vec::new();
1372
1373 for skill in skills {
1374 if let Some(&existing_idx) = seen.get(&skill.id) {
1375 collisions.push(ResourceCollision {
1376 resource_type: "skill".to_string(),
1377 name: skill.id.clone(),
1378 winner_path: result[existing_idx].path.clone(),
1379 loser_path: skill.path.clone(),
1380 });
1381 } else {
1382 seen.insert(skill.id.clone(), result.len());
1383 result.push(skill);
1384 }
1385 }
1386
1387 (result, collisions)
1388}
1389
1390fn dedupe_themes(themes: Vec<Theme>) -> (Vec<Theme>, Vec<ResourceCollision>) {
1392 let mut seen: HashMap<String, usize> = HashMap::new();
1393 let mut result: Vec<Theme> = Vec::new();
1394 let mut collisions = Vec::new();
1395
1396 for theme in themes {
1397 let name = theme.name.clone();
1398 if let Some(&existing_idx) = seen.get(&name) {
1399 collisions.push(ResourceCollision {
1400 resource_type: "theme".to_string(),
1401 name: name.clone(),
1402 winner_path: result[existing_idx].path.clone(),
1403 loser_path: theme.path.clone(),
1404 });
1405 } else {
1406 seen.insert(name, result.len());
1407 result.push(theme);
1408 }
1409 }
1410
1411 (result, collisions)
1412}
1413
1414fn dedupe_prompts(prompts: Vec<Prompt>) -> (Vec<Prompt>, Vec<ResourceCollision>) {
1416 let mut seen: HashMap<String, usize> = HashMap::new();
1417 let mut result: Vec<Prompt> = Vec::new();
1418 let mut collisions = Vec::new();
1419
1420 for prompt in prompts {
1421 if let Some(&existing_idx) = seen.get(&prompt.name) {
1422 collisions.push(ResourceCollision {
1423 resource_type: "prompt".to_string(),
1424 name: prompt.name.clone(),
1425 winner_path: result[existing_idx].path.clone(),
1426 loser_path: prompt.path.clone(),
1427 });
1428 } else {
1429 seen.insert(prompt.name.clone(), result.len());
1430 result.push(prompt);
1431 }
1432 }
1433
1434 (result, collisions)
1435}
1436
1437pub use super::resource_loader_compat::{
1442 LoadAllResourcesResult, LoadError, LoadResult, ResourceDiagnostic, ResourceType,
1443};
1444
1445#[cfg(test)]
1450mod tests {
1451 use super::*;
1452 use tempfile::tempdir;
1453
1454 #[test]
1455 fn test_context_file_creation() {
1456 let cf = ContextFile::new(
1457 PathBuf::from("/project/AGENTS.md"),
1458 "AGENTS.md",
1459 100,
1460 "# Agent Instructions\n".to_string(),
1461 );
1462 assert_eq!(cf.name, "AGENTS.md");
1463 assert_eq!(cf.priority, 100);
1464 assert_eq!(cf.extension(), Some("md".to_string()));
1465 }
1466
1467 #[test]
1468 fn test_context_file_type_priority() {
1469 assert!(ContextFileType::Agents.priority() > ContextFileType::Claude.priority());
1470 }
1471
1472 #[test]
1473 fn test_context_file_type_variants() {
1474 let agents_variants = ContextFileType::Agents.variants();
1475 assert!(agents_variants.contains(&"AGENTS.md"));
1476 assert!(agents_variants.contains(&"AGENTS.MD"));
1477 }
1478
1479 #[test]
1480 fn test_context_file_type_from_filename() {
1481 assert_eq!(
1482 ContextFileType::from_filename("AGENTS.md"),
1483 Some(ContextFileType::Agents)
1484 );
1485 assert_eq!(
1486 ContextFileType::from_filename("CLAUDE.md"),
1487 Some(ContextFileType::Claude)
1488 );
1489 assert_eq!(ContextFileType::from_filename("unknown.md"), None);
1490 }
1491
1492 #[test]
1493 fn test_source_type_display() {
1494 assert_eq!(SourceType::Default.to_string(), "default");
1495 assert_eq!(SourceType::Project.to_string(), "project");
1496 assert_eq!(SourceType::Cli.to_string(), "cli");
1497 }
1498
1499 #[test]
1500 fn test_resource_loader_default() {
1501 let loader = ResourceLoader::new();
1502 assert!(loader.cached().is_none());
1503 }
1504
1505 #[test]
1506 fn test_resource_loader_with_paths() {
1507 let temp = tempdir().unwrap();
1508 let loader = ResourceLoader::with_paths(temp.path().join("oxi"), temp.path().to_path_buf());
1509 assert_eq!(loader.cwd(), temp.path());
1510 }
1511
1512 #[test]
1513 fn test_add_sources() {
1514 let mut loader = ResourceLoader::new();
1515 loader.add_extension(PathBuf::from("/extensions/my-ext"));
1516 loader.add_skill(PathBuf::from("/skills/my-skill"));
1517 loader.add_theme(PathBuf::from("/themes/my-theme"));
1518 loader.add_prompt(PathBuf::from("/prompts/my-prompt"));
1519
1520 assert_eq!(loader.extensions.len(), 1);
1521 assert_eq!(loader.skills.len(), 1);
1522 assert_eq!(loader.themes.len(), 1);
1523 assert_eq!(loader.prompts.len(), 1);
1524 }
1525
1526 #[test]
1527 fn test_load_all_empty() {
1528 let temp = tempdir().unwrap();
1529 let loader = ResourceLoader::with_paths(temp.path().join("oxi"), temp.path().to_path_buf());
1530
1531 let result = loader.try_load_all();
1532 assert!(result.collisions.is_empty());
1533 }
1534
1535 #[test]
1536 fn test_discover_context_files_empty_dir() {
1537 let temp = tempdir().unwrap();
1538 let loader = ResourceLoader::new();
1539
1540 let discovered = loader.discover_context_files(temp.path());
1541 assert!(discovered.is_empty());
1542 }
1543
1544 #[test]
1545 fn test_discover_context_files_ancestor() {
1546 let temp = tempdir().unwrap();
1547 let subdir = temp.path().join("sub").join("project");
1548 fs::create_dir_all(&subdir).unwrap();
1549
1550 fs::write(temp.path().join("AGENTS.md"), "# Parent agents").unwrap();
1552
1553 let loader = ResourceLoader::new();
1554 let discovered = loader.discover_context_files(&subdir);
1555
1556 assert!(!discovered.is_empty());
1557 }
1558
1559 #[test]
1560 fn test_load_system_prompt_not_found() {
1561 let temp = tempdir().unwrap();
1562 let loader = ResourceLoader::with_paths(temp.path().join("oxi"), temp.path().to_path_buf());
1563
1564 let result = loader.load_system_prompt().unwrap();
1565 assert!(result.is_none());
1566 }
1567
1568 #[test]
1569 fn test_load_system_prompt_from_file() {
1570 let temp = tempdir().unwrap();
1571 let agent_dir = temp.path().join("oxi");
1572 fs::create_dir_all(&agent_dir).unwrap();
1573 fs::write(agent_dir.join("SYSTEM.md"), "System prompt content").unwrap();
1574
1575 let loader = ResourceLoader::with_paths(agent_dir.clone(), temp.path().to_path_buf());
1576
1577 let result = loader.load_system_prompt().unwrap();
1578 assert!(result.is_some());
1579 assert_eq!(result.unwrap(), "System prompt content");
1580 }
1581
1582 #[test]
1583 fn test_load_system_prompt_explicit() {
1584 let temp = tempdir().unwrap();
1585 let mut opts = ResourceLoaderOptions::new();
1586 opts.agent_dir = temp.path().join("oxi");
1587 opts.cwd = temp.path().to_path_buf();
1588 opts.system_prompt = Some("Explicit prompt".to_string());
1589
1590 let loader = ResourceLoader::with_options(opts);
1591 let result = loader.load_system_prompt().unwrap();
1592 assert_eq!(result, Some("Explicit prompt".to_string()));
1593 }
1594
1595 #[test]
1596 fn test_load_append_system_prompt() {
1597 let temp = tempdir().unwrap();
1598 let agent_dir = temp.path().join("oxi");
1599 fs::create_dir_all(&agent_dir).unwrap();
1600 fs::write(agent_dir.join("APPEND_SYSTEM.md"), "Append content").unwrap();
1601
1602 let loader = ResourceLoader::with_paths(agent_dir.clone(), temp.path().to_path_buf());
1603
1604 let result = loader.load_append_system_prompt().unwrap();
1605 assert_eq!(result, vec!["Append content".to_string()]);
1606 }
1607
1608 #[test]
1609 fn test_cache_round_trip() {
1610 let temp = tempdir().unwrap();
1611 let loader = ResourceLoader::with_paths(temp.path().join("oxi"), temp.path().to_path_buf());
1612
1613 assert!(loader.cached().is_none());
1614
1615 let _ = loader.try_load_all();
1616 assert!(loader.cached().is_some());
1617
1618 loader.clear_cache();
1619 assert!(loader.cached().is_none());
1620 }
1621
1622 #[test]
1623 fn test_path_metadata_defaults() {
1624 let meta = PathMetadata::default();
1625 assert_eq!(meta.source, "local");
1626 assert_eq!(meta.scope, "user");
1627 assert_eq!(meta.origin, "top-level");
1628 }
1629
1630 #[test]
1631 fn test_path_metadata_shortcuts() {
1632 let cli = PathMetadata::cli();
1633 assert_eq!(cli.source, "cli");
1634 assert_eq!(cli.scope, "temporary");
1635
1636 let project = PathMetadata::project();
1637 assert_eq!(project.scope, "project");
1638
1639 let user = PathMetadata::user();
1640 assert_eq!(user.scope, "user");
1641 }
1642
1643 #[test]
1644 fn test_source_helper_methods() {
1645 let temp = tempdir().unwrap();
1646 let source = Source::new(temp.path().to_path_buf(), SourceType::Default);
1647
1648 assert!(source.exists());
1649 assert!(source.is_dir());
1650 assert_eq!(source.source_type, SourceType::Default);
1651 }
1652
1653 #[test]
1654 fn test_loader_builder_pattern() {
1655 let mut loader = ResourceLoader::new();
1656 loader.with_base_dir(PathBuf::from("/base"));
1657 loader.with_cwd(PathBuf::from("/cwd"));
1658 loader.add_extension(PathBuf::from("/ext"));
1659 loader.add_skill(PathBuf::from("/skill"));
1660
1661 assert_eq!(loader.extensions.len(), 1);
1662 assert_eq!(loader.skills.len(), 1);
1663 }
1664
1665 #[test]
1666 fn test_find_git_root_no_git() {
1667 let temp = tempdir().unwrap();
1668 let result = find_git_root(temp.path());
1669 assert!(result.is_none());
1670 }
1671
1672 #[test]
1673 fn test_find_git_root() {
1674 let temp = tempdir().unwrap();
1675 fs::create_dir_all(temp.path().join("sub").join("deep")).unwrap();
1676 fs::write(temp.path().join(".git"), "gitdir: somewhere").unwrap();
1677
1678 let result = find_git_root(&temp.path().join("sub").join("deep"));
1679 assert!(result.is_some());
1680 assert_eq!(result.unwrap(), temp.path());
1681 }
1682
1683 #[test]
1684 fn test_resolve_prompt_input_text() {
1685 let result = resolve_prompt_input("hello world", "test");
1686 assert_eq!(result, Some("hello world".to_string()));
1687 }
1688
1689 #[test]
1690 fn test_resolve_prompt_input_empty() {
1691 let result = resolve_prompt_input("", "test");
1692 assert!(result.is_none());
1693 }
1694
1695 #[test]
1696 fn test_resolve_prompt_input_from_file() {
1697 let temp = tempdir().unwrap();
1698 let file_path = temp.path().join("prompt.txt");
1699 fs::write(&file_path, "file content").unwrap();
1700
1701 let result = resolve_prompt_input(file_path.to_str().unwrap(), "test");
1702 assert_eq!(result, Some("file content".to_string()));
1703 }
1704
1705 #[test]
1706 fn test_resource_collision_display() {
1707 let collision = ResourceCollision {
1708 resource_type: "skill".to_string(),
1709 name: "my-skill".to_string(),
1710 winner_path: PathBuf::from("/a/skill.md"),
1711 loser_path: PathBuf::from("/b/skill.md"),
1712 };
1713 let display = collision.to_string();
1714 assert!(display.contains("skill"));
1715 assert!(display.contains("my-skill"));
1716 }
1717
1718 #[test]
1719 fn test_load_all_creates_cache() {
1720 let temp = tempdir().unwrap();
1721 let loader = ResourceLoader::with_paths(temp.path().join("oxi"), temp.path().to_path_buf());
1722
1723 let result = loader.load_all().unwrap();
1724
1725 let cached = loader.cached();
1726 assert!(cached.is_some());
1727
1728 let cached = cached.unwrap();
1729 assert_eq!(cached.skills.len(), result.skills.len());
1730 }
1731
1732 #[test]
1733 fn test_deduplication_in_discover() {
1734 let temp = tempdir().unwrap();
1735 fs::write(temp.path().join("AGENTS.md"), "# Agents").unwrap();
1736
1737 let loader = ResourceLoader::new();
1738 let discovered = loader.discover_context_files(temp.path());
1739
1740 let paths: Vec<_> = discovered.iter().map(|(p, _)| p.clone()).collect();
1741 let unique: HashSet<_> = paths
1742 .iter()
1743 .map(|p| p.to_string_lossy().to_string())
1744 .collect();
1745 assert_eq!(paths.len(), unique.len());
1746 }
1747
1748 #[test]
1749 fn test_resource_loader_options_default() {
1750 let opts = ResourceLoaderOptions::default();
1751 assert!(!opts.no_extensions);
1752 assert!(!opts.no_skills);
1753 assert!(!opts.no_prompts);
1754 assert!(!opts.no_themes);
1755 assert!(!opts.no_context_files);
1756 }
1757
1758 #[test]
1759 fn test_extend_resources() {
1760 let mut loader = ResourceLoader::new();
1761 loader.extend_resources(
1762 vec![(PathBuf::from("/skill1"), PathMetadata::cli())],
1763 vec![(PathBuf::from("/prompt1"), PathMetadata::cli())],
1764 vec![(PathBuf::from("/theme1"), PathMetadata::cli())],
1765 );
1766
1767 assert_eq!(loader.skills.len(), 1);
1768 assert_eq!(loader.prompts.len(), 1);
1769 assert_eq!(loader.themes.len(), 1);
1770 }
1771
1772 #[test]
1773 fn test_detect_resource_type() {
1774 let temp = tempdir().unwrap();
1775
1776 let skill_dir = temp.path().join("my-skill");
1778 fs::create_dir_all(&skill_dir).unwrap();
1779 fs::write(skill_dir.join("SKILL.md"), "# My Skill").unwrap();
1780 assert_eq!(
1781 ResourceLoader::detect_resource_type(&skill_dir),
1782 Some(ResourceType::Skill)
1783 );
1784
1785 let theme_file = temp.path().join("theme.json");
1787 fs::write(&theme_file, r#"{"name": "test"}"#).unwrap();
1788 assert_eq!(
1789 ResourceLoader::detect_resource_type(&theme_file),
1790 Some(ResourceType::Theme)
1791 );
1792 }
1793
1794 #[test]
1795 fn test_validate_resource_path() {
1796 let temp = tempdir().unwrap();
1797
1798 let skill_file = temp.path().join("skill.md");
1799 fs::write(&skill_file, "# Skill").unwrap();
1800
1801 let result = ResourceLoader::validate_resource_path(&skill_file);
1802 assert!(result.is_ok());
1803
1804 let nonexistent = temp.path().join("nonexistent");
1805 let result = ResourceLoader::validate_resource_path(&nonexistent);
1806 assert!(result.is_err());
1807 }
1808
1809 #[test]
1810 fn test_getters_without_cache() {
1811 let loader = ResourceLoader::new();
1812 assert!(loader.get_skills().is_empty());
1813 assert!(loader.get_themes().is_empty());
1814 assert!(loader.get_prompts().is_empty());
1815 assert!(loader.get_context_files().is_empty());
1816 assert!(loader.get_system_prompt().is_none());
1817 assert!(loader.get_append_system_prompt().is_empty());
1818 assert!(loader.get_agents_files().is_empty());
1819 }
1820
1821 #[test]
1822 fn test_load_project_context_files_order() {
1823 let temp = tempdir().unwrap();
1824
1825 fs::write(temp.path().join("CLAUDE.md"), "# Claude").unwrap();
1827 fs::write(temp.path().join("AGENTS.md"), "# Agents").unwrap();
1828
1829 let loader = ResourceLoader::with_paths(temp.path().join("oxi"), temp.path().to_path_buf());
1830
1831 let files = loader.load_project_context_files(temp.path()).unwrap();
1832
1833 if files.len() >= 2 {
1835 assert!(files[0].priority >= files[1].priority);
1836 }
1837 }
1838
1839 #[test]
1840 fn test_source_info_serialization() {
1841 let info = SourceInfo {
1842 path: PathBuf::from("/test"),
1843 source: "local".to_string(),
1844 scope: "user".to_string(),
1845 origin: "top-level".to_string(),
1846 base_dir: Some(PathBuf::from("/base")),
1847 };
1848 let json = serde_json::to_string(&info).unwrap();
1849 let deserialized: SourceInfo = serde_json::from_str(&json).unwrap();
1850 assert_eq!(deserialized.source, "local");
1851 assert_eq!(deserialized.base_dir, Some(PathBuf::from("/base")));
1852 }
1853}