Coverage Report

Created: 2026-05-19 10:01

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
crates/flotilla-resources/src/workflow_template.rs
Line
Count
Source
1
use std::collections::{BTreeMap, BTreeSet};
2
3
use serde::{Deserialize, Serialize};
4
5
use crate::{resource::define_resource, status_patch::NoStatusPatch};
6
7
define_resource!(WorkflowTemplate, "workflowtemplates", WorkflowTemplateSpec, (), NoStatusPatch);
8
9
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
10
pub struct WorkflowTemplateSpec {
11
    #[builder(default)]
12
    #[serde(default)]
13
    pub inputs: Vec<InputDefinition>,
14
    pub tasks: Vec<TaskDefinition>,
15
}
16
17
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18
pub struct InputDefinition {
19
    pub name: String,
20
    #[serde(default)]
21
    pub description: Option<String>,
22
}
23
24
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
25
pub struct TaskDefinition {
26
    pub name: String,
27
    #[builder(default)]
28
    #[serde(default)]
29
    pub depends_on: Vec<String>,
30
    pub processes: Vec<ProcessDefinition>,
31
}
32
33
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
34
pub struct ProcessDefinition {
35
    pub role: String,
36
    #[serde(flatten)]
37
    pub source: ProcessSource,
38
    #[builder(default)]
39
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
40
    pub labels: BTreeMap<String, String>,
41
}
42
43
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44
#[serde(untagged, deny_unknown_fields)]
45
pub enum ProcessSource {
46
    Agent {
47
        selector: Selector,
48
        #[serde(default)]
49
        prompt: Option<String>,
50
    },
51
    Tool {
52
        command: String,
53
    },
54
}
55
56
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57
pub struct Selector {
58
    pub capability: String,
59
}
60
61
#[derive(Debug, Clone, PartialEq, Eq)]
62
pub enum ValidationError {
63
    DuplicateTaskName { name: String },
64
    DuplicateRoleInTask { task: String, role: String },
65
    ReservedLabelKey { task: String, role: String, key: String },
66
    UnknownDependency { task: String, missing: String },
67
    DependencyCycle { cycle: Vec<String> },
68
    DuplicateInputName { name: String },
69
    MalformedInterpolation { location: InterpolationLocation, text: String },
70
    UnknownInputReference { location: InterpolationLocation, name: String },
71
    UnknownWorkflowField { location: InterpolationLocation, name: String },
72
}
73
74
#[derive(Debug, Clone, PartialEq, Eq)]
75
pub struct InterpolationLocation {
76
    pub task: String,
77
    pub role: String,
78
    pub field: InterpolationField,
79
}
80
81
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82
pub enum InterpolationField {
83
    Prompt,
84
    Command,
85
}
86
87
impl std::fmt::Display for InterpolationField {
88
0
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89
0
        match self {
90
0
            InterpolationField::Prompt => f.write_str("prompt"),
91
0
            InterpolationField::Command => f.write_str("command"),
92
        }
93
0
    }
94
}
95
96
impl std::fmt::Display for InterpolationLocation {
97
0
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98
0
        write!(f, "task `{}` role `{}` {}", self.task, self.role, self.field)
99
0
    }
100
}
101
102
impl std::fmt::Display for ValidationError {
103
0
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104
0
        match self {
105
0
            ValidationError::DuplicateTaskName { name } => write!(f, "duplicate task name `{name}`"),
106
0
            ValidationError::DuplicateRoleInTask { task, role } => write!(f, "duplicate role `{role}` in task `{task}`"),
107
0
            ValidationError::ReservedLabelKey { task, role, key } => {
108
0
                write!(f, "reserved label key `{key}` on task `{task}` role `{role}`")
109
            }
110
0
            ValidationError::UnknownDependency { task, missing } => write!(f, "task `{task}` depends on unknown task `{missing}`"),
111
0
            ValidationError::DependencyCycle { cycle } => write!(f, "dependency cycle: {}", cycle.join(" -> ")),
112
0
            ValidationError::DuplicateInputName { name } => write!(f, "duplicate input name `{name}`"),
113
0
            ValidationError::MalformedInterpolation { location, text } => {
114
0
                write!(f, "malformed interpolation `{{{{{text}}}}}` at {location}")
115
            }
116
0
            ValidationError::UnknownInputReference { location, name } => write!(f, "unknown input `{name}` at {location}"),
117
0
            ValidationError::UnknownWorkflowField { location, name } => write!(f, "unknown workflow field `{name}` at {location}"),
118
        }
119
0
    }
