Skip to main content

outrig_cli/image_setup/
inspect.rs

1//! `outrig image inspect` -- read an image's OutRig labels.
2
3use std::collections::BTreeMap;
4
5use outrig::container::embedded::{StandaloneImageLabels, parse_standalone_image_labels};
6use outrig::image::{self, ImageTag};
7
8use crate::error::{OutrigError, Result};
9
10/// Inspect an image's declared OutRig config from OCI labels. This is
11/// metadata-only: no pull, no container start, and no MCP server initialization.
12/// By default this reads only the local image store; `remote` switches to a
13/// registry metadata read via skopeo.
14pub async fn run(image_ref: &str, remote: bool) -> Result<()> {
15    let labels = if remote {
16        image::read_remote_image_labels(image_ref).await?
17    } else {
18        read_local_image_labels(image_ref).await?
19    };
20    let parsed = parse_standalone_image_labels(image_ref, &labels)?;
21    print!("{}", render_inspect(image_ref, &parsed));
22    Ok(())
23}
24
25async fn read_local_image_labels(image_ref: &str) -> Result<BTreeMap<String, String>> {
26    let tag = ImageTag(image_ref.to_string());
27    if !image::probe_pulled(&tag).await? {
28        return Err(OutrigError::Configuration(format!(
29            "local image {image_ref:?} not found; `outrig image inspect` is local-only and does not pull"
30        ))
31        .into());
32    }
33    Ok(image::read_image_labels(&tag, None).await?)
34}
35
36pub fn render_inspect(image_ref: &str, labels: &StandaloneImageLabels) -> String {
37    let mut out = String::new();
38    out.push_str("image: ");
39    out.push_str(image_ref);
40    out.push('\n');
41
42    if let Some(description) = &labels.description {
43        out.push_str("description: ");
44        out.push_str(description);
45        out.push('\n');
46    }
47    if let Some(version) = &labels.version {
48        out.push_str("version: ");
49        out.push_str(version);
50        out.push('\n');
51    }
52    if !labels.tags.is_empty() {
53        out.push_str("tags: ");
54        out.push_str(&json(&labels.tags));
55        out.push('\n');
56    }
57
58    if let Some(mcp) = &labels.mcp {
59        out.push_str("mcp:\n");
60        for (server, spec) in mcp {
61            let (command, env) = spec.normalize();
62            out.push_str("  ");
63            out.push_str(server);
64            out.push_str(":\n");
65            out.push_str("    command: ");
66            out.push_str(&json(&command));
67            out.push('\n');
68
69            if !env.is_empty() {
70                out.push_str("    env:\n");
71                for (key, value) in env {
72                    out.push_str("      ");
73                    out.push_str(&key);
74                    out.push_str(": ");
75                    out.push_str(&json(&value));
76                    out.push('\n');
77                }
78            }
79        }
80    }
81
82    out
83}
84
85fn json<T: serde::Serialize>(value: &T) -> String {
86    serde_json::to_string(value).expect("inspect values serialize to JSON")
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    use std::collections::BTreeMap;
94
95    use outrig::config::{EnvValue, McpServerSpec};
96
97    #[test]
98    fn render_inspect_prints_metadata_commands_and_env() {
99        let mut env = BTreeMap::new();
100        env.insert(
101            "CARGO_HOME".to_string(),
102            EnvValue::Literal("/cache".to_string()),
103        );
104        env.insert(
105            "TOKEN".to_string(),
106            EnvValue::EnvRef("DB_TOKEN".to_string()),
107        );
108
109        let mut mcp = BTreeMap::new();
110        mcp.insert(
111            "build".to_string(),
112            McpServerSpec::Full {
113                command: vec!["cargo-mcp".to_string(), "--stdio".to_string()],
114                env,
115            },
116        );
117        mcp.insert(
118            "fs".to_string(),
119            McpServerSpec::Short(vec![
120                "mcp-server-filesystem".to_string(),
121                "/workspace".to_string(),
122            ]),
123        );
124
125        let labels = StandaloneImageLabels {
126            description: Some("Rust tooling".to_string()),
127            version: Some("0.1.0".to_string()),
128            tags: vec!["rust".to_string(), "build".to_string()],
129            mcp: Some(mcp),
130        };
131
132        assert_eq!(
133            render_inspect("rust-dev", &labels),
134            concat!(
135                "image: rust-dev\n",
136                "description: Rust tooling\n",
137                "version: 0.1.0\n",
138                "tags: [\"rust\",\"build\"]\n",
139                "mcp:\n",
140                "  build:\n",
141                "    command: [\"cargo-mcp\",\"--stdio\"]\n",
142                "    env:\n",
143                "      CARGO_HOME: \"/cache\"\n",
144                "      TOKEN: \"${DB_TOKEN}\"\n",
145                "  fs:\n",
146                "    command: [\"mcp-server-filesystem\",\"/workspace\"]\n",
147            )
148        );
149    }
150
151    #[test]
152    fn render_inspect_omits_absent_metadata_and_mcp() {
153        let labels = StandaloneImageLabels {
154            description: None,
155            version: None,
156            tags: Vec::new(),
157            mcp: None,
158        };
159
160        assert_eq!(render_inspect("plain", &labels), "image: plain\n");
161    }
162}