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