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