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": "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}