Skip to main content

canic_cli/list/
mod.rs

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///
14/// ListCommandError
15///
16
17#[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///
51/// ListOptions
52///
53
54#[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    /// Parse canister listing options from CLI arguments.
65    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
107/// Run a list subcommand or the default tree listing.
108pub 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(&registry, options.canister.as_deref())?
135    );
136    Ok(())
137}
138
139/// Render all registry entries, or one selected subtree, as an ASCII tree.
140pub 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
160// Load registry entries from a file or live root canister query.
161fn 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(&registry_json).map_err(ListCommandError::from)
170}
171
172// Resolve the explicit root id or the current dfx project's `root` canister id.
173fn 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
187// Run `dfx canister call <root> canic_subnet_registry --output json`.
188fn 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
198// Execute one command and capture stdout.
199fn 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
212// Render a command for diagnostics.
213fn 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
223// Select forest roots or validate the requested subtree root.
224fn 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
252// Group children by parent and keep each group sorted for stable output.
253fn 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
266// Render one registry entry and its descendants.
267fn 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
306// Format one tree node label.
307fn 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
314// Read the next required option value.
315fn 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
324// Return list command usage text.
325const 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    // Ensure list options parse live registry queries.
339    #[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    // Ensure list defaults to the current dfx project's root canister.
361    #[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    // Ensure conflicting registry sources are still rejected.
374    #[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    // Ensure registry entries render as a stable ASCII tree.
388    #[test]
389    fn renders_registry_ascii_tree() {
390        let registry = parse_registry_entries(&registry_json()).expect("parse registry");
391        let tree = render_registry_tree(&registry, None).expect("render tree");
392
393        assert_eq!(
394            tree,
395            format!("root {ROOT}\n`- app {APP}\n   `- worker {WORKER}")
396        );
397    }
398
399    // Ensure one selected subtree can be rendered without siblings.
400    #[test]
401    fn renders_selected_subtree() {
402        let registry = parse_registry_entries(&registry_json()).expect("parse registry");
403        let tree = render_registry_tree(&registry, Some(APP)).expect("render subtree");
404
405        assert_eq!(tree, format!("app {APP}\n`- worker {WORKER}"));
406    }
407
408    // Build representative subnet registry JSON.
409    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}