1use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone, PartialEq, Eq, Hash)]
12pub enum ResourceType {
13 Skill,
14 Extension,
15 Theme,
16 Prompt,
17}
18
19#[derive(Debug, Clone)]
21pub struct Resource {
22 pub id: String,
24 pub resource_type: ResourceType,
26 pub path: PathBuf,
28 pub content: Option<String>,
30 pub source: String,
32}
33
34#[derive(Debug)]
36pub struct LoadResult<T> {
37 pub items: Vec<T>,
39 pub errors: Vec<LoadError>,
41 pub diagnostics: Vec<ResourceDiagnostic>,
43}
44
45#[derive(Debug, Clone)]
47pub struct LoadError {
48 pub path: PathBuf,
49 pub error: String,
50}
51
52#[derive(Debug, Clone)]
54pub struct ResourceDiagnostic {
55 pub severity: DiagnosticSeverity,
56 pub message: String,
57 pub path: Option<PathBuf>,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum DiagnosticSeverity {
63 Warning,
64 Error,
65 Info,
66}
67
68#[derive(Debug, Clone)]
70pub struct ResourcePaths {
71 pub base_dir: PathBuf,
73 pub additional_paths: Vec<PathBuf>,
75 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
91pub fn default_resource_dir() -> PathBuf {
93 dirs::config_dir()
94 .unwrap_or_else(|| PathBuf::from("."))
95 .join("oxi")
96}
97
98pub fn skills_dir(base: &Path) -> PathBuf {
100 base.join("skills")
101}
102
103pub fn extensions_dir(base: &Path) -> PathBuf {
105 base.join("extensions")
106}
107
108pub fn themes_dir(base: &Path) -> PathBuf {
110 base.join("themes")
111}
112
113pub fn prompts_dir(base: &Path) -> PathBuf {
115 base.join("prompts")
116}
117
118pub 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
161pub 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 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#[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
207pub 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
250pub 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#[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
285pub 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
328pub 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#[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
359fn extract_yaml_field(content: &str, field: &str) -> Option<String> {
361 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 let value = value.trim_matches('"').trim_matches('\'');
373 return Some(value.to_string());
374 }
375 }
376 }
377
378 None
379}
380
381pub 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
392pub 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 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 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 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#[derive(Debug, Clone)]
452pub struct ResourceChange {
453 pub path: PathBuf,
454 pub kind: ChangeKind,
455}
456
457#[derive(Debug, Clone, Copy)]
459pub enum ChangeKind {
460 Created,
461 Modified,
462 Deleted,
463}
464
465pub 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
494pub 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
503pub 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 watcher.check_changes();
586 assert!(true);
588 }
589
590 #[test]
591 fn test_load_all_resources() {
592 let temp_dir = tempfile::tempdir().unwrap();
594 let base = temp_dir.path();
595
596 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 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}