Skip to main content

canic_installer/
install_root.rs

1use crate::release_set::{
2    config_path, configured_install_targets, dfx_call, dfx_root, emit_root_release_set_manifest,
3    load_root_release_set_manifest, resolve_artifact_root, resume_root_bootstrap,
4    root_release_set_manifest_path, stage_root_release_set, workspace_root,
5};
6use canic_core::protocol;
7use serde::Deserialize;
8use serde_json::Value;
9use std::{
10    env,
11    path::Path,
12    process::Command,
13    thread,
14    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
15};
16
17///
18/// InstallRootOptions
19///
20
21#[derive(Clone, Debug)]
22pub struct InstallRootOptions {
23    pub root_canister: String,
24    pub network: String,
25    pub ready_timeout_seconds: u64,
26}
27
28///
29/// BootstrapStatusSnapshot
30///
31
32#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
33struct BootstrapStatusSnapshot {
34    ready: bool,
35    phase: String,
36    last_error: Option<String>,
37}
38
39///
40/// InstallTimingSummary
41///
42
43#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
44struct InstallTimingSummary {
45    create_canisters: Duration,
46    build_all: Duration,
47    emit_manifest: Duration,
48    fabricate_cycles: Duration,
49    install_root: Duration,
50    stage_release_set: Duration,
51    resume_bootstrap: Duration,
52    wait_ready: Duration,
53}
54
55const LOCAL_ROOT_TARGET_CYCLES: u128 = 9_000_000_000_000_000;
56const LOCAL_DFX_READY_TIMEOUT_SECONDS: u64 = 30;
57
58impl InstallRootOptions {
59    // Resolve the current local-root install options from args and environment.
60    #[must_use]
61    pub fn from_env_and_args() -> Self {
62        Self {
63            root_canister: env::args()
64                .nth(1)
65                .or_else(|| env::var("ROOT_CANISTER").ok())
66                .unwrap_or_else(|| "root".to_string()),
67            network: env::var("DFX_NETWORK").unwrap_or_else(|_| "local".to_string()),
68            ready_timeout_seconds: env::var("READY_TIMEOUT_SECONDS")
69                .ok()
70                .and_then(|value| value.parse::<u64>().ok())
71                .unwrap_or(120),
72        }
73    }
74}
75
76// Execute the local thin-root install flow against an already running replica.
77pub fn install_root(options: InstallRootOptions) -> Result<(), Box<dyn std::error::Error>> {
78    let workspace_root = workspace_root()?;
79    let dfx_root = dfx_root()?;
80    let total_started_at = Instant::now();
81    let mut timings = InstallTimingSummary::default();
82
83    println!("Installing root against DFX_NETWORK={}", options.network);
84    ensure_dfx_running(&dfx_root, &options.network)?;
85    let mut create = Command::new("dfx");
86    create
87        .current_dir(&dfx_root)
88        .args(["canister", "create", "--all", "-qq"]);
89    let create_started_at = Instant::now();
90    run_command(&mut create)?;
91    timings.create_canisters = create_started_at.elapsed();
92
93    let build_targets = local_install_build_targets(&workspace_root, &options.root_canister)?;
94    let build_session_id = install_build_session_id();
95    let build_started_at = Instant::now();
96    run_dfx_build_targets(&dfx_root, &build_targets, &build_session_id)?;
97    timings.build_all = build_started_at.elapsed();
98
99    let emit_manifest_started_at = Instant::now();
100    let manifest_path =
101        emit_root_release_set_manifest(&workspace_root, &dfx_root, &options.network)?;
102    timings.emit_manifest = emit_manifest_started_at.elapsed();
103
104    timings.fabricate_cycles =
105        maybe_fabricate_local_cycles(&dfx_root, &options.root_canister, &options.network)?;
106
107    let mut install = Command::new("dfx");
108    install.current_dir(&dfx_root).args([
109        "canister",
110        "install",
111        &options.root_canister,
112        "--mode=reinstall",
113        "-y",
114        "--argument",
115        "(variant { Prime })",
116    ]);
117    let install_started_at = Instant::now();
118    run_command(&mut install)?;
119    timings.install_root = install_started_at.elapsed();
120
121    let artifact_root = resolve_artifact_root(&dfx_root, &options.network)?;
122    let manifest =
123        load_root_release_set_manifest(&root_release_set_manifest_path(&artifact_root)?)?;
124    assert_eq!(
125        manifest_path,
126        root_release_set_manifest_path(&artifact_root)?
127    );
128    let stage_started_at = Instant::now();
129    stage_root_release_set(&dfx_root, &options.root_canister, &manifest)?;
130    timings.stage_release_set = stage_started_at.elapsed();
131    let resume_started_at = Instant::now();
132    resume_root_bootstrap(&options.root_canister)?;
133    timings.resume_bootstrap = resume_started_at.elapsed();
134    let ready_started_at = Instant::now();
135    let ready_result = wait_for_root_ready(&options.root_canister, options.ready_timeout_seconds);
136    timings.wait_ready = ready_started_at.elapsed();
137    if let Err(err) = ready_result {
138        print_install_timing_summary(&timings, total_started_at.elapsed());
139        return Err(err);
140    }
141
142    print_install_timing_summary(&timings, total_started_at.elapsed());
143    println!("Root installed successfully");
144    println!(
145        "Smoke check: dfx canister call {} canic_ready",
146        options.root_canister
147    );
148    Ok(())
149}
150
151// Resolve the local install build set from the root canister plus the
152// configured ordinary roles owned by the root subnet.
153fn local_install_build_targets(
154    workspace_root: &Path,
155    root_canister: &str,
156) -> Result<Vec<String>, Box<dyn std::error::Error>> {
157    configured_install_targets(&config_path(workspace_root), root_canister)
158}
159
160// Run one `dfx build <canister>` call per configured local install target.
161fn run_dfx_build_targets(
162    dfx_root: &Path,
163    targets: &[String],
164    build_session_id: &str,
165) -> Result<(), Box<dyn std::error::Error>> {
166    for target in targets {
167        let mut command = dfx_build_target_command(dfx_root, target, build_session_id);
168        run_command(&mut command)?;
169    }
170
171    Ok(())
172}
173
174// Spawn one local `dfx build <canister>` step without overriding the caller's
175// selected build profile environment.
176fn dfx_build_target_command(dfx_root: &Path, target: &str, build_session_id: &str) -> Command {
177    let mut command = Command::new("dfx");
178    command
179        .current_dir(dfx_root)
180        .env("CANIC_BUILD_CONTEXT_SESSION", build_session_id)
181        .args(["build", "-qq", target]);
182    command
183}
184
185fn install_build_session_id() -> String {
186    let unique = SystemTime::now()
187        .duration_since(UNIX_EPOCH)
188        .map_or(0, |duration| duration.as_nanos());
189    format!("install-root-{}-{unique}", std::process::id())
190}
191
192// Top up local root cycles only when the current balance is below the target floor.
193fn maybe_fabricate_local_cycles(
194    dfx_root: &Path,
195    root_canister: &str,
196    network: &str,
197) -> Result<Duration, Box<dyn std::error::Error>> {
198    if network != "local" {
199        return Ok(Duration::ZERO);
200    }
201
202    let current_balance = root_cycle_balance(dfx_root, root_canister)?;
203    let Some(fabricate_cycles) = required_local_cycle_topup(current_balance) else {
204        println!(
205            "Skipping local cycle fabrication for {root_canister}; balance {} already meets target {}",
206            format_cycles(current_balance),
207            format_cycles(LOCAL_ROOT_TARGET_CYCLES)
208        );
209        return Ok(Duration::ZERO);
210    };
211
212    println!(
213        "Fabricating {} cycles locally for {root_canister} to reach target {} (current balance {})",
214        format_cycles(fabricate_cycles),
215        format_cycles(LOCAL_ROOT_TARGET_CYCLES),
216        format_cycles(current_balance)
217    );
218
219    let mut fabricate = Command::new("dfx");
220    fabricate.current_dir(dfx_root);
221    fabricate.args([
222        "ledger",
223        "fabricate-cycles",
224        "--canister",
225        root_canister,
226        "--cycles",
227        &fabricate_cycles.to_string(),
228    ]);
229    let fabricate_started_at = Instant::now();
230    let _ = run_command_allow_failure(&mut fabricate)?;
231
232    Ok(fabricate_started_at.elapsed())
233}
234
235// Read the current root canister cycle balance from `dfx canister status`.
236fn root_cycle_balance(
237    dfx_root: &Path,
238    root_canister: &str,
239) -> Result<u128, Box<dyn std::error::Error>> {
240    let output = Command::new("dfx")
241        .current_dir(dfx_root)
242        .args(["canister", "status", root_canister])
243        .output()?;
244    if !output.status.success() {
245        return Err(format!("dfx canister status failed: {}", output.status).into());
246    }
247
248    let stdout = String::from_utf8(output.stdout)?;
249    parse_canister_status_cycles(&stdout)
250        .ok_or_else(|| "could not parse cycle balance from `dfx canister status` output".into())
251}
252
253// Parse the cycle balance from the human-readable `dfx canister status` output.
254fn parse_canister_status_cycles(status_output: &str) -> Option<u128> {
255    status_output
256        .lines()
257        .find_map(parse_canister_status_balance_line)
258}
259
260fn parse_canister_status_balance_line(line: &str) -> Option<u128> {
261    let (label, value) = line.trim().split_once(':')?;
262    let label = label.trim().to_ascii_lowercase();
263    if label != "balance" && label != "cycle balance" {
264        return None;
265    }
266
267    let digits = value
268        .chars()
269        .filter(char::is_ascii_digit)
270        .collect::<String>();
271    if digits.is_empty() {
272        return None;
273    }
274
275    digits.parse::<u128>().ok()
276}
277
278// Return the local top-up delta needed to bring root up to the target cycle floor.
279fn required_local_cycle_topup(current_balance: u128) -> Option<u128> {
280    (current_balance < LOCAL_ROOT_TARGET_CYCLES)
281        .then_some(LOCAL_ROOT_TARGET_CYCLES.saturating_sub(current_balance))
282        .filter(|cycles| *cycles > 0)
283}
284
285fn format_cycles(value: u128) -> String {
286    let digits = value.to_string();
287    let mut out = String::with_capacity(digits.len() + (digits.len().saturating_sub(1) / 3));
288    for (index, ch) in digits.chars().enumerate() {
289        if index > 0 && (digits.len() - index).is_multiple_of(3) {
290            out.push('_');
291        }
292        out.push(ch);
293    }
294    out
295}
296
297// Ensure the requested replica is reachable before the local install flow begins.
298fn ensure_dfx_running(dfx_root: &Path, network: &str) -> Result<(), Box<dyn std::error::Error>> {
299    if dfx_ping(network)? {
300        return Ok(());
301    }
302
303    if network == "local" && local_dfx_autostart_enabled() {
304        println!("Local dfx replica is not reachable; starting a clean local replica");
305        let mut stop = dfx_stop_command(dfx_root);
306        let _ = run_command_allow_failure(&mut stop)?;
307
308        let mut start = dfx_start_local_command(dfx_root);
309        run_command(&mut start)?;
310        wait_for_dfx_ping(
311            network,
312            Duration::from_secs(LOCAL_DFX_READY_TIMEOUT_SECONDS),
313        )?;
314        return Ok(());
315    }
316
317    Err(format!(
318        "dfx replica is not running for network '{network}'\nStart the target replica externally and rerun."
319    )
320    .into())
321}
322
323// Check whether `dfx ping <network>` currently succeeds.
324fn dfx_ping(network: &str) -> Result<bool, Box<dyn std::error::Error>> {
325    Ok(Command::new("dfx")
326        .args(["ping", network])
327        .output()?
328        .status
329        .success())
330}
331
332// Return true when the local install flow should auto-start a clean local replica.
333fn local_dfx_autostart_enabled() -> bool {
334    parse_local_dfx_autostart(env::var("CANIC_AUTO_START_LOCAL_DFX").ok().as_deref())
335}
336
337fn parse_local_dfx_autostart(value: Option<&str>) -> bool {
338    !matches!(
339        value.map(str::trim).map(str::to_ascii_lowercase).as_deref(),
340        Some("0" | "false" | "no" | "off")
341    )
342}
343
344// Spawn one local `dfx stop` command for cleanup before a clean restart.
345fn dfx_stop_command(dfx_root: &Path) -> Command {
346    let mut command = Command::new("dfx");
347    command.current_dir(dfx_root).arg("stop");
348    command
349}
350
351// Spawn one clean background `dfx start` command for local install/test flows.
352fn dfx_start_local_command(dfx_root: &Path) -> Command {
353    let mut command = Command::new("dfx");
354    command
355        .current_dir(dfx_root)
356        .args(["start", "--background", "--clean", "--system-canisters"]);
357    command
358}
359
360// Poll `dfx ping` until the requested network responds or the timeout expires.
361fn wait_for_dfx_ping(network: &str, timeout: Duration) -> Result<(), Box<dyn std::error::Error>> {
362    let start = Instant::now();
363    while start.elapsed() < timeout {
364        if dfx_ping(network)? {
365            return Ok(());
366        }
367        thread::sleep(Duration::from_millis(500));
368    }
369
370    Err(format!(
371        "dfx replica did not become ready for network '{network}' within {}s",
372        timeout.as_secs()
373    )
374    .into())
375}
376
377// Wait until root reports ready, printing periodic progress and diagnostics.
378fn wait_for_root_ready(
379    root_canister: &str,
380    timeout_seconds: u64,
381) -> Result<(), Box<dyn std::error::Error>> {
382    let start = std::time::Instant::now();
383    let mut next_report = 0_u64;
384
385    println!("Waiting for {root_canister} to report canic_ready (timeout {timeout_seconds}s)");
386
387    loop {
388        if root_ready(root_canister)? {
389            println!(
390                "{root_canister} reported canic_ready after {}s",
391                start.elapsed().as_secs()
392            );
393            return Ok(());
394        }
395
396        if let Some(status) = root_bootstrap_status(root_canister)?
397            && let Some(last_error) = status.last_error.as_deref()
398        {
399            eprintln!(
400                "root bootstrap reported failure during phase '{}' : {}",
401                status.phase, last_error
402            );
403            eprintln!("Diagnostic: dfx canister call {root_canister} canic_bootstrap_status");
404            print_raw_call(root_canister, protocol::CANIC_BOOTSTRAP_STATUS);
405            eprintln!("Diagnostic: dfx canister call {root_canister} canic_subnet_registry");
406            print_raw_call(root_canister, "canic_subnet_registry");
407            eprintln!(
408                "Diagnostic: dfx canister call {root_canister} canic_wasm_store_bootstrap_debug"
409            );
410            print_raw_call(root_canister, "canic_wasm_store_bootstrap_debug");
411            eprintln!("Diagnostic: dfx canister call {root_canister} canic_wasm_store_overview");
412            print_raw_call(root_canister, "canic_wasm_store_overview");
413            eprintln!("Diagnostic: dfx canister call {root_canister} canic_log");
414            print_recent_root_logs(root_canister);
415            return Err(format!(
416                "root bootstrap failed during phase '{}' : {}",
417                status.phase, last_error
418            )
419            .into());
420        }
421
422        let elapsed = start.elapsed().as_secs();
423        if elapsed >= timeout_seconds {
424            eprintln!("root did not report canic_ready within {timeout_seconds}s");
425            eprintln!("Diagnostic: dfx canister call {root_canister} canic_bootstrap_status");
426            print_raw_call(root_canister, protocol::CANIC_BOOTSTRAP_STATUS);
427            eprintln!("Diagnostic: dfx canister call {root_canister} canic_subnet_registry");
428            print_raw_call(root_canister, "canic_subnet_registry");
429            eprintln!(
430                "Diagnostic: dfx canister call {root_canister} canic_wasm_store_bootstrap_debug"
431            );
432            print_raw_call(root_canister, "canic_wasm_store_bootstrap_debug");
433            eprintln!("Diagnostic: dfx canister call {root_canister} canic_wasm_store_overview");
434            print_raw_call(root_canister, "canic_wasm_store_overview");
435            eprintln!("Diagnostic: dfx canister call {root_canister} canic_log");
436            print_recent_root_logs(root_canister);
437            return Err("root did not become ready".into());
438        }
439
440        if elapsed >= next_report {
441            println!("Still waiting for {root_canister} canic_ready ({elapsed}s elapsed)");
442            if let Some(status) = root_bootstrap_status(root_canister)? {
443                match status.last_error.as_deref() {
444                    Some(last_error) => println!(
445                        "Current bootstrap status: phase={} ready={} error={}",
446                        status.phase, status.ready, last_error
447                    ),
448                    None => println!(
449                        "Current bootstrap status: phase={} ready={}",
450                        status.phase, status.ready
451                    ),
452                }
453            }
454            if let Ok(registry_json) =
455                dfx_call(root_canister, "canic_subnet_registry", None, Some("json"))
456            {
457                println!("Current subnet registry roles:");
458                println!("  {}", registry_roles(&registry_json));
459            }
460            next_report = elapsed + 5;
461        }
462
463        thread::sleep(Duration::from_secs(1));
464    }
465}
466
467// Return true once root reports `canic_ready == true`.
468fn root_ready(root_canister: &str) -> Result<bool, Box<dyn std::error::Error>> {
469    let output = dfx_call(root_canister, "canic_ready", None, Some("json"))?;
470    let data = serde_json::from_str::<Value>(&output)?;
471    Ok(parse_root_ready_value(&data))
472}
473
474// Return the current root bootstrap diagnostic state when the query is available.
475fn root_bootstrap_status(
476    root_canister: &str,
477) -> Result<Option<BootstrapStatusSnapshot>, Box<dyn std::error::Error>> {
478    let output = match dfx_call(
479        root_canister,
480        protocol::CANIC_BOOTSTRAP_STATUS,
481        None,
482        Some("json"),
483    ) {
484        Ok(output) => output,
485        Err(err) => {
486            let message = err.to_string();
487            if message.contains("has no query method")
488                || message.contains("method not found")
489                || message.contains("Canister has no query method")
490            {
491                return Ok(None);
492            }
493            return Err(err);
494        }
495    };
496    let data = serde_json::from_str::<Value>(&output)?;
497    Ok(parse_bootstrap_status_value(&data))
498}
499
500// Accept both plain-bool and wrapped-result JSON shapes from `dfx --output json`.
501fn parse_root_ready_value(data: &Value) -> bool {
502    matches!(data, Value::Bool(true)) || matches!(data.get("Ok"), Some(Value::Bool(true)))
503}
504
505fn parse_bootstrap_status_value(data: &Value) -> Option<BootstrapStatusSnapshot> {
506    serde_json::from_value::<BootstrapStatusSnapshot>(data.clone())
507        .ok()
508        .or_else(|| {
509            data.get("Ok")
510                .cloned()
511                .and_then(|ok| serde_json::from_value::<BootstrapStatusSnapshot>(ok).ok())
512        })
513}
514
515fn print_install_timing_summary(timings: &InstallTimingSummary, total: Duration) {
516    println!("Install timing summary:");
517    println!("{:<20} {:>10}", "phase", "elapsed");
518    println!("{:<20} {:>10}", "--------------------", "----------");
519    print_timing_row("create_canisters", timings.create_canisters);
520    print_timing_row("build_all", timings.build_all);
521    print_timing_row("emit_manifest", timings.emit_manifest);
522    print_timing_row("fabricate_cycles", timings.fabricate_cycles);
523    print_timing_row("install_root", timings.install_root);
524    print_timing_row("stage_release_set", timings.stage_release_set);
525    print_timing_row("resume_bootstrap", timings.resume_bootstrap);
526    print_timing_row("wait_ready", timings.wait_ready);
527    print_timing_row("total", total);
528}
529
530fn print_timing_row(label: &str, duration: Duration) {
531    println!("{label:<20} {:>9.2}s", duration.as_secs_f64());
532}
533
534// Print recent structured root log entries without raw byte dumps.
535fn print_recent_root_logs(root_canister: &str) {
536    let page_args = r"(null, null, null, record { limit = 8; offset = 0 })";
537    let Ok(logs_json) = dfx_call(root_canister, "canic_log", Some(page_args), Some("json")) else {
538        return;
539    };
540    let Ok(data) = serde_json::from_str::<Value>(&logs_json) else {
541        return;
542    };
543    let entries = data
544        .get("Ok")
545        .and_then(|ok| ok.get("entries"))
546        .and_then(Value::as_array)
547        .cloned()
548        .unwrap_or_default();
549
550    if entries.is_empty() {
551        println!("  <no runtime log entries>");
552        return;
553    }
554
555    for entry in entries.iter().rev() {
556        let level = entry.get("level").and_then(Value::as_str).unwrap_or("Info");
557        let topic = entry.get("topic").and_then(Value::as_str).unwrap_or("");
558        let message = entry
559            .get("message")
560            .and_then(Value::as_str)
561            .unwrap_or("")
562            .replace('\n', "\\n");
563        let topic_prefix = if topic.is_empty() {
564            String::new()
565        } else {
566            format!("[{topic}] ")
567        };
568        println!("  {level} {topic_prefix}{message}");
569    }
570}
571
572// Render the current subnet registry roles from one JSON response.
573fn registry_roles(registry_json: &str) -> String {
574    serde_json::from_str::<Value>(registry_json)
575        .ok()
576        .and_then(|data| {
577            data.get("Ok").and_then(Value::as_array).map(|entries| {
578                entries
579                    .iter()
580                    .filter_map(|entry| {
581                        entry
582                            .get("role")
583                            .and_then(Value::as_str)
584                            .map(str::to_string)
585                    })
586                    .collect::<Vec<_>>()
587            })
588        })
589        .map_or_else(
590            || "<unavailable>".to_string(),
591            |roles| {
592                if roles.is_empty() {
593                    "<empty>".to_string()
594                } else {
595                    roles.join(", ")
596                }
597            },
598        )
599}
600
601// Run one command and require a zero exit status.
602fn run_command(command: &mut Command) -> Result<(), Box<dyn std::error::Error>> {
603    let status = command.status()?;
604    if status.success() {
605        Ok(())
606    } else {
607        Err(format!("command failed: {status}").into())
608    }
609}
610
611// Run one command and return its status without failing the caller on non-zero exit.
612fn run_command_allow_failure(
613    command: &mut Command,
614) -> Result<std::process::ExitStatus, Box<dyn std::error::Error>> {
615    Ok(command.status()?)
616}
617
618// Print one raw fallback `dfx canister call` result to stderr for diagnostics.
619fn print_raw_call(root_canister: &str, method: &str) {
620    let mut command = Command::new("dfx");
621    if let Ok(root) = dfx_root() {
622        command.current_dir(root);
623    }
624    let _ = command
625        .args(["canister", "call", root_canister, method])
626        .status();
627}
628
629#[cfg(test)]
630mod tests {
631    use super::{
632        LOCAL_ROOT_TARGET_CYCLES, dfx_build_target_command, dfx_start_local_command,
633        dfx_stop_command, install_build_session_id, local_install_build_targets,
634        parse_bootstrap_status_value, parse_canister_status_cycles, parse_local_dfx_autostart,
635        parse_root_ready_value, required_local_cycle_topup,
636    };
637    use serde_json::json;
638    use std::{
639        fs,
640        path::{Path, PathBuf},
641        time::{SystemTime, UNIX_EPOCH},
642    };
643
644    #[test]
645    fn parse_root_ready_accepts_plain_true() {
646        assert!(parse_root_ready_value(&json!(true)));
647    }
648
649    #[test]
650    fn parse_root_ready_accepts_wrapped_ok_true() {
651        assert!(parse_root_ready_value(&json!({ "Ok": true })));
652    }
653
654    #[test]
655    fn parse_root_ready_rejects_false_shapes() {
656        assert!(!parse_root_ready_value(&json!(false)));
657        assert!(!parse_root_ready_value(&json!({ "Ok": false })));
658        assert!(!parse_root_ready_value(&json!({ "Err": "nope" })));
659    }
660
661    #[test]
662    fn parse_bootstrap_status_accepts_plain_record() {
663        let status = parse_bootstrap_status_value(&json!({
664            "ready": false,
665            "phase": "root:init:create_canisters",
666            "last_error": null
667        }))
668        .expect("plain bootstrap status must parse");
669
670        assert!(!status.ready);
671        assert_eq!(status.phase, "root:init:create_canisters");
672        assert_eq!(status.last_error, None);
673    }
674
675    #[test]
676    fn parse_bootstrap_status_accepts_wrapped_ok_record() {
677        let status = parse_bootstrap_status_value(&json!({
678            "Ok": {
679                "ready": false,
680                "phase": "failed",
681                "last_error": "registry phase failed"
682            }
683        }))
684        .expect("wrapped bootstrap status must parse");
685
686        assert!(!status.ready);
687        assert_eq!(status.phase, "failed");
688        assert_eq!(status.last_error.as_deref(), Some("registry phase failed"));
689    }
690
691    #[test]
692    fn parse_canister_status_cycles_accepts_balance_line() {
693        let output = "\
694Canister status call result for root.
695Status: Running
696Balance: 9_002_999_998_056_000 Cycles
697Memory Size: 1_234_567 Bytes
698";
699
700        assert_eq!(
701            parse_canister_status_cycles(output),
702            Some(9_002_999_998_056_000)
703        );
704    }
705
706    #[test]
707    fn parse_canister_status_cycles_accepts_cycle_balance_line() {
708        let output = "\
709Canister status call result for root.
710Cycle balance: 12_345 Cycles
711";
712
713        assert_eq!(parse_canister_status_cycles(output), Some(12_345));
714    }
715
716    #[test]
717    fn required_local_cycle_topup_skips_when_balance_already_meets_target() {
718        assert_eq!(required_local_cycle_topup(LOCAL_ROOT_TARGET_CYCLES), None);
719        assert_eq!(
720            required_local_cycle_topup(LOCAL_ROOT_TARGET_CYCLES + 1_000),
721            None
722        );
723    }
724
725    #[test]
726    fn required_local_cycle_topup_returns_missing_delta_only() {
727        assert_eq!(
728            required_local_cycle_topup(3_000_000_000_000),
729            Some(8_997_000_000_000_000)
730        );
731    }
732
733    #[test]
734    fn dfx_build_command_targets_one_canister_per_call() {
735        let command = dfx_build_target_command(
736            Path::new("/tmp/canic-dfx-root"),
737            "user_hub",
738            "install-root-test",
739        );
740
741        assert_eq!(command.get_program(), "dfx");
742        assert_eq!(
743            command
744                .get_args()
745                .map(|arg| arg.to_string_lossy().into_owned())
746                .collect::<Vec<_>>(),
747            ["build", "-qq", "user_hub"]
748        );
749        assert_eq!(
750            command
751                .get_current_dir()
752                .map(|path| path.to_string_lossy().into_owned()),
753            Some("/tmp/canic-dfx-root".to_string())
754        );
755        assert!(
756            command
757                .get_envs()
758                .any(|(key, value)| key == "CANIC_BUILD_CONTEXT_SESSION" && value.is_some()),
759            "dfx build must carry the shared build-session marker"
760        );
761    }
762
763    #[test]
764    fn install_build_session_id_is_prefixed_for_logs() {
765        let session_id = install_build_session_id();
766        assert!(session_id.starts_with("install-root-"));
767    }
768
769    #[test]
770    fn local_dfx_autostart_defaults_to_enabled() {
771        assert!(parse_local_dfx_autostart(None));
772        assert!(parse_local_dfx_autostart(Some("")));
773        assert!(parse_local_dfx_autostart(Some("1")));
774        assert!(parse_local_dfx_autostart(Some("true")));
775    }
776
777    #[test]
778    fn local_dfx_autostart_accepts_explicit_disable_values() {
779        assert!(!parse_local_dfx_autostart(Some("0")));
780        assert!(!parse_local_dfx_autostart(Some("false")));
781        assert!(!parse_local_dfx_autostart(Some("no")));
782        assert!(!parse_local_dfx_autostart(Some("off")));
783    }
784
785    #[test]
786    fn local_dfx_start_command_uses_clean_background_mode() {
787        let command = dfx_start_local_command(Path::new("/tmp/canic-dfx-root"));
788
789        assert_eq!(command.get_program(), "dfx");
790        assert_eq!(
791            command
792                .get_args()
793                .map(|arg| arg.to_string_lossy().into_owned())
794                .collect::<Vec<_>>(),
795            ["start", "--background", "--clean", "--system-canisters"]
796        );
797        assert_eq!(
798            command
799                .get_current_dir()
800                .map(|path| path.to_string_lossy().into_owned()),
801            Some("/tmp/canic-dfx-root".to_string())
802        );
803    }
804
805    #[test]
806    fn local_dfx_stop_command_targets_project_root() {
807        let command = dfx_stop_command(Path::new("/tmp/canic-dfx-root"));
808
809        assert_eq!(command.get_program(), "dfx");
810        assert_eq!(
811            command
812                .get_args()
813                .map(|arg| arg.to_string_lossy().into_owned())
814                .collect::<Vec<_>>(),
815            ["stop"]
816        );
817        assert_eq!(
818            command
819                .get_current_dir()
820                .map(|path| path.to_string_lossy().into_owned()),
821            Some("/tmp/canic-dfx-root".to_string())
822        );
823    }
824
825    #[test]
826    fn local_install_build_targets_use_root_subnet_release_roles_only() {
827        let workspace_root = write_temp_workspace_config(
828            r#"
829[subnets.prime.canisters.root]
830kind = "root"
831
832[subnets.prime.canisters.project_registry]
833kind = "singleton"
834
835[subnets.prime.canisters.user_hub]
836kind = "singleton"
837
838[subnets.extra.canisters.oracle_pokemon]
839kind = "singleton"
840"#,
841        );
842
843        let targets =
844            local_install_build_targets(&workspace_root, "root").expect("targets must resolve");
845
846        assert_eq!(
847            targets,
848            vec![
849                "root".to_string(),
850                "project_registry".to_string(),
851                "user_hub".to_string()
852            ]
853        );
854    }
855
856    fn write_temp_workspace_config(config_source: &str) -> PathBuf {
857        let unique = SystemTime::now()
858            .duration_since(UNIX_EPOCH)
859            .expect("clock must be monotonic enough for test temp dir")
860            .as_nanos();
861        let root = std::env::temp_dir().join(format!(
862            "canic-install-root-test-{}-{unique}",
863            std::process::id()
864        ));
865        fs::create_dir_all(root.join("canisters")).expect("temp canisters dir must be created");
866        fs::write(root.join("canisters/canic.toml"), config_source)
867            .expect("temp canic.toml must be written");
868        root
869    }
870}