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 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 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 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 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 assert_eq!(info.nodes[0].component_id, "component.exec");
203 assert_eq!(info.nodes[0].operation.as_deref(), Some("qa.process"));
204 }
205}