Skip to main content

oxi/
resource_loader.rs

1//! Resource loader for skills, extensions, themes, and prompts
2//!
3//! Provides utilities for loading and watching resource files
4//! from various locations.
5
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10/// Resource type
11#[derive(Debug, Clone, PartialEq, Eq, Hash)]
12pub enum ResourceType {
13    Skill,
14    Extension,
15    Theme,
16    Prompt,
17}
18
19/// A loaded resource
20#[derive(Debug, Clone)]
21pub struct Resource {
22    /// Resource ID
23    pub id: String,
24    /// Resource type
25    pub resource_type: ResourceType,
26    /// Path to the resource file/directory
27    pub path: PathBuf,
28    /// Resource content or metadata
29    pub content: Option<String>,
30    /// Source (local, npm, git, etc.)
31    pub source: String,
32}
33
34/// Resource loading result
35#[derive(Debug)]
36pub struct LoadResult<T> {
37    /// Loaded items
38    pub items: Vec<T>,
39    /// Any errors encountered
40    pub errors: Vec<LoadError>,
41    /// Diagnostics
42    pub diagnostics: Vec<ResourceDiagnostic>,
43}
44
45/// Load error
46#[derive(Debug, Clone)]
47pub struct LoadError {
48    pub path: PathBuf,
49    pub error: String,
50}
51
52/// Resource diagnostic
53#[derive(Debug, Clone)]
54pub struct ResourceDiagnostic {
55    pub severity: DiagnosticSeverity,
56    pub message: String,
57    pub path: Option<PathBuf>,
58}
59
60/// Diagnostic severity
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum DiagnosticSeverity {
63    Warning,
64    Error,
65    Info,
66}
67
68/// Resource path configuration
69#[derive(Debug, Clone)]
70pub struct ResourcePaths {
71    /// Base directory for resources
72    pub base_dir: PathBuf,
73    /// Additional paths to search
74    pub additional_paths: Vec<PathBuf>,
75    /// Whether to include default paths
76    pub include_defaults: bool,
77}
78
79impl Default for ResourcePaths {
80    fn default() -> Self {
81        Self {
82            base_dir: dirs::config_dir()
83                .unwrap_or_else(|| PathBuf::from("."))
84                .join("oxi"),
85            additional_paths: Vec::new(),
86            include_defaults: true,
87        }
88    }
89}
90
91/// Resolve the default resource directory
92pub fn default_resource_dir() -> PathBuf {
93    dirs::config_dir()
94        .unwrap_or_else(|| PathBuf::from("."))
95        .join("oxi")
96}
97
98/// Get the skills directory
99pub fn skills_dir(base: &Path) -> PathBuf {
100    base.join("skills")
101}
102
103/// Get the extensions directory
104pub fn extensions_dir(base: &Path) -> PathBuf {
105    base.join("extensions")
106}
107
108/// Get the themes directory
109pub fn themes_dir(base: &Path) -> PathBuf {
110    base.join("themes")
111}
112
113/// Get the prompts directory
114pub fn prompts_dir(base: &Path) -> PathBuf {
115    base.join("prompts")
116}
117
118/// Load skills from a directory
119pub fn load_skills_from_dir(dir: &Path) -> LoadResult<Skill> {
120    let mut items = Vec::new();
121    let mut errors = Vec::new();
122    let mut diagnostics = Vec::new();
123
124    if !dir.exists() {
125        return LoadResult {
126            items,
127            errors,
128            diagnostics,
129        };
130    }
131
132    if let Ok(entries) = fs::read_dir(dir) {
133        for entry in entries.flatten() {
134            let path = entry.path();
135            if path.is_dir() || path.extension().map(|e| e == "md").unwrap_or(false) {
136                match load_skill(&path) {
137                    Ok(skill) => items.push(skill),
138                    Err(e) => {
139                        errors.push(LoadError {
140                            path: path.clone(),
141                            error: e.clone(),
142                        });
143                        diagnostics.push(ResourceDiagnostic {
144                            severity: DiagnosticSeverity::Error,
145                            message: e,
146                            path: Some(path),
147                        });
148                    }
149                }
150            }
151        }
152    }
153
154    LoadResult {
155        items,
156        errors,
157        diagnostics,
158    }
159}
160
161/// Load a single skill
162pub fn load_skill(path: &Path) -> Result<Skill, String> {
163    let content = if path.is_file() {
164        fs::read_to_string(path).map_err(|e| e.to_string())?
165    } else if path.is_dir() {
166        // Look for SKILL.md in directory
167        let skill_md = path.join("SKILL.md");
168        if skill_md.exists() {
169            fs::read_to_string(&skill_md).map_err(|e| e.to_string())?
170        } else {
171            return Err("No SKILL.md found in directory".to_string());
172        }
173    } else {
174        return Err("Invalid skill path".to_string());
175    };
176
177    let id = path
178        .file_stem()
179        .and_then(|s| s.to_str())
180        .unwrap_or("unknown")
181        .to_string();
182
183    let name = extract_yaml_field(&content, "name").or_else(|| Some(id.clone()));
184    let description = extract_yaml_field(&content, "description");
185
186    Ok(Skill {
187        id,
188        path: path.to_path_buf(),
189        content,
190        name,
191        description,
192        source: "local".to_string(),
193    })
194}
195
196/// A loaded skill
197#[derive(Debug, Clone)]
198pub struct Skill {
199    pub id: String,
200    pub path: PathBuf,
201    pub content: String,
202    pub name: Option<String>,
203    pub description: Option<String>,
204    pub source: String,
205}
206
207/// Load themes from a directory
208pub fn load_themes_from_dir(dir: &Path) -> LoadResult<Theme> {
209    let mut items = Vec::new();
210    let mut errors = Vec::new();
211    let mut diagnostics = Vec::new();
212
213    if !dir.exists() {
214        return LoadResult {
215            items,
216            errors,
217            diagnostics,
218        };
219    }
220
221    if let Ok(entries) = fs::read_dir(dir) {
222        for entry in entries.flatten() {
223            let path = entry.path();
224            if path.extension().map(|e| e == "json").unwrap_or(false) {
225                match load_theme(&path) {
226                    Ok(theme) => items.push(theme),
227                    Err(e) => {
228                        errors.push(LoadError {
229                            path: path.clone(),
230                            error: e.clone(),
231                        });
232                        diagnostics.push(ResourceDiagnostic {
233                            severity: DiagnosticSeverity::Warning,
234                            message: e,
235                            path: Some(path),
236                        });
237                    }
238                }
239            }
240        }
241    }
242
243    LoadResult {
244        items,
245        errors,
246        diagnostics,
247    }
248}
249
250/// Load a single theme
251pub fn load_theme(path: &Path) -> Result<Theme, String> {
252    let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
253    let json: serde_json::Value = serde_json::from_str(&content).map_err(|e| e.to_string())?;
254
255    let name = json
256        .get("name")
257        .and_then(|v| v.as_str())
258        .map(String::from)
259        .unwrap_or_else(|| {
260            path.file_stem()
261                .and_then(|s| s.to_str())
262                .unwrap_or("unnamed")
263                .to_string()
264        });
265
266    Ok(Theme {
267        id: name.to_lowercase().replace(' ', "_"),
268        name,
269        path: path.to_path_buf(),
270        content: json,
271        source: "local".to_string(),
272    })
273}
274
275/// A loaded theme
276#[derive(Debug, Clone)]
277pub struct Theme {
278    pub id: String,
279    pub name: String,
280    pub path: PathBuf,
281    pub content: serde_json::Value,
282    pub source: String,
283}
284
285/// Load prompts from a directory
286pub fn load_prompts_from_dir(dir: &Path) -> LoadResult<Prompt> {
287    let mut items = Vec::new();
288    let mut errors = Vec::new();
289    let mut diagnostics = Vec::new();
290
291    if !dir.exists() {
292        return LoadResult {
293            items,
294            errors,
295            diagnostics,
296        };
297    }
298
299    if let Ok(entries) = fs::read_dir(dir) {
300        for entry in entries.flatten() {
301            let path = entry.path();
302            if path.is_file() && path.extension().map(|e| e == "md").unwrap_or(false) {
303                match load_prompt(&path) {
304                    Ok(prompt) => items.push(prompt),
305                    Err(e) => {
306                        errors.push(LoadError {
307                            path: path.clone(),
308                            error: e.clone(),
309                        });
310                        diagnostics.push(ResourceDiagnostic {
311                            severity: DiagnosticSeverity::Warning,
312                            message: e,
313                            path: Some(path),
314                        });
315                    }
316                }
317            }
318        }
319    }
320
321    LoadResult {
322        items,
323        errors,
324        diagnostics,
325    }
326}
327
328/// Load a single prompt
329pub fn load_prompt(path: &Path) -> Result<Prompt, String> {
330    let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
331
332    let name = path
333        .file_stem()
334        .and_then(|s| s.to_str())
335        .unwrap_or("unknown")
336        .to_string();
337
338    Ok(Prompt {
339        id: name.clone(),
340        name,
341        path: path.to_path_buf(),
342        content,
343        description: None,
344        source: "local".to_string(),
345    })
346}
347
348/// A loaded prompt template
349#[derive(Debug, Clone)]
350pub struct Prompt {
351    pub id: String,
352    pub name: String,
353    pub path: PathBuf,
354    pub content: String,
355    pub description: Option<String>,
356    pub source: String,
357}
358
359/// Extract a YAML frontmatter field
360fn extract_yaml_field(content: &str, field: &str) -> Option<String> {
361    // Simple YAML frontmatter extraction
362    if !content.starts_with("---") {
363        return None;
364    }
365
366    if let Some(end) = content[3..].find("---") {
367        let frontmatter = &content[3..end + 3];
368        for line in frontmatter.lines() {
369            if let Some(value) = line.strip_prefix(&format!("{}:", field)) {
370                let value = value.trim();
371                // Remove quotes if present
372                let value = value.trim_matches('"').trim_matches('\'');
373                return Some(value.to_string());
374            }
375        }
376    }
377
378    None
379}
380
381/// Resolve a path with ~ expansion
382pub fn resolve_path(path: &Path) -> PathBuf {
383    let path_str = path.to_string_lossy();
384    if path_str.starts_with("~/") {
385        if let Some(home) = dirs::home_dir() {
386            return home.join(path_str.strip_prefix("~/").unwrap());
387        }
388    }
389    path.to_path_buf()
390}
391
392/// Watch a directory for changes
393pub struct ResourceWatcher {
394    paths: Vec<PathBuf>,
395    callbacks: HashMap<PathBuf, Vec<Box<dyn Fn(ResourceChange) + Send + Sync>>>,
396}
397
398impl ResourceWatcher {
399    pub fn new() -> Self {
400        Self {
401            paths: Vec::new(),
402            callbacks: HashMap::new(),
403        }
404    }
405
406    /// Add a path to watch
407    pub fn add_path(&mut self, path: PathBuf) {
408        self.paths.push(path.clone());
409        self.callbacks.entry(path).or_insert_with(Vec::new);
410    }
411
412    /// Register a callback for changes
413    pub fn on_change<F>(&mut self, path: &Path, callback: F)
414    where
415        F: Fn(ResourceChange) + Send + Sync + 'static,
416    {
417        let path = path.to_path_buf();
418        self.callbacks
419            .entry(path.clone())
420            .or_insert_with(Vec::new)
421            .push(Box::new(callback));
422    }
423
424    /// Check for changes and notify callbacks
425    pub fn check_changes(&mut self) {
426        for path in &self.paths {
427            if let Ok(metadata) = fs::metadata(path) {
428                if metadata.modified().is_ok() {
429                    let change = ResourceChange {
430                        path: path.clone(),
431                        kind: ChangeKind::Modified,
432                    };
433                    if let Some(callbacks) = self.callbacks.get(path) {
434                        for callback in callbacks {
435                            callback(change.clone());
436                        }
437                    }
438                }
439            }
440        }
441    }
442}
443
444impl Default for ResourceWatcher {
445    fn default() -> Self {
446        Self::new()
447    }
448}
449
450/// A resource change event
451#[derive(Debug, Clone)]
452pub struct ResourceChange {
453    pub path: PathBuf,
454    pub kind: ChangeKind,
455}
456
457/// Change kind
458#[derive(Debug, Clone, Copy)]
459pub enum ChangeKind {
460    Created,
461    Modified,
462    Deleted,
463}
464
465/// Load all resources from default locations
466pub fn load_all_resources(base_dir: &Path) -> LoadAllResourcesResult {
467    let mut errors = Vec::new();
468    let mut diagnostics = Vec::new();
469
470    let skills_base = skills_dir(base_dir);
471    let skills_result = load_skills_from_dir(&skills_base);
472    errors.extend(skills_result.errors);
473    diagnostics.extend(skills_result.diagnostics);
474
475    let themes_base = themes_dir(base_dir);
476    let themes_result = load_themes_from_dir(&themes_base);
477    errors.extend(themes_result.errors);
478    diagnostics.extend(themes_result.diagnostics);
479
480    let prompts_base = prompts_dir(base_dir);
481    let prompts_result = load_prompts_from_dir(&prompts_base);
482    errors.extend(prompts_result.errors);
483    diagnostics.extend(prompts_result.diagnostics);
484
485    LoadAllResourcesResult {
486        skills: skills_result.items,
487        themes: themes_result.items,
488        prompts: prompts_result.items,
489        errors,
490        diagnostics,
491    }
492}
493
494/// Result of loading all resources
495pub struct LoadAllResourcesResult {
496    pub skills: Vec<Skill>,
497    pub themes: Vec<Theme>,
498    pub prompts: Vec<Prompt>,
499    pub errors: Vec<LoadError>,
500    pub diagnostics: Vec<ResourceDiagnostic>,
501}
502
503/// Check if a path exists and is a valid resource
504pub fn is_valid_resource_path(path: &Path, resource_type: ResourceType) -> bool {
505    if !path.exists() {
506        return false;
507    }
508
509    match resource_type {
510        ResourceType::Skill => {
511            path.is_dir() || path.extension().map(|e| e == "md").unwrap_or(false)
512        }
513        ResourceType::Theme => path.extension().map(|e| e == "json").unwrap_or(false),
514        ResourceType::Prompt => path.extension().map(|e| e == "md").unwrap_or(false),
515        ResourceType::Extension => path.extension().map(|e| e == "js" || e == "ts").unwrap_or(false),
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use std::io::Write;
523
524    #[test]
525    fn test_resolve_path_with_tilde() {
526        let path = resolve_path(Path::new("~/test"));
527        assert!(!path.to_string_lossy().contains("~"));
528    }
529
530    #[test]
531    fn test_resolve_path_absolute() {
532        let path = resolve_path(Path::new("/absolute/path"));
533        assert_eq!(path, PathBuf::from("/absolute/path"));
534    }
535
536    #[test]
537    fn test_extract_yaml_field() {
538        let content = r#"---
539name: Test Skill
540description: A test skill
541---
542# Content"#;
543        assert_eq!(extract_yaml_field(content, "name"), Some("Test Skill".to_string()));
544        assert_eq!(extract_yaml_field(content, "description"), Some("A test skill".to_string()));
545        assert_eq!(extract_yaml_field(content, "nonexistent"), None);
546    }
547
548    #[test]
549    fn test_load_skills_from_nonexistent_dir() {
550        let result = load_skills_from_dir(Path::new("/nonexistent/path"));
551        assert!(result.items.is_empty());
552        assert!(result.errors.is_empty());
553    }
554
555    #[test]
556    fn test_load_themes_from_nonexistent_dir() {
557        let result = load_themes_from_dir(Path::new("/nonexistent/path"));
558        assert!(result.items.is_empty());
559    }
560
561    #[test]
562    fn test_load_prompts_from_nonexistent_dir() {
563        let result = load_prompts_from_dir(Path::new("/nonexistent/path"));
564        assert!(result.items.is_empty());
565    }
566
567    #[test]
568    fn test_is_valid_resource_path() {
569        assert!(!is_valid_resource_path(Path::new("/nonexistent"), ResourceType::Skill));
570    }
571
572    #[test]
573    fn test_resource_watcher() {
574        let mut watcher = ResourceWatcher::new();
575        let path = PathBuf::from("/tmp/test");
576        watcher.add_path(path.clone());
577
578        let change_received = Arc::new(std::sync::atomic::AtomicBool::new(false));
579        let change_received_clone = change_received.clone();
580        watcher.on_change(&path, move |_| {
581            change_received_clone.store(true, std::sync::atomic::Ordering::SeqCst);
582        });
583
584        // Just verify it doesn't panic
585        watcher.check_changes();
586        // change_received might be false if path doesn't exist
587        assert!(true);
588    }
589
590    #[test]
591    fn test_load_all_resources() {
592        // Create a temp directory with some resources
593        let temp_dir = tempfile::tempdir().unwrap();
594        let base = temp_dir.path();
595
596        // Create skills dir
597        let skills_dir = base.join("skills");
598        fs::create_dir_all(&skills_dir).unwrap();
599        fs::write(skills_dir.join("test.md"), "---\nname: Test\n---\nTest content").unwrap();
600
601        let result = load_all_resources(base);
602
603        // Should have loaded the test skill
604        assert!(!result.skills.is_empty());
605    }
606
607    #[test]
608    fn test_skill_id_extraction() {
609        let temp_dir = tempfile::tempdir().unwrap();
610        let skill_path = temp_dir.path().join("my_skill.md");
611        fs::write(&skill_path, "# Skill").unwrap();
612
613        let skill = load_skill(&skill_path).unwrap();
614        assert_eq!(skill.id, "my_skill");
615    }
616
617    #[test]
618    fn test_resource_paths_default() {
619        let paths = ResourcePaths::default();
620        assert!(paths.base_dir.ends_with("oxi"));
621    }
622
623    #[test]
624    fn test_resource_dirs() {
625        let base = PathBuf::from("/test/base");
626        assert_eq!(skills_dir(&base), base.join("skills"));
627        assert_eq!(extensions_dir(&base), base.join("extensions"));
628        assert_eq!(themes_dir(&base), base.join("themes"));
629        assert_eq!(prompts_dir(&base), base.join("prompts"));
630    }
631
632    #[test]
633    fn test_load_error_struct() {
634        let error = LoadError {
635            path: PathBuf::from("/test"),
636            error: "test error".to_string(),
637        };
638        assert_eq!(error.error, "test error");
639    }
640
641    #[test]
642    fn test_resource_diagnostic() {
643        let diag = ResourceDiagnostic {
644            severity: DiagnosticSeverity::Warning,
645            message: "test warning".to_string(),
646            path: Some(PathBuf::from("/test")),
647        };
648        assert_eq!(diag.severity, DiagnosticSeverity::Warning);
649    }
650}