1use std::borrow::Cow;
2use std::ffi::OsStr;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use directories::BaseDirs;
7use thiserror::Error;
8
9use crate::manifest::{ComponentManifest, parse_manifest};
10use crate::signing::{SigningError, verify_manifest_hash};
11
12const MANIFEST_NAME: &str = "component.manifest.json";
13
14#[derive(Debug, Clone)]
15pub struct ComponentHandle {
16 pub manifest: ComponentManifest,
17 pub wasm_path: PathBuf,
18 pub root: PathBuf,
19 pub manifest_path: PathBuf,
20}
21
22#[derive(Debug, Error)]
23pub enum LoadError {
24 #[error(
25 "component not found for `{0}`; if pointing at a wasm file, pass --manifest <path/to/component.manifest.json>"
26 )]
27 NotFound(String),
28 #[error("failed to read {path}: {source}")]
29 Io {
30 path: PathBuf,
31 #[source]
32 source: std::io::Error,
33 },
34 #[error("manifest parse failed at {path}: {source}")]
35 Manifest {
36 path: PathBuf,
37 #[source]
38 source: crate::manifest::ManifestError,
39 },
40 #[error("missing artifact `{path}` declared in manifest")]
41 MissingArtifact { path: PathBuf },
42 #[error("hash verification failed: {0}")]
43 Signing(#[from] SigningError),
44}
45
46pub fn discover(path_or_id: &str) -> Result<ComponentHandle, LoadError> {
47 discover_with_manifest(path_or_id, None)
48}
49
50pub fn discover_with_manifest(
51 path_or_id: &str,
52 manifest_override: Option<&Path>,
53) -> Result<ComponentHandle, LoadError> {
54 if let Some(manifest_path) = manifest_override {
55 return load_from_manifest(manifest_path);
56 }
57 let normalized = normalize_path_or_id(path_or_id);
58 let normalized_str = normalized.as_ref();
59 if let Some(handle) = try_explicit(normalized_str)? {
60 return Ok(handle);
61 }
62 if let Some(handle) = try_workspace(normalized_str)? {
63 return Ok(handle);
64 }
65 if let Some(handle) = try_registry(path_or_id)? {
66 return Ok(handle);
67 }
68 Err(LoadError::NotFound(path_or_id.to_string()))
69}
70
71fn try_explicit(arg: &str) -> Result<Option<ComponentHandle>, LoadError> {
72 let path = Path::new(arg);
73 if !path.exists() {
74 return Ok(None);
75 }
76
77 let target = if path.is_dir() {
78 path.join(MANIFEST_NAME)
79 } else if path.extension().and_then(OsStr::to_str) == Some("json") {
80 path.to_path_buf()
81 } else if path.extension().and_then(OsStr::to_str) == Some("wasm") {
82 path.parent()
83 .map(|dir| dir.join(MANIFEST_NAME))
84 .unwrap_or_else(|| path.to_path_buf())
85 } else {
86 path.join(MANIFEST_NAME)
87 };
88
89 if target.exists() {
90 return load_from_manifest(&target).map(Some);
91 }
92
93 Ok(None)
94}
95
96fn try_workspace(id: &str) -> Result<Option<ComponentHandle>, LoadError> {
97 let cwd = std::env::current_dir().map_err(|e| LoadError::Io {
98 path: PathBuf::from("."),
99 source: e,
100 })?;
101 let target = cwd.join("target").join("wasm32-wasip2");
102 let file_name = format!("{id}.wasm");
103
104 for profile in ["release", "debug"] {
105 let candidate = target.join(profile).join(&file_name);
106 if candidate.exists() {
107 let manifest_path = candidate
108 .parent()
109 .map(|dir| dir.join(MANIFEST_NAME))
110 .unwrap_or_else(|| candidate.with_extension("manifest.json"));
111 if manifest_path.exists() {
112 return load_from_manifest(&manifest_path).map(Some);
113 }
114 }
115 }
116
117 Ok(None)
118}
119
120fn try_registry(id: &str) -> Result<Option<ComponentHandle>, LoadError> {
121 let Some(base) = BaseDirs::new() else {
122 return Ok(None);
123 };
124 let registry_root = base.home_dir().join(".greentic").join("components");
125 if !registry_root.exists() {
126 return Ok(None);
127 }
128
129 let mut candidates = Vec::new();
130 for entry in fs::read_dir(®istry_root).map_err(|err| LoadError::Io {
131 path: registry_root.clone(),
132 source: err,
133 })? {
134 let entry = entry.map_err(|err| LoadError::Io {
135 path: registry_root.clone(),
136 source: err,
137 })?;
138 let name = entry.file_name();
139 let name = name.to_string_lossy();
140 if name == id || (!id.contains('@') && name.starts_with(id)) {
141 candidates.push(entry.path());
142 }
143 }
144
145 candidates.sort();
146 candidates.reverse();
147
148 for dir in candidates {
149 let manifest_path = dir.join(MANIFEST_NAME);
150 if manifest_path.exists() {
151 return load_from_manifest(&manifest_path).map(Some);
152 }
153 }
154
155 Ok(None)
156}
157
158fn load_from_manifest(path: &Path) -> Result<ComponentHandle, LoadError> {
159 let contents = fs::read_to_string(path).map_err(|source| LoadError::Io {
160 path: path.to_path_buf(),
161 source,
162 })?;
163 let manifest = parse_manifest(&contents).map_err(|source| LoadError::Manifest {
164 path: path.to_path_buf(),
165 source,
166 })?;
167 let root = path
168 .parent()
169 .map(|p| p.to_path_buf())
170 .unwrap_or_else(|| PathBuf::from("."));
171 let wasm_path = root.join(manifest.artifacts.component_wasm());
172 if !wasm_path.exists() {
173 return Err(LoadError::MissingArtifact { path: wasm_path });
174 }
175 verify_manifest_hash(&manifest, &root)?;
176 Ok(ComponentHandle {
177 manifest,
178 wasm_path,
179 root,
180 manifest_path: path.to_path_buf(),
181 })
182}
183
184fn normalize_path_or_id(input: &str) -> Cow<'_, str> {
185 if let Some(rest) = input.strip_prefix("file://") {
186 Cow::Owned(rest.to_string())
187 } else {
188 Cow::Borrowed(input)
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use std::fs;
196 use std::sync::{Mutex, OnceLock};
197
198 fn cwd_lock() -> &'static Mutex<()> {
199 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
200 LOCK.get_or_init(|| Mutex::new(()))
201 }
202
203 fn manifest_json(artifact: &str, hash: &str) -> String {
204 format!(
205 r#"{{
206 "id": "com.greentic.test.component",
207 "name": "Test Component",
208 "version": "0.1.0",
209 "world": "greentic:component/component@0.6.0",
210 "describe_export": "describe",
211 "operations": [{{
212 "name": "run",
213 "input_schema": {{"type":"object","properties":{{}},"required":[],"additionalProperties":false}},
214 "output_schema": {{"type":"object","properties":{{}},"required":[],"additionalProperties":false}}
215 }}],
216 "default_operation": "run",
217 "supports": ["messaging"],
218 "profiles": {{"default": "stateless", "supported": ["stateless"]}},
219 "secret_requirements": [],
220 "capabilities": {{
221 "wasi": {{
222 "filesystem": {{"mode":"none","mounts":[]}},
223 "random": true,
224 "clocks": true
225 }},
226 "host": {{
227 "messaging": {{"inbound": true, "outbound": true}},
228 "telemetry": {{"scope": "tenant"}}
229 }}
230 }},
231 "config_schema": {{"type":"object","properties":{{}},"required":[],"additionalProperties":false}},
232 "limits": {{"memory_mb": 64, "wall_time_ms": 1000}},
233 "artifacts": {{"component_wasm": "{artifact}"}},
234 "hashes": {{"component_wasm": "{hash}"}},
235 "dev_flows": {{
236 "default": {{
237 "format": "flow-ir-json",
238 "graph": {{
239 "nodes": [{{"id":"start","type":"start"}}, {{"id":"end","type":"end"}}],
240 "edges": [{{"from":"start","to":"end"}}]
241 }}
242 }}
243 }}
244}}"#
245 )
246 }
247
248 fn write_component_fixture() -> (tempfile::TempDir, PathBuf, PathBuf) {
249 let dir = tempfile::tempdir().expect("fixture dir");
250 let wasm_path = dir.path().join("component.wasm");
251 fs::write(&wasm_path, b"fixture-wasm").expect("write wasm");
252 let hash = format!("blake3:{}", blake3::hash(b"fixture-wasm").to_hex());
253 let manifest_path = dir.path().join(MANIFEST_NAME);
254 fs::write(&manifest_path, manifest_json("component.wasm", &hash)).expect("write manifest");
255 (dir, manifest_path, wasm_path)
256 }
257
258 #[test]
259 fn normalize_path_or_id_strips_file_scheme_only() {
260 assert_eq!(
261 normalize_path_or_id("file:///tmp/component"),
262 "/tmp/component"
263 );
264 assert_eq!(normalize_path_or_id("component-id"), "component-id");
265 }
266
267 #[test]
268 fn discover_with_manifest_uses_override_before_searching_by_id() {
269 let (_dir, manifest_path, wasm_path) = write_component_fixture();
270
271 let handle =
272 discover_with_manifest("not-a-real-component", Some(&manifest_path)).expect("load");
273
274 assert_eq!(handle.manifest_path, manifest_path);
275 assert_eq!(handle.wasm_path, wasm_path);
276 }
277
278 #[test]
279 fn load_from_manifest_reports_missing_artifact() {
280 let dir = tempfile::tempdir().expect("fixture dir");
281 let manifest_path = dir.path().join(MANIFEST_NAME);
282 fs::write(
283 &manifest_path,
284 manifest_json(
285 "missing/component.wasm",
286 "blake3:0000000000000000000000000000000000000000000000000000000000000000",
287 ),
288 )
289 .expect("write manifest");
290
291 let err = load_from_manifest(&manifest_path).expect_err("artifact should be missing");
292 assert!(
293 matches!(err, LoadError::MissingArtifact { path } if path.ends_with("missing/component.wasm"))
294 );
295 }
296
297 #[test]
298 fn try_explicit_discovers_manifest_next_to_wasm() {
299 let (_dir, manifest_path, wasm_path) = write_component_fixture();
300
301 let handle = try_explicit(wasm_path.to_str().expect("utf-8 path"))
302 .expect("try_explicit succeeds")
303 .expect("fixture should resolve");
304
305 assert_eq!(handle.manifest_path, manifest_path);
306 assert_eq!(handle.wasm_path, wasm_path);
307 }
308
309 #[test]
310 fn try_explicit_returns_none_for_missing_paths() {
311 let missing = tempfile::tempdir()
312 .expect("tempdir")
313 .path()
314 .join("missing-component");
315
316 let resolved = try_explicit(missing.to_str().expect("utf-8")).expect("lookup succeeds");
317
318 assert!(resolved.is_none());
319 }
320
321 #[test]
322 fn try_explicit_discovers_manifest_inside_directory() {
323 let (_dir, manifest_path, wasm_path) = write_component_fixture();
324 let component_dir = manifest_path.parent().expect("manifest parent");
325
326 let handle = try_explicit(component_dir.to_str().expect("utf-8"))
327 .expect("try_explicit succeeds")
328 .expect("fixture should resolve");
329
330 assert_eq!(handle.manifest_path, manifest_path);
331 assert_eq!(handle.wasm_path, wasm_path);
332 }
333
334 #[test]
335 fn discover_reports_not_found_when_no_locations_match() {
336 let err = discover("com.greentic.missing.component").expect_err("missing component");
337
338 assert!(matches!(err, LoadError::NotFound(id) if id == "com.greentic.missing.component"));
339 }
340
341 #[test]
342 fn load_from_manifest_reports_parse_errors() {
343 let dir = tempfile::tempdir().expect("fixture dir");
344 let manifest_path = dir.path().join(MANIFEST_NAME);
345 fs::write(&manifest_path, "{not valid json").expect("write invalid manifest");
346
347 let err = load_from_manifest(&manifest_path).expect_err("invalid manifest should fail");
348
349 assert!(matches!(err, LoadError::Manifest { path, .. } if path == manifest_path));
350 }
351
352 #[test]
353 fn load_from_manifest_returns_handle_for_valid_fixture() {
354 let (_dir, manifest_path, wasm_path) = write_component_fixture();
355
356 let handle = load_from_manifest(&manifest_path).expect("valid manifest should load");
357
358 assert_eq!(
359 handle.root,
360 manifest_path.parent().expect("manifest parent")
361 );
362 assert_eq!(handle.manifest_path, manifest_path);
363 assert_eq!(handle.wasm_path, wasm_path);
364 }
365
366 #[test]
367 fn try_explicit_accepts_manifest_json_path_directly() {
368 let (_dir, manifest_path, wasm_path) = write_component_fixture();
369
370 let handle = try_explicit(manifest_path.to_str().expect("utf-8 path"))
371 .expect("lookup succeeds")
372 .expect("fixture should resolve");
373
374 assert_eq!(handle.manifest_path, manifest_path);
375 assert_eq!(handle.wasm_path, wasm_path);
376 }
377
378 #[test]
379 fn try_explicit_returns_none_for_existing_non_manifest_path() {
380 let dir = tempfile::tempdir().expect("tempdir");
381 let existing = dir.path().join("notes.txt");
382 fs::write(&existing, b"notes").expect("write note");
383
384 let handle = try_explicit(existing.to_str().expect("utf-8")).expect("lookup succeeds");
385
386 assert!(handle.is_none());
387 }
388
389 #[test]
390 fn try_workspace_discovers_component_from_target_directory() {
391 let _guard = cwd_lock().lock().expect("cwd lock");
392 let original_cwd = std::env::current_dir().expect("cwd");
393 let dir = tempfile::tempdir().expect("tempdir");
394 std::env::set_current_dir(dir.path()).expect("set cwd");
395
396 let profile_dir = dir.path().join("target/wasm32-wasip2/release");
397 fs::create_dir_all(&profile_dir).expect("create target dir");
398 let wasm_path = profile_dir.join("com.greentic.test.component.wasm");
399 fs::write(&wasm_path, b"fixture-wasm").expect("write wasm");
400 let hash = format!("blake3:{}", blake3::hash(b"fixture-wasm").to_hex());
401 let manifest_path = profile_dir.join(MANIFEST_NAME);
402 fs::write(
403 &manifest_path,
404 manifest_json("com.greentic.test.component.wasm", &hash),
405 )
406 .expect("write manifest");
407
408 let handle = try_workspace("com.greentic.test.component")
409 .expect("workspace lookup")
410 .expect("fixture should resolve");
411
412 assert_eq!(handle.manifest_path, manifest_path);
413 assert_eq!(handle.wasm_path, wasm_path);
414
415 std::env::set_current_dir(original_cwd).expect("restore cwd");
416 }
417
418 #[test]
419 fn try_workspace_ignores_wasm_without_adjacent_manifest() {
420 let _guard = cwd_lock().lock().expect("cwd lock");
421 let original_cwd = std::env::current_dir().expect("cwd");
422 let dir = tempfile::tempdir().expect("tempdir");
423 std::env::set_current_dir(dir.path()).expect("set cwd");
424
425 let profile_dir = dir.path().join("target/wasm32-wasip2/release");
426 fs::create_dir_all(&profile_dir).expect("create target dir");
427 fs::write(
428 profile_dir.join("com.greentic.test.component.wasm"),
429 b"fixture-wasm",
430 )
431 .expect("write wasm");
432
433 let handle = try_workspace("com.greentic.test.component").expect("workspace lookup");
434
435 assert!(handle.is_none());
436
437 std::env::set_current_dir(original_cwd).expect("restore cwd");
438 }
439}