diff --git a/src/domain/desktop/appExportPython.ts b/src/domain/desktop/appExportPython.ts index 844df74526..9d1d871dc2 100644 --- a/src/domain/desktop/appExportPython.ts +++ b/src/domain/desktop/appExportPython.ts @@ -205,8 +205,10 @@ def _keyfile_line(raw: str) -> tuple: header = GROUP_HEADER.match(line) if header: return ("group", header.group(1)) - key, equals, value = line.partition("=") - return ("entry", key.strip(), value.strip()) if equals and key else ("other",) + key, equals, value = raw.partition("=") + key = key.strip() + # Only ASCII space and tab immediately after '=' are padding. + return ("entry", key, value.lstrip(" \t")) if equals and key else ("other",) def _each_line(text: str): diff --git a/src/domain/desktop/stateChanges.test.ts b/src/domain/desktop/stateChanges.test.ts index 8dbc97343c..1321c3b28c 100644 --- a/src/domain/desktop/stateChanges.test.ts +++ b/src/domain/desktop/stateChanges.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { JsonValue } from "../jsonValue.js"; import { diffStateViews } from "./stateChanges.js"; +import { keyfileView } from "./stateFormats.js"; import { resolvePointer } from "./viewPointer.js"; describe("diffStateViews", () => { @@ -84,6 +85,33 @@ describe("diffStateViews", () => { }); describe("diffStateViews fidelity", () => { + it("preserves significant keyfile whitespace in views and differences", () => { + const view = (suffix: string, unicode: string, padding: string) => + keyfileView( + `[ui]\r\nsuffix=${suffix}\r\nunicode=${unicode}\r\npadded=${padding}\r\n`, + ); + const before = view("hello ", "\u00a0hello\u00a0", " hello"); + const after = view("hello ", "\u00a0hello\u00a0\u00a0", "\thello"); + expect(before.ui).toEqual({ + suffix: "hello ", + unicode: "\u00a0hello\u00a0", + padded: "hello", + }); + expect(after.ui).toEqual({ + suffix: "hello ", + unicode: "\u00a0hello\u00a0\u00a0", + padded: "hello", + }); + expect( + diffStateViews("keyfile", { groups: before }, { groups: after }), + ).toMatchObject({ + kind: "keys", + changed: ["ui/suffix", "ui/unicode"], + changed_count: 2, + complete: true, + }); + }); + it("keeps empty containers as values", () => { expect(diffStateViews("json", {}, { items: [] })).toMatchObject({ added: ["/items"], diff --git a/src/domain/desktop/stateFormats.ts b/src/domain/desktop/stateFormats.ts index e4eed2e211..2e868563da 100644 --- a/src/domain/desktop/stateFormats.ts +++ b/src/domain/desktop/stateFormats.ts @@ -49,13 +49,16 @@ const keyfileLine = (raw: string): KeyfileLine => { return { kind: "skip" }; const name = /^\[(?[^\]]+)\]$/u.exec(line)?.groups?.name; if (name !== undefined) return { kind: "group", name }; - const equals = line.indexOf("="); - return equals <= 0 + const equals = raw.indexOf("="); + if (equals < 0) return { kind: "other" }; + const key = raw.slice(0, equals).trim(); + return key.length === 0 ? { kind: "other" } : { kind: "entry", - key: line.slice(0, equals).trim(), - value: line.slice(equals + 1).trim(), + key, + // Only ASCII space and tab immediately after '=' are padding. + value: raw.slice(equals + 1).replace(/^[ \t]+/u, ""), }; }; diff --git a/tests/boundary/providers/desktopExportPython.test.ts b/tests/boundary/providers/desktopExportPython.test.ts index 57ec3b6621..f7938aa320 100644 --- a/tests/boundary/providers/desktopExportPython.test.ts +++ b/tests/boundary/providers/desktopExportPython.test.ts @@ -10,6 +10,7 @@ import { } from "../../../src/domain/desktop/appExportPython.js"; import { detectStateFormat, + keyfileView, isJsonText, jsonView, textualView, @@ -397,3 +398,24 @@ it("spells infinite and negative-zero SQLite reals as REA's SQLite reader does", expect(JSON.parse(cells)).toEqual(["Infinity", "-Infinity", "-0", 0, 1.5]); }); + +it("keeps the same keyfile whitespace as the retained state view", async () => { + const inputs = [ + "[ui]\nsuffix=hello \nunicode=\u00a0hello\u00a0\npadded= hello\n", + "[ui]\r\nsuffix=hello \r\nunicode=\u00a0hello\u00a0\u00a0\r\npadded=\thello\r\n", + ]; + const imports = PYTHON_IMPORTS.split("\n") + .filter((line) => !line.includes("agentenv_protocol")) + .join("\n"); + const program = `${imports}${PYTHON_HELPERS} +print(json.dumps([_text_view("settings.ini", text, len(text.encode()))["view"]["groups"] for text in json.load(sys.stdin)])) +`; + const root = await createTestTempDirectory("rea-keyfile-python-"); + const script = join(root, "fixture.py"); + await writeFile(script, program, "utf8"); + const output = execFileSync("python3", ["-I", "-B", "-X", "utf8", script], { + input: JSON.stringify(inputs), + encoding: "utf8", + }); + expect(JSON.parse(output)).toEqual(inputs.map(keyfileView)); +});