Skip to main content

canic_cli/list/
mod.rs

1use crate::version_text;
2use candid::{CandidType, Decode, Encode, Principal};
3use canic::ids::CanisterRole;
4use canic_backup::discovery::{DiscoveryError, RegistryEntry, parse_registry_entries};
5use canic_installer::{
6    install_root::{InstallState, read_current_or_fleet_install_state},
7    release_set::{config_path as default_config_path, configured_role_kinds},
8};
9use serde::{Deserialize, Serialize};
10use std::{
11    collections::{BTreeMap, BTreeSet},
12    env,
13    ffi::OsString,
14    io::{Read, Write},
15    net::TcpStream,
16    process::Command,
17    time::{SystemTime, UNIX_EPOCH},
18};
19use thiserror::Error as ThisError;
20
21const DEMO_CANISTER_NAMES: &[&str] = &[
22    "app",
23    "minimal",
24    "user_hub",
25    "user_shard",
26    "scale_hub",
27    "scale",
28    "root",
29];
30const ROLE_HEADER: &str = "ROLE";
31const KIND_HEADER: &str = "KIND";
32const CANISTER_HEADER: &str = "CANISTER_ID";
33const READY_HEADER: &str = "READY";
34const TREE_BRANCH: &str = "├─ ";
35const TREE_LAST: &str = "└─ ";
36const TREE_PIPE: &str = "│  ";
37const TREE_SPACE: &str = "   ";
38
39///
40/// ListCommandError
41///
42
43#[derive(Debug, ThisError)]
44pub enum ListCommandError {
45    #[error("{0}")]
46    Usage(&'static str),
47
48    #[error("unknown option {0}")]
49    UnknownOption(String),
50
51    #[error("option {0} requires a value")]
52    MissingValue(&'static str),
53
54    #[error("cannot combine --standalone with --root")]
55    ConflictingListSources,
56
57    #[error(
58        "no local canister ids are available yet; run dfx canister create <name>, or use make demo-install for the full reference topology"
59    )]
60    NoStandaloneCanisters,
61
62    #[error("registry JSON did not contain the requested canister {0}")]
63    CanisterNotInRegistry(String),
64
65    #[error("dfx command failed: {command}\n{stderr}")]
66    DfxFailed { command: String, stderr: String },
67
68    #[error("local replica query failed: {0}")]
69    ReplicaQuery(String),
70
71    #[error("local replica rejected query: code={code} message={message}")]
72    ReplicaRejected { code: u64, message: String },
73
74    #[error("failed to read canic fleet state: {0}")]
75    InstallState(String),
76
77    #[error(transparent)]
78    Io(#[from] std::io::Error),
79
80    #[error(transparent)]
81    Json(#[from] serde_json::Error),
82
83    #[error(transparent)]
84    Cbor(#[from] serde_cbor::Error),
85
86    #[error(transparent)]
87    Discovery(#[from] DiscoveryError),
88}
89
90///
91/// ListOptions
92///
93
94#[derive(Clone, Debug, Eq, PartialEq)]
95pub struct ListOptions {
96    pub source: ListSource,
97    pub fleet: Option<String>,
98    pub root: Option<String>,
99    pub anchor: Option<String>,
100    pub network: Option<String>,
101    pub dfx: String,
102}
103
104///
105/// ListSource
106///
107
108#[derive(Clone, Copy, Debug, Eq, PartialEq)]
109pub enum ListSource {
110    Auto,
111    Standalone,
112    RootRegistry,
113}
114
115impl ListOptions {
116    /// Parse canister listing options from CLI arguments.
117    pub fn parse<I>(args: I) -> Result<Self, ListCommandError>
118    where
119        I: IntoIterator<Item = OsString>,
120    {
121        let mut standalone = false;
122        let mut fleet = None;
123        let mut root = None;
124        let mut anchor = None;
125        let mut network = None;
126        let mut dfx = "dfx".to_string();
127
128        let mut args = args.into_iter();
129        while let Some(arg) = args.next() {
130            let arg = arg
131                .into_string()
132                .map_err(|_| ListCommandError::Usage(usage()))?;
133            if let Some(value) = arg.strip_prefix("--fleet=") {
134                fleet = Some(value.to_string());
135                continue;
136            }
137            if let Some(value) = arg.strip_prefix("--root=") {
138                root = Some(value.to_string());
139                continue;
140            }
141            if let Some(value) = arg.strip_prefix("--from=") {
142                anchor = Some(value.to_string());
143                continue;
144            }
145            if let Some(value) = arg.strip_prefix("--network=") {
146                network = Some(value.to_string());
147                continue;
148            }
149            match arg.as_str() {
150                "--standalone" => standalone = true,
151                "--fleet" => fleet = Some(next_value(&mut args, "--fleet")?),
152                "--root" => root = Some(next_value(&mut args, "--root")?),
153                "--from" => anchor = Some(next_value(&mut args, "--from")?),
154                "--network" => network = Some(next_value(&mut args, "--network")?),
155                "--dfx" => dfx = next_value(&mut args, "--dfx")?,
156                "--help" | "-h" => return Err(ListCommandError::Usage(usage())),
157                _ => return Err(ListCommandError::UnknownOption(arg)),
158            }
159        }
160
161        if standalone && root.is_some() {
162            return Err(ListCommandError::ConflictingListSources);
163        }
164
165        let source = if root.is_some() {
166            ListSource::RootRegistry
167        } else if standalone {
168            ListSource::Standalone
169        } else {
170            ListSource::Auto
171        };
172
173        Ok(Self {
174            source,
175            fleet,
176            root,
177            anchor,
178            network,
179            dfx,
180        })
181    }
182}
183
184/// Run a list subcommand or the default tree listing.
185pub fn run<I>(args: I) -> Result<(), ListCommandError>
186where
187    I: IntoIterator<Item = OsString>,
188{
189    let args = args.into_iter().collect::<Vec<_>>();
190    if args
191        .first()
192        .and_then(|arg| arg.to_str())
193        .is_some_and(|arg| matches!(arg, "help" | "--help" | "-h"))
194    {
195        println!("{}", usage());
196        return Ok(());
197    }
198    if args
199        .first()
200        .and_then(|arg| arg.to_str())
201        .is_some_and(|arg| matches!(arg, "version" | "--version" | "-V"))
202    {
203        println!("{}", version_text());
204        return Ok(());
205    }
206
207    let mut options = ListOptions::parse(args)?;
208    options.source = resolve_effective_source(&options)?;
209    let registry = load_registry_entries(&options)?;
210    let anchor = resolve_tree_anchor(&options)?;
211    let role_kinds = resolve_role_kinds(&options);
212    let readiness = list_ready_statuses(&options, &registry, anchor.as_deref())?;
213    println!(
214        "{}",
215        render_registry_tree(&registry, anchor.as_deref(), &role_kinds, &readiness)?
216    );
217    if let Some(hint) = standalone_next_step_hint(&options, &registry) {
218        eprintln!("Hint: {hint}");
219    }
220    Ok(())
221}
222
223// Pick the current installed fleet when the project has Canic fleet state.
224fn resolve_effective_source(options: &ListOptions) -> Result<ListSource, ListCommandError> {
225    if !matches!(options.source, ListSource::Auto) {
226        return Ok(options.source);
227    }
228
229    if read_selected_install_state(options)
230        .map_err(|err| ListCommandError::InstallState(err.to_string()))?
231        .is_some()
232    {
233        Ok(ListSource::RootRegistry)
234    } else {
235        Ok(ListSource::Standalone)
236    }
237}
238
239/// Render all registry entries, or one selected subtree, as a whitespace table.
240pub fn render_registry_tree(
241    registry: &[RegistryEntry],
242    canister: Option<&str>,
243    role_kinds: &BTreeMap<String, String>,
244    readiness: &BTreeMap<String, ReadyStatus>,
245) -> Result<String, ListCommandError> {
246    let rows = visible_rows(registry, canister)?;
247    Ok(render_registry_table(&rows, role_kinds, readiness))
248}
249
250// Resolve role kind labels from the selected project config when it is available.
251fn resolve_role_kinds(options: &ListOptions) -> BTreeMap<String, String> {
252    role_kind_config_candidates(options)
253        .into_iter()
254        .find_map(|path| configured_role_kinds(&path).ok())
255        .unwrap_or_default()
256}
257
258// Return likely config paths in preference order without making list depend on them.
259fn role_kind_config_candidates(options: &ListOptions) -> Vec<std::path::PathBuf> {
260    let mut paths = Vec::new();
261
262    if let Ok(Some(state)) = read_selected_install_state(options) {
263        paths.push(std::path::PathBuf::from(state.config_path));
264    }
265
266    if let Ok(workspace_root) = env::current_dir() {
267        paths.push(default_config_path(&workspace_root));
268    }
269
270    paths
271}
272
273// Return ready statuses for the visible live list.
274fn list_ready_statuses(
275    options: &ListOptions,
276    registry: &[RegistryEntry],
277    canister: Option<&str>,
278) -> Result<BTreeMap<String, ReadyStatus>, ListCommandError> {
279    let mut statuses = BTreeMap::new();
280    for entry in visible_entries(registry, canister)? {
281        statuses.insert(entry.pid.clone(), check_ready_status(options, &entry.pid)?);
282    }
283    Ok(statuses)
284}
285
286// Query one canister's generated Canic readiness endpoint.
287fn check_ready_status(
288    options: &ListOptions,
289    canister: &str,
290) -> Result<ReadyStatus, ListCommandError> {
291    if should_use_local_replica_query(options) {
292        return Ok(match local_query_ready(options, canister) {
293            Ok(true) => ReadyStatus::Ready,
294            Ok(false) => ReadyStatus::NotReady,
295            Err(_) => ReadyStatus::Error,
296        });
297    }
298
299    let mut command = Command::new(&options.dfx);
300    command.arg("canister");
301    if let Some(network) = &options.network {
302        command.args(["--network", network]);
303    }
304    command.args(["call", canister, "canic_ready", "--output", "json"]);
305
306    let output = command.output()?;
307    if !output.status.success() {
308        return Ok(ReadyStatus::Error);
309    }
310
311    let data = serde_json::from_slice::<serde_json::Value>(&output.stdout)?;
312    Ok(if parse_ready_value(&data) {
313        ReadyStatus::Ready
314    } else {
315        ReadyStatus::NotReady
316    })
317}
318
319// Load registry entries from standalone dfx ids or a live root canister query.
320fn load_registry_entries(options: &ListOptions) -> Result<Vec<RegistryEntry>, ListCommandError> {
321    if matches!(options.source, ListSource::Standalone | ListSource::Auto) {
322        return load_standalone_entries(options);
323    }
324
325    let registry_json = match options.source {
326        ListSource::RootRegistry => {
327            let root = resolve_root_canister(options)?;
328            call_subnet_registry(options, &root)?
329        }
330        ListSource::Standalone | ListSource::Auto => {
331            unreachable!("standalone source returned above")
332        }
333    };
334
335    parse_registry_entries(&registry_json).map_err(ListCommandError::from)
336}
337
338// Load created canisters from the current dfx project without requiring a Canic root.
339fn load_standalone_entries(options: &ListOptions) -> Result<Vec<RegistryEntry>, ListCommandError> {
340    let mut entries = Vec::new();
341
342    for name in DEMO_CANISTER_NAMES {
343        let Some(pid) = resolve_project_canister_id(options, name)? else {
344            continue;
345        };
346        entries.push(RegistryEntry {
347            pid,
348            role: Some((*name).to_string()),
349            kind: None,
350            parent_pid: None,
351        });
352    }
353
354    if entries.is_empty() {
355        return Err(ListCommandError::NoStandaloneCanisters);
356    }
357
358    Ok(entries)
359}
360
361// Resolve one local project canister id, returning None when it has not been created yet.
362fn resolve_project_canister_id(
363    options: &ListOptions,
364    name: &str,
365) -> Result<Option<String>, ListCommandError> {
366    let mut command = Command::new(&options.dfx);
367    command.arg("canister");
368    if let Some(network) = &options.network {
369        command.args(["--network", network]);
370    }
371    command.args(["id", name]);
372
373    let display = command_display(&command);
374    let output = command.output()?;
375    if output.status.success() {
376        return Ok(Some(
377            String::from_utf8_lossy(&output.stdout).trim().to_string(),
378        ));
379    }
380
381    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
382    if canister_id_missing(&stderr) {
383        return Ok(None);
384    }
385
386    Err(ListCommandError::DfxFailed {
387        command: display,
388        stderr,
389    })
390}
391
392// Resolve the explicit root id or the current dfx project's `root` canister id.
393fn resolve_root_canister(options: &ListOptions) -> Result<String, ListCommandError> {
394    if let Some(root) = &options.root {
395        return resolve_canister_identifier(options, root);
396    }
397
398    if let Some(state) = read_selected_install_state(options)
399        .map_err(|err| ListCommandError::InstallState(err.to_string()))?
400    {
401        return Ok(state.root_canister_id);
402    }
403
404    let mut command = Command::new(&options.dfx);
405    command.arg("canister");
406    if let Some(network) = &options.network {
407        command.args(["--network", network]);
408    }
409    command.args(["id", "root"]);
410    run_output(&mut command)
411}
412
413// Read the current or explicitly selected fleet install state.
414fn read_selected_install_state(
415    options: &ListOptions,
416) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
417    read_current_or_fleet_install_state(&state_network(options), options.fleet.as_deref())
418}
419
420// Resolve the selected tree anchor as a principal when a local dfx name is supplied.
421fn resolve_tree_anchor(options: &ListOptions) -> Result<Option<String>, ListCommandError> {
422    options
423        .anchor
424        .as_deref()
425        .map(|anchor| resolve_canister_identifier(options, anchor))
426        .transpose()
427}
428
429// Accept either an IC principal or a local dfx canister name for list inputs.
430fn resolve_canister_identifier(
431    options: &ListOptions,
432    identifier: &str,
433) -> Result<String, ListCommandError> {
434    if Principal::from_text(identifier).is_ok() {
435        return Ok(identifier.to_string());
436    }
437
438    resolve_project_canister_id(options, identifier)
439        .map(|id| id.unwrap_or_else(|| identifier.to_string()))
440}
441
442// Resolve the state network using the same local default as installer commands.
443fn state_network(options: &ListOptions) -> String {
444    options
445        .network
446        .clone()
447        .or_else(|| env::var("DFX_NETWORK").ok())
448        .unwrap_or_else(|| "local".to_string())
449}
450
451// Run `dfx canister call <root> canic_subnet_registry --output json`.
452fn call_subnet_registry(options: &ListOptions, root: &str) -> Result<String, ListCommandError> {
453    if should_use_local_replica_query(options) {
454        return local_query_subnet_registry(options, root);
455    }
456
457    let mut command = Command::new(&options.dfx);
458    command.arg("canister");
459    if let Some(network) = &options.network {
460        command.args(["--network", network]);
461    }
462    command.args(["call", root, "canic_subnet_registry", "--output", "json"]);
463    run_output(&mut command).map_err(add_root_registry_hint)
464}
465
466// Use direct local replica queries because DFX local calls can fail when stdout is captured.
467fn should_use_local_replica_query(options: &ListOptions) -> bool {
468    options
469        .network
470        .as_deref()
471        .is_none_or(|network| network == "local" || network.starts_with("http://"))
472}
473
474// Query `canic_subnet_registry` directly through the local replica HTTP API.
475fn local_query_subnet_registry(
476    options: &ListOptions,
477    root: &str,
478) -> Result<String, ListCommandError> {
479    let bytes = local_query(options, root, "canic_subnet_registry")?;
480    let result = Decode!(&bytes, Result<SubnetRegistryResponseWire, CanicErrorWire>)
481        .map_err(|err| ListCommandError::ReplicaQuery(err.to_string()))?;
482    let response = result.map_err(|err| ListCommandError::ReplicaQuery(err.to_string()))?;
483    serde_json::to_string(&response.to_dfx_json()).map_err(ListCommandError::from)
484}
485
486// Query `canic_ready` directly through the local replica HTTP API.
487fn local_query_ready(options: &ListOptions, canister: &str) -> Result<bool, ListCommandError> {
488    let bytes = local_query(options, canister, "canic_ready")?;
489    let ready =
490        Decode!(&bytes, bool).map_err(|err| ListCommandError::ReplicaQuery(err.to_string()))?;
491    Ok(ready)
492}
493
494// Execute one anonymous query call against the local replica.
495fn local_query(
496    options: &ListOptions,
497    canister: &str,
498    method: &str,
499) -> Result<Vec<u8>, ListCommandError> {
500    let canister_id = Principal::from_text(canister)
501        .map_err(|err| ListCommandError::ReplicaQuery(err.to_string()))?;
502    let arg = Encode!().map_err(|err| ListCommandError::ReplicaQuery(err.to_string()))?;
503    let sender = Principal::anonymous();
504    let envelope = QueryEnvelope {
505        content: QueryContent {
506            request_type: "query",
507            canister_id: canister_id.as_slice(),
508            method_name: method,
509            arg: &arg,
510            sender: sender.as_slice(),
511            ingress_expiry: ingress_expiry_nanos()?,
512        },
513    };
514    let body = serde_cbor::to_vec(&envelope)?;
515    let endpoint = local_replica_endpoint(options);
516    let response = post_cbor(
517        &endpoint,
518        &format!("/api/v2/canister/{canister}/query"),
519        &body,
520    )?;
521    let query_response = serde_cbor::from_slice::<QueryResponse>(&response)?;
522
523    if query_response.status == "replied" {
524        return query_response
525            .reply
526            .map(|reply| reply.arg)
527            .ok_or_else(|| ListCommandError::ReplicaQuery("missing query reply".to_string()));
528    }
529
530    Err(ListCommandError::ReplicaRejected {
531        code: query_response.reject_code.unwrap_or_default(),
532        message: query_response.reject_message.unwrap_or_default(),
533    })
534}
535
536// Resolve the local replica endpoint from explicit URL or the current DFX port.
537fn local_replica_endpoint(options: &ListOptions) -> String {
538    if let Some(network) = options
539        .network
540        .as_deref()
541        .filter(|network| network.starts_with("http://"))
542    {
543        return network.trim_end_matches('/').to_string();
544    }
545
546    let mut command = Command::new(&options.dfx);
547    command.args(["info", "webserver-port"]);
548    let port = run_output(&mut command).unwrap_or_else(|_| "4943".to_string());
549    format!("http://127.0.0.1:{port}")
550}
551
552// Return an ingress expiry comfortably in the near future for local queries.
553fn ingress_expiry_nanos() -> Result<u64, ListCommandError> {
554    let now = SystemTime::now()
555        .duration_since(UNIX_EPOCH)
556        .map_err(|err| ListCommandError::ReplicaQuery(err.to_string()))?;
557    let expiry = now
558        .as_nanos()
559        .saturating_add(5 * 60 * 1_000_000_000)
560        .min(u128::from(u64::MAX));
561    u64::try_from(expiry).map_err(|err| ListCommandError::ReplicaQuery(err.to_string()))
562}
563
564// POST one CBOR request over simple HTTP/1.1 and return the response body.
565fn post_cbor(endpoint: &str, path: &str, body: &[u8]) -> Result<Vec<u8>, ListCommandError> {
566    let (host, port) = parse_http_endpoint(endpoint)?;
567    let mut stream = TcpStream::connect((host.as_str(), port))?;
568    let request = format!(
569        "POST {path} HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/cbor\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
570        body.len()
571    );
572    stream.write_all(request.as_bytes())?;
573    stream.write_all(body)?;
574
575    let mut response = Vec::new();
576    stream.read_to_end(&mut response)?;
577    split_http_body(&response)
578}
579
580// Parse the limited HTTP endpoints supported by local direct queries.
581fn parse_http_endpoint(endpoint: &str) -> Result<(String, u16), ListCommandError> {
582    let rest = endpoint.strip_prefix("http://").ok_or_else(|| {
583        ListCommandError::ReplicaQuery(format!("unsupported endpoint {endpoint}"))
584    })?;
585    let authority = rest.split('/').next().unwrap_or(rest);
586    let (host, port) = authority
587        .rsplit_once(':')
588        .ok_or_else(|| ListCommandError::ReplicaQuery(format!("missing port in {endpoint}")))?;
589    let port = port
590        .parse::<u16>()
591        .map_err(|err| ListCommandError::ReplicaQuery(err.to_string()))?;
592    Ok((host.to_string(), port))
593}
594
595// Split a simple HTTP response and reject non-2xx status codes.
596fn split_http_body(response: &[u8]) -> Result<Vec<u8>, ListCommandError> {
597    let marker = b"\r\n\r\n";
598    let Some(index) = response
599        .windows(marker.len())
600        .position(|window| window == marker)
601    else {
602        return Err(ListCommandError::ReplicaQuery(
603            "malformed HTTP response".to_string(),
604        ));
605    };
606    let header = String::from_utf8_lossy(&response[..index]);
607    let status_ok = header
608        .lines()
609        .next()
610        .is_some_and(|status| status.contains(" 2"));
611    if !status_ok {
612        return Err(ListCommandError::ReplicaQuery(header.to_string()));
613    }
614    Ok(response[index + marker.len()..].to_vec())
615}
616
617// Execute one command and capture stdout.
618fn run_output(command: &mut Command) -> Result<String, ListCommandError> {
619    let display = command_display(command);
620    let output = command.output()?;
621    if output.status.success() {
622        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
623    } else {
624        Err(ListCommandError::DfxFailed {
625            command: display,
626            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
627        })
628    }
629}
630
631// Add a next-step hint for common root registry setup mistakes.
632fn add_root_registry_hint(error: ListCommandError) -> ListCommandError {
633    let ListCommandError::DfxFailed { command, stderr } = error else {
634        return error;
635    };
636
637    let Some(hint) = root_registry_hint(&stderr) else {
638        return ListCommandError::DfxFailed { command, stderr };
639    };
640
641    ListCommandError::DfxFailed {
642        command,
643        stderr: format!("{stderr}\nHint: {hint}\n"),
644    }
645}
646
647// Detect dfx's missing-canister-id diagnostic so standalone mode can skip uncreated entries.
648fn canister_id_missing(stderr: &str) -> bool {
649    stderr.contains("Cannot find canister id")
650}
651
652// Return guidance for root registry calls that cannot reach an installed Canic root.
653fn root_registry_hint(stderr: &str) -> Option<&'static str> {
654    if stderr.contains("Cannot find canister id") {
655        return Some(
656            "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.",
657        );
658    }
659
660    if stderr.contains("contains no Wasm module") || stderr.contains("wasm-module-not-found") {
661        return Some(
662            "`dfx canister create root` only reserves an id; it does not install Canic root code. Run `canic install`, then use `canic list`.",
663        );
664    }
665
666    None
667}
668
669// Explain the next setup step when standalone inventory only finds a reserved root id.
670fn standalone_next_step_hint(
671    options: &ListOptions,
672    registry: &[RegistryEntry],
673) -> Option<&'static str> {
674    if !matches!(options.source, ListSource::Standalone) {
675        return None;
676    }
677
678    let [entry] = registry else {
679        return None;
680    };
681
682    if entry.role.as_deref() != Some("root") {
683        return None;
684    }
685
686    Some(
687        "only the local root id exists. Run `canic install` to build, install, stage, and bootstrap the tree; then run `canic list`.",
688    )
689}
690
691// Render a command for diagnostics.
692fn command_display(command: &Command) -> String {
693    let mut parts = vec![command.get_program().to_string_lossy().to_string()];
694    parts.extend(
695        command
696            .get_args()
697            .map(|arg| arg.to_string_lossy().to_string()),
698    );
699    parts.join(" ")
700}
701
702// Select forest roots or validate the requested subtree root.
703fn root_entries<'a>(
704    registry: &'a [RegistryEntry],
705    by_pid: &BTreeMap<&str, &'a RegistryEntry>,
706    canister: Option<&str>,
707) -> Result<Vec<&'a RegistryEntry>, ListCommandError> {
708    if let Some(canister) = canister {
709        return by_pid
710            .get(canister)
711            .copied()
712            .map(|entry| vec![entry])
713            .ok_or_else(|| ListCommandError::CanisterNotInRegistry(canister.to_string()));
714    }
715
716    let ids = registry
717        .iter()
718        .map(|entry| entry.pid.as_str())
719        .collect::<BTreeSet<_>>();
720    Ok(registry
721        .iter()
722        .filter(|entry| {
723            entry
724                .parent_pid
725                .as_deref()
726                .is_none_or(|parent| !ids.contains(parent))
727        })
728        .collect())
729}
730
731// Group children by parent and keep each group sorted for stable output.
732fn child_entries(registry: &[RegistryEntry]) -> BTreeMap<&str, Vec<&RegistryEntry>> {
733    let mut children = BTreeMap::<&str, Vec<&RegistryEntry>>::new();
734    for entry in registry {
735        if let Some(parent) = entry.parent_pid.as_deref() {
736            children.entry(parent).or_default().push(entry);
737        }
738    }
739    for entries in children.values_mut() {
740        entries.sort_by_key(|entry| (entry.role.as_deref().unwrap_or(""), entry.pid.as_str()));
741    }
742    children
743}
744
745// Return the entries that would be rendered for the selected table.
746fn visible_entries<'a>(
747    registry: &'a [RegistryEntry],
748    canister: Option<&str>,
749) -> Result<Vec<&'a RegistryEntry>, ListCommandError> {
750    Ok(visible_rows(registry, canister)?
751        .into_iter()
752        .map(|row| row.entry)
753        .collect())
754}
755
756// Return visible rows with tree prefixes so canister ids carry hierarchy.
757fn visible_rows<'a>(
758    registry: &'a [RegistryEntry],
759    canister: Option<&str>,
760) -> Result<Vec<RegistryRow<'a>>, ListCommandError> {
761    let by_pid = registry
762        .iter()
763        .map(|entry| (entry.pid.as_str(), entry))
764        .collect::<BTreeMap<_, _>>();
765    let roots = root_entries(registry, &by_pid, canister)?;
766    let children = child_entries(registry);
767    let mut entries = Vec::new();
768
769    for root in roots {
770        collect_visible_entry(root, &children, "", "", &mut entries);
771    }
772
773    Ok(entries)
774}
775
776// Traverse one rendered branch in display order.
777fn collect_visible_entry<'a>(
778    entry: &'a RegistryEntry,
779    children: &BTreeMap<&str, Vec<&'a RegistryEntry>>,
780    tree_prefix: &str,
781    child_prefix: &str,
782    entries: &mut Vec<RegistryRow<'a>>,
783) {
784    entries.push(RegistryRow {
785        entry,
786        tree_prefix: tree_prefix.to_string(),
787    });
788    if let Some(child_entries) = children.get(entry.pid.as_str()) {
789        for (index, child) in child_entries.iter().enumerate() {
790            let is_last = index + 1 == child_entries.len();
791            let branch = if is_last { TREE_LAST } else { TREE_BRANCH };
792            let carry = if is_last { TREE_SPACE } else { TREE_PIPE };
793            let child_tree_prefix = format!("{child_prefix}{branch}");
794            let descendant_prefix = format!("{child_prefix}{carry}");
795            collect_visible_entry(
796                child,
797                children,
798                &child_tree_prefix,
799                &descendant_prefix,
800                entries,
801            );
802        }
803    }
804}
805
806///
807/// RegistryRow
808///
809
810struct RegistryRow<'a> {
811    entry: &'a RegistryEntry,
812    tree_prefix: String,
813}
814
815// Render registry rows as stable whitespace-aligned columns.
816fn render_registry_table(
817    rows: &[RegistryRow<'_>],
818    role_kinds: &BTreeMap<String, String>,
819    readiness: &BTreeMap<String, ReadyStatus>,
820) -> String {
821    let role_width = rows
822        .iter()
823        .map(|row| display_width(&role_label(row)))
824        .chain([display_width(ROLE_HEADER)])
825        .max()
826        .unwrap_or_else(|| display_width(ROLE_HEADER));
827    let canister_width = rows
828        .iter()
829        .map(|row| display_width(&canister_label(row)))
830        .chain([display_width(CANISTER_HEADER)])
831        .max()
832        .unwrap_or_else(|| display_width(CANISTER_HEADER));
833    let kind_width = rows
834        .iter()
835        .map(|row| display_width(&kind_label(row, role_kinds)))
836        .chain([display_width(KIND_HEADER)])
837        .max()
838        .unwrap_or_else(|| display_width(KIND_HEADER));
839
840    let mut lines = Vec::new();
841    lines.push(registry_table_row(
842        CANISTER_HEADER,
843        ROLE_HEADER,
844        KIND_HEADER,
845        READY_HEADER,
846        canister_width,
847        role_width,
848        kind_width,
849    ));
850
851    for row in rows {
852        let ready = readiness
853            .get(&row.entry.pid)
854            .map_or("unknown", |status| status.label());
855        lines.push(registry_table_row(
856            &canister_label(row),
857            &role_label(row),
858            &kind_label(row, role_kinds),
859            ready,
860            canister_width,
861            role_width,
862            kind_width,
863        ));
864    }
865
866    lines.join("\n")
867}
868
869// Render one whitespace-aligned table row.
870fn registry_table_row(
871    canister: &str,
872    role: &str,
873    kind: &str,
874    ready: &str,
875    canister_width: usize,
876    role_width: usize,
877    kind_width: usize,
878) -> String {
879    format!("{canister:<canister_width$}  {role:<role_width$}  {kind:<kind_width$}  {ready}")
880}
881
882// Count characters so Unicode tree prefixes do not over-pad columns.
883fn display_width(value: &str) -> usize {
884    value.chars().count()
885}
886
887// Format one canister principal label with its box-drawing tree branch.
888fn canister_label(row: &RegistryRow<'_>) -> String {
889    format!("{}{}", row.tree_prefix, row.entry.pid)
890}
891
892// Format one role label without adding hierarchy because role names are not unique.
893fn role_label(row: &RegistryRow<'_>) -> String {
894    let role = row.entry.role.as_deref().filter(|role| !role.is_empty());
895    match role {
896        Some(role) => role.to_string(),
897        None => "unknown".to_string(),
898    }
899}
900
901// Format one canister kind using registry data first, then config role metadata.
902fn kind_label(row: &RegistryRow<'_>, role_kinds: &BTreeMap<String, String>) -> String {
903    row.entry
904        .kind
905        .as_deref()
906        .or_else(|| {
907            row.entry
908                .role
909                .as_deref()
910                .and_then(|role| role_kinds.get(role).map(String::as_str))
911        })
912        .or_else(|| {
913            row.entry.role.as_deref().and_then(|role| {
914                CanisterRole::owned(role.to_string())
915                    .is_wasm_store()
916                    .then(|| CanisterRole::WASM_STORE.as_str())
917            })
918        })
919        .unwrap_or("unknown")
920        .to_string()
921}
922
923// Accept both plain-bool and wrapped-result JSON shapes from `dfx --output json`.
924fn parse_ready_value(data: &serde_json::Value) -> bool {
925    matches!(data, serde_json::Value::Bool(true))
926        || matches!(data.get("Ok"), Some(serde_json::Value::Bool(true)))
927}
928
929///
930/// QueryEnvelope
931///
932
933#[derive(Serialize)]
934struct QueryEnvelope<'a> {
935    content: QueryContent<'a>,
936}
937
938///
939/// QueryContent
940///
941
942#[derive(Serialize)]
943struct QueryContent<'a> {
944    request_type: &'static str,
945    #[serde(with = "serde_bytes")]
946    canister_id: &'a [u8],
947    method_name: &'a str,
948    #[serde(with = "serde_bytes")]
949    arg: &'a [u8],
950    #[serde(with = "serde_bytes")]
951    sender: &'a [u8],
952    ingress_expiry: u64,
953}
954
955///
956/// QueryResponse
957///
958
959#[derive(Deserialize)]
960struct QueryResponse {
961    status: String,
962    reply: Option<QueryReply>,
963    reject_code: Option<u64>,
964    reject_message: Option<String>,
965}
966
967///
968/// QueryReply
969///
970
971#[derive(Deserialize)]
972struct QueryReply {
973    #[serde(with = "serde_bytes")]
974    arg: Vec<u8>,
975}
976
977///
978/// SubnetRegistryResponseWire
979///
980
981#[derive(CandidType, Deserialize)]
982struct SubnetRegistryResponseWire(Vec<SubnetRegistryEntryWire>);
983
984impl SubnetRegistryResponseWire {
985    // Convert direct Candid query output into the DFX JSON shape the discovery parser accepts.
986    fn to_dfx_json(&self) -> serde_json::Value {
987        serde_json::json!({
988            "Ok": self.0.iter().map(SubnetRegistryEntryWire::to_dfx_json).collect::<Vec<_>>()
989        })
990    }
991}
992
993///
994/// SubnetRegistryEntryWire
995///
996
997#[derive(CandidType, Deserialize)]
998struct SubnetRegistryEntryWire {
999    pid: Principal,
1000    role: String,
1001    record: CanisterInfoWire,
1002}
1003
1004impl SubnetRegistryEntryWire {
1005    // Convert one registry entry into the DFX JSON shape used by existing list rendering.
1006    fn to_dfx_json(&self) -> serde_json::Value {
1007        serde_json::json!({
1008            "pid": self.pid.to_text(),
1009            "role": self.role,
1010            "record": self.record.to_dfx_json(),
1011        })
1012    }
1013}
1014
1015///
1016/// CanisterInfoWire
1017///
1018
1019#[derive(CandidType, Deserialize)]
1020struct CanisterInfoWire {
1021    pid: Principal,
1022    role: String,
1023    parent_pid: Option<Principal>,
1024    module_hash: Option<Vec<u8>>,
1025    created_at: u64,
1026}
1027
1028impl CanisterInfoWire {
1029    // Convert one canister info record into a DFX-like JSON object.
1030    fn to_dfx_json(&self) -> serde_json::Value {
1031        serde_json::json!({
1032            "pid": self.pid.to_text(),
1033            "role": self.role,
1034            "parent_pid": self.parent_pid.as_ref().map(Principal::to_text),
1035            "module_hash": self.module_hash,
1036            "created_at": self.created_at.to_string(),
1037        })
1038    }
1039}
1040
1041///
1042/// CanicErrorWire
1043///
1044
1045#[derive(CandidType, Deserialize)]
1046struct CanicErrorWire {
1047    code: ErrorCodeWire,
1048    message: String,
1049}
1050
1051impl std::fmt::Display for CanicErrorWire {
1052    // Render a compact public API error from a direct local replica query.
1053    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1054        write!(formatter, "{:?}: {}", self.code, self.message)
1055    }
1056}
1057
1058///
1059/// ErrorCodeWire
1060///
1061
1062#[derive(CandidType, Debug, Deserialize)]
1063enum ErrorCodeWire {
1064    Conflict,
1065    Forbidden,
1066    Internal,
1067    InvalidInput,
1068    InvariantViolation,
1069    NotFound,
1070    PolicyInstanceRequiresSingletonWithDirectory,
1071    PolicyReplicaRequiresSingletonWithScaling,
1072    PolicyRoleAlreadyRegistered,
1073    PolicyShardRequiresSingletonWithSharding,
1074    PolicySingletonAlreadyRegisteredUnderParent,
1075    ResourceExhausted,
1076    Unauthorized,
1077    Unavailable,
1078}
1079
1080///
1081/// ReadyStatus
1082///
1083
1084#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1085pub enum ReadyStatus {
1086    Ready,
1087    NotReady,
1088    Error,
1089}
1090
1091impl ReadyStatus {
1092    // Return the compact label used in list output.
1093    const fn label(self) -> &'static str {
1094        match self {
1095            Self::Ready => "yes",
1096            Self::NotReady => "no",
1097            Self::Error => "error",
1098        }
1099    }
1100}
1101
1102// Read the next required option value.
1103fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, ListCommandError>
1104where
1105    I: Iterator<Item = OsString>,
1106{
1107    args.next()
1108        .and_then(|value| value.into_string().ok())
1109        .ok_or(ListCommandError::MissingValue(option))
1110}
1111
1112// Return list command usage text.
1113const fn usage() -> &'static str {
1114    "usage: canic list [--standalone] [--fleet <name>] [--root <root-canister>] [--from <canister>] [--network <name>] [--dfx <path>]"
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119    use super::*;
1120    use serde_json::json;
1121
1122    const ROOT: &str = "aaaaa-aa";
1123    const APP: &str = "renrk-eyaaa-aaaaa-aaada-cai";
1124    const MINIMAL: &str = "rrkah-fqaaa-aaaaa-aaaaq-cai";
1125    const WORKER: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
1126    const WASM_STORE: &str = "ryjl3-tyaaa-aaaaa-aaaba-cai";
1127
1128    // Ensure list options parse live registry queries.
1129    #[test]
1130    fn parses_live_list_options() {
1131        let options = ListOptions::parse([
1132            OsString::from("--root"),
1133            OsString::from(ROOT),
1134            OsString::from("--fleet"),
1135            OsString::from("demo"),
1136            OsString::from("--from"),
1137            OsString::from(APP),
1138            OsString::from("--network"),
1139            OsString::from("local"),
1140            OsString::from("--dfx"),
1141            OsString::from("/bin/dfx"),
1142        ])
1143        .expect("parse list options");
1144
1145        assert_eq!(options.source, ListSource::RootRegistry);
1146        assert_eq!(options.fleet, Some("demo".to_string()));
1147        assert_eq!(options.root, Some(ROOT.to_string()));
1148        assert_eq!(options.anchor, Some(APP.to_string()));
1149        assert_eq!(options.network, Some("local".to_string()));
1150        assert_eq!(options.dfx, "/bin/dfx");
1151    }
1152
1153    // Ensure list defaults to automatic source selection.
1154    #[test]
1155    fn parses_default_auto_list_options() {
1156        let options = ListOptions::parse([OsString::from("--network"), OsString::from("local")])
1157            .expect("parse default standalone options");
1158
1159        assert_eq!(options.source, ListSource::Auto);
1160        assert_eq!(options.fleet, None);
1161        assert_eq!(options.root, None);
1162        assert_eq!(options.anchor, None);
1163        assert_eq!(options.network, Some("local".to_string()));
1164        assert_eq!(options.dfx, "dfx");
1165    }
1166
1167    // Ensure the old root-tree flag is no longer part of the list surface.
1168    #[test]
1169    fn rejects_root_tree_list_options() {
1170        let err = ListOptions::parse([OsString::from("--root-tree")])
1171            .expect_err("root-tree should not parse");
1172
1173        assert!(matches!(err, ListCommandError::UnknownOption(option) if option == "--root-tree"));
1174    }
1175
1176    // Ensure conflicting registry sources are still rejected.
1177    #[test]
1178    fn rejects_conflicting_registry_sources() {
1179        let err = ListOptions::parse([
1180            OsString::from("--standalone"),
1181            OsString::from("--root"),
1182            OsString::from(ROOT),
1183        ])
1184        .expect_err("conflicting sources should fail");
1185
1186        assert!(matches!(err, ListCommandError::ConflictingListSources));
1187    }
1188
1189    // Ensure standalone inventory uses the hardcoded demo canister roster.
1190    #[test]
1191    fn standalone_inventory_uses_static_demo_canister_names() {
1192        assert_eq!(
1193            DEMO_CANISTER_NAMES,
1194            &[
1195                "app",
1196                "minimal",
1197                "user_hub",
1198                "user_shard",
1199                "scale_hub",
1200                "scale",
1201                "root",
1202            ]
1203        );
1204    }
1205
1206    // Ensure empty-root dfx errors explain the standalone/root split.
1207    #[test]
1208    fn root_registry_hint_explains_empty_root_canister() {
1209        let hint = root_registry_hint("the canister contains no Wasm module")
1210            .expect("empty wasm hint should be available");
1211
1212        assert!(hint.contains("canic install"));
1213        assert!(hint.contains("`dfx canister create root` only reserves an id"));
1214    }
1215
1216    // Ensure root-only standalone inventory explains the install/bootstrap command.
1217    #[test]
1218    fn standalone_next_step_hint_explains_root_only_inventory() {
1219        let options = ListOptions {
1220            source: ListSource::Standalone,
1221            fleet: None,
1222            root: None,
1223            anchor: None,
1224            network: Some("local".to_string()),
1225            dfx: "dfx".to_string(),
1226        };
1227        let registry = vec![RegistryEntry {
1228            pid: ROOT.to_string(),
1229            role: Some("root".to_string()),
1230            kind: None,
1231            parent_pid: None,
1232        }];
1233
1234        let hint = standalone_next_step_hint(&options, &registry)
1235            .expect("root-only standalone hint should be available");
1236
1237        assert!(hint.contains("canic install"));
1238        assert!(hint.contains("canic list"));
1239    }
1240
1241    // Ensure non-standalone sources do not get local setup hints.
1242    #[test]
1243    fn standalone_next_step_hint_skips_root_registry_source() {
1244        let options = ListOptions::parse([OsString::from("--root"), OsString::from(ROOT)])
1245            .expect("parse root options");
1246        let registry = vec![RegistryEntry {
1247            pid: ROOT.to_string(),
1248            role: Some("root".to_string()),
1249            kind: None,
1250            parent_pid: None,
1251        }];
1252
1253        assert!(standalone_next_step_hint(&options, &registry).is_none());
1254    }
1255
1256    // Ensure registry entries render as a stable whitespace table.
1257    #[test]
1258    fn renders_registry_table() {
1259        let registry = parse_registry_entries(&registry_json()).expect("parse registry");
1260        let role_kinds = BTreeMap::new();
1261        let readiness = readiness_map();
1262        let tree =
1263            render_registry_tree(&registry, None, &role_kinds, &readiness).expect("render tree");
1264
1265        assert_eq!(
1266            tree,
1267            format!(
1268                "{:<33}  {:<7}  {:<9}  {}\n{:<33}  {:<7}  {:<9}  {}\n{:<33}  {:<7}  {:<9}  {}\n{:<33}  {:<7}  {:<9}  {}\n{:<33}  {:<7}  {:<9}  {}",
1269                "CANISTER_ID",
1270                "ROLE",
1271                "KIND",
1272                "READY",
1273                ROOT,
1274                "root",
1275                "root",
1276                "yes",
1277                format!("├─ {APP}"),
1278                "app",
1279                "singleton",
1280                "no",
1281                format!("│  └─ {WORKER}"),
1282                "worker",
1283                "replica",
1284                "error",
1285                format!("└─ {MINIMAL}"),
1286                "minimal",
1287                "singleton",
1288                "yes"
1289            )
1290        );
1291    }
1292
1293    // Ensure one selected subtree can be rendered without siblings.
1294    #[test]
1295    fn renders_selected_subtree() {
1296        let registry = parse_registry_entries(&registry_json()).expect("parse registry");
1297        let role_kinds = BTreeMap::new();
1298        let readiness = readiness_map();
1299        let tree = render_registry_tree(&registry, Some(APP), &role_kinds, &readiness)
1300            .expect("render subtree");
1301
1302        assert_eq!(
1303            tree,
1304            format!(
1305                "{:<30}  {:<6}  {:<9}  {}\n{:<30}  {:<6}  {:<9}  {}\n{:<30}  {:<6}  {:<9}  {}",
1306                "CANISTER_ID",
1307                "ROLE",
1308                "KIND",
1309                "READY",
1310                APP,
1311                "app",
1312                "singleton",
1313                "no",
1314                format!("└─ {WORKER}"),
1315                "worker",
1316                "replica",
1317                "error"
1318            )
1319        );
1320    }
1321
1322    // Ensure config role kinds fill entries that do not carry registry kind data.
1323    #[test]
1324    fn renders_registry_table_with_config_kinds() {
1325        let mut registry = parse_registry_entries(&registry_json()).expect("parse registry");
1326        for entry in &mut registry {
1327            entry.kind = None;
1328        }
1329        let role_kinds = BTreeMap::from([
1330            ("root".to_string(), "root".to_string()),
1331            ("app".to_string(), "singleton".to_string()),
1332            ("minimal".to_string(), "singleton".to_string()),
1333            ("worker".to_string(), "replica".to_string()),
1334        ]);
1335        let readiness = readiness_map();
1336        let tree =
1337            render_registry_tree(&registry, None, &role_kinds, &readiness).expect("render tree");
1338
1339        assert_eq!(
1340            tree,
1341            format!(
1342                "{:<33}  {:<7}  {:<9}  {}\n{:<33}  {:<7}  {:<9}  {}\n{:<33}  {:<7}  {:<9}  {}\n{:<33}  {:<7}  {:<9}  {}\n{:<33}  {:<7}  {:<9}  {}",
1343                "CANISTER_ID",
1344                "ROLE",
1345                "KIND",
1346                "READY",
1347                ROOT,
1348                "root",
1349                "root",
1350                "yes",
1351                format!("├─ {APP}"),
1352                "app",
1353                "singleton",
1354                "no",
1355                format!("│  └─ {WORKER}"),
1356                "worker",
1357                "replica",
1358                "error",
1359                format!("└─ {MINIMAL}"),
1360                "minimal",
1361                "singleton",
1362                "yes"
1363            )
1364        );
1365    }
1366
1367    // Ensure the implicit wasm store role has a concrete kind even though config omits it.
1368    #[test]
1369    fn implicit_wasm_store_kind_is_not_unknown() {
1370        let entry = RegistryEntry {
1371            pid: WASM_STORE.to_string(),
1372            role: Some(CanisterRole::WASM_STORE.as_str().to_string()),
1373            kind: None,
1374            parent_pid: Some(ROOT.to_string()),
1375        };
1376        let row = RegistryRow {
1377            entry: &entry,
1378            tree_prefix: String::new(),
1379        };
1380
1381        assert_eq!(
1382            kind_label(&row, &BTreeMap::new()),
1383            CanisterRole::WASM_STORE.as_str()
1384        );
1385    }
1386
1387    // Ensure readiness parsing accepts the JSON shapes emitted by dfx.
1388    #[test]
1389    fn parses_ready_json_shapes() {
1390        assert!(parse_ready_value(&json!(true)));
1391        assert!(parse_ready_value(&json!({ "Ok": true })));
1392        assert!(!parse_ready_value(&json!(false)));
1393        assert!(!parse_ready_value(&json!({ "Ok": false })));
1394    }
1395
1396    // Build representative subnet registry JSON.
1397    fn registry_json() -> String {
1398        json!({
1399            "Ok": [
1400                {
1401                    "pid": ROOT,
1402                    "role": "root",
1403                    "record": {
1404                        "pid": ROOT,
1405                        "role": "root",
1406                        "kind": "root",
1407                        "parent_pid": null
1408                    }
1409                },
1410                {
1411                    "pid": APP,
1412                    "role": "app",
1413                    "record": {
1414                        "pid": APP,
1415                        "role": "app",
1416                        "kind": "singleton",
1417                        "parent_pid": ROOT
1418                    }
1419                },
1420                {
1421                    "pid": MINIMAL,
1422                    "role": "minimal",
1423                    "record": {
1424                        "pid": MINIMAL,
1425                        "role": "minimal",
1426                        "kind": "singleton",
1427                        "parent_pid": ROOT
1428                    }
1429                },
1430                {
1431                    "pid": WORKER,
1432                    "role": "worker",
1433                    "record": {
1434                        "pid": WORKER,
1435                        "role": "worker",
1436                        "kind": "replica",
1437                        "parent_pid": [APP]
1438                    }
1439                }
1440            ]
1441        })
1442        .to_string()
1443    }
1444
1445    fn readiness_map() -> BTreeMap<String, ReadyStatus> {
1446        BTreeMap::from([
1447            (ROOT.to_string(), ReadyStatus::Ready),
1448            (APP.to_string(), ReadyStatus::NotReady),
1449            (MINIMAL.to_string(), ReadyStatus::Ready),
1450            (WORKER.to_string(), ReadyStatus::Error),
1451        ])
1452    }
1453}