From 1e92a5d35b5c3720ecf5694da4720ff5e8b55e48 Mon Sep 17 00:00:00 2001 From: Coca Date: Sun, 24 May 2026 12:18:07 +0200 Subject: [PATCH] Migrate to bpaf command line parsing and completions --- Cargo.lock | 125 +--- Cargo.toml | 5 +- README.md | 312 ++++----- README.md.in | 40 +- completions/Cargo.toml | 10 - completions/pin-completions.fish | 49 -- completions/src/main.rs | 18 - libnpins/src/lib.rs | 1 + npins.nix | 20 +- src/lib.rs | 1 - src/main.rs | 154 ++--- src/opts.rs | 1115 +++++++++++++++++++++--------- test.nix | 3 +- 13 files changed, 1048 insertions(+), 805 deletions(-) delete mode 100644 completions/Cargo.toml delete mode 100644 completions/pin-completions.fish delete mode 100644 completions/src/main.rs delete mode 100644 src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 0e82110..509858c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,21 +11,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "anstream" -version = "0.6.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" -dependencies = [ - "anstyle", - "anstyle-parse 0.2.7", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - [[package]] name = "anstream" version = "1.0.0" @@ -33,7 +18,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", - "anstyle-parse 1.0.0", + "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", @@ -47,15 +32,6 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - [[package]] name = "anstyle-parse" version = "1.0.0" @@ -157,6 +133,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bpaf" +version = "0.9.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b86829876e7e200161a5aa6ea688d46c32d64d70ee3100127790dde84688d6e" +dependencies = [ + "owo-colors", + "supports-color", +] + [[package]] name = "bstr" version = "1.12.1" @@ -204,55 +190,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" -[[package]] -name = "clap" -version = "4.5.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2df961d8c8a0d08aa9945718ccf584145eee3f3aa06cddbeac12933781102e04" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "132dbda40fb6753878316a489d5a1242a8ef2f0d9e47ba01c951ea8aa7d013a5" -dependencies = [ - "anstream 0.6.21", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_complete" -version = "4.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" -dependencies = [ - "clap", -] - -[[package]] -name = "clap_derive" -version = "4.5.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "0.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" - [[package]] name = "cmake" version = "0.1.58" @@ -484,7 +421,7 @@ version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ - "anstream 1.0.0", + "anstream", "anstyle", "env_filter", "log", @@ -609,12 +546,6 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - [[package]] name = "http" version = "1.4.0" @@ -837,6 +768,12 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1079,7 +1016,7 @@ name = "npins" version = "0.5.0" dependencies = [ "anyhow", - "clap", + "bpaf", "crossterm", "env_logger", "futures-util", @@ -1090,15 +1027,6 @@ dependencies = [ "url", ] -[[package]] -name = "npins-completions" -version = "0.5.0" -dependencies = [ - "clap", - "clap_complete", - "npins", -] - [[package]] name = "num_enum" version = "0.7.6" @@ -1139,6 +1067,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1746,6 +1680,15 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + [[package]] name = "syn" version = "2.0.117" diff --git a/Cargo.toml b/Cargo.toml index 86b4656..913b4c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["libnpins", "completions"] +members = ["libnpins"] [workspace.dependencies] serde_json = { version = "^1.0", features = ["preserve_order"] } @@ -31,8 +31,7 @@ log.workspace = true tokio = { workspace = true, features = ["rt"] } libnpins = { path = "libnpins" } -# Pin clap because the latest patch has a regression in the help output -clap.workspace = true futures-util = { version = "0.3.32", default-features = false } crossterm = { version = "0.29", default-features = false } env_logger = { version = "^0.11.0", features = ["color", "auto-color", "regex"], default-features = false } +bpaf = { version = "0.9.26", features = ["autocomplete", "dull-color", "docgen"] } diff --git a/README.md b/README.md index 0a089e2..1f9eea5 100644 --- a/README.md +++ b/README.md @@ -100,30 +100,39 @@ You may also use attributes from the JSON file, they are exposed 1:1. For exampl ## Usage ```console -$ npins help -Usage: npins [OPTIONS] - -Commands: - init Initializes the npins directory. Running this multiple times will restore/upgrade the `default.nix` and never touch your sources.json - add Adds a new pin entry - show Lists the current pin entries - update Updates all or the given pins to the latest version - verify Verifies that all or the given pins still have correct hashes. This is like `update --partial --dry-run` and then checking that the diff is empty - upgrade Upgrade the sources.json and default.nix to the latest format version. This may occasionally break Nix evaluation! - remove Removes one pin entry - import-niv Try to import entries from Niv - import-flake Try to import entries from flake.lock - freeze Freezes a pin entry, preventing it from being changed during an update - unfreeze Thaws a pin entry, allowing it to be changed during an update like a normal pin - get-path Evaluates the store path to a pin, fetching it if necessary. Don't forget to add a GC root - help Print this message or the help of the given subcommand(s) - -Options: - -d, --directory Base folder for sources.json and the boilerplate default.nix [env: NPINS_DIRECTORY=] [default: npins] - --lock-file Specifies the path to the sources.json and activates lockfile mode. In lockfile mode, no default.nix will be generated and --directory will be ignored - -v, --verbose Print debug messages - -h, --help Print help - -V, --version Print version +$ npins --help +Simple and convenient dependency pinning for Nix. All options are available in subcommands. + +Usage: npins ([-d=FOLDER] | --lock-file=FILE) [-v] COMMAND ... + +Available options: + -d, --directory=FOLDER Specifies base folder for sources.json and the boilerplate default.nix + [env:NPINS_DIRECTORY: N/A] + [default: npins] + --lock-file=FILE Specifies the lockfile and operates only on it (lockfile mode) + -v, --verbose Prints debug messages + -h, --help Prints help information + -V, --version Prints version information + +Available commands: + init Intializes the npins directory. Running this multiple times will + restore/upgrade the `default.nix` and never touch your sources.json + add Adds a new pin entry. + show Lists the current pin entries + update Updates all or the given pins to the latest version + verify Verifies that all or the given pins still have correct hashes. This is + like `update --partial --dry-run` and then checking that the diff is + empty + upgrade Upgrade the sources.json and default.nix to the latest format version. + This may occasionally break Nix evaluation! + remove Remove pin entries + import-niv Try to import entries from Niv + import-flake Try to import entries from flake.lock + freeze Freezes a pin entry, preventing it from being changed during an update + unfreeze Thaws a pin entry, allowing it to be changed during an update like a + normal pin + get-path Evaluates the store path to a pin, fetching it if necessary. Don't + forget to add a GC root ``` ### Initialization @@ -137,15 +146,15 @@ npins init This will create an `npins` folder with a `default.nix` and `sources.json` within. By default, the `nixpkgs-unstable` channel will be added as pin. ```console -$ npins help init -Initializes the npins directory. Running this multiple times will restore/upgrade the `default.nix` and never touch your sources.json +$ npins init --help +Intializes the npins directory. Running this multiple times will restore/upgrade the `default.nix` +and never touch your sources.json -Usage: npins init [OPTIONS] +Usage: npins init [--bare] -Options: - --bare Don't add an initial `nixpkgs` entry - -v, --verbose Print debug messages - -h, --help Print help +Available options: + --bare Don't add an initial `nixpkgs` entry + -h, --help Prints help information ``` ### Migrate from Niv @@ -162,18 +171,16 @@ In your Nix configuration, simply replace `import ./nix/sources.nix` with `impor Note that the import functionality is minimal and only preserves the necessary information to identify the dependency, but not the actual pinned values themselves. Therefore, migrating must always come with an update (unless you do it manually). ```console -$ npins help import-niv +$ npins import-niv --help Try to import entries from Niv -Usage: npins import-niv [OPTIONS] [PATH] +Usage: npins import-niv [-n=NAME] [FILE] -Arguments: - [PATH] [default: nix/sources.json] +Available positional items: -Options: - -n, --name Only import one entry from Niv - -v, --verbose Print debug messages - -h, --help Print help +Available options: + -n, --name=NAME Only import one entry from Niv + -h, --help Prints help information ``` ### Adding dependencies @@ -198,83 +205,57 @@ npins add pypi streamlit --upper-bound 2.0.0 # We only want 1.X Depending on what kind of dependency you are adding, different arguments must be provided. You always have the option to specify a version (or hash, depending on the type) you want to pin to. Otherwise, the latest available version will be fetched for you. Not all features are present on all pin types. ```console -$ npins help add -Adds a new pin entry - -Usage: npins add [OPTIONS] - -Commands: - channel Track a Nix channel - github Track a GitHub repository - forgejo Track a Forgejo repository - gitlab Track a GitLab repository - git Track a git repository - pypi Track a package on PyPi - container Track an OCI container - tarball Track a tarball - url Track a URL - help Print this message or the help of the given subcommand(s) - -Options: - --name Add the pin with a custom name. If a pin with that name already exists, it will be overwritten - --frozen Add the pin as frozen, meaning that it will be ignored by `npins update` by default - -n, --dry-run Don't actually apply the changes - -v, --verbose Print debug messages - -h, --help Print help +$ npins add --help +Adds a new pin entry. + +Usage: npins add [--name=NAME] [--frozen] [-n] COMMAND ... + +Available options: + --name=NAME Add the pin with a custom name. If a pin with that name already exists, it will + be overwritten + --frozen Add the pin as frozen, meaning that it will be ignored by `npins update` by + default. + -n, --dry-run Don't actually apply the changes + -h, --help Prints help information + +Available commands: + channel Track a Nix channel + github Track a GitHub repository + forgejo Track a Forgejo repository + gitlab Track a GitLab repository + git Track a git repository + pypi Track a package on PyPi + container Track an OCI container + tarball Track a URL + url Track a URL ``` There are several options for tracking git branches, releases and tags: ```console -$ npins help add git +$ npins add git --help Track a git repository -Usage: npins add git [OPTIONS] - -Arguments: - - The git remote URL. For example - -Options: - --forge - [default: auto] - - Possible values: - - none: A generic git pin, with no further information - - auto: Try to determine the Forge from the given url, potentially by probing the server - - gitlab: A Gitlab forge, e.g. gitlab.com - - github: A Github forge, i.e. github.com - - forgejo: A Forgejo forge, e.g. codeberg.org - - --name - Add the pin with a custom name. If a pin with that name already exists, it will be overwritten - - -b, --branch - Track a branch instead of a release - - --frozen - Add the pin as frozen, meaning that it will be ignored by `npins update` by default - - --at - Use a specific commit/release instead of the latest. This may be a tag name, or a git revision when --branch is set - - -v, --verbose - Print debug messages - - --pre-releases - Also track pre-releases. Conflicts with the --branch option - - --upper-bound - Bound the version resolution. For example, setting this to "2" will restrict updates to 1.X versions. Conflicts with the --branch option - - --release-prefix - Optional prefix required for each release name / tag. For example, setting this to "release/" will only consider those that start with that string - - --submodules - Also fetch submodules - - -h, --help - Print help (see a summary with '-h') +Usage: npins add git [--at=] [--submodules] (-b=BRANCH | [--pre-releases] [--upper-bound +=VERSION] [--release-prefix=VERSION]) [--forge=FORGE] URL + +Available positional items: + URL The git remote URL. For example + +Available options: + --at= Use a specific commit/release instead of the latest. This may be a tag + name, or a git revision when --branch is set. + --submodules Also fetch submodules + -b, --branch=BRANCH Track a branch instead of a release + --pre-releases Also track pre-releases. Conflicts with the --branch option. + --upper-bound=VERSION Bound the version resolution. For example, setting this to "2" will + restrict updates to 1.X versions. + --release-prefix=VERSION Optional prefix required for each release name / tag. For example, + setting this to "release/" will only consider those that start with that + string. + --forge=FORGE + [default: auto] + -h, --help Prints help information ``` Npins can track plain old links to URL resources. They will never update. @@ -283,48 +264,37 @@ Alternatively, you can also add the `--mutable` flag to make them behave similar Npins will follow any redirects and then pin that url as the actual version, while keeping the original url as "update" url. ```console -$ npins help add tarball -Track a tarball - -This can be either a static URL that never changes its contents or a "mutable" URL that redirects to an immutable snapshot. - -Usage: npins add tarball [OPTIONS] +$ npins add tarball --help +Track a URL -Arguments: - - Tarball URL +Usage: npins add tarball [--mutable] URL -Options: - --mutable - Treat this URL as mutable, and assume it will redirect to an immutable version of the content to be pinned. For example, a HEAD URL redirecting to the currently latest commit +This can be either a static URL that never changes its contents or a "mutable" URL that redirects to +an immutable snapshot. - --name - Add the pin with a custom name. If a pin with that name already exists, it will be overwritten +Available positional items: + URL Tarball URL - --frozen - Add the pin as frozen, meaning that it will be ignored by `npins update` by default - - -v, --verbose - Print debug messages - - -h, --help - Print help (see a summary with '-h') +Available options: + --mutable Treat this URL as mutable, and assume it will redirect to an immutable version of + the content to be pinned. For example, a HEAD URL redirecting to the currently + latest commit + -h, --help Prints help information ``` ### Removing dependencies ```console -$ npins help remove -Removes one pin entry +$ npins remove --help +Remove pin entries -Usage: npins remove [OPTIONS] ... +Usage: npins remove NAMES... -Arguments: - ... +Available positional items: + NAMES Names of the pins to remove -Options: - -v, --verbose Print debug messages - -h, --help Print help +Available options: + -h, --help Prints help information ``` ### Show current entries @@ -332,19 +302,18 @@ Options: This will print the currently pinned dependencies in a human readable format. The machine readable `sources.json` may be accessed directly, but make sure to always check the format version (see below). ```console -$ npins help show +$ npins show --help Lists the current pin entries -Usage: npins show [OPTIONS] [NAMES]... +Usage: npins show [-b] [-e] [NAMES]... -Arguments: - [NAMES]... Names of the pins to show +Available positional items: + NAMES Names of the pins to show -Options: - -p, --plain Prints only pin names - -e, --exclude Invert [NAMES] to exclude specified pins - -v, --verbose Print debug messages - -h, --help Print help +Available options: + -b, --plain Prints only pin names + -e, --exclude Prints all the pins not specified + -h, --help Prints help information ``` ### Updating dependencies @@ -352,29 +321,22 @@ Options: You can decide to update only selected dependencies, or all at once. For some pin types, we distinguish between "find out the latest version" and "fetch the latest version". These can be controlled with the `--full` and `--partial` flags. ```console -$ npins help update +$ npins update --help Updates all or the given pins to the latest version -Usage: npins update [OPTIONS] [NAMES]... - -Arguments: - [NAMES]... Updates only the specified pins - -Options: - -p, --partial - Don't update versions, only re-fetch hashes - -f, --full - Re-fetch hashes even if the version hasn't changed. Useful to make sure the derivations are in the Nix store - -n, --dry-run - Print the diff, but don't write back the changes - -v, --verbose - Print debug messages - --frozen - Allow updating frozen pins, which would otherwise be ignored - --max-concurrent-downloads - Maximum number of simultaneous downloads [default: 5] - -h, --help - Print help +Usage: npins update (-f | -p) [-n] [--frozen] [--max-concurrent-downloads=NUM] [NAMES]... + +Available positional items: + NAMES Updates only the specified pins + +Available options: + -f, --full Re-fetch hashes even if the version hasn't changed. Useful to make sure the + derivations are in the Nix store. + -p, --partial Don't update versions, only re-fetch hashes + -n, --dry-run Print the diff, but don't write back the changes + --frozen Allow updating frozen pins, which would otherwise be ignored + --max-concurrent-downloads=NUM Maximum number of simultaneous downloads + -h, --help Prints help information ``` ### Upgrading the pins file @@ -382,14 +344,14 @@ Options: To ensure compatibility across releases, the `npins/sources.json` and `npins/default.nix` are versioned. Whenever the format changes (i.e. because new pin types are added), the version number is increased. Use `npins upgrade` to automatically apply the necessary changes to the `sources.json` and to replace the `default.nix` with one for the current version. No stability guarantees are made on the Nix side across versions. ```console -$ npins help upgrade -Upgrade the sources.json and default.nix to the latest format version. This may occasionally break Nix evaluation! +$ npins upgrade --help +Upgrade the sources.json and default.nix to the latest format version. This may occasionally break +Nix evaluation! -Usage: npins upgrade [OPTIONS] +Usage: npins upgrade -Options: - -v, --verbose Print debug messages - -h, --help Print help +Available options: + -h, --help Prints help information ``` ### Using private GitLab repositories diff --git a/README.md.in b/README.md.in index de8b3b3..5f5ff2e 100644 --- a/README.md.in +++ b/README.md.in @@ -100,8 +100,8 @@ You may also use attributes from the JSON file, they are exposed 1:1. For exampl ## Usage ```console -$ npins help -{{npins help}} +$ npins --help +{{npins --help}} ``` ### Initialization @@ -115,8 +115,8 @@ npins init This will create an `npins` folder with a `default.nix` and `sources.json` within. By default, the `nixpkgs-unstable` channel will be added as pin. ```console -$ npins help init -{{npins help init}} +$ npins init --help +{{npins init --help}} ``` ### Migrate from Niv @@ -133,8 +133,8 @@ In your Nix configuration, simply replace `import ./nix/sources.nix` with `impor Note that the import functionality is minimal and only preserves the necessary information to identify the dependency, but not the actual pinned values themselves. Therefore, migrating must always come with an update (unless you do it manually). ```console -$ npins help import-niv -{{npins help import-niv}} +$ npins import-niv --help +{{npins import-niv --help}} ``` ### Adding dependencies @@ -159,15 +159,15 @@ npins add pypi streamlit --upper-bound 2.0.0 # We only want 1.X Depending on what kind of dependency you are adding, different arguments must be provided. You always have the option to specify a version (or hash, depending on the type) you want to pin to. Otherwise, the latest available version will be fetched for you. Not all features are present on all pin types. ```console -$ npins help add -{{npins help add}} +$ npins add --help +{{npins add --help}} ``` There are several options for tracking git branches, releases and tags: ```console -$ npins help add git -{{npins help add git}} +$ npins add git --help +{{npins add git --help}} ``` Npins can track plain old links to URL resources. They will never update. @@ -176,15 +176,15 @@ Alternatively, you can also add the `--mutable` flag to make them behave similar Npins will follow any redirects and then pin that url as the actual version, while keeping the original url as "update" url. ```console -$ npins help add tarball -{{npins help add tarball}} +$ npins add tarball --help +{{npins add tarball --help}} ``` ### Removing dependencies ```console -$ npins help remove -{{npins help remove}} +$ npins remove --help +{{npins remove --help}} ``` ### Show current entries @@ -192,8 +192,8 @@ $ npins help remove This will print the currently pinned dependencies in a human readable format. The machine readable `sources.json` may be accessed directly, but make sure to always check the format version (see below). ```console -$ npins help show -{{npins help show}} +$ npins show --help +{{npins show --help}} ``` ### Updating dependencies @@ -201,8 +201,8 @@ $ npins help show You can decide to update only selected dependencies, or all at once. For some pin types, we distinguish between "find out the latest version" and "fetch the latest version". These can be controlled with the `--full` and `--partial` flags. ```console -$ npins help update -{{npins help update}} +$ npins update --help +{{npins update --help}} ``` ### Upgrading the pins file @@ -210,8 +210,8 @@ $ npins help update To ensure compatibility across releases, the `npins/sources.json` and `npins/default.nix` are versioned. Whenever the format changes (i.e. because new pin types are added), the version number is increased. Use `npins upgrade` to automatically apply the necessary changes to the `sources.json` and to replace the `default.nix` with one for the current version. No stability guarantees are made on the Nix side across versions. ```console -$ npins help upgrade -{{npins help upgrade}} +$ npins upgrade --help +{{npins upgrade --help}} ``` ### Using private GitLab repositories diff --git a/completions/Cargo.toml b/completions/Cargo.toml deleted file mode 100644 index da87524..0000000 --- a/completions/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "npins-completions" -version.workspace = true -edition.workspace = true -license.workspace = true - -[dependencies] -clap.workspace = true -clap_complete = "4.6.3" -npins = { path = "../" } diff --git a/completions/pin-completions.fish b/completions/pin-completions.fish deleted file mode 100644 index f31f034..0000000 --- a/completions/pin-completions.fish +++ /dev/null @@ -1,49 +0,0 @@ - -# Custom pin completions appended from here - -# Our way of calling npins in fish while accounting for the lockfile/directory this completion is on -function __fish_npins - set -l saved_args $argv - set -l cmd (commandline -xpc) - set -e cmd[1] - argparse -s (__fish_npins_global_optspecs) -- $cmd 2>/dev/null; or return - set -q _flag_lock_file; and set -l lockfile "--lock-file" $_flag_lock_file - set -q _flag_directory; and set -l directory "--directory" $_flag_directory - command npins $lockfile $directory $saved_args 2>/dev/null -end - -# Provide completions for a single pin -function __fish_npins_pin_single - # In options we need to have a empty description to override - # the option's description which we inherit for some reason - set -l joined "$(__fish_npins show -p | string join \t\n)" - if test -n $joined - echo "$joined"\t - end -end - -# Provide completions for multiple pins, excluding already provided pins -function __fish_npins_pin_list - set -l cmd (commandline -xpc) - set -e cmd[1] - # Remove out options and only keep the pin arguments - # for example - # update --dry-run nixpkgs --full home-manager - # turns into - # update nixpkgs home-manager - argparse --unknown-arguments=none (__fish_npins_global_optspecs) -- $cmd 2>/dev/null; or return - __fish_npins show -p -e $argv[2..] -end - -# --name for all npins add subcommands and for npins add itself -complete -c npins -n "__fish_npins_using_subcommand add; and not __fish_seen_subcommand_from help" -l name -x -a '(__fish_npins_pin_single)' - -# Commands which can be provided a pin list -complete -c npins -n "__fish_npins_using_subcommand show" -f -a '(__fish_npins_pin_list)' -complete -c npins -n "__fish_npins_using_subcommand update" -f -a '(__fish_npins_pin_list)' -complete -c npins -n "__fish_npins_using_subcommand verify" -f -a '(__fish_npins_pin_list)' - -# Commands which require a pin list -complete -c npins -n "__fish_npins_using_subcommand remove" -x -a '(__fish_npins_pin_list)' -complete -c npins -n "__fish_npins_using_subcommand freeze" -x -a '(__fish_npins_pin_list)' -complete -c npins -n "__fish_npins_using_subcommand unfreeze" -x -a '(__fish_npins_pin_list)' diff --git a/completions/src/main.rs b/completions/src/main.rs deleted file mode 100644 index e53db90..0000000 --- a/completions/src/main.rs +++ /dev/null @@ -1,18 +0,0 @@ -use std::{env, io::stdout}; - -use clap::CommandFactory; -use clap_complete::{Shell, generate}; - -fn main() { - let mut cmd = npins::opts::Opts::command(); - let mut out = stdout().lock(); - - let shell = env::args() - .nth(1) - .expect("Expected at least one argument for the shell") - .as_str() - .parse::() - .expect("Argument was not a valid shell"); - - generate(shell, &mut cmd, "npins", &mut out) -} diff --git a/libnpins/src/lib.rs b/libnpins/src/lib.rs index 9ee2083..baa4450 100644 --- a/libnpins/src/lib.rs +++ b/libnpins/src/lib.rs @@ -20,6 +20,7 @@ pub mod niv; pub mod nix; pub mod versions; +/// The contents of the `default.nix` file we provide in npins directories pub const DEFAULT_NIX: &str = include_str!("default.nix"); /// Helper method to build you a client. diff --git a/npins.nix b/npins.nix index 1121d22..08720a1 100644 --- a/npins.nix +++ b/npins.nix @@ -23,11 +23,6 @@ let "^/libnpins/src$" "^/libnpins/src/.+$" "^/libnpins/Cargo.toml$" - "^/completions$" - "^/completions/src$" - "^/completions/src/.+$" - "^/completions/Cargo.toml$" - "^/completions/pin-completions.fish$" ]; extractSource = @@ -68,13 +63,6 @@ let inherit src; - cargoBuildFlags = [ - "-p" - "npins" - "-p" - "npins-completions" - ]; - nativeBuildInputs = [ makeWrapper installShellFiles @@ -85,11 +73,9 @@ let postFixup = '' installShellCompletion --cmd npins \ - --bash <($out/bin/npins-completions bash) \ - --fish <(cat <($out/bin/npins-completions fish) $src/completions/pin-completions.fish) \ - --zsh <($out/bin/npins-completions zsh) - - rm $out/bin/npins-completions + --bash <($out/bin/npins --bpaf-complete-style-bash) \ + --fish <($out/bin/npins --bpaf-complete-style-fish) \ + --zsh <($out/bin/npins --bpaf-complete-style-zsh) wrapProgram $out/bin/npins --prefix PATH : "${runtimePath}" ''; diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 9d1c999..0000000 --- a/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod opts; diff --git a/src/main.rs b/src/main.rs index 3cddfbb..5126e56 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,6 @@ //! The main CLI application use anyhow::{Context, Result}; -use clap::Parser; use crossterm::{ QueueableCommand, cursor::MoveToPreviousLine, @@ -18,6 +17,7 @@ use std::{ fs::File, future, io::{BufReader, IsTerminal, Write, stderr}, + path::Path, }; use url::{ParseError, Url}; @@ -65,8 +65,8 @@ impl ChannelAddOpts { impl GenericGitAddOpts { fn add(&self, repository: git::Repository) -> Result { - Ok(match &self.branch { - Some(branch) => { + Ok(match &self.selected { + GitAddSelection::Branch { branch } => { let pin = git::GitPin::new(repository, branch.clone(), self.submodules); let version = self .at @@ -75,12 +75,16 @@ impl GenericGitAddOpts { .transpose()?; (pin, version).into() }, - None => { + GitAddSelection::Release { + pre_releases, + version_upper_bound, + release_prefix, + } => { let pin = git::GitReleasePin::new( repository, - self.pre_releases, - self.version_upper_bound.clone(), - self.release_prefix.clone(), + *pre_releases, + version_upper_bound.clone(), + release_prefix.clone(), self.submodules, ); let version = self.at.as_ref().map(|at| GenericVersion { @@ -133,20 +137,7 @@ impl GitLabAddOpts { impl GitAddOpts { pub async fn add(&self) -> Result<(Option, Pin)> { - let url = Url::parse(&self.url) - .map_err(|e| { - match e { - url::ParseError::RelativeUrlWithoutBase => { - anyhow::format_err!("URL scheme is missing. For git URLs, add the fully qualified scheme like git+ssh://. For local repositories, add file://") - }, - url::ParseError::InvalidPort => { - anyhow::format_err!("Invalid port number. For git URLs, try inserting a '/' after the ':' before the user name, like so: git+ssh://git@gitlab-instance.net:/user/repo.git") - }, - e => e.into(), - } - }) - .context("Failed to parse repository URL")?; - + let url = self.url.clone(); if url.scheme().contains('.') { log::warn!( "Your URL scheme ('{}:') contains a '.', which is unusual. Please double-check its correctness.", @@ -209,18 +200,6 @@ impl ContainerAddOpts { } } -impl TarballAddOpts { - pub async fn add(&self) -> Result<(Option, Pin)> { - // Delegate to `UrlAddOpts` - UrlAddOpts { - url: self.url.clone(), - mutable: self.mutable, - } - .add(true) - .await - } -} - impl UrlAddOpts { pub async fn add(&self, unpack: bool) -> Result<(Option, Pin)> { let pin: Pin = if self.mutable { @@ -249,7 +228,7 @@ impl AddOpts { AddCommands::Forgejo(fg) => fg.add()?, AddCommands::GitLab(gl) => gl.add()?, AddCommands::PyPi(p) => p.add()?, - AddCommands::Tarball(p) => p.add().await?, + AddCommands::Tarball(p) => p.add(true).await?, AddCommands::Url(p) => p.add(false).await?, AddCommands::Container(p) => p.add()?, }; @@ -282,33 +261,25 @@ fn write_diff(writer: &mut impl Write, name: &str, diff: &[diff::DiffEntry]) { } } +pub fn read_pins(path: &Path) -> Result { + let fh = BufReader::new(File::open(path).with_context(move || { + format!( + "Failed to open {}. You must initialize npins before you can show current pins.", + path.display() + ) + })?); + NixPins::from_json_versioned(serde_json::from_reader(fh)?) + .context("Failed to deserialize sources.json") +} + impl Opts { fn read_pins(&self) -> Result { - let path = if let Some(lock_file) = self.lock_file.as_ref() { - lock_file.to_owned() - } else { - self.folder.join("sources.json") - }; - let fh = BufReader::new(File::open(&path).with_context(move || { - format!( - "Failed to open {}. You must initialize npins before you can show current pins.", - path.display() - ) - })?); - NixPins::from_json_versioned(serde_json::from_reader(fh)?) - .context("Failed to deserialize sources.json") + read_pins(self.mode.lockfile()) } fn write_pins(&self, pins: &NixPins) -> Result<()> { - let path = if let Some(lock_file) = &self.lock_file { - lock_file.to_owned() - } else { - if !self.folder.exists() { - std::fs::create_dir(&self.folder)?; - } - self.folder.join("sources.json") - }; - let mut fh = File::create(&path) + let path = self.mode.lockfile(); + let mut fh = File::create(path) .with_context(move || format!("Failed to open {} for writing.", path.display()))?; serde_json::to_writer_pretty(&mut fh, &pins.to_value_versioned())?; fh.write_all(b"\n")?; @@ -318,22 +289,22 @@ impl Opts { async fn init(&self, o: &InitOpts) -> Result<()> { log::info!("Welcome to npins!"); - // Skip the entire default.nix and convenience creating folders bit in lockfile mode - if self.lock_file.is_none() { - let default_nix = DEFAULT_NIX; - if !self.folder.exists() { - log::info!("Creating `{}` directory", self.folder.display()); - std::fs::create_dir(&self.folder).context("Failed to create npins folder")?; + if let SourceMode::Directory { + default_nix, + directory, + .. + } = &self.mode + { + if !directory.exists() { + log::info!("Creating `{}` directory", directory.display()); + std::fs::create_dir(directory).context("Failed to create npins folder")?; } log::info!("Writing default.nix"); - let p = self.folder.join("default.nix"); - let mut fh = File::create(&p).context("Failed to create npins default.nix")?; - fh.write_all(default_nix.as_bytes())?; + let mut fh = File::create(default_nix).context("Failed to create npins default.nix")?; + fh.write_all(DEFAULT_NIX.as_bytes())?; } - let sources_json = self.folder.join("sources.json"); - let path = self.lock_file.as_ref().unwrap_or(&sources_json); - // Only create the pins if the file isn't there yet + let path = self.mode.lockfile(); if path.exists() { log::info!( "The file '{}' already exists; nothing to do.", @@ -362,10 +333,7 @@ impl Opts { self.write_pins(&initial_pins)?; log::info!( "Successfully written initial files to '{}'.", - self.lock_file - .as_ref() - .unwrap_or(&self.folder.join("sources.json")) - .display() + path.display() ); Ok(()) } @@ -508,13 +476,6 @@ impl Opts { return Err(anyhow::anyhow!("no valid pin selected for update")); } - let strategy = match (opts.partial, opts.full) { - (false, false) => UpdateStrategy::Normal, - (false, true) => UpdateStrategy::Full, - (true, false) => UpdateStrategy::HashesOnly, - (true, true) => panic!("partial and full are mutually exclusive"), - }; - let animation = Animation::new(|stderr, finished| { write!(stderr, "Updated {finished}/{length} pins").unwrap() }); @@ -529,7 +490,7 @@ impl Opts { }) .map(|(name, pin)| async move { animation.on_pin_start(name); - let diff = Self::update_one(name, pin, strategy).await?; + let diff = Self::update_one(name, pin, opts.strategy).await?; animation.on_pin_finish(name, |stderr| write_diff(stderr, name, &diff)); anyhow::Result::<_, anyhow::Error>::Ok((name, diff)) }); @@ -660,27 +621,29 @@ impl Opts { } fn upgrade(&self) -> Result<()> { - if self.lock_file.is_none() { + if let SourceMode::Directory { + default_nix, + directory, + .. + } = &self.mode + { anyhow::ensure!( - self.folder.exists(), + directory.exists(), "Could not find npins folder at {}", - self.folder.display(), + directory.display(), ); - let nix_path = self.folder.join("default.nix"); - let nix_file = DEFAULT_NIX; - if std::fs::read_to_string(&nix_path)? == nix_file { + if std::fs::read_to_string(default_nix)? == DEFAULT_NIX { log::info!("default.nix is already up to date"); } else { log::info!("Replacing default.nix with an up to date version"); - std::fs::write(&nix_path, nix_file) + std::fs::write(default_nix, DEFAULT_NIX) .context("Failed to create npins default.nix")?; } } log::info!("Upgrading lock file to the newest format version"); - let sources_json = self.folder.join("sources.json"); - let path = self.lock_file.as_ref().unwrap_or(&sources_json); + let path = self.mode.lockfile(); let fh = BufReader::new(File::open(path).with_context(move || { format!( "Failed to open {}. You must initialize npins first.", @@ -926,11 +889,7 @@ impl Opts { /* Although redundant, we still parse the lock file here for better error messages */ self.read_pins()?; - let path = self - .lock_file - .to_owned() - .unwrap_or(self.folder.join("sources.json")); - let out_path = nix::nix_eval_pin(&path, &o.name) + let out_path = nix::nix_eval_pin(self.mode.lockfile(), &o.name) .await .context("Could not evaluate pin")?; /* note(piegames): HMU if you ever find yourself using npins on Windows */ @@ -942,11 +901,6 @@ impl Opts { } pub fn run(&self) -> Result<()> { - if self.lock_file.is_some() && &*self.folder != std::path::Path::new("npins") { - anyhow::bail!( - "If --lock-file is set, --directory will be ignored and thus should not be set to a non-default value (which is \"npins\")" - ); - } match &self.command { Command::Init(o) => start_runtime(self.init(o))?, Command::Show(o) => self.show(o)?, @@ -1054,7 +1008,7 @@ impl<'a, F: for<'b> Fn(&'b mut std::io::StderrLock, i32)> Animation<'a, F> { } fn main() -> Result<()> { - let opts = Opts::parse(); + let opts = crate::Opts::parser().run(); env_logger::builder() .filter_level(if opts.verbose { diff --git a/src/opts.rs b/src/opts.rs index aaa3988..f832bd9 100644 --- a/src/opts.rs +++ b/src/opts.rs @@ -1,8 +1,89 @@ -use clap::{Parser, Subcommand, ValueEnum, ValueHint}; +//! Contains the entire command line interface for npins +//! including the parsing and completions for it, ordered +//! roughly from top to bottom twice for both the types +//! and the implementations on them + +use anyhow::Context; +use bpaf::{OptionParser, Parser, ShellComp, construct, long, positional, pure, short}; +use core::{cell::OnceCell, convert::Infallible, fmt::Display}; use libnpins::channel; -use std::path::PathBuf; +use std::{ + path::{Path, PathBuf}, + rc::Rc, +}; use url::Url; +#[derive(Debug)] +pub struct Opts { + pub mode: SourceMode, + pub verbose: bool, + + pub command: Command, +} + +/// Type of file structures we support operating on +#[derive(Debug)] +pub enum SourceMode { + /// Represents a initalized npins which has our `default.nix` and `sources.json` + Directory { + sources: PathBuf, + default_nix: PathBuf, + directory: PathBuf, + }, + /// Represents the path to a npins lockfile with nothing else that is managed by us + Lockfile(PathBuf), +} + +#[derive(Debug)] +pub enum Command { + Init(InitOpts), + // Boxing AddOpts as it is by far our largest structure, reduces + // memory requirements for smaller devices (even if marginal) + Add(Box), + Show(ShowOpts), + Update(UpdateOpts), + Verify(VerifyOpts), + Upgrade, + Remove(RemoveOpts), + ImportNiv(ImportOpts), + ImportFlake(ImportFlakeOpts), + Freeze(FreezeOpts), + Unfreeze(FreezeOpts), + GetPath(GetPathOpts), +} + +#[derive(Debug)] +pub struct InitOpts { + pub bare: bool, +} + +#[derive(Debug)] +pub struct AddOpts { + pub name: Option, + pub frozen: bool, + pub dry_run: bool, + + pub command: AddCommands, +} + +#[derive(Debug)] +pub struct ShowOpts { + pub plain: bool, + pub exclude: bool, + + pub names: Vec, +} + +#[derive(Debug)] +pub struct UpdateOpts { + pub strategy: UpdateStrategy, + pub dry_run: bool, + pub update_frozen: bool, + pub max_concurrent_downloads: usize, + + pub names: Vec, +} + /// How to handle updates #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum UpdateStrategy { @@ -14,400 +95,794 @@ pub enum UpdateStrategy { Full, } -#[derive(Debug, Parser)] -pub struct ChannelAddOpts { - pub channel_name: String, - /// Select a specific artifact from the channel, defaults to Nixpkgs if omitted. - /// - /// Find valid artifact names on or `nix-shell -p awscli2 --run 'aws s3 ls nix-channels/$CHANNEL'` (unfortunately requires an AWS account). - /// Common values: `latest-nixos-graphical-x86_64-linux.iso`, `latest-nixos-minimal-aarch64-linux.iso` - /// - /// - /* ↑ these two lines are intentionally left blank (for better help formatting) */ - #[clap(default_value = channel::NIXPKGS_ARTIFACT)] - pub artifact: String, +#[derive(Debug)] +pub struct VerifyOpts { + pub max_concurrent_downloads: usize, + + pub names: Vec, } -#[derive(Debug, Parser)] -pub struct GenericGitAddOpts { - /// Track a branch instead of a release - #[arg(short, long, value_hint = ValueHint::Other)] - pub branch: Option, +#[derive(Debug)] +pub struct RemoveOpts { + pub names: Vec, +} - /// Use a specific commit/release instead of the latest. - /// This may be a tag name, or a git revision when --branch is set. - #[arg(long, value_name = "tag or rev", value_hint = ValueHint::Other)] - pub at: Option, +#[derive(Debug)] +pub struct ImportOpts { + pub name: Option, - /// Also track pre-releases. - /// Conflicts with the --branch option. - #[arg(long, conflicts_with = "branch")] - pub pre_releases: bool, - - /// Bound the version resolution. For example, setting this to "2" will - /// restrict updates to 1.X versions. Conflicts with the --branch option. - #[arg( - long = "upper-bound", - value_name = "version", - conflicts_with_all = &["branch", "at"], - value_hint = ValueHint::Other - )] - pub version_upper_bound: Option, + pub path: PathBuf, +} - /// Optional prefix required for each release name / tag. For - /// example, setting this to "release/" will only consider those - /// that start with that string. - #[arg(long = "release-prefix", value_hint = ValueHint::Other)] - pub release_prefix: Option, +#[derive(Debug)] +pub struct ImportFlakeOpts { + pub name: Option, - /// Also fetch submodules - #[arg(long)] - pub submodules: bool, + pub path: PathBuf, +} + +#[derive(Debug)] +pub struct FreezeOpts { + pub names: Vec, +} + +#[derive(Debug)] +pub struct GetPathOpts { + pub name: String, } -#[derive(Debug, Parser)] +#[derive(Debug)] +pub enum AddCommands { + Channel(ChannelAddOpts), + GitHub(GitHubAddOpts), + Forgejo(ForgejoAddOpts), + GitLab(GitLabAddOpts), + Git(GitAddOpts), + PyPi(PyPiAddOpts), + Container(ContainerAddOpts), + Tarball(UrlAddOpts), + Url(UrlAddOpts), +} + +#[derive(Debug)] +pub struct ChannelAddOpts { + pub channel_name: String, + pub artifact: String, +} + +#[derive(Debug)] pub struct GitHubAddOpts { - #[arg(value_hint = ValueHint::Other)] + pub more: GenericGitAddOpts, + pub owner: String, - #[arg(value_hint = ValueHint::Other)] pub repository: String, - - #[command(flatten)] - pub more: GenericGitAddOpts, } -#[derive(Debug, Parser)] +#[derive(Debug)] pub struct ForgejoAddOpts { - #[arg(value_hint = ValueHint::Url)] + pub more: GenericGitAddOpts, + pub server: String, - #[arg(value_hint = ValueHint::Other)] pub owner: String, - #[arg(value_hint = ValueHint::Other)] pub repository: String, - - #[command(flatten)] - pub more: GenericGitAddOpts, } -#[derive(Debug, Parser)] +#[derive(Debug)] pub struct GitLabAddOpts { - /// Usually just `"owner" "repository"`, but GitLab allows arbitrary folder-like structures. - // TODO set min number of values to 2 again - #[arg(required = true, value_hint = ValueHint::Other)] - pub repo_path: Vec, + pub more: GenericGitAddOpts, - #[arg( - long, - default_value = "https://gitlab.com/", - help = "Use a self-hosted GitLab instance instead", - value_name = "url", - value_hint = ValueHint::Url - )] pub server: url::Url, - - #[arg( - long, - help = "Use a private token to access the repository.", - value_name = "token", - value_hint = ValueHint::Other - )] pub private_token: Option, - #[command(flatten)] - pub more: GenericGitAddOpts, -} - -#[derive(Debug, Parser, Clone, Copy, Default, ValueEnum)] -pub enum GitForgeOpts { - /// A generic git pin, with no further information - None, - #[default] - /// Try to determine the Forge from the given url, potentially by probing the server - Auto, - /// A Gitlab forge, e.g. gitlab.com - Gitlab, - /// A Github forge, i.e. github.com - Github, - /// A Forgejo forge, e.g. codeberg.org - Forgejo, + pub repo_path: Vec, } -#[derive(Debug, Parser)] +#[derive(Debug)] pub struct GitAddOpts { - /// The git remote URL. For example - #[arg(value_hint = ValueHint::Url)] - pub url: String, + pub more: GenericGitAddOpts, - #[arg(long, value_enum, default_value = "auto")] pub forge: GitForgeOpts, - #[command(flatten)] - pub more: GenericGitAddOpts, + pub url: Url, } -#[derive(Debug, Parser)] +#[derive(Debug)] pub struct PyPiAddOpts { - /// Name of the package at PyPi.org - #[arg(value_hint = ValueHint::Other)] - pub package_name: String, - - /// Use a specific release instead of the latest. - #[arg(long, value_name = "version", value_hint = ValueHint::Other)] pub at: Option, - /// Bound the version resolution. For example, setting this to "2" will - /// restrict updates to 1.X versions. Conflicts with the --branch option. - #[arg(long = "upper-bound", value_name = "version", conflicts_with = "at", value_hint = ValueHint::Other)] + // TODO: `at` and `version_upper_bound` were previously mutually exclusive, why? pub version_upper_bound: Option, + + pub package_name: String, } -#[derive(Debug, Parser)] +#[derive(Debug)] pub struct ContainerAddOpts { - #[arg(value_hint = ValueHint::Other)] - pub image_name: String, - #[arg(value_hint = ValueHint::Other)] - pub image_tag: String, - #[arg(long, value_hint = ValueHint::Other)] pub arch: Option, -} -// Same as `UrlAddOpts` below, but different struct to have different doc comments -// (the CLI parser uses them to generate the argument descriptions) -#[derive(Debug, Parser)] -pub struct TarballAddOpts { - /// Tarball URL - pub url: Url, - /// Treat this URL as mutable, and assume it will redirect to an immutable version of the content to be pinned. For example, a HEAD URL redirecting to the currently latest commit - #[arg(long)] - pub mutable: bool, + pub image_name: String, + pub image_tag: String, } -#[derive(Debug, Parser)] +#[derive(Debug)] pub struct UrlAddOpts { - /// URL to pin - pub url: Url, - /// Treat this URL as mutable, and assume it will redirect to an immutable version of the content to be pinned. For example, a HEAD URL redirecting to the currently latest commit - #[arg(long)] pub mutable: bool, -} -#[derive(Debug, Subcommand)] -pub enum AddCommands { - /// Track a Nix channel - #[command(name = "channel")] - Channel(ChannelAddOpts), - /// Track a GitHub repository - #[command(name = "github")] - GitHub(GitHubAddOpts), - /// Track a Forgejo repository - #[command(name = "forgejo")] - Forgejo(ForgejoAddOpts), - /// Track a GitLab repository - #[command(name = "gitlab")] - GitLab(GitLabAddOpts), - /// Track a git repository - #[command(name = "git")] - Git(GitAddOpts), - /// Track a package on PyPi - #[command(name = "pypi")] - PyPi(PyPiAddOpts), - /// Track an OCI container - #[command(name = "container")] - Container(ContainerAddOpts), - /// Track a tarball - /// - /// This can be either a static URL that never changes its contents or a - /// "mutable" URL that redirects to an immutable snapshot. - #[command(name = "tarball")] - Tarball(TarballAddOpts), - /// Track a URL - /// - /// This can be either a static URL that never changes its contents or a - /// "mutable" URL that redirects to an immutable snapshot. - #[command(name = "url")] - Url(UrlAddOpts), + pub url: Url, } -#[derive(Debug, Parser)] -pub struct AddOpts { - /// Add the pin with a custom name. - /// If a pin with that name already exists, it will be overwritten - #[arg(long, global = true, value_hint = ValueHint::Other)] - pub name: Option, - /// Add the pin as frozen, meaning that it will be ignored by `npins update` by default. - #[arg(long, global = true)] - pub frozen: bool, - /// Don't actually apply the changes - #[arg(short = 'n', long)] - pub dry_run: bool, - #[command(subcommand)] - pub command: AddCommands, +#[derive(Debug)] +pub struct GenericGitAddOpts { + pub at: Option, + pub selected: GitAddSelection, + pub submodules: bool, } -#[derive(Debug, Parser)] -pub struct ShowOpts { - /// Names of the pins to show - #[arg(value_hint = ValueHint::Other)] - pub names: Vec, - /// Prints only pin names - #[arg(short = 'p', long)] - pub plain: bool, - /// Invert [NAMES] to exclude specified pins - #[arg(short = 'e', long)] - pub exclude: bool, +#[derive(Debug)] +pub enum GitAddSelection { + Branch { + branch: String, + }, + Release { + pre_releases: bool, + // TODO: `at` and `version_upper_bound` were previously mutually exclusive, why? + version_upper_bound: Option, + release_prefix: Option, + }, } -#[derive(Debug, Parser)] -pub struct RemoveOpts { - // Names of the pins to remove - #[arg(required = true, value_hint = ValueHint::Other)] - pub names: Vec, +#[derive(Debug, Clone, Copy, Default)] +pub enum GitForgeOpts { + None, + #[default] + Auto, + Gitlab, + Github, + Forgejo, } -#[derive(Debug, Parser)] -pub struct UpdateOpts { - /// Updates only the specified pins. - #[arg(value_hint = ValueHint::Other)] - pub names: Vec, - /// Don't update versions, only re-fetch hashes - #[arg(short, long, conflicts_with = "full")] - pub partial: bool, - /// Re-fetch hashes even if the version hasn't changed. - /// Useful to make sure the derivations are in the Nix store. - #[arg(short, long, conflicts_with = "partial")] - pub full: bool, - /// Print the diff, but don't write back the changes - #[arg(short = 'n', long, global = true)] - pub dry_run: bool, - /// Allow updating frozen pins, which would otherwise be ignored - #[arg(long = "frozen")] - pub update_frozen: bool, - /// Maximum number of simultaneous downloads - #[arg(default_value = "5", long, value_hint = ValueHint::Other)] - pub max_concurrent_downloads: usize, +/// Shared path of the lockfile we are parsing +/// Should only be used inside of completions +type CompletionLockfile = Rc>>; + +impl Opts { + pub fn parser() -> OptionParser { + // Look... I know this is very cursed and all but we need a way to share + // the lockfile to completions and bpaf docs explicitly state that parsers + // cannot pass information to each other so you have to do this hack. + // https://docs.rs/bpaf/0.9.26/bpaf/_documentation/_0_intro/index.html#flexibility + let lockfile_shared = CompletionLockfile::default(); + let lockfile_set = lockfile_shared.clone(); + + let mode = SourceMode::parser().map(move |m| { + let _ = lockfile_set.set(m.lockfile().into()); + m + }); + + let verbose = short('v') + .long("verbose") + .help("Prints debug messages") + .switch(); + + let command = Command::parser(lockfile_shared); + + construct!(Opts { + mode, + verbose, + command + }) + .to_options() + .descr("Simple and convenient dependency pinning for Nix. All options are available in subcommands.") + .fallback_to_usage() + .version(env!("CARGO_PKG_VERSION")) + } } -#[derive(Debug, Parser)] -pub struct VerifyOpts { - /// Verifies only the specified pins. - #[arg(value_hint = ValueHint::Other)] - pub names: Vec, - /// Maximum number of simultaneous downloads - #[arg(default_value = "5", long, value_hint = ValueHint::Other)] - pub max_concurrent_downloads: usize, +impl SourceMode { + pub fn parser() -> impl Parser { + let lock_file = long("lock-file") + .help("Specifies the lockfile and operates only on it (lockfile mode)") + .argument::("FILE") + .complete_shell(ShellComp::File { mask: None }) + .map(Self::Lockfile); + + let directory = short('d') + .long("directory") + .help("Specifies base folder for sources.json and the boilerplate default.nix") + .env("NPINS_DIRECTORY") + .argument::("FOLDER") + .complete_shell(ShellComp::Dir { mask: None }) + .fallback_with(|| Result::<_, Infallible>::Ok(PathBuf::from("npins"))) + .format_fallback(|path, f| path.display().fmt(f)) + .map(Self::from_directory); + + construct!([directory, lock_file]) + } + + pub fn from_directory(directory: PathBuf) -> Self { + Self::Directory { + sources: directory.join("sources.json"), + default_nix: directory.join("default.nix"), + directory, + } + } + + pub fn lockfile(&self) -> &Path { + let (Self::Directory { sources, .. } | Self::Lockfile(sources)) = self; + sources + } } -#[derive(Debug, Parser)] -pub struct InitOpts { - /// Don't add an initial `nixpkgs` entry - #[arg(long)] - pub bare: bool, +impl Command { + pub fn parser(lockfile: CompletionLockfile) -> impl Parser { + let init = construct!(InitOpts { + bare(long("bare").switch().help("Don't add an initial `nixpkgs` entry")) + }) + .map(Self::Init) + .to_options() + .descr("Intializes the npins directory. Running this multiple times will restore/upgrade the `default.nix` and never touch your sources.json") + .fallback_to_usage() + .command("init"); + + let add = AddOpts::parser(lockfile.clone()) + .map(Box::new) + .map(Self::Add); + + let plain = long("plain") + .short('b') + .switch() + .help("Prints only pin names"); + let exclude = long("exclude") + .short('e') + .switch() + .help("Prints all the pins not specified"); + let names = positional::("NAMES") + .help("Names of the pins to show") + .many() + .complete(complete_pins(lockfile.clone())); + let show = construct!(ShowOpts { + plain, + exclude, + names + }) + .map(Self::Show) + .to_options() + .descr("Lists the current pin entries") + .fallback_to_usage() + .command("show"); + + let strategy = UpdateStrategy::parser(); + let max_concurrent_downloads = long("max-concurrent-downloads") + .help("Maximum number of simultaneous downloads") + .argument::("NUM") + .fallback(5); + let dry_run = long("dry-run") + .short('n') + .help("Print the diff, but don't write back the changes") + .switch(); + let update_frozen = long("frozen") + .help("Allow updating frozen pins, which would otherwise be ignored") + .switch(); + let names = positional::("NAMES") + .help("Updates only the specified pins") + .many() + .complete(complete_pins(lockfile.clone())); + let update = construct!(UpdateOpts { + strategy, + dry_run, + update_frozen, + max_concurrent_downloads, + names + }) + .map(Self::Update) + .to_options() + .descr("Updates all or the given pins to the latest version") + .fallback_to_usage() + .command("update"); + + let max_concurrent_downloads = long("max-concurrent-downloads") + .help("Maximum number of simultaneous downloads") + .argument::("NUM") + .fallback(5); + let names = positional::("NAMES") + .help("Verifies only the specified pins") + .many() + .complete(complete_pins(lockfile.clone())); + let verify = construct!(VerifyOpts { + max_concurrent_downloads, + names + }) + .map(Self::Verify) + .to_options() + .descr("Verifies that all or the given pins still have correct hashes. This is like `update --partial --dry-run` and then checking that the diff is empty") + .fallback_to_usage() + .command("verify"); + + let upgrade = pure(()) + .to_options() + .descr("Upgrade the sources.json and default.nix to the latest format version. This may occasionally break Nix evaluation!") + .fallback_to_usage() + .command("upgrade") + .map(|()| Self::Upgrade); + + let names = positional::("NAMES") + .help("Names of the pins to remove") + .some("Need at least one pin entry to remove") + .complete(complete_pins(lockfile.clone())); + let remove = construct!(RemoveOpts { names }) + .map(Self::Remove) + .to_options() + .descr("Remove pin entries") + .fallback_to_usage() + .command("remove"); + + let name = long("name") + .short('n') + .help("Only import one entry from Niv") + .argument::("NAME") + .optional(); + let path = positional::("FILE") + .complete_shell(ShellComp::File { mask: None }) + .fallback_with(|| Result::<_, Infallible>::Ok(PathBuf::from("nix/sources.json"))) + .format_fallback(|path, f| path.display().fmt(f)); + let import_niv = construct!(ImportOpts { name, path }) + .map(Self::ImportNiv) + .to_options() + .descr("Try to import entries from Niv") + .fallback_to_usage() + .command("import-niv"); + + let name = long("name") + .short('n') + .help("Only import one entry from the flake") + .argument::("NAME") + .optional(); + let path = positional::("FILE") + .complete_shell(ShellComp::File { mask: None }) + .fallback_with(|| Result::<_, Infallible>::Ok(PathBuf::from("flake.lock"))) + .format_fallback(|path, f| path.display().fmt(f)); + let import_flake = construct!(ImportFlakeOpts { name, path }) + .map(Self::ImportFlake) + .to_options() + .descr("Try to import entries from flake.lock") + .fallback_to_usage() + .command("import-flake"); + + let names = positional::("NAMES") + .help("Names of the pins to freeze") + .some("Need at least one pin entry to freeze") + .complete(complete_frozen(lockfile.clone(), false)); + let freeze = construct!(FreezeOpts { names }) + .map(Self::Freeze) + .to_options() + .descr("Freezes a pin entry, preventing it from being changed during an update") + .fallback_to_usage() + .command("freeze"); + + let names = positional::("NAMES") + .help("Names of the pins to unfreeze") + .some("Need at least one pin entry to unfreeze") + .complete(complete_frozen(lockfile.clone(), true)); + let unfreeze = construct!(FreezeOpts { names }) + .map(Self::Unfreeze) + .to_options() + .descr( + "Thaws a pin entry, allowing it to be changed during an update like a normal pin", + ) + .fallback_to_usage() + .command("unfreeze"); + + let name = positional::("NAME") + .help("Name of the pin") + .complete(complete_pin(lockfile.clone())); + let get_path = construct!(GetPathOpts { name }) + .map(Self::GetPath) + .to_options() + .descr("Evaluates the store path to a pin, fetching it if necessary. Don't forget to add a GC root") + .fallback_to_usage() + .command("get-path"); + + construct!([ + init, + add, + show, + update, + verify, + upgrade, + remove, + import_niv, + import_flake, + freeze, + unfreeze, + get_path + ]) + } } -#[derive(Debug, Parser)] -pub struct ImportOpts { - #[arg(default_value = "nix/sources.json", value_hint = ValueHint::FilePath)] - pub path: PathBuf, - /// Only import one entry from Niv - #[arg(short, long, value_hint = ValueHint::Other)] - pub name: Option, -} +impl UpdateStrategy { + pub fn parser() -> impl Parser { + let full = long("full").short('f').help("Re-fetch hashes even if the version hasn't changed.\nUseful to make sure the derivations are in the Nix store.").req_flag(Self::Full); + let partial = long("partial") + .short('p') + .help("Don't update versions, only re-fetch hashes") + .req_flag(Self::HashesOnly); -#[derive(Debug, Parser)] -pub struct ImportFlakeOpts { - #[arg(default_value = "flake.lock", value_hint = ValueHint::FilePath)] - pub path: PathBuf, - /// Only import one entry from the flake - #[arg(short, long, value_hint = ValueHint::Other)] - pub name: Option, + construct!([full, partial]).fallback(Self::Normal) + } } -#[derive(Debug, Parser)] -pub struct FreezeOpts { - /// Names of the pin(s) - #[arg(required = true, value_hint = ValueHint::Other)] - pub names: Vec, +impl AddOpts { + pub fn parser(lockfile: CompletionLockfile) -> impl Parser { + let name = long("name") + .argument::("NAME") + .help("Add the pin with a custom name. If a pin with that name already exists, it will be overwritten") + .complete(complete_pin(lockfile)) + .optional(); + + let frozen = long("frozen").switch().help( + "Add the pin as frozen, meaning that it will be ignored by `npins update` by default.", + ); + + let dry_run = long("dry-run") + .short('n') + .switch() + .help("Don't actually apply the changes"); + + let command = AddCommands::parser(); + + construct!(AddOpts { + name, + frozen, + dry_run, + command + }) + .to_options() + .descr("Adds a new pin entry.") + .fallback_to_usage() + .command("add") + } } -#[derive(Debug, Parser)] -pub struct GetPathOpts { - /// Name of the pin - #[arg(required = true, value_hint = ValueHint::Other)] - pub name: String, +impl AddCommands { + pub fn parser() -> impl Parser { + let channel_name = positional("CHANNEL").help("The name of the channel to pin"); + let artifact = positional("ARTIFACT").help("Select a specific artifact from the channel, defaults to Nixpkgs if omitted. +Find valid artifact names on or `nix-shell -p awscli2 --run 'aws s3 ls nix-channels/$CHANNEL'` (unfortunately requires an AWS account). +Common values: `latest-nixos-graphical-x86_64-linux.iso`, `latest-nixos-minimal-aarch64-linux.iso`").fallback_with(|| Result::<_, Infallible>::Ok(String::from(channel::NIXPKGS_ARTIFACT))).display_fallback(); + let channel = construct!(ChannelAddOpts { + channel_name, + artifact + }) + .map(Self::Channel) + .to_options() + .descr("Track a Nix channel") + .fallback_to_usage() + .command("channel"); + + let more = GenericGitAddOpts::parser(); + let owner = positional("OWNER"); + let repository = positional("REPOSITORY"); + let github = construct!(GitHubAddOpts { + more, + owner, + repository + }) + .map(Self::GitHub) + .to_options() + .descr("Track a GitHub repository") + .fallback_to_usage() + .command("github"); + + let more = GenericGitAddOpts::parser(); + let server = positional("SERVER"); + let owner = positional("OWNER"); + let repository = positional("REPOSITORY"); + let forgejo = construct!(ForgejoAddOpts { + more, + server, + owner, + repository + }) + .map(Self::Forgejo) + .to_options() + .descr("Track a Forgejo repository") + .fallback_to_usage() + .command("forgejo"); + + let more = GenericGitAddOpts::parser(); + let server = long("server") + .argument("URL") + .help("Use a specific GitLab instance") + .fallback_with(|| Url::parse("https://gitlab.com/")) + .display_fallback(); + let private_token = long("private-token") + .argument("TOKEN") + .help("Use a private token to access the repository.") + .optional(); + let repo_path = positional::("REPO PATH") + .help(r#"Usually just `"owner" "repository"`, but GitLab allows arbitrary folder-like structures."#) + .many() + .guard(|r| r.len() >= 2, "Repository path must be contain at least 2 segments"); + let gitlab = construct!(GitLabAddOpts { + more, + server, + private_token, + repo_path, + }) + .map(Self::GitLab) + .to_options() + .descr("Track a GitLab repository") + .fallback_to_usage() + .command("gitlab"); + + let more = GenericGitAddOpts::parser(); + let forge = GitForgeOpts::parser(); + let url = positional::("URL") + .help("The git remote URL. For example ") + .parse(|x| { + Url::parse(&x) + .map_err(|e| { + match e { + url::ParseError::RelativeUrlWithoutBase => { + anyhow::format_err!("URL scheme is missing. For git URLs, add the fully qualified scheme like git+ssh://. For local repositories, add file://") + }, + url::ParseError::InvalidPort => { + anyhow::format_err!("Invalid port number. For git URLs, try inserting a '/' after the ':' before the user name, like so: git+ssh://git@gitlab-instance.net:/user/repo.git") + }, + e => e.into(), + } + }) + .context("Failed to parse repository URL") + }); + let git = construct!(GitAddOpts { more, forge, url }) + .map(Self::Git) + .to_options() + .descr("Track a git repository") + .fallback_to_usage() + .command("git"); + + let at = long("at") + .argument("VERSION") + .help("Use a specific release instead of the latest.") + .optional(); + let version_upper_bound = long("upper-bound") + .argument("VERSION") + .help(r#"Bound the version resolution. For example, setting this to "2" will restrict updates to 1.X versions."#) + .optional(); + let package_name = positional("PACKAGE").help("Name of the package at PyPi.org"); + let pypi = construct!(PyPiAddOpts { + at, + version_upper_bound, + package_name + }) + .map(Self::PyPi) + .to_options() + .descr("Track a package on PyPi") + .fallback_to_usage() + .command("pypi"); + + let arch = long("arch").argument("ARCH").optional(); + let image_name = positional("NAME").help("Name of the image"); + let image_tag = positional("TAG").help("Tag of the image"); + let container = construct!(ContainerAddOpts { + arch, + image_name, + image_tag + }) + .map(Self::Container) + .to_options() + .descr("Track an OCI container") + .fallback_to_usage() + .command("container"); + + let mutable = long("mutable").switch().help("Treat this URL as mutable, and assume it will redirect to an immutable version of the content to be pinned. For example, a HEAD URL redirecting to the currently latest commit"); + let url = positional("URL").help("Tarball URL"); + let tarball = construct!(UrlAddOpts { + mutable, + url + }) + .map(Self::Tarball) + .to_options() + .descr("Track a URL") + .header(r#"This can be either a static URL that never changes its contents or a "mutable" URL that redirects to an immutable snapshot."#) + .fallback_to_usage() + .command("tarball"); + + let mutable = long("mutable").switch().help("Treat this URL as mutable, and assume it will redirect to an immutable version of the content to be pinned. For example, a HEAD URL redirecting to the currently latest commit"); + let url = positional("URL").help("URL to pin"); + let url = construct!(UrlAddOpts { + mutable, + url + }) + .map(Self::Url) + .to_options() + .descr("Track a URL") + .header(r#"This can be either a static URL that never changes its contents or a "mutable" URL that redirects to an immutable snapshot."#) + .fallback_to_usage() + .command("url"); + + construct!([ + channel, github, forgejo, gitlab, git, pypi, container, tarball, url + ]) + } } -#[derive(Debug, Subcommand)] -pub enum Command { - /// Initializes the npins directory. Running this multiple times will restore/upgrade the - /// `default.nix` and never touch your sources.json. - Init(InitOpts), +impl GenericGitAddOpts { + pub fn parser() -> impl Parser { + let at = long("at") + .argument::("TAG OR REV") + .help("Use a specific commit/release instead of the latest.\nThis may be a tag name, or a git revision when --branch is set.") + .optional(); - /// Adds a new pin entry. - // Boxing AddOpts as it is by far our largest structure, reduces - // memory requirements for smaller devices (even if maginal) - Add(Box), + let submodules = long("submodules").switch().help("Also fetch submodules"); - /// Lists the current pin entries. - Show(ShowOpts), - - /// Updates all or the given pins to the latest version. - Update(UpdateOpts), + let selected = GitAddSelection::parser(); - /// Verifies that all or the given pins still have correct hashes. This is like `update --partial --dry-run` and then checking that the diff is empty - Verify(VerifyOpts), - - /// Upgrade the sources.json and default.nix to the latest format version. This may occasionally break Nix evaluation! - Upgrade, - - /// Removes one pin entry. - Remove(RemoveOpts), + construct!(Self { + at, + submodules, + selected + }) + } +} - /// Try to import entries from Niv - ImportNiv(ImportOpts), +impl GitAddSelection { + pub fn parser() -> impl Parser { + let branch = short('b') + .long("branch") + .argument::("BRANCH") + .help("Track a branch instead of a release"); + + let branch = construct!(Self::Branch { branch }); + + let pre_releases = long("pre-releases") + .switch() + .help("Also track pre-releases.\nConflicts with the --branch option."); + + let version_upper_bound = long("upper-bound") + .argument::("VERSION") + .help(r#"Bound the version resolution. For example, setting this to "2" will restrict updates to 1.X versions."#) + .optional(); + + let release_prefix = long("release-prefix") + .argument::("VERSION") + .help(r#"Optional prefix required for each release name / tag. For example, setting this to "release/" will only consider those that start with that string."#) + .optional(); + + let release = construct!(Self::Release { + pre_releases, + version_upper_bound, + release_prefix + }); + + construct!([branch, release]) + } +} - /// Try to import entries from flake.lock - ImportFlake(ImportFlakeOpts), +impl GitForgeOpts { + pub fn parser() -> impl Parser { + long("forge") + .argument::("FORGE") + .complete(|_| { + Vec::from([ + ( + "none", + Some("A generic git pin, with no further information"), + ), + ( + "auto", + Some("Try to determine the Forge from the given url, potentially by probing the server"), + ), + ( + "gitlab", + Some("A Gitlab forge, e.g. gitlab.com"), + ), + ( + "github", + Some("A Github forge, i.e. github.com"), + ), + ( + "forgejo", + Some("A Forgejo forge, e.g. codeberg.org"), + ), + ]) + }) + .parse(|f| { + Ok(match f.as_str() { + "none" => Self::None, + "auto" => Self::Auto, + "gitlab" => Self::Gitlab, + "github" => Self::Github, + "forgejo" => Self::Forgejo, + x => return Err(format!("invalid value '{x}' for forge")), + }) + }) + .fallback(GitForgeOpts::Auto) + .display_fallback() + } +} - /// Freezes a pin entry, preventing it from being changed during an update - Freeze(FreezeOpts), +impl Display for GitForgeOpts { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::None => "none", + Self::Auto => "auto", + Self::Gitlab => "gitlab", + Self::Github => "github", + Self::Forgejo => "forgejo", + }) + } +} - /// Thaws a pin entry, allowing it to be changed during an update like a normal pin - Unfreeze(FreezeOpts), +fn complete_pin(lockfile: CompletionLockfile) -> impl Fn(&String) -> Vec<(String, Option)> { + move |incomplete| { + let Some(Ok(pins)) = lockfile.get().map(AsRef::as_ref).map(crate::read_pins) else { + return Vec::new(); + }; + + pins.pins + .into_keys() + .filter(|name| name.starts_with(incomplete)) + .map(|name| (name, None)) + .collect() + } +} - /// Evaluates the store path to a pin, fetching it if necessary. Don't forget to add a GC root - GetPath(GetPathOpts), +fn complete_pins( + lockfile: CompletionLockfile, +) -> impl Fn(&Vec) -> Vec<(String, Option)> { + move |v| { + let Some(Ok(mut pins)) = lockfile.get().map(AsRef::as_ref).map(crate::read_pins) else { + return Vec::new(); + }; + + // Last element could contain a pin name which we want the user + // to know exists and not autocomplete to one longer then it. + // Think of a case like lix and lix-module, if I type lix and + // then autocomplete if we didn't do this we'd get only lix-module + let (incomplete, finished) = v.split_last().unzip(); + for n in finished.unwrap_or(&[]) { + pins.pins.remove(n); + } + + let incomplete = incomplete.map(String::as_str).unwrap_or(""); + pins.pins + .into_keys() + .filter(|name| name.starts_with(incomplete)) + .map(|name| (name, None)) + .collect() + } } -#[derive(Debug, Parser)] -#[command( - version, - about, - arg_required_else_help = true, - // Confirm clap defaults - propagate_version = false, - disable_colored_help = false, - color = clap::ColorChoice::Auto -)] -pub struct Opts { - /// Base folder for sources.json and the boilerplate default.nix - #[arg( - short = 'd', - long = "directory", - default_value = "npins", - env = "NPINS_DIRECTORY", - value_hint = ValueHint::DirPath - )] - pub folder: std::path::PathBuf, - - /// Specifies the path to the sources.json and activates lockfile mode. - /// In lockfile mode, no default.nix will be generated and --directory will be ignored. - #[arg(long, value_hint = ValueHint::FilePath)] - pub lock_file: Option, - - /// Print debug messages. - #[arg(global = true, short = 'v', long = "verbose")] - pub verbose: bool, +fn complete_frozen( + lockfile: CompletionLockfile, + is_frozen: bool, +) -> impl Fn(&Vec) -> Vec<(String, Option)> { + move |v| { + let Some(Ok(mut pins)) = lockfile.get().map(AsRef::as_ref).map(crate::read_pins) else { + return Vec::new(); + }; + + // Last element could contain a pin name which we want the user + // to know exists and not autocomplete to one longer then it. + // Think of a case like lix and lix-module, if I type lix and + // then autocomplete if we didn't do this we'd get only lix-module + let (incomplete, finished) = v.split_last().unzip(); + for n in finished.unwrap_or(&[]) { + pins.pins.remove(n); + } + + let incomplete = incomplete.map(String::as_str).unwrap_or(""); + pins.pins + .into_iter() + .filter(|(_, p)| p.is_frozen() == is_frozen) + .filter(|(name, _)| name.starts_with(incomplete)) + .map(|(name, _)| (name, None)) + .collect() + } +} - #[command(subcommand)] - pub command: Command, +#[test] +fn check_invariants() { + Opts::parser().check_invariants(true) } diff --git a/test.nix b/test.nix index 46ab8c2..f4064ec 100644 --- a/test.nix +++ b/test.nix @@ -509,7 +509,8 @@ in npins --lock-file sources.json init --bare # Setting a custom directory should fail in lockfile mode ! npins --lock-file sources.json -d npins2 show - npins --lock-file sources.json -d npins show + ! npins --lock-file sources.json -d npins show + npins --lock-file sources.json show test -e npins/default.nix && exit 1 V=$(jq -r .pins sources.json) [[ "$V" = "{}" ]]