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, 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
199 let wasm_file_name = wasm_path
200 .strip_prefix(dir)
201 .unwrap_or(&wasm_path)
202 .components()
203 .map(|c| c.as_os_str().to_string_lossy())
204 .collect::<Vec<_>>()
205 .join("/");
206
207 components.push(DiscoveredComponent {
208 rel_wasm_path: PathBuf::from("components").join(&wasm_file_name),
209 abs_wasm_path: wasm_path,
210 id_hint: dir_name,
211 });
212 }
213 }
214 }
215
216 components.sort_by(|a, b| a.id_hint.cmp(&b.id_hint));
217 Ok(components)
218}
219
220fn collect_wasm_files(dir: &Path) -> Result<Vec<PathBuf>> {
221 let mut wasm_files = Vec::new();
222 let mut stack = vec![dir.to_path_buf()];
223
224 while let Some(current) = stack.pop() {
225 for entry in fs::read_dir(¤t)
226 .with_context(|| format!("failed to list components in {}", current.display()))?
227 {
228 let entry = entry?;
229 let path = entry.path();
230 let file_type = entry.file_type()?;
231 if file_type.is_dir() {
232 stack.push(path);
233 continue;
234 }
235 if file_type.is_file() && path.extension() == Some(std::ffi::OsStr::new("wasm")) {
236 wasm_files.push(path);
237 }
238 }
239 }
240
241 Ok(wasm_files)
242}
243
244fn infer_component_world(path: &Path) -> Option<String> {
245 let bytes = fs::read(path).ok()?;
246 let decoded = match wit_component::decode(&bytes) {
247 Ok(decoded) => decoded,
248 Err(err) => {
249 warn!(
250 path = %path.display(),
251 "failed to decode component for world inference: {err}"
252 );
253 return None;
254 }
255 };
256
257 let (resolve, world_id) = match decoded {
258 DecodedWasm::Component(resolve, world) => (resolve, world),
259 DecodedWasm::WitPackage(..) => return None,
260 };
261
262 let world = &resolve.worlds[world_id];
263 let pkg_id = world.package?;
264 let pkg = &resolve.packages[pkg_id];
265
266 let mut label = format!("{}:{}/{}", pkg.name.namespace, pkg.name.name, world.name);
267 if let Some(version) = &pkg.name.version {
268 label.push('@');
269 label.push_str(&version.to_string());
270 }
271
272 Some(label)
273}
274
275fn index_components(
276 components: Vec<ComponentConfig>,
277) -> (BTreeMap<String, ComponentConfig>, BTreeMap<String, String>) {
278 let mut by_id = BTreeMap::new();
279 let mut by_path = BTreeMap::new();
280
281 for component in components {
282 by_path.insert(path_key(&component.wasm), component.id.clone());
283 by_id.insert(component.id.clone(), component);
284 }
285
286 (by_id, by_path)
287}
288
289fn path_key(path: &Path) -> String {
290 path.components()
291 .map(|c| c.as_os_str().to_string_lossy())
292 .collect::<Vec<_>>()
293 .join("/")
294}
295
296fn default_component(id: String, wasm: PathBuf) -> ComponentConfig {
297 ComponentConfig {
298 id,
299 version: "0.1.0".to_string(),
300 world: "greentic:component/stub".to_string(),
301 supports: vec![FlowKindLabel::Messaging],
302 profiles: ComponentProfiles {
303 default: Some("default".to_string()),
304 supported: vec!["default".to_string()],
305 },
306 capabilities: ComponentCapabilities::default(),
307 wasm,
308 operations: Vec::new(),
309 config_schema: None,
310 resources: None,
311 configurators: None,
312 }
313}
314
315fn normalize(path: PathBuf) -> PathBuf {
316 if path.is_absolute() {
317 path
318 } else {
319 std::env::current_dir()
320 .unwrap_or_else(|_| PathBuf::from("."))
321 .join(path)
322 }
323}