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};
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///
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    let title = list_title(&options);
179    println!(
180        "{}",
181        render_list_output(
182            &title,
183            &registry,
184            anchor.as_deref(),
185            &role_kinds,
186            &readiness
187        )?
188    );
189    if let Some(hint) = standalone_next_step_hint(&options, &registry) {
190        eprintln!("Hint: {hint}");
191    }
192    Ok(())
193}
194
195// Pick the current installed fleet when the project has Canic fleet state.
196fn 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
211/// Render all registry entries, or one selected subtree, as a whitespace table.
212pub 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
222/// Render a named list view with a fleet/source title above the registry table.
223pub 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
237// Return the operator-facing title for the selected list source.
238fn 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
255// Resolve role kind labels from the selected project config when it is available.
256fn 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
263// Return likely config paths in preference order without making list depend on them.
264fn 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
278// Return ready statuses for the visible live list.
279fn 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
291// Query one canister's generated Canic readiness endpoint.
292fn 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
321// Load registry entries from standalone dfx ids or a live root canister query.
322fn 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(&registry_json).map_err(ListCommandError::from)
338}
339
340// Load created canisters from the current dfx project without requiring a Canic root.
341fn 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
363// Resolve one local project canister id, returning None when it has not been created yet.
364fn 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
373// Resolve the explicit root id or the current dfx project's `root` canister id.
374fn 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
390// Read the current or explicitly selected fleet install state.
391fn 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
397// Resolve the selected tree anchor as a principal when a local dfx name is supplied.
398fn 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
406// Accept either an IC principal or a local dfx canister name for list inputs.
407fn 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
419// Resolve the state network using the same local default as host install commands.
420fn 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
428// Run `dfx canister call <root> canic_subnet_registry --output json`.
429fn 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
445// Add a next-step hint for common root registry setup mistakes.
446fn 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
461// Convert host dfx failures into the list command's public error surface.
462fn 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
471// Return guidance for root registry calls that cannot reach an installed Canic root.
472fn 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
488// Explain the next setup step when standalone inventory only finds a reserved root id.
489fn 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
510// Select forest roots or validate the requested subtree root.
511fn 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
539// Group children by parent and keep each group sorted for stable output.
540fn 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
553// Return the entries that would be rendered for the selected table.
554fn 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
564// Return visible rows with tree prefixes so canister ids carry hierarchy.
565fn 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
584// Traverse one rendered branch in display order.
585fn 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
614///
615/// RegistryRow
616///
617
618struct RegistryRow<'a> {
619    entry: &'a RegistryEntry,
620    tree_prefix: String,
621}
622
623///
624/// ListTitle
625///
626
627#[derive(Clone, Debug, Eq, PartialEq)]
628pub struct ListTitle {
629    pub fleet: String,
630    pub network: String,
631}
632
633impl ListTitle {
634    /// Render the compact title block shown above `canic list` tables.
635    #[must_use]
636    pub fn render(&self) -> String {
637        format!("Fleet: {}\nNetwork: {}", self.fleet, self.network)
638    }
639}
640
641// Render registry rows as stable whitespace-aligned columns.
642fn 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
665// Collect rendered cell values before width calculation.
666fn 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
686// Compute display widths for the list table, including headers.
687fn 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
704// Render one padded list table row with the wider list-specific column gap.
705fn 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
719// Render the line under the table headers.
720fn 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
728// Format one canister principal label with its box-drawing tree branch.
729fn canister_label(row: &RegistryRow<'_>) -> String {
730    format!("{}{}", row.tree_prefix, row.entry.pid)
731}
732
733// Format one role label without adding hierarchy because role names are not unique.
734fn 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
742// Format one canister kind using registry data first, then config role metadata.
743fn 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
764// Accept both plain-bool and wrapped-result JSON shapes from `dfx --output json`.
765fn 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///
771/// ReadyStatus
772///
773
774#[derive(Clone, Copy, Debug, Eq, PartialEq)]
775pub enum ReadyStatus {
776    Ready,
777    NotReady,
778    Error,
779}
780
781impl ReadyStatus {
782    // Return the compact label used in list output.
783    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
792// Return list command usage text.
793const 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    // Ensure list options parse live registry queries.
809    #[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    // Ensure list defaults to automatic source selection.
834    #[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    // Ensure conflicting registry sources are still rejected.
848    #[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    // Ensure standalone inventory uses the hardcoded demo canister roster.
861    #[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    // Ensure empty-root dfx errors explain the standalone/root split.
878    #[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    // Ensure root-only standalone inventory explains the install/bootstrap command.
888    #[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, &registry)
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    // Ensure non-standalone sources do not get local setup hints.
913    #[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, &registry).is_none());
925    }
926
927    // Ensure registry entries render as a stable whitespace table.
928    #[test]
929    fn renders_registry_table() {
930        let registry = parse_registry_entries(&registry_json()).expect("parse registry");
931        let role_kinds = BTreeMap::new();
932        let readiness = readiness_map();
933        let tree =
934            render_registry_tree(&registry, 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    // Ensure one selected subtree can be rendered without siblings.
964    #[test]
965    fn renders_selected_subtree() {
966        let registry = parse_registry_entries(&registry_json()).expect("parse registry");
967        let role_kinds = BTreeMap::new();
968        let readiness = readiness_map();
969        let tree = render_registry_tree(&registry, 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    // Ensure config role kinds fill entries that do not carry registry kind data.
992    #[test]
993    fn renders_registry_table_with_config_kinds() {
994        let mut registry = parse_registry_entries(&registry_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(&registry, 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    // Ensure the full list output names the selected fleet before the tree table.
1036    #[test]
1037    fn renders_list_output_with_fleet_title() {
1038        let registry = parse_registry_entries(&registry_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            &registry,
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    // Ensure the implicit wasm store role has a concrete kind even though config omits it.
1057    #[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    // Ensure readiness parsing accepts the JSON shapes emitted by dfx.
1077    #[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    // Build representative subnet registry JSON.
1086    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}