Skip to main content

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": crate::cli_i18n::t("cli.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            "{}",
79            crate::cli_i18n::tf(
80                "cli.update.pack_yaml_updated",
81                &[
82                    &component_stats.added.to_string(),
83                    &component_stats.removed.to_string(),
84                    &component_stats.total.to_string(),
85                    &flow_stats.added.to_string(),
86                    &flow_stats.removed.to_string(),
87                    &flow_stats.total.to_string(),
88                ]
89            )
90        );
91    }
92
93    Ok(())
94}
95pub fn update_pack(input: &Path, strict: bool) -> Result<UpdateResult> {
96    let pack_dir = normalize(input.to_path_buf());
97    let pack_yaml = normalize_under_root(&pack_dir, Path::new("pack.yaml"))?;
98    let components_dir = normalize_under_root(&pack_dir, Path::new("components"))?;
99    let flows_dir = normalize_under_root(&pack_dir, Path::new("flows"))?;
100
101    fs::create_dir_all(&components_dir)?;
102    fs::create_dir_all(&flows_dir)?;
103
104    let mut config: PackConfig = serde_yaml_bw::from_str(
105        &fs::read_to_string(&pack_yaml)
106            .with_context(|| format!("failed to read {}", pack_yaml.display()))?,
107    )
108    .with_context(|| format!("{} is not a valid pack.yaml", pack_yaml.display()))?;
109
110    let component_stats = sync_components(&mut config, &components_dir)?;
111    let flow_stats = sync_flows(&mut config, &flows_dir, &pack_dir, strict)?;
112
113    let serialized = serde_yaml_bw::to_string(&config)?;
114    fs::write(&pack_yaml, serialized)?;
115
116    Ok(UpdateResult {
117        pack_dir,
118        config,
119        component_stats,
120        flow_stats,
121    })
122}
123
124fn sync_flows(
125    config: &mut PackConfig,
126    flows_dir: &Path,
127    pack_dir: &Path,
128    strict: bool,
129) -> Result<FlowUpdateStats> {
130    let discovered = discover_flows(flows_dir)?;
131    let initial_flows = config.flows.len();
132    let mut preserved = 0usize;
133    let mut added = 0usize;
134
135    let (mut existing_by_id, existing_by_path) = index_flows(std::mem::take(&mut config.flows));
136    let mut updated = Vec::new();
137
138    for file_name in discovered {
139        let rel_path = PathBuf::from("flows").join(&file_name);
140        let path_key = path_key(&rel_path);
141        let file_path = flows_dir.join(&file_name);
142
143        let flow = compile_flow(&file_path)?;
144        let flow_id = flow.id.to_string();
145        let entrypoints = flow_entrypoints(&flow);
146
147        let mut cfg = if let Some(existing) = existing_by_path
148            .get(&path_key)
149            .and_then(|id| existing_by_id.remove(id))
150        {
151            preserved += 1;
152            existing
153        } else if let Some(existing) = existing_by_id.remove(&flow_id) {
154            preserved += 1;
155            existing
156        } else {
157            added += 1;
158            default_flow(flow_id.clone(), rel_path.clone(), entrypoints.clone())
159        };
160
161        cfg.id = flow_id;
162        cfg.file = rel_path;
163        if cfg.entrypoints.is_empty() {
164            cfg.entrypoints = entrypoints.clone();
165        }
166        if cfg.entrypoints.is_empty() {
167            cfg.entrypoints = vec!["default".to_string()];
168        }
169
170        crate::flow_resolve::ensure_sidecar_exists(pack_dir, &cfg, &flow, strict)?;
171
172        updated.push(cfg);
173    }
174
175    updated.sort_by(|a, b| a.id.cmp(&b.id));
176    config.flows = updated;
177
178    let removed = initial_flows.saturating_sub(preserved);
179
180    Ok(FlowUpdateStats {
181        added,
182        removed,
183        total: config.flows.len(),
184    })
185}
186
187fn discover_flows(dir: &Path) -> Result<Vec<std::ffi::OsString>> {
188    let mut names = Vec::new();
189
190    if dir.exists() {
191        for entry in fs::read_dir(dir)
192            .with_context(|| format!("failed to list flows in {}", dir.display()))?
193        {
194            let entry = entry?;
195            if !entry.file_type()?.is_file() {
196                continue;
197            }
198            if entry.path().extension() != Some(std::ffi::OsStr::new("ygtc")) {
199                continue;
200            }
201            names.push(entry.file_name());
202        }
203    }
204
205    names.sort();
206    Ok(names)
207}
208
209fn index_flows(flows: Vec<FlowConfig>) -> (BTreeMap<String, FlowConfig>, BTreeMap<String, String>) {
210    let mut by_id = BTreeMap::new();
211    let mut by_path = BTreeMap::new();
212
213    for flow in flows {
214        by_path.insert(path_key(&flow.file), flow.id.clone());
215        by_id.insert(flow.id.clone(), flow);
216    }
217
218    (by_id, by_path)
219}
220
221fn path_key(path: &Path) -> String {
222    path.components()
223        .map(|c| c.as_os_str().to_string_lossy())
224        .collect::<Vec<_>>()
225        .join("/")
226}
227
228fn flow_entrypoints(flow: &Flow) -> Vec<String> {
229    let mut entrypoints: Vec<_> = flow.entrypoints.keys().map(|key| key.to_string()).collect();
230    entrypoints.sort();
231    entrypoints
232}
233
234fn compile_flow(path: &Path) -> Result<Flow> {
235    let yaml_src = fs::read_to_string(path)
236        .with_context(|| format!("failed to read flow {}", path.display()))?;
237    compile_ygtc_str(&yaml_src)
238        .with_context(|| format!("failed to compile flow {}", path.display()))
239}
240
241fn default_flow(id: String, file: PathBuf, entrypoints: Vec<String>) -> FlowConfig {
242    FlowConfig {
243        id,
244        file,
245        tags: vec!["default".to_string()],
246        entrypoints: if entrypoints.is_empty() {
247            vec!["default".to_string()]
248        } else {
249            entrypoints
250        },
251    }
252}
253
254fn normalize(path: PathBuf) -> PathBuf {
255    if path.is_absolute() {
256        path
257    } else {
258        std::env::current_dir()
259            .unwrap_or_else(|_| PathBuf::from("."))
260            .join(path)
261    }
262}