use std::path::{Path, PathBuf};
use serde::Deserialize;
#[derive(Debug, thiserror::Error)]
pub enum ResourceDiscoveryError {
#[error("invalid extension manifest at {path}: {reason}")]
InvalidManifest { path: PathBuf, reason: String },
#[error("missing required field '{field}' in manifest at {path}")]
MissingField { field: String, path: PathBuf },
#[error("duplicate extension name '{name}' in discovery layer at {path}")]
DuplicateName { name: String, path: PathBuf },
#[error("I/O error discovering extensions: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone, PartialEq)]
pub struct ExtensionManifest {
pub name: String,
pub version: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct TomlExtensionFile {
extension: TomlExtensionTable,
}
#[derive(Debug, Clone, Deserialize)]
struct TomlExtensionTable {
name: Option<String>,
version: Option<String>,
description: Option<String>,
}
impl ExtensionManifest {
pub fn from_toml(content: &str, path: &Path) -> Result<Self, ResourceDiscoveryError> {
let file: TomlExtensionFile =
toml::from_str(content).map_err(|e| ResourceDiscoveryError::InvalidManifest {
path: path.to_path_buf(),
reason: e.to_string(),
})?;
let raw = file.extension;
let name = raw.name.filter(|n| !n.trim().is_empty()).ok_or_else(|| {
ResourceDiscoveryError::MissingField {
field: "name".into(),
path: path.to_path_buf(),
}
})?;
Ok(Self {
name,
version: raw.version,
description: raw.description,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiscoveryLayer {
pub root: PathBuf,
pub subdirectory: Option<String>,
pub precedence: u32,
}
impl DiscoveryLayer {
pub fn scan_dir(&self) -> PathBuf {
match &self.subdirectory {
Some(sub) => self.root.join(sub),
None => self.root.clone(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ExplicitResourcePaths {
pub extensions: Vec<PathBuf>,
pub packages: Vec<PathBuf>,
pub skills: Vec<PathBuf>,
pub fragments: Vec<PathBuf>,
pub themes: Vec<PathBuf>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResourceDiscoveryLayers {
pub extensions: Vec<DiscoveryLayer>,
pub packages: Vec<DiscoveryLayer>,
pub skills: Vec<DiscoveryLayer>,
pub fragments: Vec<DiscoveryLayer>,
pub themes: Vec<DiscoveryLayer>,
}
const USER_LAYER_PRECEDENCE: u32 = 0;
const PROJECT_LAYER_PRECEDENCE: u32 = 1;
const EXPLICIT_LAYER_PRECEDENCE: u32 = 2;
pub fn standard_discovery_layers(
workspace_root: &Path,
user_config_dir: Option<&Path>,
explicit: ExplicitResourcePaths,
) -> ResourceDiscoveryLayers {
ResourceDiscoveryLayers {
extensions: standard_layers_for_kind(
workspace_root,
user_config_dir,
"extensions",
".opi/extensions",
&explicit.extensions,
),
packages: standard_layers_for_kind(
workspace_root,
user_config_dir,
"packages",
".opi/packages",
&explicit.packages,
),
skills: standard_layers_for_kind(
workspace_root,
user_config_dir,
"skills",
".opi/skills",
&explicit.skills,
),
fragments: standard_layers_for_kind(
workspace_root,
user_config_dir,
"fragments",
".opi/fragments",
&explicit.fragments,
),
themes: standard_layers_for_kind(
workspace_root,
user_config_dir,
"themes",
".opi/themes",
&explicit.themes,
),
}
}
fn standard_layers_for_kind(
workspace_root: &Path,
user_config_dir: Option<&Path>,
user_subdir: &str,
project_subdir: &str,
explicit_paths: &[PathBuf],
) -> Vec<DiscoveryLayer> {
let mut layers = Vec::new();
if let Some(user_config_dir) = user_config_dir {
layers.push(DiscoveryLayer {
root: user_config_dir.to_path_buf(),
subdirectory: Some(user_subdir.to_owned()),
precedence: USER_LAYER_PRECEDENCE,
});
}
layers.push(DiscoveryLayer {
root: workspace_root.to_path_buf(),
subdirectory: Some(project_subdir.to_owned()),
precedence: PROJECT_LAYER_PRECEDENCE,
});
layers.extend(explicit_paths.iter().map(|path| DiscoveryLayer {
root: resolve_explicit_path(workspace_root, path),
subdirectory: None,
precedence: EXPLICIT_LAYER_PRECEDENCE,
}));
layers
}
fn resolve_explicit_path(workspace_root: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
workspace_root.join(path)
}
}
#[derive(Debug, Clone)]
pub struct ExtensionResource {
pub manifest: ExtensionManifest,
pub path: PathBuf,
pub layer_precedence: u32,
}
pub fn discover_extension_resources(
layers: &[DiscoveryLayer],
) -> Result<Vec<ExtensionResource>, ResourceDiscoveryError> {
let mut seen: std::collections::HashMap<String, ExtensionResource> =
std::collections::HashMap::new();
for layer in layers {
let scan_dir = layer.scan_dir();
if !scan_dir.is_dir() {
continue;
}
if scan_dir.join("extension.toml").exists() {
discover_extension_dir(&scan_dir, layer, &mut seen)?;
continue;
}
let entries = match std::fs::read_dir(&scan_dir) {
Ok(entries) => entries,
Err(e) => return Err(ResourceDiscoveryError::Io(e)),
};
for entry in entries {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let manifest_path = path.join("extension.toml");
if !manifest_path.exists() {
continue;
}
discover_extension_dir(&path, layer, &mut seen)?;
}
}
let mut resources: Vec<ExtensionResource> = seen.into_values().collect();
resources.sort_by(|a, b| a.manifest.name.cmp(&b.manifest.name));
Ok(resources)
}
fn discover_extension_dir(
path: &Path,
layer: &DiscoveryLayer,
seen: &mut std::collections::HashMap<String, ExtensionResource>,
) -> Result<(), ResourceDiscoveryError> {
let manifest_path = path.join("extension.toml");
let content = std::fs::read_to_string(&manifest_path)?;
let manifest = ExtensionManifest::from_toml(&content, &manifest_path)?;
let canonical = path.canonicalize()?;
match seen.get(&manifest.name) {
Some(existing) if layer.precedence == existing.layer_precedence => {
return Err(ResourceDiscoveryError::DuplicateName {
name: manifest.name,
path: canonical,
});
}
Some(existing) if layer.precedence < existing.layer_precedence => return Ok(()),
Some(_) | None => {
seen.insert(
manifest.name.clone(),
ExtensionResource {
manifest,
path: canonical,
layer_precedence: layer.precedence,
},
);
}
}
Ok(())
}