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 table::WhitespaceTable,
16};
17use clap::Command as ClapCommand;
18use std::{
19 collections::{BTreeMap, BTreeSet},
20 env,
21 ffi::OsString,
22};
23use thiserror::Error as ThisError;
24
25const DEMO_CANISTER_NAMES: &[&str] = &[
26 "app",
27 "minimal",
28 "user_hub",
29 "user_shard",
30 "scale_hub",
31 "scale",
32 "root",
33];
34const ROLE_HEADER: &str = "ROLE";
35const KIND_HEADER: &str = "KIND";
36const CANISTER_HEADER: &str = "CANISTER_ID";
37const READY_HEADER: &str = "READY";
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 println!(
179 "{}",
180 render_registry_tree(®istry, anchor.as_deref(), &role_kinds, &readiness)?
181 );
182 if let Some(hint) = standalone_next_step_hint(&options, ®istry) {
183 eprintln!("Hint: {hint}");
184 }
185 Ok(())
186}
187
188fn resolve_effective_source(options: &ListOptions) -> Result<ListSource, ListCommandError> {
190 if !matches!(options.source, ListSource::Auto) {
191 return Ok(options.source);
192 }
193
194 if read_selected_install_state(options)
195 .map_err(|err| ListCommandError::InstallState(err.to_string()))?
196 .is_some()
197 {
198 Ok(ListSource::RootRegistry)
199 } else {
200 Ok(ListSource::Standalone)
201 }
202}
203
204pub fn render_registry_tree(
206 registry: &[RegistryEntry],
207 canister: Option<&str>,
208 role_kinds: &BTreeMap<String, String>,
209 readiness: &BTreeMap<String, ReadyStatus>,
210) -> Result<String, ListCommandError> {
211 let rows = visible_rows(registry, canister)?;
212 Ok(render_registry_table(&rows, role_kinds, readiness))
213}
214
215fn resolve_role_kinds(options: &ListOptions) -> BTreeMap<String, String> {
217 role_kind_config_candidates(options)
218 .into_iter()
219 .find_map(|path| configured_role_kinds(&path).ok())
220 .unwrap_or_default()
221}
222
223fn role_kind_config_candidates(options: &ListOptions) -> Vec<std::path::PathBuf> {
225 let mut paths = Vec::new();
226
227 if let Ok(Some(state)) = read_selected_install_state(options) {
228 paths.push(std::path::PathBuf::from(state.config_path));
229 }
230
231 if let Ok(workspace_root) = env::current_dir() {
232 paths.push(default_config_path(&workspace_root));
233 }
234
235 paths
236}
237
238fn list_ready_statuses(
240 options: &ListOptions,
241 registry: &[RegistryEntry],
242 canister: Option<&str>,
243) -> Result<BTreeMap<String, ReadyStatus>, ListCommandError> {
244 let mut statuses = BTreeMap::new();
245 for entry in visible_entries(registry, canister)? {
246 statuses.insert(entry.pid.clone(), check_ready_status(options, &entry.pid)?);
247 }
248 Ok(statuses)
249}
250
251fn check_ready_status(
253 options: &ListOptions,
254 canister: &str,
255) -> Result<ReadyStatus, ListCommandError> {
256 if replica_query::should_use_local_replica_query(options.network.as_deref()) {
257 return Ok(
258 match replica_query::query_ready(&options.dfx, options.network.as_deref(), canister) {
259 Ok(true) => ReadyStatus::Ready,
260 Ok(false) => ReadyStatus::NotReady,
261 Err(_) => ReadyStatus::Error,
262 },
263 );
264 }
265
266 let Ok(output) = Dfx::new(&options.dfx, options.network.clone()).canister_call_output(
267 canister,
268 "canic_ready",
269 Some("json"),
270 ) else {
271 return Ok(ReadyStatus::Error);
272 };
273 let data = serde_json::from_str::<serde_json::Value>(&output)?;
274 Ok(if parse_ready_value(&data) {
275 ReadyStatus::Ready
276 } else {
277 ReadyStatus::NotReady
278 })
279}
280
281fn load_registry_entries(options: &ListOptions) -> Result<Vec<RegistryEntry>, ListCommandError> {
283 if matches!(options.source, ListSource::Standalone | ListSource::Auto) {
284 return load_standalone_entries(options);
285 }
286
287 let registry_json = match options.source {
288 ListSource::RootRegistry => {
289 let root = resolve_root_canister(options)?;
290 call_subnet_registry(options, &root)?
291 }
292 ListSource::Standalone | ListSource::Auto => {
293 unreachable!("standalone source returned above")
294 }
295 };
296
297 parse_registry_entries(®istry_json).map_err(ListCommandError::from)
298}
299
300fn load_standalone_entries(options: &ListOptions) -> Result<Vec<RegistryEntry>, ListCommandError> {
302 let mut entries = Vec::new();
303
304 for name in DEMO_CANISTER_NAMES {
305 let Some(pid) = resolve_project_canister_id(options, name)? else {
306 continue;
307 };
308 entries.push(RegistryEntry {
309 pid,
310 role: Some((*name).to_string()),
311 kind: None,
312 parent_pid: None,
313 });
314 }
315
316 if entries.is_empty() {
317 return Err(ListCommandError::NoStandaloneCanisters);
318 }
319
320 Ok(entries)
321}
322
323fn resolve_project_canister_id(
325 options: &ListOptions,
326 name: &str,
327) -> Result<Option<String>, ListCommandError> {
328 Dfx::new(&options.dfx, options.network.clone())
329 .canister_id_optional(name)
330 .map_err(list_dfx_error)
331}
332
333fn resolve_root_canister(options: &ListOptions) -> Result<String, ListCommandError> {
335 if let Some(root) = &options.root {
336 return resolve_canister_identifier(options, root);
337 }
338
339 if let Some(state) = read_selected_install_state(options)
340 .map_err(|err| ListCommandError::InstallState(err.to_string()))?
341 {
342 return Ok(state.root_canister_id);
343 }
344
345 Dfx::new(&options.dfx, options.network.clone())
346 .canister_id("root")
347 .map_err(list_dfx_error)
348}
349
350fn read_selected_install_state(
352 options: &ListOptions,
353) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
354 read_current_or_fleet_install_state(&state_network(options), options.fleet.as_deref())
355}
356
357fn resolve_tree_anchor(options: &ListOptions) -> Result<Option<String>, ListCommandError> {
359 options
360 .anchor
361 .as_deref()
362 .map(|anchor| resolve_canister_identifier(options, anchor))
363 .transpose()
364}
365
366fn resolve_canister_identifier(
368 options: &ListOptions,
369 identifier: &str,
370) -> Result<String, ListCommandError> {
371 if Principal::from_text(identifier).is_ok() {
372 return Ok(identifier.to_string());
373 }
374
375 resolve_project_canister_id(options, identifier)
376 .map(|id| id.unwrap_or_else(|| identifier.to_string()))
377}
378
379fn state_network(options: &ListOptions) -> String {
381 options
382 .network
383 .clone()
384 .or_else(|| env::var("DFX_NETWORK").ok())
385 .unwrap_or_else(|| "local".to_string())
386}
387
388fn call_subnet_registry(options: &ListOptions, root: &str) -> Result<String, ListCommandError> {
390 if replica_query::should_use_local_replica_query(options.network.as_deref()) {
391 return replica_query::query_subnet_registry_json(
392 &options.dfx,
393 options.network.as_deref(),
394 root,
395 )
396 .map_err(|err| ListCommandError::ReplicaQuery(err.to_string()));
397 }
398
399 Dfx::new(&options.dfx, options.network.clone())
400 .canister_call_output(root, "canic_subnet_registry", Some("json"))
401 .map_err(list_dfx_error)
402 .map_err(add_root_registry_hint)
403}
404
405fn add_root_registry_hint(error: ListCommandError) -> ListCommandError {
407 let ListCommandError::DfxFailed { command, stderr } = error else {
408 return error;
409 };
410
411 let Some(hint) = root_registry_hint(&stderr) else {
412 return ListCommandError::DfxFailed { command, stderr };
413 };
414
415 ListCommandError::DfxFailed {
416 command,
417 stderr: format!("{stderr}\nHint: {hint}\n"),
418 }
419}
420
421fn list_dfx_error(error: DfxCommandError) -> ListCommandError {
423 match error {
424 DfxCommandError::Io(err) => ListCommandError::Io(err),
425 DfxCommandError::Failed { command, stderr } => {
426 ListCommandError::DfxFailed { command, stderr }
427 }
428 }
429}
430
431fn root_registry_hint(stderr: &str) -> Option<&'static str> {
433 if stderr.contains("Cannot find canister id") {
434 return Some(
435 "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.",
436 );
437 }
438
439 if stderr.contains("contains no Wasm module") || stderr.contains("wasm-module-not-found") {
440 return Some(
441 "`dfx canister create root` only reserves an id; it does not install Canic root code. Run `canic install`, then use `canic list`.",
442 );
443 }
444
445 None
446}
447
448fn standalone_next_step_hint(
450 options: &ListOptions,
451 registry: &[RegistryEntry],
452) -> Option<&'static str> {
453 if !matches!(options.source, ListSource::Standalone) {
454 return None;
455 }
456
457 let [entry] = registry else {
458 return None;
459 };
460
461 if entry.role.as_deref() != Some("root") {
462 return None;
463 }
464
465 Some(
466 "only the local root id exists. Run `canic install` to build, install, stage, and bootstrap the tree; then run `canic list`.",
467 )
468}
469
470fn root_entries<'a>(
472 registry: &'a [RegistryEntry],
473 by_pid: &BTreeMap<&str, &'a RegistryEntry>,
474 canister: Option<&str>,
475) -> Result<Vec<&'a RegistryEntry>, ListCommandError> {
476 if let Some(canister) = canister {
477 return by_pid
478 .get(canister)
479 .copied()
480 .map(|entry| vec![entry])
481 .ok_or_else(|| ListCommandError::CanisterNotInRegistry(canister.to_string()));
482 }
483
484 let ids = registry
485 .iter()
486 .map(|entry| entry.pid.as_str())
487 .collect::<BTreeSet<_>>();
488 Ok(registry
489 .iter()
490 .filter(|entry| {
491 entry
492 .parent_pid
493 .as_deref()
494 .is_none_or(|parent| !ids.contains(parent))
495 })
496 .collect())
497}
498
499fn child_entries(registry: &[RegistryEntry]) -> BTreeMap<&str, Vec<&RegistryEntry>> {
501 let mut children = BTreeMap::<&str, Vec<&RegistryEntry>>::new();
502 for entry in registry {
503 if let Some(parent) = entry.parent_pid.as_deref() {
504 children.entry(parent).or_default().push(entry);
505 }
506 }
507 for entries in children.values_mut() {
508 entries.sort_by_key(|entry| (entry.role.as_deref().unwrap_or(""), entry.pid.as_str()));
509 }
510 children
511}
512
513fn visible_entries<'a>(
515 registry: &'a [RegistryEntry],
516 canister: Option<&str>,
517) -> Result<Vec<&'a RegistryEntry>, ListCommandError> {
518 Ok(visible_rows(registry, canister)?
519 .into_iter()
520 .map(|row| row.entry)
521 .collect())
522}
523
524fn visible_rows<'a>(
526 registry: &'a [RegistryEntry],
527 canister: Option<&str>,
528) -> Result<Vec<RegistryRow<'a>>, ListCommandError> {
529 let by_pid = registry
530 .iter()
531 .map(|entry| (entry.pid.as_str(), entry))
532 .collect::<BTreeMap<_, _>>();
533 let roots = root_entries(registry, &by_pid, canister)?;
534 let children = child_entries(registry);
535 let mut entries = Vec::new();
536
537 for root in roots {
538 collect_visible_entry(root, &children, "", "", &mut entries);
539 }
540
541 Ok(entries)
542}
543
544fn collect_visible_entry<'a>(
546 entry: &'a RegistryEntry,
547 children: &BTreeMap<&str, Vec<&'a RegistryEntry>>,
548 tree_prefix: &str,
549 child_prefix: &str,
550 entries: &mut Vec<RegistryRow<'a>>,
551) {
552 entries.push(RegistryRow {
553 entry,
554 tree_prefix: tree_prefix.to_string(),
555 });
556 if let Some(child_entries) = children.get(entry.pid.as_str()) {
557 for (index, child) in child_entries.iter().enumerate() {
558 let is_last = index + 1 == child_entries.len();
559 let branch = if is_last { TREE_LAST } else { TREE_BRANCH };
560 let carry = if is_last { TREE_SPACE } else { TREE_PIPE };
561 let child_tree_prefix = format!("{child_prefix}{branch}");
562 let descendant_prefix = format!("{child_prefix}{carry}");
563 collect_visible_entry(
564 child,
565 children,
566 &child_tree_prefix,
567 &descendant_prefix,
568 entries,
569 );
570 }
571 }
572}
573
574struct RegistryRow<'a> {
579 entry: &'a RegistryEntry,
580 tree_prefix: String,
581}
582
583fn render_registry_table(
585 rows: &[RegistryRow<'_>],
586 role_kinds: &BTreeMap<String, String>,
587 readiness: &BTreeMap<String, ReadyStatus>,
588) -> String {
589 let mut table = WhitespaceTable::new([CANISTER_HEADER, ROLE_HEADER, KIND_HEADER, READY_HEADER]);
590 for row in rows {
591 let ready = readiness
592 .get(&row.entry.pid)
593 .map_or("unknown", |status| status.label());
594 table.push_row([
595 canister_label(row),
596 role_label(row),
597 kind_label(row, role_kinds),
598 ready.to_string(),
599 ]);
600 }
601
602 table.render()
603}
604
605fn canister_label(row: &RegistryRow<'_>) -> String {
607 format!("{}{}", row.tree_prefix, row.entry.pid)
608}
609
610fn role_label(row: &RegistryRow<'_>) -> String {
612 let role = row.entry.role.as_deref().filter(|role| !role.is_empty());
613 match role {
614 Some(role) => role.to_string(),
615 None => "unknown".to_string(),
616 }
617}
618
619fn kind_label(row: &RegistryRow<'_>, role_kinds: &BTreeMap<String, String>) -> String {
621 row.entry
622 .kind
623 .as_deref()
624 .or_else(|| {
625 row.entry
626 .role
627 .as_deref()
628 .and_then(|role| role_kinds.get(role).map(String::as_str))
629 })
630 .or_else(|| {
631 row.entry.role.as_deref().and_then(|role| {
632 CanisterRole::owned(role.to_string())
633 .is_wasm_store()
634 .then(|| CanisterRole::WASM_STORE.as_str())
635 })
636 })
637 .unwrap_or("unknown")
638 .to_string()
639}
640
641fn parse_ready_value(data: &serde_json::Value) -> bool {
643 matches!(data, serde_json::Value::Bool(true))
644 || matches!(data.get("Ok"), Some(serde_json::Value::Bool(true)))
645}
646
647#[derive(Clone, Copy, Debug, Eq, PartialEq)]
652pub enum ReadyStatus {
653 Ready,
654 NotReady,
655 Error,
656}
657
658impl ReadyStatus {
659 const fn label(self) -> &'static str {
661 match self {
662 Self::Ready => "yes",
663 Self::NotReady => "no",
664 Self::Error => "error",
665 }
666 }
667}
668
669const fn usage() -> &'static str {
671 "usage: canic list [--standalone] [--fleet <name>] [--root <root-canister>] [--from <canister>] [--network <name>] [--dfx <path>]"
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677 use serde_json::json;
678
679 const ROOT: &str = "aaaaa-aa";
680 const APP: &str = "renrk-eyaaa-aaaaa-aaada-cai";
681 const MINIMAL: &str = "rrkah-fqaaa-aaaaa-aaaaq-cai";
682 const WORKER: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
683 const WASM_STORE: &str = "ryjl3-tyaaa-aaaaa-aaaba-cai";
684
685 #[test]
687 fn parses_live_list_options() {
688 let options = ListOptions::parse([
689 OsString::from("--root"),
690 OsString::from(ROOT),
691 OsString::from("--fleet"),
692 OsString::from("demo"),
693 OsString::from("--from"),
694 OsString::from(APP),
695 OsString::from("--network"),
696 OsString::from("local"),
697 OsString::from("--dfx"),
698 OsString::from("/bin/dfx"),
699 ])
700 .expect("parse list options");
701
702 assert_eq!(options.source, ListSource::RootRegistry);
703 assert_eq!(options.fleet, Some("demo".to_string()));
704 assert_eq!(options.root, Some(ROOT.to_string()));
705 assert_eq!(options.anchor, Some(APP.to_string()));
706 assert_eq!(options.network, Some("local".to_string()));
707 assert_eq!(options.dfx, "/bin/dfx");
708 }
709
710 #[test]
712 fn parses_default_auto_list_options() {
713 let options = ListOptions::parse([OsString::from("--network"), OsString::from("local")])
714 .expect("parse default standalone options");
715
716 assert_eq!(options.source, ListSource::Auto);
717 assert_eq!(options.fleet, None);
718 assert_eq!(options.root, None);
719 assert_eq!(options.anchor, None);
720 assert_eq!(options.network, Some("local".to_string()));
721 assert_eq!(options.dfx, "dfx");
722 }
723
724 #[test]
726 fn rejects_conflicting_registry_sources() {
727 let err = ListOptions::parse([
728 OsString::from("--standalone"),
729 OsString::from("--root"),
730 OsString::from(ROOT),
731 ])
732 .expect_err("conflicting sources should fail");
733
734 assert!(matches!(err, ListCommandError::ConflictingListSources));
735 }
736
737 #[test]
739 fn standalone_inventory_uses_static_demo_canister_names() {
740 assert_eq!(
741 DEMO_CANISTER_NAMES,
742 &[
743 "app",
744 "minimal",
745 "user_hub",
746 "user_shard",
747 "scale_hub",
748 "scale",
749 "root",
750 ]
751 );
752 }
753
754 #[test]
756 fn root_registry_hint_explains_empty_root_canister() {
757 let hint = root_registry_hint("the canister contains no Wasm module")
758 .expect("empty wasm hint should be available");
759
760 assert!(hint.contains("canic install"));
761 assert!(hint.contains("`dfx canister create root` only reserves an id"));
762 }
763
764 #[test]
766 fn standalone_next_step_hint_explains_root_only_inventory() {
767 let options = ListOptions {
768 source: ListSource::Standalone,
769 fleet: None,
770 root: None,
771 anchor: None,
772 network: Some("local".to_string()),
773 dfx: "dfx".to_string(),
774 };
775 let registry = vec![RegistryEntry {
776 pid: ROOT.to_string(),
777 role: Some("root".to_string()),
778 kind: None,
779 parent_pid: None,
780 }];
781
782 let hint = standalone_next_step_hint(&options, ®istry)
783 .expect("root-only standalone hint should be available");
784
785 assert!(hint.contains("canic install"));
786 assert!(hint.contains("canic list"));
787 }
788
789 #[test]
791 fn standalone_next_step_hint_skips_root_registry_source() {
792 let options = ListOptions::parse([OsString::from("--root"), OsString::from(ROOT)])
793 .expect("parse root options");
794 let registry = vec![RegistryEntry {
795 pid: ROOT.to_string(),
796 role: Some("root".to_string()),
797 kind: None,
798 parent_pid: None,
799 }];
800
801 assert!(standalone_next_step_hint(&options, ®istry).is_none());
802 }
803
804 #[test]
806 fn renders_registry_table() {
807 let registry = parse_registry_entries(®istry_json()).expect("parse registry");
808 let role_kinds = BTreeMap::new();
809 let readiness = readiness_map();
810 let tree =
811 render_registry_tree(®istry, None, &role_kinds, &readiness).expect("render tree");
812
813 assert_eq!(
814 tree,
815 format!(
816 "{:<33} {:<7} {:<9} {}\n{:<33} {:<7} {:<9} {}\n{:<33} {:<7} {:<9} {}\n{:<33} {:<7} {:<9} {}\n{:<33} {:<7} {:<9} {}",
817 "CANISTER_ID",
818 "ROLE",
819 "KIND",
820 "READY",
821 ROOT,
822 "root",
823 "root",
824 "yes",
825 format!("├─ {APP}"),
826 "app",
827 "singleton",
828 "no",
829 format!("│ └─ {WORKER}"),
830 "worker",
831 "replica",
832 "error",
833 format!("└─ {MINIMAL}"),
834 "minimal",
835 "singleton",
836 "yes"
837 )
838 );
839 }
840
841 #[test]
843 fn renders_selected_subtree() {
844 let registry = parse_registry_entries(®istry_json()).expect("parse registry");
845 let role_kinds = BTreeMap::new();
846 let readiness = readiness_map();
847 let tree = render_registry_tree(®istry, Some(APP), &role_kinds, &readiness)
848 .expect("render subtree");
849
850 assert_eq!(
851 tree,
852 format!(
853 "{:<30} {:<6} {:<9} {}\n{:<30} {:<6} {:<9} {}\n{:<30} {:<6} {:<9} {}",
854 "CANISTER_ID",
855 "ROLE",
856 "KIND",
857 "READY",
858 APP,
859 "app",
860 "singleton",
861 "no",
862 format!("└─ {WORKER}"),
863 "worker",
864 "replica",
865 "error"
866 )
867 );
868 }
869
870 #[test]
872 fn renders_registry_table_with_config_kinds() {
873 let mut registry = parse_registry_entries(®istry_json()).expect("parse registry");
874 for entry in &mut registry {
875 entry.kind = None;
876 }
877 let role_kinds = BTreeMap::from([
878 ("root".to_string(), "root".to_string()),
879 ("app".to_string(), "singleton".to_string()),
880 ("minimal".to_string(), "singleton".to_string()),
881 ("worker".to_string(), "replica".to_string()),
882 ]);
883 let readiness = readiness_map();
884 let tree =
885 render_registry_tree(®istry, None, &role_kinds, &readiness).expect("render tree");
886
887 assert_eq!(
888 tree,
889 format!(
890 "{:<33} {:<7} {:<9} {}\n{:<33} {:<7} {:<9} {}\n{:<33} {:<7} {:<9} {}\n{:<33} {:<7} {:<9} {}\n{:<33} {:<7} {:<9} {}",
891 "CANISTER_ID",
892 "ROLE",
893 "KIND",
894 "READY",
895 ROOT,
896 "root",
897 "root",
898 "yes",
899 format!("├─ {APP}"),
900 "app",
901 "singleton",
902 "no",
903 format!("│ └─ {WORKER}"),
904 "worker",
905 "replica",
906 "error",
907 format!("└─ {MINIMAL}"),
908 "minimal",
909 "singleton",
910 "yes"
911 )
912 );
913 }
914
915 #[test]
917 fn implicit_wasm_store_kind_is_not_unknown() {
918 let entry = RegistryEntry {
919 pid: WASM_STORE.to_string(),
920 role: Some(CanisterRole::WASM_STORE.as_str().to_string()),
921 kind: None,
922 parent_pid: Some(ROOT.to_string()),
923 };
924 let row = RegistryRow {
925 entry: &entry,
926 tree_prefix: String::new(),
927 };
928
929 assert_eq!(
930 kind_label(&row, &BTreeMap::new()),
931 CanisterRole::WASM_STORE.as_str()
932 );
933 }
934
935 #[test]
937 fn parses_ready_json_shapes() {
938 assert!(parse_ready_value(&json!(true)));
939 assert!(parse_ready_value(&json!({ "Ok": true })));
940 assert!(!parse_ready_value(&json!(false)));
941 assert!(!parse_ready_value(&json!({ "Ok": false })));
942 }
943
944 fn registry_json() -> String {
946 json!({
947 "Ok": [
948 {
949 "pid": ROOT,
950 "role": "root",
951 "record": {
952 "pid": ROOT,
953 "role": "root",
954 "kind": "root",
955 "parent_pid": null
956 }
957 },
958 {
959 "pid": APP,
960 "role": "app",
961 "record": {
962 "pid": APP,
963 "role": "app",
964 "kind": "singleton",
965 "parent_pid": ROOT
966 }
967 },
968 {
969 "pid": MINIMAL,
970 "role": "minimal",
971 "record": {
972 "pid": MINIMAL,
973 "role": "minimal",
974 "kind": "singleton",
975 "parent_pid": ROOT
976 }
977 },
978 {
979 "pid": WORKER,
980 "role": "worker",
981 "record": {
982 "pid": WORKER,
983 "role": "worker",
984 "kind": "replica",
985 "parent_pid": [APP]
986 }
987 }
988 ]
989 })
990 .to_string()
991 }
992
993 fn readiness_map() -> BTreeMap<String, ReadyStatus> {
994 BTreeMap::from([
995 (ROOT.to_string(), ReadyStatus::Ready),
996 (APP.to_string(), ReadyStatus::NotReady),
997 (MINIMAL.to_string(), ReadyStatus::Ready),
998 (WORKER.to_string(), ReadyStatus::Error),
999 ])
1000 }
1001}