1use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11#[derive(Debug, Clone, PartialEq, Eq, Hash)]
13pub enum ResourceType {
14 Skill,
15 Extension,
16 Theme,
17 Prompt,
18}
19
20#[derive(Debug, Clone)]
22pub struct Resource {
23 pub id: String,
25 pub resource_type: ResourceType,
27 pub path: PathBuf,
29 pub content: Option<String>,
31 pub source: String,
33}
34
35#[derive(Debug)]
37pub struct LoadResult<T> {
38 pub items: Vec<T>,
40 pub errors: Vec<LoadError>,
42 pub diagnostics: Vec<ResourceDiagnostic>,
44}
45
46#[derive(Debug, Clone)]
48pub struct LoadError {
49 pub path: PathBuf,
50 pub error: String,
51}
52
53#[derive(Debug, Clone)]
55pub struct ResourceDiagnostic {
56 pub severity: DiagnosticSeverity,
57 pub message: String,
58 pub path: Option<PathBuf>,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum DiagnosticSeverity {
64 Warning,
65 Error,
66 Info,
67}
68
69#[derive(Debug, Clone)]
71pub struct ResourcePaths {
72 pub base_dir: PathBuf,
74 pub additional_paths: Vec<PathBuf>,
76 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
92pub fn default_resource_dir() -> PathBuf {
94 dirs::config_dir()
95 .unwrap_or_else(|| PathBuf::from("."))
96 .join("oxi")
97}
98
99pub fn skills_dir(base: &Path) -> PathBuf {
101 base.join("skills")
102}
103
104pub fn extensions_dir(base: &Path) -> PathBuf {
106 base.join("extensions")
107}
108
109pub fn themes_dir(base: &Path) -> PathBuf {
111 base.join("themes")
112}
113
114pub fn prompts_dir(base: &Path) -> PathBuf {
116 base.join("prompts")
117}
118
119pub 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
162pub 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 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#[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
208pub 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
251pub 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#[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
286pub 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
329pub 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#[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
360fn extract_yaml_field(content: &str, field: &str) -> Option<String> {
362 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 let value = value.trim_matches('"').trim_matches('\'');
374 return Some(value.to_string());
375 }
376 }
377 }
378
379 None
380}
381
382pub 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
393pub 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 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 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 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#[derive(Debug, Clone)]
453pub struct ResourceChange {
454 pub path: PathBuf,
455 pub kind: ChangeKind,
456}
457
458#[derive(Debug, Clone, Copy)]
460pub enum ChangeKind {
461 Created,
462 Modified,
463 Deleted,
464}
465
466pub 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
495pub 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
504pub 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 watcher.check_changes();
587 assert!(true);
589 }
590
591 #[test]
592 fn test_load_all_resources() {
593 let temp_dir = tempfile::tempdir().unwrap();
595 let base = temp_dir.path();
596
597 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 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}