Coverage Report

Created: 2026-05-19 10:01

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
crates/flotilla-commands/src/commands/convoy.rs
Line
Count
Source
1
use clap::{Parser, Subcommand};
2
use flotilla_protocol::{Command, CommandAction};
3
4
use crate::{
5
    quote::quote_value,
6
    resolved::{HostResolution, RepoContext},
7
    Resolved,
8
};
9
10
#[derive(Debug, Clone, PartialEq, Eq, Parser)]
11
#[command(about = "Manage convoys")]
12
pub struct ConvoyNoun {
13
    /// Convoy name
14
    pub subject: String,
15
16
    #[command(subcommand)]
17
    pub verb: ConvoyVerb,
18
}
19
20
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
21
pub enum ConvoyVerb {
22
    /// Manage convoy tasks
23
    Task(ConvoyTaskNoun),
24
    /// Create a convoy from a workflow template
25
    Create {
26
        /// Workflow template to instantiate
27
        #[arg(long)]
28
        template: String,
29
        /// Input value (repeatable): --input key=value
30
        #[arg(long = "input", value_parser = parse_input_kv)]
31
        inputs: Vec<(String, String)>,
32
        /// Repository URL the workflow operates on
33
        #[arg(long = "repo")]
34
        repository_url: Option<String>,
35
        /// Git ref (branch/tag/commit) within the repository
36
        #[arg(long = "ref")]
37
        r#ref: Option<String>,
38
    },
39
}
40
41
6
fn parse_input_kv(raw: &str) -> Result<(String, String), String> {
42
6
    let (key, value) = raw.split_once('=').ok_or_else(|| 
format!0
("input must be key=value: {raw}"))
?0
;
43
6
    if key.is_empty() {
44
0
        return Err(format!("input key cannot be empty: {raw}"));
45
6
    }
46
6
    Ok((key.to_string(), value.to_string()))
47
6
}
48
49
#[derive(Debug, Clone, PartialEq, Eq, Parser)]
50
pub struct ConvoyTaskNoun {
51
    /// Task name
52
    pub subject: String,
53
54
    #[command(subcommand)]
55
    pub verb: ConvoyTaskVerb,
56
}
57
58
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
59
pub enum ConvoyTaskVerb {
60
    /// Mark a convoy task complete
61
    Complete {
62
        /// Optional completion message recorded on the task
63
        #[arg(long)]
64
        message: Option<String>,
65
    },
66
}
67
68
impl ConvoyNoun {
69
6
    pub fn resolve(self) -> Result<Resolved, String> {
70
6
        match self.verb {
71
4
            ConvoyVerb::Task(task) => match task.verb {
72
4
                ConvoyTaskVerb::Complete { message } => Ok(Resolved::NeedsContext {
73
4
                    command: Command {
74
4
                        node_id: None,
75
4
                        provisioning_target: None,
76
4
                        context_repo: None,
77
4
                        action: CommandAction::ConvoyTaskComplete { convoy: self.subject, task: task.subject, message },
78
4
                    },
79
4
                    repo: RepoContext::None,
80
4
                    host: HostResolution::Local,
81
4
                }),
82
            },
83
2
            ConvoyVerb::Create { template, inputs, repository_url, r#ref } => Ok(Resolved::NeedsContext {
84
2
                command: Command {
85
2
                    node_id: None,
86
2
                    provisioning_target: None,
87
2
                    context_repo: None,
88
2
                    action: CommandAction::ConvoyCreate { name: self.subject, workflow_ref: template, inputs, repository_url, r#ref },
89
2
                },
90
2
                repo: RepoContext::None,
91
2
                host: HostResolution::Local,
92
2
            }),
93
        }
94
6
    }
95
}
96
97
impl std::fmt::Display for ConvoyNoun {
98
3
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99
3
        write!(f, "convoy {}", self.subject)
?0
;
100
3
        match &self.verb {
101
1
            ConvoyVerb::Task(task) => {
102
1
                write!(f, " task {}", task.subject)
?0
;
103
1
                match &task.verb {
104
1
                    ConvoyTaskVerb::Complete { message } => {
105
1
                        write!(f, " complete")
?0
;
106
1
                        if let Some(
message0
) = message {
107
0
                            write!(f, " --message {message}")?;
108
1
                        }
109
                    }
110
                }
111
            }
112
2
            ConvoyVerb::Create { template, inputs, repository_url, r#ref } => {
113
2
                write!(f, " create --template {}", quote_value(template))
?0
;
114
2
                for (k, v) in inputs {
115
2
                    write!(f, " --input {}", quote_value(&format!("{k}={v}")))
?0
;
116
                }
117
2
                if let Some(url) = repository_url {
118
2
                    write!(f, " --repo {}", quote_value(url))
?0
;
119
0
                }
120
2
                if let Some(
reference1
) = r#ref {
121
1
                    write!(f, " --ref {}", quote_value(reference))
?0
;
122
1
                }
123
            }
124
        }
125
3
        Ok(())
126
3
    }
