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,
15 Extension,
17 Theme,
19 Prompt,
21}
22
23#[derive(Debug, Clone)]
25#[allow(dead_code)]
26pub struct Resource {
27 pub id: String,
29 pub resource_type: ResourceType,
31 pub path: PathBuf,
33 pub content: Option<String>,
35 pub source: String,
37}
38
39#[derive(Debug)]
41pub struct LoadResult<T> {
42 pub items: Vec<T>,
44 pub errors: Vec<LoadError>,
46 pub diagnostics: Vec<ResourceDiagnostic>,
48}
49
50#[derive(Debug, Clone)]
52pub struct LoadError {
53 pub path: PathBuf,
55 pub error: String,
57}
58
59#[derive(Debug, Clone)]
61pub struct ResourceDiagnostic {
62 pub severity: DiagnosticSeverity,
64 pub message: String,
66 pub path: Option<PathBuf>,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum DiagnosticSeverity {
73 Warning,
75 Error,
77 Info,
79}
80
81#[derive(Debug, Clone)]
83#[allow(dead_code)]
84pub struct ResourcePaths {
85 pub base_dir: PathBuf,
87 pub additional_paths: Vec<PathBuf>,
89 pub include_defaults: bool,
91}
92
93impl Default for ResourcePaths {
94 fn default() -> Self {
95 Self {
96 base_dir: dirs::config_dir()
97 .unwrap_or_else(|| PathBuf::from("."))
98 .join("oxi"),
99 additional_paths: Vec::new(),
100 include_defaults: true,
101 }
102 }
103}
104
105pub fn load_skills_from_dir_impl(dir: &Path) -> LoadResult<Skill> {
107 let mut items = Vec::new();
108 let mut errors = Vec::new();
109 let mut diagnostics = Vec::new();
110
111 if !dir.exists() {
112 return LoadResult {
113 items,
114 errors,
115 diagnostics,
116 };
117 }
118
119 if let Ok(entries) = fs::read_dir(dir) {
120 for entry in entries.flatten() {
121 let path = entry.path();
122 if path.is_dir() || path.extension().map(|e| e == "md").unwrap_or(false) {
123 match load_skill_impl(&path) {
124 Ok(skill) => items.push(skill),
125 Err(e) => {
126 errors.push(LoadError {
127 path: path.clone(),
128 error: e.clone(),
129 });
130 diagnostics.push(ResourceDiagnostic {
131 severity: DiagnosticSeverity::Error,
132 message: e,
133 path: Some(path),
134 });
135 }
136 }
137 }
138 }
139 }
140
141 LoadResult {
142 items,
143 errors,
144 diagnostics,
145 }
146}
147
148pub fn load_skill_impl(path: &Path) -> Result<Skill, String> {
150 let content = if path.is_file() {
151 fs::read_to_string(path).map_err(|e| e.to_string())?
152 } else if path.is_dir() {
153 let skill_md = path.join("SKILL.md");
154 if skill_md.exists() {
155 fs::read_to_string(&skill_md).map_err(|e| e.to_string())?
156 } else {
157 return Err("No SKILL.md found in directory".to_string());
158 }
159 } else {
160 return Err("Invalid skill path".to_string());
161 };
162
163 let id = path
164 .file_stem()
165 .and_then(|s| s.to_str())
166 .unwrap_or("unknown")
167 .to_string();
168
169 let name = extract_yaml_field(&content, "name").or_else(|| Some(id.clone()));
170 let description = extract_yaml_field(&content, "description");
171
172 Ok(Skill {
173 id,
174 path: path.to_path_buf(),
175 content,
176 name,
177 description,
178 source: "local".to_string(),
179 })
180}
181
182#[derive(Debug, Clone)]
184pub struct Skill {
185 pub id: String,
187 pub path: PathBuf,
189 pub content: String,
191 pub name: Option<String>,
193 pub description: Option<String>,
195 pub source: String,
197}
198
199pub fn load_themes_from_dir_impl(dir: &Path) -> LoadResult<Theme> {
201 let mut items = Vec::new();
202 let mut errors = Vec::new();
203 let mut diagnostics = Vec::new();
204
205 if !dir.exists() {
206 return LoadResult {
207 items,
208 errors,
209 diagnostics,
210 };
211 }
212
213 if let Ok(entries) = fs::read_dir(dir) {
214 for entry in entries.flatten() {
215 let path = entry.path();
216 if path.extension().map(|e| e == "json").unwrap_or(false) {
217 match load_theme_impl(&path) {
218 Ok(theme) => items.push(theme),
219 Err(e) => {
220 errors.push(LoadError {
221 path: path.clone(),
222 error: e.clone(),
223 });
224 diagnostics.push(ResourceDiagnostic {
225 severity: DiagnosticSeverity::Warning,
226 message: e,
227 path: Some(path),
228 });
229 }
230 }
231 }
232 }
233 }
234
235 LoadResult {
236 items,
237 errors,
238 diagnostics,
239 }
240}
241
242pub fn load_theme_impl(path: &Path) -> Result<Theme, String> {
244 let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
245 let json: serde_json::Value = serde_json::from_str(&content).map_err(|e| e.to_string())?;
246
247 let name = json
248 .get("name")
249 .and_then(|v| v.as_str())
250 .map(String::from)
251 .unwrap_or_else(|| {
252 path.file_stem()
253 .and_then(|s| s.to_str())
254 .unwrap_or("unnamed")
255 .to_string()
256 });
257
258 Ok(Theme {
259 id: name.to_lowercase().replace(' ', "_"),
260 name,
261 path: path.to_path_buf(),
262 content: json,
263 source: "local".to_string(),
264 })
265}
266
267#[derive(Debug, Clone)]
269pub struct Theme {
270 pub id: String,
272 pub name: String,
274 pub path: PathBuf,
276 pub content: serde_json::Value,
278 pub source: String,
280}
281
282pub fn load_prompts_from_dir_impl(dir: &Path) -> LoadResult<Prompt> {
284 let mut items = Vec::new();
285 let mut errors = Vec::new();
286 let mut diagnostics = Vec::new();
287
288 if !dir.exists() {
289 return LoadResult {
290 items,
291 errors,
292 diagnostics,
293 };
294 }
295
296 if let Ok(entries) = fs::read_dir(dir) {
297 for entry in entries.flatten() {
298 let path = entry.path();
299 if path.is_file() && path.extension().map(|e| e == "md").unwrap_or(false) {
300 match load_prompt_impl(&path) {
301 Ok(prompt) => items.push(prompt),
302 Err(e) => {
303 errors.push(LoadError {
304 path: path.clone(),
305 error: e.clone(),
306 });
307 diagnostics.push(ResourceDiagnostic {
308 severity: DiagnosticSeverity::Warning,
309 message: e,
310 path: Some(path),
311 });
312 }
313 }
314 }
315 }
316 }
317
318 LoadResult {
319 items,
320 errors,
321 diagnostics,
322 }
323}
324
325pub fn load_prompt_impl(path: &Path) -> Result<Prompt, String> {
327 let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
328
329 let name = path
330 .file_stem()
331 .and_then(|s| s.to_str())
332 .unwrap_or("unknown")
333 .to_string();
334
335 Ok(Prompt {
336 id: name.clone(),
337 name,
338 path: path.to_path_buf(),
339 content,
340 description: None,
341 source: "local".to_string(),
342 })
343}
344
345#[derive(Debug, Clone)]
347pub struct Prompt {
348 pub id: String,
350 pub name: String,
352 pub path: PathBuf,
354 pub content: String,
356 pub description: Option<String>,
358 pub source: String,
360}
361
362fn extract_yaml_field(content: &str, field: &str) -> Option<String> {
364 if !content.starts_with("---") {
365 return None;
366 }
367
368 if let Some(end) = content[3..].find("---") {
369 let frontmatter = &content[3..end + 3];
370 for line in frontmatter.lines() {
371 if let Some(value) = line.strip_prefix(&format!("{}:", field)) {
372 let value = value.trim();
373 let value = value.trim_matches('"').trim_matches('\'');
374 return Some(value.to_string());
375 }
376 }
377 }
378
379 None
380}
381
382#[allow(dead_code)]
384pub struct ResourceWatcher {
385 paths: Vec<PathBuf>,
386 #[allow(clippy::type_complexity)]
387 callbacks: HashMap<PathBuf, Vec<Box<dyn Fn(ResourceChange) + Send + Sync>>>,
388}
389
390impl ResourceWatcher {
391 #[allow(dead_code)]
393 pub fn new() -> Self {
394 Self {
395 paths: Vec::new(),
396 callbacks: HashMap::new(),
397 }
398 }
399
400 #[allow(dead_code)]
402 pub fn add_path(&mut self, path: PathBuf) {
403 self.paths.push(path.clone());
404 self.callbacks.entry(path).or_default();
405 }
406
407 #[allow(dead_code)]
409 pub fn on_change<F>(&mut self, path: &Path, callback: F)
410 where
411 F: Fn(ResourceChange) + Send + Sync + 'static,
412 {
413 let path = path.to_path_buf();
414 self.callbacks
415 .entry(path.clone())
416 .or_default()
417 .push(Box::new(callback));
418 }
419
420 #[allow(dead_code)]
422 pub fn check_changes(&mut self) {
423 for path in &self.paths {
424 if let Ok(metadata) = fs::metadata(path) {
425 if metadata.modified().is_ok() {
426 let change = ResourceChange {
427 path: path.clone(),
428 kind: ChangeKind::Modified,
429 };
430 if let Some(callbacks) = self.callbacks.get(path) {
431 for callback in callbacks {
432 callback(change.clone());
433 }
434 }
435 }
436 }
437 }
438 }
439}
440
441impl Default for ResourceWatcher {
442 fn default() -> Self {
443 Self::new()
444 }
445}
446
447#[derive(Debug, Clone)]
449#[allow(dead_code)]
450pub struct ResourceChange {
451 pub path: PathBuf,
453 pub kind: ChangeKind,
455}
456
457#[derive(Debug, Clone, Copy)]
459#[allow(dead_code)]
460pub enum ChangeKind {
461 Created,
463 Modified,
465 Deleted,
467}
468
469#[allow(dead_code)]
471pub fn load_all_resources_impl(base_dir: &Path) -> LoadAllResourcesResult {
472 let mut errors = Vec::new();
473 let mut diagnostics = Vec::new();
474
475 let skills_base = base_dir.join("skills");
476 let skills_result = load_skills_from_dir_impl(&skills_base);
477 errors.extend(skills_result.errors);
478 diagnostics.extend(skills_result.diagnostics);
479
480 let themes_base = base_dir.join("themes");
481 let themes_result = load_themes_from_dir_impl(&themes_base);
482 errors.extend(themes_result.errors);
483 diagnostics.extend(themes_result.diagnostics);
484
485 let prompts_base = base_dir.join("prompts");
486 let prompts_result = load_prompts_from_dir_impl(&prompts_base);
487 errors.extend(prompts_result.errors);
488 diagnostics.extend(prompts_result.diagnostics);
489
490 LoadAllResourcesResult {
491 skills: skills_result.items,
492 themes: themes_result.items,
493 prompts: prompts_result.items,
494 errors,
495 diagnostics,
496 }
497}
498
499#[allow(dead_code)]
501pub struct LoadAllResourcesResult {
502 pub skills: Vec<Skill>,
504 pub themes: Vec<Theme>,
506 pub prompts: Vec<Prompt>,
508 pub errors: Vec<LoadError>,
510 pub diagnostics: Vec<ResourceDiagnostic>,
512}