1use crate::model::{ComparisonOptions, ExecutionPlan};
2use apif_ast::GctfDocument;
3use apif_optimizer as optimizer;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
7pub enum StreamingPattern {
8 Sequential,
9 Parallel,
10 Interleaved,
11}
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13pub struct ValidationResult {
14 pub passed: bool,
15 pub errors: Vec<String>,
16}
17
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub enum WorkflowEvent {
20 TestLoaded {
21 file_path: String,
22 },
23 Connect {
24 backend: String,
25 address: String,
26 },
27 Connected {
28 backend: String,
29 address: String,
30 },
31 LoadDescriptors {
32 backend: String,
33 service: String,
34 },
35 DescriptorsLoaded {
36 backend: String,
37 service: String,
38 method_count: usize,
39 },
40 SendRequest {
41 backend: String,
42 request_index: usize,
43 content_type: String,
44 line_range: (usize, usize),
45 },
46 RequestSent {
47 backend: String,
48 request_index: usize,
49 },
50 ReceiveResponse {
51 backend: String,
52 response_index: usize,
53 expectation_type: String,
54 },
55 ResponseReceived {
56 backend: String,
57 response_index: usize,
58 },
59 Assertion {
60 backend: String,
61 expression: String,
62 passed: bool,
63 line: usize,
64 },
65 Extraction {
66 backend: String,
67 variable: String,
68 value: String,
69 line: usize,
70 },
71 Error {
72 backend: String,
73 message: String,
74 },
75 TrailersReceived {
76 backend: String,
77 trailers: std::collections::HashMap<String, String>,
78 },
79 Done {
80 backend: String,
81 duration_ms: u64,
82 },
83 SemanticAnalysis {
84 type_mismatches: Vec<SemanticError>,
85 unknown_plugins: Vec<String>,
86 },
87 OptimizationFound {
88 hints: Vec<OptimizationHint>,
89 },
90 ValidationResult {
91 passed: bool,
92 errors: Vec<String>,
93 },
94}
95
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97pub struct SemanticError {
98 pub line: usize,
99 pub rule_id: String,
100 pub message: String,
101 pub expression: Option<String>,
102 pub plugin_name: Option<String>,
103}
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105pub struct OptimizationHint {
106 pub line: usize,
107 pub rule_id: String,
108 pub before: String,
109 pub after: String,
110}
111#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
112pub struct ResponseOptions {
113 pub partial: bool,
114 pub redact: Vec<String>,
115 pub has_tolerance: bool,
116 pub unordered_arrays: bool,
117 pub with_asserts: bool,
118}
119
120impl From<&ComparisonOptions> for ResponseOptions {
121 fn from(o: &ComparisonOptions) -> Self {
122 Self {
123 partial: o.partial,
124 redact: o.redact.clone(),
125 has_tolerance: o.tolerance.is_some(),
126 unordered_arrays: o.unordered_arrays,
127 with_asserts: o.with_asserts,
128 }
129 }
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct Workflow {
134 pub file_path: String,
135 pub events: Vec<WorkflowEvent>,
136 pub summary: WorkflowSummary,
137}
138impl Workflow {
139 pub fn has_streaming(&self) -> bool {
140 let rc = self
141 .events
142 .iter()
143 .filter(|e| matches!(e, WorkflowEvent::RequestSent { .. }))
144 .count();
145 let ec = self
146 .events
147 .iter()
148 .filter(|e| matches!(e, WorkflowEvent::ResponseReceived { .. }))
149 .count();
150 rc > 1 || ec > 1
151 }
152 pub fn rpc_mode_name(&self) -> &str {
153 let rc = self
154 .events
155 .iter()
156 .filter(|e| matches!(e, WorkflowEvent::RequestSent { .. }))
157 .count();
158 let ec = self
159 .events
160 .iter()
161 .filter(|e| matches!(e, WorkflowEvent::ResponseReceived { .. }))
162 .count();
163 if rc > 1 && ec == 1 {
164 return "Client Streaming";
165 }
166 if rc == 1 && ec > 1 {
167 return "Server Streaming";
168 }
169 if rc > 1 && ec > 1 {
170 return "Bidi Streaming";
171 }
172 "Unary"
173 }
174 pub fn from_plan(plan: &ExecutionPlan) -> Self {
175 let mut events = vec![
176 WorkflowEvent::TestLoaded {
177 file_path: plan.file_path.clone(),
178 },
179 WorkflowEvent::Connect {
180 backend: plan.connection.backend.clone(),
181 address: plan.connection.address.clone(),
182 },
183 WorkflowEvent::Connected {
184 backend: plan.connection.backend.clone(),
185 address: plan.connection.address.clone(),
186 },
187 ];
188 for r in &plan.requests {
189 events.push(WorkflowEvent::SendRequest {
190 backend: plan.connection.backend.clone(),
191 request_index: r.index,
192 content_type: r.content_type.clone(),
193 line_range: (r.line_start, r.line_end),
194 });
195 events.push(WorkflowEvent::RequestSent {
196 backend: plan.connection.backend.clone(),
197 request_index: r.index,
198 });
199 }
200 for e in &plan.expectations {
201 events.push(WorkflowEvent::ReceiveResponse {
202 backend: plan.connection.backend.clone(),
203 response_index: e.index,
204 expectation_type: e.expectation_type.clone(),
205 });
206 events.push(WorkflowEvent::ResponseReceived {
207 backend: plan.connection.backend.clone(),
208 response_index: e.index,
209 });
210 }
211 for a in &plan.assertions {
212 events.push(WorkflowEvent::Assertion {
213 backend: plan.connection.backend.clone(),
214 expression: a.assertions.first().cloned().unwrap_or_default(),
215 passed: true,
216 line: a.line_start,
217 });
218 }
219 for ex in &plan.extractions {
220 for (k, v) in &ex.variables {
221 events.push(WorkflowEvent::Extraction {
222 backend: plan.connection.backend.clone(),
223 variable: k.clone(),
224 value: v.clone(),
225 line: ex.line_start,
226 });
227 }
228 }
229 events.push(WorkflowEvent::Done {
230 backend: plan.connection.backend.clone(),
231 duration_ms: 0,
232 });
233
234 let summary = WorkflowSummary {
235 total_requests: plan.requests.len(),
236 total_responses: plan
237 .expectations
238 .iter()
239 .filter(|e| e.expectation_type == "response")
240 .count(),
241 total_extractions: plan.extractions.len(),
242 total_assertions: plan.assertions.len(),
243 backends: vec![plan.connection.backend.clone()],
244 rpc_mode: plan.summary.rpc_mode_name.clone(),
245 has_streaming: plan.requests.len() > 1 || plan.expectations.len() > 1,
246 has_bidi_streaming: plan.requests.len() > 1 && plan.expectations.len() > 1,
247 };
248 Self {
249 file_path: plan.file_path.clone(),
250 events,
251 summary,
252 }
253 }
254
255 pub fn from_document_with_analysis(doc: &GctfDocument) -> Self {
256 let file_path = doc.file_path.clone();
257 let mut events = vec![WorkflowEvent::TestLoaded {
258 file_path: file_path.clone(),
259 }];
260
261 events.push(WorkflowEvent::Connect {
262 backend: "default".into(),
263 address: String::new(),
264 });
265 events.push(WorkflowEvent::Connected {
266 backend: "default".into(),
267 address: String::new(),
268 });
269
270 if let Some(s) = doc.first_section(apif_ast::SectionType::Endpoint)
271 && let apif_ast::SectionContent::Single(e) = &s.content
272 {
273 events.push(WorkflowEvent::LoadDescriptors {
274 backend: "default".into(),
275 service: e.clone(),
276 });
277 }
278 events.push(WorkflowEvent::DescriptorsLoaded {
279 backend: "default".into(),
280 service: String::new(),
281 method_count: 0,
282 });
283
284 for (i, s) in doc
285 .sections_by_type(apif_ast::SectionType::Request)
286 .iter()
287 .enumerate()
288 {
289 let ct = match &s.content {
290 apif_ast::SectionContent::Json(_) => "json",
291 apif_ast::SectionContent::JsonLines(_) => "json-lines",
292 _ => "unknown",
293 };
294 events.push(WorkflowEvent::SendRequest {
295 backend: "default".into(),
296 request_index: i + 1,
297 content_type: ct.into(),
298 line_range: (s.start_line, s.end_line),
299 });
300 events.push(WorkflowEvent::RequestSent {
301 backend: "default".into(),
302 request_index: i + 1,
303 });
304 }
305 for (i, _s) in doc
306 .sections_by_type(apif_ast::SectionType::Response)
307 .iter()
308 .enumerate()
309 {
310 events.push(WorkflowEvent::ReceiveResponse {
311 backend: "default".into(),
312 response_index: i + 1,
313 expectation_type: "response".into(),
314 });
315 events.push(WorkflowEvent::ResponseReceived {
316 backend: "default".into(),
317 response_index: i + 1,
318 });
319 }
320 if let Some(s) = doc.first_section(apif_ast::SectionType::Error) {
321 events.push(WorkflowEvent::Error {
322 backend: "default".into(),
323 message: format!("Error at line {}", s.start_line),
324 });
325 }
326 for s in doc.sections_by_type(apif_ast::SectionType::Asserts) {
327 if let apif_ast::SectionContent::Assertions(lines) = &s.content {
328 for line in lines {
329 events.push(WorkflowEvent::Assertion {
330 backend: "default".into(),
331 expression: line.clone(),
332 passed: true,
333 line: s.start_line,
334 });
335 }
336 }
337 }
338 for s in doc.sections_by_type(apif_ast::SectionType::Extract) {
339 if let apif_ast::SectionContent::Extract(vars) = &s.content {
340 for (k, v) in vars {
341 events.push(WorkflowEvent::Extraction {
342 backend: "default".into(),
343 variable: k.clone(),
344 value: v.clone(),
345 line: s.start_line,
346 });
347 }
348 }
349 }
350
351 if let Some(mismatches) = doc
352 .first_section(apif_ast::SectionType::Meta)
353 .and_then(|_| {
354 let tm = apif_semantics::collect_assertion_type_mismatches(doc);
355 let up = apif_semantics::collect_unknown_plugin_calls(doc);
356 if tm.is_empty() && up.is_empty() {
357 None
358 } else {
359 Some((
360 tm.into_iter()
361 .map(|u| SemanticError {
362 line: u.line,
363 rule_id: u.rule_id.clone(),
364 message: u.message,
365 expression: Some(u.expression),
366 plugin_name: Some(u.rule_id.clone()),
367 })
368 .collect(),
369 up.into_iter().map(|u| u.plugin_name).collect(),
370 ))
371 }
372 })
373 {
374 events.push(WorkflowEvent::SemanticAnalysis {
375 type_mismatches: mismatches.0,
376 unknown_plugins: mismatches.1,
377 });
378 }
379
380 let hints: Vec<OptimizationHint> = apif_optimizer::collect_assertion_optimizations(
381 doc,
382 optimizer::OptimizeLevel::Advisory,
383 )
384 .into_iter()
385 .map(|h| OptimizationHint {
386 line: h.line,
387 rule_id: h.rule_id.to_string(),
388 before: h.before,
389 after: h.after,
390 })
391 .collect();
392 if !hints.is_empty() {
393 events.push(WorkflowEvent::OptimizationFound { hints });
394 }
395
396 let validation = apif_parser::validate_document(doc);
397 events.push(WorkflowEvent::ValidationResult {
398 passed: validation.is_ok(),
399 errors: validation
400 .err()
401 .into_iter()
402 .map(|e| e.to_string())
403 .collect(),
404 });
405
406 let plan = ExecutionPlan::from_document(doc);
407 events.push(WorkflowEvent::Done {
408 backend: "default".into(),
409 duration_ms: 0,
410 });
411
412 let summary = WorkflowSummary {
413 total_requests: plan.requests.len(),
414 total_responses: plan
415 .expectations
416 .iter()
417 .filter(|e| e.expectation_type == "response")
418 .count(),
419 total_extractions: plan.extractions.len(),
420 total_assertions: plan.assertions.len(),
421 backends: vec!["default".into()],
422 rpc_mode: plan.summary.rpc_mode_name.clone(),
423 has_streaming: plan.requests.len() > 1 || plan.expectations.len() > 1,
424 has_bidi_streaming: plan.requests.len() > 1 && plan.expectations.len() > 1,
425 };
426 Self {
427 file_path,
428 events,
429 summary,
430 }
431 }
432}
433
434#[derive(Debug, Clone, Default, Serialize, Deserialize)]
435pub struct WorkflowSummary {
436 pub total_requests: usize,
437 pub total_responses: usize,
438 pub total_extractions: usize,
439 pub total_assertions: usize,
440 pub backends: Vec<String>,
441 pub rpc_mode: String,
442 pub has_streaming: bool,
443 pub has_bidi_streaming: bool,
444}