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 candid::Principal;
9use canic_host::install_root::{InstallRootOptions, install_root};
10use clap::{Arg, Command as ClapCommand};
11use std::{env, ffi::OsString};
12use thiserror::Error as ThisError;
13
14const DEFAULT_ROOT_TARGET: &str = "root";
15const DEFAULT_READY_TIMEOUT_SECONDS: u64 = 120;
16
17#[derive(Debug, ThisError)]
22pub enum InstallCommandError {
23 #[error("{0}")]
24 Usage(&'static str),
25
26 #[error("cannot provide both positional root target and --root")]
27 ConflictingRootTarget,
28
29 #[error("invalid --ready-timeout-seconds value {0}")]
30 InvalidReadyTimeout(String),
31
32 #[error(transparent)]
33 Install(#[from] Box<dyn std::error::Error>),
34}
35
36#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct InstallOptions {
42 pub root_target: String,
43 pub root_build_target: String,
44 pub network: String,
45 pub ready_timeout_seconds: u64,
46 pub config_path: Option<String>,
47}
48
49impl InstallOptions {
50 pub fn parse<I>(args: I) -> Result<Self, InstallCommandError>
52 where
53 I: IntoIterator<Item = OsString>,
54 {
55 let matches = parse_matches(install_command(), args)
56 .map_err(|_| InstallCommandError::Usage(usage()))?;
57 let positional_targets = string_values(&matches, "root-target");
58 let flag_target = string_option(&matches, "root");
59 let root_target = resolve_root_target(positional_targets, flag_target)?;
60 let root_build_target = string_option(&matches, "root-build-target")
61 .unwrap_or_else(|| default_root_build_target(&root_target));
62 let ready_timeout_seconds = string_option(&matches, "ready-timeout-seconds")
63 .map(|value| parse_ready_timeout(&value))
64 .transpose()?
65 .unwrap_or_else(default_ready_timeout_seconds);
66
67 Ok(Self {
68 root_target,
69 root_build_target,
70 network: string_option(&matches, "network").unwrap_or_else(default_network),
71 ready_timeout_seconds,
72 config_path: string_option(&matches, "config"),
73 })
74 }
75
76 #[must_use]
78 pub fn into_install_root_options(self) -> InstallRootOptions {
79 InstallRootOptions {
80 root_canister: self.root_target,
81 root_build_target: self.root_build_target,
82 network: self.network,
83 ready_timeout_seconds: self.ready_timeout_seconds,
84 config_path: self.config_path,
85 interactive_config_selection: true,
86 }
87 }
88}
89
90fn install_command() -> ClapCommand {
92 ClapCommand::new("install")
93 .disable_help_flag(true)
94 .arg(Arg::new("root-target").num_args(0..))
95 .arg(value_arg("root").long("root"))
96 .arg(value_arg("root-build-target").long("root-build-target"))
97 .arg(value_arg("config").long("config"))
98 .arg(value_arg("network").long("network"))
99 .arg(value_arg("ready-timeout-seconds").long("ready-timeout-seconds"))
100}
101
102pub fn run<I>(args: I) -> Result<(), InstallCommandError>
104where
105 I: IntoIterator<Item = OsString>,
106{
107 let args = args.into_iter().collect::<Vec<_>>();
108 if first_arg_is_help(&args) {
109 println!("{}", usage());
110 return Ok(());
111 }
112 if first_arg_is_version(&args) {
113 println!("{}", version_text());
114 return Ok(());
115 }
116
117 let options = InstallOptions::parse(args)?;
118 install_root(options.into_install_root_options()).map_err(InstallCommandError::from)
119}
120
121fn resolve_root_target(
123 positional_targets: Vec<String>,
124 flag_target: Option<String>,
125) -> Result<String, InstallCommandError> {
126 match (positional_targets.as_slice(), flag_target) {
127 ([], None) => Ok(DEFAULT_ROOT_TARGET.to_string()),
128 ([], Some(target)) => Ok(target),
129 ([target], None) => Ok(target.clone()),
130 _ => Err(InstallCommandError::ConflictingRootTarget),
131 }
132}
133
134fn parse_ready_timeout(value: &str) -> Result<u64, InstallCommandError> {
136 value
137 .parse::<u64>()
138 .map_err(|_| InstallCommandError::InvalidReadyTimeout(value.to_string()))
139}
140
141fn default_network() -> String {
143 env::var("DFX_NETWORK").unwrap_or_else(|_| "local".to_string())
144}
145
146fn default_ready_timeout_seconds() -> u64 {
148 env::var("READY_TIMEOUT_SECONDS")
149 .ok()
150 .and_then(|value| value.parse::<u64>().ok())
151 .unwrap_or(DEFAULT_READY_TIMEOUT_SECONDS)
152}
153
154fn default_root_build_target(root_target: &str) -> String {
156 if Principal::from_text(root_target).is_ok() {
157 DEFAULT_ROOT_TARGET.to_string()
158 } else {
159 root_target.to_string()
160 }
161}
162
163const fn usage() -> &'static str {
165 "usage: canic install [root-target] [--root <name-or-principal>] [--root-build-target <dfx-canister-name>] [--config <canic.toml>] [--network <name>] [--ready-timeout-seconds <seconds>]"
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 const ROOT_PRINCIPAL: &str = "uxrrr-q7777-77774-qaaaq-cai";
173
174 #[test]
176 fn install_defaults_to_root_target() {
177 let options = InstallOptions::parse([]).expect("parse defaults");
178
179 assert_eq!(options.root_target, "root");
180 assert_eq!(options.root_build_target, "root");
181 assert_eq!(options.network, "local");
182 assert_eq!(options.ready_timeout_seconds, DEFAULT_READY_TIMEOUT_SECONDS);
183 assert_eq!(options.config_path, None);
184 }
185
186 #[test]
188 fn install_accepts_positional_canister_name() {
189 let options =
190 InstallOptions::parse([OsString::from("custom_root")]).expect("parse root name");
191
192 assert_eq!(options.root_target, "custom_root");
193 assert_eq!(options.root_build_target, "custom_root");
194 }
195
196 #[test]
198 fn install_accepts_principal_target() {
199 let options =
200 InstallOptions::parse([OsString::from(ROOT_PRINCIPAL)]).expect("parse principal");
201
202 assert_eq!(options.root_target, ROOT_PRINCIPAL);
203 assert_eq!(options.root_build_target, "root");
204 }
205
206 #[test]
208 fn install_accepts_root_flag() {
209 let options = InstallOptions::parse([
210 OsString::from("--root"),
211 OsString::from(ROOT_PRINCIPAL),
212 OsString::from("--network"),
213 OsString::from("local"),
214 OsString::from("--ready-timeout-seconds"),
215 OsString::from("30"),
216 ])
217 .expect("parse root flag");
218
219 assert_eq!(options.root_target, ROOT_PRINCIPAL);
220 assert_eq!(options.root_build_target, "root");
221 assert_eq!(options.network, "local");
222 assert_eq!(options.ready_timeout_seconds, 30);
223 }
224
225 #[test]
227 fn install_accepts_config_path() {
228 let options = InstallOptions::parse([
229 OsString::from("--config"),
230 OsString::from("canisters/demo/canic.toml"),
231 ])
232 .expect("parse config path");
233
234 assert_eq!(
235 options.config_path,
236 Some("canisters/demo/canic.toml".to_string())
237 );
238 }
239
240 #[test]
242 fn install_rejects_fleet_flag() {
243 let err = InstallOptions::parse([OsString::from("--fleet"), OsString::from("demo")])
244 .expect_err("install fleet flag should fail");
245
246 assert!(matches!(err, InstallCommandError::Usage(_)));
247 }
248
249 #[test]
251 fn install_accepts_explicit_root_build_target() {
252 let options = InstallOptions::parse([
253 OsString::from("--root"),
254 OsString::from(ROOT_PRINCIPAL),
255 OsString::from("--root-build-target"),
256 OsString::from("custom_root"),
257 ])
258 .expect("parse build target");
259
260 assert_eq!(options.root_target, ROOT_PRINCIPAL);
261 assert_eq!(options.root_build_target, "custom_root");
262 }
263
264 #[test]
266 fn install_rejects_duplicate_root_targets() {
267 let err = InstallOptions::parse([OsString::from("root"), OsString::from("--root=root")])
268 .expect_err("duplicate root target should fail");
269
270 assert!(matches!(err, InstallCommandError::ConflictingRootTarget));
271 }
272}