1use anyhow::{Context, Result, anyhow};
2use indexmap::IndexMap;
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::collections::HashMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use crate::model::{FlowDoc, NodeDoc};
10
11pub const MODE_SCAFFOLD: &str = "scaffold";
12pub const MODE_NEW: &str = "new";
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum WizardPlanStep {
17 EnsureDir { path: PathBuf },
18 WriteFile { path: PathBuf, content: String },
19 ValidateFlow { path: PathBuf },
20 RunCommand { command: String, args: Vec<String> },
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct WizardPlan {
25 pub mode: String,
26 pub validate: bool,
27 pub steps: Vec<WizardPlanStep>,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "kebab-case")]
32pub enum FlowQuestionKind {
33 String,
34 Bool,
35 Choice,
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct FlowQuestionSpec {
40 pub id: String,
41 pub prompt: String,
42 pub kind: FlowQuestionKind,
43 pub required: bool,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub default: Option<Value>,
46 #[serde(default, skip_serializing_if = "Vec::is_empty")]
47 pub options: Vec<Value>,
48}
49
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51pub struct QaSpec {
52 pub mode: String,
53 pub questions: Vec<FlowQuestionSpec>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Default)]
57pub struct ApplyOptions {
58 pub validate: bool,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct ProviderContext {
63 pub root_dir: PathBuf,
64}
65
66impl Default for ProviderContext {
67 fn default() -> Self {
68 Self {
69 root_dir: PathBuf::from("."),
70 }
71 }
72}
73
74#[derive(Debug, Default, Clone)]
75pub struct FlowScaffoldWizardProvider;
76
77pub fn wizard_provider() -> FlowScaffoldWizardProvider {
78 FlowScaffoldWizardProvider
79}
80
81impl FlowScaffoldWizardProvider {
82 pub fn id(&self) -> &'static str {
83 "greentic-flow.scaffold"
84 }
85
86 pub fn spec(&self, mode: &str, _ctx: &ProviderContext) -> Result<QaSpec> {
87 validate_mode(mode)?;
88 Ok(QaSpec {
89 mode: mode.to_string(),
90 questions: vec![
91 FlowQuestionSpec {
92 id: "flow.name".to_string(),
93 prompt: "Flow id (used in the file content)".to_string(),
94 kind: FlowQuestionKind::String,
95 required: true,
96 default: None,
97 options: Vec::new(),
98 },
99 FlowQuestionSpec {
100 id: "flow.title".to_string(),
101 prompt: "Optional flow title".to_string(),
102 kind: FlowQuestionKind::String,
103 required: false,
104 default: None,
105 options: Vec::new(),
106 },
107 FlowQuestionSpec {
108 id: "flow.description".to_string(),
109 prompt: "Optional flow description".to_string(),
110 kind: FlowQuestionKind::String,
111 required: false,
112 default: None,
113 options: Vec::new(),
114 },
115 FlowQuestionSpec {
116 id: "flow.path".to_string(),
117 prompt: "Flow file path (for example flows/main.ygtc)".to_string(),
118 kind: FlowQuestionKind::String,
119 required: true,
120 default: Some(Value::String("flows/main.ygtc".to_string())),
121 options: Vec::new(),
122 },
123 FlowQuestionSpec {
124 id: "flow.kind".to_string(),
125 prompt: "Flow kind".to_string(),
126 kind: FlowQuestionKind::Choice,
127 required: true,
128 default: Some(Value::String("messaging".to_string())),
129 options: vec![
130 Value::String("messaging".to_string()),
131 Value::String("events".to_string()),
132 Value::String("component-config".to_string()),
133 Value::String("job".to_string()),
134 Value::String("http".to_string()),
135 ],
136 },
137 FlowQuestionSpec {
138 id: "flow.entrypoint".to_string(),
139 prompt: "Default entrypoint node id".to_string(),
140 kind: FlowQuestionKind::String,
141 required: true,
142 default: Some(Value::String("start".to_string())),
143 options: Vec::new(),
144 },
145 FlowQuestionSpec {
146 id: "flow.nodes.scaffold".to_string(),
147 prompt: "Scaffold starter nodes".to_string(),
148 kind: FlowQuestionKind::Bool,
149 required: true,
150 default: Some(Value::Bool(false)),
151 options: Vec::new(),
152 },
153 FlowQuestionSpec {
154 id: "flow.nodes.variant".to_string(),
155 prompt: "Starter graph variant".to_string(),
156 kind: FlowQuestionKind::Choice,
157 required: true,
158 default: Some(Value::String("start-end".to_string())),
159 options: vec![
160 Value::String("start-end".to_string()),
161 Value::String("start-log-end".to_string()),
162 ],
163 },
164 ],
165 })
166 }
167
168 pub fn apply(
169 &self,
170 mode: &str,
171 ctx: &ProviderContext,
172 answers: &HashMap<String, Value>,
173 options: &ApplyOptions,
174 ) -> Result<WizardPlan> {
175 validate_mode(mode)?;
176 let flow_name = required_str(answers, "flow.name")?;
177 let flow_title = optional_str(answers, "flow.title");
178 let flow_description = optional_str(answers, "flow.description");
179 let flow_kind = required_str(answers, "flow.kind")?;
180 let flow_path = required_str(answers, "flow.path")?;
181 let entrypoint = answers
182 .get("flow.entrypoint")
183 .and_then(Value::as_str)
184 .unwrap_or("start");
185 let scaffold_nodes = answers
186 .get("flow.nodes.scaffold")
187 .and_then(Value::as_bool)
188 .unwrap_or(false);
189 let variant = answers
190 .get("flow.nodes.variant")
191 .and_then(Value::as_str)
192 .unwrap_or("start-end");
193
194 let flow_rel = PathBuf::from(flow_path);
195 let flow_file = ctx.root_dir.join(&flow_rel);
196 let mut doc = FlowDoc {
197 id: flow_name.to_string(),
198 title: flow_title.map(ToOwned::to_owned),
199 description: flow_description.map(ToOwned::to_owned),
200 flow_type: flow_kind.to_string(),
201 start: None,
202 parameters: Value::Object(Default::default()),
203 tags: Vec::new(),
204 schema_version: Some(2),
205 entrypoints: IndexMap::new(),
206 meta: None,
207 slot_schema: None,
208 nodes: IndexMap::new(),
209 };
210
211 if scaffold_nodes {
212 doc.entrypoints
213 .insert("default".to_string(), Value::String(entrypoint.to_string()));
214 for (id, node) in starter_nodes(variant, entrypoint)? {
215 doc.nodes.insert(id, node);
216 }
217 }
218
219 let mut yaml = serde_yaml_bw::to_string(&doc).context("serialize scaffold flow")?;
220 if !yaml.ends_with('\n') {
221 yaml.push('\n');
222 }
223
224 let mut steps = Vec::new();
225 if let Some(parent) = flow_file.parent()
226 && !parent.as_os_str().is_empty()
227 {
228 steps.push(WizardPlanStep::EnsureDir {
229 path: parent.to_path_buf(),
230 });
231 }
232 steps.push(WizardPlanStep::WriteFile {
233 path: flow_file.clone(),
234 content: yaml,
235 });
236 if options.validate {
237 steps.push(WizardPlanStep::ValidateFlow { path: flow_file });
238 }
239
240 Ok(WizardPlan {
241 mode: mode.to_string(),
242 validate: options.validate,
243 steps,
244 })
245 }
246}
247
248pub fn execute_plan(plan: &WizardPlan) -> Result<()> {
249 for step in &plan.steps {
250 match step {
251 WizardPlanStep::EnsureDir { path } => {
252 fs::create_dir_all(path)
253 .with_context(|| format!("create scaffold directory {}", path.display()))?;
254 }
255 WizardPlanStep::WriteFile { path, content } => {
256 if let Some(parent) = path.parent()
257 && !parent.as_os_str().is_empty()
258 {
259 fs::create_dir_all(parent)
260 .with_context(|| format!("create parent directory {}", parent.display()))?;
261 }
262 fs::write(path, content)
263 .with_context(|| format!("write scaffold flow {}", path.display()))?;
264 }
265 WizardPlanStep::ValidateFlow { path } => {
266 validate_flow_file(path)?;
267 }
268 WizardPlanStep::RunCommand { command, .. } => {
269 return Err(anyhow!(
270 "run-command execution is not implemented in-process (command: {command})"
271 ));
272 }
273 }
274 }
275 Ok(())
276}
277
278fn validate_flow_file(path: &Path) -> Result<()> {
279 let doc = crate::loader::load_ygtc_from_path(path)
280 .map_err(|err| anyhow!("load scaffolded flow {}: {err}", path.display()))?;
281 let compiled = crate::compile_flow(doc)
282 .map_err(|err| anyhow!("compile scaffolded flow {}: {err}", path.display()))?;
283 let lint_errors = crate::lint::lint_builtin_rules(&compiled);
284 if lint_errors.is_empty() {
285 Ok(())
286 } else {
287 Err(anyhow!(
288 "scaffolded flow {} failed builtin lint: {}",
289 path.display(),
290 lint_errors.join("; ")
291 ))
292 }
293}
294
295fn starter_nodes(variant: &str, entrypoint: &str) -> Result<Vec<(String, NodeDoc)>> {
296 if entrypoint.trim().is_empty() {
297 return Err(anyhow!(
298 "flow.entrypoint cannot be empty when scaffolding nodes"
299 ));
300 }
301
302 let end_id = "end".to_string();
303 let mut nodes = Vec::new();
304
305 match variant {
306 "start-end" => {
307 nodes.push((
308 entrypoint.to_string(),
309 template_node("{\"stage\":\"start\"}", vec![route_to(&end_id)]),
310 ));
311 nodes.push((
312 end_id,
313 template_node("{\"stage\":\"end\"}", vec![route_out()]),
314 ));
315 }
316 "start-log-end" => {
317 let log_id = "log".to_string();
318 nodes.push((
319 entrypoint.to_string(),
320 template_node("{\"stage\":\"start\"}", vec![route_to(&log_id)]),
321 ));
322 nodes.push((
323 log_id,
324 template_node(
325 "{\"stage\":\"log\",\"message\":\"payload\"}",
326 vec![route_to("end")],
327 ),
328 ));
329 nodes.push((
330 end_id,
331 template_node("{\"stage\":\"end\"}", vec![route_out()]),
332 ));
333 }
334 other => {
335 return Err(anyhow!(
336 "unsupported flow.nodes.variant '{other}'; expected start-end or start-log-end"
337 ));
338 }
339 }
340
341 Ok(nodes)
342}
343
344fn template_node(template: &str, routing: Vec<Value>) -> NodeDoc {
345 let mut raw = IndexMap::new();
346 raw.insert("template".to_string(), Value::String(template.to_string()));
347 NodeDoc {
348 routing: Value::Array(routing),
349 telemetry: None,
350 operation: Some("template".to_string()),
351 payload: Value::String(template.to_string()),
352 raw,
353 }
354}
355
356fn route_to(to: &str) -> Value {
357 serde_json::json!({ "to": to })
358}
359
360fn route_out() -> Value {
361 serde_json::json!({ "out": true })
362}
363
364fn validate_mode(mode: &str) -> Result<()> {
365 if matches!(mode, MODE_SCAFFOLD | MODE_NEW) {
366 Ok(())
367 } else {
368 Err(anyhow!(
369 "unsupported wizard mode '{mode}'; expected '{MODE_SCAFFOLD}' or '{MODE_NEW}'"
370 ))
371 }
372}
373
374fn required_str<'a>(answers: &'a HashMap<String, Value>, key: &str) -> Result<&'a str> {
375 answers
376 .get(key)
377 .and_then(Value::as_str)
378 .filter(|v| !v.trim().is_empty())
379 .ok_or_else(|| anyhow!("missing required answer '{key}'"))
380}
381
382fn optional_str<'a>(answers: &'a HashMap<String, Value>, key: &str) -> Option<&'a str> {
383 answers
384 .get(key)
385 .and_then(Value::as_str)
386 .map(str::trim)
387 .filter(|v| !v.is_empty())
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 #[test]
395 fn spec_contains_stable_question_ids() {
396 let provider = wizard_provider();
397 let spec = provider
398 .spec(MODE_SCAFFOLD, &ProviderContext::default())
399 .unwrap();
400 let ids: Vec<&str> = spec.questions.iter().map(|q| q.id.as_str()).collect();
401 assert!(ids.contains(&"flow.name"));
402 assert!(ids.contains(&"flow.path"));
403 assert!(ids.contains(&"flow.entrypoint"));
404 assert!(ids.contains(&"flow.kind"));
405 assert!(ids.iter().any(|id| id.starts_with("flow.nodes.")));
406 }
407}