1use crate::{
2 snapshot::{RegistryEntry, SnapshotCommandError, parse_registry_entries},
3 version_text,
4};
5use std::{
6 collections::{BTreeMap, BTreeSet},
7 ffi::OsString,
8 fs,
9 process::Command,
10};
11use thiserror::Error as ThisError;
12
13#[derive(Debug, ThisError)]
18pub enum ListCommandError {
19 #[error("{0}")]
20 Usage(&'static str),
21
22 #[error("missing required option {0}")]
23 MissingOption(&'static str),
24
25 #[error("unknown option {0}")]
26 UnknownOption(String),
27
28 #[error("option {0} requires a value")]
29 MissingValue(&'static str),
30
31 #[error("cannot combine --root and --registry-json")]
32 ConflictingRegistrySources,
33
34 #[error("registry JSON did not contain the requested canister {0}")]
35 CanisterNotInRegistry(String),
36
37 #[error("dfx command failed: {command}\n{stderr}")]
38 DfxFailed { command: String, stderr: String },
39
40 #[error(transparent)]
41 Io(#[from] std::io::Error),
42
43 #[error(transparent)]
44 Json(#[from] serde_json::Error),
45
46 #[error(transparent)]
47 Snapshot(#[from] SnapshotCommandError),
48}
49
50#[derive(Clone, Debug, Eq, PartialEq)]
55pub struct ListOptions {
56 pub root: Option<String>,
57 pub registry_json: Option<String>,
58 pub canister: Option<String>,
59 pub network: Option<String>,
60 pub dfx: String,
61}
62
63impl ListOptions {
64 pub fn parse<I>(args: I) -> Result<Self, ListCommandError>
66 where
67 I: IntoIterator<Item = OsString>,
68 {
69 let mut root = None;
70 let mut registry_json = None;
71 let mut canister = None;
72 let mut network = None;
73 let mut dfx = "dfx".to_string();
74
75 let mut args = args.into_iter();
76 while let Some(arg) = args.next() {
77 let arg = arg
78 .into_string()
79 .map_err(|_| ListCommandError::Usage(usage()))?;
80 match arg.as_str() {
81 "--root" => root = Some(next_value(&mut args, "--root")?),
82 "--registry-json" => {
83 registry_json = Some(next_value(&mut args, "--registry-json")?);
84 }
85 "--canister" => canister = Some(next_value(&mut args, "--canister")?),
86 "--network" => network = Some(next_value(&mut args, "--network")?),
87 "--dfx" => dfx = next_value(&mut args, "--dfx")?,
88 "--help" | "-h" => return Err(ListCommandError::Usage(usage())),
89 _ => return Err(ListCommandError::UnknownOption(arg)),
90 }
91 }
92
93 if root.is_some() && registry_json.is_some() {
94 return Err(ListCommandError::ConflictingRegistrySources);
95 }
96
97 Ok(Self {
98 root,
99 registry_json,
100 canister,
101 network,
102 dfx,
103 })
104 }
105}
106
107pub fn run<I>(args: I) -> Result<(), ListCommandError>
109where
110 I: IntoIterator<Item = OsString>,
111{
112 let args = args.into_iter().collect::<Vec<_>>();
113 if args
114 .first()
115 .and_then(|arg| arg.to_str())
116 .is_some_and(|arg| matches!(arg, "help" | "--help" | "-h"))
117 {
118 println!("{}", usage());
119 return Ok(());
120 }
121 if args
122 .first()
123 .and_then(|arg| arg.to_str())
124 .is_some_and(|arg| matches!(arg, "version" | "--version" | "-V"))
125 {
126 println!("{}", version_text());
127 return Ok(());
128 }
129
130 let options = ListOptions::parse(args)?;
131 let registry = load_registry_entries(&options)?;
132 println!(
133 "{}",
134 render_registry_tree(®istry, options.canister.as_deref())?
135 );
136 Ok(())
137}
138
139pub fn render_registry_tree(
141 registry: &[RegistryEntry],
142 canister: Option<&str>,
143) -> Result<String, ListCommandError> {
144 let by_pid = registry
145 .iter()
146 .map(|entry| (entry.pid.as_str(), entry))
147 .collect::<BTreeMap<_, _>>();
148 let roots = root_entries(registry, &by_pid, canister)?;
149 let children = child_entries(registry);
150 let mut lines = Vec::new();
151
152 for (index, root) in roots.iter().enumerate() {
153 let last = index + 1 == roots.len();
154 render_entry(root, &children, "", last, true, &mut lines);
155 }
156
157 Ok(lines.join("\n"))
158}
159
160fn load_registry_entries(options: &ListOptions) -> Result<Vec<RegistryEntry>, ListCommandError> {
162 let registry_json = if let Some(path) = &options.registry_json {
163 fs::read_to_string(path)?
164 } else {
165 let root = resolve_root_canister(options)?;
166 call_subnet_registry(options, &root)?
167 };
168
169 parse_registry_entries(®istry_json).map_err(ListCommandError::from)
170}
171
172fn resolve_root_canister(options: &ListOptions) -> Result<String, ListCommandError> {
174 if let Some(root) = &options.root {
175 return Ok(root.clone());
176 }
177
178 let mut command = Command::new(&options.dfx);
179 command.arg("canister");
180 if let Some(network) = &options.network {
181 command.args(["--network", network]);
182 }
183 command.args(["id", "root"]);
184 run_output(&mut command)
185}
186
187fn call_subnet_registry(options: &ListOptions, root: &str) -> Result<String, ListCommandError> {
189 let mut command = Command::new(&options.dfx);
190 command.arg("canister");
191 if let Some(network) = &options.network {
192 command.args(["--network", network]);
193 }
194 command.args(["call", root, "canic_subnet_registry", "--output", "json"]);
195 run_output(&mut command)
196}
197
198fn run_output(command: &mut Command) -> Result<String, ListCommandError> {
200 let display = command_display(command);
201 let output = command.output()?;
202 if output.status.success() {
203 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
204 } else {
205 Err(ListCommandError::DfxFailed {
206 command: display,
207 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
208 })
209 }
210}
211
212fn command_display(command: &Command) -> String {
214 let mut parts = vec![command.get_program().to_string_lossy().to_string()];
215 parts.extend(
216 command
217 .get_args()
218 .map(|arg| arg.to_string_lossy().to_string()),
219 );
220 parts.join(" ")
221}
222
223fn root_entries<'a>(
225 registry: &'a [RegistryEntry],
226 by_pid: &BTreeMap<&str, &'a RegistryEntry>,
227 canister: Option<&str>,
228) -> Result<Vec<&'a RegistryEntry>, ListCommandError> {
229 if let Some(canister) = canister {
230 return by_pid
231 .get(canister)
232 .copied()
233 .map(|entry| vec![entry])
234 .ok_or_else(|| ListCommandError::CanisterNotInRegistry(canister.to_string()));
235 }
236
237 let ids = registry
238 .iter()
239 .map(|entry| entry.pid.as_str())
240 .collect::<BTreeSet<_>>();
241 Ok(registry
242 .iter()
243 .filter(|entry| {
244 entry
245 .parent_pid
246 .as_deref()
247 .is_none_or(|parent| !ids.contains(parent))
248 })
249 .collect())
250}
251
252fn child_entries(registry: &[RegistryEntry]) -> BTreeMap<&str, Vec<&RegistryEntry>> {
254 let mut children = BTreeMap::<&str, Vec<&RegistryEntry>>::new();
255 for entry in registry {
256 if let Some(parent) = entry.parent_pid.as_deref() {
257 children.entry(parent).or_default().push(entry);
258 }
259 }
260 for entries in children.values_mut() {
261 entries.sort_by_key(|entry| (entry.role.as_deref().unwrap_or(""), entry.pid.as_str()));
262 }
263 children
264}
265
266fn render_entry(
268 entry: &RegistryEntry,
269 children: &BTreeMap<&str, Vec<&RegistryEntry>>,
270 prefix: &str,
271 last: bool,
272 root: bool,
273 lines: &mut Vec<String>,
274) {
275 if root {
276 lines.push(entry_label(entry));
277 } else {
278 let branch = if last { "`- " } else { "|- " };
279 lines.push(format!("{prefix}{branch}{}", entry_label(entry)));
280 }
281
282 let Some(child_entries) = children.get(entry.pid.as_str()) else {
283 return;
284 };
285
286 let child_prefix = if root {
287 String::new()
288 } else if last {
289 format!("{prefix} ")
290 } else {
291 format!("{prefix}| ")
292 };
293
294 for (index, child) in child_entries.iter().enumerate() {
295 render_entry(
296 child,
297 children,
298 &child_prefix,
299 index + 1 == child_entries.len(),
300 false,
301 lines,
302 );
303 }
304}
305
306fn entry_label(entry: &RegistryEntry) -> String {
308 match &entry.role {
309 Some(role) if !role.is_empty() => format!("{role} {}", entry.pid),
310 _ => format!("unknown {}", entry.pid),
311 }
312}
313
314fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, ListCommandError>
316where
317 I: Iterator<Item = OsString>,
318{
319 args.next()
320 .and_then(|value| value.into_string().ok())
321 .ok_or(ListCommandError::MissingValue(option))
322}
323
324const fn usage() -> &'static str {
326 "usage: canic list [--root <root-canister> | --registry-json <file>] [--canister <id>] [--network <name>] [--dfx <path>]"
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332 use serde_json::json;
333
334 const ROOT: &str = "aaaaa-aa";
335 const APP: &str = "renrk-eyaaa-aaaaa-aaada-cai";
336 const WORKER: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
337
338 #[test]
340 fn parses_live_list_options() {
341 let options = ListOptions::parse([
342 OsString::from("--root"),
343 OsString::from(ROOT),
344 OsString::from("--canister"),
345 OsString::from(APP),
346 OsString::from("--network"),
347 OsString::from("local"),
348 OsString::from("--dfx"),
349 OsString::from("/bin/dfx"),
350 ])
351 .expect("parse list options");
352
353 assert_eq!(options.root, Some(ROOT.to_string()));
354 assert_eq!(options.registry_json, None);
355 assert_eq!(options.canister, Some(APP.to_string()));
356 assert_eq!(options.network, Some("local".to_string()));
357 assert_eq!(options.dfx, "/bin/dfx");
358 }
359
360 #[test]
362 fn parses_default_project_root_list_options() {
363 let options = ListOptions::parse([OsString::from("--network"), OsString::from("local")])
364 .expect("parse default root options");
365
366 assert_eq!(options.root, None);
367 assert_eq!(options.registry_json, None);
368 assert_eq!(options.canister, None);
369 assert_eq!(options.network, Some("local".to_string()));
370 assert_eq!(options.dfx, "dfx");
371 }
372
373 #[test]
375 fn rejects_conflicting_registry_sources() {
376 let err = ListOptions::parse([
377 OsString::from("--root"),
378 OsString::from(ROOT),
379 OsString::from("--registry-json"),
380 OsString::from("registry.json"),
381 ])
382 .expect_err("conflicting sources should fail");
383
384 assert!(matches!(err, ListCommandError::ConflictingRegistrySources));
385 }
386
387 #[test]
389 fn renders_registry_ascii_tree() {
390 let registry = parse_registry_entries(®istry_json()).expect("parse registry");
391 let tree = render_registry_tree(®istry, None).expect("render tree");
392
393 assert_eq!(
394 tree,
395 format!("root {ROOT}\n`- app {APP}\n `- worker {WORKER}")
396 );
397 }
398
399 #[test]
401 fn renders_selected_subtree() {
402 let registry = parse_registry_entries(®istry_json()).expect("parse registry");
403 let tree = render_registry_tree(®istry, Some(APP)).expect("render subtree");
404
405 assert_eq!(tree, format!("app {APP}\n`- worker {WORKER}"));
406 }
407
408 fn registry_json() -> String {
410 json!({
411 "Ok": [
412 {
413 "pid": ROOT,
414 "role": "root",
415 "record": {
416 "pid": ROOT,
417 "role": "root",
418 "parent_pid": null
419 }
420 },
421 {
422 "pid": APP,
423 "role": "app",
424 "record": {
425 "pid": APP,
426 "role": "app",
427 "parent_pid": ROOT
428 }
429 },
430 {
431 "pid": WORKER,
432 "role": "worker",
433 "record": {
434 "pid": WORKER,
435 "role": "worker",
436 "parent_pid": [APP]
437 }
438 }
439 ]
440 })
441 .to_string()
442 }
443}