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#[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#[derive(Clone, Debug, Eq, PartialEq)]
47struct FleetOptions {
48 network: String,
49}
50
51#[derive(Clone, Debug, Eq, PartialEq)]
56struct UseFleetOptions {
57 fleet: String,
58 network: String,
59}
60
61pub 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
93pub 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 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 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
181fn 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
213fn 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
229fn 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
238fn 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
247fn 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
257fn default_network() -> String {
259 env::var("DFX_NETWORK").unwrap_or_else(|_| "local".to_string())
260}
261
262const fn usage() -> &'static str {
264 "usage: canic fleets [--network <name>]"
265}
266
267const 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 #[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 #[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 #[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 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}