packc/cli/
components.rs

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    /// Root directory of the pack (must contain pack.yaml)
33    #[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(&current)
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}