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#[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#[derive(Clone, Debug, Eq, PartialEq)]
57struct FleetOptions {
58 network: String,
59}
60
61#[derive(Clone, Debug, Eq, PartialEq)]
66struct UseFleetOptions {
67 fleet: String,
68 network: String,
69}
70
71pub 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
95pub 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 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 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
153fn 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
168fn 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
189fn 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
216fn 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
227fn usage() -> String {
229 let mut command = fleets_command();
230 command.render_help().to_string()
231}
232
233fn 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 #[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 #[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 #[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 #[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 #[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 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}