Skip to main content

greentic_flow/info/
report.rs

1use anyhow::Result;
2use greentic_types::Flow;
3use greentic_types::flow_resolve::{FlowResolveV1, read_flow_resolve, sidecar_path_for_flow};
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct InfoReport {
9    pub info_schema_version: u32,
10    pub id: String,
11    pub kind: String,
12    pub title: Option<String>,
13    pub description: Option<String>,
14    pub tags: Vec<String>,
15    pub resolve: ResolveStatus,
16    pub entrypoints: Vec<EntrypointInfo>,
17    pub nodes: Vec<NodeInfo>,
18    pub parameters: Vec<ParameterInfo>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ResolveStatus {
23    /// "bound" | "partial" | "unbound"
24    pub status: String,
25    pub sidecar_path: Option<String>,
26    pub resolved_nodes: u32,
27    pub total_nodes: u32,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct EntrypointInfo {
32    pub name: String,
33    pub target: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct NodeInfo {
38    pub id: String,
39    pub component_id: String,
40    pub operation: Option<String>,
41    pub pack_alias: Option<String>,
42    pub routing: String,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ParameterInfo {
47    pub name: String,
48    #[serde(rename = "type")]
49    pub ty: String,
50    pub required: bool,
51}
52
53impl InfoReport {
54    pub fn from_flow(flow: &Flow, flow_path: &Path) -> Result<Self> {
55        let sidecar_path = sidecar_path_for_flow(flow_path);
56        let sidecar: Option<FlowResolveV1> = if sidecar_path.exists() {
57            read_flow_resolve(&sidecar_path).ok()
58        } else {
59            None
60        };
61
62        let total = flow.nodes.len() as u32;
63        let (status, resolved) = match &sidecar {
64            None => ("unbound".to_string(), 0u32),
65            Some(s) => {
66                let r = s.nodes.len() as u32;
67                if r == total {
68                    ("bound".to_string(), r)
69                } else {
70                    ("partial".to_string(), r)
71                }
72            }
73        };
74
75        Ok(Self {
76            info_schema_version: 1,
77            id: flow.id.as_str().to_string(),
78            kind: format!("{:?}", flow.kind).to_lowercase(),
79            title: flow.metadata.title.clone(),
80            description: flow.metadata.description.clone(),
81            tags: flow.metadata.tags.iter().cloned().collect(),
82            resolve: ResolveStatus {
83                status,
84                sidecar_path: sidecar.as_ref().map(|_| sidecar_path.display().to_string()),
85                resolved_nodes: resolved,
86                total_nodes: total,
87            },
88            entrypoints: flow
89                .entrypoints
90                .iter()
91                .map(|(name, target_val)| EntrypointInfo {
92                    name: name.clone(),
93                    target: target_val
94                        .as_str()
95                        .map(|s| s.to_string())
96                        .unwrap_or_default(),
97                })
98                .collect(),
99            nodes: flow
100                .nodes
101                .iter()
102                .map(|(id, n)| NodeInfo {
103                    id: id.as_str().to_string(),
104                    component_id: n.component.id.as_str().to_string(),
105                    operation: n.component.operation.clone(),
106                    pack_alias: n.component.pack_alias.clone(),
107                    routing: format!("{:?}", n.routing),
108                })
109                .collect(),
110            parameters: parameters_from_extra(&flow.metadata.extra),
111        })
112    }
113}
114
115fn parameters_from_extra(extra: &serde_json::Value) -> Vec<ParameterInfo> {
116    // metadata.extra is expected to be a JSON object where keys are parameter
117    // names and values are small schema objects with "type" and optional "required".
118    // Flows without parameters have extra == null or {} — return empty in those cases.
119    let map = match extra.as_object() {
120        Some(m) => m,
121        None => return vec![],
122    };
123    map.iter()
124        .map(|(name, v)| ParameterInfo {
125            name: name.clone(),
126            ty: v
127                .get("type")
128                .and_then(|t| t.as_str())
129                .unwrap_or("unknown")
130                .to_string(),
131            required: v.get("required").and_then(|r| r.as_bool()).unwrap_or(true),
132        })
133        .collect()
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use std::io::Write;
140
141    #[test]
142    fn json_has_schema_version_one() {
143        let r = InfoReport {
144            info_schema_version: 1,
145            id: "x".into(),
146            kind: "messaging".into(),
147            title: None,
148            description: None,
149            tags: vec![],
150            resolve: ResolveStatus {
151                status: "unbound".into(),
152                sidecar_path: None,
153                resolved_nodes: 0,
154                total_nodes: 0,
155            },
156            entrypoints: vec![],
157            nodes: vec![],
158            parameters: vec![],
159        };
160        let v: serde_json::Value = serde_json::to_value(&r).unwrap();
161        assert_eq!(v["info_schema_version"], 1);
162        assert_eq!(v["kind"], "messaging");
163    }
164
165    #[test]
166    fn unbound_flow_reports_unbound_and_zero_resolved() {
167        let tmp = tempfile::TempDir::new().unwrap();
168        let path = tmp.path().join("flow.ygtc");
169        // Minimal valid .ygtc v1 flow: flat top-level metadata, flat nodes
170        // with an operation key (`qa.process`) and an `out: true` routing.
171        let yaml = r#"id: ex
172title: Example
173type: messaging
174nodes:
175  in:
176    qa.process:
177      welcome: "hello"
178    routing:
179      - out: true
180"#;
181        std::fs::File::create(&path)
182            .unwrap()
183            .write_all(yaml.as_bytes())
184            .unwrap();
185        let flow = crate::compile_ygtc_file(&path).expect("compile flow fixture");
186        let info = InfoReport::from_flow(&flow, &path).expect("from_flow");
187        assert_eq!(info.id, "ex");
188        assert_eq!(info.title.as_deref(), Some("Example"));
189        assert_eq!(info.resolve.status, "unbound");
190        assert_eq!(info.resolve.total_nodes, 1);
191        assert_eq!(info.resolve.resolved_nodes, 0);
192        // compile_flow injects a "default" entrypoint pointing at the first
193        // node when no explicit entrypoints are authored.
194        assert_eq!(info.entrypoints.len(), 1);
195        assert_eq!(info.entrypoints[0].name, "default");
196        assert_eq!(info.entrypoints[0].target, "in");
197        assert_eq!(info.nodes.len(), 1);
198        assert_eq!(info.nodes[0].id, "in");
199        // The loader defaults unset `schema_version` to 2 (see `loader.rs`),
200        // so a dot-operation like `qa.process` is rewritten to the generic
201        // `component.exec` component with `operation = Some("qa.process")`.
202        assert_eq!(info.nodes[0].component_id, "component.exec");
203        assert_eq!(info.nodes[0].operation.as_deref(), Some("qa.process"));
204    }
205}