1#![forbid(unsafe_code)]
2
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result, anyhow};
8use clap::Parser;
9use greentic_types::{ComponentCapabilities, ComponentManifest, ComponentProfiles};
10use tracing::{info, warn};
11use wit_component::DecodedWasm;
12
13use crate::config::{ComponentConfig, FlowKindLabel, PackConfig};
14use crate::path_safety::normalize_under_root;
15
16#[derive(Debug, Clone, Default)]
17pub struct ComponentUpdateStats {
18 pub added: usize,
19 pub removed: usize,
20 pub total: usize,
21}
22
23#[derive(Debug, Clone, Default)]
24struct DiscoveredComponent {
25 rel_wasm_path: PathBuf,
26 abs_wasm_path: PathBuf,
27 id_hint: String,
28}
29
30#[derive(Debug, Parser)]
31pub struct ComponentsArgs {
32 #[arg(long = "in", value_name = "DIR")]
34 pub input: PathBuf,
35}
36
37pub fn handle(args: ComponentsArgs, json: bool) -> Result<()> {
38 let pack_dir = normalize(args.input);
39 let pack_yaml = normalize_under_root(&pack_dir, Path::new("pack.yaml"))?;
40 let components_dir = normalize_under_root(&pack_dir, Path::new("components"))?;
41
42 fs::create_dir_all(&components_dir)?;
43
44 let mut config: PackConfig = serde_yaml_bw::from_str(
45 &fs::read_to_string(&pack_yaml)
46 .with_context(|| format!("failed to read {}", pack_yaml.display()))?,
47 )
48 .with_context(|| format!("{} is not a valid pack.yaml", pack_yaml.display()))?;
49
50 let stats = sync_components(&mut config, &components_dir)?;
51
52 let serialized = serde_yaml_bw::to_string(&config)?;
53 fs::write(&pack_yaml, serialized)?;
54
55 if json {
56 println!(
57 "{}",
58 serde_json::to_string_pretty(&serde_json::json!({
59 "status": "ok",
60 "pack_dir": pack_dir,
61 "components": {
62 "added": stats.added,
63 "removed": stats.removed,
64 "total": stats.total,
65 }
66 }))?
67 );
68 } else {
69 info!(
70 added = stats.added,
71 removed = stats.removed,
72 total = stats.total,
73 "updated pack components"
74 );
75 println!(
76 "components updated (added: {}, removed: {}, total: {})",
77 stats.added, stats.removed, stats.total
78 );
79 }
80
81 Ok(())
82}
83
84pub fn sync_components(
85 config: &mut PackConfig,
86 components_dir: &Path,
87) -> Result<ComponentUpdateStats> {
88 let discovered = discover_components(components_dir)?;
89 let initial_components = config.components.len();
90 let mut preserved = 0usize;
91 let mut added = 0usize;
92
93 let (mut existing_by_id, existing_by_path) =
94 index_components(std::mem::take(&mut config.components));
95 let mut updated = Vec::new();
96
97 for discovered in discovered {
98 let rel_path = discovered.rel_wasm_path;
99 let path_key = path_key(&rel_path);
100 let stem = discovered.id_hint;
101 let chosen_id = existing_by_path
102 .get(&path_key)
103 .cloned()
104 .unwrap_or_else(|| stem.to_string());
105
106 let mut component = if let Some(existing) = existing_by_path
107 .get(&path_key)
108 .and_then(|id| existing_by_id.remove(id))
109 {
110 preserved += 1;
111 existing
112 } else if let Some(existing) = existing_by_id.remove(&chosen_id) {
113 preserved += 1;
114 existing
115 } else {
116 added += 1;
117 default_component(chosen_id.clone(), rel_path.clone())
118 };
119
120 if let Some(world) = infer_component_world(&discovered.abs_wasm_path)
121 && (component.world.trim().is_empty() || component.world == "greentic:component/stub")
122 {
123 component.world = world;
124 }
125
126 component.id = chosen_id;
127 component.wasm = rel_path;
128 updated.push(component);
129 }
130
131 updated.sort_by(|a, b| a.id.cmp(&b.id));
132 config.components = updated;
133
134 let removed = initial_components.saturating_sub(preserved);
135
136 Ok(ComponentUpdateStats {
137 added,
138 removed,
139 total: config.components.len(),
140 })
141}
142
143fn discover_components(dir: &Path) -> Result<Vec<DiscoveredComponent>> {
144 let mut components = Vec::new();
145
146 if dir.exists() {
147 for entry in fs::read_dir(dir)
148 .with_context(|| format!("failed to list components in {}", dir.display()))?
149 {
150 let entry = entry?;
151 let path = entry.path();
152 let file_type = entry.file_type()?;
153
154 if file_type.is_file() {
155 if path.extension() != Some(std::ffi::OsStr::new("wasm")) {
156 continue;
157 }
158 let stem = path
159 .file_stem()
160 .and_then(|s| s.to_str())
161 .ok_or_else(|| anyhow!("invalid component filename: {}", path.display()))?;
162 components.push(DiscoveredComponent {
163 rel_wasm_path: PathBuf::from("components").join(
164 path.file_name()
165 .ok_or_else(|| anyhow!("invalid component filename"))?,
166 ),
167 abs_wasm_path: path.clone(),
168 id_hint: stem.to_string(),
169 });
170 continue;
171 }
172
173 if file_type.is_dir() {
174 let mut wasm_files = collect_wasm_files(&path)?;
175 if wasm_files.is_empty() {
176 continue;
177 }
178
179 let chosen = wasm_files.iter().find(|p| {
180 p.file_name()
181 .map(|n| n == std::ffi::OsStr::new("component.wasm"))
182 .unwrap_or(false)
183 });
184 let wasm_path = chosen.cloned().unwrap_or_else(|| {
185 if wasm_files.len() == 1 {
186 wasm_files[0].clone()
187 } else {
188 wasm_files.sort();
189 wasm_files[0].clone()
190 }
191 });
192
193 let dir_name = path
194 .file_name()
195 .and_then(|n| n.to_str())
196 .ok_or_else(|| anyhow!("invalid component directory name: {}", path.display()))?
197 .to_string();
198 let manifest_id =
199 load_component_manifest_id(&path)?.unwrap_or_else(|| dir_name.clone());
200
201 let wasm_file_name = wasm_path
202 .strip_prefix(dir)
203 .unwrap_or(&wasm_path)
204 .components()
205 .map(|c| c.as_os_str().to_string_lossy())
206 .collect::<Vec<_>>()
207 .join("/");
208
209 components.push(DiscoveredComponent {
210 rel_wasm_path: PathBuf::from("components").join(&wasm_file_name),
211 abs_wasm_path: wasm_path,
212 id_hint: manifest_id,
213 });
214 }
215 }
216 }
217
218 components.sort_by(|a, b| a.id_hint.cmp(&b.id_hint));
219 Ok(components)
220}
221
222fn collect_wasm_files(dir: &Path) -> Result<Vec<PathBuf>> {
223 let mut wasm_files = Vec::new();
224 let mut stack = vec![dir.to_path_buf()];
225
226 while let Some(current) = stack.pop() {
227 for entry in fs::read_dir(¤t)
228 .with_context(|| format!("failed to list components in {}", current.display()))?
229 {
230 let entry = entry?;
231 let path = entry.path();
232 let file_type = entry.file_type()?;
233 if file_type.is_dir() {
234 stack.push(path);
235 continue;
236 }
237 if file_type.is_file() && path.extension() == Some(std::ffi::OsStr::new("wasm")) {
238 wasm_files.push(path);
239 }
240 }
241 }
242
243 Ok(wasm_files)
244}
245
246fn load_component_manifest_id(dir: &Path) -> Result<Option<String>> {
247 let candidates = [
248 dir.join("component.manifest.cbor"),
249 dir.join("component.manifest.json"),
250 dir.join("component.json"),
251 ];
252 for manifest_path in candidates {
253 if !manifest_path.exists() {
254 continue;
255 }
256 let bytes = fs::read(&manifest_path)
257 .with_context(|| format!("failed to read {}", manifest_path.display()))?;
258 let manifest: ComponentManifest = if manifest_path
259 .extension()
260 .and_then(|ext| ext.to_str())
261 .is_some_and(|ext| ext.eq_ignore_ascii_case("cbor"))
262 {
263 serde_cbor::from_slice(&bytes)
264 .with_context(|| format!("{} is not valid CBOR", manifest_path.display()))?
265 } else {
266 serde_json::from_slice(&bytes)
267 .with_context(|| format!("{} is not valid JSON", manifest_path.display()))?
268 };
269 return Ok(Some(manifest.id.as_str().to_string()));
270 }
271
272 Ok(None)
273}
274
275fn infer_component_world(path: &Path) -> Option<String> {
276 let bytes = fs::read(path).ok()?;
277 let decoded = match wit_component::decode(&bytes) {
278 Ok(decoded) => decoded,
279 Err(err) => {
280 warn!(
281 path = %path.display(),
282 "failed to decode component for world inference: {err}"
283 );
284 return None;
285 }
286 };
287
288 let (resolve, world_id) = match decoded {
289 DecodedWasm::Component(resolve, world) => (resolve, world),
290 DecodedWasm::WitPackage(..) => return None,
291 };
292
293 let world = &resolve.worlds[world_id];
294 let pkg_id = world.package?;
295 let pkg = &resolve.packages[pkg_id];
296
297 let mut label = format!("{}:{}/{}", pkg.name.namespace, pkg.name.name, world.name);
298 if let Some(version) = &pkg.name.version {
299 label.push('@');
300 label.push_str(&version.to_string());
301 }
302
303 Some(label)
304}
305
306fn index_components(
307 components: Vec<ComponentConfig>,
308) -> (BTreeMap<String, ComponentConfig>, BTreeMap<String, String>) {
309 let mut by_id = BTreeMap::new();
310 let mut by_path = BTreeMap::new();
311
312 for component in components {
313 by_path.insert(path_key(&component.wasm), component.id.clone());
314 by_id.insert(component.id.clone(), component);
315 }
316
317 (by_id, by_path)
318}
319
320fn path_key(path: &Path) -> String {
321 path.components()
322 .map(|c| c.as_os_str().to_string_lossy())
323 .collect::<Vec<_>>()
324 .join("/")
325}
326
327fn default_component(id: String, wasm: PathBuf) -> ComponentConfig {
328 ComponentConfig {
329 id,
330 version: "0.1.0".to_string(),
331 world: "greentic:component/stub".to_string(),
332 supports: vec![FlowKindLabel::Messaging],
333 profiles: ComponentProfiles {
334 default: Some("default".to_string()),
335 supported: vec!["default".to_string()],
336 },
337 capabilities: ComponentCapabilities::default(),
338 wasm,
339 operations: Vec::new(),
340 config_schema: None,
341 resources: None,
342 configurators: None,
343 }
344}
345
346fn normalize(path: PathBuf) -> PathBuf {
347 if path.is_absolute() {
348 path
349 } else {
350 std::env::current_dir()
351 .unwrap_or_else(|_| PathBuf::from("."))
352 .join(path)
353 }
354}