Skip to main content

opi_coding_agent/
resource.rs

1//! Extension resource discovery and loading.
2//!
3//! Provides the resource loading strategy for discovering extension manifests
4//! from project, user, and explicit paths with documented precedence.
5//!
6//! # Precedence Model
7//!
8//! Extension resources are discovered from multiple layers, each with a numeric
9//! precedence value. Higher precedence values override lower ones when
10//! extension names collide. The standard precedence order is:
11//!
12//! 1. **User-level** (`~/.config/opi/extensions/` on Unix,
13//!    `%APPDATA%\opi\extensions\` on Windows) — precedence 0
14//! 2. **Project-level** (`.opi/extensions/` in workspace root) — precedence 1
15//! 3. **Explicit** (CLI `--extension` paths or config `extensions.paths`) —
16//!    precedence 2
17//!
18//! When two layers provide an extension with the same name, the higher
19//! precedence layer wins. Within a single layer, duplicate names produce an
20//! error.
21//!
22//! # Manifest Format
23//!
24//! Each extension directory must contain an `extension.toml` manifest:
25//!
26//! ```toml
27//! [extension]
28//! name = "my-extension"    # required, non-empty
29//! version = "1.0.0"        # optional
30//! description = "..."      # optional
31//! ```
32//!
33//! # Path Normalization
34//!
35//! All paths are canonicalized (resolved to absolute form) before comparison.
36//! This prevents duplicate detection bypass via relative paths or symlinks.
37//!
38//! # Unstable
39//!
40//! This module is part of the **unstable 0.x extension API**. Breaking changes
41//! may occur between minor versions without a major version bump.
42
43use std::path::{Path, PathBuf};
44
45use serde::Deserialize;
46
47// ---------------------------------------------------------------------------
48// Error types
49// ---------------------------------------------------------------------------
50
51/// Errors from extension resource discovery.
52#[derive(Debug, thiserror::Error)]
53pub enum ResourceDiscoveryError {
54    /// A manifest file could not be parsed as valid TOML.
55    #[error("invalid extension manifest at {path}: {reason}")]
56    InvalidManifest { path: PathBuf, reason: String },
57    /// A required field is missing or empty in the manifest.
58    #[error("missing required field '{field}' in manifest at {path}")]
59    MissingField { field: String, path: PathBuf },
60    /// Two resources in the same precedence layer use the same name.
61    #[error("duplicate extension name '{name}' in discovery layer at {path}")]
62    DuplicateName { name: String, path: PathBuf },
63    /// An I/O error occurred during discovery.
64    #[error("I/O error discovering extensions: {0}")]
65    Io(#[from] std::io::Error),
66}
67
68// ---------------------------------------------------------------------------
69// Manifest types
70// ---------------------------------------------------------------------------
71
72/// Parsed extension manifest from `extension.toml`.
73#[derive(Debug, Clone, PartialEq)]
74pub struct ExtensionManifest {
75    /// Extension name. Required, non-empty, unique across all layers.
76    pub name: String,
77    /// Semantic version. Optional.
78    pub version: Option<String>,
79    /// Human-readable description. Optional.
80    pub description: Option<String>,
81}
82
83/// Top-level TOML structure wrapping the `[extension]` table.
84#[derive(Debug, Clone, Deserialize)]
85struct TomlExtensionFile {
86    extension: TomlExtensionTable,
87}
88
89/// Fields within the `[extension]` TOML table.
90#[derive(Debug, Clone, Deserialize)]
91struct TomlExtensionTable {
92    name: Option<String>,
93    version: Option<String>,
94    description: Option<String>,
95}
96
97impl ExtensionManifest {
98    /// Parse a manifest from TOML content, validating required fields.
99    pub fn from_toml(content: &str, path: &Path) -> Result<Self, ResourceDiscoveryError> {
100        let file: TomlExtensionFile =
101            toml::from_str(content).map_err(|e| ResourceDiscoveryError::InvalidManifest {
102                path: path.to_path_buf(),
103                reason: e.to_string(),
104            })?;
105
106        let raw = file.extension;
107
108        let name = raw.name.filter(|n| !n.trim().is_empty()).ok_or_else(|| {
109            ResourceDiscoveryError::MissingField {
110                field: "name".into(),
111                path: path.to_path_buf(),
112            }
113        })?;
114
115        Ok(Self {
116            name,
117            version: raw.version,
118            description: raw.description,
119        })
120    }
121}
122
123// ---------------------------------------------------------------------------
124// Discovery types
125// ---------------------------------------------------------------------------
126
127/// A single discovery layer with root path, optional subdirectory, and
128/// precedence value.
129///
130/// Higher precedence values override lower ones for duplicate extension names.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct DiscoveryLayer {
133    /// Root directory for this discovery layer.
134    pub root: PathBuf,
135    /// Optional subdirectory to append to root (e.g. `.opi/extensions`).
136    /// When `None`, the root is used directly.
137    pub subdirectory: Option<String>,
138    /// Numeric precedence. Higher values win on name collision.
139    pub precedence: u32,
140}
141
142impl DiscoveryLayer {
143    /// Resolve the full scan directory for this layer.
144    pub fn scan_dir(&self) -> PathBuf {
145        match &self.subdirectory {
146            Some(sub) => self.root.join(sub),
147            None => self.root.clone(),
148        }
149    }
150}
151
152/// Explicit resource paths from resolved configuration or embedder setup.
153#[derive(Debug, Clone, Default, PartialEq, Eq)]
154pub struct ExplicitResourcePaths {
155    pub extensions: Vec<PathBuf>,
156    pub packages: Vec<PathBuf>,
157    pub skills: Vec<PathBuf>,
158    pub fragments: Vec<PathBuf>,
159    pub themes: Vec<PathBuf>,
160}
161
162/// Discovery layers for every metadata-backed resource kind.
163#[derive(Debug, Clone, Default, PartialEq, Eq)]
164pub struct ResourceDiscoveryLayers {
165    pub extensions: Vec<DiscoveryLayer>,
166    pub packages: Vec<DiscoveryLayer>,
167    pub skills: Vec<DiscoveryLayer>,
168    pub fragments: Vec<DiscoveryLayer>,
169    pub themes: Vec<DiscoveryLayer>,
170}
171
172const USER_LAYER_PRECEDENCE: u32 = 0;
173const PROJECT_LAYER_PRECEDENCE: u32 = 1;
174const EXPLICIT_LAYER_PRECEDENCE: u32 = 2;
175
176/// Build the standard user/project/explicit discovery layers for a workspace.
177///
178/// Missing directories are handled by the per-kind discovery functions. Relative
179/// explicit paths are resolved against `workspace_root`.
180pub fn standard_discovery_layers(
181    workspace_root: &Path,
182    user_config_dir: Option<&Path>,
183    explicit: ExplicitResourcePaths,
184) -> ResourceDiscoveryLayers {
185    ResourceDiscoveryLayers {
186        extensions: standard_layers_for_kind(
187            workspace_root,
188            user_config_dir,
189            "extensions",
190            ".opi/extensions",
191            &explicit.extensions,
192        ),
193        packages: standard_layers_for_kind(
194            workspace_root,
195            user_config_dir,
196            "packages",
197            ".opi/packages",
198            &explicit.packages,
199        ),
200        skills: standard_layers_for_kind(
201            workspace_root,
202            user_config_dir,
203            "skills",
204            ".opi/skills",
205            &explicit.skills,
206        ),
207        fragments: standard_layers_for_kind(
208            workspace_root,
209            user_config_dir,
210            "fragments",
211            ".opi/fragments",
212            &explicit.fragments,
213        ),
214        themes: standard_layers_for_kind(
215            workspace_root,
216            user_config_dir,
217            "themes",
218            ".opi/themes",
219            &explicit.themes,
220        ),
221    }
222}
223
224fn standard_layers_for_kind(
225    workspace_root: &Path,
226    user_config_dir: Option<&Path>,
227    user_subdir: &str,
228    project_subdir: &str,
229    explicit_paths: &[PathBuf],
230) -> Vec<DiscoveryLayer> {
231    let mut layers = Vec::new();
232    if let Some(user_config_dir) = user_config_dir {
233        layers.push(DiscoveryLayer {
234            root: user_config_dir.to_path_buf(),
235            subdirectory: Some(user_subdir.to_owned()),
236            precedence: USER_LAYER_PRECEDENCE,
237        });
238    }
239    layers.push(DiscoveryLayer {
240        root: workspace_root.to_path_buf(),
241        subdirectory: Some(project_subdir.to_owned()),
242        precedence: PROJECT_LAYER_PRECEDENCE,
243    });
244    layers.extend(explicit_paths.iter().map(|path| DiscoveryLayer {
245        root: resolve_explicit_path(workspace_root, path),
246        subdirectory: None,
247        precedence: EXPLICIT_LAYER_PRECEDENCE,
248    }));
249    layers
250}
251
252fn resolve_explicit_path(workspace_root: &Path, path: &Path) -> PathBuf {
253    if path.is_absolute() {
254        path.to_path_buf()
255    } else {
256        workspace_root.join(path)
257    }
258}
259
260/// A discovered extension resource with its manifest, filesystem path, and
261/// layer precedence.
262#[derive(Debug, Clone)]
263pub struct ExtensionResource {
264    /// The parsed extension manifest.
265    pub manifest: ExtensionManifest,
266    /// Absolute path to the extension directory.
267    pub path: PathBuf,
268    /// Precedence value of the discovery layer that produced this resource.
269    pub layer_precedence: u32,
270}
271
272// ---------------------------------------------------------------------------
273// Discovery
274// ---------------------------------------------------------------------------
275
276/// Discover extension resources across multiple layers with precedence-based
277/// deduplication.
278///
279/// Layers are processed in order. For each layer, the scan directory is
280/// enumerated for subdirectories containing `extension.toml` files. When
281/// multiple layers produce extensions with the same name, the one with the
282/// highest `precedence` value is kept. Duplicate names within the same
283/// precedence layer are reported as an error.
284///
285/// Returns the deduplicated list of discovered resources, or the first error
286/// encountered during manifest parsing.
287pub fn discover_extension_resources(
288    layers: &[DiscoveryLayer],
289) -> Result<Vec<ExtensionResource>, ResourceDiscoveryError> {
290    let mut seen: std::collections::HashMap<String, ExtensionResource> =
291        std::collections::HashMap::new();
292
293    for layer in layers {
294        let scan_dir = layer.scan_dir();
295        if !scan_dir.is_dir() {
296            continue;
297        }
298
299        if scan_dir.join("extension.toml").exists() {
300            discover_extension_dir(&scan_dir, layer, &mut seen)?;
301            continue;
302        }
303
304        let entries = match std::fs::read_dir(&scan_dir) {
305            Ok(entries) => entries,
306            Err(e) => return Err(ResourceDiscoveryError::Io(e)),
307        };
308
309        for entry in entries {
310            let entry = entry?;
311            let path = entry.path();
312
313            // Only process directories.
314            if !path.is_dir() {
315                continue;
316            }
317
318            let manifest_path = path.join("extension.toml");
319            if !manifest_path.exists() {
320                continue;
321            }
322
323            discover_extension_dir(&path, layer, &mut seen)?;
324        }
325    }
326
327    // Return resources sorted by name for deterministic ordering.
328    let mut resources: Vec<ExtensionResource> = seen.into_values().collect();
329    resources.sort_by(|a, b| a.manifest.name.cmp(&b.manifest.name));
330    Ok(resources)
331}
332
333fn discover_extension_dir(
334    path: &Path,
335    layer: &DiscoveryLayer,
336    seen: &mut std::collections::HashMap<String, ExtensionResource>,
337) -> Result<(), ResourceDiscoveryError> {
338    let manifest_path = path.join("extension.toml");
339    let content = std::fs::read_to_string(&manifest_path)?;
340    let manifest = ExtensionManifest::from_toml(&content, &manifest_path)?;
341
342    let canonical = path.canonicalize()?;
343
344    match seen.get(&manifest.name) {
345        Some(existing) if layer.precedence == existing.layer_precedence => {
346            return Err(ResourceDiscoveryError::DuplicateName {
347                name: manifest.name,
348                path: canonical,
349            });
350        }
351        Some(existing) if layer.precedence < existing.layer_precedence => return Ok(()),
352        Some(_) | None => {
353            seen.insert(
354                manifest.name.clone(),
355                ExtensionResource {
356                    manifest,
357                    path: canonical,
358                    layer_precedence: layer.precedence,
359                },
360            );
361        }
362    }
363
364    Ok(())
365}