Skip to main content

oxi/storage/
resource_loader.rs

1//! Enhanced Resource loader for oxi
2//!
3//! Loads and manages skills, extensions, themes, and prompts from various locations.
4//! Also handles discovery and loading of project context files (AGENTS.md, CLAUDE.md).
5//!
6//! Features:
7//! - Resource discovery from multiple directories (user, project, CLI)
8//! - Resource type detection and validation
9//! - Caching of loaded resources with invalidation
10//! - Deduplication with collision diagnostics
11//! - System prompt discovery (SYSTEM.md, APPEND_SYSTEM.md)
12//! - Extension conflict detection
13//! - Hot-reload on file changes
14
15use std::collections::{HashMap, HashSet};
16use std::fs;
17use std::path::{Path, PathBuf};
18use std::time::Instant;
19
20use parking_lot::RwLock;
21
22// Re-export types from resource_loader_compat
23pub use super::resource_loader_compat::{Prompt, Skill, Theme};
24
25// ============================================================================
26// Context Files
27// ============================================================================
28
29/// A project context file (AGENTS.md, CLAUDE.md, etc.)
30#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
31pub struct ContextFile {
32    /// Full path to the file
33    pub path: PathBuf,
34    /// Filename (e.g., "AGENTS.md", "CLAUDE.md")
35    pub name: String,
36    /// Priority for inclusion (higher = more important)
37    pub priority: u8,
38    /// File content
39    pub content: String,
40}
41
42impl ContextFile {
43    /// Create a new context file
44    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    /// Get the file extension
54    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/// Context file candidates in priority order
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum ContextFileType {
65    /// AGENTS.md - highest priority, explicit agent instructions
66    Agents,
67    /// CLAUDE.md - Claude-specific instructions
68    Claude,
69}
70
71impl ContextFileType {
72    /// Get the filename for this context file type
73    pub fn filename(&self) -> &'static str {
74        match self {
75            ContextFileType::Agents => "AGENTS.md",
76            ContextFileType::Claude => "CLAUDE.md",
77        }
78    }
79
80    /// Get the priority (higher = more important)
81    pub fn priority(&self) -> u8 {
82        match self {
83            ContextFileType::Agents => 100,
84            ContextFileType::Claude => 90,
85        }
86    }
87
88    /// Get all supported variants (case-insensitive)
89    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    /// Detect file type from filename
97    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// ============================================================================
108// Source Types
109// ============================================================================
110
111/// Source of a loaded resource
112#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
113#[allow(dead_code)]
114pub enum SourceType {
115    /// Default system location (~/.oxi or ~/.config/oxi)
116    Default,
117    /// Project-level location (.oxi in project root)
118    Project,
119    /// CLI-specified location
120    Cli,
121    /// Inline/factory-created resource
122    Inline,
123    /// Npm package resource
124    Package,
125    /// Git repository resource
126    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/// A source directory for resources
143#[derive(Debug, Clone)]
144#[allow(dead_code)]
145pub struct Source {
146    /// Path to the source directory
147    pub path: PathBuf,
148    /// Source type
149    pub source_type: SourceType,
150    /// Whether this source is enabled
151    pub enabled: bool,
152}
153
154impl Source {
155    /// Create a new source
156    #[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    /// Check if path exists
166    #[allow(dead_code)]
167    pub fn exists(&self) -> bool {
168        self.path.exists()
169    }
170
171    /// Check if path is a directory
172    #[allow(dead_code)]
173    pub fn is_dir(&self) -> bool {
174        self.path.is_dir()
175    }
176}
177
178// ============================================================================
179// Source Info (for tracking where resources came from)
180// ============================================================================
181
182/// Information about where a resource was loaded from
183#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
184#[allow(dead_code)]
185pub struct SourceInfo {
186    /// Path to the resource
187    pub path: PathBuf,
188    /// Source type (local, cli, package, etc.)
189    pub source: String,
190    /// Scope (user, project, temporary)
191    pub scope: String,
192    /// Origin (top-level, package, etc.)
193    pub origin: String,
194    /// Base directory (optional)
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub base_dir: Option<PathBuf>,
197}
198
199// ============================================================================
200// Extension Sources
201// ============================================================================
202
203/// Source for extension loading
204#[derive(Debug, Clone)]
205#[allow(dead_code)]
206pub struct ExtensionSource {
207    /// Path to the extension
208    #[allow(dead_code)]
209    pub path: PathBuf,
210    /// Metadata about the extension source
211    #[allow(dead_code)]
212    pub metadata: PathMetadata,
213    /// Source info for tracking
214    #[allow(dead_code)]
215    pub source_info: Option<SourceInfo>,
216}
217
218/// Metadata about a resource path
219#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
220pub struct PathMetadata {
221    /// Source (default, cli, package, etc.)
222    pub source: String,
223    /// Scope (user, project, temporary, etc.)
224    pub scope: String,
225    /// Origin (top-level, package, etc.)
226    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    /// Create metadata for a CLI source
241    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    /// Create metadata for a project source
250    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    /// Create metadata for a default/user source
259    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/// Skill source configuration
269#[derive(Debug, Clone)]
270pub struct SkillSource {
271    /// Path to the skill
272    pub path: PathBuf,
273    /// Metadata
274    #[allow(dead_code)]
275    pub metadata: PathMetadata,
276    /// Whether enabled
277    pub enabled: bool,
278}
279
280/// Theme source configuration
281#[derive(Debug, Clone)]
282pub struct ThemeSource {
283    /// Path to the theme
284    pub path: PathBuf,
285    /// Metadata
286    #[allow(dead_code)]
287    pub metadata: PathMetadata,
288    /// Whether enabled
289    pub enabled: bool,
290}
291
292/// Prompt source configuration
293#[derive(Debug, Clone)]
294pub struct PromptSource {
295    /// Path to the prompt
296    pub path: PathBuf,
297    /// Metadata
298    #[allow(dead_code)]
299    pub metadata: PathMetadata,
300    /// Whether enabled
301    pub enabled: bool,
302}
303
304// ============================================================================
305// Resource Collision
306// ============================================================================
307
308/// A resource collision (two resources with the same name)
309#[derive(Debug, Clone)]
310pub struct ResourceCollision {
311    /// Resource type
312    pub resource_type: String,
313    /// Name that collided
314    pub name: String,
315    /// Path of the winner
316    pub winner_path: PathBuf,
317    /// Path of the loser
318    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// ============================================================================
335// Loaded Resources
336// ============================================================================
337
338/// All loaded resources
339#[derive(Debug, Clone)]
340pub struct LoadedResources {
341    /// All loaded skills
342    pub skills: Vec<Skill>,
343    /// All loaded themes
344    pub themes: Vec<Theme>,
345    /// All loaded prompts
346    pub prompts: Vec<Prompt>,
347    /// All loaded context files
348    pub context_files: Vec<ContextFile>,
349    /// System prompt content
350    pub system_prompt: Option<String>,
351    /// Append system prompt content
352    pub append_system_prompt: Vec<String>,
353    /// Errors encountered during loading
354    pub errors: Vec<LoadError>,
355    /// Diagnostics from loading
356    pub diagnostics: Vec<ResourceDiagnostic>,
357    /// Resource collisions
358    pub collisions: Vec<ResourceCollision>,
359    /// Timestamp when resources were loaded
360    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// ============================================================================
381// Resource Loader Options
382// ============================================================================
383
384/// Options for configuring the resource loader
385#[derive(Debug, Clone)]
386pub struct ResourceLoaderOptions {
387    /// Current working directory
388    pub cwd: PathBuf,
389    /// Base agent directory (e.g., ~/.config/oxi)
390    pub agent_dir: PathBuf,
391    /// Additional extension paths (from CLI)
392    pub additional_extension_paths: Vec<PathBuf>,
393    /// Additional skill paths (from CLI)
394    pub additional_skill_paths: Vec<PathBuf>,
395    /// Additional prompt template paths (from CLI)
396    pub additional_prompt_paths: Vec<PathBuf>,
397    /// Additional theme paths (from CLI)
398    pub additional_theme_paths: Vec<PathBuf>,
399    /// Disable extension loading
400    pub no_extensions: bool,
401    /// Disable skill loading
402    pub no_skills: bool,
403    /// Disable prompt loading
404    pub no_prompts: bool,
405    /// Disable theme loading
406    pub no_themes: bool,
407    /// Disable context file loading
408    pub no_context_files: bool,
409    /// Explicit system prompt (from CLI)
410    pub system_prompt: Option<String>,
411    /// Explicit append system prompts
412    pub append_system_prompt: Vec<String>,
413}
414
415impl ResourceLoaderOptions {
416    /// Create options with default directories
417    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
445// ============================================================================
446// Resource Loader
447// ============================================================================
448
449/// Resource loader for all oxi resources
450///
451/// Loads skills, prompts, themes, context files, and system prompts from
452/// multiple directories (user-level, project-level, CLI-specified).
453/// Supports caching, deduplication, and hot-reload.
454pub struct ResourceLoader {
455    /// Options
456    options: ResourceLoaderOptions,
457    /// Extension sources
458    extensions: Vec<ExtensionSource>,
459    /// Skill sources
460    skills: Vec<SkillSource>,
461    /// Theme sources
462    themes: Vec<ThemeSource>,
463    /// Prompt sources
464    prompts: Vec<PromptSource>,
465    /// Loaded resources cache
466    cache: RwLock<Option<LoadedResources>>,
467    /// Last file modification times for hot-reload
468    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    /// Create a new resource loader with defaults
479    pub fn new() -> Self {
480        Self::with_options(ResourceLoaderOptions::new())
481    }
482
483    /// Create a resource loader with options
484    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    /// Create with custom base and working directory
497    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    // -----------------------------------------------------------------------
507    // Builder methods
508    // -----------------------------------------------------------------------
509
510    /// Set base directory
511    pub fn with_base_dir(&mut self, base_dir: PathBuf) -> &mut Self {
512        self.options.agent_dir = base_dir;
513        self
514    }
515
516    /// Set current working directory
517    pub fn with_cwd(&mut self, cwd: PathBuf) -> &mut Self {
518        self.options.cwd = cwd;
519        self
520    }
521
522    /// Add an extension source
523    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    /// Add a skill source
533    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    /// Add a theme source
543    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    /// Add a prompt source
553    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    /// Extend resources from extension paths
563    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    // -----------------------------------------------------------------------
593    // Loading
594    // -----------------------------------------------------------------------
595
596    /// Load all resources
597    pub fn load_all(&self) -> Result<LoadedResources, anyhow::Error> {
598        let mut result = LoadedResources::default();
599
600        // Load skills
601        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        // Load themes
607        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        // Load prompts
613        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        // Deduplicate and detect collisions
619        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        // Load context files
632        if !self.options.no_context_files {
633            result.context_files = self.load_project_context_files(&self.options.cwd)?;
634        }
635
636        // Load system prompts
637        result.system_prompt = self.load_system_prompt()?;
638        result.append_system_prompt = self.load_append_system_prompt()?;
639
640        // Update modification times for hot-reload
641        self.update_modification_times(&result);
642
643        // Update cache
644        *self.cache.write() = Some(result.clone());
645
646        Ok(result)
647    }
648
649    /// Load all resources, returning default on error
650    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    /// Reload resources (clears cache first)
661    pub fn reload(&self) -> Result<LoadedResources, anyhow::Error> {
662        self.clear_cache();
663        self.load_all()
664    }
665
666    // -----------------------------------------------------------------------
667    // System Prompts
668    // -----------------------------------------------------------------------
669
670    /// Load system prompt from SYSTEM.md files
671    pub fn load_system_prompt(&self) -> Result<Option<String>, anyhow::Error> {
672        // If explicit system prompt was provided, use it
673        if let Some(ref prompt) = self.options.system_prompt {
674            return Ok(resolve_prompt_input(prompt, "system prompt"));
675        }
676
677        // Discover SYSTEM.md files
678        let candidates = vec![
679            // Project-level
680            self.options.cwd.join(".oxi").join("SYSTEM.md"),
681            // Global
682            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    /// Load append system prompt from APPEND_SYSTEM.md files
700    pub fn load_append_system_prompt(&self) -> Result<Vec<String>, anyhow::Error> {
701        // If explicit append prompts were provided, use them
702        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            // Project-level
715            self.options.cwd.join(".oxi").join("APPEND_SYSTEM.md"),
716            // Global
717            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    // -----------------------------------------------------------------------
739    // Context Files
740    // -----------------------------------------------------------------------
741
742    /// Load project context files (AGENTS.md, CLAUDE.md, etc.)
743    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        // 1. Check global agent dir for context files
751        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        // 2. Discover context files in project + ancestors
766        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        // Sort by priority (descending)
786        context_files.sort_by_key(|b| std::cmp::Reverse(b.priority));
787
788        Ok(context_files)
789    }
790
791    /// Discover context files in project and ancestor directories
792    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        // Try to find git root to limit search
797        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            // Check if we've reached or passed the git root
807            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            // Move to parent
823            if let Some(parent) = current.parent() {
824                current = parent.to_path_buf();
825            } else {
826                break;
827            }
828            iterations += 1;
829        }
830
831        // Deduplicate by path
832        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    /// Read a context file, handling potential errors
847    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    // -----------------------------------------------------------------------
858    // Resource Loading (internal)
859    // -----------------------------------------------------------------------
860
861    /// Load skills from configured sources
862    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        // Load from default directories
868        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        // Load from custom sources
883        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    /// Load themes from configured sources
908    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    /// Load prompts from configured sources
952    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    // -----------------------------------------------------------------------
996    // Cache & Hot-Reload
997    // -----------------------------------------------------------------------
998
999    /// Get cached resources if available
1000    pub fn cached(&self) -> Option<LoadedResources> {
1001        self.cache.read().clone()
1002    }
1003
1004    /// Clear the cache
1005    pub fn clear_cache(&self) {
1006        *self.cache.write() = None;
1007    }
1008
1009    /// Check if cache is stale (any source file was modified since last load)
1010    pub fn is_cache_stale(&self) -> bool {
1011        let cache = self.cache.read();
1012        if cache.is_none() {
1013            return true; // No cache means stale
1014        }
1015
1016        let mtimes = self.modification_times.read();
1017        if mtimes.is_empty() {
1018            return false; // No files to track
1019        }
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    /// Load if cache is stale, otherwise return cached
1035    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    /// Update modification times for all tracked files
1046    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    // -----------------------------------------------------------------------
1077    // Resource Type Detection
1078    // -----------------------------------------------------------------------
1079
1080    /// Detect the resource type from a path
1081    pub fn detect_resource_type(path: &Path) -> Option<ResourceType> {
1082        if !path.exists() {
1083            return None;
1084        }
1085
1086        if path.is_dir() {
1087            // Directories can be skills (with SKILL.md) or extensions
1088            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            // Default for directories: skill
1095            return Some(ResourceType::Skill);
1096        }
1097
1098        // File-based detection
1099        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    /// Check if a path exists and is a valid resource
1109    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    /// Validate that a resource path can be loaded
1127    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    // -----------------------------------------------------------------------
1137    // Accessors
1138    // -----------------------------------------------------------------------
1139
1140    /// Get the current working directory
1141    pub fn cwd(&self) -> &Path {
1142        &self.options.cwd
1143    }
1144
1145    /// Get the agent directory
1146    pub fn agent_dir(&self) -> &Path {
1147        &self.options.agent_dir
1148    }
1149
1150    /// Get loaded skills
1151    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    /// Get loaded themes
1160    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    /// Get loaded prompts
1169    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    /// Get loaded context files
1178    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    /// Get system prompt
1187    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    /// Get append system prompt
1195    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    /// Get agents files (alias for context files in agent format)
1204    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
1218// ============================================================================
1219// Standalone Functions
1220// ============================================================================
1221
1222/// Load a context file from a directory (AGENTS.md, CLAUDE.md, etc.)
1223pub 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
1239/// Find the git root for a directory
1240pub 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
1262/// Resolve prompt input (read from file if path, otherwise return as-is)
1263pub 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
1287/// Resolve the default resource directory
1288pub fn default_resource_dir() -> std::path::PathBuf {
1289    dirs::config_dir()
1290        .unwrap_or_else(|| std::path::PathBuf::from("."))
1291        .join("oxi")
1292}
1293
1294/// Get the skills directory
1295pub fn skills_dir(base: &std::path::Path) -> std::path::PathBuf {
1296    base.join("skills")
1297}
1298
1299/// Get the extensions directory
1300#[allow(dead_code)]
1301pub fn extensions_dir(base: &std::path::Path) -> std::path::PathBuf {
1302    base.join("extensions")
1303}
1304
1305/// Get the themes directory
1306pub fn themes_dir(base: &std::path::Path) -> std::path::PathBuf {
1307    base.join("themes")
1308}
1309
1310/// Get the prompts directory
1311pub fn prompts_dir(base: &std::path::Path) -> std::path::PathBuf {
1312    base.join("prompts")
1313}
1314
1315/// Load skills from a directory
1316pub fn load_skills_from_dir(dir: &std::path::Path) -> LoadResult<Skill> {
1317    super::resource_loader_compat::load_skills_from_dir_impl(dir)
1318}
1319
1320/// Load a single skill
1321pub fn load_skill(path: &std::path::Path) -> Result<Skill, String> {
1322    super::resource_loader_compat::load_skill_impl(path)
1323}
1324
1325/// Load themes from a directory
1326pub fn load_themes_from_dir(dir: &std::path::Path) -> LoadResult<Theme> {
1327    super::resource_loader_compat::load_themes_from_dir_impl(dir)
1328}
1329
1330/// Load a single theme
1331pub fn load_theme(path: &std::path::Path) -> Result<Theme, String> {
1332    super::resource_loader_compat::load_theme_impl(path)
1333}
1334
1335/// Load prompts from a directory
1336pub fn load_prompts_from_dir(dir: &std::path::Path) -> LoadResult<Prompt> {
1337    super::resource_loader_compat::load_prompts_from_dir_impl(dir)
1338}
1339
1340/// Load a single prompt
1341pub fn load_prompt(path: &std::path::Path) -> Result<Prompt, String> {
1342    super::resource_loader_compat::load_prompt_impl(path)
1343}
1344
1345/// Load all resources from default locations
1346#[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
1351/// Resolve a path with ~ expansion
1352pub 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            // SAFE: strip_prefix guaranteed to return Some because we checked starts_with("~/") above
1357            return home.join(path_str.strip_prefix("~/").expect("starts_with checked"));
1358        }
1359    }
1360    path.to_path_buf()
1361}
1362
1363// ============================================================================
1364// Deduplication
1365// ============================================================================
1366
1367/// Deduplicate skills by ID, keeping first occurrence
1368fn 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
1390/// Deduplicate themes by ID, keeping first occurrence
1391fn 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
1414/// Deduplicate prompts by name, keeping first occurrence
1415fn 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
1437// ============================================================================
1438// Re-exports from compat module
1439// ============================================================================
1440
1441pub use super::resource_loader_compat::{
1442    LoadAllResourcesResult, LoadError, LoadResult, ResourceDiagnostic, ResourceType,
1443};
1444
1445// ============================================================================
1446// Tests
1447// ============================================================================
1448
1449#[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        // Create AGENTS.md in parent directory
1551        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        // Skill directory with SKILL.md
1777        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        // Theme JSON file
1786        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        // Create multiple context files
1826        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        // AGENTS.md should come first (higher priority)
1834        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}