127
}
128
129
#[cfg(test)]
130
mod tests {
131
    use clap::Parser;
132
    use flotilla_protocol::{Command, CommandAction};
133
134
    use super::ConvoyNoun;
135
    use crate::{
136
        resolved::{HostResolution, RepoContext},
137
        test_utils::assert_round_trip,
138
        Resolved,
139
    };
140
141
5
    fn parse(args: &[&str]) -> ConvoyNoun {
142
5
        ConvoyNoun::try_parse_from(args).expect("should parse")
143
5
    }
144
145
    #[test]
146
1
    fn convoy_task_complete_resolves() {
147
1
        let resolved = parse(&["convoy", "convoy-a", "task", "implement", "complete"]).resolve().expect("resolve");
148
1
        assert_eq!(resolved, Resolved::NeedsContext {
149
1
            command: Command {
150
1
                node_id: None,
151
1
                provisioning_target: None,
152
1
                context_repo: None,
153
1
                action: CommandAction::ConvoyTaskComplete { convoy: "convoy-a".into(), task: "implement".into(), message: None },
154
1
            },
155
1
            repo: RepoContext::None,
156
1
            host: HostResolution::Local,
157
1
        });
158
1
    }
159
160
    #[test]
161
1
    fn convoy_task_complete_with_message_resolves() {
162
1
        let resolved = parse(&["convoy", "convoy-a", "task", "implement", "complete", "--message", "done"]).resolve().expect("resolve");
163
1
        assert_eq!(resolved, Resolved::NeedsContext {
164
1
            command: Command {
165
1
                node_id: None,
166
1
                provisioning_target: None,
167
1
                context_repo: None,
168
1
                action: CommandAction::ConvoyTaskComplete {
169
1
                    convoy: "convoy-a".into(),
170
1
                    task: "implement".into(),
171
1
                    message: Some("done".into()),
172
1
                },
173
1
            },
174
1
            repo: RepoContext::None,
175
1
            host: HostResolution::Local,
176
1
        });
177
1
    }
178
179
    #[test]
180
1
    fn round_trip_complete() {
181
1
        assert_round_trip::<ConvoyNoun>(&["convoy", "convoy-a", "task", "implement", "complete"]);
182
1
    }
183
184
    #[test]
185
1
    fn convoy_create_resolves() {
186
1
        let resolved = parse(&[
187
1
            "convoy",
188
1
            "my-convoy",
189
1
            "create",
190
1
            "--template",
191
1
            "scratch",
192
1
            "--input",
193
1
            "topic=demo",
194
1
            "--input",
195
1
            "branch=foo",
196
1
            "--repo",
197
1
            "https://github.com/flotilla-org/flotilla.git",
198
1
            "--ref",
199
1
            "main",
200
1
        ])
201
1
        .resolve()
202
1
        .expect("resolve");
203
1
        assert_eq!(resolved, Resolved::NeedsContext {
204
1
            command: Command {
205
1
                node_id: None,
206
1
                provisioning_target: None,
207
1
                context_repo: None,
208
1
                action: CommandAction::ConvoyCreate {
209
1
                    name: "my-convoy".into(),
210
1
                    workflow_ref: "scratch".into(),
211
1
                    inputs: vec![("topic".into(), "demo".into()), ("branch".into(), "foo".into())],
212
1
                    repository_url: Some("https://github.com/flotilla-org/flotilla.git".into()),
213
1
                    r#ref: Some("main".into()),
214
1
                },
215
1
            },
216
1
            repo: RepoContext::None,
217
1
            host: HostResolution::Local,
218
1
        });
219
1
    }
220
221
    #[test]
222
1
    fn convoy_create_minimal_resolves() {
223
1
        let resolved = parse(&["convoy", "scratch-1", "create", "--template", "scratch"]).resolve().expect("resolve");
224
1
        assert_eq!(resolved, Resolved::NeedsContext {
225
1
            command: Command {
226
1
                node_id: None,
227
1
                provisioning_target: None,
228
1
                context_repo: None,
229
1
                action: CommandAction::ConvoyCreate {
230
1
                    name: "scratch-1".into(),
231
1
                    workflow_ref: "scratch".into(),
232
1
                    inputs: vec![],
233
1
                    repository_url: None,
234
1
                    r#ref: None,
235
1
                },
236
1
            },
237
1
            repo: RepoContext::None,
238
1
            host: HostResolution::Local,
239
1
        });
240
1
    }
241
242
    #[test]
243
1
    fn round_trip_create() {
244
1
        assert_round_trip::<ConvoyNoun>(&[
245
1
            "convoy",
246
1
            "my-convoy",
247
1
            "create",
248
1
            "--template",
249
1
            "scratch",
250
1
            "--input",
251
1
            "topic=demo",
252
1
            "--repo",
253
1
            "https://example.com/repo.git",
254
1
            "--ref",
255
1
            "main",
256
1
        ]);
257
1
    }
258
259
    #[test]
260
1
    fn create_display_quotes_values_with_whitespace() {
261
1
        let parsed = parse(&[
262
1
            "convoy",
263
1
            "my-convoy",
264
1
            "create",
265
1
            "--template",
266
1
            "scratch",
267
1
            "--input",
268
1
            "topic=my work",
269
1
            "--repo",
270
1
            "https://example.com/path with space.git",
271
1
        ]);
272
1
        let displayed = parsed.to_string();
273
1
        assert!(displayed.contains("--input \"topic=my work\""), "expected quoted input in {displayed:?}");
274
1
        assert!(displayed.contains("--repo \"https://example.com/path with space.git\""), "expected quoted repo in {displayed:?}");
275
1
    }
276
}