Skip to main content

canic_host/
dfx.rs

1use crate::default_network;
2use std::{error::Error, fmt, path::Path, process::Command};
3
4///
5/// DfxRawOutput
6///
7
8#[derive(Clone, Debug, Eq, PartialEq)]
9pub struct DfxRawOutput {
10    pub success: bool,
11    pub status: String,
12    pub stdout: Vec<u8>,
13    pub stderr: Vec<u8>,
14}
15
16///
17/// DfxCommandError
18///
19
20#[derive(Debug)]
21pub enum DfxCommandError {
22    Io(std::io::Error),
23    Failed { command: String, stderr: String },
24    SnapshotIdUnavailable { output: String },
25}
26
27impl fmt::Display for DfxCommandError {
28    // Render dfx command failures with the command line and captured diagnostics.
29    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
30        match self {
31            Self::Io(err) => write!(formatter, "{err}"),
32            Self::Failed { command, stderr } => {
33                write!(formatter, "dfx command failed: {command}\n{stderr}")
34            }
35            Self::SnapshotIdUnavailable { output } => {
36                write!(
37                    formatter,
38                    "could not parse snapshot id from dfx output: {output}"
39                )
40            }
41        }
42    }
43}
44
45impl Error for DfxCommandError {
46    // Preserve the underlying I/O error as the source when command execution fails locally.
47    fn source(&self) -> Option<&(dyn Error + 'static)> {
48        match self {
49            Self::Io(err) => Some(err),
50            Self::Failed { .. } | Self::SnapshotIdUnavailable { .. } => None,
51        }
52    }
53}
54
55impl From<std::io::Error> for DfxCommandError {
56    // Convert process-spawn failures into the shared dfx command error type.
57    fn from(err: std::io::Error) -> Self {
58        Self::Io(err)
59    }
60}
61
62///
63/// Dfx
64///
65
66#[derive(Clone, Debug, Eq, PartialEq)]
67pub struct Dfx {
68    executable: String,
69    network: Option<String>,
70}
71
72impl Dfx {
73    /// Build a dfx command context from an executable path and optional network.
74    #[must_use]
75    pub fn new(executable: impl Into<String>, network: Option<String>) -> Self {
76        Self {
77            executable: executable.into(),
78            network,
79        }
80    }
81
82    /// Return the optional network name carried by this command context.
83    #[must_use]
84    pub fn network(&self) -> Option<&str> {
85        self.network.as_deref()
86    }
87
88    /// Build a base dfx command from this context.
89    #[must_use]
90    pub fn command(&self) -> Command {
91        Command::new(&self.executable)
92    }
93
94    /// Build a base dfx command rooted at one dfx project directory.
95    #[must_use]
96    pub fn command_in(&self, cwd: &Path) -> Command {
97        let mut command = self.command();
98        command.current_dir(cwd);
99        command
100    }
101
102    /// Build a `dfx canister ...` command with optional network args applied.
103    #[must_use]
104    pub fn canister_command(&self) -> Command {
105        let mut command = self.command();
106        command.arg("canister");
107        add_network_args(&mut command, self.network());
108        command
109    }
110
111    /// Ping the selected dfx network.
112    pub fn ping(&self) -> Result<(), DfxCommandError> {
113        let mut command = self.command();
114        command.arg("ping");
115        let network = self.network().map_or_else(default_network, str::to_string);
116        command.arg(network);
117        run_status(&mut command)
118    }
119
120    /// Resolve one project canister id, returning `None` when the id is absent.
121    pub fn canister_id_optional(&self, name: &str) -> Result<Option<String>, DfxCommandError> {
122        let mut command = self.canister_command();
123        command.args(["id", name]);
124        match run_output(&mut command) {
125            Ok(output) => Ok(Some(output)),
126            Err(DfxCommandError::Failed { command, stderr }) if canister_id_missing(&stderr) => {
127                let _ = command;
128                Ok(None)
129            }
130            Err(err) => Err(err),
131        }
132    }
133
134    /// Resolve one project canister id.
135    pub fn canister_id(&self, name: &str) -> Result<String, DfxCommandError> {
136        let mut command = self.canister_command();
137        command.args(["id", name]);
138        run_output(&mut command)
139    }
140
141    /// Call one canister method with optional dfx JSON output.
142    pub fn canister_call_output(
143        &self,
144        canister: &str,
145        method: &str,
146        output: Option<&str>,
147    ) -> Result<String, DfxCommandError> {
148        let mut command = self.canister_command();
149        command.args(["call", canister, method]);
150        if let Some(output) = output {
151            command.args(["--output", output]);
152        }
153        run_output(&mut command)
154    }
155
156    /// List snapshot ids for one canister.
157    pub fn snapshot_list(&self, canister: &str) -> Result<String, DfxCommandError> {
158        let mut command = self.canister_command();
159        command.args(["snapshot", "list", canister]);
160        run_output(&mut command)
161    }
162
163    /// Create one canister snapshot and return combined stdout/stderr text.
164    pub fn snapshot_create(&self, canister: &str) -> Result<String, DfxCommandError> {
165        let mut command = self.canister_command();
166        command.args(["snapshot", "create", canister]);
167        run_output_with_stderr(&mut command)
168    }
169
170    /// Create one canister snapshot and resolve the resulting snapshot id.
171    pub fn snapshot_create_id(&self, canister: &str) -> Result<String, DfxCommandError> {
172        let before = self.snapshot_list_ids(canister)?;
173        let output = self.snapshot_create(canister)?;
174        if let Some(snapshot_id) = parse_snapshot_id(&output) {
175            return Ok(snapshot_id);
176        }
177
178        let before = before
179            .into_iter()
180            .collect::<std::collections::BTreeSet<_>>();
181        let mut new_ids = self
182            .snapshot_list_ids(canister)?
183            .into_iter()
184            .filter(|snapshot_id| !before.contains(snapshot_id))
185            .collect::<Vec<_>>();
186        if new_ids.len() == 1 {
187            Ok(new_ids.remove(0))
188        } else {
189            Err(DfxCommandError::SnapshotIdUnavailable { output })
190        }
191    }
192
193    /// List snapshot ids for one canister as parsed dfx identifiers.
194    pub fn snapshot_list_ids(&self, canister: &str) -> Result<Vec<String>, DfxCommandError> {
195        let output = self.snapshot_list(canister)?;
196        Ok(parse_snapshot_list_ids(&output))
197    }
198
199    /// Stop one canister.
200    pub fn stop_canister(&self, canister: &str) -> Result<(), DfxCommandError> {
201        let mut command = self.canister_command();
202        command.args(["stop", canister]);
203        run_status(&mut command)
204    }
205
206    /// Start one canister.
207    pub fn start_canister(&self, canister: &str) -> Result<(), DfxCommandError> {
208        let mut command = self.canister_command();
209        command.args(["start", canister]);
210        run_status(&mut command)
211    }
212
213    /// Download one canister snapshot into an artifact directory.
214    pub fn snapshot_download(
215        &self,
216        canister: &str,
217        snapshot_id: &str,
218        artifact_path: &Path,
219    ) -> Result<(), DfxCommandError> {
220        let mut command = self.canister_command();
221        command.args(["snapshot", "download", canister, snapshot_id, "--dir"]);
222        command.arg(artifact_path);
223        run_status(&mut command)
224    }
225
226    /// Render a dry-run snapshot-create command.
227    #[must_use]
228    pub fn snapshot_create_display(&self, canister: &str) -> String {
229        let mut command = self.canister_command();
230        command.args(["snapshot", "create", canister]);
231        command_display(&command)
232    }
233
234    /// Render a dry-run snapshot-download command.
235    #[must_use]
236    pub fn snapshot_download_display(
237        &self,
238        canister: &str,
239        snapshot_id: &str,
240        artifact_path: &Path,
241    ) -> String {
242        let mut command = self.canister_command();
243        command.args(["snapshot", "download", canister, snapshot_id, "--dir"]);
244        command.arg(artifact_path);
245        command_display(&command)
246    }
247
248    /// Render a dry-run stop command.
249    #[must_use]
250    pub fn stop_canister_display(&self, canister: &str) -> String {
251        let mut command = self.canister_command();
252        command.args(["stop", canister]);
253        command_display(&command)
254    }
255
256    /// Render a dry-run start command.
257    #[must_use]
258    pub fn start_canister_display(&self, canister: &str) -> String {
259        let mut command = self.canister_command();
260        command.args(["start", canister]);
261        command_display(&command)
262    }
263}
264
265/// Build a base `dfx` command with the default executable.
266#[must_use]
267pub fn default_command() -> Command {
268    Dfx::new("dfx", None).command()
269}
270
271/// Build a base `dfx` command rooted at one dfx project directory.
272#[must_use]
273pub fn default_command_in(cwd: &Path) -> Command {
274    Dfx::new("dfx", None).command_in(cwd)
275}
276
277/// Add optional `--network` arguments after `dfx canister`.
278pub fn add_network_args(command: &mut Command, network: Option<&str>) {
279    if let Some(network) = network {
280        command.args(["--network", network]);
281    }
282}
283
284/// Execute a command and capture trimmed stdout.
285pub fn run_output(command: &mut Command) -> Result<String, DfxCommandError> {
286    let display = command_display(command);
287    let output = command.output()?;
288    if output.status.success() {
289        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
290    } else {
291        Err(DfxCommandError::Failed {
292            command: display,
293            stderr: command_stderr(&output),
294        })
295    }
296}
297
298/// Execute a command and capture stdout plus stderr on success.
299pub fn run_output_with_stderr(command: &mut Command) -> Result<String, DfxCommandError> {
300    let display = command_display(command);
301    let output = command.output()?;
302    if output.status.success() {
303        let mut text = String::from_utf8_lossy(&output.stdout).to_string();
304        text.push_str(&String::from_utf8_lossy(&output.stderr));
305        Ok(text.trim().to_string())
306    } else {
307        Err(DfxCommandError::Failed {
308            command: display,
309            stderr: command_stderr(&output),
310        })
311    }
312}
313
314/// Execute a command and require a successful status.
315pub fn run_status(command: &mut Command) -> Result<(), DfxCommandError> {
316    let display = command_display(command);
317    let output = command.output()?;
318    if output.status.success() {
319        Ok(())
320    } else {
321        Err(DfxCommandError::Failed {
322            command: display,
323            stderr: command_stderr(&output),
324        })
325    }
326}
327
328/// Execute a rendered dfx-compatible command and return raw process output.
329pub fn run_raw_output(program: &str, args: &[String]) -> Result<DfxRawOutput, std::io::Error> {
330    let output = Command::new(program).args(args).output()?;
331    Ok(DfxRawOutput {
332        success: output.status.success(),
333        status: exit_status_label(output.status),
334        stdout: output.stdout,
335        stderr: output.stderr,
336    })
337}
338
339/// Render a command for diagnostics and dry-run previews.
340#[must_use]
341pub fn command_display(command: &Command) -> String {
342    let mut parts = vec![command.get_program().to_string_lossy().to_string()];
343    parts.extend(
344        command
345            .get_args()
346            .map(|arg| arg.to_string_lossy().to_string()),
347    );
348    parts.join(" ")
349}
350
351/// Detect dfx's missing-canister-id diagnostic.
352#[must_use]
353pub fn canister_id_missing(stderr: &str) -> bool {
354    stderr.contains("Cannot find canister id")
355}
356
357/// Parse a likely snapshot id from `dfx canister snapshot create` output.
358#[must_use]
359pub fn parse_snapshot_id(output: &str) -> Option<String> {
360    output
361        .split(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ':' | ','))
362        .filter(|part| !part.is_empty())
363        .rev()
364        .find(|part| {
365            part.chars()
366                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
367        })
368        .map(str::to_string)
369}
370
371/// Parse `dfx canister snapshot list` output into snapshot ids.
372#[must_use]
373pub fn parse_snapshot_list_ids(output: &str) -> Vec<String> {
374    output
375        .lines()
376        .filter_map(|line| {
377            line.split_once(':')
378                .map(|(snapshot_id, _)| snapshot_id.trim())
379        })
380        .filter(|snapshot_id| !snapshot_id.is_empty())
381        .map(str::to_string)
382        .collect()
383}
384
385// Prefer stderr, but keep stdout diagnostics for dfx commands that report there.
386fn command_stderr(output: &std::process::Output) -> String {
387    let stderr = String::from_utf8_lossy(&output.stderr);
388    if stderr.trim().is_empty() {
389        String::from_utf8_lossy(&output.stdout).to_string()
390    } else {
391        stderr.to_string()
392    }
393}
394
395// Render process exit status without relying on platform-specific internals.
396fn exit_status_label(status: std::process::ExitStatus) -> String {
397    status
398        .code()
399        .map_or_else(|| "signal".to_string(), |code| code.to_string())
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    // Ensure snapshot ids can be extracted from common dfx create output.
407    #[test]
408    fn parses_snapshot_id_from_output() {
409        let snapshot_id = parse_snapshot_id("Created snapshot: snap_abc-123\n");
410
411        assert_eq!(snapshot_id.as_deref(), Some("snap_abc-123"));
412    }
413
414    // Ensure snapshot list output is reduced to ordered snapshot ids.
415    #[test]
416    fn parses_snapshot_ids_from_list_output() {
417        let snapshot_ids = parse_snapshot_list_ids(
418            "0000000000000000ffffffffff9000050101: size 10\n\
419             0000000000000000ffffffffff9000050102: size 12\n",
420        );
421
422        assert_eq!(
423            snapshot_ids,
424            vec![
425                "0000000000000000ffffffffff9000050101",
426                "0000000000000000ffffffffff9000050102"
427            ]
428        );
429    }
430}