use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub use crate::resource_loader_compat::{Skill, Theme, Prompt};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ContextFile {
pub path: PathBuf,
pub name: String,
pub priority: u8,
pub content: String,
}
impl ContextFile {
pub fn new(path: PathBuf, name: impl Into<String>, priority: u8, content: String) -> Self {
Self {
path,
name: name.into(),
priority,
content,
}
}
pub fn extension(&self) -> Option<String> {
self.path
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_lowercase())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContextFileType {
Agents,
Claude,
}
impl ContextFileType {
pub fn filename(&self) -> &'static str {
match self {
ContextFileType::Agents => "AGENTS.md",
ContextFileType::Claude => "CLAUDE.md",
}
}
pub fn priority(&self) -> u8 {
match self {
ContextFileType::Agents => 100,
ContextFileType::Claude => 90,
}
}
pub fn variants(&self) -> Vec<&'static str> {
match self {
ContextFileType::Agents => vec!["AGENTS.md", "AGENTS.MD"],
ContextFileType::Claude => vec!["CLAUDE.md", "CLAUDE.MD"],
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SourceType {
Default,
Project,
Cli,
Inline,
Package,
Git,
}
#[derive(Debug, Clone)]
pub struct Source {
pub path: PathBuf,
pub source_type: SourceType,
pub enabled: bool,
}
impl Source {
pub fn new(path: PathBuf, source_type: SourceType) -> Self {
Self {
path,
source_type,
enabled: true,
}
}
pub fn exists(&self) -> bool {
self.path.exists()
}
pub fn is_dir(&self) -> bool {
self.path.is_dir()
}
}
#[derive(Debug, Clone)]
pub struct ExtensionSource {
pub path: PathBuf,
pub metadata: PathMetadata,
}
#[derive(Debug, Clone)]
pub struct PathMetadata {
pub source: String,
pub scope: String,
pub origin: String,
}
impl Default for PathMetadata {
fn default() -> Self {
Self {
source: "local".to_string(),
scope: "user".to_string(),
origin: "top-level".to_string(),
}
}
}
#[derive(Debug, Clone)]
pub struct SkillSource {
pub path: PathBuf,
pub metadata: PathMetadata,
}
#[derive(Debug, Clone)]
pub struct ThemeSource {
pub path: PathBuf,
pub metadata: PathMetadata,
}
#[derive(Debug, Clone)]
pub struct PromptSource {
pub path: PathBuf,
pub metadata: PathMetadata,
}
pub struct ResourceLoader {
base_dir: PathBuf,
cwd: PathBuf,
extensions: Vec<ExtensionSource>,
skills: Vec<SkillSource>,
themes: Vec<ThemeSource>,
prompts: Vec<PromptSource>,
cache: RwLock<Option<LoadedResources>>,
}
#[derive(Debug, Clone)]
pub struct LoadedResources {
pub skills: Vec<Skill>,
pub themes: Vec<Theme>,
pub prompts: Vec<Prompt>,
pub context_files: Vec<ContextFile>,
pub errors: Vec<LoadError>,
pub diagnostics: Vec<ResourceDiagnostic>,
}
impl Default for ResourceLoader {
fn default() -> Self {
Self::new()
}
}
impl ResourceLoader {
pub fn new() -> Self {
let base_dir = default_resource_dir();
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
Self {
base_dir,
cwd,
extensions: Vec::new(),
skills: Vec::new(),
themes: Vec::new(),
prompts: Vec::new(),
cache: RwLock::new(None),
}
}
pub fn with_paths(base_dir: PathBuf, cwd: PathBuf) -> Self {
Self {
base_dir,
cwd,
extensions: Vec::new(),
skills: Vec::new(),
themes: Vec::new(),
prompts: Vec::new(),
cache: RwLock::new(None),
}
}
pub fn with_base_dir(&mut self, base_dir: PathBuf) -> &mut Self {
self.base_dir = base_dir;
self
}
pub fn with_cwd(&mut self, cwd: PathBuf) -> &mut Self {
self.cwd = cwd;
self
}
pub fn add_extension(&mut self, path: PathBuf) -> &mut Self {
self.extensions.push(ExtensionSource {
path,
metadata: PathMetadata::default(),
});
self
}
pub fn add_skill(&mut self, path: PathBuf) -> &mut Self {
self.skills.push(SkillSource {
path,
metadata: PathMetadata::default(),
});
self
}
pub fn add_theme(&mut self, path: PathBuf) -> &mut Self {
self.themes.push(ThemeSource {
path,
metadata: PathMetadata::default(),
});
self
}
pub fn add_prompt(&mut self, path: PathBuf) -> &mut Self {
self.prompts.push(PromptSource {
path,
metadata: PathMetadata::default(),
});
self
}
pub fn load_all(&self) -> Result<LoadedResources, anyhow::Error> {
let mut errors = Vec::new();
let mut diagnostics = Vec::new();
let skills = self.load_skills();
for err in &skills.errors {
errors.push(err.clone());
}
diagnostics.extend(skills.diagnostics);
let themes = self.load_themes();
for err in &themes.errors {
errors.push(err.clone());
}
diagnostics.extend(themes.diagnostics);
let prompts = self.load_prompts();
for err in &prompts.errors {
errors.push(err.clone());
}
diagnostics.extend(prompts.diagnostics);
let context_files = self.load_project_context_files(&self.cwd)?;
let result = LoadedResources {
skills: skills.items,
themes: themes.items,
prompts: prompts.items,
context_files,
errors,
diagnostics,
};
*self.cache.write() = Some(result.clone());
Ok(result)
}
pub fn try_load_all(&self) -> LoadedResources {
self.load_all().unwrap_or_else(|e| {
LoadedResources {
skills: Vec::new(),
themes: Vec::new(),
prompts: Vec::new(),
context_files: Vec::new(),
errors: vec![LoadError {
path: PathBuf::from("."),
error: e.to_string(),
}],
diagnostics: Vec::new(),
}
})
}
pub fn load_project_context_files(&self, cwd: &Path) -> Result<Vec<ContextFile>, anyhow::Error> {
let mut context_files = Vec::new();
let seen_paths = &mut HashMap::new();
let system_prompt = self.load_system_prompt_file("default.md")?;
if let Some(content) = system_prompt {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"));
let default_path = home.join(".oxi").join("system-prompts").join("default.md");
context_files.push(ContextFile::new(
default_path,
"default.md",
95, content,
));
}
let discovered = self.discover_context_files(cwd);
for (path, file_type) in discovered {
if let Some(content) = self.read_context_file(&path)? {
let name = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let path_str = path.to_string_lossy().to_string();
if !seen_paths.contains_key(&path_str) {
seen_paths.insert(path_str.clone(), true);
context_files.push(ContextFile::new(
path,
name,
file_type.priority(),
content,
));
}
}
}
if let Some(home) = dirs::home_dir() {
for file_type in &[ContextFileType::Agents, ContextFileType::Claude] {
for variant in file_type.variants() {
let global_path = home.join(".oxi").join(variant);
if global_path.exists() {
let path_str = global_path.to_string_lossy().to_string();
if !seen_paths.contains_key(&path_str) {
if let Some(content) = self.read_context_file(&global_path)? {
seen_paths.insert(path_str, true);
context_files.push(ContextFile::new(
global_path,
variant.to_string(),
80, content,
));
}
}
}
}
}
}
context_files.sort_by(|a, b| b.priority.cmp(&a.priority));
Ok(context_files)
}
pub fn discover_context_files(&self, dir: &Path) -> Vec<(PathBuf, ContextFileType)> {
let mut discovered = Vec::new();
let file_types = [ContextFileType::Agents, ContextFileType::Claude];
let git_root = self.find_git_root(dir);
let mut current = dir.to_path_buf();
let root = PathBuf::from("/");
let max_iterations = 50;
let mut iterations = 0;
while current != root && iterations < max_iterations {
if let Some(ref git_r) = git_root {
if current == *git_r || !current.starts_with(git_r) {
break;
}
}
for file_type in &file_types {
for variant in file_type.variants() {
let candidate = current.join(variant);
if candidate.exists() && candidate.is_file() {
discovered.push((candidate, *file_type));
}
}
}
if let Some(parent) = current.parent() {
current = parent.to_path_buf();
} else {
break;
}
iterations += 1;
}
let mut seen = HashSet::new();
discovered.retain(|(path, _)| {
let path_str = path.to_string_lossy().to_string();
if seen.contains(&path_str) {
false
} else {
seen.insert(path_str);
true
}
});
discovered
}
fn find_git_root(&self, dir: &Path) -> Option<PathBuf> {
let mut current = dir.to_path_buf();
let root = PathBuf::from("/");
let max_iterations = 20;
let mut iterations = 0;
while current != root && iterations < max_iterations {
if current.join(".git").exists() {
return Some(current);
}
if let Some(parent) = current.parent() {
current = parent.to_path_buf();
} else {
break;
}
iterations += 1;
}
None
}
pub fn load_system_prompt_file(&self, name: &str) -> Result<Option<String>, anyhow::Error> {
let mut paths_to_try: Vec<PathBuf> = vec![
self.cwd.join(".oxi").join("system-prompts").join(name),
self.base_dir.join("system-prompts").join(name),
];
if let Some(home) = dirs::home_dir() {
paths_to_try.push(home.join(".oxi").join("system-prompts").join(name));
}
for path in paths_to_try {
if path.exists() && path.is_file() {
let content = fs::read_to_string(&path)
.map_err(|e| anyhow::anyhow!("Failed to read {}: {}", path.display(), e))?;
return Ok(Some(content));
}
}
Ok(None)
}
fn read_context_file(&self, path: &Path) -> Result<Option<String>, anyhow::Error> {
match fs::read_to_string(path) {
Ok(content) => Ok(Some(content)),
Err(e) => {
tracing::warn!("Failed to read context file {}: {}", path.display(), e);
Ok(None)
}
}
}
fn load_skills(&self) -> LoadResult<Skill> {
let mut items = Vec::new();
let mut errors = Vec::new();
let mut diagnostics = Vec::new();
let skills_base = skills_dir(&self.base_dir);
let project_skills = self.cwd.join(".oxi").join("skills");
for dir in &[skills_base, project_skills] {
if dir.exists() {
let result = load_skills_from_dir(dir);
items.extend(result.items);
errors.extend(result.errors);
diagnostics.extend(result.diagnostics);
}
}
for source in &self.skills {
if source.path.exists() {
match load_skill(&source.path) {
Ok(skill) => items.push(skill),
Err(e) => {
errors.push(LoadError {
path: source.path.clone(),
error: e,
});
}
}
}
}
LoadResult { items, errors, diagnostics }
}
fn load_themes(&self) -> LoadResult<Theme> {
let mut items = Vec::new();
let mut errors = Vec::new();
let mut diagnostics = Vec::new();
let themes_base = themes_dir(&self.base_dir);
let project_themes = self.cwd.join(".oxi").join("themes");
for dir in &[themes_base, project_themes] {
if dir.exists() {
let result = load_themes_from_dir(dir);
items.extend(result.items);
errors.extend(result.errors);
diagnostics.extend(result.diagnostics);
}
}
for source in &self.themes {
if source.path.exists() {
match load_theme(&source.path) {
Ok(theme) => items.push(theme),
Err(e) => {
errors.push(LoadError {
path: source.path.clone(),
error: e,
});
}
}
}
}
LoadResult { items, errors, diagnostics }
}
fn load_prompts(&self) -> LoadResult<Prompt> {
let mut items = Vec::new();
let mut errors = Vec::new();
let mut diagnostics = Vec::new();
let prompts_base = prompts_dir(&self.base_dir);
let project_prompts = self.cwd.join(".oxi").join("prompts");
for dir in &[prompts_base, project_prompts] {
if dir.exists() {
let result = load_prompts_from_dir(dir);
items.extend(result.items);
errors.extend(result.errors);
diagnostics.extend(result.diagnostics);
}
}
for source in &self.prompts {
if source.path.exists() {
match load_prompt(&source.path) {
Ok(prompt) => items.push(prompt),
Err(e) => {
errors.push(LoadError {
path: source.path.clone(),
error: e,
});
}
}
}
}
LoadResult { items, errors, diagnostics }
}
pub fn cached(&self) -> Option<LoadedResources> {
self.cache.read().clone()
}
pub fn clear_cache(&self) {
*self.cache.write() = None;
}
pub fn reload(&self) -> Result<LoadedResources, anyhow::Error> {
self.clear_cache();
self.load_all()
}
}
pub use crate::resource_loader_compat::{
ResourceType, Resource, LoadResult, LoadError,
ResourceDiagnostic, DiagnosticSeverity, ResourcePaths, ResourceWatcher,
ResourceChange, ChangeKind, LoadAllResourcesResult,
};
pub fn default_resource_dir() -> std::path::PathBuf {
dirs::config_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("oxi")
}
pub fn skills_dir(base: &std::path::Path) -> std::path::PathBuf {
base.join("skills")
}
pub fn extensions_dir(base: &std::path::Path) -> std::path::PathBuf {
base.join("extensions")
}
pub fn themes_dir(base: &std::path::Path) -> std::path::PathBuf {
base.join("themes")
}
pub fn prompts_dir(base: &std::path::Path) -> std::path::PathBuf {
base.join("prompts")
}
pub fn load_skills_from_dir(dir: &std::path::Path) -> LoadResult<Skill> {
crate::resource_loader_compat::load_skills_from_dir_impl(dir)
}
pub fn load_skill(path: &std::path::Path) -> Result<Skill, String> {
crate::resource_loader_compat::load_skill_impl(path)
}
pub fn load_themes_from_dir(dir: &std::path::Path) -> LoadResult<Theme> {
crate::resource_loader_compat::load_themes_from_dir_impl(dir)
}
pub fn load_theme(path: &std::path::Path) -> Result<Theme, String> {
crate::resource_loader_compat::load_theme_impl(path)
}
pub fn load_prompts_from_dir(dir: &std::path::Path) -> LoadResult<Prompt> {
crate::resource_loader_compat::load_prompts_from_dir_impl(dir)
}
pub fn load_prompt(path: &std::path::Path) -> Result<Prompt, String> {
crate::resource_loader_compat::load_prompt_impl(path)
}
pub fn load_all_resources(base_dir: &std::path::Path) -> LoadAllResourcesResult {
crate::resource_loader_compat::load_all_resources_impl(base_dir)
}
pub fn resolve_path(path: &std::path::Path) -> std::path::PathBuf {
let path_str = path.to_string_lossy();
if path_str.starts_with("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(path_str.strip_prefix("~/").unwrap());
}
}
path.to_path_buf()
}
pub fn is_valid_resource_path(path: &std::path::Path, resource_type: ResourceType) -> bool {
if !path.exists() {
return false;
}
match resource_type {
ResourceType::Skill => path.is_dir() || path.extension().map(|e| e == "md").unwrap_or(false),
ResourceType::Theme => path.extension().map(|e| e == "json").unwrap_or(false),
ResourceType::Prompt => path.extension().map(|e| e == "md").unwrap_or(false),
ResourceType::Extension => path.extension().map(|e| e == "js" || e == "ts").unwrap_or(false),
}
}
use parking_lot::RwLock;
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use tempfile::tempdir;
#[test]
fn test_context_file_creation() {
let cf = ContextFile::new(
PathBuf::from("/project/AGENTS.md"),
"AGENTS.md",
100,
"# Agent Instructions\n".to_string(),
);
assert_eq!(cf.name, "AGENTS.md");
assert_eq!(cf.priority, 100);
assert_eq!(cf.extension(), Some("md".to_string()));
}
#[test]
fn test_context_file_type_priority() {
assert!(ContextFileType::Agents.priority() > ContextFileType::Claude.priority());
}
#[test]
fn test_context_file_type_variants() {
let agents_variants = ContextFileType::Agents.variants();
assert!(agents_variants.contains(&"AGENTS.md"));
assert!(agents_variants.contains(&"AGENTS.MD"));
}
#[test]
fn test_resource_loader_default() {
let loader = ResourceLoader::new();
assert!(loader.cached().is_none());
}
#[test]
fn test_resource_loader_with_paths() {
let temp = tempdir().unwrap();
let loader = ResourceLoader::with_paths(
temp.path().join("oxi"),
temp.path().to_path_buf(),
);
assert_eq!(loader.cwd, temp.path());
}
#[test]
fn test_add_sources() {
let mut loader = ResourceLoader::new();
loader.add_extension(PathBuf::from("/extensions/my-ext"));
loader.add_skill(PathBuf::from("/skills/my-skill"));
loader.add_theme(PathBuf::from("/themes/my-theme"));
loader.add_prompt(PathBuf::from("/prompts/my-prompt"));
assert_eq!(loader.extensions.len(), 1);
assert_eq!(loader.skills.len(), 1);
assert_eq!(loader.themes.len(), 1);
assert_eq!(loader.prompts.len(), 1);
}
#[test]
fn test_load_all_empty() {
let temp = tempdir().unwrap();
let loader = ResourceLoader::with_paths(
temp.path().join("oxi"),
temp.path().to_path_buf(),
);
let result = loader.try_load_all();
assert!(result.errors.is_empty() || !result.errors.is_empty()); }
#[test]
fn test_discover_context_files_empty_dir() {
let temp = tempdir().unwrap();
let loader = ResourceLoader::new();
let discovered = loader.discover_context_files(temp.path());
assert!(discovered.is_empty());
}
#[ignore] #[test]
fn test_discover_context_files_with_files() {
let temp = tempdir().unwrap();
fs::write(temp.path().join("AGENTS.md"), "# Agent instructions").unwrap();
let loader = ResourceLoader::new();
let discovered = loader.discover_context_files(temp.path());
assert_eq!(discovered.len(), 1);
assert!(discovered[0].0.to_string_lossy().ends_with("AGENTS.md"));
}
#[test]
fn test_discover_context_files_ancestor() {
let temp = tempdir().unwrap();
let subdir = temp.path().join("sub").join("project");
fs::create_dir_all(&subdir).unwrap();
fs::write(temp.path().join("AGENTS.md"), "# Parent agents").unwrap();
let loader = ResourceLoader::new();
let discovered = loader.discover_context_files(&subdir);
assert!(!discovered.is_empty());
}
#[test]
fn test_load_system_prompt_file_not_found() {
let temp = tempdir().unwrap();
let loader = ResourceLoader::with_paths(
temp.path().join("oxi"),
temp.path().to_path_buf(),
);
let result = loader.load_system_prompt_file("nonexistent.md").unwrap();
assert!(result.is_none());
}
#[test]
fn test_load_system_prompt_file_exists() {
let temp = tempdir().unwrap();
let system_prompts = temp.path().join("oxi").join("system-prompts");
fs::create_dir_all(&system_prompts).unwrap();
fs::write(system_prompts.join("custom.md"), "Custom system prompt").unwrap();
let loader = ResourceLoader::with_paths(
temp.path().join("oxi"),
temp.path().to_path_buf(),
);
let result = loader.load_system_prompt_file("custom.md").unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap(), "Custom system prompt");
}
#[test]
fn test_cache_round_trip() {
let temp = tempdir().unwrap();
let loader = ResourceLoader::with_paths(
temp.path().join("oxi"),
temp.path().to_path_buf(),
);
assert!(loader.cached().is_none());
let _ = loader.try_load_all();
assert!(loader.cached().is_some());
loader.clear_cache();
assert!(loader.cached().is_none());
}
#[test]
fn test_load_all_creates_cache() {
let temp = tempdir().unwrap();
let loader = ResourceLoader::with_paths(
temp.path().join("oxi"),
temp.path().to_path_buf(),
);
let result = loader.load_all().unwrap();
let cached = loader.cached();
assert!(cached.is_some());
let cached = cached.unwrap();
assert_eq!(cached.skills.len(), result.skills.len());
}
#[test]
fn test_path_metadata_default() {
let meta = PathMetadata::default();
assert_eq!(meta.source, "local");
assert_eq!(meta.scope, "user");
assert_eq!(meta.origin, "top-level");
}
#[test]
fn test_source_helper_methods() {
let temp = tempdir().unwrap();
let source = Source::new(temp.path().to_path_buf(), SourceType::Default);
assert!(source.exists());
assert!(source.is_dir());
assert_eq!(source.source_type, SourceType::Default);
}
#[test]
fn test_loader_builder_pattern() {
let mut loader = ResourceLoader::new();
loader.with_base_dir(PathBuf::from("/base"));
loader.with_cwd(PathBuf::from("/cwd"));
loader.add_extension(PathBuf::from("/ext"));
loader.add_skill(PathBuf::from("/skill"));
assert_eq!(loader.extensions.len(), 1);
assert_eq!(loader.skills.len(), 1);
}
#[ignore] #[test]
fn test_load_project_context_files_order() {
let temp = tempdir().unwrap();
fs::write(temp.path().join("CLAUDE.md"), "# Claude").unwrap();
fs::write(temp.path().join("AGENTS.md"), "# Agents").unwrap();
let loader = ResourceLoader::with_paths(
temp.path().join("oxi"),
temp.path().to_path_buf(),
);
let files = loader.load_project_context_files(temp.path()).unwrap();
if files.len() >= 2 {
assert_eq!(files[0].name, "AGENTS.md");
assert!(files[0].priority > files[1].priority);
}
}
#[test]
fn test_find_git_root_no_git() {
let temp = tempdir().unwrap();
let loader = ResourceLoader::new();
let git_root = loader.find_git_root(temp.path());
assert!(git_root.is_none());
}
#[test]
fn test_deduplication_in_discover() {
let temp = tempdir().unwrap();
fs::write(temp.path().join("AGENTS.md"), "# Agents").unwrap();
let loader = ResourceLoader::new();
let discovered = loader.discover_context_files(temp.path());
let paths: Vec<_> = discovered.iter().map(|(p, _)| p.clone()).collect();
let unique: HashSet<_> = paths.iter().map(|p| p.to_string_lossy().to_string()).collect();
assert_eq!(paths.len(), unique.len());
}
}