From 7533cc4f0835d5ff0c7f2c139b943e7efeee2962 Mon Sep 17 00:00:00 2001 From: PYDuquesnoy Date: Thu, 3 Sep 2026 11:14:04 +0200 Subject: [PATCH] fix(discovery): merge all three VS Code scopes, and let $IRIS_NAMESPACE outrank the editor (#187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.17.0 moved the VS Code reader ahead of the blind scans, and it still could not read the shape people actually have. GENAI-Course supplied the real file and it is split across THREE scopes, not one: Alumno/Alumno.code-workspace intersystems.servers: { "workshop-iris": {...} } Alumno/Ejercicios/Hospital/.vscode/ objectscript.conn: { server: "workshop-iris", settings.json ns: "HOSPITAL" } Each file alone resolves to NotConfigured. A reader that checks paths one at a time therefore finds nothing however many paths it checks — which is why this merges the scopes before resolving once, narrower winning as VS Code itself resolves a setting: user dirs::config_dir()/{Code, Code - Insiders, VSCodium}/User/settings.json workspace nearest *.code-workspace at or above the cwd (its `settings` block) folder ./.vscode/settings.json The walk-up mirrors what workspace_config already does for .iris-agentic-dev.toml. $IRIS_NAMESPACE NOW OVERRIDES THE ns FROM SETTINGS, and this is the part that matters most. Reading the .code-workspace resolves the connection WITH the editor's namespace, which would have handed the model a working default and silently deleted the IRIS_NAMESPACE=DONOTUSE guardrail — a read-only namespace pinned on purpose so a write that forgets to name its namespace fails at once instead of landing somewhere real. GENAI-Course measures 95% of namespace-omitted calls failing loudly today; that is the intended behaviour, not noise. Operator scope is the wider promise, so it wins over editor scope, exactly as $IRIS_PASSWORD supplies what the editor will not. Evidence, each at its own confidence: - 25 unit tests (12 new) — the three-scope overlay, workspace-wins-per-key, user entries surviving, a workspace opt-out user scope cannot undo, the .code-workspace `settings` nesting parsed off the real workshop file, and the namespace override. - .code-workspace reading proven AT THE WIRE: from the Hospital folder the binary walks up two levels and reaches http://198.51.100.7:59999/irishealth/... — a host, port and pathPrefix that exist only in that file. Removing it falls back to the scans and compiles. A Web Gateway on port 80 with a path prefix is not something a scan of 52773 could ever have found. - namespace override proven at the wire ON THE MCP PATH, which is the one that uses conn.namespace: with the .code-workspace present, no IRIS_NAMESPACE -> namespace=APP (the editor's), IRIS_NAMESPACE=USER -> namespace=USER. Control with the file removed: APP never appears. My first attempt to prove the override went through the CLI `compile` and was a FALSE POSITIVE: it printed /v1/DONOTUSE/ from clap's #[arg(env = "IRIS_NAMESPACE")], not from this code, and that subcommand never consults the discovered connection at all. Hence the MCP-driven test above. Still open in #187: no OS-keychain reader, and unlikely to gain one — VS Code keeps secrets in an encrypted store rather than as per-server items an outside process can read. For the workshop it is moot: their .code-workspace carries the password already. Version 0.18.0 — behaviour change, see the header of discovery.rs. Refs #187 Co-Authored-By: Claude Opus 5 --- Cargo.lock | 4 +- Cargo.toml | 2 +- README.md | 16 +- .../src/iris/discovery.rs | 170 ++++++++++--- .../src/iris/vscode_config.rs | 71 +++++- .../tests/vscode_config_tests.rs | 239 +++++++++++++++++- 6 files changed, 454 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3a7c861..12e9675 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -945,7 +945,7 @@ dependencies = [ [[package]] name = "iris-agentic-dev" -version = "0.17.0" +version = "0.18.0" dependencies = [ "anyhow", "chrono", @@ -963,7 +963,7 @@ dependencies = [ [[package]] name = "iris-agentic-dev-core" -version = "0.17.0" +version = "0.18.0" dependencies = [ "anyhow", "bollard", diff --git a/Cargo.toml b/Cargo.toml index 128e75b..240d21d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.17.0" +version = "0.18.0" edition = "2021" authors = ["Thomas Dyar "] license = "MIT" diff --git a/README.md b/README.md index d4e0566..4904a5d 100644 --- a/README.md +++ b/README.md @@ -71,11 +71,17 @@ Restart Claude and **verify with the `check_config` tool** that it connects and > Claude Code and reads the same user-scope `~/.claude.json` registration shown above. > - **Connection settings from VS Code are read natively by this binary.** `iris-interop-dev` parses > `.vscode/settings.json` — `objectscript.conn`, including named servers resolved through -> `intersystems.servers` — ahead of the blind localhost and Docker scans, so a workspace that -> names its server wins over whatever happens to answer on port 52773. The password is the one -> field it will not invent: Server Manager keeps it in the OS keychain, which this binary cannot -> read, so supply it with `IRIS_PASSWORD`. The full order, and the remaining limits, are at the -> top of +> `intersystems.servers` — from all **three** VS Code scopes, narrower winning: your user-scope +> settings, the nearest `*.code-workspace` above you, and this folder's `.vscode/settings.json`. +> That is what lets a folder which merely names a server (`"server": "workshop-iris"`) resolve +> against the definition kept in the multi-root `.code-workspace` two levels up — and win over +> whatever happens to answer on port 52773. +> +> Two things stay under **your** control, not the editor's. `IRIS_PASSWORD` supplies the +> password VS Code does not expose (it keeps secrets in an encrypted store this binary cannot +> read), and **`IRIS_NAMESPACE` overrides the `ns` from settings** — so a deliberately +> read-only default namespace stays in force and a write that forgets to name its namespace +> still fails immediately. The full order, and the remaining limits, are at the top of > [`discovery.rs`](crates/iris-agentic-dev-core/src/iris/discovery.rs) (issue #187). > - **What this fork does not carry is the Marketplace extension** that auto-registers the server, > and `skill install --agent copilot`. That path belongs to the upstream community tool — see diff --git a/crates/iris-agentic-dev-core/src/iris/discovery.rs b/crates/iris-agentic-dev-core/src/iris/discovery.rs index 25e77bd..2134001 100644 --- a/crates/iris-agentic-dev-core/src/iris/discovery.rs +++ b/crates/iris-agentic-dev-core/src/iris/discovery.rs @@ -16,12 +16,30 @@ //! on 52773 from another. #187 moved step 4 up from last, where it could not run on any //! machine that had IRIS answering locally. //! -//! REMAINING LIMIT of step 4, still tracked by #187: `discover_via_vscode_settings` -//! searches exactly one path, `current_dir()/.vscode/settings.json` — not user-scope VS -//! Code settings, which is where Server Manager normally keeps servers. A workspace that -//! defines its own server is found; a server added through the Server Manager UI is not. -//! There is still no OS-keychain reader either, but an absent password is now reported -//! rather than replaced with `SYS`. +//! Step 4 reads all THREE VS Code scopes and merges them, narrower winning, as VS Code +//! itself resolves a setting: +//! user `dirs::config_dir()/{Code, Code - Insiders, VSCodium}/User/settings.json` +//! workspace the nearest `*.code-workspace` at or above the cwd (its `settings` block) +//! folder `./.vscode/settings.json` +//! +//! #187: merging is the whole point, not an optimisation. The real shape is SPLIT — a +//! multi-root workspace defines `intersystems.servers` once in the `.code-workspace`, and +//! each folder's `.vscode/settings.json` only references it by name. Every file alone +//! resolves to NotConfigured, so a reader that checks paths one at a time finds nothing no +//! matter how many paths it checks. +//! +//! OPERATOR SCOPE OVERRIDES EDITOR SCOPE. `$IRIS_PASSWORD` supplies a password VS Code does +//! not carry; `$IRIS_NAMESPACE` overrides the `ns` it does. The second is a guardrail, not a +//! convenience: a workshop pins `IRIS_NAMESPACE=DONOTUSE` to a read-only namespace so a write +//! that forgets to name its namespace fails AT ONCE rather than landing somewhere real. +//! Taking `ns` from the editor would hand back a namespace that WORKS and delete that +//! guardrail silently — measured at 95% of namespace-omitted calls failing loudly today, +//! which is the intended behaviour. +//! +//! REMAINING LIMIT, still #187: no OS-keychain reader, and unlikely to gain one — VS Code +//! keeps secrets in an encrypted store, not as per-server items an outside process can read. +//! `$IRIS_PASSWORD` is the supported way in; an absent password is reported by name rather +//! than replaced with `SYS`. use crate::iris::connection::{DiscoverySource, IrisConnection}; use crate::iris::vscode_config::VsCodeResolution; @@ -613,46 +631,124 @@ async fn discover_via_docker() -> Option { None } -/// Resolve a connection from VS Code settings.json, if one is configured here. +/// VS Code USER-scope settings paths — where Server Manager actually writes +/// `intersystems.servers`. /// -/// #187: a configured-but-passwordless entry returns `None` like an unconfigured one, -/// but emits a warning naming the file and the server first. The two used to be -/// indistinguishable because the missing password was filled in with `SYS`, so the -/// cascade stopped on a connection that could only ever 401. -async fn discover_via_vscode_settings() -> Option { - let candidates = [std::env::current_dir().ok()?.join(".vscode/settings.json")]; +/// `dirs::config_dir()` is the correct root on all three platforms: +/// macOS `~/Library/Application Support`, Linux `~/.config`, Windows `%APPDATA%`. +fn vscode_user_settings_paths() -> Vec { + let Some(base) = dirs::config_dir() else { + return Vec::new(); + }; + ["Code", "Code - Insiders", "VSCodium"] + .iter() + .map(|app| base.join(app).join("User").join("settings.json")) + .collect() +} - for path in &candidates { +/// Nearest `*.code-workspace` at or above `start`, which is where a multi-root workspace +/// defines its servers. #187: the workshop keeps `intersystems.servers` here and references +/// it by name from each folder's `.vscode/settings.json`, so a reader that never walks up +/// finds a name it cannot resolve. `workspace_config` already walks up for +/// `.iris-agentic-dev.toml`; this is the same move. +fn find_code_workspace(start: &std::path::Path) -> Option { + let mut dir = Some(start); + for _ in 0..12 { + let d = dir?; + let mut hits: Vec<_> = std::fs::read_dir(d) + .ok()? + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e == "code-workspace")) + .collect(); + if !hits.is_empty() { + hits.sort(); // deterministic when a folder holds more than one + return hits.into_iter().next(); + } + dir = d.parent(); + } + None +} + +/// Resolve a connection from VS Code settings, merging user scope with the workspace. +/// +/// #187: the real-world shape is SPLIT across two files. Server Manager writes the server +/// definition into user-scope settings; the workspace `.vscode/settings.json` only names it +/// (`"server": "workshop-iris"`). Each file alone resolves to `NotConfigured`, so this reads +/// both and overlays the workspace on user scope before resolving once. Adding the user path +/// as another candidate in a loop would have found nothing — that is the trap this shape sets. +/// +/// A configured-but-passwordless entry returns `None` like an unconfigured one, but emits a +/// warning naming the file and the server first. +async fn discover_via_vscode_settings() -> Option { + fn load(path: &std::path::Path) -> Option { if !path.exists() { - continue; + return None; } - let settings = match crate::iris::vscode_config::parse_vscode_settings(path) { - Ok(s) => s, + match crate::iris::vscode_config::parse_vscode_settings(path) { + Ok(s) => Some(s), Err(e) => { tracing::warn!("Could not parse {}: {}", path.display(), e); - continue; - } - }; - match settings.resolve() { - VsCodeResolution::Resolved(conn) => return Some(conn), - VsCodeResolution::NotConfigured => continue, - VsCodeResolution::MissingPassword { server } => { - let what = match server.as_deref() { - Some(name) => format!("server '{name}'"), - None => "a connection".to_string(), - }; - tracing::warn!( - "{} configures {} but carries no password. VS Code Server Manager keeps it in \ - the OS keychain, which this binary cannot read — set IRIS_PASSWORD to supply \ - it. Continuing discovery without this entry. (issue #187)", - path.display(), - what - ); - continue; + None } } } - None + + // User scope is the BASE: the first VS Code flavour that has a settings file wins. + let mut user = crate::iris::vscode_config::VsCodeSettings::default(); + let mut user_path = None; + for path in vscode_user_settings_paths() { + if let Some(s) = load(&path) { + user = s; + user_path = Some(path); + break; + } + } + + // Middle scope: the nearest .code-workspace at or above the cwd, where a multi-root + // workspace defines its servers. + let cwd = std::env::current_dir().ok(); + let ws_file = cwd.as_deref().and_then(find_code_workspace); + let multi_root = ws_file + .as_deref() + .and_then( + |p| match crate::iris::vscode_config::parse_code_workspace(p) { + Ok(s) => Some(s), + Err(e) => { + tracing::warn!("Could not parse {}: {}", p.display(), e); + None + } + }, + ) + .unwrap_or_default(); + + // Narrowest scope: this folder's .vscode/settings.json. + let folder_path = cwd.map(|d| d.join(".vscode/settings.json")); + let folder = folder_path.as_deref().and_then(load).unwrap_or_default(); + + let named_in = folder_path.filter(|p| p.exists()).or(ws_file).or(user_path); + + match folder.overlay_on(multi_root.overlay_on(user)).resolve() { + VsCodeResolution::Resolved(conn) => Some(conn), + VsCodeResolution::NotConfigured => None, + VsCodeResolution::MissingPassword { server } => { + let what = match server.as_deref() { + Some(name) => format!("server '{name}'"), + None => "a connection".to_string(), + }; + tracing::warn!( + "VS Code settings configure {} but no password is available for it{}. Server \ + Manager keeps the password in the OS keychain, which this binary cannot read — \ + set IRIS_PASSWORD to supply it. Continuing discovery without this entry. \ + (issue #187)", + what, + named_in + .map(|p| format!(" ({})", p.display())) + .unwrap_or_default() + ); + None + } + } } #[cfg(test)] diff --git a/crates/iris-agentic-dev-core/src/iris/vscode_config.rs b/crates/iris-agentic-dev-core/src/iris/vscode_config.rs index 4f35502..96e95b6 100644 --- a/crates/iris-agentic-dev-core/src/iris/vscode_config.rs +++ b/crates/iris-agentic-dev-core/src/iris/vscode_config.rs @@ -132,6 +132,25 @@ fn strip_jsonc(src: &str) -> String { out } +/// A `.code-workspace` file: the same settings, nested under a `settings` key. +#[derive(Debug, Deserialize, Default)] +struct CodeWorkspaceFile { + settings: Option, +} + +/// Parse the `settings` block of a `.code-workspace` file. +/// +/// #187: this is where the workshop actually defines its server — not user-scope settings, +/// and not `.vscode/settings.json`. A multi-root workspace names the server once here and +/// each folder's `.vscode/settings.json` references it by name. +pub fn parse_code_workspace(path: impl AsRef) -> anyhow::Result { + let content = std::fs::read_to_string(path.as_ref())?; + let parsed: CodeWorkspaceFile = serde_json::from_str(&content) + .or_else(|_| serde_json::from_str(&strip_jsonc(&content))) + .unwrap_or_default(); + Ok(parsed.settings.unwrap_or_default()) +} + /// Parse a VS Code settings.json file. /// Bug 10: the old parser only stripped full-line // comments. /// This version handles inline //, /* */ block comments, and trailing commas. @@ -170,13 +189,58 @@ pub enum VsCodeResolution { } impl VsCodeSettings { + /// Overlay `self` (workspace scope) on top of `base` (user scope), the way VS Code + /// resolves a setting: the narrower scope wins, key by key. + /// + /// #187: this exists because the workshop shape is SPLIT across the two files and + /// neither half resolves alone. Server Manager writes `intersystems.servers` into + /// user-scope settings; the workspace `.vscode/settings.json` only references a + /// server by name. Reading either file on its own yields `NotConfigured`, so adding + /// the user path as one more candidate in the discovery loop would have changed + /// nothing — the scopes have to be merged BEFORE resolution. + /// + /// `objectscript.conn` is taken whole from the narrower scope that defines one, which + /// is what makes `"active": false` in a workspace a real opt-out rather than something + /// user scope can silently undo. `intersystems.servers` merges per key so a workspace + /// can override one server without having to restate the rest. + pub fn overlay_on(self, base: VsCodeSettings) -> VsCodeSettings { + let intersystems_servers = match (self.intersystems_servers, base.intersystems_servers) { + (Some(mine), Some(mut theirs)) => { + theirs.extend(mine); // workspace entries win per key + Some(theirs) + } + (mine, theirs) => mine.or(theirs), + }; + VsCodeSettings { + objectscript_conn: self.objectscript_conn.or(base.objectscript_conn), + intersystems_servers, + } + } + /// Resolve to a connection, taking the fallback password from the caller. /// /// Pure — `resolve()` supplies `$IRIS_PASSWORD`. Split so tests can cover the /// credential rules without mutating process environment. pub fn resolve_with(&self, env_password: Option<&str>) -> VsCodeResolution { + self.resolve_with_env(env_password, None) + } + + /// Resolve, letting the OPERATOR environment override what the EDITOR configured. + /// + /// `$IRIS_PASSWORD` fills a password VS Code does not carry; `$IRIS_NAMESPACE` overrides + /// the `ns` it does. #187: the second exists because a workshop pins + /// `IRIS_NAMESPACE=DONOTUSE` deliberately — a read-only namespace so that a write which + /// forgets to name its namespace fails AT ONCE instead of quietly landing in the wrong + /// one. Taking `ns` from `.vscode/settings.json` would hand back a namespace that WORKS + /// and silently delete that guardrail. Operator scope is the wider promise, so it wins. + pub fn resolve_with_env( + &self, + env_password: Option<&str>, + env_namespace: Option<&str>, + ) -> VsCodeResolution { // An empty string is an absent password, not a password of length zero. let env_password = env_password.filter(|p| !p.is_empty()); + let env_namespace = env_namespace.filter(|n| !n.is_empty()); let present = |v: Option<&str>| v.filter(|p| !p.is_empty()).map(str::to_owned); let conn = match self.objectscript_conn.as_ref() { @@ -186,7 +250,7 @@ impl VsCodeSettings { if conn.active == Some(false) { return VsCodeResolution::NotConfigured; } - let ns = conn.ns.as_deref().unwrap_or("USER"); + let ns = env_namespace.or(conn.ns.as_deref()).unwrap_or("USER"); // Named server path — resolve through `intersystems.servers`. if let Some(server_name) = &conn.server { @@ -255,6 +319,9 @@ impl VsCodeSettings { /// Resolve to a connection, falling back to `$IRIS_PASSWORD` for the secret that /// Server Manager keeps in the OS keychain. pub fn resolve(&self) -> VsCodeResolution { - self.resolve_with(std::env::var("IRIS_PASSWORD").ok().as_deref()) + self.resolve_with_env( + std::env::var("IRIS_PASSWORD").ok().as_deref(), + std::env::var("IRIS_NAMESPACE").ok().as_deref(), + ) } } diff --git a/crates/iris-agentic-dev-core/tests/vscode_config_tests.rs b/crates/iris-agentic-dev-core/tests/vscode_config_tests.rs index 43081c9..3ed1ce2 100644 --- a/crates/iris-agentic-dev-core/tests/vscode_config_tests.rs +++ b/crates/iris-agentic-dev-core/tests/vscode_config_tests.rs @@ -146,7 +146,9 @@ fn settings_without_objectscript_conn_is_ok() { // a 401 naming no cause. `resolve_with` takes the environment fallback as an // argument so these stay pure and cannot race on process env. -use iris_agentic_dev_core::iris::vscode_config::VsCodeResolution; +use iris_agentic_dev_core::iris::vscode_config::{ + parse_code_workspace, VsCodeResolution, VsCodeSettings, +}; const KEYCHAIN_SERVER: &str = r#"{ "objectscript.conn": {"active": true, "server": "workshop-iris", "ns": "IRISAPP"}, @@ -311,3 +313,238 @@ fn a_server_name_with_no_servers_map_at_all_is_not_configured() { ), } } + +// ── #187 (2): user-scope settings ──────────────────────────────────────────── +// +// The workshop shape is split across TWO files by design. Server Manager writes +// `intersystems.servers` into USER-scope settings; the workspace `.vscode/settings.json` +// only references the server by name. Reading either file alone yields NotConfigured, +// which is why adding the user path as another loop candidate would not have fixed +// anything — the two scopes have to be MERGED before resolution. + +fn parse(content: &str) -> VsCodeSettings { + let dir = tempfile::tempdir().unwrap(); + let path = write_settings(dir.path(), content); + parse_vscode_settings(&path).unwrap() +} + +/// Exactly what a student has after the workshop VM is set up: the workspace names +/// `workshop-iris`, Server Manager defines it in user scope, the password is in the +/// keychain and supplied by the environment. Host and port come from user scope, the +/// namespace from the workspace — which is the whole point of the merge. +#[test] +fn the_workshop_shape_resolves_once_user_scope_is_merged() { + let workspace = parse( + r#"{"objectscript.conn": {"active": true, "server": "workshop-iris", "ns": "HOSPITAL"}}"#, + ); + let user = parse( + r#"{"intersystems.servers": { + "workshop-iris": { + "webServer": {"scheme": "http", "host": "iris.workshop.local", "port": 52773}, + "username": "alumno" + } + }}"#, + ); + match workspace + .overlay_on(user) + .resolve_with(Some("from-the-env")) + { + VsCodeResolution::Resolved(conn) => { + assert_eq!(conn.base_url, "http://iris.workshop.local:52773"); + assert_eq!(conn.namespace, "HOSPITAL"); + assert_eq!(conn.username, "alumno"); + assert_eq!(conn.password, "from-the-env"); + } + other => panic!("expected Resolved, got {other:?}"), + } +} + +/// Without the env password it must name the server rather than fall silent — the +/// student gets told which entry needs IRIS_PASSWORD instead of an unexplained 401. +#[test] +fn a_merged_server_with_no_password_names_itself() { + let workspace = parse(r#"{"objectscript.conn": {"active": true, "server": "workshop-iris"}}"#); + let user = parse( + r#"{"intersystems.servers": {"workshop-iris": {"webServer": {"host": "h", "port": 52773}}}}"#, + ); + match workspace.overlay_on(user).resolve_with(None) { + VsCodeResolution::MissingPassword { server } => { + assert_eq!(server.as_deref(), Some("workshop-iris")) + } + other => panic!("expected MissingPassword, got {other:?}"), + } +} + +/// Workspace wins key-by-key, the way VS Code resolves scopes. +#[test] +fn a_workspace_server_entry_overrides_the_user_one_of_the_same_name() { + let workspace = parse( + r#"{"objectscript.conn": {"active": true, "server": "iris"}, + "intersystems.servers": {"iris": {"webServer": {"host": "workspace.example", "port": 443}, "password": "p"}}}"#, + ); + let user = parse( + r#"{"intersystems.servers": {"iris": {"webServer": {"host": "user.example", "port": 52773}, "password": "p"}}}"#, + ); + match workspace.overlay_on(user).resolve_with(None) { + VsCodeResolution::Resolved(conn) => { + assert_eq!(conn.base_url, "http://workspace.example:443") + } + other => panic!("expected Resolved, got {other:?}"), + } +} + +/// A user-scope entry the workspace does not mention stays available. +#[test] +fn user_scope_entries_survive_the_overlay() { + let workspace = parse( + r#"{"objectscript.conn": {"active": true, "server": "other"}, + "intersystems.servers": {"iris": {"webServer": {"host": "workspace.example"}, "password": "p"}}}"#, + ); + let user = parse( + r#"{"intersystems.servers": {"other": {"webServer": {"host": "user.example", "port": 52773}, "password": "p"}}}"#, + ); + match workspace.overlay_on(user).resolve_with(None) { + VsCodeResolution::Resolved(conn) => assert_eq!(conn.base_url, "http://user.example:52773"), + other => panic!("expected Resolved, got {other:?}"), + } +} + +/// A workspace with no conn of its own falls back to the user-scope one. +#[test] +fn a_user_scope_conn_is_used_when_the_workspace_has_none() { + let workspace = parse(r#"{"editor.fontSize": 14}"#); + let user = parse( + r#"{"objectscript.conn": {"active": true, "host": "user.example", "port": 52773, "password": "p", "ns": "USER"}}"#, + ); + match workspace.overlay_on(user).resolve_with(None) { + VsCodeResolution::Resolved(conn) => assert_eq!(conn.base_url, "http://user.example:52773"), + other => panic!("expected Resolved, got {other:?}"), + } +} + +/// `active: false` in the workspace opts out even when user scope would connect — +/// otherwise the opt-out documented in the README would be silently overridable. +#[test] +fn a_workspace_opt_out_is_not_undone_by_user_scope() { + let workspace = + parse(r#"{"objectscript.conn": {"active": false, "host": "h", "password": "p"}}"#); + let user = parse( + r#"{"objectscript.conn": {"active": true, "host": "user.example", "port": 52773, "password": "p"}}"#, + ); + assert!(matches!( + workspace.overlay_on(user).resolve_with(None), + VsCodeResolution::NotConfigured + )); +} + +/// Two empty scopes are still nothing — no accidental localhost default. +#[test] +fn merging_two_unconfigured_scopes_is_still_unconfigured() { + assert!(matches!( + parse(r#"{"editor.fontSize": 14}"#) + .overlay_on(parse(r#"{"telemetry.telemetryLevel": "off"}"#)) + .resolve_with(None), + VsCodeResolution::NotConfigured + )); +} + +// ── #187 (2b): the .code-workspace scope, and the namespace guardrail ──────── +// +// Verbatim from the workshop's Alumno.code-workspace. The server is defined ONCE +// here and referenced by name from each exercise folder — so neither user scope nor +// .vscode/settings.json carries it, and a reader that does not walk up finds a name +// it cannot resolve. Note port 80 + pathPrefix: a Web Gateway, which no port scan of +// 52773 would ever have found. +const WORKSHOP_CODE_WORKSPACE: &str = r#"{ + "folders": [ { "name": "Hospital", "path": "./Ejercicios/Hospital" } ], + "settings": { + "intersystems.servers": { + "workshop-iris": { + "webServer": { "scheme": "http", "host": "localhost", "port": 80, "pathPrefix": "/irishealth" }, + "username": "_SYSTEM", + "password": "SYS", + "description": "Workshop IRIS for Health instance (local)" + } + }, + "files.associations": { "*.cls": "objectscript-class" } + }, + "extensions": { "recommendations": [ "intersystems-community.servermanager" ] } +}"#; + +fn parse_ws(content: &str) -> VsCodeSettings { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Alumno.code-workspace"); + std::fs::write(&path, content).unwrap(); + parse_code_workspace(&path).unwrap() +} + +/// The settings live under a nested `settings` key; the surrounding `folders` and +/// `extensions` must not derail the parse. +#[test] +fn a_code_workspace_yields_the_settings_nested_inside_it() { + let s = parse_ws(WORKSHOP_CODE_WORKSPACE); + let servers = s + .intersystems_servers + .expect("intersystems.servers from the .code-workspace settings block"); + let ws = servers.get("workshop-iris").expect("workshop-iris defined"); + assert_eq!(ws.web_server.port, Some(80)); + assert_eq!(ws.web_server.path_prefix.as_deref(), Some("/irishealth")); +} + +/// End to end for a student: folder names the server, .code-workspace defines it. +/// The Web Gateway URL is the proof — no scan produces port 80 with a path prefix. +#[test] +fn the_student_layout_resolves_across_folder_and_code_workspace() { + let folder = parse( + r#"{"objectscript.conn": {"active": true, "server": "workshop-iris", "ns": "HOSPITAL"}}"#, + ); + match folder + .overlay_on(parse_ws(WORKSHOP_CODE_WORKSPACE)) + .resolve_with(None) + { + VsCodeResolution::Resolved(conn) => { + assert_eq!(conn.base_url, "http://localhost:80/irishealth"); + assert_eq!(conn.username, "_SYSTEM"); + assert_eq!(conn.namespace, "HOSPITAL"); + } + other => panic!("expected Resolved, got {other:?}"), + } +} + +/// THE GUARDRAIL. A workshop pins IRIS_NAMESPACE=DONOTUSE on purpose: a read-only +/// namespace so a write that forgets to name its namespace fails at once instead of +/// landing somewhere real. Taking `ns` from the editor would hand back a namespace +/// that WORKS and delete that guardrail silently — 95% of namespace-omitted calls +/// currently fail loudly, which is the point. +#[test] +fn the_operator_namespace_overrides_the_one_the_editor_configured() { + let folder = parse( + r#"{"objectscript.conn": {"active": true, "server": "workshop-iris", "ns": "HOSPITAL"}}"#, + ); + match folder + .overlay_on(parse_ws(WORKSHOP_CODE_WORKSPACE)) + .resolve_with_env(None, Some("DONOTUSE")) + { + VsCodeResolution::Resolved(conn) => { + assert_eq!( + conn.namespace, "DONOTUSE", + "the editor's ns silently replaced the operator's guardrail" + ); + assert_eq!(conn.base_url, "http://localhost:80/irishealth"); + } + other => panic!("expected Resolved, got {other:?}"), + } +} + +/// An unset IRIS_NAMESPACE must not blank the namespace out. +#[test] +fn no_operator_namespace_leaves_the_editors_ns_alone() { + let folder = + parse(r#"{"objectscript.conn": {"active": true, "server": "s", "ns": "HOSPITAL"}}"#); + let ws = + parse(r#"{"intersystems.servers": {"s": {"webServer": {"host": "h"}, "password": "p"}}}"#); + match folder.overlay_on(ws).resolve_with_env(None, None) { + VsCodeResolution::Resolved(conn) => assert_eq!(conn.namespace, "HOSPITAL"), + other => panic!("expected Resolved, got {other:?}"), + } +}