120
}
121
122
#[derive(Clone, Copy, PartialEq, Eq)]
123
enum VisitState {
124
    Visiting,
125
    Visited,
126
}
127
128
31
pub fn validate(spec: &WorkflowTemplateSpec) -> Result<(), Vec<ValidationError>> {
129
31
    let mut errors = Vec::new();
130
31
    let declared_inputs = collect_inputs(spec, &mut errors);
131
31
    let tasks_by_name = collect_tasks(spec, &mut errors);
132
133
59
    for task in 
&spec.tasks31
{
134
59
        validate_task(task, &declared_inputs, &tasks_by_name, &mut errors);
135
59
    }
136
31
    validate_cycles(&tasks_by_name, &mut errors);
137
138
31
    if errors.is_empty() {
139
13
        Ok(())
140
    } else {
141
18
        Err(errors)
142
    }
143
31
}
144
145
31
fn collect_inputs(spec: &WorkflowTemplateSpec, errors: &mut Vec<ValidationError>) -> BTreeSet<String> {
146
31
    let mut declared_inputs = BTreeSet::new();
147
45
    for input in 
&spec.inputs31
{
148
45
        if !declared_inputs.insert(input.name.clone()) {
149
2
            push_error(errors, ValidationError::DuplicateInputName { name: input.name.clone() });
150
43
        }
151
    }
152
31
    declared_inputs
153
31
}
154
155
31
fn collect_tasks<'a>(spec: &'a WorkflowTemplateSpec, errors: &mut Vec<ValidationError>) -> BTreeMap<String, &'a TaskDefinition> {
156
31
    let mut tasks_by_name = BTreeMap::new();
157
59
    for task in 
&spec.tasks31
{
158
59
        if tasks_by_name.insert(task.name.clone(), task).is_some() {
159
2
            push_error(errors, ValidationError::DuplicateTaskName { name: task.name.clone() });
160
57
        }
161
    }
162
31
    tasks_by_name
