From 8ea8e374b9f18330aea51b1430993d903ecde8df Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Mon, 8 Jun 2026 10:58:42 +0200 Subject: [PATCH 1/2] Remove dead compose volume rendering code --- .../src/runtime/compose/override_file.rs | 11 +- .../src/runtime/compose/override_mounts.rs | 141 +++--------------- .../src/runtime/compose/override_yaml.rs | 33 +--- cmd/devcontainer/src/runtime/compose/tests.rs | 141 ++++++------------ cmd/devcontainer/src/runtime/context.rs | 47 ++++++ .../src/runtime/context/workspace.rs | 12 +- 6 files changed, 125 insertions(+), 260 deletions(-) diff --git a/cmd/devcontainer/src/runtime/compose/override_file.rs b/cmd/devcontainer/src/runtime/compose/override_file.rs index 0c34ed334..80db873de 100644 --- a/cmd/devcontainer/src/runtime/compose/override_file.rs +++ b/cmd/devcontainer/src/runtime/compose/override_file.rs @@ -17,10 +17,7 @@ use super::super::metadata::serialized_container_metadata; use super::super::paths::unique_temp_path; use super::service::{self, ServiceDefinition}; use super::ComposeSpec; -use override_mounts::{ - compose_additional_volumes, compose_environment, compose_named_volumes, - compose_workspace_volume, -}; +use override_mounts::{compose_additional_volumes, compose_environment, compose_named_volumes}; use override_yaml::{ escape_compose_label, escape_compose_scalar, render_compose_string_sequence, render_compose_volume_entry, render_named_volume_entry, @@ -96,11 +93,7 @@ pub(super) fn compose_metadata_override_file( if let Some(command) = compose_wrapper_command(resolved, service_definition.as_ref())? { content.push_str(&format!(" command: {command}\n")); } - let mut volumes = Vec::new(); - if let Some(volume) = compose_workspace_volume(resolved, args, remote_workspace_folder) { - volumes.push(volume); - } - volumes.extend(compose_additional_volumes(resolved, args)?); + let volumes = compose_additional_volumes(resolved, args)?; let named_volumes = compose_named_volumes(&volumes); if !volumes.is_empty() { content.push_str("\n volumes:\n"); diff --git a/cmd/devcontainer/src/runtime/compose/override_mounts.rs b/cmd/devcontainer/src/runtime/compose/override_mounts.rs index b15c22fe9..962768144 100644 --- a/cmd/devcontainer/src/runtime/compose/override_mounts.rs +++ b/cmd/devcontainer/src/runtime/compose/override_mounts.rs @@ -2,17 +2,10 @@ use serde_json::{Map, Number, Value}; -use crate::runtime::context::{ - additional_mounts_for_workspace_target, workspace_mount_for_args, ResolvedConfig, -}; +use crate::runtime::context::ResolvedConfig; use crate::runtime::mounts::cli_mount_values; use crate::runtime::mounts::split_mount_options; -pub(super) enum ComposeVolumeEntry { - Short(String), - Long(ComposeMountDefinition), -} - pub(super) struct ComposeNamedVolume { pub(super) name: String, pub(super) external: bool, @@ -22,37 +15,11 @@ pub(super) struct ComposeMountDefinition { pub(super) fields: Map, } -pub(super) fn compose_workspace_volume( - resolved: &ResolvedConfig, - args: &[String], - remote_workspace_folder: &str, -) -> Option { - let mount = workspace_mount_for_args(resolved, remote_workspace_folder, args); - let definition = compose_mount_definition_from_str(&mount)?; - if definition.mount_type().unwrap_or("bind") != "bind" { - return None; - } - definition - .short_syntax() - .map(ComposeVolumeEntry::Short) - .or(Some(ComposeVolumeEntry::Long(definition))) -} - pub(super) fn compose_additional_volumes( resolved: &ResolvedConfig, args: &[String], -) -> Result, String> { +) -> Result, String> { let mut volumes = Vec::new(); - if resolved.configuration.get("workspaceMount").is_none() { - let remote_workspace_folder = - crate::runtime::context::remote_workspace_folder_for_args(resolved, args); - volumes.extend( - additional_mounts_for_workspace_target(resolved, &remote_workspace_folder, args) - .iter() - .filter_map(|mount| compose_mount_definition_from_str(mount)) - .map(ComposeVolumeEntry::Long), - ); - } volumes.extend( resolved .configuration @@ -69,18 +36,14 @@ pub(super) fn compose_additional_volumes( volumes.extend( cli_mount_values(args)? .iter() - .filter_map(|mount| compose_mount_definition_from_str(mount)) - .map(ComposeVolumeEntry::Long), + .filter_map(|mount| compose_mount_definition_from_str(mount)), ); Ok(volumes) } -pub(super) fn compose_named_volumes(volumes: &[ComposeVolumeEntry]) -> Vec { +pub(super) fn compose_named_volumes(volumes: &[ComposeMountDefinition]) -> Vec { let mut named_volumes: Vec = Vec::new(); - for volume in volumes { - let ComposeVolumeEntry::Long(definition) = volume else { - continue; - }; + for definition in volumes { if definition.mount_type().unwrap_or("bind") != "volume" { continue; } @@ -114,11 +77,9 @@ pub(super) fn compose_named_volumes(volumes: &[ComposeVolumeEntry]) -> Vec Option { +fn compose_mount_definition(value: &Value) -> Option { match value { - Value::String(text) => { - compose_mount_definition_from_str(text).map(ComposeVolumeEntry::Long) - } + Value::String(text) => compose_mount_definition_from_str(text), Value::Object(entries) => { let mut fields = Map::new(); fields.insert( @@ -177,7 +138,7 @@ fn compose_mount_definition(value: &Value) -> Option { } merge_mount_value(&mut fields, key, value.clone()); } - Some(ComposeVolumeEntry::Long(ComposeMountDefinition { fields })) + Some(ComposeMountDefinition { fields }) } _ => None, } @@ -240,31 +201,6 @@ impl ComposeMountDefinition { pub(super) fn mount_type(&self) -> Option<&str> { self.fields.get("type").and_then(Value::as_str) } - - pub(super) fn short_syntax(&self) -> Option { - if self.mount_type().unwrap_or("bind") != "bind" { - return None; - } - if self - .fields - .keys() - .any(|key| !matches!(key.as_str(), "type" | "source" | "target" | "read_only")) - { - return None; - } - let source = self.fields.get("source").and_then(Value::as_str)?; - let target = self.fields.get("target").and_then(Value::as_str)?; - let mut volume = format!("{source}:{target}"); - if self - .fields - .get("read_only") - .and_then(Value::as_bool) - .unwrap_or(false) - { - volume.push_str(":ro"); - } - Some(volume) - } } fn mount_option_key_path(key: &str) -> Vec<&str> { @@ -356,19 +292,11 @@ mod tests { use super::{ compose_mount_definition, compose_mount_definition_from_str, compose_named_volumes, insert_nested_mount_value, merge_mount_scalar_or_object, parse_mount_option_scalar, - ComposeMountDefinition, ComposeVolumeEntry, }; - fn long_definition(entry: Option) -> Option> { - match entry? { - ComposeVolumeEntry::Long(definition) => Some(definition.fields), - ComposeVolumeEntry::Short(_) => None, - } - } - #[test] fn compose_mount_definition_accepts_object_aliases_and_read_only() { - let fields = long_definition(compose_mount_definition(&json!({ + let fields = compose_mount_definition(&json!({ "type": "volume", "src": "cache", "dst": "/cache", @@ -380,8 +308,9 @@ mod tests { } }, "consistency": "cached" - }))) - .expect("expected long compose mount definition"); + })) + .expect("expected compose mount definition") + .fields; assert_eq!(fields.get("type"), Some(&json!("volume"))); assert_eq!(fields.get("source"), Some(&json!("cache"))); @@ -404,20 +333,13 @@ mod tests { })) .is_none()); - let definition = long_definition(compose_mount_definition(&json!({ + let definition = compose_mount_definition(&json!({ "type": "volume", "target": "/cache" - }))) - .expect("expected long compose mount definition"); - assert_eq!( - ComposeMountDefinition { fields: definition }.short_syntax(), - None - ); - assert!(long_definition(None).is_none()); - assert!(long_definition(Some(ComposeVolumeEntry::Short( - "/host:/workspace".to_string() - ))) - .is_none()); + })) + .expect("expected compose mount definition") + .fields; + assert_eq!(definition.get("source"), None); } #[test] @@ -446,12 +368,6 @@ mod tests { Some(u64::MAX) ); assert_eq!(definition.fields.get("ratio"), Some(&json!(1.5))); - assert_eq!(definition.short_syntax(), None); - - let readonly = compose_mount_definition_from_str("source=/host,target=/work,readonly") - .expect("readonly bind mount should parse"); - assert_eq!(readonly.short_syntax(), Some("/host:/work:ro".to_string())); - let with_false_values = compose_mount_definition_from_str( "type=volume,src=cache,destination=/cache,external=false,enabled=false", ) @@ -461,21 +377,6 @@ mod tests { Some(&json!({ "external": false })) ); assert_eq!(with_false_values.fields.get("enabled"), Some(&json!(false))); - - let mut no_source = Map::new(); - no_source.insert("type".to_string(), json!("bind")); - no_source.insert("target".to_string(), json!("/work")); - assert_eq!( - ComposeMountDefinition { fields: no_source }.short_syntax(), - None - ); - let mut no_target = Map::new(); - no_target.insert("type".to_string(), json!("bind")); - no_target.insert("source".to_string(), json!("/host")); - assert_eq!( - ComposeMountDefinition { fields: no_target }.short_syntax(), - None - ); } #[test] @@ -491,13 +392,7 @@ mod tests { let bind = compose_mount_definition_from_str("source=/host,target=/work") .expect("bind mount should parse"); - let named = compose_named_volumes(&[ - ComposeVolumeEntry::Short("/host:/work".to_string()), - ComposeVolumeEntry::Long(local), - ComposeVolumeEntry::Long(external), - ComposeVolumeEntry::Long(anonymous), - ComposeVolumeEntry::Long(bind), - ]); + let named = compose_named_volumes(&[local, external, anonymous, bind]); assert_eq!(named.len(), 1); assert_eq!(named[0].name, "cache"); diff --git a/cmd/devcontainer/src/runtime/compose/override_yaml.rs b/cmd/devcontainer/src/runtime/compose/override_yaml.rs index 09453c383..221dbe329 100644 --- a/cmd/devcontainer/src/runtime/compose/override_yaml.rs +++ b/cmd/devcontainer/src/runtime/compose/override_yaml.rs @@ -2,7 +2,7 @@ use serde_json::{Map, Value}; -use super::override_mounts::{ComposeNamedVolume, ComposeVolumeEntry}; +use super::override_mounts::{ComposeMountDefinition, ComposeNamedVolume}; pub(super) fn escape_compose_label(label: &str) -> String { label.replace('\'', "''").replace('$', "$$") @@ -12,13 +12,8 @@ pub(super) fn escape_compose_scalar(value: &str) -> String { value.replace('\'', "''").replace('$', "$$") } -pub(super) fn render_compose_volume_entry(entry: &ComposeVolumeEntry) -> String { - match entry { - ComposeVolumeEntry::Short(volume) => { - format!(" - '{}'\n", escape_compose_scalar(volume)) - } - ComposeVolumeEntry::Long(definition) => render_yaml_mapping_list_entry(&definition.fields), - } +pub(super) fn render_compose_volume_entry(definition: &ComposeMountDefinition) -> String { + render_yaml_mapping_list_entry(&definition.fields) } pub(super) fn render_compose_string_sequence(values: &[String]) -> Result { @@ -116,9 +111,7 @@ fn render_yaml_sequence_item(value: &Value, indent: usize) -> String { mod tests { use serde_json::{json, Map, Value}; - use super::super::override_mounts::{ - ComposeMountDefinition, ComposeNamedVolume, ComposeVolumeEntry, - }; + use super::super::override_mounts::{ComposeMountDefinition, ComposeNamedVolume}; use super::{ escape_compose_label, escape_compose_scalar, render_compose_string_sequence, render_compose_volume_entry, render_named_volume_entry, render_yaml_key_value, @@ -140,14 +133,7 @@ mod tests { } #[test] - fn render_volume_entries_support_short_long_and_named_shapes() { - assert_eq!( - render_compose_volume_entry(&ComposeVolumeEntry::Short( - "/tmp/src:/tmp/dst:$cached".to_string() - )), - " - '/tmp/src:/tmp/dst:$$cached'\n" - ); - + fn render_volume_entries_support_long_and_named_shapes() { let mut fields = Map::new(); fields.insert("type".to_string(), Value::String("volume".to_string())); fields.insert("source".to_string(), Value::String("cache".to_string())); @@ -166,10 +152,7 @@ mod tests { }), ); - let rendered = - render_compose_volume_entry(&ComposeVolumeEntry::Long(ComposeMountDefinition { - fields, - })); + let rendered = render_compose_volume_entry(&ComposeMountDefinition { fields }); assert!(rendered.contains("- optional: null"), "{rendered}"); assert!(rendered.contains("type: 'volume'"), "{rendered}"); @@ -195,9 +178,7 @@ mod tests { " scratch:\n" ); assert_eq!( - render_compose_volume_entry(&ComposeVolumeEntry::Long(ComposeMountDefinition { - fields: Map::new(), - })), + render_compose_volume_entry(&ComposeMountDefinition { fields: Map::new() }), "" ); } diff --git a/cmd/devcontainer/src/runtime/compose/tests.rs b/cmd/devcontainer/src/runtime/compose/tests.rs index 73ee5ecc0..68fb479ec 100644 --- a/cmd/devcontainer/src/runtime/compose/tests.rs +++ b/cmd/devcontainer/src/runtime/compose/tests.rs @@ -21,7 +21,7 @@ use super::{ }; use crate::runtime::context::ResolvedConfig; use crate::test_support::{ - init_git_repo, process_env_guard, run_git, unique_temp_dir, write_executable_script, + init_git_repo, process_env_guard, unique_temp_dir, write_executable_script, }; static PATH_ENV_LOCK: OnceLock> = OnceLock::new(); @@ -673,7 +673,7 @@ fn compose_build_override_file_renders_cache_from_values_with_version() { } #[test] -fn metadata_override_file_mounts_workspace_by_default() { +fn metadata_override_file_does_not_mount_workspace_by_default() { let root = unique_temp_dir("devcontainer-compose-test"); fs::create_dir_all(&root).expect("workspace root"); let resolved = crate::runtime::context::ResolvedConfig { @@ -682,23 +682,20 @@ fn metadata_override_file_mounts_workspace_by_default() { configuration: json!({ "dockerComposeFile": "docker-compose.yml", "service": "app", + "workspaceFolder": "/workspace/dataplattformen" }), }; - let override_file = compose_metadata_override_file(&resolved, &[], "/workspaces/project", None) - .expect("override result") - .expect("override path"); + let override_file = + compose_metadata_override_file(&resolved, &[], "/workspace/dataplattformen", None) + .expect("override result") + .expect("override path"); let override_content = fs::read_to_string(&override_file).expect("override content"); - let expected_mount_target = format!( - "/workspaces/{}", - root.file_name().unwrap().to_string_lossy() - ); - assert!(override_content.contains("volumes:")); - assert!(override_content.contains(&format!( - "- '{}:{}'", - root.display(), - expected_mount_target + assert!(!override_content.contains("\n volumes:\n")); + assert!(!override_content.contains(&format!( + "- '{}:/workspace/dataplattformen'", + root.display() ))); let _ = fs::remove_file(override_file); @@ -859,7 +856,7 @@ fn metadata_override_file_clears_command_when_compose_entrypoint_would_consume_i } #[test] -fn metadata_override_file_mounts_nested_workspaces_from_the_git_root() { +fn metadata_override_file_does_not_mount_nested_workspaces_from_the_git_root() { with_host_tool_path(|| { let root = unique_temp_dir("devcontainer-compose-test"); let repo_root = root.join("repo"); @@ -887,12 +884,9 @@ fn metadata_override_file_mounts_nested_workspaces_from_the_git_root() { .expect("override path"); let override_content = fs::read_to_string(&override_file).expect("override content"); - assert!(override_content.contains(&format!( - "- '{}:/workspaces/repo'", - expected_repo_root.display() - ))); + assert!(!override_content.contains("\n volumes:\n")); assert!(!override_content.contains(&format!( - "{}:/workspaces/repo/packages/app", + "- '{}:/workspaces/repo'", expected_repo_root.display() ))); @@ -902,79 +896,34 @@ fn metadata_override_file_mounts_nested_workspaces_from_the_git_root() { } #[test] -fn metadata_override_file_rebases_worktree_common_dir_for_configured_workspace_folder() { - with_host_tool_path(|| { - let root = unique_temp_dir("devcontainer-compose-test"); - let repo_root = root.join("repo"); - let worktree_root = root.join("worktrees").join("feature"); - fs::create_dir_all(&repo_root).expect("repo root"); - init_git_repo(&repo_root); - fs::write(repo_root.join("README.md"), "hello\n").expect("readme"); - run_git(&repo_root, &["add", "README.md"]); - run_git( - &repo_root, - &[ - "-c", - "user.name=Devcontainer Tests", - "-c", - "user.email=devcontainer-tests@example.com", - "commit", - "-m", - "init", - "--quiet", - ], - ); - if let Some(parent) = worktree_root.parent() { - fs::create_dir_all(parent).expect("worktree parent"); - } - run_git( - &repo_root, - &[ - "worktree", - "add", - "--relative-paths", - worktree_root.to_string_lossy().as_ref(), - "-b", - "feature", - ], - ); - let expected_worktree_root = worktree_root - .canonicalize() - .unwrap_or_else(|_| worktree_root.clone()); - let expected_repo_git_dir = repo_root - .join(".git") - .canonicalize() - .unwrap_or_else(|_| repo_root.join(".git")); - let resolved = crate::runtime::context::ResolvedConfig { - workspace_folder: expected_worktree_root.clone(), - config_file: expected_worktree_root.join(".devcontainer.json"), - configuration: json!({ - "dockerComposeFile": "docker-compose.yml", - "service": "app", - "workspaceFolder": "/workspace", - }), - }; +fn metadata_override_file_does_not_add_workspace_or_git_common_dir_mounts() { + let root = unique_temp_dir("devcontainer-compose-test"); + fs::create_dir_all(&root).expect("workspace root"); + let resolved = crate::runtime::context::ResolvedConfig { + workspace_folder: root.clone(), + config_file: root.join(".devcontainer.json"), + configuration: json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspace", + }), + }; - let override_file = compose_metadata_override_file( - &resolved, - &["--mount-git-worktree-common-dir".to_string()], - "/workspace", - None, - ) - .expect("override result") - .expect("override path"); - let override_content = fs::read_to_string(&override_file).expect("override content"); + let override_file = compose_metadata_override_file( + &resolved, + &["--mount-git-worktree-common-dir".to_string()], + "/workspace", + None, + ) + .expect("override result") + .expect("override path"); + let override_content = fs::read_to_string(&override_file).expect("override content"); - assert!(override_content.contains(&format!( - "- '{}:/workspace'", - expected_worktree_root.display() - ))); - assert!(override_content.contains(&expected_repo_git_dir.display().to_string())); - assert!(override_content.contains("/repo/.git")); + assert!(!override_content.contains("\n volumes:\n")); + assert!(!override_content.contains(&format!("- '{}:/workspace'", root.display()))); - let _ = fs::remove_file(override_file); - let _ = fs::remove_dir_all(root); - }); + let _ = fs::remove_file(override_file); + let _ = fs::remove_dir_all(root); } #[test] @@ -1131,7 +1080,7 @@ fn metadata_override_file_declares_named_volumes_top_level() { } #[test] -fn metadata_override_file_preserves_workspace_mount_options() { +fn metadata_override_file_ignores_compose_workspace_mount() { let root = unique_temp_dir("devcontainer-compose-test"); fs::create_dir_all(&root).expect("workspace root"); let resolved = crate::runtime::context::ResolvedConfig { @@ -1144,16 +1093,14 @@ fn metadata_override_file_preserves_workspace_mount_options() { }), }; - let override_file = compose_metadata_override_file(&resolved, &[], "/workspaces/project", None) + let override_file = compose_metadata_override_file(&resolved, &[], "/", None) .expect("override result") .expect("override path"); let override_content = fs::read_to_string(&override_file).expect("override content"); - assert!(override_content.contains("type: 'bind'")); - assert!(override_content.contains("source: '/tmp/workspace'")); - assert!(override_content.contains("target: '/workspaces/project'")); - assert!(override_content.contains("consistency: 'delegated'")); - assert!(!override_content.contains("/tmp/workspace:/workspaces/project")); + assert!(!override_content.contains("\n volumes:\n")); + assert!(!override_content.contains("/tmp/workspace")); + assert!(!override_content.contains("/workspaces/project")); let _ = fs::remove_file(override_file); let _ = fs::remove_dir_all(root); diff --git a/cmd/devcontainer/src/runtime/context.rs b/cmd/devcontainer/src/runtime/context.rs index ad9fa8dac..88d1b6de4 100644 --- a/cmd/devcontainer/src/runtime/context.rs +++ b/cmd/devcontainer/src/runtime/context.rs @@ -669,6 +669,53 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn compose_remote_workspace_folder_prefers_configured_workspace_folder() { + let resolved = ResolvedConfig { + workspace_folder: std::path::PathBuf::from("/tmp/example"), + config_file: std::path::PathBuf::from("/tmp/example/.devcontainer/devcontainer.json"), + configuration: json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/configured" + }), + }; + + assert_eq!( + remote_workspace_folder_for_args(&resolved, &[]), + "/configured" + ); + } + + #[test] + fn compose_remote_workspace_folder_defaults_to_root() { + let resolved = ResolvedConfig { + workspace_folder: std::path::PathBuf::from("/tmp/example"), + config_file: std::path::PathBuf::from("/tmp/example/.devcontainer/devcontainer.json"), + configuration: json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app" + }), + }; + + assert_eq!(remote_workspace_folder_for_args(&resolved, &[]), "/"); + } + + #[test] + fn compose_remote_workspace_folder_ignores_workspace_mount() { + let resolved = ResolvedConfig { + workspace_folder: std::path::PathBuf::from("/tmp/example"), + config_file: std::path::PathBuf::from("/tmp/example/.devcontainer/devcontainer.json"), + configuration: json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceMount": "type=bind,source=/tmp/example,target=/mounted" + }), + }; + + assert_eq!(remote_workspace_folder_for_args(&resolved, &[]), "/"); + } + #[test] fn remote_workspace_folder_prefers_configured_workspace_folder() { let resolved = ResolvedConfig { diff --git a/cmd/devcontainer/src/runtime/context/workspace.rs b/cmd/devcontainer/src/runtime/context/workspace.rs index b3176a24e..7f53486d8 100644 --- a/cmd/devcontainer/src/runtime/context/workspace.rs +++ b/cmd/devcontainer/src/runtime/context/workspace.rs @@ -46,11 +46,13 @@ pub(crate) fn remote_workspace_folder_for_args( resolved: &ResolvedConfig, args: &[String], ) -> String { - if compose::uses_compose_config(&resolved.configuration) - && resolved.configuration.get("workspaceFolder").is_none() - && resolved.configuration.get("workspaceMount").is_none() - { - return "/".to_string(); + if compose::uses_compose_config(&resolved.configuration) { + return resolved + .configuration + .get("workspaceFolder") + .and_then(Value::as_str) + .unwrap_or("/") + .to_string(); } if let Some(workspace_folder) = resolved From afc357e686f0ab1803269d3e737f53f73b87c9a0 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Mon, 8 Jun 2026 11:06:29 +0200 Subject: [PATCH 2/2] Mount compose smoke workspace explicitly --- scripts/standalone/real-engine-smoke.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/standalone/real-engine-smoke.sh b/scripts/standalone/real-engine-smoke.sh index 44310c63f..4b152d4db 100755 --- a/scripts/standalone/real-engine-smoke.sh +++ b/scripts/standalone/real-engine-smoke.sh @@ -145,6 +145,8 @@ services: app: image: alpine:3.20 command: sh -c "while sleep 3600; do :; done" + volumes: + - ..:/workspace EOF cat >"$compose_workspace/.devcontainer/devcontainer.json" <<'EOF' {