Parse output from older version of the devcontainer CLI by looking for a JSON object in plaintext (#47403)

KyleBarton created

Closes #46852

The devcontainer CLI which ships with VS Code can be added to the user's
`PATH` with the VS Code command `Dev Containers: Install devcontainer
CLI`. This version of the CLI is older than what Zed was developed with,
and as a result, it doesn't separate its json-formatted output and its
plaintext metadata into distinct output streams. This broke parsing in
the CLI adapter in Zed. This fix accounts for that, and if parsing
fails, attempts to find a relevant JSON object in plaintext and tries to
parse that.

Tested with VSCode's version of the devcontainer CLI (`0.80.1`) as well
as the up-to-date version (`0.81.1`)

Release Notes:

- Improved parsing of devcontainer CLI output when using earlier
versions

Change summary

crates/dev_container/src/devcontainer_api.rs | 67 +++++++++++++--------
1 file changed, 42 insertions(+), 25 deletions(-)

Detailed changes

crates/dev_container/src/devcontainer_api.rs 🔗

@@ -330,13 +330,7 @@ async fn devcontainer_up(
         Ok(output) => {
             if output.status.success() {
                 let raw = String::from_utf8_lossy(&output.stdout);
-                serde_json::from_str::<DevContainerUp>(&raw).map_err(|e| {
-                    log::error!(
-                        "Unable to parse response from 'devcontainer up' command, error: {:?}",
-                        e
-                    );
-                    DevContainerError::DevContainerParseFailed
-                })
+                parse_json_from_cli(&raw)
             } else {
                 let message = format!(
                     "Non-success status running devcontainer up for workspace: out: {:?}, err: {:?}",
@@ -355,6 +349,7 @@ async fn devcontainer_up(
         }
     }
 }
+
 async fn devcontainer_read_configuration(
     path_to_cli: &PathBuf,
     found_in_path: bool,
@@ -377,13 +372,7 @@ async fn devcontainer_read_configuration(
         Ok(output) => {
             if output.status.success() {
                 let raw = String::from_utf8_lossy(&output.stdout);
-                serde_json::from_str::<DevContainerConfigurationOutput>(&raw).map_err(|e| {
-                    log::error!(
-                        "Unable to parse response from 'devcontainer read-configuration' command, error: {:?}",
-                        e
-                    );
-                    DevContainerError::DevContainerParseFailed
-                })
+                parse_json_from_cli(&raw)
             } else {
                 let message = format!(
                     "Non-success status running devcontainer read-configuration for workspace: out: {:?}, err: {:?}",
@@ -449,13 +438,7 @@ async fn devcontainer_template_apply(
         Ok(output) => {
             if output.status.success() {
                 let raw = String::from_utf8_lossy(&output.stdout);
-                serde_json::from_str::<DevContainerApply>(&raw).map_err(|e| {
-                    log::error!(
-                        "Unable to parse response from 'devcontainer templates apply' command, error: {:?}",
-                        e
-                    );
-                    DevContainerError::DevContainerParseFailed
-                })
+                parse_json_from_cli(&raw)
             } else {
                 let message = format!(
                     "Non-success status running devcontainer templates apply for workspace: out: {:?}, err: {:?}",
@@ -474,6 +457,29 @@ async fn devcontainer_template_apply(
         }
     }
 }
+// Try to parse directly first (newer versions output pure JSON)
+// If that fails, look for JSON start (older versions have plaintext prefix)
+fn parse_json_from_cli<T: serde::de::DeserializeOwned>(raw: &str) -> Result<T, DevContainerError> {
+    serde_json::from_str::<T>(&raw)
+        .or_else(|e| {
+            log::error!("Error parsing json: {} - will try to find json object in larger plaintext", e);
+            let json_start = raw
+                .find(|c| c == '{')
+                .ok_or_else(|| {
+                    log::error!("No JSON found in devcontainer up output");
+                    DevContainerError::DevContainerParseFailed
+                })?;
+
+            serde_json::from_str(&raw[json_start..]).map_err(|e| {
+                log::error!(
+                    "Unable to parse JSON from devcontainer up output (starting at position {}), error: {:?}",
+                    json_start,
+                    e
+                );
+                DevContainerError::DevContainerParseFailed
+            })
+        })
+}
 
 fn devcontainer_cli_command(
     path_to_cli: &PathBuf,
@@ -522,7 +528,7 @@ fn project_directory(cx: &mut AsyncWindowContext) -> Option<Arc<Path>> {
 }
 
 fn template_features_to_json(features_selected: &HashSet<DevContainerFeature>) -> String {
-    let things = features_selected
+    let features_map = features_selected
         .iter()
         .map(|feature| {
             let mut map = HashMap::new();
@@ -541,17 +547,28 @@ fn template_features_to_json(features_selected: &HashSet<DevContainerFeature>) -
             map
         })
         .collect::<Vec<HashMap<&str, String>>>();
-    serde_json::to_string(&things).unwrap()
+    serde_json::to_string(&features_map).unwrap()
 }
 
 #[cfg(test)]
 mod tests {
-    use crate::devcontainer_api::DevContainerUp;
+    use crate::devcontainer_api::{DevContainerUp, parse_json_from_cli};
 
     #[test]
     fn should_parse_from_devcontainer_json() {
         let json = r#"{"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#;
-        let up: DevContainerUp = serde_json::from_str(json).unwrap();
+        let up: DevContainerUp = parse_json_from_cli(json).unwrap();
+        assert_eq!(up._outcome, "success");
+        assert_eq!(
+            up.container_id,
+            "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
+        );
+        assert_eq!(up._remote_user, "vscode");
+        assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
+
+        let json_in_plaintext = r#"[2026-01-22T16:19:08.802Z] @devcontainers/cli 0.80.1. Node.js v22.21.1. darwin 24.6.0 arm64.
+            {"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#;
+        let up: DevContainerUp = parse_json_from_cli(json_in_plaintext).unwrap();
         assert_eq!(up._outcome, "success");
         assert_eq!(
             up.container_id,