163
31
}
164
165
59
fn validate_task(
166
59
    task: &TaskDefinition,
167
59
    declared_inputs: &BTreeSet<String>,
168
59
    tasks_by_name: &BTreeMap<String, &TaskDefinition>,
169
59
    errors: &mut Vec<ValidationError>,
170
59
) {
171
59
    let mut roles = BTreeSet::new();
172
59
    for 
dependency28
in &task.depends_on {
173
28
        if !tasks_by_name.contains_key(dependency) {
174
3
            push_error(errors, ValidationError::UnknownDependency { task: task.name.clone(), missing: dependency.clone() });
175
25
        }
176
    }
177
178
106
    for process in 
&task.processes59
{
179
106
        if !roles.insert(process.role.clone()) {
180
2
            push_error(errors, ValidationError::DuplicateRoleInTask { task: task.name.clone(), role: process.role.clone() });
181
104
        }
182
183
106
        for 
key3
in process.labels.keys() {
184
3
            if key.starts_with(crate::labels::RESERVED_PREFIX) {
185
1
                push_error(errors, ValidationError::ReservedLabelKey {
186
1
                    task: task.name.clone(),
187
1
                    role: process.role.clone(),
188
1
                    key: key.clone(),
189
1
                });
190
2
            }
191
        }
192
193
106
        match &process.source {
194
43
            ProcessSource::Agent { prompt, .. } => {
195
43
                if let Some(prompt) = prompt {
196
43
                    validate_template_text(
197
43
                        prompt,
198
43
                        &InterpolationLocation { task: task.name.clone(), role: process.role.clone(), field: InterpolationField::Prompt },
199
43
                        declared_inputs,
200
43
                        errors,
201
43
                    );
202
43
                
}0
203
            }
204
63
            ProcessSource::Tool { command } => validate_template_text(
205
63
                command,
206
63
                &InterpolationLocation { task: task.name.clone(), role: process.role.clone(), field: InterpolationField::Command },
207
63
                declared_inputs,
208
63
                errors,
209
            ),
210
        }
211
    }
212
59
}
213
214
31
fn validate_cycles(tasks_by_name: &BTreeMap<String, &TaskDefinition>, errors: &mut Vec<ValidationError>) {
215
31
    let mut states = BTreeMap::new();
216
31
    let mut stack = Vec::new();
217
218
57
    for task_name in 
tasks_by_name31
.
keys31
() {
219
57
        visit_task(task_name, tasks_by_name, &mut states, &mut stack, errors);
220
57
    }
221
31
}
222
223
80
fn visit_task(
224
80
    task_name: &str,
225
80
    tasks_by_name: &BTreeMap<String, &TaskDefinition>,
226
80
    states: &mut BTreeMap<String, VisitState>,
227
80
    stack: &mut Vec<String>,
228
80
    errors: &mut Vec<ValidationError>,
229
80
) {
230
80
    match states.get(task_name) {
231
23
        Some(VisitState::Visited) => return,
232
57
        None => {}
233
0
        Some(VisitState::Visiting) => unreachable!("cycle detection handles visiting dependencies before recursion"),
234
    }
235
236
57
    states.insert(task_name.to_string(), VisitState::Visiting);
237
57
    stack.push(task_name.to_string());
238
239
57
    if let Some(task) = tasks_by_name.get(task_name) {
240
57
        let mut dependencies = task.depends_on.iter().map(String::as_str).collect::<Vec<_>>();
241
57
        dependencies.sort_unstable();
242
57
        for 
dependency28
in dependencies {
243
28
            if !tasks_by_name.contains_key(dependency) {
244
3
                continue;
245
25
            }
246
247
25
            if states.get(dependency) == Some(&VisitState::Visiting) {
248
2
                if let Some(index) = stack.iter().position(|name| name == dependency) {
249
2
                    let mut cycle = stack[index..].to_vec();
250
2
                    cycle.push(dependency.to_string());
251
2
                    push_error(errors, ValidationError::DependencyCycle { cycle });
252
2
                
}0
253
2
                continue;
254
23
            }
255
256
23
            visit_task(dependency, tasks_by_name, states, stack, errors);
257
        }
258
0
    }
259
260
57
    stack.pop();
261
57
    states.insert(task_name.to_string(), VisitState::Visited);
262
80
}
263
264
106
fn validate_template_text(
265
106
    text: &str,
266
106
    location: &InterpolationLocation,
267
106
    declared_inputs: &BTreeSet<String>,
268
106
    errors: &mut Vec<ValidationError>,
269
106
) {
270
106
    let mut search_from = 0;
271
180
    while let Some(
open_offset74
) = text[search_from..].find("{{") {
272
74
        let open = search_from + open_offset;
273
74
        let token_start = open + 2;
274
74
        match text[token_start..].find("}}") {
275
74
            Some(close_offset) => {
276
74
                let token_end = token_start + close_offset;
277
74
                validate_token(&text[token_start..token_end], location, declared_inputs, errors);
278
74
                search_from = token_end + 2;
279
74
            }
280
            None => {
281
0
                let token = &text[token_start..];
282
0
                if is_owned_token(token) {
283
0
                    push_error(errors, ValidationError::MalformedInterpolation { location: location.clone(), text: token.to_string() });
284
0
                }
285
0
                break;
286
            }
287
        }
288
    }
289
106
}
290
291
74
fn validate_token(token: &str, location: &InterpolationLocation, declared_inputs: &BTreeSet<String>, errors: &mut Vec<ValidationError>) {
292
74
    if !is_owned_token(token) {
293
2
        return;
294
72
    }
295
296
72
    if token.chars().any(char::is_whitespace) {
297
2
        push_error(errors, ValidationError::MalformedInterpolation { location: location.clone(), text: token.to_string() });
298
2
        return;
299
70
    }
300
301
70
    let segments = token.split('.').collect::<Vec<_>>();
302
142
    if 
segments.iter()70
.
any70
(|segment| segment.is_empty() || !segment.chars().all(is_valid_segment_char)) {
303
0
        push_error(errors, ValidationError::MalformedInterpolation { location: location.clone(), text: token.to_string() });
304
0
        return;
305
70
    }
306
307
70
    match segments.as_slice() {
308
68
        ["inputs", 
input_name40
] if !
declared_inputs40
.
contains40
(
*input_name40
)2
=> {
309
2
            push_error(errors, ValidationError::UnknownInputReference { location: location.clone(), name: (*input_name).to_string() });
310
2
        }
311
38
        ["inputs", _] => {}
312
28
        ["workflow", "name"] | ["workflow", 
"namespace"11
] =>
{}26
313
2
        ["workflow", field] => {
314
2
            push_error(errors, ValidationError::UnknownWorkflowField { location: location.clone(), name: (*field).to_string() })
315
        }
316
2
        [prefix, ..] if *prefix == "inputs" || *prefix == "workflow" => {
317
2
            push_error(errors, ValidationError::MalformedInterpolation { location: location.clone(), text: token.to_string() })
318
        }
319
0
        _ => {}
320
    }
321
74
}
322
323
74
fn is_owned_token(token: &str) -> bool {
324
74
    
matches!72
(token.split('.').next(), Some("inputs" |
"workflow"32
))
325
74
}
326
327
911
fn is_valid_segment_char(ch: char) -> bool {
328
911
    ch.is_ascii_alphanumeric() || 
ch == '_'0
||
ch == '-'0
329
911
}
330
331
20
fn push_error(errors: &mut Vec<ValidationError>, error: ValidationError) {
332
20
    if !errors.contains(&error) {
333
20
        errors.push(error);
334
20
    
}0
335
20
}
336
337
#[cfg(test)]
338
mod tests {
339
    use super::{
340
        validate, InputDefinition, InterpolationField, InterpolationLocation, ProcessDefinition, ProcessSource, Selector, TaskDefinition,
341
        ValidationError, WorkflowTemplateSpec,
342
    };
343
344
9
    fn valid_spec() -> WorkflowTemplateSpec {
345
9
        WorkflowTemplateSpec::builder()
346
9
            .inputs(vec![InputDefinition { name: "feature".to_string(), description: None }])
347
9
            .tasks(vec![
348
9
                TaskDefinition::builder()
349
9
                    .name("implement".to_string())
350
9
                    .processes(vec![
351
9
                        ProcessDefinition::builder()
352
9
                            .role("coder".to_string())
353
9
                            .source(ProcessSource::Agent {
354
9
                                selector: Selector { capability: "code".to_string() },
355
9
                                prompt: Some("Implement {{inputs.feature}} for {{workflow.name}}".to_string()),
356
9
                            })
357
9
                            .build(),
358
9
                        ProcessDefinition::builder()
359
9
                            .role("build".to_string())
360
9
                            .source(ProcessSource::Tool { command: "cargo check".to_string() })
361
9
                            .build(),
362
                    ])
363
9
                    .build(),
364
9
                TaskDefinition::builder()
365
9
                    .name("review".to_string())
366
9
                    .depends_on(vec!["implement".to_string()])
367
9
                    .processes(vec![ProcessDefinition::builder()
368
9
                        .role("reviewer".to_string())
369
9
                        .source(ProcessSource::Agent {
370
9
                            selector: Selector { capability: "code-review".to_string() },
371
9
                            prompt: Some("Review {{workflow.namespace}}".to_string()),
372
9
                        })
373
9
                        .build()])
374
9
                    .build(),
375
            ])
376
9
            .build()
377
9
    }
378
379
    #[test]
380
1
    fn validate_rejects_duplicate_task_names() {
381
1
        let mut spec = valid_spec();
382
1
        spec.tasks.push(spec.tasks[0].clone());
383
384
1
        let errors = validate(&spec).expect_err("duplicate task names should fail");
385
1
        assert!(errors.contains(&ValidationError::DuplicateTaskName { name: "implement".to_string() }));
386
1
    }
387
388
    #[test]
389
1
    fn validate_rejects_duplicate_role_names_within_task() {
390
1
        let mut spec = valid_spec();
391
1
        spec.tasks[0].processes.push(
392
1
            ProcessDefinition::builder()
393
1
                .role("coder".to_string())
394
1
                .source(ProcessSource::Tool { command: "cargo test".to_string() })
395
1
                .build(),
396
        );
397
398
1
        let errors = validate(&spec).expect_err("duplicate role names should fail");
399
1
        assert!(errors.contains(&ValidationError::DuplicateRoleInTask { task: "implement".to_string(), role: "coder".to_string() }));
400
1
    }
401
402
    #[test]
403
1
    fn validate_rejects_unknown_dependencies() {
404
1
        let mut spec = valid_spec();
405
1
        spec.tasks[1].depends_on = vec!["missing".to_string()];
406
407
1
        let errors = validate(&spec).expect_err("unknown dependencies should fail");
408
1
        assert!(errors.contains(&ValidationError::UnknownDependency { task: "review".to_string(), missing: "missing".to_string() }));
409
1
    }
410
411
    #[test]
412
1
    fn validate_rejects_cycles() {
413
1
        let mut spec = valid_spec();
414
1
        spec.tasks[0].depends_on = vec!["review".to_string()];
415
416
1
        let errors = validate(&spec).expect_err("cycles should fail");
417
1
        assert!(errors.contains(&ValidationError::DependencyCycle {
418
1
            cycle: vec!["implement".to_string(), "review".to_string(), "implement".to_string()],
419
1
        }));
420
1
    }
421
422
    #[test]
423
1
    fn validate_rejects_duplicate_input_names() {
424
1
        let mut spec = valid_spec();
425
1
        spec.inputs.push(InputDefinition { name: "feature".to_string(), description: Some("duplicate".to_string()) });
426
427
1
        let errors = validate(&spec).expect_err("duplicate inputs should fail");
428
1
        assert!(errors.contains(&ValidationError::DuplicateInputName { name: "feature".to_string() }));
429
1
    }
430
431
    #[test]
432
1
    fn validate_rejects_unknown_input_references() {
433
1
        let mut spec = valid_spec();
434
1
        spec.tasks[0].processes[0].source = ProcessSource::Agent {
435
1
            selector: Selector { capability: "code".to_string() },
436
1
            prompt: Some("Implement {{inputs.branch}}".to_string()),
437
1
        };
438
439
1
        let errors = validate(&spec).expect_err("unknown input references should fail");
440
1
        assert!(errors.contains(&ValidationError::UnknownInputReference {
441
1
            location: InterpolationLocation { task: "implement".to_string(), role: "coder".to_string(), field: InterpolationField::Prompt },
442
1
            name: "branch".to_string(),
443
1
        }));
444
1
    }
445
446
    #[test]
447
1
    fn validate_rejects_unknown_workflow_fields() {
448
1
        let mut spec = valid_spec();
449
1
        spec.tasks[0].processes[0].source = ProcessSource::Agent {
450
1
            selector: Selector { capability: "code".to_string() },
451
1
            prompt: Some("Implement {{workflow.uid}}".to_string()),
452
1
        };
453
454
1
        let errors = validate(&spec).expect_err("unknown workflow fields should fail");
455
1
        assert!(errors.contains(&ValidationError::UnknownWorkflowField {
456
1
            location: InterpolationLocation { task: "implement".to_string(), role: "coder".to_string(), field: InterpolationField::Prompt },
457
1
            name: "uid".to_string(),
458
1
        }));
459
1
    }
460
461
    #[test]
462
1
    fn validate_rejects_malformed_owned_interpolations() {
463
1
        let mut spec = valid_spec();
464
1
        spec.tasks[0].processes[0].source = ProcessSource::Agent {
465
1
            selector: Selector { capability: "code".to_string() },
466
1
            prompt: Some("Implement {{inputs.feature }} and {{workflow.name.extra}}".to_string()),
467
1
        };
468
469
1
        let errors = validate(&spec).expect_err("malformed owned interpolation should fail");
470
1
        assert!(errors.contains(&ValidationError::MalformedInterpolation {
471
1
            location: InterpolationLocation { task: "implement".to_string(), role: "coder".to_string(), field: InterpolationField::Prompt },
472
1
            text: "inputs.feature ".to_string(),
473
1
        }));
474
1
        assert!(errors.contains(&ValidationError::MalformedInterpolation {
475
1
            location: InterpolationLocation { task: "implement".to_string(), role: "coder".to_string(), field: InterpolationField::Prompt },
476
1
            text: "workflow.name.extra".to_string(),
477
1
        }));
478
1
    }
479
480
    #[test]
481
1
    fn validate_allows_foreign_interpolations() {
482
1
        let mut spec = valid_spec();
483
1
        spec.tasks[0].processes[1].source =
484
1
            ProcessSource::Tool { command: "kubectl get pod -o go-template='{{.metadata.name}}'".to_string() };
485
486
1
        assert!(validate(&spec).is_ok(), "foreign interpolations should pass through");
487
1
    }
488
}