Skip to main content

canic_installer/
install_root.rs

1use crate::release_set::{
2    configured_install_targets, configured_release_roles, dfx_call, dfx_root,
3    emit_root_release_set_manifest_with_config, load_root_release_set_manifest,
4    resolve_artifact_root, resume_root_bootstrap, root_release_set_manifest_path,
5    stage_root_release_set, workspace_root,
6};
7use crate::workspace_discovery::normalize_workspace_path;
8use canic::cdk::types::Principal;
9use canic_core::protocol;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use std::{
13    env, fs,
14    io::{self, IsTerminal, Write},
15    path::{Path, PathBuf},
16    process::Command,
17    thread,
18    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
19};
20
21///
22/// InstallRootOptions
23///
24
25#[derive(Clone, Debug)]
26pub struct InstallRootOptions {
27    pub fleet_name: String,
28    pub root_canister: String,
29    pub root_build_target: String,
30    pub network: String,
31    pub ready_timeout_seconds: u64,
32    pub config_path: Option<String>,
33    pub interactive_config_selection: bool,
34}
35
36///
37/// InstallState
38///
39
40#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
41pub struct InstallState {
42    pub schema_version: u32,
43    #[serde(default = "default_fleet_name")]
44    pub fleet: String,
45    pub installed_at_unix_secs: u64,
46    pub network: String,
47    pub root_target: String,
48    pub root_canister_id: String,
49    pub root_build_target: String,
50    pub workspace_root: String,
51    pub dfx_root: String,
52    pub config_path: String,
53    pub release_set_manifest_path: String,
54}
55
56///
57/// BootstrapStatusSnapshot
58///
59
60#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
61struct BootstrapStatusSnapshot {
62    ready: bool,
63    phase: String,
64    last_error: Option<String>,
65}
66
67///
68/// InstallTimingSummary
69///
70
71#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
72struct InstallTimingSummary {
73    create_canisters: Duration,
74    build_all: Duration,
75    emit_manifest: Duration,
76    fabricate_cycles: Duration,
77    install_root: Duration,
78    stage_release_set: Duration,
79    resume_bootstrap: Duration,
80    wait_ready: Duration,
81}
82
83///
84/// ConfigChoiceRow
85///
86
87struct ConfigChoiceRow {
88    option: String,
89    config: String,
90    canisters: String,
91}
92
93const LOCAL_ROOT_TARGET_CYCLES: u128 = 9_000_000_000_000_000;
94const LOCAL_DFX_READY_TIMEOUT_SECONDS: u64 = 30;
95const INSTALL_STATE_SCHEMA_VERSION: u32 = 1;
96const INSTALL_STATE_FILE: &str = "install-state.json";
97pub const DEFAULT_FLEET_NAME: &str = "default";
98const CURRENT_FLEET_FILE: &str = "current-fleet";
99const CONFIG_CHOICE_ROLE_PREVIEW_LIMIT: usize = 5;
100
101impl InstallRootOptions {
102    // Resolve the current local-root install options from args and environment.
103    #[must_use]
104    pub fn from_env_and_args() -> Self {
105        let root_canister = env::args()
106            .nth(1)
107            .or_else(|| env::var("ROOT_CANISTER").ok())
108            .unwrap_or_else(|| "root".to_string());
109
110        Self {
111            fleet_name: env::var("CANIC_FLEET").unwrap_or_else(|_| DEFAULT_FLEET_NAME.to_string()),
112            root_build_target: env::var("ROOT_BUILD_TARGET")
113                .ok()
114                .unwrap_or_else(|| root_canister.clone()),
115            root_canister,
116            network: env::var("DFX_NETWORK").unwrap_or_else(|_| "local".to_string()),
117            ready_timeout_seconds: env::var("READY_TIMEOUT_SECONDS")
118                .ok()
119                .and_then(|value| value.parse::<u64>().ok())
120                .unwrap_or(120),
121            config_path: None,
122            interactive_config_selection: true,
123        }
124    }
125}
126
127// Execute the local thin-root install flow against an already running replica.
128pub fn install_root(options: InstallRootOptions) -> Result<(), Box<dyn std::error::Error>> {
129    validate_fleet_name(&options.fleet_name)?;
130    let workspace_root = workspace_root()?;
131    let dfx_root = dfx_root()?;
132    let config_path = resolve_install_config_path(
133        &workspace_root,
134        options.config_path.as_deref(),
135        options.interactive_config_selection,
136    )?;
137    let total_started_at = Instant::now();
138    let mut timings = InstallTimingSummary::default();
139
140    println!(
141        "Installing fleet {} against DFX_NETWORK={}",
142        options.fleet_name, options.network
143    );
144    ensure_dfx_running(&dfx_root, &options.network)?;
145    let mut create = Command::new("dfx");
146    create
147        .current_dir(&dfx_root)
148        .args(["canister", "create", "--all", "-qq"]);
149    let create_started_at = Instant::now();
150    run_command(&mut create)?;
151    timings.create_canisters = create_started_at.elapsed();
152
153    let build_targets = local_install_build_targets(&config_path, &options.root_build_target)?;
154    let build_session_id = install_build_session_id();
155    let build_started_at = Instant::now();
156    run_dfx_build_targets(&dfx_root, &build_targets, &build_session_id, &config_path)?;
157    timings.build_all = build_started_at.elapsed();
158
159    let emit_manifest_started_at = Instant::now();
160    let manifest_path = emit_root_release_set_manifest_with_config(
161        &workspace_root,
162        &dfx_root,
163        &options.network,
164        &config_path,
165    )?;
166    timings.emit_manifest = emit_manifest_started_at.elapsed();
167
168    timings.fabricate_cycles =
169        maybe_fabricate_local_cycles(&dfx_root, &options.root_canister, &options.network)?;
170
171    let mut install = Command::new("dfx");
172    install.current_dir(&dfx_root).args([
173        "canister",
174        "install",
175        &options.root_canister,
176        "--mode=reinstall",
177        "-y",
178        "--argument",
179        "(variant { Prime })",
180    ]);
181    let install_started_at = Instant::now();
182    run_command(&mut install)?;
183    timings.install_root = install_started_at.elapsed();
184
185    let artifact_root = resolve_artifact_root(&dfx_root, &options.network)?;
186    let manifest =
187        load_root_release_set_manifest(&root_release_set_manifest_path(&artifact_root)?)?;
188    assert_eq!(
189        manifest_path,
190        root_release_set_manifest_path(&artifact_root)?
191    );
192    let stage_started_at = Instant::now();
193    stage_root_release_set(&dfx_root, &options.root_canister, &manifest)?;
194    timings.stage_release_set = stage_started_at.elapsed();
195    let resume_started_at = Instant::now();
196    resume_root_bootstrap(&options.root_canister)?;
197    timings.resume_bootstrap = resume_started_at.elapsed();
198    let ready_started_at = Instant::now();
199    let ready_result = wait_for_root_ready(&options.root_canister, options.ready_timeout_seconds);
200    timings.wait_ready = ready_started_at.elapsed();
201    if let Err(err) = ready_result {
202        print_install_timing_summary(&timings, total_started_at.elapsed());
203        return Err(err);
204    }
205
206    print_install_timing_summary(&timings, total_started_at.elapsed());
207    let state = build_install_state(
208        &options,
209        &workspace_root,
210        &dfx_root,
211        &config_path,
212        &manifest_path,
213    )?;
214    let state_path = write_install_state(&dfx_root, &options.network, &state)?;
215    print_install_result_summary(&options.network, &state.fleet, &state_path);
216    Ok(())
217}
218
219/// Read the persisted install state for one project/network when present.
220pub fn read_install_state(
221    dfx_root: &Path,
222    network: &str,
223) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
224    if let Some(fleet) = read_selected_fleet_name(dfx_root, network)? {
225        return read_fleet_install_state(dfx_root, network, &fleet);
226    }
227
228    read_legacy_install_state(dfx_root, network)
229}
230
231/// Read a named fleet install state for one project/network when present.
232pub fn read_fleet_install_state(
233    dfx_root: &Path,
234    network: &str,
235    fleet: &str,
236) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
237    validate_fleet_name(fleet)?;
238    let path = fleet_install_state_path(dfx_root, network, fleet);
239    if !path.is_file() {
240        return Ok(None);
241    }
242
243    let bytes = fs::read(&path)?;
244    let mut state: InstallState = serde_json::from_slice(&bytes)?;
245    if state.fleet.is_empty() {
246        state.fleet = fleet.to_string();
247    }
248    Ok(Some(state))
249}
250
251/// Read the install state for the discovered current project/network.
252pub fn read_current_install_state(
253    network: &str,
254) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
255    let dfx_root = dfx_root()?;
256    read_install_state(&dfx_root, network)
257}
258
259/// Read either a named fleet state or the selected current fleet state.
260pub fn read_current_or_fleet_install_state(
261    network: &str,
262    fleet: Option<&str>,
263) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
264    let dfx_root = dfx_root()?;
265    match fleet {
266        Some(fleet) => read_fleet_install_state(&dfx_root, network, fleet),
267        None => read_install_state(&dfx_root, network),
268    }
269}
270
271///
272/// FleetSummary
273///
274
275#[derive(Clone, Debug, Eq, PartialEq)]
276pub struct FleetSummary {
277    pub name: String,
278    pub current: bool,
279    pub state: InstallState,
280}
281
282/// List installed fleets for the current project/network.
283pub fn list_current_fleets(network: &str) -> Result<Vec<FleetSummary>, Box<dyn std::error::Error>> {
284    let dfx_root = dfx_root()?;
285    list_fleets(&dfx_root, network)
286}
287
288/// List installed fleets for one project/network.
289pub fn list_fleets(
290    dfx_root: &Path,
291    network: &str,
292) -> Result<Vec<FleetSummary>, Box<dyn std::error::Error>> {
293    let current = read_selected_fleet_name(dfx_root, network)?;
294    let mut fleets = Vec::new();
295    let dir = fleets_dir(dfx_root, network);
296    if dir.is_dir() {
297        for entry in fs::read_dir(&dir)? {
298            let entry = entry?;
299            let path = entry.path();
300            if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
301                continue;
302            }
303            let Some(name) = path.file_stem().and_then(|stem| stem.to_str()) else {
304                continue;
305            };
306            if let Some(state) = read_fleet_install_state(dfx_root, network, name)? {
307                fleets.push(FleetSummary {
308                    name: name.to_string(),
309                    current: current.as_deref() == Some(name),
310                    state,
311                });
312            }
313        }
314    }
315
316    if fleets.is_empty()
317        && let Some(state) = read_legacy_install_state(dfx_root, network)?
318    {
319        fleets.push(FleetSummary {
320            name: state.fleet.clone(),
321            current: true,
322            state,
323        });
324    }
325
326    fleets.sort_by(|left, right| left.name.cmp(&right.name));
327    Ok(fleets)
328}
329
330/// Select one installed fleet as the current project/network default.
331pub fn select_current_fleet(
332    network: &str,
333    fleet: &str,
334) -> Result<InstallState, Box<dyn std::error::Error>> {
335    let dfx_root = dfx_root()?;
336    select_fleet(&dfx_root, network, fleet)
337}
338
339/// Select one installed fleet for one project/network.
340pub fn select_fleet(
341    dfx_root: &Path,
342    network: &str,
343    fleet: &str,
344) -> Result<InstallState, Box<dyn std::error::Error>> {
345    let Some(state) = read_fleet_install_state(dfx_root, network, fleet)?.or_else(|| {
346        matching_legacy_fleet_state(dfx_root, network, fleet)
347            .ok()
348            .flatten()
349    }) else {
350        return Err(format!("unknown fleet {fleet} on network {network}").into());
351    };
352    if fleet_install_state_path(dfx_root, network, fleet).is_file() {
353        write_current_fleet_name(dfx_root, network, fleet)?;
354    } else {
355        write_install_state(dfx_root, network, &state)?;
356    }
357    Ok(state)
358}
359
360/// Return the legacy project-local install state path for one network.
361#[must_use]
362pub fn install_state_path(dfx_root: &Path, network: &str) -> PathBuf {
363    dfx_root
364        .join(".canic")
365        .join(network)
366        .join(INSTALL_STATE_FILE)
367}
368
369/// Return the project-local state path for one named fleet.
370#[must_use]
371pub fn fleet_install_state_path(dfx_root: &Path, network: &str, fleet: &str) -> PathBuf {
372    fleets_dir(dfx_root, network).join(format!("{fleet}.json"))
373}
374
375/// Return the project-local current-fleet pointer path for one network.
376#[must_use]
377pub fn current_fleet_path(dfx_root: &Path, network: &str) -> PathBuf {
378    dfx_root
379        .join(".canic")
380        .join(network)
381        .join(CURRENT_FLEET_FILE)
382}
383
384// Return the directory that owns named fleet state files.
385fn fleets_dir(dfx_root: &Path, network: &str) -> PathBuf {
386    dfx_root.join(".canic").join(network).join("fleets")
387}
388
389// Resolve install config selection without silently choosing among demo/test configs.
390fn resolve_install_config_path(
391    workspace_root: &Path,
392    explicit_config_path: Option<&str>,
393    interactive: bool,
394) -> Result<PathBuf, Box<dyn std::error::Error>> {
395    if let Some(path) = explicit_config_path {
396        return Ok(normalize_workspace_path(
397            workspace_root,
398            PathBuf::from(path),
399        ));
400    }
401
402    if let Some(path) = env::var_os("CANIC_CONFIG_PATH") {
403        return Ok(normalize_workspace_path(
404            workspace_root,
405            PathBuf::from(path),
406        ));
407    }
408
409    let default = workspace_root.join("canisters/canic.toml");
410    if default.is_file() {
411        return Ok(default);
412    }
413
414    let choices = discover_canic_config_choices(&workspace_root.join("canisters"))?;
415    if interactive
416        && let Some(path) = prompt_install_config_choice(workspace_root, &default, &choices)?
417    {
418        return Ok(path);
419    }
420
421    Err(config_selection_error(workspace_root, &default, &choices).into())
422}
423
424// Discover candidate `canic.toml` files under the conventional canisters tree.
425fn discover_canic_config_choices(root: &Path) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
426    let mut choices = Vec::new();
427    collect_canic_config_choices(root, &mut choices)?;
428    choices.sort();
429    Ok(choices)
430}
431
432// Recursively collect candidate config paths.
433fn collect_canic_config_choices(
434    root: &Path,
435    choices: &mut Vec<PathBuf>,
436) -> Result<(), Box<dyn std::error::Error>> {
437    if !root.is_dir() {
438        return Ok(());
439    }
440
441    for entry in fs::read_dir(root)? {
442        let entry = entry?;
443        let path = entry.path();
444        if path.is_dir() {
445            collect_canic_config_choices(&path, choices)?;
446        } else if path.file_name().and_then(|name| name.to_str()) == Some("canic.toml")
447            && is_install_project_config(&path)
448        {
449            choices.push(path);
450        }
451    }
452
453    Ok(())
454}
455
456// Treat only configs next to a root canister directory as installable choices.
457fn is_install_project_config(path: &Path) -> bool {
458    path.parent()
459        .is_some_and(|parent| parent.join("root/Cargo.toml").is_file())
460}
461
462// Format an actionable config-selection error with whitespace-aligned choices.
463fn config_selection_error(workspace_root: &Path, default: &Path, choices: &[PathBuf]) -> String {
464    let mut lines = vec![format!(
465        "missing default Canic config at {}",
466        display_workspace_path(workspace_root, default)
467    )];
468
469    if choices.is_empty() {
470        lines.push("create canisters/canic.toml or run canic install --config <path>".to_string());
471        return lines.join("\n");
472    }
473
474    if choices.len() == 1 {
475        let choice = display_workspace_path(workspace_root, &choices[0]);
476        lines.push(String::new());
477        lines.extend(config_choice_table(workspace_root, choices));
478        lines.push(String::new());
479        lines.push(format!("run: canic install --config {choice}"));
480        return lines.join("\n");
481    }
482
483    lines.push("choose a config path explicitly:".to_string());
484    lines.push(String::new());
485    lines.extend(config_choice_table(workspace_root, choices));
486    lines.push(String::new());
487    lines.push("run: canic install --config <path>".to_string());
488    lines.join("\n")
489}
490
491// Prompt interactively for one discovered config when running in a terminal.
492fn prompt_install_config_choice(
493    workspace_root: &Path,
494    default: &Path,
495    choices: &[PathBuf],
496) -> Result<Option<PathBuf>, Box<dyn std::error::Error>> {
497    if choices.is_empty() || !io::stdin().is_terminal() {
498        return Ok(None);
499    }
500
501    eprintln!(
502        "missing default Canic config at {}",
503        display_workspace_path(workspace_root, default)
504    );
505    eprintln!();
506    for line in config_choice_table(workspace_root, choices) {
507        eprintln!("{line}");
508    }
509    eprintln!();
510
511    loop {
512        eprint!("enter config number (ctrl-c to quit): ");
513        io::stderr().flush()?;
514
515        let mut answer = String::new();
516        if io::stdin().read_line(&mut answer)? == 0 {
517            return Ok(None);
518        }
519
520        let trimmed = answer.trim();
521        let Ok(index) = trimmed.parse::<usize>() else {
522            eprintln!("invalid selection: {trimmed}");
523            continue;
524        };
525        let Some(path) = choices.get(index.saturating_sub(1)) else {
526            eprintln!("selection out of range: {index}");
527            continue;
528        };
529
530        return Ok(Some(path.clone()));
531    }
532}
533
534// Render config choices with enough metadata to choose the intended topology.
535fn config_choice_table(workspace_root: &Path, choices: &[PathBuf]) -> Vec<String> {
536    let rows = choices
537        .iter()
538        .enumerate()
539        .map(|(index, path)| config_choice_row(workspace_root, index + 1, path))
540        .collect::<Vec<_>>();
541    let option_width = rows
542        .iter()
543        .map(|row| row.option.len())
544        .chain(["#".len()])
545        .max()
546        .expect("option width");
547    let config_width = rows
548        .iter()
549        .map(|row| row.config.len())
550        .chain(["CONFIG".len()])
551        .max()
552        .expect("config width");
553    let mut lines = vec![format!(
554        "{:<option_width$}  {:<config_width$}  CANISTERS",
555        "#", "CONFIG"
556    )];
557
558    for row in rows {
559        lines.push(format!(
560            "{:<option_width$}  {:<config_width$}  {}",
561            row.option, row.config, row.canisters
562        ));
563    }
564
565    lines
566}
567
568// Summarize the root-subnet release roles for one install config choice.
569fn config_choice_row(workspace_root: &Path, option: usize, path: &Path) -> ConfigChoiceRow {
570    let config = display_workspace_path(workspace_root, path);
571    match configured_release_roles(path) {
572        Ok(roles) => ConfigChoiceRow {
573            option: option.to_string(),
574            config,
575            canisters: format_canister_summary(&roles),
576        },
577        Err(_) => ConfigChoiceRow {
578            option: option.to_string(),
579            config,
580            canisters: "invalid config".to_string(),
581        },
582    }
583}
584
585// Format the root-subnet canister count with a bounded role preview.
586fn format_canister_summary(roles: &[String]) -> String {
587    if roles.is_empty() {
588        return "0".to_string();
589    }
590
591    let preview = roles
592        .iter()
593        .take(CONFIG_CHOICE_ROLE_PREVIEW_LIMIT)
594        .map(String::as_str)
595        .collect::<Vec<_>>()
596        .join(", ");
597    let suffix = if roles.len() > CONFIG_CHOICE_ROLE_PREVIEW_LIMIT {
598        ", ..."
599    } else {
600        ""
601    };
602
603    format!("{} ({preview}{suffix})", roles.len())
604}
605
606// Render a workspace-relative path where possible for concise diagnostics.
607fn display_workspace_path(workspace_root: &Path, path: &Path) -> String {
608    path.strip_prefix(workspace_root)
609        .unwrap_or(path)
610        .display()
611        .to_string()
612}
613
614// Resolve the local install build set from the root canister plus the
615// configured ordinary roles owned by the root subnet.
616fn local_install_build_targets(
617    config_path: &Path,
618    root_canister: &str,
619) -> Result<Vec<String>, Box<dyn std::error::Error>> {
620    configured_install_targets(config_path, root_canister)
621}
622
623// Build the persisted project-local install state from a completed install.
624fn build_install_state(
625    options: &InstallRootOptions,
626    workspace_root: &Path,
627    dfx_root: &Path,
628    config_path: &Path,
629    release_set_manifest_path: &Path,
630) -> Result<InstallState, Box<dyn std::error::Error>> {
631    Ok(InstallState {
632        schema_version: INSTALL_STATE_SCHEMA_VERSION,
633        fleet: options.fleet_name.clone(),
634        installed_at_unix_secs: current_unix_secs()?,
635        network: options.network.clone(),
636        root_target: options.root_canister.clone(),
637        root_canister_id: resolve_root_canister_id(dfx_root, &options.root_canister)?,
638        root_build_target: options.root_build_target.clone(),
639        workspace_root: workspace_root.display().to_string(),
640        dfx_root: dfx_root.display().to_string(),
641        config_path: config_path.display().to_string(),
642        release_set_manifest_path: release_set_manifest_path.display().to_string(),
643    })
644}
645
646// Persist the completed install state under the project-local `.canic` directory.
647fn write_install_state(
648    dfx_root: &Path,
649    network: &str,
650    state: &InstallState,
651) -> Result<PathBuf, Box<dyn std::error::Error>> {
652    validate_fleet_name(&state.fleet)?;
653    let path = fleet_install_state_path(dfx_root, network, &state.fleet);
654    if let Some(parent) = path.parent() {
655        fs::create_dir_all(parent)?;
656    }
657    fs::write(&path, serde_json::to_vec_pretty(state)?)?;
658    write_current_fleet_name(dfx_root, network, &state.fleet)?;
659    Ok(path)
660}
661
662// Read a legacy single-slot install state when no named fleet pointer exists.
663fn read_legacy_install_state(
664    dfx_root: &Path,
665    network: &str,
666) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
667    let path = install_state_path(dfx_root, network);
668    if !path.is_file() {
669        return Ok(None);
670    }
671
672    let bytes = fs::read(&path)?;
673    let mut state: InstallState = serde_json::from_slice(&bytes)?;
674    if state.fleet.is_empty() {
675        state.fleet = DEFAULT_FLEET_NAME.to_string();
676    }
677    Ok(Some(state))
678}
679
680// Return the legacy single-slot state only when it matches the requested fleet.
681fn matching_legacy_fleet_state(
682    dfx_root: &Path,
683    network: &str,
684    fleet: &str,
685) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
686    Ok(read_legacy_install_state(dfx_root, network)?.filter(|state| state.fleet == fleet))
687}
688
689// Read the selected fleet name for one project/network.
690fn read_selected_fleet_name(
691    dfx_root: &Path,
692    network: &str,
693) -> Result<Option<String>, Box<dyn std::error::Error>> {
694    let path = current_fleet_path(dfx_root, network);
695    if !path.is_file() {
696        return Ok(None);
697    }
698
699    let name = fs::read_to_string(path)?.trim().to_string();
700    validate_fleet_name(&name)?;
701    Ok(Some(name))
702}
703
704// Write the selected fleet name for one project/network.
705fn write_current_fleet_name(
706    dfx_root: &Path,
707    network: &str,
708    fleet: &str,
709) -> Result<(), Box<dyn std::error::Error>> {
710    validate_fleet_name(fleet)?;
711    let path = current_fleet_path(dfx_root, network);
712    if let Some(parent) = path.parent() {
713        fs::create_dir_all(parent)?;
714    }
715    fs::write(path, format!("{fleet}\n"))?;
716    Ok(())
717}
718
719// Return the serde default for legacy install-state records.
720fn default_fleet_name() -> String {
721    DEFAULT_FLEET_NAME.to_string()
722}
723
724// Keep fleet names filesystem-safe and easy to type in commands.
725fn validate_fleet_name(name: &str) -> Result<(), Box<dyn std::error::Error>> {
726    let valid = !name.is_empty()
727        && name
728            .bytes()
729            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'));
730    if valid {
731        Ok(())
732    } else {
733        Err(format!("invalid fleet name {name:?}; use letters, numbers, '-' or '_'").into())
734    }
735}
736
737// Resolve the installed root id, accepting principal targets without a dfx lookup.
738fn resolve_root_canister_id(
739    dfx_root: &Path,
740    root_canister: &str,
741) -> Result<String, Box<dyn std::error::Error>> {
742    if Principal::from_text(root_canister).is_ok() {
743        return Ok(root_canister.to_string());
744    }
745
746    let mut command = Command::new("dfx");
747    command
748        .current_dir(dfx_root)
749        .args(["canister", "id", root_canister]);
750    Ok(run_command_stdout(&mut command)?.trim().to_string())
751}
752
753// Read the current host clock as a unix timestamp for install state.
754fn current_unix_secs() -> Result<u64, Box<dyn std::error::Error>> {
755    Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
756}
757
758// Run one `dfx build <canister>` call per configured local install target.
759fn run_dfx_build_targets(
760    dfx_root: &Path,
761    targets: &[String],
762    build_session_id: &str,
763    config_path: &Path,
764) -> Result<(), Box<dyn std::error::Error>> {
765    println!("Build artifacts:");
766    println!("{:<16} {:<18} {:>10}", "CANISTER", "PROGRESS", "ELAPSED");
767
768    for (index, target) in targets.iter().enumerate() {
769        let mut command = dfx_build_target_command(dfx_root, target, build_session_id);
770        command.env("CANIC_CONFIG_PATH", config_path);
771        let started_at = Instant::now();
772        let output = command.output()?;
773        let elapsed = started_at.elapsed();
774
775        if !output.status.success() {
776            return Err(format!(
777                "dfx build failed for {target}: {}\nstdout:\n{}\nstderr:\n{}",
778                output.status,
779                String::from_utf8_lossy(&output.stdout).trim(),
780                String::from_utf8_lossy(&output.stderr).trim()
781            )
782            .into());
783        }
784
785        println!(
786            "{:<16} {:<18} {:>9.2}s",
787            target,
788            progress_bar(index + 1, targets.len(), 10),
789            elapsed.as_secs_f64()
790        );
791    }
792
793    println!();
794    Ok(())
795}
796
797// Spawn one local `dfx build <canister>` step without overriding the caller's
798// selected build profile environment.
799fn dfx_build_target_command(dfx_root: &Path, target: &str, build_session_id: &str) -> Command {
800    let mut command = Command::new("dfx");
801    command
802        .current_dir(dfx_root)
803        .env("CANIC_BUILD_CONTEXT_SESSION", build_session_id)
804        .args(["build", "-qq", target]);
805    command
806}
807
808fn install_build_session_id() -> String {
809    let unique = SystemTime::now()
810        .duration_since(UNIX_EPOCH)
811        .map_or(0, |duration| duration.as_nanos());
812    format!("install-root-{}-{unique}", std::process::id())
813}
814
815// Top up local root cycles only when the current balance is below the target floor.
816fn maybe_fabricate_local_cycles(
817    dfx_root: &Path,
818    root_canister: &str,
819    network: &str,
820) -> Result<Duration, Box<dyn std::error::Error>> {
821    if network != "local" {
822        return Ok(Duration::ZERO);
823    }
824
825    let current_balance = root_cycle_balance(dfx_root, root_canister)?;
826    let Some(fabricate_cycles) = required_local_cycle_topup(current_balance) else {
827        println!(
828            "Skipping local cycle fabrication for {root_canister}; balance {} already meets target {}",
829            format_cycles(current_balance),
830            format_cycles(LOCAL_ROOT_TARGET_CYCLES)
831        );
832        return Ok(Duration::ZERO);
833    };
834
835    println!(
836        "Fabricating {} cycles locally for {root_canister} to reach target {} (current balance {})",
837        format_cycles(fabricate_cycles),
838        format_cycles(LOCAL_ROOT_TARGET_CYCLES),
839        format_cycles(current_balance)
840    );
841
842    let mut fabricate = Command::new("dfx");
843    fabricate.current_dir(dfx_root);
844    fabricate.args([
845        "ledger",
846        "fabricate-cycles",
847        "--canister",
848        root_canister,
849        "--cycles",
850        &fabricate_cycles.to_string(),
851    ]);
852    let fabricate_started_at = Instant::now();
853    let _ = run_command_allow_failure(&mut fabricate)?;
854
855    Ok(fabricate_started_at.elapsed())
856}
857
858// Read the current root canister cycle balance from `dfx canister status`.
859fn root_cycle_balance(
860    dfx_root: &Path,
861    root_canister: &str,
862) -> Result<u128, Box<dyn std::error::Error>> {
863    let output = Command::new("dfx")
864        .current_dir(dfx_root)
865        .args(["canister", "status", root_canister])
866        .output()?;
867    if !output.status.success() {
868        return Err(format!("dfx canister status failed: {}", output.status).into());
869    }
870
871    let stdout = String::from_utf8(output.stdout)?;
872    parse_canister_status_cycles(&stdout)
873        .ok_or_else(|| "could not parse cycle balance from `dfx canister status` output".into())
874}
875
876// Parse the cycle balance from the human-readable `dfx canister status` output.
877fn parse_canister_status_cycles(status_output: &str) -> Option<u128> {
878    status_output
879        .lines()
880        .find_map(parse_canister_status_balance_line)
881}
882
883fn parse_canister_status_balance_line(line: &str) -> Option<u128> {
884    let (label, value) = line.trim().split_once(':')?;
885    let label = label.trim().to_ascii_lowercase();
886    if label != "balance" && label != "cycle balance" {
887        return None;
888    }
889
890    let digits = value
891        .chars()
892        .filter(char::is_ascii_digit)
893        .collect::<String>();
894    if digits.is_empty() {
895        return None;
896    }
897
898    digits.parse::<u128>().ok()
899}
900
901// Return the local top-up delta needed to bring root up to the target cycle floor.
902fn required_local_cycle_topup(current_balance: u128) -> Option<u128> {
903    (current_balance < LOCAL_ROOT_TARGET_CYCLES)
904        .then_some(LOCAL_ROOT_TARGET_CYCLES.saturating_sub(current_balance))
905        .filter(|cycles| *cycles > 0)
906}
907
908fn format_cycles(value: u128) -> String {
909    let digits = value.to_string();
910    let mut out = String::with_capacity(digits.len() + (digits.len().saturating_sub(1) / 3));
911    for (index, ch) in digits.chars().enumerate() {
912        if index > 0 && (digits.len() - index).is_multiple_of(3) {
913            out.push('_');
914        }
915        out.push(ch);
916    }
917    out
918}
919
920fn progress_bar(current: usize, total: usize, width: usize) -> String {
921    if total == 0 || width == 0 {
922        return "[] 0/0".to_string();
923    }
924
925    let filled = current.saturating_mul(width).div_ceil(total);
926    let filled = filled.min(width);
927    format!(
928        "[{}{}] {current}/{total}",
929        "#".repeat(filled),
930        " ".repeat(width - filled)
931    )
932}
933
934// Ensure the requested replica is reachable before the local install flow begins.
935fn ensure_dfx_running(dfx_root: &Path, network: &str) -> Result<(), Box<dyn std::error::Error>> {
936    if dfx_ping(network)? {
937        return Ok(());
938    }
939
940    if network == "local" && local_dfx_autostart_enabled() {
941        println!("Local dfx replica is not reachable; starting a clean local replica");
942        let mut stop = dfx_stop_command(dfx_root);
943        let _ = run_command_allow_failure(&mut stop)?;
944
945        let mut start = dfx_start_local_command(dfx_root);
946        run_command(&mut start)?;
947        wait_for_dfx_ping(
948            network,
949            Duration::from_secs(LOCAL_DFX_READY_TIMEOUT_SECONDS),
950        )?;
951        return Ok(());
952    }
953
954    Err(format!(
955        "dfx replica is not running for network '{network}'\nStart the target replica externally and rerun."
956    )
957    .into())
958}
959
960// Check whether `dfx ping <network>` currently succeeds.
961fn dfx_ping(network: &str) -> Result<bool, Box<dyn std::error::Error>> {
962    Ok(Command::new("dfx")
963        .args(["ping", network])
964        .output()?
965        .status
966        .success())
967}
968
969// Return true when the local install flow should auto-start a clean local replica.
970fn local_dfx_autostart_enabled() -> bool {
971    parse_local_dfx_autostart(env::var("CANIC_AUTO_START_LOCAL_DFX").ok().as_deref())
972}
973
974fn parse_local_dfx_autostart(value: Option<&str>) -> bool {
975    !matches!(
976        value.map(str::trim).map(str::to_ascii_lowercase).as_deref(),
977        Some("0" | "false" | "no" | "off")
978    )
979}
980
981// Spawn one local `dfx stop` command for cleanup before a clean restart.
982fn dfx_stop_command(dfx_root: &Path) -> Command {
983    let mut command = Command::new("dfx");
984    command.current_dir(dfx_root).arg("stop");
985    command
986}
987
988// Spawn one clean background `dfx start` command for local install/test flows.
989fn dfx_start_local_command(dfx_root: &Path) -> Command {
990    let mut command = Command::new("dfx");
991    command
992        .current_dir(dfx_root)
993        .args(["start", "--background", "--clean", "--system-canisters"]);
994    command
995}
996
997// Poll `dfx ping` until the requested network responds or the timeout expires.
998fn wait_for_dfx_ping(network: &str, timeout: Duration) -> Result<(), Box<dyn std::error::Error>> {
999    let start = Instant::now();
1000    while start.elapsed() < timeout {
1001        if dfx_ping(network)? {
1002            return Ok(());
1003        }
1004        thread::sleep(Duration::from_millis(500));
1005    }
1006
1007    Err(format!(
1008        "dfx replica did not become ready for network '{network}' within {}s",
1009        timeout.as_secs()
1010    )
1011    .into())
1012}
1013
1014// Wait until root reports ready, printing periodic progress and diagnostics.
1015fn wait_for_root_ready(
1016    root_canister: &str,
1017    timeout_seconds: u64,
1018) -> Result<(), Box<dyn std::error::Error>> {
1019    let start = std::time::Instant::now();
1020    let mut next_report = 0_u64;
1021
1022    println!("Waiting for {root_canister} to report canic_ready (timeout {timeout_seconds}s)");
1023
1024    loop {
1025        if root_ready(root_canister)? {
1026            println!(
1027                "{root_canister} reported canic_ready after {}s",
1028                start.elapsed().as_secs()
1029            );
1030            return Ok(());
1031        }
1032
1033        if let Some(status) = root_bootstrap_status(root_canister)?
1034            && let Some(last_error) = status.last_error.as_deref()
1035        {
1036            eprintln!(
1037                "root bootstrap reported failure during phase '{}' : {}",
1038                status.phase, last_error
1039            );
1040            eprintln!("Diagnostic: dfx canister call {root_canister} canic_bootstrap_status");
1041            print_raw_call(root_canister, protocol::CANIC_BOOTSTRAP_STATUS);
1042            eprintln!("Diagnostic: dfx canister call {root_canister} canic_subnet_registry");
1043            print_raw_call(root_canister, "canic_subnet_registry");
1044            eprintln!(
1045                "Diagnostic: dfx canister call {root_canister} canic_wasm_store_bootstrap_debug"
1046            );
1047            print_raw_call(root_canister, "canic_wasm_store_bootstrap_debug");
1048            eprintln!("Diagnostic: dfx canister call {root_canister} canic_wasm_store_overview");
1049            print_raw_call(root_canister, "canic_wasm_store_overview");
1050            eprintln!("Diagnostic: dfx canister call {root_canister} canic_log");
1051            print_recent_root_logs(root_canister);
1052            return Err(format!(
1053                "root bootstrap failed during phase '{}' : {}",
1054                status.phase, last_error
1055            )
1056            .into());
1057        }
1058
1059        let elapsed = start.elapsed().as_secs();
1060        if elapsed >= timeout_seconds {
1061            eprintln!("root did not report canic_ready within {timeout_seconds}s");
1062            eprintln!("Diagnostic: dfx canister call {root_canister} canic_bootstrap_status");
1063            print_raw_call(root_canister, protocol::CANIC_BOOTSTRAP_STATUS);
1064            eprintln!("Diagnostic: dfx canister call {root_canister} canic_subnet_registry");
1065            print_raw_call(root_canister, "canic_subnet_registry");
1066            eprintln!(
1067                "Diagnostic: dfx canister call {root_canister} canic_wasm_store_bootstrap_debug"
1068            );
1069            print_raw_call(root_canister, "canic_wasm_store_bootstrap_debug");
1070            eprintln!("Diagnostic: dfx canister call {root_canister} canic_wasm_store_overview");
1071            print_raw_call(root_canister, "canic_wasm_store_overview");
1072            eprintln!("Diagnostic: dfx canister call {root_canister} canic_log");
1073            print_recent_root_logs(root_canister);
1074            return Err("root did not become ready".into());
1075        }
1076
1077        if elapsed >= next_report {
1078            println!("Still waiting for {root_canister} canic_ready ({elapsed}s elapsed)");
1079            if let Some(status) = root_bootstrap_status(root_canister)? {
1080                match status.last_error.as_deref() {
1081                    Some(last_error) => println!(
1082                        "Current bootstrap status: phase={} ready={} error={}",
1083                        status.phase, status.ready, last_error
1084                    ),
1085                    None => println!(
1086                        "Current bootstrap status: phase={} ready={}",
1087                        status.phase, status.ready
1088                    ),
1089                }
1090            }
1091            if let Ok(registry_json) =
1092                dfx_call(root_canister, "canic_subnet_registry", None, Some("json"))
1093            {
1094                println!("Current subnet registry roles:");
1095                println!("  {}", registry_roles(&registry_json));
1096            }
1097            next_report = elapsed + 5;
1098        }
1099
1100        thread::sleep(Duration::from_secs(1));
1101    }
1102}
1103
1104// Return true once root reports `canic_ready == true`.
1105fn root_ready(root_canister: &str) -> Result<bool, Box<dyn std::error::Error>> {
1106    let output = dfx_call(root_canister, "canic_ready", None, Some("json"))?;
1107    let data = serde_json::from_str::<Value>(&output)?;
1108    Ok(parse_root_ready_value(&data))
1109}
1110
1111// Return the current root bootstrap diagnostic state when the query is available.
1112fn root_bootstrap_status(
1113    root_canister: &str,
1114) -> Result<Option<BootstrapStatusSnapshot>, Box<dyn std::error::Error>> {
1115    let output = match dfx_call(
1116        root_canister,
1117        protocol::CANIC_BOOTSTRAP_STATUS,
1118        None,
1119        Some("json"),
1120    ) {
1121        Ok(output) => output,
1122        Err(err) => {
1123            let message = err.to_string();
1124            if message.contains("has no query method")
1125                || message.contains("method not found")
1126                || message.contains("Canister has no query method")
1127            {
1128                return Ok(None);
1129            }
1130            return Err(err);
1131        }
1132    };
1133    let data = serde_json::from_str::<Value>(&output)?;
1134    Ok(parse_bootstrap_status_value(&data))
1135}
1136
1137// Accept both plain-bool and wrapped-result JSON shapes from `dfx --output json`.
1138fn parse_root_ready_value(data: &Value) -> bool {
1139    matches!(data, Value::Bool(true)) || matches!(data.get("Ok"), Some(Value::Bool(true)))
1140}
1141
1142fn parse_bootstrap_status_value(data: &Value) -> Option<BootstrapStatusSnapshot> {
1143    serde_json::from_value::<BootstrapStatusSnapshot>(data.clone())
1144        .ok()
1145        .or_else(|| {
1146            data.get("Ok")
1147                .cloned()
1148                .and_then(|ok| serde_json::from_value::<BootstrapStatusSnapshot>(ok).ok())
1149        })
1150}
1151
1152fn print_install_timing_summary(timings: &InstallTimingSummary, total: Duration) {
1153    println!("Install timing summary:");
1154    println!("{:<20} {:>10}", "phase", "elapsed");
1155    println!("{:<20} {:>10}", "--------------------", "----------");
1156    print_timing_row("create_canisters", timings.create_canisters);
1157    print_timing_row("build_all", timings.build_all);
1158    print_timing_row("emit_manifest", timings.emit_manifest);
1159    print_timing_row("fabricate_cycles", timings.fabricate_cycles);
1160    print_timing_row("install_root", timings.install_root);
1161    print_timing_row("stage_release_set", timings.stage_release_set);
1162    print_timing_row("resume_bootstrap", timings.resume_bootstrap);
1163    print_timing_row("wait_ready", timings.wait_ready);
1164    print_timing_row("total", total);
1165}
1166
1167fn print_timing_row(label: &str, duration: Duration) {
1168    println!("{label:<20} {:>9.2}s", duration.as_secs_f64());
1169}
1170
1171// Print the final install result as a compact whitespace table.
1172fn print_install_result_summary(network: &str, fleet: &str, state_path: &Path) {
1173    println!("Install result:");
1174    println!("{:<14} success", "status");
1175    println!("{:<14} {}", "fleet", fleet);
1176    println!("{:<14} {}", "install_state", state_path.display());
1177    println!("{:<14} canic list --network {}", "smoke_check", network);
1178}
1179
1180// Print recent structured root log entries without raw byte dumps.
1181fn print_recent_root_logs(root_canister: &str) {
1182    let page_args = r"(null, null, null, record { limit = 8; offset = 0 })";
1183    let Ok(logs_json) = dfx_call(root_canister, "canic_log", Some(page_args), Some("json")) else {
1184        return;
1185    };
1186    let Ok(data) = serde_json::from_str::<Value>(&logs_json) else {
1187        return;
1188    };
1189    let entries = data
1190        .get("Ok")
1191        .and_then(|ok| ok.get("entries"))
1192        .and_then(Value::as_array)
1193        .cloned()
1194        .unwrap_or_default();
1195
1196    if entries.is_empty() {
1197        println!("  <no runtime log entries>");
1198        return;
1199    }
1200
1201    for entry in entries.iter().rev() {
1202        let level = entry.get("level").and_then(Value::as_str).unwrap_or("Info");
1203        let topic = entry.get("topic").and_then(Value::as_str).unwrap_or("");
1204        let message = entry
1205            .get("message")
1206            .and_then(Value::as_str)
1207            .unwrap_or("")
1208            .replace('\n', "\\n");
1209        let topic_prefix = if topic.is_empty() {
1210            String::new()
1211        } else {
1212            format!("[{topic}] ")
1213        };
1214        println!("  {level} {topic_prefix}{message}");
1215    }
1216}
1217
1218// Render the current subnet registry roles from one JSON response.
1219fn registry_roles(registry_json: &str) -> String {
1220    serde_json::from_str::<Value>(registry_json)
1221        .ok()
1222        .and_then(|data| {
1223            data.get("Ok").and_then(Value::as_array).map(|entries| {
1224                entries
1225                    .iter()
1226                    .filter_map(|entry| {
1227                        entry
1228                            .get("role")
1229                            .and_then(Value::as_str)
1230                            .map(str::to_string)
1231                    })
1232                    .collect::<Vec<_>>()
1233            })
1234        })
1235        .map_or_else(
1236            || "<unavailable>".to_string(),
1237            |roles| {
1238                if roles.is_empty() {
1239                    "<empty>".to_string()
1240                } else {
1241                    roles.join(", ")
1242                }
1243            },
1244        )
1245}
1246
1247// Run one command and require a zero exit status.
1248fn run_command(command: &mut Command) -> Result<(), Box<dyn std::error::Error>> {
1249    let status = command.status()?;
1250    if status.success() {
1251        Ok(())
1252    } else {
1253        Err(format!("command failed: {status}").into())
1254    }
1255}
1256
1257// Run one command, require success, and return stdout.
1258fn run_command_stdout(command: &mut Command) -> Result<String, Box<dyn std::error::Error>> {
1259    let output = command.output()?;
1260    if output.status.success() {
1261        return Ok(String::from_utf8_lossy(&output.stdout).to_string());
1262    }
1263
1264    Err(format!(
1265        "command failed: {}\n{}",
1266        output.status,
1267        String::from_utf8_lossy(&output.stderr)
1268    )
1269    .into())
1270}
1271
1272// Run one command and return its status without failing the caller on non-zero exit.
1273fn run_command_allow_failure(
1274    command: &mut Command,
1275) -> Result<std::process::ExitStatus, Box<dyn std::error::Error>> {
1276    Ok(command.status()?)
1277}
1278
1279// Print one raw fallback `dfx canister call` result to stderr for diagnostics.
1280fn print_raw_call(root_canister: &str, method: &str) {
1281    let mut command = Command::new("dfx");
1282    if let Ok(root) = dfx_root() {
1283        command.current_dir(root);
1284    }
1285    let _ = command
1286        .args(["canister", "call", root_canister, method])
1287        .status();
1288}
1289
1290#[cfg(test)]
1291mod tests {
1292    use super::{
1293        INSTALL_STATE_SCHEMA_VERSION, InstallState, LOCAL_ROOT_TARGET_CYCLES,
1294        config_selection_error, current_fleet_path, dfx_build_target_command,
1295        dfx_start_local_command, dfx_stop_command, discover_canic_config_choices,
1296        fleet_install_state_path, install_build_session_id, list_fleets,
1297        local_install_build_targets, parse_bootstrap_status_value, parse_canister_status_cycles,
1298        parse_local_dfx_autostart, parse_root_ready_value, read_fleet_install_state,
1299        read_install_state, required_local_cycle_topup, resolve_install_config_path,
1300        write_install_state,
1301    };
1302    use serde_json::json;
1303    use std::{
1304        env, fs,
1305        path::{Path, PathBuf},
1306        sync::{Mutex, OnceLock},
1307        time::{SystemTime, UNIX_EPOCH},
1308    };
1309
1310    static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1311
1312    #[test]
1313    fn parse_root_ready_accepts_plain_true() {
1314        assert!(parse_root_ready_value(&json!(true)));
1315    }
1316
1317    #[test]
1318    fn parse_root_ready_accepts_wrapped_ok_true() {
1319        assert!(parse_root_ready_value(&json!({ "Ok": true })));
1320    }
1321
1322    #[test]
1323    fn parse_root_ready_rejects_false_shapes() {
1324        assert!(!parse_root_ready_value(&json!(false)));
1325        assert!(!parse_root_ready_value(&json!({ "Ok": false })));
1326        assert!(!parse_root_ready_value(&json!({ "Err": "nope" })));
1327    }
1328
1329    #[test]
1330    fn parse_bootstrap_status_accepts_plain_record() {
1331        let status = parse_bootstrap_status_value(&json!({
1332            "ready": false,
1333            "phase": "root:init:create_canisters",
1334            "last_error": null
1335        }))
1336        .expect("plain bootstrap status must parse");
1337
1338        assert!(!status.ready);
1339        assert_eq!(status.phase, "root:init:create_canisters");
1340        assert_eq!(status.last_error, None);
1341    }
1342
1343    #[test]
1344    fn parse_bootstrap_status_accepts_wrapped_ok_record() {
1345        let status = parse_bootstrap_status_value(&json!({
1346            "Ok": {
1347                "ready": false,
1348                "phase": "failed",
1349                "last_error": "registry phase failed"
1350            }
1351        }))
1352        .expect("wrapped bootstrap status must parse");
1353
1354        assert!(!status.ready);
1355        assert_eq!(status.phase, "failed");
1356        assert_eq!(status.last_error.as_deref(), Some("registry phase failed"));
1357    }
1358
1359    #[test]
1360    fn parse_canister_status_cycles_accepts_balance_line() {
1361        let output = "\
1362Canister status call result for root.
1363Status: Running
1364Balance: 9_002_999_998_056_000 Cycles
1365Memory Size: 1_234_567 Bytes
1366";
1367
1368        assert_eq!(
1369            parse_canister_status_cycles(output),
1370            Some(9_002_999_998_056_000)
1371        );
1372    }
1373
1374    #[test]
1375    fn parse_canister_status_cycles_accepts_cycle_balance_line() {
1376        let output = "\
1377Canister status call result for root.
1378Cycle balance: 12_345 Cycles
1379";
1380
1381        assert_eq!(parse_canister_status_cycles(output), Some(12_345));
1382    }
1383
1384    #[test]
1385    fn required_local_cycle_topup_skips_when_balance_already_meets_target() {
1386        assert_eq!(required_local_cycle_topup(LOCAL_ROOT_TARGET_CYCLES), None);
1387        assert_eq!(
1388            required_local_cycle_topup(LOCAL_ROOT_TARGET_CYCLES + 1_000),
1389            None
1390        );
1391    }
1392
1393    #[test]
1394    fn required_local_cycle_topup_returns_missing_delta_only() {
1395        assert_eq!(
1396            required_local_cycle_topup(3_000_000_000_000),
1397            Some(8_997_000_000_000_000)
1398        );
1399    }
1400
1401    #[test]
1402    fn dfx_build_command_targets_one_canister_per_call() {
1403        let command = dfx_build_target_command(
1404            Path::new("/tmp/canic-dfx-root"),
1405            "user_hub",
1406            "install-root-test",
1407        );
1408
1409        assert_eq!(command.get_program(), "dfx");
1410        assert_eq!(
1411            command
1412                .get_args()
1413                .map(|arg| arg.to_string_lossy().into_owned())
1414                .collect::<Vec<_>>(),
1415            ["build", "-qq", "user_hub"]
1416        );
1417        assert_eq!(
1418            command
1419                .get_current_dir()
1420                .map(|path| path.to_string_lossy().into_owned()),
1421            Some("/tmp/canic-dfx-root".to_string())
1422        );
1423        assert!(
1424            command
1425                .get_envs()
1426                .any(|(key, value)| key == "CANIC_BUILD_CONTEXT_SESSION" && value.is_some()),
1427            "dfx build must carry the shared build-session marker"
1428        );
1429    }
1430
1431    #[test]
1432    fn install_build_session_id_is_prefixed_for_logs() {
1433        let session_id = install_build_session_id();
1434        assert!(session_id.starts_with("install-root-"));
1435    }
1436
1437    #[test]
1438    fn local_dfx_autostart_defaults_to_enabled() {
1439        assert!(parse_local_dfx_autostart(None));
1440        assert!(parse_local_dfx_autostart(Some("")));
1441        assert!(parse_local_dfx_autostart(Some("1")));
1442        assert!(parse_local_dfx_autostart(Some("true")));
1443    }
1444
1445    #[test]
1446    fn local_dfx_autostart_accepts_explicit_disable_values() {
1447        assert!(!parse_local_dfx_autostart(Some("0")));
1448        assert!(!parse_local_dfx_autostart(Some("false")));
1449        assert!(!parse_local_dfx_autostart(Some("no")));
1450        assert!(!parse_local_dfx_autostart(Some("off")));
1451    }
1452
1453    #[test]
1454    fn local_dfx_start_command_uses_clean_background_mode() {
1455        let command = dfx_start_local_command(Path::new("/tmp/canic-dfx-root"));
1456
1457        assert_eq!(command.get_program(), "dfx");
1458        assert_eq!(
1459            command
1460                .get_args()
1461                .map(|arg| arg.to_string_lossy().into_owned())
1462                .collect::<Vec<_>>(),
1463            ["start", "--background", "--clean", "--system-canisters"]
1464        );
1465        assert_eq!(
1466            command
1467                .get_current_dir()
1468                .map(|path| path.to_string_lossy().into_owned()),
1469            Some("/tmp/canic-dfx-root".to_string())
1470        );
1471    }
1472
1473    #[test]
1474    fn local_dfx_stop_command_targets_project_root() {
1475        let command = dfx_stop_command(Path::new("/tmp/canic-dfx-root"));
1476
1477        assert_eq!(command.get_program(), "dfx");
1478        assert_eq!(
1479            command
1480                .get_args()
1481                .map(|arg| arg.to_string_lossy().into_owned())
1482                .collect::<Vec<_>>(),
1483            ["stop"]
1484        );
1485        assert_eq!(
1486            command
1487                .get_current_dir()
1488                .map(|path| path.to_string_lossy().into_owned()),
1489            Some("/tmp/canic-dfx-root".to_string())
1490        );
1491    }
1492
1493    #[test]
1494    fn local_install_build_targets_use_root_subnet_release_roles_only() {
1495        let workspace_root = write_temp_workspace_config(
1496            r#"
1497[subnets.prime.canisters.root]
1498kind = "root"
1499
1500[subnets.prime.canisters.project_registry]
1501kind = "singleton"
1502
1503[subnets.prime.canisters.user_hub]
1504kind = "singleton"
1505
1506[subnets.extra.canisters.oracle_pokemon]
1507kind = "singleton"
1508"#,
1509        );
1510
1511        let targets =
1512            local_install_build_targets(&workspace_root.join("canisters/canic.toml"), "root")
1513                .expect("targets must resolve");
1514
1515        assert_eq!(
1516            targets,
1517            vec![
1518                "root".to_string(),
1519                "project_registry".to_string(),
1520                "user_hub".to_string()
1521            ]
1522        );
1523    }
1524
1525    #[test]
1526    fn install_config_defaults_to_project_config_when_present() {
1527        with_guarded_env(|| {
1528            let root = unique_temp_dir("canic-install-config-default");
1529            let config = root.join("canisters/canic.toml");
1530            fs::create_dir_all(config.parent().expect("config parent")).expect("create parent");
1531            fs::write(&config, "").expect("write config");
1532            let previous = env::var_os("CANIC_CONFIG_PATH");
1533            unsafe {
1534                env::remove_var("CANIC_CONFIG_PATH");
1535            }
1536
1537            let resolved = resolve_install_config_path(&root, None, false).expect("resolve config");
1538
1539            assert_eq!(resolved, config);
1540            restore_env_var("CANIC_CONFIG_PATH", previous);
1541            fs::remove_dir_all(root).expect("clean temp dir");
1542        });
1543    }
1544
1545    #[test]
1546    fn install_config_accepts_explicit_path() {
1547        let root = unique_temp_dir("canic-install-config-explicit");
1548        let resolved = resolve_install_config_path(&root, Some("canisters/demo/canic.toml"), false)
1549            .expect("resolve config");
1550
1551        assert_eq!(resolved, root.join("canisters/demo/canic.toml"));
1552        let _ = fs::remove_dir_all(root);
1553    }
1554
1555    #[test]
1556    fn install_config_error_lists_choices_when_project_default_missing() {
1557        with_guarded_env(|| {
1558            let root = unique_temp_dir("canic-install-config-choices");
1559            let demo = root.join("canisters/demo/canic.toml");
1560            let test = root.join("canisters/test/runtime_probe/canic.toml");
1561            fs::create_dir_all(demo.parent().expect("demo parent")).expect("create demo parent");
1562            fs::create_dir_all(test.parent().expect("test parent")).expect("create test parent");
1563            fs::create_dir_all(root.join("canisters/demo/root")).expect("create demo root");
1564            fs::write(
1565                &demo,
1566                r#"
1567[subnets.prime.canisters.root]
1568kind = "root"
1569
1570[subnets.prime.canisters.app]
1571kind = "singleton"
1572
1573[subnets.prime.canisters.user_hub]
1574kind = "singleton"
1575"#,
1576            )
1577            .expect("write demo config");
1578            fs::write(&test, "").expect("write test config");
1579            fs::write(root.join("canisters/demo/root/Cargo.toml"), "")
1580                .expect("write demo root manifest");
1581            let previous = env::var_os("CANIC_CONFIG_PATH");
1582            unsafe {
1583                env::remove_var("CANIC_CONFIG_PATH");
1584            }
1585
1586            let err =
1587                resolve_install_config_path(&root, None, false).expect_err("selection must fail");
1588            let message = err.to_string();
1589
1590            assert!(message.contains("missing default Canic config at canisters/canic.toml"));
1591            assert!(!message.contains("found one install config:"));
1592            assert!(message.contains("canisters/demo/canic.toml"));
1593            assert!(message.contains("2 (app, user_hub)"));
1594            assert!(message.contains("canisters/canic.toml\n\n#"));
1595            assert!(message.contains("2 (app, user_hub)\n\nrun:"));
1596            assert!(!message.contains("canisters/test/runtime_probe/canic.toml"));
1597            assert!(message.contains("run: canic install --config canisters/demo/canic.toml"));
1598
1599            restore_env_var("CANIC_CONFIG_PATH", previous);
1600            fs::remove_dir_all(root).expect("clean temp dir");
1601        });
1602    }
1603
1604    #[test]
1605    fn config_selection_error_is_whitespace_table() {
1606        let root = unique_temp_dir("canic-install-config-single-table");
1607        let config = root.join("canisters/demo/canic.toml");
1608        fs::create_dir_all(config.parent().expect("config parent")).expect("create config parent");
1609        fs::write(
1610            &config,
1611            r#"
1612[subnets.prime.canisters.root]
1613kind = "root"
1614
1615[subnets.prime.canisters.app]
1616kind = "singleton"
1617"#,
1618        )
1619        .expect("write config");
1620        let message = config_selection_error(
1621            &root,
1622            &root.join("canisters/canic.toml"),
1623            std::slice::from_ref(&config),
1624        );
1625
1626        assert!(message.contains('#'));
1627        assert!(message.contains("CONFIG"));
1628        assert!(message.contains("CANISTERS"));
1629        assert!(message.contains("canisters/demo/canic.toml"));
1630        assert!(message.contains("1 (app)"));
1631        assert!(message.contains("canisters/canic.toml\n\n#"));
1632        assert!(message.contains("1 (app)\n\nrun:"));
1633        assert!(message.contains("run: canic install --config canisters/demo/canic.toml"));
1634        fs::remove_dir_all(root).expect("clean temp dir");
1635    }
1636
1637    #[test]
1638    fn config_selection_error_lists_multiple_paths_with_numbered_options() {
1639        let root = unique_temp_dir("canic-install-config-multiple-table");
1640        let demo = root.join("canisters/demo/canic.toml");
1641        let example = root.join("canisters/example/canic.toml");
1642        fs::create_dir_all(demo.parent().expect("demo parent")).expect("create demo parent");
1643        fs::create_dir_all(example.parent().expect("example parent"))
1644            .expect("create example parent");
1645        fs::write(
1646            &demo,
1647            r#"
1648[subnets.prime.canisters.root]
1649kind = "root"
1650
1651[subnets.prime.canisters.app]
1652kind = "singleton"
1653"#,
1654        )
1655        .expect("write demo config");
1656        fs::write(
1657            &example,
1658            r#"
1659[subnets.prime.canisters.root]
1660kind = "root"
1661
1662[subnets.prime.canisters.user_hub]
1663kind = "singleton"
1664
1665[subnets.prime.canisters.user_shard]
1666kind = "singleton"
1667
1668[subnets.prime.canisters.scale]
1669kind = "singleton"
1670
1671[subnets.prime.canisters.scale_hub]
1672kind = "singleton"
1673"#,
1674        )
1675        .expect("write example config");
1676        let message =
1677            config_selection_error(&root, &root.join("canisters/canic.toml"), &[demo, example]);
1678
1679        assert!(message.contains("choose a config path explicitly:"));
1680        assert!(message.contains("choose a config path explicitly:\n\n#"));
1681        assert!(message.contains('#'));
1682        assert!(message.contains("CONFIG"));
1683        assert!(message.contains("CANISTERS"));
1684        assert!(message.contains("1  canisters/demo/canic.toml"));
1685        assert!(message.contains("2  canisters/example/canic.toml"));
1686        assert!(message.contains("canisters/demo/canic.toml"));
1687        assert!(message.contains("1 (app)"));
1688        assert!(message.contains("canisters/example/canic.toml"));
1689        assert!(message.contains("4 (scale, scale_hub, user_hub, user_shard)"));
1690        assert!(message.contains("4 (scale, scale_hub, user_hub, user_shard)\n\nrun:"));
1691        assert!(message.contains("run: canic install --config <path>"));
1692        fs::remove_dir_all(root).expect("clean temp dir");
1693    }
1694
1695    #[test]
1696    fn discovered_install_config_choices_are_path_sorted() {
1697        let root = unique_temp_dir("canic-install-config-sorted");
1698        let alpha = root.join("alpha/canic.toml");
1699        let zeta = root.join("zeta/canic.toml");
1700        fs::create_dir_all(alpha.parent().expect("alpha parent").join("root"))
1701            .expect("create alpha root");
1702        fs::create_dir_all(zeta.parent().expect("zeta parent").join("root"))
1703            .expect("create zeta root");
1704        fs::write(&zeta, "").expect("write zeta config");
1705        fs::write(&alpha, "").expect("write alpha config");
1706        fs::write(
1707            alpha
1708                .parent()
1709                .expect("alpha parent")
1710                .join("root/Cargo.toml"),
1711            "",
1712        )
1713        .expect("write alpha root manifest");
1714        fs::write(
1715            zeta.parent().expect("zeta parent").join("root/Cargo.toml"),
1716            "",
1717        )
1718        .expect("write zeta root manifest");
1719
1720        let choices = discover_canic_config_choices(&root).expect("discover choices");
1721
1722        assert_eq!(choices, vec![alpha, zeta]);
1723        fs::remove_dir_all(root).expect("clean temp dir");
1724    }
1725
1726    #[test]
1727    fn install_state_path_is_scoped_by_network() {
1728        assert_eq!(
1729            fleet_install_state_path(Path::new("/tmp/canic-project"), "local", "demo"),
1730            PathBuf::from("/tmp/canic-project/.canic/local/fleets/demo.json")
1731        );
1732        assert_eq!(
1733            current_fleet_path(Path::new("/tmp/canic-project"), "local"),
1734            PathBuf::from("/tmp/canic-project/.canic/local/current-fleet")
1735        );
1736    }
1737
1738    #[test]
1739    fn install_state_round_trips_from_project_state_dir() {
1740        let root = unique_temp_dir("canic-install-state");
1741        let state = InstallState {
1742            schema_version: INSTALL_STATE_SCHEMA_VERSION,
1743            fleet: "demo".to_string(),
1744            installed_at_unix_secs: 42,
1745            network: "local".to_string(),
1746            root_target: "root".to_string(),
1747            root_canister_id: "uxrrr-q7777-77774-qaaaq-cai".to_string(),
1748            root_build_target: "root".to_string(),
1749            workspace_root: root.display().to_string(),
1750            dfx_root: root.display().to_string(),
1751            config_path: root.join("canisters/canic.toml").display().to_string(),
1752            release_set_manifest_path: root
1753                .join(".dfx/local/canisters/root/root.release-set.json")
1754                .display()
1755                .to_string(),
1756        };
1757
1758        let path = write_install_state(&root, "local", &state).expect("write state");
1759        let read_back = read_install_state(&root, "local")
1760            .expect("read state")
1761            .expect("state exists");
1762        let named = read_fleet_install_state(&root, "local", "demo")
1763            .expect("read named fleet")
1764            .expect("named fleet exists");
1765        let fleets = list_fleets(&root, "local").expect("list fleets");
1766
1767        assert_eq!(path, root.join(".canic/local/fleets/demo.json"));
1768        assert_eq!(read_back, state);
1769        assert_eq!(named, state);
1770        assert_eq!(fleets.len(), 1);
1771        assert_eq!(fleets[0].name, "demo");
1772        assert!(fleets[0].current);
1773
1774        fs::remove_dir_all(root).expect("clean temp dir");
1775    }
1776
1777    fn write_temp_workspace_config(config_source: &str) -> PathBuf {
1778        let root = unique_temp_dir("canic-install-root-test");
1779        fs::create_dir_all(root.join("canisters")).expect("temp canisters dir must be created");
1780        fs::write(root.join("canisters/canic.toml"), config_source)
1781            .expect("temp canic.toml must be written");
1782        root
1783    }
1784
1785    fn unique_temp_dir(prefix: &str) -> PathBuf {
1786        let unique = SystemTime::now()
1787            .duration_since(UNIX_EPOCH)
1788            .expect("clock must be monotonic enough for test temp dir")
1789            .as_nanos();
1790        std::env::temp_dir().join(format!("{prefix}-{}-{unique}", std::process::id()))
1791    }
1792
1793    fn with_guarded_env(run: impl FnOnce()) {
1794        let lock = ENV_LOCK.get_or_init(|| Mutex::new(()));
1795        let _guard = lock.lock().expect("env lock poisoned");
1796        run();
1797    }
1798
1799    fn restore_env_var(key: &str, previous: Option<std::ffi::OsString>) {
1800        unsafe {
1801            if let Some(value) = previous {
1802                env::set_var(key, value);
1803            } else {
1804                env::remove_var(key);
1805            }
1806        }
1807    }
1808}