Skip to main content

canic_cli/list/
mod.rs

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///
11/// ListCommandError
12///
13
14#[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///
48/// ListOptions
49///
50
51#[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    /// Parse canister listing options from CLI arguments.
62    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
104/// Run a list subcommand or the default tree listing.
105pub 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(&registry, options.canister.as_deref())?
124    );
125    Ok(())
126}
127
128/// Render all registry entries, or one selected subtree, as an ASCII tree.
129pub 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
149// Load registry entries from a file or live root canister query.
150fn 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(&registry_json).map_err(ListCommandError::from)
159}
160
161// Resolve the explicit root id or the current dfx project's `root` canister id.
162fn 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
176// Run `dfx canister call <root> canic_subnet_registry --output json`.
177fn 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
187// Execute one command and capture stdout.
188fn 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
201// Render a command for diagnostics.
202fn 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
212// Select forest roots or validate the requested subtree root.
213fn 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
241// Group children by parent and keep each group sorted for stable output.
242fn 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
255// Render one registry entry and its descendants.
256fn 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
295// Format one tree node label.
296fn 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
303// Read the next required option value.
304fn 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
313// Return list command usage text.
314const 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    // Ensure list options parse live registry queries.
328    #[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    // Ensure list defaults to the current dfx project's root canister.
350    #[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    // Ensure conflicting registry sources are still rejected.
363    #[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    // Ensure registry entries render as a stable ASCII tree.
377    #[test]
378    fn renders_registry_ascii_tree() {
379        let registry = parse_registry_entries(&registry_json()).expect("parse registry");
380        let tree = render_registry_tree(&registry, None).expect("render tree");
381
382        assert_eq!(
383            tree,
384            format!("root {ROOT}\n`- app {APP}\n   `- worker {WORKER}")
385        );
386    }
387
388    // Ensure one selected subtree can be rendered without siblings.
389    #[test]
390    fn renders_selected_subtree() {
391        let registry = parse_registry_entries(&registry_json()).expect("parse registry");
392        let tree = render_registry_tree(&registry, Some(APP)).expect("render subtree");
393
394        assert_eq!(tree, format!("app {APP}\n`- worker {WORKER}"));
395    }
396
397    // Build representative subnet registry JSON.
398    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}