Skip to main content

canic_cli/fleets/
mod.rs

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