Skip to main content

onlyne_client/backend/
command.rs

1use super::*;
2
3pub(super) fn command_error(program: &str, output: &CommandOutput) -> anyhow::Error {
4    anyhow::anyhow!(
5        "runtime command failed: {program} (status {}): {}",
6        output.status,
7        String::from_utf8_lossy(&output.stderr).trim()
8    )
9}
10
11pub(super) fn run_checked(
12    runner: &dyn Runner,
13    program: &str,
14    args: &[String],
15    cwd: Option<&Path>,
16    env: &BTreeMap<String, String>,
17) -> Result<CommandOutput> {
18    let output = runner.run(program, args, cwd, env)?;
19    if output.status != 0 {
20        return Err(command_error(program, &output));
21    }
22    Ok(output)
23}
24
25/// A backend command failure decoded from the CLI's JSON error body.
26///
27/// Orca exits 1 with an empty stderr and `{"ok":false,"error":{"code":…}}` on
28/// stdout, so an exit-code-only message names neither the cause nor the
29/// command. [`run_json`] keeps both, and `code` is the machine-readable value
30/// backends branch on (`terminal_handle_stale`, `selector_not_found`, …).
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct CommandFailure {
33    command: String,
34    status: i32,
35    code: Option<String>,
36    message: Option<String>,
37}
38
39impl CommandFailure {
40    /// Build a failure for a command the backend describes itself.
41    pub(crate) fn new(
42        command: impl Into<String>,
43        status: i32,
44        code: Option<String>,
45        message: Option<String>,
46    ) -> Self {
47        Self {
48            command: command.into(),
49            status,
50            code,
51            message,
52        }
53    }
54
55    /// The CLI's machine-readable error code, when the body carried one.
56    pub fn code(&self) -> Option<&str> {
57        self.code.as_deref()
58    }
59}
60
61impl std::fmt::Display for CommandFailure {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        write!(
64            f,
65            "runtime command failed: {} (status {}",
66            self.command, self.status
67        )?;
68        if let Some(code) = &self.code {
69            write!(f, ", {code}")?;
70        }
71        write!(f, ")")?;
72        if let Some(message) = &self.message {
73            write!(f, ": {message}")?;
74        }
75        Ok(())
76    }
77}
78
79impl std::error::Error for CommandFailure {}
80
81/// Run one backend command whose protocol is JSON on stdout, and return its
82/// `result` object — or the whole document when it has no `result`.
83///
84/// Failure detail comes from the body first and the stderr second, so a
85/// backend that reports errors inside the payload still produces a message
86/// that names the cause. Callers branch on the code through
87/// [`CommandFailure::code`] after a `downcast_ref`.
88pub(super) fn run_json(
89    runner: &dyn Runner,
90    program: &str,
91    args: &[String],
92    cwd: Option<&Path>,
93    env: &BTreeMap<String, String>,
94) -> Result<Value> {
95    let output = runner.run(program, args, cwd, env)?;
96    let body = serde_json::from_slice::<Value>(&output.stdout).ok();
97    let command = format!("{program} {}", args.join(" "));
98    if output.status == 0 {
99        let Some(value) = body else {
100            return Err(command_failure(&command, &output, None).into());
101        };
102        if value.get("ok").and_then(Value::as_bool) == Some(false) {
103            return Err(command_failure(&command, &output, Some(&value)).into());
104        }
105        return Ok(match value.get("result") {
106            Some(result) => result.clone(),
107            None => value,
108        });
109    }
110    Err(command_failure(&command, &output, body.as_ref()).into())
111}
112
113/// The structured failure for one refused (or body-less) CLI answer.
114///
115/// Orca writes `{"ok":false,…}` to stdout; a host that answers with plain text
116/// on stderr is read the same way. Both shapes are decoded so
117/// [`CommandFailure::code`] carries the machine-readable code either way.
118pub(super) fn command_failure(
119    command: &str,
120    output: &CommandOutput,
121    body: Option<&Value>,
122) -> CommandFailure {
123    let stderr_body = || -> Option<Value> {
124        let text = String::from_utf8_lossy(&output.stderr);
125        let text = text.trim();
126        if text.is_empty() {
127            return None;
128        }
129        serde_json::from_str::<Value>(text).ok()
130    };
131    let fallback = stderr_body();
132    let error = body
133        .and_then(|body| body.get("error"))
134        .or_else(|| fallback.as_ref().and_then(|value| value.get("error")));
135    let text = |key: &str| {
136        error
137            .and_then(|error| error.get(key))
138            .and_then(Value::as_str)
139            .filter(|value| !value.is_empty())
140            .map(str::to_owned)
141    };
142    let message = text("message").or_else(|| {
143        let stderr = String::from_utf8_lossy(&output.stderr);
144        let stderr = stderr.trim();
145        if stderr.is_empty() {
146            body.map(Value::to_string)
147        } else {
148            Some(stderr.to_string())
149        }
150    });
151    CommandFailure::new(command, output.status, text("code"), message)
152}
153
154/// The JSON error code of a backend CLI failure, when it carried one.
155pub(super) fn failure_code(error: &anyhow::Error) -> Option<&str> {
156    error
157        .downcast_ref::<CommandFailure>()
158        .and_then(CommandFailure::code)
159}
160
161pub(super) fn unsupported(backend: &str, operation: &str, detail: &str) -> anyhow::Error {
162    anyhow::anyhow!("runtime backend {backend} does not support {operation}: {detail}")
163}