From 8eea22aa40d21cf35dd19d8f44749cb353b7f16f Mon Sep 17 00:00:00 2001 From: autocommit Date: Fri, 22 May 2026 15:10:46 -0700 Subject: [PATCH] =?UTF-8?q?feat(config):=20=E2=9C=A8=20Introduce=20'this?= =?UTF-8?q?=5Fhost'=20serialization=20logic=20and=20enforce=20consistent?= =?UTF-8?q?=20config=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/claire/config.py | 7 ++-- tests/test_config.py | 81 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/src/claire/config.py b/src/claire/config.py index 7c7ebf7..5650628 100644 --- a/src/claire/config.py +++ b/src/claire/config.py @@ -216,6 +216,10 @@ def _serialize(cfg: ClaireConfig) -> str: lines: list[str] = [f'machine_id = "{cfg.machine_id}"'] if cfg.sync_secret is not None: lines.append(f'sync_secret = "{cfg.sync_secret}"') + # Top-level scalars MUST come before any `[section]` header — once a + # table opens, subsequent KV pairs belong to it. Emit `this_host` here. + if cfg.this_host is not None: + lines.append(f'this_host = "{cfg.this_host}"') lines.extend( [ "", @@ -262,9 +266,6 @@ def _serialize(cfg: ClaireConfig) -> str: f'"{h}" = {c}' for h, c in sorted(lim.per_host.items()) ) lines.append(f"per_host = {{ {inner} }}") - if cfg.this_host is not None: - lines.append("") - lines.append(f'this_host = "{cfg.this_host}"') for h in cfg.known_hosts: lines.append("") lines.append("[[known_hosts]]") diff --git a/tests/test_config.py b/tests/test_config.py index eaef069..dfc0125 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -124,6 +124,87 @@ def test_load_or_init_reads_per_host_caps(tmp_path: Path) -> None: assert cfg.limits.cap_for("plum") == 4 # unnamed → default +# --- host detection / known_hosts ------------------------------------------ + + +def test_this_host_label_explicit_overrides_hostname(tmp_path: Path) -> None: + cfg_path = tmp_path / "claire.toml" + cfg_path.write_text( + 'machine_id = "m"\n' + 'sync_secret = "s"\n' + 'this_host = "plum"\n' + '\n[web]\nhost = "127.0.0.1"\nport = 8765\n', + encoding="utf-8", + ) + cfg = load_or_init(cfg_path) + assert cfg.this_host == "plum" + assert cfg.this_host_label() == "plum" + + +def test_this_host_label_defaults_to_short_os_hostname(tmp_path: Path) -> None: + import socket + cfg = load_or_init(tmp_path / "claire.toml") + expected = socket.gethostname().split(".", 1)[0].lower() + assert cfg.this_host is None + assert cfg.this_host_label() == expected + + +def test_resolve_host_label_rewrites_local_to_this_host(tmp_path: Path) -> None: + cfg_path = tmp_path / "claire.toml" + cfg_path.write_text( + 'machine_id = "m"\n' + 'sync_secret = "s"\n' + 'this_host = "plum"\n' + '\n[web]\nhost = "127.0.0.1"\nport = 8765\n', + encoding="utf-8", + ) + cfg = load_or_init(cfg_path) + assert cfg.resolve_host_label("local") == "plum" + # Already-canonical labels pass through. + assert cfg.resolve_host_label("apricot") == "apricot" + + +def test_resolve_host_label_uses_aliases(tmp_path: Path) -> None: + cfg_path = tmp_path / "claire.toml" + cfg_path.write_text( + 'machine_id = "m"\n' + 'sync_secret = "s"\n' + 'this_host = "plum"\n' + '\n[web]\nhost = "127.0.0.1"\nport = 8765\n' + '\n[[known_hosts]]\n' + 'name = "apricot"\n' + 'aliases = ["apri", "apr"]\n', + encoding="utf-8", + ) + cfg = load_or_init(cfg_path) + assert cfg.resolve_host_label("apri") == "apricot" + assert cfg.resolve_host_label("apr") == "apricot" + assert cfg.resolve_host_label("apricot") == "apricot" + # An unrelated label is unchanged. + assert cfg.resolve_host_label("black") == "black" + + +def test_serialize_round_trips_host_detection(tmp_path: Path) -> None: + cfg_path = tmp_path / "claire.toml" + cfg_path.write_text( + 'machine_id = "m"\n' + 'this_host = "plum"\n' + '\n[web]\nhost = "127.0.0.1"\nport = 8765\n' + '\n[[known_hosts]]\n' + 'name = "apricot"\n' + 'aliases = ["apri"]\n' + 'description = "dev box"\n', + encoding="utf-8", + ) + load_or_init(cfg_path) # rewrites file (backfills sync_secret) + reloaded = load_or_init(cfg_path) + assert reloaded.this_host == "plum" + assert len(reloaded.known_hosts) == 1 + assert reloaded.known_hosts[0].name == "apricot" + assert reloaded.known_hosts[0].aliases == ["apri"] + assert reloaded.known_hosts[0].description == "dev box" + + def test_serialize_round_trips_per_host_caps(tmp_path: Path) -> None: # A config missing sync_secret triggers the migration rewrite, which # runs _serialize — the per_host map must survive the round-trip.