diff options
| -rw-r--r-- | .github/workflows/coverage.yml | 35 | ||||
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | AGENTS.md | 8 | ||||
| -rw-r--r-- | README.asciidoc | 7 | ||||
| -rw-r--r-- | bisect.yml | 3 | ||||
| -rw-r--r-- | dune-project | 11 | ||||
| -rw-r--r-- | flake.nix | 30 | ||||
| -rw-r--r-- | lib/dune | 2 | ||||
| -rw-r--r-- | nix/package/nixtamal.nix | 1 | ||||
| -rw-r--r-- | nixtamal.opam | 3 | ||||
| -rw-r--r-- | test/dune | 2 | ||||
| -rw-r--r-- | test/test_fossil.ml | 76 | ||||
| -rw-r--r-- | test/test_lockfile.ml | 71 | ||||
| -rw-r--r-- | test/test_main.ml | 3 | ||||
| -rw-r--r-- | test/test_upgrade.ml | 113 |
15 files changed, 357 insertions, 10 deletions
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..ee358f0 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,35 @@ +name: coverage + +on: + push: + pull_request: + +jobs: + test-with-coverage: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Nix + uses: cachix/install-nix-action@v31 + + - name: Run tests with bisect_ppx + run: | + nix develop --option builders '' --command sh -c 'BISECT_ENABLE=YES dune runtest --instrument-with bisect_ppx --force' + + - name: Generate coverage reports + run: | + nix develop --option builders '' --command sh -c 'bisect-ppx-report summary --coverage-path _build/default/test > coverage-summary.txt' + nix develop --option builders '' --command sh -c 'bisect-ppx-report html --coverage-path _build/default/test --tree -o _coverage' + + - name: Upload coverage artifacts + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: | + coverage-summary.txt + coverage/ + _coverage/ + if-no-files-found: warn @@ -1,2 +1,4 @@ result llm/ +_build/ +_coverage/ @@ -2,7 +2,13 @@ ## Documentation -User-facing documentation is in `README.asciidoc`. Agent notes go in `./llm/`. +User-facing documentation is in `README.asciidoc`. Agent notes go in `./llm/` (note: this folder is now gitignored for security). + +## Contact + +Website: https://nixtamal.tech (launching soon) + +Community XMPP MUC: xmpp:nixtamal@chat.toastal.in.th?join ## Build/Lint/Test Commands diff --git a/README.asciidoc b/README.asciidoc index 607f02e..921e96e 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -9,6 +9,7 @@ /nɪks.təˈmal/ ("nix-tamal") Maker:: toastal +Website:: https://nixtamal.tech (launching soon) Put out:: 2026 @@ -30,7 +31,7 @@ Nixtamal is a tool to pin input version like its predecessors niv, npins, Pinch, * keeps a stable reference pin to supported VCSs * supports mirrors for fetching [1] -* supports patch-based VCSs, like Pijul & Darcs, in a first-class sense (tho nixpkgs will be required due to Nix builtins fetchers limitations) +* supports patch-based VCSs, like Pijul, Darcs, & Fossil, in a first-class sense (tho nixpkgs will be required due to Nix builtins fetchers limitations) * uses a KDL manifest file with templating instead of CLI input * supports arbitrary commands for getting the latest change for inputs * refreshes inputs; skips frozen @@ -40,6 +41,7 @@ Nixtamal is a tool to pin input version like its predecessors niv, npins, Pinch, * source code & community will never be hosted on a proprietary, privacy-invasive, megacorporate platform with obligations to shareholders or venture capital * licensed for your freedom * ML-family programming (feels closer to Nix) +* supports schema versioning for manifests and lockfiles Future goals (planned but not yet implemented): @@ -136,6 +138,7 @@ Opens text editor. & After editing … ---- $ nixtamal lock $ nixtamal refresh +$ nixtamal upgrade ---- === What next? @@ -305,6 +308,8 @@ For details read LICENSE.txt with full license texts at license/. Currently this is best done by sending a patchset to toastal+nixtamal@posteo.net or DM me a remote to clone @ toastal@toastal.in.th. +Website: https://nixtamal.tech + Community is in an XMPP MUC (chatroom) with future hopes to have an IRC gateway. Join @ xmpp:nixtamal@chat.toastal.in.th?join. Additionally, please read the PITCHING_IN.rst file for other information/expectations. diff --git a/bisect.yml b/bisect.yml new file mode 100644 index 0000000..c1874d6 --- /dev/null +++ b/bisect.yml @@ -0,0 +1,3 @@ +report: + - html + - text diff --git a/dune-project b/dune-project index 0ededdd..f023186 100644 --- a/dune-project +++ b/dune-project @@ -35,10 +35,11 @@ (and :with-test (>= 0.91))) - (qcheck-alcotest - (and - :with-test - (>= 0.91)))) - (tags ("nix"))) + (qcheck-alcotest + (and + :with-test + (>= 0.91))) + (bisect_ppx :with-test)) + (tags ("nix"))) ; See the complete stanza docs at https://dune.readthedocs.io/en/stable/reference/dune-project/index.html @@ -29,7 +29,7 @@ # Development shell using the same approach as shell.nix devShell = pkgs.mkShell { - buildInputs = nixtamalPkg.buildInputs or []; + buildInputs = (nixtamalPkg.buildInputs or []) ++ (nixtamalPkg.checkInputs or []); nativeBuildInputs = nixtamalPkg.nativeBuildInputs or []; }; in @@ -47,6 +47,32 @@ checks = { # Basic check that the package builds nixtamal-build = nixtamalPkg; + + # Explicit test check + nixtamal-test = nixtamalPkg; + + # Coverage instrumentation sanity check + nixtamal-coverage = pkgs.runCommand "nixtamal-coverage-check" + { + nativeBuildInputs = (nixtamalPkg.nativeBuildInputs or [ ]) + ++ (nixtamalPkg.buildInputs or [ ]) + ++ (with pkgs.ocamlPackages; [ + bisect_ppx + dune_3 + ]); + } + '' + export HOME="$TMPDIR" + cp -r ${nixtamalPkg.src} source + chmod -R u+w source + cd source + + BISECT_ENABLE=YES dune runtest --instrument-with bisect_ppx --force + bisect-ppx-report summary --coverage-path _build/default/test > coverage-summary.txt + + mkdir -p "$out" + cp coverage-summary.txt "$out"/ + ''; }; # Library outputs for ecosystem integration @@ -78,4 +104,4 @@ default = self.apps.${system}.nixtamal; }; }); -}
\ No newline at end of file +} @@ -14,6 +14,8 @@ saturn uri) (flags (:standard -alert -deprecated)) + (instrumentation + (backend bisect_ppx)) (preprocess (pps ppx_deriving.enum diff --git a/nix/package/nixtamal.nix b/nix/package/nixtamal.nix index 11a0f32..0a94574 100644 --- a/nix/package/nixtamal.nix +++ b/nix/package/nixtamal.nix @@ -102,6 +102,7 @@ ocamlPackages.buildDunePackage { checkInputs = with ocamlPackages; [ alcotest + bisect_ppx qcheck qcheck-alcotest ]; diff --git a/nixtamal.opam b/nixtamal.opam index 0904ad8..17b3e83 100644 --- a/nixtamal.opam +++ b/nixtamal.opam @@ -9,7 +9,7 @@ tags: ["nix"] depends: [ "dune" {>= "3.20"} "camomile" - "cmdliner" {>= "2.0.0"} + "cmdliner" "eio" "eio_main" "fmt" @@ -23,6 +23,7 @@ depends: [ "alcotest" {with-test} "qcheck" {with-test & >= "0.91"} "qcheck-alcotest" {with-test & >= "0.91"} + "bisect_ppx" {with-test} "odoc" {with-doc} ] build: [ @@ -1,4 +1,6 @@ (test (name test_main) (flags (:standard -alert -deprecated)) + (instrumentation + (backend bisect_ppx)) (libraries nixtamal alcotest qcheck qcheck-alcotest)) diff --git a/test/test_fossil.ml b/test/test_fossil.ml new file mode 100644 index 0000000..c4bb56f --- /dev/null +++ b/test/test_fossil.ml @@ -0,0 +1,76 @@ +(*─────────────────────────────────────────────────────────────────────────────┐ +│ SPDX-FileCopyrightText: 2026 toastal <https://toast.al/contact/> │ +│ SPDX-License-Identifier: LGPL-2.1-or-later WITH OCaml-LGPL-linking-exception │ +└─────────────────────────────────────────────────────────────────────────────*) +open Alcotest +open Nixtamal + +let suite = + let t = Input.Template.make in + [ + test_case "Manifest Fossil reference check-in from KDL" `Quick (fun () -> + let reference = + let kdl = + {|check-in "abc123"|} + |> KDL.of_string + |> Result.get_ok + in + match Manifest.Fossil.Reference.codec.of_kdl kdl with + | Ok ref -> ref + | Error err -> failwith Fmt.(str "%a" (list ~sep: semi KDL.Valid.pp_err) err) + in + check + (testable Input.Fossil.Reference.pp Input.Fossil.Reference.equal) + "check-in is decoded as Fossil reference" + (`Checkin "abc123") + reference + ); + test_case "Manifest Fossil input roundtrip" `Quick (fun () -> + let fossil = + Manifest.Fossil.make + ~repository:(t "https://example.org/src.fossil") + ~reference:(`Branch "trunk") + in + let roundtrip = + fossil + |> Manifest.Fossil.codec.to_kdl + |> Manifest.Fossil.codec.of_kdl + |> Result.get_ok + in + check + (testable Manifest.Fossil.pp Manifest.Fossil.equal) + "Fossil KDL codec is shape-preserving" + fossil + roundtrip + ); + test_case "Lockfile Fossil kind roundtrip" `Quick (fun () -> + let input_kind = + Input.make_kind_fossil + ~repository:(t "https://example.org/src.fossil") + ~reference:(`Tag "v1.0") + ~date:"2026-01-01T00:00:00Z" + ~latest_checkin:"abc123" + () + in + let models = Input.jg_models2 @@ Input.make ~name:(Name.Name.make "fossil") ~kind:input_kind () in + let lock_kind = Lockfile.Kind.to_lock ~models input_kind in + let decoded = + lock_kind + |> Jsont.Json.encode Lockfile.Kind.jsont + |> Result.get_ok + |> Jsont.Json.decode Lockfile.Kind.jsont + |> Result.get_ok + in + check + (testable Lockfile.Kind.pp Lockfile.Kind.equal) + "Fossil lockfile kind JSON codec is shape-preserving" + lock_kind + decoded; + match lock_kind with + | `Fossil fossil_lock -> + check int "Fossil mirrors are always empty" 0 (List.length fossil_lock.mirrors); + check (option string) "Fossil lock datetime" (Some "2026-01-01T00:00:00Z") fossil_lock.datetime; + check (option string) "Fossil lock latest_checkin" (Some "abc123") fossil_lock.latest_checkin + | _ -> fail "Expected Fossil lock kind" + ); + ] diff --git a/test/test_lockfile.ml b/test/test_lockfile.ml new file mode 100644 index 0000000..8d5f92b --- /dev/null +++ b/test/test_lockfile.ml @@ -0,0 +1,71 @@ +(*─────────────────────────────────────────────────────────────────────────────┐ +│ SPDX-FileCopyrightText: 2026 toastal <https://toast.al/contact/> │ +│ SPDX-License-Identifier: LGPL-2.1-or-later WITH OCaml-LGPL-linking-exception │ +└─────────────────────────────────────────────────────────────────────────────*) +open Alcotest +open Nixtamal + +let write_file path content = + Eio.Path.with_open_out ~create:(`Or_truncate 0o644) path @@ fun flow -> + Eio.Flow.copy_string content flow + +let setup_workdir ~env name = + let cwd = Eio.Stdenv.cwd env in + let dir = Eio.Path.(cwd / "_build" / "tests" / name) in + ( + match Eio.Path.kind ~follow:true dir with + | `Not_found -> () + | _ -> Eio.Path.rmtree dir + ); + Eio.Path.mkdirs ~perm:0o755 dir; + Working_directory.set ~directory:dir; + dir + +let write_minimal_manifest ~version dir = + let content = Fmt.str {|version "%s" +inputs {} +|} version in + write_file Eio.Path.(dir / Manifest.filename) content + +let suite = + [ + test_case "Lockfile auto-creation from manifest" `Quick (fun () -> + Eio_main.run @@ fun env -> + let dir = setup_workdir ~env "lockfile-auto-create" in + write_minimal_manifest ~version:"0.1.1" dir; + Lockfile.lockfile := None; + let res = read_manifest_and_lockfile () in + check bool "manifest and lock read succeeds" true (Result.is_ok res); + check bool "lockfile was created in memory" true (Option.is_some !Lockfile.lockfile) + ); + test_case "Lockfile write/read roundtrip" `Quick (fun () -> + Eio_main.run @@ fun env -> + let dir = setup_workdir ~env "lockfile-roundtrip" in + write_minimal_manifest ~version:"0.1.1" dir; + let name = Name.Name.make "lockfile-roundtrip-input" in + let input = + Input.make + ~name + ~kind:(Input.make_kind_file ~url:(Input.Template.make "https://example.org/archive.tar.gz") ()) + () + in + ignore (Input_foreman.add name input); + let made = + match Lockfile.make ~version:"0.1.1" () with + | Ok lock -> lock + | Error err -> failf "Failed to make lockfile: %s" (Fmt.str "%a" Error.pp_lockfile_error err) + in + check bool "write succeeds" true (Result.is_ok (Lockfile.write ())); + let reread = + match Lockfile.read () with + | Ok (Some lock) -> lock + | Ok None -> fail "Expected lockfile on disk" + | Error err -> failf "Failed to read lockfile: %s" err + in + check + (testable Lockfile.pp Lockfile.equal) + "serialized lockfile roundtrips" + made + reread + ); + ] diff --git a/test/test_main.ml b/test/test_main.ml index 27e0209..3daefce 100644 --- a/test/test_main.ml +++ b/test/test_main.ml @@ -5,4 +5,7 @@ let () = Alcotest.run "Nixtamal" [ "Input", Test_input.suite; + "Upgrade", Test_upgrade.suite; + "Fossil", Test_fossil.suite; + "Lockfile", Test_lockfile.suite; ] diff --git a/test/test_upgrade.ml b/test/test_upgrade.ml new file mode 100644 index 0000000..c10c647 --- /dev/null +++ b/test/test_upgrade.ml @@ -0,0 +1,113 @@ +(*─────────────────────────────────────────────────────────────────────────────┐ +│ SPDX-FileCopyrightText: 2026 toastal <https://toast.al/contact/> │ +│ SPDX-License-Identifier: LGPL-2.1-or-later WITH OCaml-LGPL-linking-exception │ +└─────────────────────────────────────────────────────────────────────────────*) +open Alcotest +open Nixtamal + +let write_file path content = + Eio.Path.with_open_out ~create:(`Or_truncate 0o644) path @@ fun flow -> + Eio.Flow.copy_string content flow + +let read_file path = + Eio.Path.with_open_in path @@ fun flow -> + let buf = Eio.Buf_read.of_flow flow ~max_size: max_int in + Eio.Buf_read.take_all buf + +let has_substring haystack needle = + try + ignore (Str.search_forward (Str.regexp_string needle) haystack 0); + true + with Not_found -> false + +let setup_workdir ~env name = + let cwd = Eio.Stdenv.cwd env in + let dir = Eio.Path.(cwd / "_build" / "tests" / name) in + ( + match Eio.Path.kind ~follow:true dir with + | `Not_found -> () + | _ -> Eio.Path.rmtree dir + ); + Eio.Path.mkdirs ~perm:0o755 dir; + Working_directory.set ~directory:dir; + dir + +let write_minimal_manifest ~version dir = + let content = Fmt.str {|version "%s" +inputs {} +|} version in + write_file Eio.Path.(dir / Manifest.filename) content + +let write_minimal_lockfile ~version dir = + let content = Fmt.str {|{"v":"%s","i":{}}|} version in + write_file Eio.Path.(dir / Lockfile.filename) content + +let suite = + [ + test_case "Upgrade backup path naming" `Quick (fun () -> + check string "backup extension" "manifest.kdl.bak" (backup_path "manifest.kdl") + ); + test_case "Upgrade dry-run keeps files untouched" `Quick (fun () -> + Eio_main.run @@ fun env -> + let dir = setup_workdir ~env "upgrade-dry-run" in + write_minimal_manifest ~version:"0.1.1" dir; + write_minimal_lockfile ~version:"0.1.1" dir; + Lockfile.lockfile := None; + let res = upgrade ~to_:Schema.Version.V0_2_0 ~dry_run:true () in + check bool "dry-run succeeds" true (Result.is_ok res); + let manifest_content = read_file Eio.Path.(dir / Manifest.filename) in + check bool "manifest stays old version" true (has_substring manifest_content "0.1.1"); + check bool "manifest backup not created" false (Eio.Path.is_file Eio.Path.(dir / backup_path Manifest.filename)); + check bool "lock backup not created" false (Eio.Path.is_file Eio.Path.(dir / backup_path Lockfile.filename)) + ); + test_case "Upgrade rewrites versions and cleans backups" `Quick (fun () -> + Eio_main.run @@ fun env -> + let dir = setup_workdir ~env "upgrade-success" in + write_minimal_manifest ~version:"0.1.1" dir; + write_minimal_lockfile ~version:"0.1.1" dir; + Lockfile.lockfile := None; + let res = upgrade ~to_:Schema.Version.V0_2_0 () in + check bool "upgrade succeeds" true (Result.is_ok res); + let manifest_content = read_file Eio.Path.(dir / Manifest.filename) in + check bool "manifest version upgraded" true (has_substring manifest_content "0.2.0"); + let lock_version = + match Lockfile.read () with + | Ok (Some lock) -> lock.version + | Ok None -> fail "Lockfile missing after upgrade" + | Error err -> failf "Failed to read lockfile: %s" err + in + check string "lockfile version upgraded" "0.2.0" lock_version; + check bool "manifest backup cleaned" false (Eio.Path.is_file Eio.Path.(dir / backup_path Manifest.filename)) + ); + test_case "Upgrade aborts on version mismatch" `Quick (fun () -> + Eio_main.run @@ fun env -> + let dir = setup_workdir ~env "upgrade-version-mismatch" in + write_minimal_manifest ~version:"0.1.1" dir; + write_minimal_lockfile ~version:"0.1.1" dir; + Lockfile.lockfile := None; + let res = upgrade ~from:Schema.Version.V0_2_0 ~to_:Schema.Version.V0_2_0 () in + check bool "upgrade fails" true (Result.is_error res); + let lock_version = + match Lockfile.read () with + | Ok (Some lock) -> lock.version + | _ -> fail "Lockfile should still be present after abort" + in + check string "lockfile unchanged" "0.1.1" lock_version; + check bool "manifest backup not created" false (Eio.Path.is_file Eio.Path.(dir / backup_path Manifest.filename)); + check bool "lockfile backup not created" false (Eio.Path.is_file Eio.Path.(dir / backup_path Lockfile.filename)) + ); + test_case "Upgrade detects unknown manifest version" `Quick (fun () -> + Eio_main.run @@ fun env -> + let dir = setup_workdir ~env "upgrade-version-detect" in + write_minimal_manifest ~version:"9.9.9" dir; + write_minimal_lockfile ~version:"0.1.1" dir; + Lockfile.lockfile := None; + let res = upgrade () in + check bool + "unknown schema version is rejected" + true + (match res with + | Error (`Upgrade msg) -> String.length msg > 0 + | _ -> false) + ); + ] |
