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#[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#[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
109pub enum ListSource {
110 Auto,
111 Standalone,
112 RootRegistry,
113}
114
115impl ListOptions {
116 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
184pub 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, ®istry, anchor.as_deref())?;
213 println!(
214 "{}",
215 render_registry_tree(®istry, anchor.as_deref(), &role_kinds, &readiness)?
216 );
217 if let Some(hint) = standalone_next_step_hint(&options, ®istry) {
218 eprintln!("Hint: {hint}");
219 }
220 Ok(())
221}
222
223fn 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
239pub 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
250fn 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
258fn 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
273fn 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
286fn 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
319fn 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(®istry_json).map_err(ListCommandError::from)
336}
337
338fn 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
361fn 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
392fn 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
413fn 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
420fn 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
429fn 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
442fn 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
451fn 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
466fn 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
474fn 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
486fn 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
494fn 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
536fn 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
552fn 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
564fn 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
580fn 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
595fn 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
617fn 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
631fn 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
647fn canister_id_missing(stderr: &str) -> bool {
649 stderr.contains("Cannot find canister id")
650}
651
652fn 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
669fn 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
691fn 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
702fn 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
731fn 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
745fn 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
756fn 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
776fn 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
806struct RegistryRow<'a> {
811 entry: &'a RegistryEntry,
812 tree_prefix: String,
813}
814
815fn 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
869fn 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
882fn display_width(value: &str) -> usize {
884 value.chars().count()
885}
886
887fn canister_label(row: &RegistryRow<'_>) -> String {
889 format!("{}{}", row.tree_prefix, row.entry.pid)
890}
891
892fn 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
901fn 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
923fn 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#[derive(Serialize)]
934struct QueryEnvelope<'a> {
935 content: QueryContent<'a>,
936}
937
938#[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#[derive(Deserialize)]
960struct QueryResponse {
961 status: String,
962 reply: Option<QueryReply>,
963 reject_code: Option<u64>,
964 reject_message: Option<String>,
965}
966
967#[derive(Deserialize)]
972struct QueryReply {
973 #[serde(with = "serde_bytes")]
974 arg: Vec<u8>,
975}
976
977#[derive(CandidType, Deserialize)]
982struct SubnetRegistryResponseWire(Vec<SubnetRegistryEntryWire>);
983
984impl SubnetRegistryResponseWire {
985 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#[derive(CandidType, Deserialize)]
998struct SubnetRegistryEntryWire {
999 pid: Principal,
1000 role: String,
1001 record: CanisterInfoWire,
1002}
1003
1004impl SubnetRegistryEntryWire {
1005 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#[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 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#[derive(CandidType, Deserialize)]
1046struct CanicErrorWire {
1047 code: ErrorCodeWire,
1048 message: String,
1049}
1050
1051impl std::fmt::Display for CanicErrorWire {
1052 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1054 write!(formatter, "{:?}: {}", self.code, self.message)
1055 }
1056}
1057
1058#[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1085pub enum ReadyStatus {
1086 Ready,
1087 NotReady,
1088 Error,
1089}
1090
1091impl ReadyStatus {
1092 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
1102fn 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
1112const 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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, ®istry)
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 #[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, ®istry).is_none());
1254 }
1255
1256 #[test]
1258 fn renders_registry_table() {
1259 let registry = parse_registry_entries(®istry_json()).expect("parse registry");
1260 let role_kinds = BTreeMap::new();
1261 let readiness = readiness_map();
1262 let tree =
1263 render_registry_tree(®istry, 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 #[test]
1295 fn renders_selected_subtree() {
1296 let registry = parse_registry_entries(®istry_json()).expect("parse registry");
1297 let role_kinds = BTreeMap::new();
1298 let readiness = readiness_map();
1299 let tree = render_registry_tree(®istry, 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 #[test]
1324 fn renders_registry_table_with_config_kinds() {
1325 let mut registry = parse_registry_entries(®istry_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(®istry, 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 #[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 #[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 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}