1use crate::{
2 args::{
3 first_arg_is_help, first_arg_is_version, flag_arg, parse_matches, string_option, value_arg,
4 },
5 version_text,
6};
7use candid::Principal;
8use canic::ids::CanisterRole;
9use canic_backup::discovery::{DiscoveryError, RegistryEntry, parse_registry_entries};
10use canic_host::{
11 dfx::{Dfx, DfxCommandError},
12 install_root::{InstallState, read_current_or_fleet_install_state},
13 release_set::{config_path as default_config_path, configured_role_kinds},
14 replica_query,
15};
16use clap::Command as ClapCommand;
17use std::{
18 collections::{BTreeMap, BTreeSet},
19 env,
20 ffi::OsString,
21};
22use thiserror::Error as ThisError;
23
24const DEMO_CANISTER_NAMES: &[&str] = &[
25 "app",
26 "minimal",
27 "user_hub",
28 "user_shard",
29 "scale_hub",
30 "scale",
31 "root",
32];
33const ROLE_HEADER: &str = "ROLE";
34const KIND_HEADER: &str = "KIND";
35const CANISTER_HEADER: &str = "CANISTER_ID";
36const READY_HEADER: &str = "READY";
37const LIST_COLUMN_GAP: &str = " ";
38const TREE_BRANCH: &str = "├─ ";
39const TREE_LAST: &str = "└─ ";
40const TREE_PIPE: &str = "│ ";
41const TREE_SPACE: &str = " ";
42
43#[derive(Debug, ThisError)]
48pub enum ListCommandError {
49 #[error("{0}")]
50 Usage(&'static str),
51
52 #[error("unknown option {0}")]
53 UnknownOption(String),
54
55 #[error("cannot combine --standalone with --root")]
56 ConflictingListSources,
57
58 #[error(
59 "no local canister ids are available yet; run dfx canister create <name>, or use make demo-install for the full reference topology"
60 )]
61 NoStandaloneCanisters,
62
63 #[error("registry JSON did not contain the requested canister {0}")]
64 CanisterNotInRegistry(String),
65
66 #[error("dfx command failed: {command}\n{stderr}")]
67 DfxFailed { command: String, stderr: String },
68
69 #[error("local replica query failed: {0}")]
70 ReplicaQuery(String),
71
72 #[error("failed to read canic fleet state: {0}")]
73 InstallState(String),
74
75 #[error(transparent)]
76 Io(#[from] std::io::Error),
77
78 #[error(transparent)]
79 Json(#[from] serde_json::Error),
80
81 #[error(transparent)]
82 Discovery(#[from] DiscoveryError),
83}
84
85#[derive(Clone, Debug, Eq, PartialEq)]
90pub struct ListOptions {
91 pub source: ListSource,
92 pub fleet: Option<String>,
93 pub root: Option<String>,
94 pub anchor: Option<String>,
95 pub network: Option<String>,
96 pub dfx: String,
97}
98
99#[derive(Clone, Copy, Debug, Eq, PartialEq)]
104pub enum ListSource {
105 Auto,
106 Standalone,
107 RootRegistry,
108}
109
110impl ListOptions {
111 pub fn parse<I>(args: I) -> Result<Self, ListCommandError>
113 where
114 I: IntoIterator<Item = OsString>,
115 {
116 let args = args.into_iter().collect::<Vec<_>>();
117 let matches =
118 parse_matches(list_command(), args).map_err(|_| ListCommandError::Usage(usage()))?;
119 let standalone = matches.get_flag("standalone");
120 let root = string_option(&matches, "root");
121
122 if standalone && root.is_some() {
123 return Err(ListCommandError::ConflictingListSources);
124 }
125
126 let source = if root.is_some() {
127 ListSource::RootRegistry
128 } else if standalone {
129 ListSource::Standalone
130 } else {
131 ListSource::Auto
132 };
133
134 Ok(Self {
135 source,
136 fleet: string_option(&matches, "fleet"),
137 root,
138 anchor: string_option(&matches, "from"),
139 network: string_option(&matches, "network"),
140 dfx: string_option(&matches, "dfx").unwrap_or_else(|| "dfx".to_string()),
141 })
142 }
143}
144
145fn list_command() -> ClapCommand {
147 ClapCommand::new("list")
148 .disable_help_flag(true)
149 .arg(flag_arg("standalone").long("standalone"))
150 .arg(value_arg("fleet").long("fleet"))
151 .arg(value_arg("root").long("root"))
152 .arg(value_arg("from").long("from"))
153 .arg(value_arg("network").long("network"))
154 .arg(value_arg("dfx").long("dfx"))
155}
156
157pub fn run<I>(args: I) -> Result<(), ListCommandError>
159where
160 I: IntoIterator<Item = OsString>,
161{
162 let args = args.into_iter().collect::<Vec<_>>();
163 if first_arg_is_help(&args) {
164 println!("{}", usage());
165 return Ok(());
166 }
167 if first_arg_is_version(&args) {
168 println!("{}", version_text());
169 return Ok(());
170 }
171
172 let mut options = ListOptions::parse(args)?;
173 options.source = resolve_effective_source(&options)?;
174 let registry = load_registry_entries(&options)?;
175 let anchor = resolve_tree_anchor(&options)?;
176 let role_kinds = resolve_role_kinds(&options);
177 let readiness = list_ready_statuses(&options, ®istry, anchor.as_deref())?;
178 let title = list_title(&options);
179 println!(
180 "{}",
181 render_list_output(
182 &title,
183 ®istry,
184 anchor.as_deref(),
185 &role_kinds,
186 &readiness
187 )?
188 );
189 if let Some(hint) = standalone_next_step_hint(&options, ®istry) {
190 eprintln!("Hint: {hint}");
191 }
192 Ok(())
193}
194
195fn resolve_effective_source(options: &ListOptions) -> Result<ListSource, ListCommandError> {
197 if !matches!(options.source, ListSource::Auto) {
198 return Ok(options.source);
199 }
200
201 if read_selected_install_state(options)
202 .map_err(|err| ListCommandError::InstallState(err.to_string()))?
203 .is_some()
204 {
205 Ok(ListSource::RootRegistry)
206 } else {
207 Ok(ListSource::Standalone)
208 }
209}
210
211pub fn render_registry_tree(
213 registry: &[RegistryEntry],
214 canister: Option<&str>,
215 role_kinds: &BTreeMap<String, String>,
216 readiness: &BTreeMap<String, ReadyStatus>,
217) -> Result<String, ListCommandError> {
218 let rows = visible_rows(registry, canister)?;
219 Ok(render_registry_table(&rows, role_kinds, readiness))
220}
221
222pub fn render_list_output(
224 title: &ListTitle,
225 registry: &[RegistryEntry],
226 canister: Option<&str>,
227 role_kinds: &BTreeMap<String, String>,
228 readiness: &BTreeMap<String, ReadyStatus>,
229) -> Result<String, ListCommandError> {
230 Ok(format!(
231 "{}\n\n{}",
232 title.render(),
233 render_registry_tree(registry, canister, role_kinds, readiness)?
234 ))
235}
236
237fn list_title(options: &ListOptions) -> ListTitle {
239 let fleet = match options.source {
240 ListSource::Standalone => "standalone".to_string(),
241 ListSource::Auto | ListSource::RootRegistry => read_selected_install_state(options)
242 .ok()
243 .flatten()
244 .map(|state| state.fleet)
245 .or_else(|| options.fleet.clone())
246 .unwrap_or_else(|| "root-registry".to_string()),
247 };
248
249 ListTitle {
250 fleet,
251 network: state_network(options),
252 }
253}
254
255fn resolve_role_kinds(options: &ListOptions) -> BTreeMap<String, String> {
257 role_kind_config_candidates(options)
258 .into_iter()
259 .find_map(|path| configured_role_kinds(&path).ok())
260 .unwrap_or_default()
261}
262
263fn role_kind_config_candidates(options: &ListOptions) -> Vec<std::path::PathBuf> {
265 let mut paths = Vec::new();
266
267 if let Ok(Some(state)) = read_selected_install_state(options) {
268 paths.push(std::path::PathBuf::from(state.config_path));
269 }
270
271 if let Ok(workspace_root) = env::current_dir() {
272 paths.push(default_config_path(&workspace_root));
273 }
274
275 paths
276}
277
278fn list_ready_statuses(
280 options: &ListOptions,
281 registry: &[RegistryEntry],
282 canister: Option<&str>,
283) -> Result<BTreeMap<String, ReadyStatus>, ListCommandError> {
284 let mut statuses = BTreeMap::new();
285 for entry in visible_entries(registry, canister)? {
286 statuses.insert(entry.pid.clone(), check_ready_status(options, &entry.pid)?);
287 }
288 Ok(statuses)
289}
290
291fn check_ready_status(
293 options: &ListOptions,
294 canister: &str,
295) -> Result<ReadyStatus, ListCommandError> {
296 if replica_query::should_use_local_replica_query(options.network.as_deref()) {
297 return Ok(
298 match replica_query::query_ready(&options.dfx, options.network.as_deref(), canister) {
299 Ok(true) => ReadyStatus::Ready,
300 Ok(false) => ReadyStatus::NotReady,
301 Err(_) => ReadyStatus::Error,
302 },
303 );
304 }
305
306 let Ok(output) = Dfx::new(&options.dfx, options.network.clone()).canister_call_output(
307 canister,
308 "canic_ready",
309 Some("json"),
310 ) else {
311 return Ok(ReadyStatus::Error);
312 };
313 let data = serde_json::from_str::<serde_json::Value>(&output)?;
314 Ok(if parse_ready_value(&data) {
315 ReadyStatus::Ready
316 } else {
317 ReadyStatus::NotReady
318 })
319}
320
321fn load_registry_entries(options: &ListOptions) -> Result<Vec<RegistryEntry>, ListCommandError> {
323 if matches!(options.source, ListSource::Standalone | ListSource::Auto) {
324 return load_standalone_entries(options);
325 }
326
327 let registry_json = match options.source {
328 ListSource::RootRegistry => {
329 let root = resolve_root_canister(options)?;
330 call_subnet_registry(options, &root)?
331 }
332 ListSource::Standalone | ListSource::Auto => {
333 unreachable!("standalone source returned above")
334 }
335 };
336
337 parse_registry_entries(®istry_json).map_err(ListCommandError::from)
338}
339
340fn load_standalone_entries(options: &ListOptions) -> Result<Vec<RegistryEntry>, ListCommandError> {
342 let mut entries = Vec::new();
343
344 for name in DEMO_CANISTER_NAMES {
345 let Some(pid) = resolve_project_canister_id(options, name)? else {
346 continue;
347 };
348 entries.push(RegistryEntry {
349 pid,
350 role: Some((*name).to_string()),
351 kind: None,
352 parent_pid: None,
353 });
354 }
355
356 if entries.is_empty() {
357 return Err(ListCommandError::NoStandaloneCanisters);
358 }
359
360 Ok(entries)
361}
362
363fn resolve_project_canister_id(
365 options: &ListOptions,
366 name: &str,
367) -> Result<Option<String>, ListCommandError> {
368 Dfx::new(&options.dfx, options.network.clone())
369 .canister_id_optional(name)
370 .map_err(list_dfx_error)
371}
372
373fn resolve_root_canister(options: &ListOptions) -> Result<String, ListCommandError> {
375 if let Some(root) = &options.root {
376 return resolve_canister_identifier(options, root);
377 }
378
379 if let Some(state) = read_selected_install_state(options)
380 .map_err(|err| ListCommandError::InstallState(err.to_string()))?
381 {
382 return Ok(state.root_canister_id);
383 }
384
385 Dfx::new(&options.dfx, options.network.clone())
386 .canister_id("root")
387 .map_err(list_dfx_error)
388}
389
390fn read_selected_install_state(
392 options: &ListOptions,
393) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
394 read_current_or_fleet_install_state(&state_network(options), options.fleet.as_deref())
395}
396
397fn resolve_tree_anchor(options: &ListOptions) -> Result<Option<String>, ListCommandError> {
399 options
400 .anchor
401 .as_deref()
402 .map(|anchor| resolve_canister_identifier(options, anchor))
403 .transpose()
404}
405
406fn resolve_canister_identifier(
408 options: &ListOptions,
409 identifier: &str,
410) -> Result<String, ListCommandError> {
411 if Principal::from_text(identifier).is_ok() {
412 return Ok(identifier.to_string());
413 }
414
415 resolve_project_canister_id(options, identifier)
416 .map(|id| id.unwrap_or_else(|| identifier.to_string()))
417}
418
419fn state_network(options: &ListOptions) -> String {
421 options
422 .network
423 .clone()
424 .or_else(|| env::var("DFX_NETWORK").ok())
425 .unwrap_or_else(|| "local".to_string())
426}
427
428fn call_subnet_registry(options: &ListOptions, root: &str) -> Result<String, ListCommandError> {
430 if replica_query::should_use_local_replica_query(options.network.as_deref()) {
431 return replica_query::query_subnet_registry_json(
432 &options.dfx,
433 options.network.as_deref(),
434 root,
435 )
436 .map_err(|err| ListCommandError::ReplicaQuery(err.to_string()));
437 }
438
439 Dfx::new(&options.dfx, options.network.clone())
440 .canister_call_output(root, "canic_subnet_registry", Some("json"))
441 .map_err(list_dfx_error)
442 .map_err(add_root_registry_hint)
443}
444
445fn add_root_registry_hint(error: ListCommandError) -> ListCommandError {
447 let ListCommandError::DfxFailed { command, stderr } = error else {
448 return error;
449 };
450
451 let Some(hint) = root_registry_hint(&stderr) else {
452 return ListCommandError::DfxFailed { command, stderr };
453 };
454
455 ListCommandError::DfxFailed {
456 command,
457 stderr: format!("{stderr}\nHint: {hint}\n"),
458 }
459}
460
461fn list_dfx_error(error: DfxCommandError) -> ListCommandError {
463 match error {
464 DfxCommandError::Io(err) => ListCommandError::Io(err),
465 DfxCommandError::Failed { command, stderr } => {
466 ListCommandError::DfxFailed { command, stderr }
467 }
468 }
469}
470
471fn root_registry_hint(stderr: &str) -> Option<&'static str> {
473 if stderr.contains("Cannot find canister id") {
474 return Some(
475 "no root canister id exists in this dfx project. Use plain `canic list` for local standalone inventory, or run `canic install` before querying the root registry.",
476 );
477 }
478
479 if stderr.contains("contains no Wasm module") || stderr.contains("wasm-module-not-found") {
480 return Some(
481 "`dfx canister create root` only reserves an id; it does not install Canic root code. Run `canic install`, then use `canic list`.",
482 );
483 }
484
485 None
486}
487
488fn standalone_next_step_hint(
490 options: &ListOptions,
491 registry: &[RegistryEntry],
492) -> Option<&'static str> {
493 if !matches!(options.source, ListSource::Standalone) {
494 return None;
495 }
496
497 let [entry] = registry else {
498 return None;
499 };
500
501 if entry.role.as_deref() != Some("root") {
502 return None;
503 }
504
505 Some(
506 "only the local root id exists. Run `canic install` to build, install, stage, and bootstrap the tree; then run `canic list`.",
507 )
508}
509
510fn root_entries<'a>(
512 registry: &'a [RegistryEntry],
513 by_pid: &BTreeMap<&str, &'a RegistryEntry>,
514 canister: Option<&str>,
515) -> Result<Vec<&'a RegistryEntry>, ListCommandError> {
516 if let Some(canister) = canister {
517 return by_pid
518 .get(canister)
519 .copied()
520 .map(|entry| vec![entry])
521 .ok_or_else(|| ListCommandError::CanisterNotInRegistry(canister.to_string()));
522 }
523
524 let ids = registry
525 .iter()
526 .map(|entry| entry.pid.as_str())
527 .collect::<BTreeSet<_>>();
528 Ok(registry
529 .iter()
530 .filter(|entry| {
531 entry
532 .parent_pid
533 .as_deref()
534 .is_none_or(|parent| !ids.contains(parent))
535 })
536 .collect())
537}
538
539fn child_entries(registry: &[RegistryEntry]) -> BTreeMap<&str, Vec<&RegistryEntry>> {
541 let mut children = BTreeMap::<&str, Vec<&RegistryEntry>>::new();
542 for entry in registry {
543 if let Some(parent) = entry.parent_pid.as_deref() {
544 children.entry(parent).or_default().push(entry);
545 }
546 }
547 for entries in children.values_mut() {
548 entries.sort_by_key(|entry| (entry.role.as_deref().unwrap_or(""), entry.pid.as_str()));
549 }
550 children
551}
552
553fn visible_entries<'a>(
555 registry: &'a [RegistryEntry],
556 canister: Option<&str>,
557) -> Result<Vec<&'a RegistryEntry>, ListCommandError> {
558 Ok(visible_rows(registry, canister)?
559 .into_iter()
560 .map(|row| row.entry)
561 .collect())
562}
563
564fn visible_rows<'a>(
566 registry: &'a [RegistryEntry],
567 canister: Option<&str>,
568) -> Result<Vec<RegistryRow<'a>>, ListCommandError> {
569 let by_pid = registry
570 .iter()
571 .map(|entry| (entry.pid.as_str(), entry))
572 .collect::<BTreeMap<_, _>>();
573 let roots = root_entries(registry, &by_pid, canister)?;
574 let children = child_entries(registry);
575 let mut entries = Vec::new();
576
577 for root in roots {
578 collect_visible_entry(root, &children, "", "", &mut entries);
579 }
580
581 Ok(entries)
582}
583
584fn collect_visible_entry<'a>(
586 entry: &'a RegistryEntry,
587 children: &BTreeMap<&str, Vec<&'a RegistryEntry>>,
588 tree_prefix: &str,
589 child_prefix: &str,
590 entries: &mut Vec<RegistryRow<'a>>,
591) {
592 entries.push(RegistryRow {
593 entry,
594 tree_prefix: tree_prefix.to_string(),
595 });
596 if let Some(child_entries) = children.get(entry.pid.as_str()) {
597 for (index, child) in child_entries.iter().enumerate() {
598 let is_last = index + 1 == child_entries.len();
599 let branch = if is_last { TREE_LAST } else { TREE_BRANCH };
600 let carry = if is_last { TREE_SPACE } else { TREE_PIPE };
601 let child_tree_prefix = format!("{child_prefix}{branch}");
602 let descendant_prefix = format!("{child_prefix}{carry}");
603 collect_visible_entry(
604 child,
605 children,
606 &child_tree_prefix,
607 &descendant_prefix,
608 entries,
609 );
610 }
611 }
612}
613
614struct RegistryRow<'a> {
619 entry: &'a RegistryEntry,
620 tree_prefix: String,
621}
622
623#[derive(Clone, Debug, Eq, PartialEq)]
628pub struct ListTitle {
629 pub fleet: String,
630 pub network: String,
631}
632
633impl ListTitle {
634 #[must_use]
636 pub fn render(&self) -> String {
637 format!("Fleet: {}\nNetwork: {}", self.fleet, self.network)
638 }
639}
640
641fn render_registry_table(
643 rows: &[RegistryRow<'_>],
644 role_kinds: &BTreeMap<String, String>,
645 readiness: &BTreeMap<String, ReadyStatus>,
646) -> String {
647 let table_rows = registry_table_rows(rows, role_kinds, readiness);
648 let widths = registry_table_widths(&table_rows);
649 let header = render_registry_table_row(
650 &[CANISTER_HEADER, ROLE_HEADER, KIND_HEADER, READY_HEADER],
651 &widths,
652 );
653 let separator = render_registry_separator(&widths);
654 let mut lines = Vec::with_capacity(table_rows.len() + 2);
655 lines.push(header);
656 lines.push(separator);
657 lines.extend(
658 table_rows
659 .iter()
660 .map(|row| render_registry_table_row(row, &widths)),
661 );
662 lines.join("\n")
663}
664
665fn registry_table_rows(
667 rows: &[RegistryRow<'_>],
668 role_kinds: &BTreeMap<String, String>,
669 readiness: &BTreeMap<String, ReadyStatus>,
670) -> Vec<[String; 4]> {
671 let mut table_rows = Vec::with_capacity(rows.len());
672 for row in rows {
673 let ready = readiness
674 .get(&row.entry.pid)
675 .map_or("unknown", |status| status.label());
676 table_rows.push([
677 canister_label(row),
678 role_label(row),
679 kind_label(row, role_kinds),
680 ready.to_string(),
681 ]);
682 }
683 table_rows
684}
685
686fn registry_table_widths(rows: &[[String; 4]]) -> [usize; 4] {
688 let mut widths = [
689 CANISTER_HEADER.chars().count(),
690 ROLE_HEADER.chars().count(),
691 KIND_HEADER.chars().count(),
692 READY_HEADER.chars().count(),
693 ];
694
695 for row in rows {
696 for (index, cell) in row.iter().enumerate() {
697 widths[index] = widths[index].max(cell.chars().count());
698 }
699 }
700
701 widths
702}
703
704fn render_registry_table_row(row: &[impl AsRef<str>], widths: &[usize; 4]) -> String {
706 widths
707 .iter()
708 .enumerate()
709 .map(|(index, width)| {
710 let value = row.get(index).map_or("", AsRef::as_ref);
711 format!("{value:<width$}")
712 })
713 .collect::<Vec<_>>()
714 .join(LIST_COLUMN_GAP)
715 .trim_end()
716 .to_string()
717}
718
719fn render_registry_separator(widths: &[usize; 4]) -> String {
721 widths
722 .iter()
723 .map(|width| "-".repeat(*width))
724 .collect::<Vec<_>>()
725 .join(LIST_COLUMN_GAP)
726}
727
728fn canister_label(row: &RegistryRow<'_>) -> String {
730 format!("{}{}", row.tree_prefix, row.entry.pid)
731}
732
733fn role_label(row: &RegistryRow<'_>) -> String {
735 let role = row.entry.role.as_deref().filter(|role| !role.is_empty());
736 match role {
737 Some(role) => role.to_string(),
738 None => "unknown".to_string(),
739 }
740}
741
742fn kind_label(row: &RegistryRow<'_>, role_kinds: &BTreeMap<String, String>) -> String {
744 row.entry
745 .kind
746 .as_deref()
747 .or_else(|| {
748 row.entry
749 .role
750 .as_deref()
751 .and_then(|role| role_kinds.get(role).map(String::as_str))
752 })
753 .or_else(|| {
754 row.entry.role.as_deref().and_then(|role| {
755 CanisterRole::owned(role.to_string())
756 .is_wasm_store()
757 .then(|| CanisterRole::WASM_STORE.as_str())
758 })
759 })
760 .unwrap_or("unknown")
761 .to_string()
762}
763
764fn parse_ready_value(data: &serde_json::Value) -> bool {
766 matches!(data, serde_json::Value::Bool(true))
767 || matches!(data.get("Ok"), Some(serde_json::Value::Bool(true)))
768}
769
770#[derive(Clone, Copy, Debug, Eq, PartialEq)]
775pub enum ReadyStatus {
776 Ready,
777 NotReady,
778 Error,
779}
780
781impl ReadyStatus {
782 const fn label(self) -> &'static str {
784 match self {
785 Self::Ready => "yes",
786 Self::NotReady => "no",
787 Self::Error => "error",
788 }
789 }
790}
791
792const fn usage() -> &'static str {
794 "usage: canic list [--standalone] [--fleet <name>] [--root <root-canister>] [--from <canister>] [--network <name>] [--dfx <path>]"
795}
796
797#[cfg(test)]
798mod tests {
799 use super::*;
800 use serde_json::json;
801
802 const ROOT: &str = "aaaaa-aa";
803 const APP: &str = "renrk-eyaaa-aaaaa-aaada-cai";
804 const MINIMAL: &str = "rrkah-fqaaa-aaaaa-aaaaq-cai";
805 const WORKER: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
806 const WASM_STORE: &str = "ryjl3-tyaaa-aaaaa-aaaba-cai";
807
808 #[test]
810 fn parses_live_list_options() {
811 let options = ListOptions::parse([
812 OsString::from("--root"),
813 OsString::from(ROOT),
814 OsString::from("--fleet"),
815 OsString::from("demo"),
816 OsString::from("--from"),
817 OsString::from(APP),
818 OsString::from("--network"),
819 OsString::from("local"),
820 OsString::from("--dfx"),
821 OsString::from("/bin/dfx"),
822 ])
823 .expect("parse list options");
824
825 assert_eq!(options.source, ListSource::RootRegistry);
826 assert_eq!(options.fleet, Some("demo".to_string()));
827 assert_eq!(options.root, Some(ROOT.to_string()));
828 assert_eq!(options.anchor, Some(APP.to_string()));
829 assert_eq!(options.network, Some("local".to_string()));
830 assert_eq!(options.dfx, "/bin/dfx");
831 }
832
833 #[test]
835 fn parses_default_auto_list_options() {
836 let options = ListOptions::parse([OsString::from("--network"), OsString::from("local")])
837 .expect("parse default standalone options");
838
839 assert_eq!(options.source, ListSource::Auto);
840 assert_eq!(options.fleet, None);
841 assert_eq!(options.root, None);
842 assert_eq!(options.anchor, None);
843 assert_eq!(options.network, Some("local".to_string()));
844 assert_eq!(options.dfx, "dfx");
845 }
846
847 #[test]
849 fn rejects_conflicting_registry_sources() {
850 let err = ListOptions::parse([
851 OsString::from("--standalone"),
852 OsString::from("--root"),
853 OsString::from(ROOT),
854 ])
855 .expect_err("conflicting sources should fail");
856
857 assert!(matches!(err, ListCommandError::ConflictingListSources));
858 }
859
860 #[test]
862 fn standalone_inventory_uses_static_demo_canister_names() {
863 assert_eq!(
864 DEMO_CANISTER_NAMES,
865 &[
866 "app",
867 "minimal",
868 "user_hub",
869 "user_shard",
870 "scale_hub",
871 "scale",
872 "root",
873 ]
874 );
875 }
876
877 #[test]
879 fn root_registry_hint_explains_empty_root_canister() {
880 let hint = root_registry_hint("the canister contains no Wasm module")
881 .expect("empty wasm hint should be available");
882
883 assert!(hint.contains("canic install"));
884 assert!(hint.contains("`dfx canister create root` only reserves an id"));
885 }
886
887 #[test]
889 fn standalone_next_step_hint_explains_root_only_inventory() {
890 let options = ListOptions {
891 source: ListSource::Standalone,
892 fleet: None,
893 root: None,
894 anchor: None,
895 network: Some("local".to_string()),
896 dfx: "dfx".to_string(),
897 };
898 let registry = vec![RegistryEntry {
899 pid: ROOT.to_string(),
900 role: Some("root".to_string()),
901 kind: None,
902 parent_pid: None,
903 }];
904
905 let hint = standalone_next_step_hint(&options, ®istry)
906 .expect("root-only standalone hint should be available");
907
908 assert!(hint.contains("canic install"));
909 assert!(hint.contains("canic list"));
910 }
911
912 #[test]
914 fn standalone_next_step_hint_skips_root_registry_source() {
915 let options = ListOptions::parse([OsString::from("--root"), OsString::from(ROOT)])
916 .expect("parse root options");
917 let registry = vec![RegistryEntry {
918 pid: ROOT.to_string(),
919 role: Some("root".to_string()),
920 kind: None,
921 parent_pid: None,
922 }];
923
924 assert!(standalone_next_step_hint(&options, ®istry).is_none());
925 }
926
927 #[test]
929 fn renders_registry_table() {
930 let registry = parse_registry_entries(®istry_json()).expect("parse registry");
931 let role_kinds = BTreeMap::new();
932 let readiness = readiness_map();
933 let tree =
934 render_registry_tree(®istry, None, &role_kinds, &readiness).expect("render tree");
935 let widths = [33, 7, 9, 5];
936
937 assert_eq!(
938 tree,
939 [
940 render_registry_table_row(
941 &[CANISTER_HEADER, ROLE_HEADER, KIND_HEADER, READY_HEADER],
942 &widths
943 ),
944 render_registry_separator(&widths),
945 render_registry_table_row(&[ROOT, "root", "root", "yes"], &widths),
946 render_registry_table_row(
947 &[&format!("├─ {APP}"), "app", "singleton", "no"],
948 &widths
949 ),
950 render_registry_table_row(
951 &[&format!("│ └─ {WORKER}"), "worker", "replica", "error"],
952 &widths
953 ),
954 render_registry_table_row(
955 &[&format!("└─ {MINIMAL}"), "minimal", "singleton", "yes"],
956 &widths
957 )
958 ]
959 .join("\n")
960 );
961 }
962
963 #[test]
965 fn renders_selected_subtree() {
966 let registry = parse_registry_entries(®istry_json()).expect("parse registry");
967 let role_kinds = BTreeMap::new();
968 let readiness = readiness_map();
969 let tree = render_registry_tree(®istry, Some(APP), &role_kinds, &readiness)
970 .expect("render subtree");
971 let widths = [30, 6, 9, 5];
972
973 assert_eq!(
974 tree,
975 [
976 render_registry_table_row(
977 &[CANISTER_HEADER, ROLE_HEADER, KIND_HEADER, READY_HEADER],
978 &widths
979 ),
980 render_registry_separator(&widths),
981 render_registry_table_row(&[APP, "app", "singleton", "no"], &widths),
982 render_registry_table_row(
983 &[&format!("└─ {WORKER}"), "worker", "replica", "error"],
984 &widths
985 )
986 ]
987 .join("\n")
988 );
989 }
990
991 #[test]
993 fn renders_registry_table_with_config_kinds() {
994 let mut registry = parse_registry_entries(®istry_json()).expect("parse registry");
995 for entry in &mut registry {
996 entry.kind = None;
997 }
998 let role_kinds = BTreeMap::from([
999 ("root".to_string(), "root".to_string()),
1000 ("app".to_string(), "singleton".to_string()),
1001 ("minimal".to_string(), "singleton".to_string()),
1002 ("worker".to_string(), "replica".to_string()),
1003 ]);
1004 let readiness = readiness_map();
1005 let tree =
1006 render_registry_tree(®istry, None, &role_kinds, &readiness).expect("render tree");
1007 let widths = [33, 7, 9, 5];
1008
1009 assert_eq!(
1010 tree,
1011 [
1012 render_registry_table_row(
1013 &[CANISTER_HEADER, ROLE_HEADER, KIND_HEADER, READY_HEADER],
1014 &widths
1015 ),
1016 render_registry_separator(&widths),
1017 render_registry_table_row(&[ROOT, "root", "root", "yes"], &widths),
1018 render_registry_table_row(
1019 &[&format!("├─ {APP}"), "app", "singleton", "no"],
1020 &widths
1021 ),
1022 render_registry_table_row(
1023 &[&format!("│ └─ {WORKER}"), "worker", "replica", "error"],
1024 &widths
1025 ),
1026 render_registry_table_row(
1027 &[&format!("└─ {MINIMAL}"), "minimal", "singleton", "yes"],
1028 &widths
1029 )
1030 ]
1031 .join("\n")
1032 );
1033 }
1034
1035 #[test]
1037 fn renders_list_output_with_fleet_title() {
1038 let registry = parse_registry_entries(®istry_json()).expect("parse registry");
1039 let title = ListTitle {
1040 fleet: "demo".to_string(),
1041 network: "local".to_string(),
1042 };
1043 let output = render_list_output(
1044 &title,
1045 ®istry,
1046 Some(APP),
1047 &BTreeMap::new(),
1048 &readiness_map(),
1049 )
1050 .expect("render list output");
1051
1052 assert!(output.starts_with("Fleet: demo\nNetwork: local\n\nCANISTER_ID"));
1053 assert!(output.contains("\n------------------------------"));
1054 }
1055
1056 #[test]
1058 fn implicit_wasm_store_kind_is_not_unknown() {
1059 let entry = RegistryEntry {
1060 pid: WASM_STORE.to_string(),
1061 role: Some(CanisterRole::WASM_STORE.as_str().to_string()),
1062 kind: None,
1063 parent_pid: Some(ROOT.to_string()),
1064 };
1065 let row = RegistryRow {
1066 entry: &entry,
1067 tree_prefix: String::new(),
1068 };
1069
1070 assert_eq!(
1071 kind_label(&row, &BTreeMap::new()),
1072 CanisterRole::WASM_STORE.as_str()
1073 );
1074 }
1075
1076 #[test]
1078 fn parses_ready_json_shapes() {
1079 assert!(parse_ready_value(&json!(true)));
1080 assert!(parse_ready_value(&json!({ "Ok": true })));
1081 assert!(!parse_ready_value(&json!(false)));
1082 assert!(!parse_ready_value(&json!({ "Ok": false })));
1083 }
1084
1085 fn registry_json() -> String {
1087 json!({
1088 "Ok": [
1089 {
1090 "pid": ROOT,
1091 "role": "root",
1092 "record": {
1093 "pid": ROOT,
1094 "role": "root",
1095 "kind": "root",
1096 "parent_pid": null
1097 }
1098 },
1099 {
1100 "pid": APP,
1101 "role": "app",
1102 "record": {
1103 "pid": APP,
1104 "role": "app",
1105 "kind": "singleton",
1106 "parent_pid": ROOT
1107 }
1108 },
1109 {
1110 "pid": MINIMAL,
1111 "role": "minimal",
1112 "record": {
1113 "pid": MINIMAL,
1114 "role": "minimal",
1115 "kind": "singleton",
1116 "parent_pid": ROOT
1117 }
1118 },
1119 {
1120 "pid": WORKER,
1121 "role": "worker",
1122 "record": {
1123 "pid": WORKER,
1124 "role": "worker",
1125 "kind": "replica",
1126 "parent_pid": [APP]
1127 }
1128 }
1129 ]
1130 })
1131 .to_string()
1132 }
1133
1134 fn readiness_map() -> BTreeMap<String, ReadyStatus> {
1135 BTreeMap::from([
1136 (ROOT.to_string(), ReadyStatus::Ready),
1137 (APP.to_string(), ReadyStatus::NotReady),
1138 (MINIMAL.to_string(), ReadyStatus::Ready),
1139 (WORKER.to_string(), ReadyStatus::Error),
1140 ])
1141 }
1142}