1use std::path::{Path, PathBuf};
44
45use serde::Deserialize;
46
47#[derive(Debug, thiserror::Error)]
53pub enum ResourceDiscoveryError {
54 #[error("invalid extension manifest at {path}: {reason}")]
56 InvalidManifest { path: PathBuf, reason: String },
57 #[error("missing required field '{field}' in manifest at {path}")]
59 MissingField { field: String, path: PathBuf },
60 #[error("duplicate extension name '{name}' in discovery layer at {path}")]
62 DuplicateName { name: String, path: PathBuf },
63 #[error("I/O error discovering extensions: {0}")]
65 Io(#[from] std::io::Error),
66}
67
68#[derive(Debug, Clone, PartialEq)]
74pub struct ExtensionManifest {
75 pub name: String,
77 pub version: Option<String>,
79 pub description: Option<String>,
81}
82
83#[derive(Debug, Clone, Deserialize)]
85struct TomlExtensionFile {
86 extension: TomlExtensionTable,
87}
88
89#[derive(Debug, Clone, Deserialize)]
91struct TomlExtensionTable {
92 name: Option<String>,
93 version: Option<String>,
94 description: Option<String>,
95}
96
97impl ExtensionManifest {
98 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#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct DiscoveryLayer {
133 pub root: PathBuf,
135 pub subdirectory: Option<String>,
138 pub precedence: u32,
140}
141
142impl DiscoveryLayer {
143 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#[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#[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
176pub 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#[derive(Debug, Clone)]
263pub struct ExtensionResource {
264 pub manifest: ExtensionManifest,
266 pub path: PathBuf,
268 pub layer_precedence: u32,
270}
271
272pub 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 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 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}