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 #[arg(long = "in", value_name = "DIR")]
21 pub input: PathBuf,
22
23 #[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}