Skip to main content

canic_cli/list/
mod.rs

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///
44/// ListCommandError
45///
46
47#[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///
86/// ListOptions
87///
88
89#[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///
100/// ListSource
101///
102
103#[derive(Clone, Copy, Debug, Eq, PartialEq)]
104pub enum ListSource {
105    Auto,
106    Standalone,
107    RootRegistry,
108}
109
110impl ListOptions {
111    /// Parse canister listing options from CLI arguments.
112    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
145// Build the list parser.
146fn 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
157/// Run a list subcommand or the default tree listing.
158pub 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, &registry, anchor.as_deref())?;
178    println!(
179        "{}",
180        render_registry_tree(&registry, anchor.as_deref(), &role_kinds, &readiness)?
181    );
182    if let Some(hint) = standalone_next_step_hint(&options, &registry) {
183        eprintln!("Hint: {hint}");
184    }
185    Ok(())
186}
187
188// Pick the current installed fleet when the project has Canic fleet state.
189fn 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
204/// Render all registry entries, or one selected subtree, as a whitespace table.
205pub 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
215// Resolve role kind labels from the selected project config when it is available.
216fn 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
223// Return likely config paths in preference order without making list depend on them.
224fn 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
238// Return ready statuses for the visible live list.
239fn 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
251// Query one canister's generated Canic readiness endpoint.
252fn 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
281// Load registry entries from standalone dfx ids or a live root canister query.
282fn 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(&registry_json).map_err(ListCommandError::from)
298}
299
300// Load created canisters from the current dfx project without requiring a Canic root.
301fn 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
323// Resolve one local project canister id, returning None when it has not been created yet.
324fn 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
333// Resolve the explicit root id or the current dfx project's `root` canister id.
334fn 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
350// Read the current or explicitly selected fleet install state.
351fn 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
357// Resolve the selected tree anchor as a principal when a local dfx name is supplied.
358fn 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
366// Accept either an IC principal or a local dfx canister name for list inputs.
367fn 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
379// Resolve the state network using the same local default as host install commands.
380fn 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
388// Run `dfx canister call <root> canic_subnet_registry --output json`.
389fn 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
405// Add a next-step hint for common root registry setup mistakes.
406fn 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
421// Convert host dfx failures into the list command's public error surface.
422fn 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
431// Return guidance for root registry calls that cannot reach an installed Canic root.
432fn 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
448// Explain the next setup step when standalone inventory only finds a reserved root id.
449fn 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
470// Select forest roots or validate the requested subtree root.
471fn 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
499// Group children by parent and keep each group sorted for stable output.
500fn 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
513// Return the entries that would be rendered for the selected table.
514fn 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
524// Return visible rows with tree prefixes so canister ids carry hierarchy.
525fn 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
544// Traverse one rendered branch in display order.
545fn 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
574///
575/// RegistryRow
576///
577
578struct RegistryRow<'a> {
579    entry: &'a RegistryEntry,
580    tree_prefix: String,
581}
582
583// Render registry rows as stable whitespace-aligned columns.
584fn 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
605// Format one canister principal label with its box-drawing tree branch.
606fn canister_label(row: &RegistryRow<'_>) -> String {
607    format!("{}{}", row.tree_prefix, row.entry.pid)
608}
609
610// Format one role label without adding hierarchy because role names are not unique.
611fn 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
619// Format one canister kind using registry data first, then config role metadata.
620fn 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
641// Accept both plain-bool and wrapped-result JSON shapes from `dfx --output json`.
642fn 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///
648/// ReadyStatus
649///
650
651#[derive(Clone, Copy, Debug, Eq, PartialEq)]
652pub enum ReadyStatus {
653    Ready,
654    NotReady,
655    Error,
656}
657
658impl ReadyStatus {
659    // Return the compact label used in list output.
660    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
669// Return list command usage text.
670const 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    // Ensure list options parse live registry queries.
686    #[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    // Ensure list defaults to automatic source selection.
711    #[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    // Ensure conflicting registry sources are still rejected.
725    #[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    // Ensure standalone inventory uses the hardcoded demo canister roster.
738    #[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    // Ensure empty-root dfx errors explain the standalone/root split.
755    #[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    // Ensure root-only standalone inventory explains the install/bootstrap command.
765    #[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, &registry)
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    // Ensure non-standalone sources do not get local setup hints.
790    #[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, &registry).is_none());
802    }
803
804    // Ensure registry entries render as a stable whitespace table.
805    #[test]
806    fn renders_registry_table() {
807        let registry = parse_registry_entries(&registry_json()).expect("parse registry");
808        let role_kinds = BTreeMap::new();
809        let readiness = readiness_map();
810        let tree =
811            render_registry_tree(&registry, 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    // Ensure one selected subtree can be rendered without siblings.
842    #[test]
843    fn renders_selected_subtree() {
844        let registry = parse_registry_entries(&registry_json()).expect("parse registry");
845        let role_kinds = BTreeMap::new();
846        let readiness = readiness_map();
847        let tree = render_registry_tree(&registry, 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    // Ensure config role kinds fill entries that do not carry registry kind data.
871    #[test]
872    fn renders_registry_table_with_config_kinds() {
873        let mut registry = parse_registry_entries(&registry_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(&registry, 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    // Ensure the implicit wasm store role has a concrete kind even though config omits it.
916    #[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    // Ensure readiness parsing accepts the JSON shapes emitted by dfx.
936    #[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    // Build representative subnet registry JSON.
945    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}