Skip to main content

canic_cli/fleets/
mod.rs

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