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#[derive(Clone, Debug)]
22pub struct InstallRootOptions {
23 pub root_canister: String,
24 pub network: String,
25 pub ready_timeout_seconds: u64,
26}
27
28#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
33struct BootstrapStatusSnapshot {
34 ready: bool,
35 phase: String,
36 last_error: Option<String>,
37}
38
39#[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 #[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
76pub 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
151fn 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
160fn 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
174fn 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
192fn 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
235fn 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
253fn 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
278fn 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
297fn 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
323fn 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
332fn 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
344fn 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
351fn 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
360fn 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
377fn 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(®istry_json));
459 }
460 next_report = elapsed + 5;
461 }
462
463 thread::sleep(Duration::from_secs(1));
464 }
465}
466
467fn 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
474fn 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
500fn 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
534fn 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
572fn 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
601fn 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
611fn run_command_allow_failure(
613 command: &mut Command,
614) -> Result<std::process::ExitStatus, Box<dyn std::error::Error>> {
615 Ok(command.status()?)
616}
617
618fn 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}