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