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