diff --git a/Cargo.lock b/Cargo.lock index a99aa6d..1a5e1ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1728,7 +1728,7 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "torromail-cache" -version = "0.10.2" +version = "0.10.3" dependencies = [ "rusqlite", "torromail-core", @@ -1736,7 +1736,7 @@ dependencies = [ [[package]] name = "torromail-control" -version = "0.10.2" +version = "0.10.3" dependencies = [ "getrandom 0.2.17", "keyring", @@ -1747,11 +1747,11 @@ dependencies = [ [[package]] name = "torromail-core" -version = "0.10.2" +version = "0.10.3" [[package]] name = "torromail-discovery" -version = "0.10.2" +version = "0.10.3" dependencies = [ "getrandom 0.2.17", "torromail-control", @@ -1760,7 +1760,7 @@ dependencies = [ [[package]] name = "torromail-imap-tls" -version = "0.10.2" +version = "0.10.3" dependencies = [ "rustls", "torromail-core", @@ -1769,7 +1769,7 @@ dependencies = [ [[package]] name = "torromail-mcp" -version = "0.10.2" +version = "0.10.3" dependencies = [ "security-framework", "serde_json", @@ -1783,7 +1783,7 @@ dependencies = [ [[package]] name = "torromail-oauth" -version = "0.10.2" +version = "0.10.3" dependencies = [ "serde_json", "ureq", @@ -1791,7 +1791,7 @@ dependencies = [ [[package]] name = "torromail-tui" -version = "0.10.2" +version = "0.10.3" dependencies = [ "chrono", "ratatui", diff --git a/Cargo.toml b/Cargo.toml index 868c959..48b58d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ resolver = "3" [workspace.package] -version = "0.10.2" +version = "0.10.3" edition = "2024" license = "MIT OR Apache-2.0" authors = ["TorroMail contributors"] diff --git a/apps/TorroMailApp/Sources/TorroMailKit/TorroMailKit.swift b/apps/TorroMailApp/Sources/TorroMailKit/TorroMailKit.swift index f32f553..82f17de 100644 --- a/apps/TorroMailApp/Sources/TorroMailKit/TorroMailKit.swift +++ b/apps/TorroMailApp/Sources/TorroMailKit/TorroMailKit.swift @@ -1141,9 +1141,9 @@ public enum MCPClientRegistry { )) } - // OpenCode keeps its config under ~/.config on every platform. A - // `.jsonc` is used when it is the only one there; comments in it make - // it unreadable to a strict parser — an error, never an overwrite. + // OpenCode keeps its config under ~/.config on every platform. Use its + // `.jsonc` when it is the only one there; the setup editor preserves + // comments and trailing commas while changing only TorroMail's entry. let openCodeDirectory = home.appendingPathComponent(".config/opencode", isDirectory: true) if fileManager.fileExists(atPath: openCodeDirectory.path) { let json = openCodeDirectory.appendingPathComponent("opencode.json") @@ -1412,12 +1412,11 @@ public enum MCPClientSetup { } } - /// A deliberately small JSON5 object editor for OpenClaw's owned config. + /// A deliberately small JSON5/JSONC object editor for client-owned config. /// It understands comments, quoted or bare keys, trailing commas, strings, - /// arrays and nested objects. TorroMail only changes one path - /// (`mcp.servers.torromail`), leaving every byte outside that property - /// alone. This is the fallback when the separately installed OpenClaw app - /// has no CLI launcher visible to a sandboxed GUI process. + /// arrays and nested objects. TorroMail changes only its own MCP property, + /// leaving every byte outside that property alone. OpenClaw needs the JSON5 + /// support; OpenCode needs the JSONC subset. private struct JSON5Document { private struct Property { var name: String @@ -1450,6 +1449,22 @@ public enum MCPClientSetup { return false } + func string(path: [String]) throws -> String? { + guard !path.isEmpty, let root = try rootObject() else { return nil } + var object = root + for (index, component) in path.enumerated() { + guard let property = try properties(in: object).first(where: { $0.name == component }) else { + return nil + } + if index == path.count - 1 { + return try stringValue(at: property.valueStart, limit: property.valueEnd) + } + guard let nested = try self.object(at: property.valueStart) else { return nil } + object = nested + } + return nil + } + mutating func set(path: [String], jsonValue: String) throws { guard !path.isEmpty, let root = try rootObject() else { throw Failure("The existing configuration could not be read.") @@ -1636,6 +1651,18 @@ public enum MCPClientSetup { return (String(decoding: bytes[start.. String? { + guard start < limit, bytes[start] == 0x22, + let end = try skippedStringOrComment(from: start, limit: limit), + skipTrivia(from: end, limit: limit) == limit else { + return nil + } + return (try? JSONSerialization.jsonObject( + with: Data(bytes[start.. Int { var braces = 0 var brackets = 0 @@ -1788,9 +1815,10 @@ public enum MCPClientSetup { /// Whether a client already points at TorroMail. public static func isConfigured(_ client: MCPClient) -> Bool { switch client.setup { - case let .mcpServersJSON(configURL), let .serversJSON(configURL), let .openCodeJSON(configURL), - let .claudeCodeCLI(_, configURL): + case let .mcpServersJSON(configURL), let .serversJSON(configURL), let .claudeCodeCLI(_, configURL): return jsonConfigHasServer(at: configURL, rootKey: client.setup.jsonRootKey) + case let .openCodeJSON(configURL): + return openCodeConfigHasServer(at: configURL) case let .codexCLI(_, configURL): // Reading the file beats launching the CLI on every refresh, and // a TOML table header is unambiguous enough to scan for. @@ -1819,6 +1847,13 @@ public enum MCPClientSetup { } private static let openClawServerPath = ["mcp", "servers", MCPClientRegistry.serverName] + private static let openCodeServerPath = ["mcp", MCPClientRegistry.serverName] + private static let openCodeTokenPath = openCodeServerPath + ["environment", "TORROMAIL_TOKEN"] + + private static func openCodeConfigHasServer(at configURL: URL) -> Bool { + guard let data = try? Data(contentsOf: configURL), !data.isEmpty else { return false } + return (try? JSON5Document(data: data).contains(path: openCodeServerPath)) == true + } private static func openClawConfigHasServer(at configURL: URL) -> Bool { guard let data = try? Data(contentsOf: configURL), !data.isEmpty else { return false } @@ -1846,13 +1881,15 @@ public enum MCPClientSetup { return false } switch client.setup { - case let .mcpServersJSON(configURL), let .serversJSON(configURL), let .openCodeJSON(configURL), - let .claudeCodeCLI(_, configURL): + case let .mcpServersJSON(configURL), let .serversJSON(configURL), let .claudeCodeCLI(_, configURL): return jsonConfigToken( at: configURL, rootKey: client.setup.jsonRootKey, environmentKey: client.setup.jsonEnvironmentKey ) == token + case let .openCodeJSON(configURL): + guard let data = try? Data(contentsOf: configURL), !data.isEmpty else { return false } + return (try? JSON5Document(data: data).string(path: openCodeTokenPath)) == token case let .codexCLI(_, configURL): // The key is high-entropy, so plain containment on the TOML is // unambiguous — better than parsing a format we never write. @@ -2009,7 +2046,7 @@ public enum MCPClientSetup { fileManager: FileManager = .default ) throws { switch client.setup { - case let .mcpServersJSON(configURL), let .serversJSON(configURL), let .openCodeJSON(configURL): + case let .mcpServersJSON(configURL), let .serversJSON(configURL): try addToJSONConfig( at: configURL, rootKey: client.setup.jsonRootKey, @@ -2017,6 +2054,13 @@ public enum MCPClientSetup { token: token, fileManager: fileManager ) + case let .openCodeJSON(configURL): + try setOpenCodeServer( + at: configURL, + commandPath: commandPath, + token: token, + fileManager: fileManager + ) case let .codexCLI(executableURL, _): try addViaCLI( executableURL: executableURL, @@ -2084,6 +2128,58 @@ public enum MCPClientSetup { try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: target.path) } + private static func setOpenCodeServer( + at target: URL, + commandPath: String, + token: String, + fileManager: FileManager + ) throws { + let definition: [String: Any] = [ + "type": "local", + "command": [commandPath], + "environment": ["TORROMAIL_TOKEN": token], + "enabled": true + ] + let definitionData = try JSONSerialization.data(withJSONObject: definition, options: [.sortedKeys]) + guard let definitionJSON = String(data: definitionData, encoding: .utf8) else { + throw Failure("Could not build the OpenCode configuration.") + } + let existing: Data + if fileManager.fileExists(atPath: target.path) { + do { + existing = try Data(contentsOf: target) + } catch { + throw Failure("The existing configuration could not be read.") + } + } else { + existing = Data() + } + var document = JSON5Document(data: existing.isEmpty ? Data("{}".utf8) : existing) + try document.set(path: openCodeServerPath, jsonValue: definitionJSON) + try fileManager.createDirectory( + at: target.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try document.data.write(to: target, options: .atomic) + } + + private static func removeOpenCodeServer( + at target: URL, + fileManager: FileManager + ) throws { + guard fileManager.fileExists(atPath: target.path) else { return } + let existing: Data + do { + existing = try Data(contentsOf: target) + } catch { + throw Failure("The existing configuration could not be read.") + } + guard !existing.isEmpty else { return } + var document = JSON5Document(data: existing) + try document.remove(path: openCodeServerPath) + try document.data.write(to: target, options: .atomic) + } + private static func removeOpenClawServer( at target: URL, fileManager: FileManager @@ -2123,16 +2219,6 @@ public enum MCPClientSetup { "command": commandPath, "env": ["TORROMAIL_TOKEN": token] ] - // OpenCode's schema: a `local` server whose command is a list and - // whose variables are called `environment`. - if rootKey == "mcp" { - entry = [ - "type": "local", - "command": [commandPath], - "environment": ["TORROMAIL_TOKEN": token], - "enabled": true - ] - } // VS Code's `servers` schema tags the transport; the `mcpServers` // clients infer stdio from `command` and reject an unknown key here. if rootKey == "servers" { entry["type"] = "stdio" } @@ -2602,12 +2688,14 @@ extension MCPClientSetup { fileManager: FileManager = .default ) throws { switch client.setup { - case let .mcpServersJSON(configURL), let .serversJSON(configURL), let .openCodeJSON(configURL): + case let .mcpServersJSON(configURL), let .serversJSON(configURL): try removeFromJSONConfig( at: configURL, rootKey: client.setup.jsonRootKey, fileManager: fileManager ) + case let .openCodeJSON(configURL): + try removeOpenCodeServer(at: configURL, fileManager: fileManager) case let .codexCLI(executableURL, _): try removeViaCLI(executableURL: executableURL, extraArguments: []) case let .claudeCodeCLI(executableURL, _): diff --git a/apps/TorroMailApp/Tests/TorroMailKitContract/main.swift b/apps/TorroMailApp/Tests/TorroMailKitContract/main.swift index 8fc1e3d..eb4012c 100644 --- a/apps/TorroMailApp/Tests/TorroMailKitContract/main.swift +++ b/apps/TorroMailApp/Tests/TorroMailKitContract/main.swift @@ -1995,9 +1995,18 @@ for (index, mergeCase) in mergeCases.enumerated() { continue } require(!failed, "shared client case succeeds: \(caseName)") - let written = (try? Data(contentsOf: configURL)) - .flatMap { try? JSONSerialization.jsonObject(with: $0) } as? NSDictionary - require(written == mergeCase["expected"] as? NSDictionary, "shared client case: \(caseName)") + let writtenText = (try? String(contentsOf: configURL, encoding: .utf8)) ?? "" + if let expected = mergeCase["expected"] as? NSDictionary { + let written = (try? Data(contentsOf: configURL)) + .flatMap { try? JSONSerialization.jsonObject(with: $0) } as? NSDictionary + require(written == expected, "shared client case: \(caseName)") + } + for expected in mergeCase["expected_text_contains"] as? [String] ?? [] { + require(writtenText.contains(expected), "shared client case preserves/adds \(expected): \(caseName)") + } + for excluded in mergeCase["expected_text_excludes"] as? [String] ?? [] { + require(!writtenText.contains(excluded), "shared client case removes \(excluded): \(caseName)") + } require( MCPClientSetup.isConfigured(sharedClient) == !removing, "shared client case reads back as \(removing ? "not configured" : "configured"): \(caseName)" diff --git a/contracts/client-config.json b/contracts/client-config.json index 7164fef..e2248bc 100644 --- a/contracts/client-config.json +++ b/contracts/client-config.json @@ -175,11 +175,39 @@ "name": "opencode keeps its servers under `mcp`, as local entries with a command list, beside its own settings", "root_key": "mcp" }, + { + "action": "add", + "existing": "{\n // my settings must survive\n \"model\": \"x\",\n \"mcp\": {\n \"other\": { \"type\": \"remote\", \"url\": \"https://example.com/mcp\" },\n },\n}", + "expected_text_contains": [ + "// my settings must survive", + "\"model\": \"x\"", + "\"other\": { \"type\": \"remote\"", + "\"torromail\"", + "\"TORROMAIL_TOKEN\":\"torro_cursor_0123abcd\"" + ], + "name": "opencode JSONC keeps comments, trailing commas, existing servers, and gains TorroMail", + "root_key": "mcp" + }, + { + "action": "remove", + "existing": "{\n // my settings must survive\n \"model\": \"x\",\n \"mcp\": {\n \"other\": { \"type\": \"remote\", \"url\": \"https://example.com/mcp\" },\n \"torromail\": {\"type\":\"local\",\"command\":[\"/Applications/TorroMail.app/Contents/MacOS/torromail-mcp\"],\"environment\":{\"TORROMAIL_TOKEN\":\"torro_cursor_0123abcd\"},\"enabled\":true},\n },\n}", + "expected_text_contains": [ + "// my settings must survive", + "\"model\": \"x\"", + "\"other\": { \"type\": \"remote\"" + ], + "expected_text_excludes": [ + "\"torromail\"", + "torro_cursor_0123abcd" + ], + "name": "removing from opencode JSONC preserves comments, trailing commas, and other servers", + "root_key": "mcp" + }, { "action": "add", "error": true, - "existing": "{\n // my settings\n \"model\": \"x\"\n}", - "name": "an opencode config with comments in it is left alone", + "existing": "{\n // an unfinished object is still malformed\n \"mcp\": {", + "name": "malformed opencode JSONC is rejected and left untouched", "root_key": "mcp" }, { diff --git a/crates/torromail-control/src/clients.rs b/crates/torromail-control/src/clients.rs index ec11332..cdb0bd7 100644 --- a/crates/torromail-control/src/clients.rs +++ b/crates/torromail-control/src/clients.rs @@ -334,9 +334,9 @@ pub fn installed(environment: &Environment) -> Vec { // file every MCP host reads would hand that key to all of them. json_client("pi", home.join(".pi/agent"), "mcp.json", false); - // OpenCode keeps its config under ~/.config on every platform. A `.jsonc` - // is used when that is the only one there; comments in it make it - // unreadable to a strict parser, which is an error — never an overwrite. + // OpenCode keeps its config under ~/.config on every platform. Use its + // `.jsonc` when it is the only one there; the setup editor preserves + // comments and trailing commas while changing only TorroMail's entry. let opencode = home.join(".config/opencode"); if opencode.exists() { let jsonc_only = !opencode.join("opencode.json").exists() && opencode.join("opencode.jsonc").exists(); @@ -406,10 +406,24 @@ fn is_executable(path: &Path) -> bool { // MARK: reading a client's configuration fn json_server_entry(setup: &ClientSetup) -> Option { + if matches!(setup, ClientSetup::OpenCodeJson { .. }) { + return None; + } let root: Value = serde_json::from_str(&std::fs::read_to_string(setup.config()).ok()?).ok()?; root.get(setup.json_root_key())?.get(SERVER_NAME).cloned() } +const OPEN_CODE_SERVER_PATH: &[&str] = &["mcp", SERVER_NAME]; +const OPEN_CODE_TOKEN_PATH: &[&str] = &["mcp", SERVER_NAME, "environment", TOKEN_VARIABLE]; + +fn open_code_document(config: &Path) -> Option { + let bytes = std::fs::read(config).ok()?; + if bytes.is_empty() { + return None; + } + Some(crate::jsonc::Document::new(bytes)) +} + /// Whether a client already points at TorroMail. Read from its own /// configuration — cheaper than launching a CLI on every refresh. #[must_use] @@ -418,6 +432,9 @@ pub fn is_configured(setup: &ClientSetup) -> bool { ClientSetup::CodexCli { config, .. } => std::fs::read_to_string(config) .map(|text| codex_section(&text).is_some()) .unwrap_or(false), + ClientSetup::OpenCodeJson { config } => open_code_document(config) + .and_then(|document| document.contains(OPEN_CODE_SERVER_PATH).ok()) + .unwrap_or(false), _ => json_server_entry(setup).is_some(), } } @@ -448,8 +465,11 @@ pub fn configured_token(setup: &ClientSetup) -> Option { let close = open + quoted[open..].find('"')?; Some(quoted[open..close].to_owned()) } + ClientSetup::OpenCodeJson { config } => open_code_document(config)? + .string(OPEN_CODE_TOKEN_PATH) + .ok()?, _ => json_server_entry(setup)? - .get(if matches!(setup, ClientSetup::OpenCodeJson { .. }) { "environment" } else { "env" })? + .get("env")? .get(TOKEN_VARIABLE)? .as_str() .map(str::to_owned), @@ -506,14 +526,10 @@ pub fn run_tool(executable: &Path, arguments: &[String]) -> Result<(), SetupErro /// Registers the server with a client, access key included. pub fn add(setup: &ClientSetup, command_path: &str, token: &str, run: ToolRunner<'_>) -> Result<(), SetupError> { match setup { - ClientSetup::McpServersJson { config } | ClientSetup::ServersJson { config } | ClientSetup::OpenCodeJson { config } => { + ClientSetup::McpServersJson { config } | ClientSetup::ServersJson { config } => { let root_key = setup.json_root_key(); let mut root = read_json_config(config)?; - let mut entry = if matches!(setup, ClientSetup::OpenCodeJson { .. }) { - json!({ "type": "local", "command": [command_path], "environment": { TOKEN_VARIABLE: token }, "enabled": true }) - } else { - json!({ "command": command_path, "env": { TOKEN_VARIABLE: token } }) - }; + let mut entry = json!({ "command": command_path, "env": { TOKEN_VARIABLE: token } }); // VS Code's schema tags the transport; the `mcpServers` clients // infer stdio from `command` and reject an unknown key here. if root_key == "servers" { @@ -526,6 +542,7 @@ pub fn add(setup: &ClientSetup, command_path: &str, token: &str, run: ToolRunner servers[SERVER_NAME] = entry; write_json_config(config, &root) } + ClientSetup::OpenCodeJson { config } => set_open_code_server(config, command_path, token), ClientSetup::CodexCli { executable, .. } => add_via_tool( executable, &[], @@ -548,7 +565,7 @@ pub fn add(setup: &ClientSetup, command_path: &str, token: &str, run: ToolRunner /// Removes TorroMail from a client's configuration — the counterpart to `add`. pub fn remove(setup: &ClientSetup, run: ToolRunner<'_>) -> Result<(), SetupError> { match setup { - ClientSetup::McpServersJson { config } | ClientSetup::ServersJson { config } | ClientSetup::OpenCodeJson { config } => { + ClientSetup::McpServersJson { config } | ClientSetup::ServersJson { config } => { if std::fs::read(config).map(|bytes| bytes.is_empty()).unwrap_or(true) { return Ok(()); } @@ -559,6 +576,7 @@ pub fn remove(setup: &ClientSetup, run: ToolRunner<'_>) -> Result<(), SetupError servers.remove(SERVER_NAME); write_json_config(config, &root) } + ClientSetup::OpenCodeJson { config } => remove_open_code_server(config), ClientSetup::CodexCli { executable, .. } => { run(executable, &["mcp".to_owned(), "remove".to_owned(), SERVER_NAME.to_owned()]) } @@ -616,6 +634,48 @@ fn write_json_config(config: &Path, root: &Map) -> Result<(), Set .map_err(|error| SetupError::WriteFailed(error.to_string())) } +fn open_code_document_for_write(config: &Path) -> Result { + match std::fs::read(config) { + Ok(bytes) if bytes.is_empty() => Ok(crate::jsonc::Document::new(b"{}".to_vec())), + Ok(bytes) => Ok(crate::jsonc::Document::new(bytes)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(crate::jsonc::Document::new(b"{}".to_vec())) + } + Err(_) => Err(SetupError::UnreadableConfig), + } +} + +fn write_open_code_config(config: &Path, document: crate::jsonc::Document) -> Result<(), SetupError> { + let target = std::fs::canonicalize(config).unwrap_or_else(|_| config.to_path_buf()); + crate::state::write_atomically(&target, &document.into_bytes()) + .map_err(|error| SetupError::WriteFailed(error.to_string())) +} + +fn set_open_code_server(config: &Path, command_path: &str, token: &str) -> Result<(), SetupError> { + let definition = json!({ + "type": "local", + "command": [command_path], + "environment": { TOKEN_VARIABLE: token }, + "enabled": true, + }); + let json = serde_json::to_string(&definition).map_err(|error| SetupError::WriteFailed(error.to_string()))?; + let mut document = open_code_document_for_write(config)?; + document.set(OPEN_CODE_SERVER_PATH, &json).map_err(|_| SetupError::UnreadableConfig)?; + write_open_code_config(config, document) +} + +fn remove_open_code_server(config: &Path) -> Result<(), SetupError> { + let bytes = match std::fs::read(config) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(_) => return Err(SetupError::UnreadableConfig), + Ok(bytes) if bytes.is_empty() => return Ok(()), + Ok(bytes) => bytes, + }; + let mut document = crate::jsonc::Document::new(bytes); + document.remove(OPEN_CODE_SERVER_PATH).map_err(|_| SetupError::UnreadableConfig)?; + write_open_code_config(config, document) +} + // MARK: the snippet to paste /// The snippet in the shape the target client expects. `token` is the key to diff --git a/crates/torromail-control/src/jsonc.rs b/crates/torromail-control/src/jsonc.rs new file mode 100644 index 0000000..c69b095 --- /dev/null +++ b/crates/torromail-control/src/jsonc.rs @@ -0,0 +1,457 @@ +//! Surgical JSONC editing for client-owned configuration files. +//! +//! The editor changes one object path while preserving comments, trailing +//! commas, formatting, and every unrelated byte. It intentionally implements +//! only the object traversal TorroMail needs; inserted values are serialized +//! JSON supplied by the caller. + +use std::ops::Range; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct Error; + +#[derive(Debug, Clone)] +struct Property { + name: String, + key_start: usize, + value_start: usize, + value_end: usize, + comma_before: Option, + comma_after: Option, +} + +pub(crate) struct Document { + bytes: Vec, +} + +impl Document { + pub(crate) fn new(bytes: Vec) -> Self { + Self { bytes } + } + + pub(crate) fn into_bytes(self) -> Vec { + self.bytes + } + + pub(crate) fn contains(&self, path: &[&str]) -> Result { + let Some(mut object) = self.root_object()? else { + return Ok(false); + }; + for (index, component) in path.iter().enumerate() { + let Some(property) = self + .properties(object.clone())? + .into_iter() + .find(|property| property.name == *component) + else { + return Ok(false); + }; + if index == path.len() - 1 { + return Ok(true); + } + let Some(nested) = self.object_at(property.value_start)? else { + return Ok(false); + }; + object = nested; + } + Ok(false) + } + + pub(crate) fn string(&self, path: &[&str]) -> Result, Error> { + let Some(mut object) = self.root_object()? else { + return Ok(None); + }; + for (index, component) in path.iter().enumerate() { + let Some(property) = self + .properties(object.clone())? + .into_iter() + .find(|property| property.name == *component) + else { + return Ok(None); + }; + if index == path.len() - 1 { + return self.string_value(property.value_start, property.value_end); + } + let Some(nested) = self.object_at(property.value_start)? else { + return Ok(None); + }; + object = nested; + } + Ok(None) + } + + pub(crate) fn set(&mut self, path: &[&str], json_value: &str) -> Result<(), Error> { + if path.is_empty() { + return Err(Error); + } + let Some(root) = self.root_object()? else { + return Err(Error); + }; + self.set_in(path, json_value.as_bytes(), root) + } + + pub(crate) fn remove(&mut self, path: &[&str]) -> Result<(), Error> { + if path.is_empty() { + return Err(Error); + } + let Some(root) = self.root_object()? else { + return Err(Error); + }; + self.remove_in(path, root) + } + + fn set_in( + &mut self, + path: &[&str], + json_value: &[u8], + object: Range, + ) -> Result<(), Error> { + let component = path.first().ok_or(Error)?; + let existing = self + .properties(object.clone())? + .into_iter() + .find(|property| property.name == *component); + if path.len() == 1 { + if let Some(existing) = existing { + self.bytes.splice( + existing.value_start..existing.value_end, + json_value.iter().copied(), + ); + } else { + self.insert_property(component, json_value, object)?; + } + return Ok(()); + } + + if let Some(existing) = existing { + let nested = self.object_at(existing.value_start)?.ok_or(Error)?; + return self.set_in(&path[1..], json_value, nested); + } + + let nested = nested_object(&path[1..], json_value); + self.insert_property(component, &nested, object) + } + + fn remove_in(&mut self, path: &[&str], object: Range) -> Result<(), Error> { + let Some(component) = path.first() else { + return Ok(()); + }; + let Some(property) = self + .properties(object)? + .into_iter() + .find(|property| property.name == *component) + else { + return Ok(()); + }; + if path.len() > 1 { + let Some(nested) = self.object_at(property.value_start)? else { + return Ok(()); + }; + return self.remove_in(&path[1..], nested); + } + + if let Some(comma) = property.comma_after { + self.bytes.drain(property.key_start..=comma); + } else if let Some(comma) = property.comma_before { + self.bytes.drain(comma..property.value_end); + } else { + self.bytes.drain(property.key_start..property.value_end); + } + Ok(()) + } + + fn root_object(&self) -> Result>, Error> { + let mut start = usize::from(self.bytes.starts_with(&[0xEF, 0xBB, 0xBF])) * 3; + start = self.skip_trivia(start, self.bytes.len()); + if self.bytes.get(start) != Some(&b'{') { + return Ok(None); + } + let root = self.object_at(start)?.ok_or(Error)?; + if self.skip_trivia(root.end, self.bytes.len()) != self.bytes.len() { + return Err(Error); + } + Ok(Some(root)) + } + + fn object_at(&self, raw_start: usize) -> Result>, Error> { + let start = self.skip_trivia(raw_start, self.bytes.len()); + if self.bytes.get(start) != Some(&b'{') { + return Ok(None); + } + let mut depth = 0_usize; + let mut index = start; + while index < self.bytes.len() { + if let Some(next) = self.skipped_string_or_comment(index, self.bytes.len())? { + index = next; + continue; + } + match self.bytes[index] { + b'{' => depth += 1, + b'}' => { + depth = depth.checked_sub(1).ok_or(Error)?; + if depth == 0 { + return Ok(Some(start..index + 1)); + } + } + _ => {} + } + index += 1; + } + Err(Error) + } + + fn properties(&self, object: Range) -> Result, Error> { + let closing_brace = object.end.checked_sub(1).ok_or(Error)?; + let mut index = self.skip_trivia(object.start + 1, closing_brace); + let mut previous_comma = None; + let mut result = Vec::new(); + + while index < closing_brace { + let key_start = index; + let (name, key_end) = self.parsed_key(index, closing_brace)?.ok_or(Error)?; + index = self.skip_trivia(key_end, closing_brace); + if self.bytes.get(index) != Some(&b':') { + return Err(Error); + } + let value_start = self.skip_trivia(index + 1, closing_brace); + if value_start >= closing_brace { + return Err(Error); + } + let separator = self.value_separator(value_start, closing_brace)?; + let comma = + (separator < closing_brace && self.bytes[separator] == b',').then_some(separator); + result.push(Property { + name, + key_start, + value_start, + value_end: separator, + comma_before: previous_comma, + comma_after: comma, + }); + let Some(comma) = comma else { + break; + }; + previous_comma = Some(comma); + index = self.skip_trivia(comma + 1, closing_brace); + if index == closing_brace { + break; + } + } + Ok(result) + } + + fn parsed_key(&self, start: usize, limit: usize) -> Result, Error> { + if start >= limit { + return Ok(None); + } + if self.bytes[start] == b'"' || self.bytes[start] == b'\'' { + let quote = self.bytes[start]; + let mut index = start + 1; + let mut value = Vec::new(); + while index < limit { + if self.bytes[index] == b'\\' { + if index + 1 >= limit { + return Err(Error); + } + value.push(self.bytes[index + 1]); + index += 2; + } else if self.bytes[index] == quote { + return Ok(Some(( + String::from_utf8(value).map_err(|_| Error)?, + index + 1, + ))); + } else { + value.push(self.bytes[index]); + index += 1; + } + } + return Err(Error); + } + + let mut index = start; + while index < limit { + let byte = self.bytes[index]; + if byte == b':' || is_whitespace(byte) { + break; + } + if byte == b'/' + && index + 1 < limit + && (self.bytes[index + 1] == b'/' || self.bytes[index + 1] == b'*') + { + break; + } + index += 1; + } + if index == start { + return Ok(None); + } + Ok(Some(( + String::from_utf8(self.bytes[start..index].to_vec()).map_err(|_| Error)?, + index, + ))) + } + + fn string_value(&self, start: usize, limit: usize) -> Result, Error> { + if start >= limit || self.bytes[start] != b'"' { + return Ok(None); + } + let Some(end) = self.skipped_string_or_comment(start, limit)? else { + return Ok(None); + }; + if self.skip_trivia(end, limit) != limit { + return Ok(None); + } + Ok(serde_json::from_slice(&self.bytes[start..end]).ok()) + } + + fn value_separator(&self, start: usize, object_end: usize) -> Result { + let mut braces = 0_usize; + let mut brackets = 0_usize; + let mut index = start; + while index < object_end { + if let Some(next) = self.skipped_string_or_comment(index, object_end)? { + index = next; + continue; + } + match self.bytes[index] { + b'{' => braces += 1, + b'}' if braces > 0 => braces -= 1, + b'[' => brackets += 1, + b']' if brackets > 0 => brackets -= 1, + b',' if braces == 0 && brackets == 0 => return Ok(index), + _ => {} + } + index += 1; + } + Ok(object_end) + } + + fn skipped_string_or_comment( + &self, + start: usize, + limit: usize, + ) -> Result, Error> { + if start >= limit { + return Ok(None); + } + if self.bytes[start] == b'"' || self.bytes[start] == b'\'' { + let quote = self.bytes[start]; + let mut index = start + 1; + while index < limit { + if self.bytes[index] == b'\\' { + index += 2; + } else if self.bytes[index] == quote { + return Ok(Some(index + 1)); + } else { + index += 1; + } + } + return Err(Error); + } + if self.bytes[start] != b'/' || start + 1 >= limit { + return Ok(None); + } + if self.bytes[start + 1] == b'/' { + let mut index = start + 2; + while index < limit && self.bytes[index] != b'\n' && self.bytes[index] != b'\r' { + index += 1; + } + return Ok(Some(index)); + } + if self.bytes[start + 1] == b'*' { + let mut index = start + 2; + while index + 1 < limit { + if self.bytes[index] == b'*' && self.bytes[index + 1] == b'/' { + return Ok(Some(index + 2)); + } + index += 1; + } + return Err(Error); + } + Ok(None) + } + + fn skip_trivia(&self, start: usize, limit: usize) -> usize { + let mut index = start; + while index < limit { + if is_whitespace(self.bytes[index]) { + index += 1; + continue; + } + if self.bytes[index] == b'/' && index + 1 < limit && self.bytes[index + 1] == b'/' { + index += 2; + while index < limit && self.bytes[index] != b'\n' && self.bytes[index] != b'\r' { + index += 1; + } + continue; + } + if self.bytes[index] == b'/' && index + 1 < limit && self.bytes[index + 1] == b'*' { + index += 2; + while index + 1 < limit + && !(self.bytes[index] == b'*' && self.bytes[index + 1] == b'/') + { + index += 1; + } + if index + 1 < limit { + index += 2; + } + continue; + } + break; + } + index + } + + fn insert_property( + &mut self, + name: &str, + value: &[u8], + object: Range, + ) -> Result<(), Error> { + let properties = self.properties(object.clone())?; + let closing_brace = object.end.checked_sub(1).ok_or(Error)?; + let closing_indent = self.indentation_before(closing_brace); + let child_indent = format!("{closing_indent} "); + let needs_comma = properties + .last() + .is_some_and(|property| property.comma_after.is_none()); + let prefix = format!( + "{}\n{child_indent}\"{name}\": ", + if needs_comma { "," } else { "" } + ); + let mut addition = prefix.into_bytes(); + addition.extend_from_slice(value); + addition.extend_from_slice(format!("\n{closing_indent}").as_bytes()); + self.bytes.splice(closing_brace..closing_brace, addition); + Ok(()) + } + + fn indentation_before(&self, index: usize) -> String { + let mut line_start = index; + while line_start > 0 + && self.bytes[line_start - 1] != b'\n' + && self.bytes[line_start - 1] != b'\r' + { + line_start -= 1; + } + let candidate = &self.bytes[line_start..index]; + if candidate.iter().all(|byte| *byte == b' ' || *byte == b'\t') { + String::from_utf8_lossy(candidate).into_owned() + } else { + String::new() + } + } +} + +fn nested_object(path: &[&str], json_value: &[u8]) -> Vec { + let Some(first) = path.first() else { + return json_value.to_vec(); + }; + let mut result = format!("{{\"{first}\":").into_bytes(); + result.extend_from_slice(&nested_object(&path[1..], json_value)); + result.push(b'}'); + result +} + +fn is_whitespace(byte: u8) -> bool { + matches!(byte, b' ' | b'\t' | b'\n' | b'\r' | 0x0C) +} diff --git a/crates/torromail-control/src/lib.rs b/crates/torromail-control/src/lib.rs index 0ec15e7..880ce79 100644 --- a/crates/torromail-control/src/lib.rs +++ b/crates/torromail-control/src/lib.rs @@ -12,6 +12,7 @@ pub mod cache_files; pub mod clients; pub mod connect; pub mod enroll; +mod jsonc; pub mod logs; pub mod mailbox_names; pub mod paths; diff --git a/crates/torromail-control/tests/client_setup.rs b/crates/torromail-control/tests/client_setup.rs index cc77666..1e0adea 100644 --- a/crates/torromail-control/tests/client_setup.rs +++ b/crates/torromail-control/tests/client_setup.rs @@ -61,9 +61,17 @@ fn the_shared_config_cases_merge_as_written() { continue; } assert_eq!(outcome, Ok(()), "{name}"); - let written: Value = - serde_json::from_str(&std::fs::read_to_string(&config).expect("the config exists")).expect("JSON"); - assert_eq!(written, case["expected"], "{name}"); + let written_text = std::fs::read_to_string(&config).expect("the config exists"); + if !case["expected"].is_null() { + let written: Value = serde_json::from_str(&written_text).expect("JSON"); + assert_eq!(written, case["expected"], "{name}"); + } + for expected in case["expected_text_contains"].as_array().into_iter().flatten() { + assert!(written_text.contains(expected.as_str().expect("text")), "{name}: missing {expected}"); + } + for excluded in case["expected_text_excludes"].as_array().into_iter().flatten() { + assert!(!written_text.contains(excluded.as_str().expect("text")), "{name}: kept {excluded}"); + } if case["action"].as_str() != Some("remove") { assert!(clients::is_configured(&setup), "{name}"); assert!(clients::has_key(&setup, &token), "{name}"); @@ -147,11 +155,12 @@ fn macos_looks_where_macos_apps_keep_their_config() { let environment = Environment { platform: Platform::MacOs, home: home.clone(), executable_directories: vec![] }; let found = clients::installed(&environment); - assert_eq!(found.iter().map(|client| client.id).collect::>(), ["claude-desktop"]); + let claude_desktop = found.iter().find(|client| client.id == "claude-desktop").expect("Claude Desktop"); assert_eq!( - found[0].setup.config(), + claude_desktop.setup.config(), home.join("Library/Application Support/Claude/claude_desktop_config.json") ); + assert!(!found.iter().any(|client| client.id == "vscode"), "macOS ignores the Linux VS Code path"); } #[test]