orodruin-cli 0.2.0

Toolbox-style Apple container wrapper / Linux podman wrapper for project development environments
Documentation
use serde::Serialize;
use serde_json::Value;
use thiserror::Error;

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ContainerSummary {
    pub id: String,
    pub name: Option<String>,
    pub status: Option<String>,
    pub running: bool,
}

impl ContainerSummary {
    pub fn matches(&self, needle: &str) -> bool {
        self.id == needle || self.name.as_deref() == Some(needle)
    }
}

#[derive(Debug, Error)]
pub enum StateError {
    #[error("failed to parse container list output: {0}")]
    ParseList(#[source] serde_json::Error),
}

pub fn parse_list_output(output: &str) -> Result<Vec<ContainerSummary>, StateError> {
    let value = serde_json::from_str::<Value>(output).map_err(StateError::ParseList)?;
    Ok(match value {
        Value::Array(items) => items.into_iter().filter_map(parse_summary).collect(),
        Value::Object(_) => parse_summary(value).into_iter().collect(),
        _ => Vec::new(),
    })
}

pub fn parse_inspect_output(output: &str) -> Option<Value> {
    let value = serde_json::from_str::<Value>(output).ok()?;
    match value {
        Value::Array(items) => items.into_iter().next(),
        other => Some(other),
    }
}

fn parse_summary(value: Value) -> Option<ContainerSummary> {
    let id = field(&value, &["id", "Id", "ID", "identifier", "name"])?;
    let name = field(&value, &["name", "Name"]).or_else(|| first_string_field(&value, &["Names"]));
    let status = field(&value, &["status", "Status", "state", "State"]);
    let running = value
        .get("running")
        .and_then(Value::as_bool)
        .or_else(|| {
            status
                .as_ref()
                .map(|value| value.eq_ignore_ascii_case("running"))
        })
        .unwrap_or(false);
    Some(ContainerSummary {
        id,
        name,
        status,
        running,
    })
}

fn field(value: &Value, names: &[&str]) -> Option<String> {
    names.iter().find_map(|name| {
        value.get(name).and_then(|value| match value {
            Value::String(value) => Some(value.clone()),
            Value::Number(value) => Some(value.to_string()),
            _ => None,
        })
    })
}

fn first_string_field(value: &Value, names: &[&str]) -> Option<String> {
    names.iter().find_map(|name| {
        value.get(name).and_then(|value| {
            value
                .as_array()
                .and_then(|values| values.first())
                .and_then(Value::as_str)
                .map(str::to_owned)
        })
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn list_parse_error_bubbles_up() {
        let error = parse_list_output("not-json").unwrap_err();
        assert!(
            error
                .to_string()
                .contains("failed to parse container list output")
        );
    }

    #[test]
    fn parse_list_output_supports_podman_id_and_names() {
        let parsed = parse_list_output(
            &json!([
                {
                    "Id": "abc123",
                    "Names": ["orodruin-demo"],
                    "State": "running",
                    "Status": "Up 10 seconds"
                }
            ])
            .to_string(),
        )
        .unwrap();

        assert_eq!(parsed.len(), 1);
        assert_eq!(parsed[0].id, "abc123");
        assert_eq!(parsed[0].name.as_deref(), Some("orodruin-demo"));
        assert!(parsed[0].matches("orodruin-demo"));
    }
}