diff --git a/tools/content_build/__main__.py b/tools/content_build/__main__.py new file mode 100644 index 0000000..5e958ee --- /dev/null +++ b/tools/content_build/__main__.py @@ -0,0 +1,72 @@ +"""CLI entry: `python -m content_build [--check]`. Orchestrates +parse -> model -> resolve -> emit. --check builds in memory and diffs against +the on-disk committed JSON (stale-or-invalid -> exit 1).""" + +import argparse +import sys +from pathlib import Path + +from .emit import build_trees, check_secrecy, dump_json, write_trees +from .errors import BuildError +from .model import entry_from_raw +from .parse import parse_bible +from .resolve import resolve + + +def _load_world(lore_dir: Path): + raws = [] + for md in sorted(Path(lore_dir).glob("*.md")): + raws.extend(parse_bible(md)) + return resolve([entry_from_raw(r) for r in raws]) + + +def build(lore_dir, world_dir, server_dir) -> None: + world = _load_world(lore_dir) + client_files, server_files = build_trees(world) + check_secrecy(client_files, world) + write_trees(client_files, server_files, world_dir, server_dir) + + +def _diff(path: Path, obj) -> list[str]: + want = dump_json(obj) + if not path.exists(): + return [f"missing {path}"] + if path.read_text() != want: + return [f"differs {path}"] + return [] + + +def check(lore_dir, world_dir, server_dir) -> int: + world = _load_world(lore_dir) + client_files, server_files = build_trees(world) + check_secrecy(client_files, world) + stale: list[str] = [] + for relpath, obj in client_files.items(): + stale += _diff(Path(world_dir) / relpath, obj) + for relpath, obj in server_files.items(): + stale += _diff(Path(server_dir) / relpath, obj) + for s in stale: + print("STALE:", s, file=sys.stderr) + return 1 if stale else 0 + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(prog="content_build") + ap.add_argument("--check", action="store_true", + help="verify committed JSON is fresh + valid; no writes") + ap.add_argument("--lore", default="content/lore") + ap.add_argument("--world", default="content/world") + ap.add_argument("--server", default="content/server") + args = ap.parse_args(argv) + try: + if args.check: + return check(args.lore, args.world, args.server) + build(args.lore, args.world, args.server) + return 0 + except BuildError as e: + print("BUILD FAILED:", e, file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/content_build/tests/test_cli.py b/tools/content_build/tests/test_cli.py new file mode 100644 index 0000000..840a538 --- /dev/null +++ b/tools/content_build/tests/test_cli.py @@ -0,0 +1,54 @@ +import shutil +from pathlib import Path + +from content_build.__main__ import main + +FIXTURE_DIR = Path(__file__).parent / "fixtures" / "valid" + + +def _lore(tmp_path): + lore = tmp_path / "lore" + lore.mkdir() + shutil.copy(FIXTURE_DIR / "world.md", lore / "world.md") + return lore + + +def test_build_then_check_is_clean(tmp_path): + lore = _lore(tmp_path) + world, server = tmp_path / "world", tmp_path / "server" + argv = ["--lore", str(lore), "--world", str(world), "--server", str(server)] + assert main(argv) == 0 + assert (world / "npcs" / "tess.json").exists() + assert (server / "topics" / "big-twist.json").exists() + # a fresh --check against just-written output is clean + assert main(argv + ["--check"]) == 0 + + +def test_check_detects_stale(tmp_path): + lore = _lore(tmp_path) + world, server = tmp_path / "world", tmp_path / "server" + argv = ["--lore", str(lore), "--world", str(world), "--server", str(server)] + assert main(argv) == 0 + # mutate a committed artifact -> --check must fail + (world / "canon" / "testburg.json").write_text('{"id": "town.testburg"}\n') + assert main(argv + ["--check"]) == 1 + + +def test_check_detects_missing(tmp_path): + lore = _lore(tmp_path) + world, server = tmp_path / "world", tmp_path / "server" + argv = ["--lore", str(lore), "--world", str(world), "--server", str(server)] + assert main(argv) == 0 + (world / "npcs" / "tess.json").unlink() + assert main(argv + ["--check"]) == 1 + + +def test_invalid_bible_fails_build(tmp_path): + lore = tmp_path / "lore" + lore.mkdir() + (lore / "bad.md").write_text( + "```yaml\nid: town.a\ntype: town\nstatus: canon\nsecrecy: 0\n" + "related: [does.not-exist]\nbody: x\n```\n") + argv = ["--lore", str(lore), "--world", str(tmp_path / "w"), + "--server", str(tmp_path / "s")] + assert main(argv) == 1