summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/coverage.yml35
-rw-r--r--.gitignore2
-rw-r--r--AGENTS.md8
-rw-r--r--README.asciidoc7
-rw-r--r--bisect.yml3
-rw-r--r--dune-project11
-rw-r--r--flake.nix30
-rw-r--r--lib/dune2
-rw-r--r--nix/package/nixtamal.nix1
-rw-r--r--nixtamal.opam3
-rw-r--r--test/dune2
-rw-r--r--test/test_fossil.ml76
-rw-r--r--test/test_lockfile.ml71
-rw-r--r--test/test_main.ml3
-rw-r--r--test/test_upgrade.ml113
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
diff --git a/.gitignore b/.gitignore
index 2e975c9..741e1a0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,4 @@
result
llm/
+_build/
+_coverage/
diff --git a/AGENTS.md b/AGENTS.md
index 6e2a2f6..c3c0acd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/flake.nix b/flake.nix
index 115a9d1..e603e31 100644
--- a/flake.nix
+++ b/flake.nix
@@ -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
+}
diff --git a/lib/dune b/lib/dune
index 3d47618..f1c788b 100644
--- a/lib/dune
+++ b/lib/dune
@@ -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: [
diff --git a/test/dune b/test/dune
index 563fb44..d934086 100644
--- a/test/dune
+++ b/test/dune
@@ -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)
+ );
+ ]