packc/cli/
update.rs

1#![forbid(unsafe_code)]
2
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result};
8use clap::Parser;
9use greentic_flow::compile_ygtc_str;
10use greentic_types::Flow;
11use tracing::info;
12
13use crate::cli::components::sync_components;
14use crate::config::{FlowConfig, PackConfig};
15use crate::path_safety::normalize_under_root;
16
17#[derive(Debug, Parser)]
18pub struct UpdateArgs {
19    /// Root directory of the pack (must contain pack.yaml)
20    #[arg(long = "in", value_name = "DIR")]
21    pub input: PathBuf,
22
23    /// Enforce that all flow nodes have resolve mappings; otherwise error.
24    #[arg(long = "strict", default_value_t = false)]
25    pub strict: bool,
26}
27
28#[derive(Debug, Clone, Copy, Default)]
29pub struct FlowUpdateStats {
30    added: usize,
31    removed: usize,
32    total: usize,
33}
34
35#[derive(Debug)]
36pub struct UpdateResult {
37    pub pack_dir: PathBuf,
38    pub config: PackConfig,
39    pub component_stats: crate::cli::components::ComponentUpdateStats,
40    pub flow_stats: FlowUpdateStats,
41}
42
43pub fn handle(args: UpdateArgs, json: bool) -> Result<()> {
44    let result = update_pack(&args.input, args.strict)?;
45    let pack_dir = result.pack_dir;
46    let component_stats = result.component_stats;
47    let flow_stats = result.flow_stats;
48
49    if json {
50        println!(
51            "{}",
52            serde_json::to_string_pretty(&serde_json::json!({
53                "status": "ok",
54                "pack_dir": pack_dir,
55                "components": {
56                    "added": component_stats.added,
57                    "removed": component_stats.removed,
58                    "total": component_stats.total,
59                },
60                "flows": {
61                    "added": flow_stats.added,
62                    "removed": flow_stats.removed,
63                    "total": flow_stats.total,
64                },
65            }))?
66        );
67    } else {
68        info!(
69            components_added = component_stats.added,
70            components_removed = component_stats.removed,
71            components_total = component_stats.total,
72            flows_added = flow_stats.added,
73            flows_removed = flow_stats.removed,
74            flows_total = flow_stats.total,
75            "updated pack manifest"
76        );
77        println!(
78            "pack.yaml updated (components: +{}, -{}, total {}; flows: +{}, -{}, total {})",
79            component_stats.added,
80            component_stats.removed,
81            component_stats.total,
82            flow_stats.added,
83            flow_stats.removed,
84            flow_stats.total
85        );
86    }
87
88    Ok(())
89}
90pub fn update_pack(input: &Path, strict: bool) -> Result<UpdateResult> {
91    let pack_dir = normalize(input.to_path_buf());
92    let pack_yaml = normalize_under_root(&pack_dir, Path::new("pack.yaml"))?;
93    let components_dir = normalize_under_root(&pack_dir, Path::new("components"))?;
94    let flows_dir = normalize_under_root(&pack_dir, Path::new("flows"))?;
95
96    fs::create_dir_all(&components_dir)?;
97    fs::create_dir_all(&flows_dir)?;
98
99    let mut config: PackConfig = serde_yaml_bw::from_str(
100        &fs::read_to_string(&pack_yaml)
101            .with_context(|| format!("failed to read {}", pack_yaml.display()))?,
102    )
103    .with_context(|| format!("{} is not a valid pack.yaml", pack_yaml.display()))?;
104
105    let component_stats = sync_components(&mut config, &components_dir)?;
106    let flow_stats = sync_flows(&mut config, &flows_dir, &pack_dir, strict)?;
107
108    let serialized = serde_yaml_bw::to_string(&config)?;
109    fs::write(&pack_yaml, serialized)?;
110
111    Ok(UpdateResult {
112        pack_dir,
113        config,
114        component_stats,
115        flow_stats,
116    })
117}
118
119fn sync_flows(
120    config: &mut PackConfig,
121    flows_dir: &Path,
122    pack_dir: &Path,
123    strict: bool,
124) -> Result<FlowUpdateStats> {
125    let discovered = discover_flows(flows_dir)?;
126    let initial_flows = config.flows.len();
127    let mut preserved = 0usize;
128    let mut added = 0usize;
129
130    let (mut existing_by_id, existing_by_path) = index_flows(std::mem::take(&mut config.flows));
131    let mut updated = Vec::new();
132
133    for file_name in discovered {
134        let rel_path = PathBuf::from("flows").join(&file_name);
135        let path_key = path_key(&rel_path);
136        let file_path = flows_dir.join(&file_name);
137
138        let flow = compile_flow(&file_path)?;
139        let flow_id = flow.id.to_string();
140        let entrypoints = flow_entrypoints(&flow);
141
142        let mut cfg = if let Some(existing) = existing_by_path
143            .get(&path_key)
144            .and_then(|id| existing_by_id.remove(id))
145        {
146            preserved += 1;
147            existing
148        } else if let Some(existing) = existing_by_id.remove(&flow_id) {
149            preserved += 1;
150            existing
151        } else {
152            added += 1;
153            default_flow(flow_id.clone(), rel_path.clone(), entrypoints.clone())
154        };
155
156        cfg.id = flow_id;
157        cfg.file = rel_path;
158        if cfg.entrypoints.is_empty() {
159            cfg.entrypoints = entrypoints.clone();
160        }
161        if cfg.entrypoints.is_empty() {
162            cfg.entrypoints = vec!["default".to_string()];
163        }
164
165        crate::flow_resolve::ensure_sidecar_exists(pack_dir, &cfg, &flow, strict)?;
166
167        updated.push(cfg);
168    }
169
170    updated.sort_by(|a, b| a.id.cmp(&b.id));
171    config.flows = updated;
172
173    let removed = initial_flows.saturating_sub(preserved);
174
175    Ok(FlowUpdateStats {
176        added,
177        removed,
178        total: config.flows.len(),
179    })
180}
181
182fn discover_flows(dir: &Path) -> Result<Vec<std::ffi::OsString>> {
183    let mut names = Vec::new();
184
185    if dir.exists() {
186        for entry in fs::read_dir(dir)
187            .with_context(|| format!("failed to list flows in {}", dir.display()))?
188        {
189            let entry = entry?;
190            if !entry.file_type()?.is_file() {
191                continue;
192            }
193            if entry.path().extension() != Some(std::ffi::OsStr::new("ygtc")) {
194                continue;
195            }
196            names.push(entry.file_name());
197        }
198    }
199
200    names.sort();
201    Ok(names)
202}
203
204fn index_flows(flows: Vec<FlowConfig>) -> (BTreeMap<String, FlowConfig>, BTreeMap<String, String>) {
205    let mut by_id = BTreeMap::new();
206    let mut by_path = BTreeMap::new();
207
208    for flow in flows {
209        by_path.insert(path_key(&flow.file), flow.id.clone());
210        by_id.insert(flow.id.clone(), flow);
211    }
212
213    (by_id, by_path)
214}
215
216fn path_key(path: &Path) -> String {
217    path.components()
218        .map(|c| c.as_os_str().to_string_lossy())
219        .collect::<Vec<_>>()
220        .join("/")
221}
222
223fn flow_entrypoints(flow: &Flow) -> Vec<String> {
224    let mut entrypoints: Vec<_> = flow.entrypoints.keys().map(|key| key.to_string()).collect();
225    entrypoints.sort();
226    entrypoints
227}
228
229fn compile_flow(path: &Path) -> Result<Flow> {
230    let yaml_src = fs::read_to_string(path)
231        .with_context(|| format!("failed to read flow {}", path.display()))?;
232    compile_ygtc_str(&yaml_src)
233        .with_context(|| format!("failed to compile flow {}", path.display()))
234}
235
236fn default_flow(id: String, file: PathBuf, entrypoints: Vec<String>) -> FlowConfig {
237    FlowConfig {
238        id,
239        file,
240        tags: vec!["default".to_string()],
241        entrypoints: if entrypoints.is_empty() {
242            vec!["default".to_string()]
243        } else {
244            entrypoints
245        },
246    }
247}
248
249fn normalize(path: PathBuf) -> PathBuf {
250    if path.is_absolute() {
251        path
252    } else {
253        std::env::current_dir()
254            .unwrap_or_else(|_| PathBuf::from("."))
255            .join(path)
256    }
257}