Skip to main content

canic_cli/install/
mod.rs

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///
11/// InstallCommandError
12///
13
14#[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///
36/// InstallOptions
37///
38
39#[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    /// Parse install options from CLI arguments and environment defaults.
51    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    /// Convert parsed CLI options into installer options.
139    #[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
153/// Run the root install workflow.
154pub 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
180// Set the root target once, accepting either a canister name or principal text.
181fn 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
189// Read the next required option value.
190fn 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
199// Parse the operator-supplied readiness timeout.
200fn parse_ready_timeout(value: &str) -> Result<u64, InstallCommandError> {
201    value
202        .parse::<u64>()
203        .map_err(|_| InstallCommandError::InvalidReadyTimeout(value.to_string()))
204}
205
206// Use the conventional root build target when the install target is a principal.
207fn 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
215// Return install command usage text.
216const 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    // Ensure install defaults to the conventional local root canister target.
227    #[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    // Ensure canister names are used for both build and install by default.
240    #[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    // Ensure principal targets still build the conventional root artifact.
250    #[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    // Ensure --root accepts the same target syntax as the positional argument.
260    #[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    // Ensure install accepts an explicit project config path.
279    #[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    // Ensure custom principal installs can override the build target explicitly.
297    #[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    // Ensure duplicate root target forms are rejected before mutation starts.
312    #[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}