Skip to main content

canic_cli/fleets/
mod.rs

1use crate::{
2    args::{
3        default_network, first_arg_is_help, first_arg_is_version, parse_matches, string_option,
4        string_values, value_arg,
5    },
6    version_text,
7};
8use canic_host::install_root::{
9    FleetSummary, InstallState, list_current_fleets, select_current_fleet,
10};
11use canic_host::table::WhitespaceTable;
12use clap::{Arg, Command as ClapCommand};
13use std::ffi::OsString;
14use thiserror::Error as ThisError;
15
16const CURRENT_HEADER: &str = "CURRENT";
17const FLEET_HEADER: &str = "FLEET";
18const NETWORK_HEADER: &str = "NETWORK";
19const ROOT_HEADER: &str = "ROOT";
20const CONFIG_HEADER: &str = "CONFIG";
21const FLEETS_HELP_AFTER: &str = "\
22Examples:
23  canic fleets
24  canic fleets --network local";
25const USE_HELP_AFTER: &str = "\
26Examples:
27  canic use demo
28  canic use staging --network local";
29
30///
31/// FleetCommandError
32///
33
34#[derive(Debug, ThisError)]
35pub enum FleetCommandError {
36    #[error("{0}")]
37    Usage(String),
38
39    #[error("missing fleet name")]
40    MissingFleetName,
41
42    #[error("multiple fleet names provided")]
43    ConflictingFleetName,
44
45    #[error("no Canic fleets are installed for network {0}; run canic install --config <path>")]
46    NoFleets(String),
47
48    #[error(transparent)]
49    Installer(#[from] Box<dyn std::error::Error>),
50}
51
52///
53/// FleetOptions
54///
55
56#[derive(Clone, Debug, Eq, PartialEq)]
57struct FleetOptions {
58    network: String,
59}
60
61///
62/// UseFleetOptions
63///
64
65#[derive(Clone, Debug, Eq, PartialEq)]
66struct UseFleetOptions {
67    fleet: String,
68    network: String,
69}
70
71/// Run the fleet listing command.
72pub fn run<I>(args: I) -> Result<(), FleetCommandError>
73where
74    I: IntoIterator<Item = OsString>,
75{
76    let args = args.into_iter().collect::<Vec<_>>();
77    if first_arg_is_help(&args) {
78        println!("{}", usage());
79        return Ok(());
80    }
81    if first_arg_is_version(&args) {
82        println!("{}", version_text());
83        return Ok(());
84    }
85
86    let options = FleetOptions::parse(args)?;
87    let fleets = list_current_fleets(&options.network)?;
88    if fleets.is_empty() {
89        return Err(FleetCommandError::NoFleets(options.network));
90    }
91    println!("{}", render_fleets(&fleets));
92    Ok(())
93}
94
95/// Run the current fleet selection command.
96pub fn run_use<I>(args: I) -> Result<(), FleetCommandError>
97where
98    I: IntoIterator<Item = OsString>,
99{
100    let args = args.into_iter().collect::<Vec<_>>();
101    if first_arg_is_help(&args) {
102        println!("{}", use_usage());
103        return Ok(());
104    }
105    if first_arg_is_version(&args) {
106        println!("{}", version_text());
107        return Ok(());
108    }
109
110    let options = UseFleetOptions::parse(args)?;
111    let state = select_current_fleet(&options.network, &options.fleet)?;
112    println!("{}", render_selected_fleet(&state));
113    Ok(())
114}
115
116impl FleetOptions {
117    // Parse fleet listing options.
118    fn parse<I>(args: I) -> Result<Self, FleetCommandError>
119    where
120        I: IntoIterator<Item = OsString>,
121    {
122        let matches =
123            parse_matches(fleets_command(), args).map_err(|_| FleetCommandError::Usage(usage()))?;
124
125        Ok(Self {
126            network: string_option(&matches, "network").unwrap_or_else(default_network),
127        })
128    }
129}
130
131impl UseFleetOptions {
132    // Parse current fleet selection options.
133    fn parse<I>(args: I) -> Result<Self, FleetCommandError>
134    where
135        I: IntoIterator<Item = OsString>,
136    {
137        let matches = parse_matches(use_fleet_command(), args)
138            .map_err(|_| FleetCommandError::Usage(use_usage()))?;
139        let fleet_names = string_values(&matches, "fleet");
140        let fleet = match fleet_names.as_slice() {
141            [] => return Err(FleetCommandError::MissingFleetName),
142            [fleet] => fleet.clone(),
143            _ => return Err(FleetCommandError::ConflictingFleetName),
144        };
145
146        Ok(Self {
147            fleet,
148            network: string_option(&matches, "network").unwrap_or_else(default_network),
149        })
150    }
151}
152
153// Build the fleet list parser.
154fn fleets_command() -> ClapCommand {
155    ClapCommand::new("fleets")
156        .bin_name("canic fleets")
157        .about("List installed Canic fleets")
158        .disable_help_flag(true)
159        .arg(
160            value_arg("network")
161                .long("network")
162                .value_name("name")
163                .help("DFX network to inspect"),
164        )
165        .after_help(FLEETS_HELP_AFTER)
166}
167
168// Build the current-fleet selection parser.
169fn use_fleet_command() -> ClapCommand {
170    ClapCommand::new("use")
171        .bin_name("canic use")
172        .about("Select the current Canic fleet")
173        .disable_help_flag(true)
174        .arg(
175            Arg::new("fleet")
176                .num_args(0..=1)
177                .value_name("name")
178                .help("Installed fleet name to make current"),
179        )
180        .arg(
181            value_arg("network")
182                .long("network")
183                .value_name("name")
184                .help("DFX network to update"),
185        )
186        .after_help(USE_HELP_AFTER)
187}
188
189// Render installed fleets as a compact whitespace table.
190fn render_fleets(fleets: &[FleetSummary]) -> String {
191    let rows = fleets
192        .iter()
193        .map(|fleet| {
194            (
195                if fleet.current { "*" } else { "" },
196                fleet.name.as_str(),
197                fleet.state.network.as_str(),
198                fleet.state.root_canister_id.as_str(),
199                fleet.state.config_path.as_str(),
200            )
201        })
202        .collect::<Vec<_>>();
203    let mut table = WhitespaceTable::new([
204        CURRENT_HEADER,
205        FLEET_HEADER,
206        NETWORK_HEADER,
207        ROOT_HEADER,
208        CONFIG_HEADER,
209    ]);
210    for row in rows {
211        table.push_row([row.0, row.1, row.2, row.3, row.4]);
212    }
213    table.render()
214}
215
216// Render the newly selected fleet.
217fn render_selected_fleet(state: &InstallState) -> String {
218    let mut table = WhitespaceTable::new([FLEET_HEADER, NETWORK_HEADER, ROOT_HEADER]);
219    table.push_row([
220        state.fleet.as_str(),
221        state.network.as_str(),
222        state.root_canister_id.as_str(),
223    ]);
224    ["Current fleet:".to_string(), table.render()].join("\n")
225}
226
227// Return fleet list usage text.
228fn usage() -> String {
229    let mut command = fleets_command();
230    command.render_help().to_string()
231}
232
233// Return fleet selection usage text.
234fn use_usage() -> String {
235    let mut command = use_fleet_command();
236    command.render_help().to_string()
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    const ROOT: &str = "uxrrr-q7777-77774-qaaaq-cai";
244
245    // Ensure fleet listing options accept network selection.
246    #[test]
247    fn parses_fleet_options() {
248        let options = FleetOptions::parse([OsString::from("--network"), OsString::from("ic")])
249            .expect("parse fleet options");
250
251        assert_eq!(options.network, "ic");
252    }
253
254    // Ensure fleet use options require exactly one fleet name.
255    #[test]
256    fn parses_use_fleet_options() {
257        let options = UseFleetOptions::parse([
258            OsString::from("demo"),
259            OsString::from("--network"),
260            OsString::from("local"),
261        ])
262        .expect("parse use options");
263
264        assert_eq!(options.fleet, "demo");
265        assert_eq!(options.network, "local");
266    }
267
268    // Ensure fleet listing renders deterministic whitespace columns.
269    #[test]
270    fn renders_fleets_table() {
271        let fleets = vec![summary("demo", true), summary("staging", false)];
272
273        assert_eq!(
274            render_fleets(&fleets),
275            format!(
276                "{:<7}  {:<7}  {:<7}  {:<27}  {}\n{:<7}  {:<7}  {:<7}  {:<27}  {}\n{:<7}  {:<7}  {:<7}  {:<27}  {}",
277                "CURRENT",
278                "FLEET",
279                "NETWORK",
280                "ROOT",
281                "CONFIG",
282                "*",
283                "demo",
284                "local",
285                ROOT,
286                "canisters/demo/canic.toml",
287                "",
288                "staging",
289                "local",
290                ROOT,
291                "canisters/staging/canic.toml",
292            )
293        );
294    }
295
296    // Ensure fleet command help is no longer a terse one-line usage string.
297    #[test]
298    fn fleet_usage_lists_options_and_examples() {
299        let text = usage();
300
301        assert!(text.contains("List installed Canic fleets"));
302        assert!(text.contains("Usage: canic fleets"));
303        assert!(text.contains("--network <name>"));
304        assert!(text.contains("Examples:"));
305    }
306
307    // Ensure current-fleet help renders the singular fleet argument.
308    #[test]
309    fn use_usage_lists_singular_fleet_argument() {
310        let text = use_usage();
311
312        assert!(text.contains("Select the current Canic fleet"));
313        assert!(text.contains("Usage: canic use"));
314        assert!(text.contains("[name]"));
315        assert!(!text.contains("[name]..."));
316    }
317
318    // Build a representative fleet summary.
319    fn summary(name: &str, current: bool) -> FleetSummary {
320        FleetSummary {
321            name: name.to_string(),
322            current,
323            state: InstallState {
324                schema_version: 1,
325                fleet: name.to_string(),
326                installed_at_unix_secs: 42,
327                network: "local".to_string(),
328                root_target: "root".to_string(),
329                root_canister_id: ROOT.to_string(),
330                root_build_target: "root".to_string(),
331                workspace_root: "/tmp/canic".to_string(),
332                dfx_root: "/tmp/canic".to_string(),
333                config_path: format!("canisters/{name}/canic.toml"),
334                release_set_manifest_path: ".dfx/local/root.release-set.json".to_string(),
335            },
336        }
337    }
338}