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#[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#[derive(Clone, Debug, Eq, PartialEq)]
49struct FleetOptions {
50 network: String,
51}
52
53#[derive(Clone, Debug, Eq, PartialEq)]
58struct UseFleetOptions {
59 fleet: String,
60 network: String,
61}
62
63pub 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
87pub 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 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 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
145fn fleets_command() -> ClapCommand {
147 ClapCommand::new("fleets")
148 .disable_help_flag(true)
149 .arg(value_arg("network").long("network"))
150}
151
152fn 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
160fn 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
187fn 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
198fn default_network() -> String {
200 env::var("DFX_NETWORK").unwrap_or_else(|_| "local".to_string())
201}
202
203const fn usage() -> &'static str {
205 "usage: canic fleets [--network <name>]"
206}
207
208const 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 #[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 #[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 #[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 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}