From 66c84efbf47974a66e743ae05583d39dd411cd2c Mon Sep 17 00:00:00 2001 From: Archie Atkinson Date: Mon, 22 Dec 2025 13:43:06 +0000 Subject: [PATCH 1/2] Intial implemention --- Cargo.lock | 84 +++++++++- Cargo.toml | 1 + config_examples/basic.toml | 8 + .../.devcontainer/Dockerfile | 3 + .../.devcontainer/devcontainer.json | 7 + .../devcontainer_example/simple_preset.toml | 5 + config_examples/simple_preset.toml | 8 - justfile | 4 +- src/cli.rs | 2 +- src/configuration.rs | 61 ++++++- src/devcontainer.rs | 150 ++++++++++++++++++ src/docker.rs | 12 +- src/lib.rs | 1 + 13 files changed, 322 insertions(+), 24 deletions(-) create mode 100644 config_examples/devcontainer_example/.devcontainer/Dockerfile create mode 100644 config_examples/devcontainer_example/.devcontainer/devcontainer.json create mode 100644 config_examples/devcontainer_example/simple_preset.toml delete mode 100644 config_examples/simple_preset.toml create mode 100644 src/devcontainer.rs diff --git a/Cargo.lock b/Cargo.lock index 6f2cffe..5cb19ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -175,6 +175,7 @@ dependencies = [ "pretty_assertions", "rand", "serde", + "serde_json5", "serial_test", "sha2", "shell-words", @@ -1327,6 +1328,49 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "pest" +version = "2.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbcfd20a6d4eeba40179f05735784ad32bdaef05ce8e8af05f180d45bb3e7e22" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51f72981ade67b1ca6adc26ec221be9f463f2b5839c7508998daa17c23d94d7f" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee9efd8cdb50d719a80088b76f81aec7c41ed6d522ee750178f83883d271625" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "pest_meta" +version = "2.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf1d70880e76bdc13ba52eafa6239ce793d85c8e43896507e43dd8984ff05b82" +dependencies = [ + "pest", + "sha2", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1605,10 +1649,11 @@ checksum = "584e070911c7017da6cb2eb0788d09f43d789029b5877d3e5ecc8acf86ceee21" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ + "serde_core", "serde_derive", ] @@ -1622,11 +1667,20 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -1635,14 +1689,26 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", +] + +[[package]] +name = "serde_json5" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d34d03f54462862f2a42918391c9526337f53171eaa4d8894562be7f252edd3" +dependencies = [ + "pest", + "pest_derive", + "serde", ] [[package]] @@ -2141,6 +2207,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicode-ident" version = "1.0.18" diff --git a/Cargo.toml b/Cargo.toml index b0aa3b0..cd86f93 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ tokio-util = "0.7" sha2 = "0.10" miette = { version = "7.5", features = ["fancy"] } indicatif = "0.17" +serde_json5 = "0.2.1" [dev-dependencies] assert_cmd = "2.0" diff --git a/config_examples/basic.toml b/config_examples/basic.toml index 787af07..54fca6e 100644 --- a/config_examples/basic.toml +++ b/config_examples/basic.toml @@ -4,3 +4,11 @@ exec_cmds = ["apk add helix asciiquarium"] entry_cmd = "/bin/ash" create_options = [ "-it"] entry_options = ["-it"] + +[environment.dev] +devcontainer_json = "$PWD/.devcontainer/devcontainer.json" +create_options = ["-it", "-v $SSH_AUTH_SOCK:$SSH_AUTH_SOCK", "-e SSH_AUTH_SOCK=$SSH_AUTH_SOCK"] +cp_cmds = ["/home/archie/dotfiles/bundler/bundle/. CONTAINER:/tmp/bundle"] +exec_cmds = ["bash -c 'cd /tmp/bundle && bash install.sh'"] +entry_options = ["-it", "-e EDITOR=hx"] +entry_cmd = "/bin/bash" diff --git a/config_examples/devcontainer_example/.devcontainer/Dockerfile b/config_examples/devcontainer_example/.devcontainer/Dockerfile new file mode 100644 index 0000000..ce12f2f --- /dev/null +++ b/config_examples/devcontainer_example/.devcontainer/Dockerfile @@ -0,0 +1,3 @@ +FROM ubuntu:24.04 + +CMD ["/bin/bash"] diff --git a/config_examples/devcontainer_example/.devcontainer/devcontainer.json b/config_examples/devcontainer_example/.devcontainer/devcontainer.json new file mode 100644 index 0000000..b411744 --- /dev/null +++ b/config_examples/devcontainer_example/.devcontainer/devcontainer.json @@ -0,0 +1,7 @@ +{ + "name": "MyPreset", + "build": { + "dockerfile": "Dockerfile" + }, + "workspaceMount": "source=${localWorkspaceFolder},target=/root,type=bind" +} diff --git a/config_examples/devcontainer_example/simple_preset.toml b/config_examples/devcontainer_example/simple_preset.toml new file mode 100644 index 0000000..1418ae1 --- /dev/null +++ b/config_examples/devcontainer_example/simple_preset.toml @@ -0,0 +1,5 @@ +[environment.dev] +devcontainer_json = "$PWD/.devcontainer/devcontainer.json" +entry_cmd = "/bin/bash" +create_options = [ "-it"] +entry_options = ["-it"] diff --git a/config_examples/simple_preset.toml b/config_examples/simple_preset.toml deleted file mode 100644 index f08b605..0000000 --- a/config_examples/simple_preset.toml +++ /dev/null @@ -1,8 +0,0 @@ -[preset.interactive] -create_options = ["-it"] -entry_options = ["-it"] - -[environment.example] -image = "alpine:edge" -entry_cmd = "/bin/ash" -presets = ["interactive"] diff --git a/justfile b/justfile index 44da685..ede25ac 100644 --- a/justfile +++ b/justfile @@ -3,8 +3,8 @@ cargo build [no-cd, no-exit-message] -@ test: clippy - cargo test +test *args: clippy + cargo test {{args}} [no-cd, no-exit-message] @ clippy: diff --git a/src/cli.rs b/src/cli.rs index 0c34424..72d8df4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -97,7 +97,7 @@ impl AppConfig { fn set_config_path(config_path: Option) -> Result { if let Some(path) = config_path { return if path.exists() && path.is_file() { - Ok(path) + Ok(path.canonicalize().unwrap()) } else { Err(CliError::NoConfigAtProvidedPath(path.as_os_str().into()).into()) }; diff --git a/src/configuration.rs b/src/configuration.rs index ba81ea2..19acaa3 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -12,7 +12,7 @@ use std::{ }; use thiserror::Error; -use crate::{cli::AppConfig, util::UnexpectedExt}; +use crate::{cli::AppConfig, devcontainer::Metadata, util::UnexpectedExt}; #[derive(Debug, Error, PartialEq, Diagnostic)] pub enum ConfigError { @@ -108,6 +108,9 @@ pub struct TomlEnvironment { #[serde(default)] build_context: String, + #[serde(default)] + devcontainer_json: String, + #[serde(default)] entry_options: Vec, @@ -143,6 +146,9 @@ pub struct TomlPreset { #[serde(default)] build_context: String, + #[serde(default)] + devcontainer_json: String, + #[serde(default)] entry_options: Vec, @@ -178,6 +184,7 @@ pub struct Environment { pub image: String, pub dockerfile: Option, pub build_context: Option, + pub devcontainer_json: Option, pub entry_cmd: String, pub entry_options: Vec, pub exec_cmds: Vec, @@ -207,6 +214,7 @@ impl Configuration { let config = self.check_presets_exist(config)?; let config = self.valid_unique_fields(config)?; let envs = self.merge_presets(config)?; + let envs = self.inject_devcontaier(envs)?; let envs = self.validate_environments(envs)?; self.create_environment(envs) } @@ -233,6 +241,40 @@ impl Configuration { } } + fn inject_devcontaier(&self, mut envs: TomlEnvs) -> Result { + for (_, env) in envs.iter_mut() { + if !env.devcontainer_json.is_empty() { + let mut options = ExpandOptions::new(); + options.expansion_type = Some(ExpansionType::Unix); + + let path = envmnt::expand(&env.devcontainer_json, Some(options)); + let json = Metadata::new(path).unwrap(); + + if let Some(user) = json.remote_user { + let user_commands = vec![format!("-u {user}"), format!("-e USER={user}")]; + env.exec_options.extend_from_slice(&user_commands); + env.entry_options.extend_from_slice(&user_commands); + } + + if let Some(mount) = json.workspace_mount { + env.create_options.push(mount); + } + + if env.provided_image.is_empty() { + env.provided_image = json.image.unwrap_or_default(); + } + + if env.dockerfile.is_empty() && env.build_context.is_empty() { + if let Some(build) = json.build { + env.dockerfile = build.dockerfile.unwrap_or_default(); + env.build_context = build.context.unwrap_or_default(); + } + } + } + } + Ok(envs) + } + fn check_presets_exist(&self, config: TomlConfiguration) -> Result { for (env_name, env) in &config.environments { for preset_name in &env.presets { @@ -294,6 +336,7 @@ impl Configuration { "image" => !env.provided_image.is_empty(), "dockerfile" => !env.dockerfile.is_empty(), "build_context" => !env.build_context.is_empty(), + "devcontainer_json" => !env.devcontainer_json.is_empty(), _ => unreachable!("Unknown field {field}"), }; @@ -315,6 +358,9 @@ impl Configuration { "image" => !config.presets[preset_name].provided_image.is_empty(), "dockerfile" => !config.presets[preset_name].dockerfile.is_empty(), "build_context" => !config.presets[preset_name].build_context.is_empty(), + "devcontainer_json" => { + !config.presets[preset_name].devcontainer_json.is_empty() + } _ => unreachable!("Unknown field {field}"), }; @@ -359,7 +405,13 @@ impl Configuration { Ok(()) }; - let unique_fields = ["entry_cmd", "image", "dockerfile", "build_context"]; + let unique_fields = [ + "entry_cmd", + "image", + "dockerfile", + "build_context", + "devcontainer_json", + ]; for (env_name, env) in &config.environments { for field in unique_fields { check_unique(field, env, env_name)?; @@ -385,6 +437,10 @@ impl Configuration { env.dockerfile = preset.dockerfile.clone(); } + if !preset.devcontainer_json.is_empty() { + env.devcontainer_json = preset.devcontainer_json.clone(); + } + env.entry_options.extend_from_slice(&preset.entry_options); env.exec_cmds.extend_from_slice(&preset.exec_cmds); env.exec_options.extend_from_slice(&preset.exec_options); @@ -503,6 +559,7 @@ impl Configuration { dockerfile, build_context, entry_cmd: env.entry_cmd, + devcontainer_json: Some(env.devcontainer_json), entry_options: env.entry_options, exec_cmds: env.exec_cmds, exec_options: env.exec_options, diff --git a/src/devcontainer.rs b/src/devcontainer.rs new file mode 100644 index 0000000..c50acb9 --- /dev/null +++ b/src/devcontainer.rs @@ -0,0 +1,150 @@ +use std::{fs, path::Path}; + +use miette::{Diagnostic, Result}; +use serde::Deserialize; +use thiserror::Error; + +#[derive(Debug, Error, PartialEq, Diagnostic)] +pub enum JsonError { + #[error("Bad devcontainer.json path {0}")] + #[diagnostic(code(devcontainer::parsing))] + Path(String), + #[error("Malformed devcontainer.json {0}")] + #[diagnostic(code(devcontainer::parsing))] + Parsing(String), +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Metadata { + pub name: Option, + pub remote_user: Option, + pub workspace_mount: Option, + pub workspace_folder: Option, + pub image: Option, + pub build: Option, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Build { + pub dockerfile: Option, + pub context: Option, +} + +impl Metadata { + pub fn new(json_path: impl AsRef) -> Result { + let content = fs::read_to_string(&json_path).map_err(|e| JsonError::Path(e.to_string()))?; + let mut metadata: Metadata = + serde_json5::from_str(&content).map_err(|e| JsonError::Parsing(e.to_string()))?; + + Self::fix_mount(&json_path, &mut metadata); + Self::fix_build(&json_path, &mut metadata); + Ok(metadata) + } + + fn fix_mount(json_path: impl AsRef, metadata: &mut Metadata) { + let Some(mount) = metadata.workspace_mount.clone() else { + return; + }; + + let split: Vec = mount.split(',').map(|f| f.to_owned()).collect(); + let mut source = split[0].strip_prefix("source=").unwrap().to_owned(); + if &source == "${localWorkspaceFolder}" { + let path = json_path.as_ref().to_path_buf(); + source = path + .ancestors() + .nth(2) // Get workspace folder + .and_then(|p| p.to_str()) + .map(|s| s.to_owned()) + .expect("Failed to get grandparent path as string"); + } + + let target = split[1].strip_prefix("target=").unwrap(); + + log::warn!("Assuming mount type is 'bind'"); + let mount = format!("-v {source}:{target}"); + + metadata.workspace_mount = Some(mount); + } + + fn fix_build(json_path: impl AsRef, metadata: &mut Metadata) { + let Some(ref mut build) = metadata.build else { + return; + }; + + let devcontainer_root = json_path.as_ref().parent().unwrap(); + + if let Some(ref mut dockerfile) = build.dockerfile { + let mut dockerfile_path = devcontainer_root.to_path_buf(); + dockerfile_path.push(&dockerfile); + *dockerfile = dockerfile_path.to_str().unwrap().to_owned(); + }; + + if let Some(ref mut context) = build.context { + let mut dockerfile_path = devcontainer_root.to_path_buf(); + dockerfile_path.push(&context); + *context = dockerfile_path.to_str().unwrap().to_owned(); + }; + } +} + +#[cfg(test)] +mod test { + + use std::{fs::File, io::Write}; + + use tempfile::TempDir; + + use super::*; + + #[test] + fn new_metadata() { + let data = r#" + { + "name": "Name", + "image": "Image", + "remoteUser": "RemoteUser", + "workspaceMount": "source=Source,target=Target,mount=bind", + "workspaceFolder": "WorkspaceFolder", + "build": { + "dockerfile": "Dockerfile", + "context": "Context", + } + } + "#; + + let devcontainer_dir = TempDir::new().unwrap(); + + let mut json_path = devcontainer_dir.path().to_path_buf(); + json_path.push("devcontainer.json"); + + let mut file = File::create(&json_path).unwrap(); + file.write(data.as_bytes()).unwrap(); + + let metadata = Metadata::new(&json_path).unwrap(); + + assert_eq!(metadata.name, Some("Name".into())); + assert_eq!(metadata.image, Some("Image".into())); + assert_eq!(metadata.remote_user, Some("RemoteUser".into())); + assert_eq!(metadata.workspace_mount, Some("-v Source:Target".into())); + assert_eq!(metadata.workspace_folder, Some("WorkspaceFolder".into())); + + let docker_path = format!( + "{}/{}", + devcontainer_dir.path().to_str().unwrap(), + "Dockerfile" + ); + + let build = metadata.build.unwrap(); + + assert_eq!(build.dockerfile, Some(docker_path)); + + let context_path = format!( + "{}/{}", + devcontainer_dir.path().to_str().unwrap(), + "Context" + ); + assert_eq!(build.context, Some(context_path)); + } +} diff --git a/src/docker.rs b/src/docker.rs index 7b733fa..5e9c552 100644 --- a/src/docker.rs +++ b/src/docker.rs @@ -52,9 +52,9 @@ pub enum DockerError { #[diagnostic(code(cli::container::command::killed))] CommandKilled(String), - #[error("The following command failed to run:\n{0}")] + #[error("The following command failed to run:\n{0}\n{1}")] #[diagnostic(code(cli::container::command::failed))] - CommandFailed(String), + CommandFailed(String, String), } macro_rules! docker_err { @@ -102,8 +102,10 @@ impl DockerHandler { .as_path() .to_string_lossy() .to_string(); + let args = vec!["build", "-t", &self.env.image, "-f", &dockerfile_path, "."]; let build_context = self.env.build_context.as_ref().unwrap_or(&self.config_dir); + self.run_docker_command(args, build_context)?; spinner.finish_and_clear(); @@ -154,7 +156,7 @@ impl DockerHandler { let exit_code = Command::new(CONTAINER_ENGINE) .args(&args) .status() - .map_err(|_| DockerError::CommandFailed(command))? + .map_err(|e| DockerError::CommandFailed(command.clone(), e.to_string()))? .code(); let error_str = match exit_code { @@ -305,7 +307,7 @@ impl DockerHandler { .args(&args) .current_dir(working_dir) .output() - .map_err(|_| DockerError::CommandFailed(command.clone()))?; + .map_err(|e| DockerError::CommandFailed(command.clone(), e.to_string()))?; let status_code = output.status.code(); match status_code { @@ -313,7 +315,7 @@ impl DockerHandler { Some(0) => Ok(output), Some(_) => Err(DockerError::CommandExitCode { cmd: command, - stdout: String::from_utf8(output.stdout.clone()).unwrap(), + stdout: String::from_utf8(output.stderr.clone()).unwrap(), } .into()), } diff --git a/src/lib.rs b/src/lib.rs index 23b1009..bd85730 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod cli; pub mod configuration; +pub mod devcontainer; pub mod docker; pub mod util; pub use util::UnexpectedExt; From bbb92c848888e545be61f97790e13339710ad77c Mon Sep 17 00:00:00 2001 From: Archie Atkinson Date: Mon, 22 Dec 2025 18:16:45 +0000 Subject: [PATCH 2/2] Adds devcontainer tests --- src/devcontainer.rs | 191 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 155 insertions(+), 36 deletions(-) diff --git a/src/devcontainer.rs b/src/devcontainer.rs index c50acb9..d8ccb3f 100644 --- a/src/devcontainer.rs +++ b/src/devcontainer.rs @@ -4,14 +4,22 @@ use miette::{Diagnostic, Result}; use serde::Deserialize; use thiserror::Error; +use crate::UnexpectedExt; + #[derive(Debug, Error, PartialEq, Diagnostic)] -pub enum JsonError { - #[error("Bad devcontainer.json path {0}")] - #[diagnostic(code(devcontainer::parsing))] - Path(String), +pub enum DevContError { + #[error("Bad devcontainer.json path '{0}'\n{1}")] + #[diagnostic(code(devcontainer::path))] + Path(String, String), #[error("Malformed devcontainer.json {0}")] #[diagnostic(code(devcontainer::parsing))] Parsing(String), + #[error("Malformed 'workspaceMount': {0}\n{1}")] + #[diagnostic(code(devcontainer::json))] + WorkspaceMount(String, String), + #[error("Failed to find '${{localWorkspaceFolder}}': {0}\n{1}")] + #[diagnostic(code(devcontainer::json))] + WorkspaceFolder(String, String), } #[derive(Deserialize, Debug)] @@ -34,58 +42,95 @@ pub struct Build { impl Metadata { pub fn new(json_path: impl AsRef) -> Result { - let content = fs::read_to_string(&json_path).map_err(|e| JsonError::Path(e.to_string()))?; + let mut content = fs::read_to_string(&json_path).map_err(|e| { + DevContError::Path( + json_path + .as_ref() + .to_str() + .unwrap_or("") + .to_owned(), + e.to_string(), + ) + })?; + + content = Self::replace_envs(&json_path, &content)?; + let mut metadata: Metadata = - serde_json5::from_str(&content).map_err(|e| JsonError::Parsing(e.to_string()))?; + serde_json5::from_str(&content).map_err(|e| DevContError::Parsing(e.to_string()))?; - Self::fix_mount(&json_path, &mut metadata); - Self::fix_build(&json_path, &mut metadata); + metadata = Self::parse_mount(metadata)?; + metadata = Self::fix_build(&json_path, metadata)?; Ok(metadata) } - fn fix_mount(json_path: impl AsRef, metadata: &mut Metadata) { + fn replace_envs(json_path: impl AsRef, content: &str) -> Result { + let path = json_path.as_ref().to_path_buf(); + eprintln!("PATH: {:?}", path); + let workspace_dir = path + .ancestors() + .nth(2) // Get workspace folder + .ok_or(DevContError::WorkspaceFolder( + path.to_str().unexpected()?.to_owned(), + "Unable to find workspace in path".to_string(), + ))? + .to_str() + .map(|s| s.to_owned()) + .unexpected()?; + + let json = content.replace("${localWorkspaceFolder}", &workspace_dir); + + Ok(json) + } + + fn parse_mount(mut metadata: Metadata) -> Result { let Some(mount) = metadata.workspace_mount.clone() else { - return; + return Ok(metadata); }; let split: Vec = mount.split(',').map(|f| f.to_owned()).collect(); - let mut source = split[0].strip_prefix("source=").unwrap().to_owned(); - if &source == "${localWorkspaceFolder}" { - let path = json_path.as_ref().to_path_buf(); - source = path - .ancestors() - .nth(2) // Get workspace folder - .and_then(|p| p.to_str()) - .map(|s| s.to_owned()) - .expect("Failed to get grandparent path as string"); - } - - let target = split[1].strip_prefix("target=").unwrap(); + + let source = split[0] + .strip_prefix("source=") + .ok_or(DevContError::WorkspaceMount( + mount.to_owned(), + "No 'source' provided".to_string(), + ))? + .to_owned(); + + let target = split[1] + .strip_prefix("target=") + .ok_or(DevContError::WorkspaceMount( + mount.to_owned(), + "No 'target' provided".to_string(), + ))?; log::warn!("Assuming mount type is 'bind'"); let mount = format!("-v {source}:{target}"); metadata.workspace_mount = Some(mount); + Ok(metadata) } - fn fix_build(json_path: impl AsRef, metadata: &mut Metadata) { + fn fix_build(json_path: impl AsRef, mut metadata: Metadata) -> Result { let Some(ref mut build) = metadata.build else { - return; + return Ok(metadata); }; - let devcontainer_root = json_path.as_ref().parent().unwrap(); + let dir = json_path.as_ref().parent().unexpected()?; if let Some(ref mut dockerfile) = build.dockerfile { - let mut dockerfile_path = devcontainer_root.to_path_buf(); + let mut dockerfile_path = dir.to_path_buf(); dockerfile_path.push(&dockerfile); - *dockerfile = dockerfile_path.to_str().unwrap().to_owned(); + *dockerfile = dockerfile_path.to_str().unexpected()?.to_owned(); }; if let Some(ref mut context) = build.context { - let mut dockerfile_path = devcontainer_root.to_path_buf(); + let mut dockerfile_path = dir.to_path_buf(); dockerfile_path.push(&context); - *context = dockerfile_path.to_str().unwrap().to_owned(); + *context = dockerfile_path.to_str().unexpected()?.to_owned(); }; + + Ok(metadata) } } @@ -94,10 +139,77 @@ mod test { use std::{fs::File, io::Write}; - use tempfile::TempDir; + use tempfile::{NamedTempFile, TempDir}; use super::*; + #[test] + fn invalid_path() { + let report = Metadata::new("").unwrap_err(); + + assert!(matches!( + report.downcast_ref::(), + Some(DevContError::Path(_, _)) + )); + } + + #[test] + fn malform_json() { + let data = r#" + { + name + } + "#; + + let mut json = NamedTempFile::new().unwrap(); + json.write(data.as_bytes()).unwrap(); + + let report = Metadata::new(json.path()).unwrap_err(); + + assert!(matches!( + report.downcast_ref::(), + Some(DevContError::Parsing(_)) + )); + } + + #[test] + fn mount_no_source() { + let data = r#" + { + "workspaceMount": "Source,target=Target,mount=bind", + } + "#; + + let mut json = NamedTempFile::new().unwrap(); + json.write(data.as_bytes()).unwrap(); + + let report = Metadata::new(json.path()).unwrap_err(); + + assert!(matches!( + report.downcast_ref::(), + Some(DevContError::WorkspaceMount(_, _)) + )); + } + + #[test] + fn mount_no_target() { + let data = r#" + { + "workspaceMount": "source=Source,mount=bind", + } + "#; + + let mut json = NamedTempFile::new().unwrap(); + json.write(data.as_bytes()).unwrap(); + + let report = Metadata::new(json.path()).unwrap_err(); + + assert!(matches!( + report.downcast_ref::(), + Some(DevContError::WorkspaceMount(_, _)) + )); + } + #[test] fn new_metadata() { let data = r#" @@ -106,7 +218,7 @@ mod test { "image": "Image", "remoteUser": "RemoteUser", "workspaceMount": "source=Source,target=Target,mount=bind", - "workspaceFolder": "WorkspaceFolder", + "workspaceFolder": "${localWorkspaceFolder}", "build": { "dockerfile": "Dockerfile", "context": "Context", @@ -114,9 +226,13 @@ mod test { } "#; - let devcontainer_dir = TempDir::new().unwrap(); + let tmp = TempDir::new().unwrap(); - let mut json_path = devcontainer_dir.path().to_path_buf(); + let workspace_dir = tmp.path().to_path_buf(); + + let mut json_path = workspace_dir.clone(); + json_path.push(".devcontainer"); + fs::create_dir_all(&json_path).unwrap(); json_path.push("devcontainer.json"); let mut file = File::create(&json_path).unwrap(); @@ -128,11 +244,14 @@ mod test { assert_eq!(metadata.image, Some("Image".into())); assert_eq!(metadata.remote_user, Some("RemoteUser".into())); assert_eq!(metadata.workspace_mount, Some("-v Source:Target".into())); - assert_eq!(metadata.workspace_folder, Some("WorkspaceFolder".into())); + assert_eq!( + metadata.workspace_folder, + Some(workspace_dir.as_path().to_str().unwrap().to_owned()) + ); let docker_path = format!( "{}/{}", - devcontainer_dir.path().to_str().unwrap(), + json_path.parent().unwrap().to_str().unwrap(), "Dockerfile" ); @@ -142,7 +261,7 @@ mod test { let context_path = format!( "{}/{}", - devcontainer_dir.path().to_str().unwrap(), + json_path.parent().unwrap().to_str().unwrap(), "Context" ); assert_eq!(build.context, Some(context_path));