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#[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#[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#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
61struct BootstrapStatusSnapshot {
62 ready: bool,
63 phase: String,
64 last_error: Option<String>,
65}
66
67#[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
83struct 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 #[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
127pub 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
219pub 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
231pub 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
251pub 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
259pub 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#[derive(Clone, Debug, Eq, PartialEq)]
276pub struct FleetSummary {
277 pub name: String,
278 pub current: bool,
279 pub state: InstallState,
280}
281
282pub 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
288pub 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
330pub 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
339pub 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#[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#[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#[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
384fn fleets_dir(dfx_root: &Path, network: &str) -> PathBuf {
386 dfx_root.join(".canic").join(network).join("fleets")
387}
388
389fn 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
424fn 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
432fn 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
456fn is_install_project_config(path: &Path) -> bool {
458 path.parent()
459 .is_some_and(|parent| parent.join("root/Cargo.toml").is_file())
460}
461
462fn 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
491fn 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
534fn 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
568fn 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
585fn 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
606fn 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
614fn 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
623fn 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
646fn 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
662fn 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
680fn 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
689fn 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
704fn 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
719fn default_fleet_name() -> String {
721 DEFAULT_FLEET_NAME.to_string()
722}
723
724fn 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
737fn 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
753fn current_unix_secs() -> Result<u64, Box<dyn std::error::Error>> {
755 Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
756}
757
758fn 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
797fn 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
815fn 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
858fn 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
876fn 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
901fn 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
934fn 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
960fn 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
969fn 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
981fn 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
988fn 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
997fn 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
1014fn 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(®istry_json));
1096 }
1097 next_report = elapsed + 5;
1098 }
1099
1100 thread::sleep(Duration::from_secs(1));
1101 }
1102}
1103
1104fn 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
1111fn 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
1137fn 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
1171fn 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
1180fn 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
1218fn 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
1247fn 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
1257fn 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
1272fn run_command_allow_failure(
1274 command: &mut Command,
1275) -> Result<std::process::ExitStatus, Box<dyn std::error::Error>> {
1276 Ok(command.status()?)
1277}
1278
1279fn 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}