Skip to main content

greentic_flow/wizard/
mod.rs

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}