command_json.rs

  1use std::process::Output;
  2
  3use async_trait::async_trait;
  4use serde::Deserialize;
  5use util::command::Command;
  6
  7use crate::devcontainer_api::DevContainerError;
  8
  9pub(crate) struct DefaultCommandRunner;
 10
 11impl DefaultCommandRunner {
 12    pub(crate) fn new() -> Self {
 13        Self
 14    }
 15}
 16
 17#[async_trait]
 18impl CommandRunner for DefaultCommandRunner {
 19    async fn run_command(&self, command: &mut Command) -> Result<Output, std::io::Error> {
 20        command.output().await
 21    }
 22}
 23
 24#[async_trait]
 25pub(crate) trait CommandRunner: Send + Sync {
 26    async fn run_command(&self, command: &mut Command) -> Result<Output, std::io::Error>;
 27}
 28
 29pub(crate) async fn evaluate_json_command<T>(
 30    mut command: Command,
 31) -> Result<Option<T>, DevContainerError>
 32where
 33    T: for<'de> Deserialize<'de>,
 34{
 35    let output = command.output().await.map_err(|e| {
 36        log::error!("Error running command {:?}: {e}", command);
 37        DevContainerError::CommandFailed(command.get_program().display().to_string())
 38    })?;
 39
 40    deserialize_json_output(output).map_err(|e| {
 41        log::error!("Error running command {:?}: {e}", command);
 42        DevContainerError::CommandFailed(command.get_program().display().to_string())
 43    })
 44}
 45
 46pub(crate) fn deserialize_json_output<T>(output: Output) -> Result<Option<T>, String>
 47where
 48    T: for<'de> Deserialize<'de>,
 49{
 50    if output.status.success() {
 51        let raw = String::from_utf8_lossy(&output.stdout);
 52        if raw.is_empty() || raw.trim() == "[]" || raw.trim() == "{}" {
 53            return Ok(None);
 54        }
 55        serde_json_lenient::from_str(&raw)
 56            .map_err(|e| format!("Error deserializing from raw json: {e}"))
 57    } else {
 58        let std_err = String::from_utf8_lossy(&output.stderr);
 59        Err(format!(
 60            "Sent non-successful output; cannot deserialize. StdErr: {std_err}"
 61        ))
 62    }
 63}
 64
 65#[cfg(test)]
 66mod tests {
 67    use std::process::ExitStatus;
 68
 69    use super::*;
 70
 71    fn success_output(stdout: &str) -> Output {
 72        Output {
 73            status: ExitStatus::default(),
 74            stdout: stdout.as_bytes().to_vec(),
 75            stderr: Vec::new(),
 76        }
 77    }
 78
 79    #[derive(Debug, Deserialize, PartialEq)]
 80    struct TestItem {
 81        id: String,
 82    }
 83
 84    #[test]
 85    fn test_deserialize_newline_delimited_json_rejected() {
 86        // Strict single-value contract: NDJSON must be rejected. Commands that
 87        // may legitimately return multiple rows (e.g. `docker ps`) parse their
 88        // output themselves rather than routing through this helper.
 89        let output = success_output("{\"id\":\"first\"}\n{\"id\":\"second\"}\n");
 90        let result: Result<Option<TestItem>, String> = deserialize_json_output(output);
 91        assert!(result.is_err(), "expected parse error, got {result:?}");
 92    }
 93
 94    #[test]
 95    fn test_deserialize_empty_output() {
 96        let output = success_output("");
 97        let result: Option<TestItem> = deserialize_json_output(output).unwrap();
 98        assert_eq!(result, None);
 99    }
100
101    #[test]
102    fn test_deserialize_empty_object() {
103        let output = success_output("{}");
104        let result: Option<TestItem> = deserialize_json_output(output).unwrap();
105        assert_eq!(result, None);
106    }
107}