1use crate::version_text;
2use candid::Principal;
3use canic_installer::install_root::{DEFAULT_FLEET_NAME, InstallRootOptions, install_root};
4use std::{env, ffi::OsString};
5use thiserror::Error as ThisError;
6
7const DEFAULT_ROOT_TARGET: &str = "root";
8const DEFAULT_READY_TIMEOUT_SECONDS: u64 = 120;
9
10#[derive(Debug, ThisError)]
15pub enum InstallCommandError {
16 #[error("{0}")]
17 Usage(&'static str),
18
19 #[error("unknown option {0}")]
20 UnknownOption(String),
21
22 #[error("option {0} requires a value")]
23 MissingValue(&'static str),
24
25 #[error("cannot provide both positional root target and --root")]
26 ConflictingRootTarget,
27
28 #[error("invalid --ready-timeout-seconds value {0}")]
29 InvalidReadyTimeout(String),
30
31 #[error(transparent)]
32 Install(#[from] Box<dyn std::error::Error>),
33}
34
35#[derive(Clone, Debug, Eq, PartialEq)]
40pub struct InstallOptions {
41 pub fleet_name: String,
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 mut root_target = None;
56 let mut root_build_target = None;
57 let mut fleet_name =
58 env::var("CANIC_FLEET").unwrap_or_else(|_| DEFAULT_FLEET_NAME.to_string());
59 let mut network = env::var("DFX_NETWORK").unwrap_or_else(|_| "local".to_string());
60 let mut config_path = None;
61 let mut ready_timeout_seconds = env::var("READY_TIMEOUT_SECONDS")
62 .ok()
63 .and_then(|value| value.parse::<u64>().ok())
64 .unwrap_or(DEFAULT_READY_TIMEOUT_SECONDS);
65
66 let mut args = args.into_iter();
67 while let Some(arg) = args.next() {
68 let arg = arg
69 .into_string()
70 .map_err(|_| InstallCommandError::Usage(usage()))?;
71
72 if let Some(value) = arg.strip_prefix("--root=") {
73 set_root_target(&mut root_target, value.to_string())?;
74 continue;
75 }
76 if let Some(value) = arg.strip_prefix("--root-build-target=") {
77 root_build_target = Some(value.to_string());
78 continue;
79 }
80 if let Some(value) = arg.strip_prefix("--fleet=") {
81 fleet_name = value.to_string();
82 continue;
83 }
84 if let Some(value) = arg.strip_prefix("--network=") {
85 network = value.to_string();
86 continue;
87 }
88 if let Some(value) = arg.strip_prefix("--config=") {
89 config_path = Some(value.to_string());
90 continue;
91 }
92 if let Some(value) = arg.strip_prefix("--ready-timeout-seconds=") {
93 ready_timeout_seconds = parse_ready_timeout(value)?;
94 continue;
95 }
96
97 match arg.as_str() {
98 "--root" => {
99 let value = next_value(&mut args, "--root")?;
100 set_root_target(&mut root_target, value)?;
101 }
102 "--root-build-target" => {
103 root_build_target = Some(next_value(&mut args, "--root-build-target")?);
104 }
105 "--fleet" => {
106 fleet_name = next_value(&mut args, "--fleet")?;
107 }
108 "--network" => {
109 network = next_value(&mut args, "--network")?;
110 }
111 "--config" => {
112 config_path = Some(next_value(&mut args, "--config")?);
113 }
114 "--ready-timeout-seconds" => {
115 let value = next_value(&mut args, "--ready-timeout-seconds")?;
116 ready_timeout_seconds = parse_ready_timeout(&value)?;
117 }
118 "--help" | "-h" => return Err(InstallCommandError::Usage(usage())),
119 _ if arg.starts_with('-') => return Err(InstallCommandError::UnknownOption(arg)),
120 _ => set_root_target(&mut root_target, arg)?,
121 }
122 }
123
124 let root_target = root_target.unwrap_or_else(|| DEFAULT_ROOT_TARGET.to_string());
125 let root_build_target =
126 root_build_target.unwrap_or_else(|| default_root_build_target(&root_target));
127
128 Ok(Self {
129 fleet_name,
130 root_target,
131 root_build_target,
132 network,
133 ready_timeout_seconds,
134 config_path,
135 })
136 }
137
138 #[must_use]
140 pub fn into_install_root_options(self) -> InstallRootOptions {
141 InstallRootOptions {
142 fleet_name: self.fleet_name,
143 root_canister: self.root_target,
144 root_build_target: self.root_build_target,
145 network: self.network,
146 ready_timeout_seconds: self.ready_timeout_seconds,
147 config_path: self.config_path,
148 interactive_config_selection: true,
149 }
150 }
151}
152
153pub fn run<I>(args: I) -> Result<(), InstallCommandError>
155where
156 I: IntoIterator<Item = OsString>,
157{
158 let args = args.into_iter().collect::<Vec<_>>();
159 if args
160 .first()
161 .and_then(|arg| arg.to_str())
162 .is_some_and(|arg| matches!(arg, "help" | "--help" | "-h"))
163 {
164 println!("{}", usage());
165 return Ok(());
166 }
167 if args
168 .first()
169 .and_then(|arg| arg.to_str())
170 .is_some_and(|arg| matches!(arg, "version" | "--version" | "-V"))
171 {
172 println!("{}", version_text());
173 return Ok(());
174 }
175
176 let options = InstallOptions::parse(args)?;
177 install_root(options.into_install_root_options()).map_err(InstallCommandError::from)
178}
179
180fn set_root_target(target: &mut Option<String>, value: String) -> Result<(), InstallCommandError> {
182 if target.replace(value).is_some() {
183 return Err(InstallCommandError::ConflictingRootTarget);
184 }
185
186 Ok(())
187}
188
189fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, InstallCommandError>
191where
192 I: Iterator<Item = OsString>,
193{
194 args.next()
195 .and_then(|value| value.into_string().ok())
196 .ok_or(InstallCommandError::MissingValue(option))
197}
198
199fn parse_ready_timeout(value: &str) -> Result<u64, InstallCommandError> {
201 value
202 .parse::<u64>()
203 .map_err(|_| InstallCommandError::InvalidReadyTimeout(value.to_string()))
204}
205
206fn default_root_build_target(root_target: &str) -> String {
208 if Principal::from_text(root_target).is_ok() {
209 DEFAULT_ROOT_TARGET.to_string()
210 } else {
211 root_target.to_string()
212 }
213}
214
215const fn usage() -> &'static str {
217 "usage: canic install [root-target] [--fleet <name>] [--root <name-or-principal>] [--root-build-target <dfx-canister-name>] [--config <canic.toml>] [--network <name>] [--ready-timeout-seconds <seconds>]"
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 const ROOT_PRINCIPAL: &str = "uxrrr-q7777-77774-qaaaq-cai";
225
226 #[test]
228 fn install_defaults_to_root_target() {
229 let options = InstallOptions::parse([]).expect("parse defaults");
230
231 assert_eq!(options.root_target, "root");
232 assert_eq!(options.root_build_target, "root");
233 assert_eq!(options.fleet_name, DEFAULT_FLEET_NAME);
234 assert_eq!(options.network, "local");
235 assert_eq!(options.ready_timeout_seconds, DEFAULT_READY_TIMEOUT_SECONDS);
236 assert_eq!(options.config_path, None);
237 }
238
239 #[test]
241 fn install_accepts_positional_canister_name() {
242 let options =
243 InstallOptions::parse([OsString::from("custom_root")]).expect("parse root name");
244
245 assert_eq!(options.root_target, "custom_root");
246 assert_eq!(options.root_build_target, "custom_root");
247 }
248
249 #[test]
251 fn install_accepts_principal_target() {
252 let options =
253 InstallOptions::parse([OsString::from(ROOT_PRINCIPAL)]).expect("parse principal");
254
255 assert_eq!(options.root_target, ROOT_PRINCIPAL);
256 assert_eq!(options.root_build_target, "root");
257 }
258
259 #[test]
261 fn install_accepts_root_flag() {
262 let options = InstallOptions::parse([
263 OsString::from("--root"),
264 OsString::from(ROOT_PRINCIPAL),
265 OsString::from("--network"),
266 OsString::from("local"),
267 OsString::from("--ready-timeout-seconds"),
268 OsString::from("30"),
269 ])
270 .expect("parse root flag");
271
272 assert_eq!(options.root_target, ROOT_PRINCIPAL);
273 assert_eq!(options.root_build_target, "root");
274 assert_eq!(options.network, "local");
275 assert_eq!(options.ready_timeout_seconds, 30);
276 }
277
278 #[test]
280 fn install_accepts_config_path() {
281 let options = InstallOptions::parse([
282 OsString::from("--config"),
283 OsString::from("canisters/demo/canic.toml"),
284 OsString::from("--fleet"),
285 OsString::from("demo"),
286 ])
287 .expect("parse config path");
288
289 assert_eq!(
290 options.config_path,
291 Some("canisters/demo/canic.toml".to_string())
292 );
293 assert_eq!(options.fleet_name, "demo");
294 }
295
296 #[test]
298 fn install_accepts_explicit_root_build_target() {
299 let options = InstallOptions::parse([
300 OsString::from("--root"),
301 OsString::from(ROOT_PRINCIPAL),
302 OsString::from("--root-build-target"),
303 OsString::from("custom_root"),
304 ])
305 .expect("parse build target");
306
307 assert_eq!(options.root_target, ROOT_PRINCIPAL);
308 assert_eq!(options.root_build_target, "custom_root");
309 }
310
311 #[test]
313 fn install_rejects_duplicate_root_targets() {
314 let err = InstallOptions::parse([OsString::from("root"), OsString::from("--root=root")])
315 .expect_err("duplicate root target should fail");
316
317 assert!(matches!(err, InstallCommandError::ConflictingRootTarget));
318 }
319}