diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a9792f69dc7..4e78be9f393 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,6 +56,13 @@ jobs: build-mode: none - language: rust build-mode: none + # Path exclusions work with `none`; inline unit tests remain in scope. + config: | + paths-ignore: + - '**/examples/**' + - '**/tests/**' + - '**/fuzz/**' + - '**/benches/**' # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' # Use `c-cpp` to analyze code written in C, C++ or both # Use 'java-kotlin' to analyze code written in Java, Kotlin or both @@ -82,6 +89,7 @@ jobs: with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} + config: ${{ matrix.config }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. diff --git a/.gitignore b/.gitignore index 78cd84318a8..682ceb272a3 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,8 @@ $**/fuzz/Cargo.lock # Instead of adding more environment-specific ignores here, like for the IDE in use, prefer Git's user-global # `core.excludesFile` mechanism, see https://git-scm.com/docs/git-config#Documentation/git-config.txt-coreexcludesFile. + +# Share Zed tasks even when editor files are ignored globally; keep other Zed settings local. +!/.zed/ +/.zed/* +!/.zed/tasks.json diff --git a/.zed/tasks.json b/.zed/tasks.json new file mode 100644 index 00000000000..a46d249b6d3 --- /dev/null +++ b/.zed/tasks.json @@ -0,0 +1,233 @@ +// Feature-specific test configurations, with both hashes enabled where applicable. +// gix-transport has no hash toggles, so enable them on its gix-hash dev-dependency. +// gix-error tests enable anyhow through a dev-dependency; tree-error overrides auto-chain-error. +[ + { + "label": "test gix-error (default tree)", + "command": "cargo", + "args": [ + "nextest", + "run", + "-p", + "gix-error", + "--no-fail-fast" + ], + "cwd": "$ZED_WORKTREE_ROOT", + "env": { "GIX_TEST_IGNORE_ARCHIVES": "1" } + }, + { + "label": "test gix-error (auto-chain-error)", + "command": "cargo", + "args": [ + "nextest", + "run", + "-p", + "gix-error", + "--features", + "auto-chain-error", + "--no-fail-fast" + ], + "cwd": "$ZED_WORKTREE_ROOT", + "env": { "GIX_TEST_IGNORE_ARCHIVES": "1" } + }, + { + "label": "test gix-error (auto-chain-error, tree-error)", + "command": "cargo", + "args": [ + "nextest", + "run", + "-p", + "gix-error", + "--features", + "auto-chain-error,tree-error", + "--no-fail-fast" + ], + "cwd": "$ZED_WORKTREE_ROOT", + "env": { "GIX_TEST_IGNORE_ARCHIVES": "1" } + }, + { + "label": "test gix-protocol (blocking-client)", + "command": "cargo", + "args": [ + "nextest", + "run", + "-p", + "gix-protocol", + "--features", + "blocking-client,sha1,sha256", + "--no-fail-fast" + ], + "cwd": "$ZED_WORKTREE_ROOT", + "env": { "GIX_TEST_IGNORE_ARCHIVES": "1" } + }, + { + "label": "test gix-protocol (async-client)", + "command": "cargo", + "args": [ + "nextest", + "run", + "-p", + "gix-protocol", + "--features", + "async-client,sha1,sha256", + "--no-fail-fast" + ], + "cwd": "$ZED_WORKTREE_ROOT", + "env": { "GIX_TEST_IGNORE_ARCHIVES": "1" } + }, + { + "label": "test gix-transport (curl)", + "command": "cargo", + "args": [ + "nextest", + "run", + "-p", + "gix-transport", + "--features", + "http-client-curl,gix-hash/sha1,gix-hash/sha256", + "--no-fail-fast" + ], + "cwd": "$ZED_WORKTREE_ROOT", + "env": { "GIX_TEST_IGNORE_ARCHIVES": "1" } + }, + { + "label": "test gix-transport (curl, insecure HTTP credentials)", + "command": "cargo", + "args": [ + "nextest", + "run", + "-p", + "gix-transport", + "--features", + "http-client-curl,http-client-insecure-credentials,gix-hash/sha1,gix-hash/sha256", + "--test", + "blocking-transport-http-only", + "--no-fail-fast" + ], + "cwd": "$ZED_WORKTREE_ROOT", + "env": { "GIX_TEST_IGNORE_ARCHIVES": "1" } + }, + { + "label": "test gix-transport (reqwest)", + "command": "cargo", + "args": [ + "nextest", + "run", + "-p", + "gix-transport", + "--features", + "http-client-reqwest,gix-hash/sha1,gix-hash/sha256", + "--no-fail-fast" + ], + "cwd": "$ZED_WORKTREE_ROOT", + "env": { "GIX_TEST_IGNORE_ARCHIVES": "1" } + }, + { + "label": "test gix-transport (reqwest, insecure HTTP credentials)", + "command": "cargo", + "args": [ + "nextest", + "run", + "-p", + "gix-transport", + "--no-default-features", + "--features", + "blocking-client,http-client-reqwest,http-client-insecure-credentials,gix-hash/sha1,gix-hash/sha256", + "--test", + "blocking-transport", + "--no-fail-fast" + ], + "cwd": "$ZED_WORKTREE_ROOT", + "env": { "GIX_TEST_IGNORE_ARCHIVES": "1" } + }, + { + "label": "test gix-transport (async-client)", + "command": "cargo", + "args": [ + "nextest", + "run", + "-p", + "gix-transport", + "--features", + "async-client,gix-hash/sha1,gix-hash/sha256", + "--no-fail-fast" + ], + "cwd": "$ZED_WORKTREE_ROOT", + "env": { "GIX_TEST_IGNORE_ARCHIVES": "1" } + }, + { + "label": "test gix (basic, comfort, max-performance-safe)", + "command": "cargo", + "args": [ + "nextest", + "run", + "-p", + "gix", + "--no-default-features", + "--features", + "basic,comfort,max-performance-safe,sha1,sha256", + "--no-fail-fast" + ], + "cwd": "$ZED_WORKTREE_ROOT", + "env": { "GIX_TEST_IGNORE_ARCHIVES": "1" } + }, + { + "label": "test gix (basic, extras, comfort)", + "command": "cargo", + "args": [ + "nextest", + "run", + "-p", + "gix", + "--no-default-features", + "--features", + "basic,extras,comfort,sha1,sha256", + "--no-fail-fast" + ], + "cwd": "$ZED_WORKTREE_ROOT", + "env": { "GIX_TEST_IGNORE_ARCHIVES": "1" } + }, + { + "label": "test gix (async-network-client)", + "command": "cargo", + "args": [ + "nextest", + "run", + "-p", + "gix", + "--features", + "async-network-client,sha1,sha256", + "--no-fail-fast" + ], + "cwd": "$ZED_WORKTREE_ROOT", + "env": { "GIX_TEST_IGNORE_ARCHIVES": "1" } + }, + { + "label": "test gix (blocking-network-client)", + "command": "cargo", + "args": [ + "nextest", + "run", + "-p", + "gix", + "--features", + "blocking-network-client,sha1,sha256", + "--no-fail-fast" + ], + "cwd": "$ZED_WORKTREE_ROOT", + "env": { "GIX_TEST_IGNORE_ARCHIVES": "1" } + }, + { + "label": "test gix (async-std, flaky)", + "command": "cargo", + "args": [ + "test", + "-p", + "gix", + "--features", + "async-network-client-async-std,sha1,sha256" + ], + "cwd": "$ZED_WORKTREE_ROOT", + "env": { "GIX_TEST_IGNORE_ARCHIVES": "1" } + } +] diff --git a/AGENTS.md b/AGENTS.md index a7c8485d67c..3b835eb4acc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,13 +37,23 @@ Plumbing crates are migrating from `thiserror` enums to `gix-error`. Check wheth uses `gix-error` (look at its `Cargo.toml`); if it does, follow the patterns below. If it still uses `thiserror`, keep using `thiserror` for consistency within that crate. -- **Error type alias**: `pub type Error = gix_error::Exn;` +- **Error types**: use `gix_error::Exn`, `gix_error::Exn`, or the appropriate + concrete error directly, importing types under their canonical names as needed. Do not introduce + crate-specific or operation-specific forwarding aliases or renamed error exports. +- **Exception results**: use `ExnResult` for results with `Exn` errors. + It defaults to `T = ()` and `E = gix_error::exn::Untyped`: use `ExnResult` for erased errors, + bare `ExnResult` for erased unit results, and `ExnMessageResult` for message contexts. + `ExnMessageResult` also defaults to unit success. Preserve concrete error types when adapting signatures. + Always import `ExnResult` and `ExnMessageResult` directly from `gix_error` (or their `gix` re-exports) + and use their bare names in signatures. +- **Porcelain errors**: use the central `gix::Error` re-export at public API boundaries. Keep the + underlying error type and any `Exn` parameter when adapting an existing signature. - **Static messages**: `gix_error::message("something failed")` - **Formatted messages**: `gix_error::message!("failed to read {path}")` - **Wrapping callee errors with context**: `.or_raise(|| message("context about what failed"))?` - **Standalone error (no callee)**: `Err(message("something went wrong").raise())` - **Wrapping an `impl Error` with context**: `err.and_raise(message("context"))` -- **Closure/callback bounds**: use `Result` (bare), not `Exn`; +- **Closure/callback bounds**: use `ExnResult` with the default erased error type; inside the function, convert with `.or_raise(|| message("..."))?`; inside the closure, convert typed to bare with `.or_erased()` - **`Exn` does NOT implement `std::error::Error`** — this is by design. @@ -51,7 +61,7 @@ uses `gix-error` (look at its `Cargo.toml`); if it does, follow the patterns bel - Example: `std::io::Error::other(exn.into_error())` - **In tests** returning `gix_testtools::Result` (= `Result<(), Box>`), `Exn` can't be used with `?` directly — use `.map_err(|e| e.into_error())?` -- **Common imports**: `use gix_error::{message, ErrorExt, ResultExt};` +- **Common imports**: `use gix_error::{message, ErrorExt, ExnMessageResult, ExnResult, ResultExt};` - See `gix-error/src/lib.rs` module docs for a full migration guide from `thiserror` ### Commit Messages diff --git a/Cargo.lock b/Cargo.lock index ce1b7d60c73..0decce5ed9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1526,7 +1526,6 @@ dependencies = [ "smallvec", "sysinfo", "tempfile", - "thiserror 2.0.18", "tracing", "tracing-forest 0.2.0", "tracing-subscriber", @@ -1607,7 +1606,6 @@ dependencies = [ "signal-hook 0.4.4", "smallvec", "termtree", - "thiserror 2.0.18", ] [[package]] @@ -1620,6 +1618,7 @@ dependencies = [ "gix-error", "gix-hash", "gix-testtools", + "insta", "pretty_assertions", "serde", ] @@ -1661,6 +1660,7 @@ dependencies = [ "gix-quote", "gix-testtools", "gix-trace", + "insta", "serde", "smallvec", "unicode-bom", @@ -1694,9 +1694,9 @@ dependencies = [ "gix-trace", "gix-traverse", "gix-worktree", + "insta", "pretty_assertions", "smallvec", - "thiserror 2.0.18", ] [[package]] @@ -1711,10 +1711,12 @@ name = "gix-command" version = "0.10.1" dependencies = [ "bstr", + "gix-error", "gix-path", "gix-quote", "gix-testtools", "gix-trace", + "insta", ] [[package]] @@ -1728,6 +1730,7 @@ dependencies = [ "gix-error", "gix-hash", "gix-testtools", + "insta", "memmap2", "nonempty", "serde", @@ -1744,6 +1747,7 @@ dependencies = [ "document-features", "gix-config", "gix-config-value", + "gix-error", "gix-features", "gix-glob", "gix-path", @@ -1755,7 +1759,6 @@ dependencies = [ "serde", "serial_test", "smallvec", - "thiserror 2.0.18", "unicode-bom", ] @@ -1766,10 +1769,12 @@ dependencies = [ "bitflags 2.13.0", "bstr", "document-features", + "gix-error", "gix-path", + "gix-testtools", + "insta", "libc", "serde", - "thiserror 2.0.18", ] [[package]] @@ -1789,8 +1794,8 @@ dependencies = [ "gix-testtools", "gix-trace", "gix-url", + "insta", "serde", - "thiserror 2.0.18", ] [[package]] @@ -1802,6 +1807,7 @@ dependencies = [ "gix-error", "gix-hash", "gix-testtools", + "insta", "itoa", "jiff", "pretty_assertions", @@ -1818,6 +1824,7 @@ dependencies = [ "getrandom 0.4.3", "gix-attributes", "gix-command", + "gix-error", "gix-filter", "gix-fs", "gix-hash", @@ -1835,7 +1842,6 @@ dependencies = [ "insta", "pretty_assertions", "serde", - "thiserror 2.0.18", ] [[package]] @@ -1845,6 +1851,7 @@ dependencies = [ "bstr", "gix-dir", "gix-discover", + "gix-error", "gix-fs", "gix-ignore", "gix-index", @@ -1855,8 +1862,8 @@ dependencies = [ "gix-trace", "gix-utils", "gix-worktree", + "insta", "pretty_assertions", - "thiserror 2.0.18", ] [[package]] @@ -1866,15 +1873,16 @@ dependencies = [ "bstr", "defer", "dunce", + "gix-error", "gix-fs", "gix-path", "gix-ref", "gix-sec", "gix-testtools", + "insta", "is_ci", "serial_test", "tempfile", - "thiserror 2.0.18", ] [[package]] @@ -1885,6 +1893,7 @@ dependencies = [ "bstr", "document-features", "gix-error", + "gix-testtools", "insta", ] @@ -1898,8 +1907,10 @@ dependencies = [ "crossbeam-channel", "document-features", "gix-path", + "gix-testtools", "gix-trace", "gix-utils", + "insta", "libc", "once_cell", "parking_lot", @@ -1919,6 +1930,7 @@ dependencies = [ "encoding_rs", "gix-attributes", "gix-command", + "gix-error", "gix-filter", "gix-hash", "gix-object", @@ -1929,8 +1941,8 @@ dependencies = [ "gix-trace", "gix-utils", "gix-worktree", + "insta", "smallvec", - "thiserror 2.0.18", ] [[package]] @@ -1942,7 +1954,9 @@ dependencies = [ "gix-error", "gix-features", "gix-path", + "gix-testtools", "gix-utils", + "insta", "is_ci", "serde", "tempfile", @@ -1952,6 +1966,7 @@ dependencies = [ name = "gix-fsck" version = "0.25.1" dependencies = [ + "gix-error", "gix-hash", "gix-hashtable", "gix-object", @@ -1985,6 +2000,7 @@ dependencies = [ "gix-features", "gix-hash", "gix-testtools", + "insta", "serde", "sha1dc", "sha2", @@ -2039,6 +2055,7 @@ dependencies = [ "filetime", "fnv", "gix-bitmap", + "gix-error", "gix-features", "gix-fs", "gix-hash", @@ -2058,7 +2075,6 @@ dependencies = [ "rustix", "serde", "smallvec", - "thiserror 2.0.18", ] [[package]] @@ -2071,7 +2087,9 @@ version = "24.0.0" dependencies = [ "gix-error", "gix-tempfile", + "gix-testtools", "gix-utils", + "insta", "tempfile", ] @@ -2095,6 +2113,7 @@ dependencies = [ "gix-date", "gix-error", "gix-testtools", + "insta", "serde", ] @@ -2108,6 +2127,7 @@ dependencies = [ "document-features", "gix-command", "gix-diff", + "gix-error", "gix-filter", "gix-fs", "gix-hash", @@ -2125,11 +2145,11 @@ dependencies = [ "gix-trace", "gix-utils", "gix-worktree", + "insta", "nonempty", "pretty_assertions", "serde", "termtree", - "thiserror 2.0.18", ] [[package]] @@ -2139,6 +2159,7 @@ dependencies = [ "bitflags 2.13.0", "gix-commitgraph", "gix-date", + "gix-error", "gix-hash", "gix-object", "gix-odb", @@ -2158,6 +2179,7 @@ dependencies = [ "gix-object", "gix-odb", "gix-testtools", + "insta", ] [[package]] @@ -2170,6 +2192,7 @@ dependencies = [ "gix-actor", "gix-command", "gix-date", + "gix-error", "gix-features", "gix-hash", "gix-hashtable", @@ -2186,7 +2209,6 @@ dependencies = [ "serde", "smallvec", "termtree", - "thiserror 2.0.18", ] [[package]] @@ -2211,13 +2233,13 @@ dependencies = [ "gix-quote", "gix-testtools", "gix-zlib", + "insta", "maplit", "memmap2", "parking_lot", "pretty_assertions", "serde", "tempfile", - "thiserror 2.0.18", ] [[package]] @@ -2249,7 +2271,6 @@ dependencies = [ "parking_lot", "serde", "smallvec", - "thiserror 2.0.18", "uluru", ] @@ -2263,14 +2284,16 @@ dependencies = [ "faster-hex", "futures-io", "futures-lite", + "gix-error", "gix-hash", "gix-macros", "gix-odb", "gix-pack", + "gix-testtools", "gix-trace", + "insta", "pin-project-lite", "serde", - "thiserror 2.0.18", ] [[package]] @@ -2279,8 +2302,10 @@ version = "0.12.6" dependencies = [ "bstr", "gix-error", + "gix-testtools", "gix-trace", "gix-validate", + "insta", "serial_test", "tempfile", "windows 0.62.2", @@ -2295,11 +2320,12 @@ dependencies = [ "bstr", "gix-attributes", "gix-config-value", + "gix-error", "gix-glob", "gix-path", "gix-testtools", + "insta", "serial_test", - "thiserror 2.0.18", ] [[package]] @@ -2309,11 +2335,12 @@ dependencies = [ "expectrl", "gix-command", "gix-config-value", + "gix-error", "gix-testtools", + "insta", "parking_lot", "rustix", "serial_test", - "thiserror 2.0.18", ] [[package]] @@ -2328,6 +2355,7 @@ dependencies = [ "futures-lite", "gix-credentials", "gix-date", + "gix-error", "gix-features", "gix-hash", "gix-lock", @@ -2340,12 +2368,13 @@ dependencies = [ "gix-refspec", "gix-revwalk", "gix-shallow", + "gix-testtools", "gix-trace", "gix-transport", "gix-utils", + "insta", "nonempty", "serde", - "thiserror 2.0.18", ] [[package]] @@ -2385,7 +2414,6 @@ dependencies = [ "libc", "memmap2", "serde", - "thiserror 2.0.18", ] [[package]] @@ -2393,12 +2421,12 @@ name = "gix-refspec" version = "0.45.1" dependencies = [ "bstr", + "gix-error", "gix-hash", "gix-testtools", "gix-validate", "insta", "smallvec", - "thiserror 2.0.18", ] [[package]] @@ -2436,7 +2464,6 @@ dependencies = [ "gix-object", "gix-testtools", "smallvec", - "thiserror 2.0.18", ] [[package]] @@ -2461,12 +2488,12 @@ name = "gix-shallow" version = "0.13.0" dependencies = [ "bstr", + "gix-error", "gix-hash", "gix-lock", "nonempty", "serde", "tempfile", - "thiserror 2.0.18", ] [[package]] @@ -2478,6 +2505,7 @@ dependencies = [ "filetime", "gix-diff", "gix-dir", + "gix-error", "gix-features", "gix-filter", "gix-fs", @@ -2491,9 +2519,9 @@ dependencies = [ "gix-testtools", "gix-worktree", "hashbrown 0.16.1", + "insta", "portable-atomic", "pretty_assertions", - "thiserror 2.0.18", "windows-sys 0.61.2", ] @@ -2510,7 +2538,7 @@ dependencies = [ "gix-refspec", "gix-testtools", "gix-url", - "thiserror 2.0.18", + "insta", ] [[package]] @@ -2520,6 +2548,8 @@ dependencies = [ "dashmap", "document-features", "gix-fs", + "gix-testtools", + "insta", "libc", "parking_lot", "signal-hook 0.4.4", @@ -2614,13 +2644,14 @@ dependencies = [ "gix-path", "gix-quote", "gix-sec", + "gix-testtools", "gix-transport", "gix-url", + "insta", "parking_lot", "pin-project-lite", "reqwest", "serde", - "thiserror 2.0.18", ] [[package]] @@ -2630,6 +2661,7 @@ dependencies = [ "bitflags 2.13.0", "gix-commitgraph", "gix-date", + "gix-error", "gix-hash", "gix-hashtable", "gix-object", @@ -2640,7 +2672,6 @@ dependencies = [ "gix-traverse", "insta", "smallvec", - "thiserror 2.0.18", ] [[package]] @@ -2658,6 +2689,7 @@ dependencies = [ "gix-path", "gix-testtools", "gix-utils", + "insta", "percent-encoding", "serde", "serde_json", @@ -2679,7 +2711,9 @@ name = "gix-validate" version = "0.11.4" dependencies = [ "bstr", + "gix-error", "gix-testtools", + "insta", ] [[package]] @@ -2690,6 +2724,7 @@ dependencies = [ "document-features", "gix-attributes", "gix-discover", + "gix-error", "gix-features", "gix-fs", "gix-glob", @@ -2701,6 +2736,7 @@ dependencies = [ "gix-path", "gix-testtools", "gix-validate", + "insta", "serde", ] @@ -2709,6 +2745,7 @@ name = "gix-worktree-state" version = "0.34.1" dependencies = [ "bstr", + "gix-error", "gix-features", "gix-filter", "gix-fs", @@ -2720,8 +2757,8 @@ dependencies = [ "gix-testtools", "gix-worktree", "gix-worktree-state", + "insta", "io-close", - "thiserror 2.0.18", "walkdir", ] @@ -2742,6 +2779,7 @@ dependencies = [ "gix-traverse", "gix-worktree", "gix-worktree-stream", + "insta", "parking_lot", ] @@ -2751,6 +2789,7 @@ version = "0.1.0" dependencies = [ "bstr", "gix-error", + "insta", "serde", "zlib-rs", ] diff --git a/Cargo.toml b/Cargo.toml index 77abe48ec98..9c586acd93d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -381,7 +381,6 @@ explicit_deref_methods = "allow" # x22 inconsistent_struct_constructor = "allow" # x18 range_plus_one = "allow" # x17 inefficient_to_string = "allow" # x14 -from_iter_instead_of_collect = "allow" # x13 unused_self = "allow" # x10 many_single_char_names = "allow" # x10 manual_string_new = "allow" # x10 diff --git a/etc/plan/gix-error.md b/etc/plan/gix-error.md deleted file mode 100644 index 59344f6b0bb..00000000000 --- a/etc/plan/gix-error.md +++ /dev/null @@ -1,179 +0,0 @@ -# gix-error / `Exn` Migration Plan - -Source issue: [GitoxideLabs/gitoxide#2351](https://github.com/GitoxideLabs/gitoxide/issues/2351) -Imported on: 2026-04-22 -Working assumption: the checkboxes in this file reflect the current `gix-error` branch in this checkout, not only the historical state of the upstream issue. - -## Mission - -Finish the migration from `thiserror`-based error enums to `gix-error` / `Exn`, while preserving three caller-facing properties: - -- typed validation failures stay identifiable as `gix_error::ValidationError` -- repository-open failures keep a distinct `NotARepository` path -- crate-local plumbing errors stay cheap and composable until they are intentionally erased at the `gix` boundary - -## Constraints - -- Use the current workspace as the source of truth for completion. -- Treat upstream PR history as context only. Several linked PRs are merged upstream, but that work is not fully reflected in this branch. -- Keep migration leaf-first so downstream breakage stays local. - -## Reconciled Status - -- [x] Proof of concept completed in [#2352](https://github.com/GitoxideLabs/gitoxide/pull/2352), merged on January 12, 2026. -- [x] `anyhow` / source-chain integration completed in [#2383](https://github.com/GitoxideLabs/gitoxide/pull/2383), merged on January 19, 2026. -- [ ] Make `cargo nextest --workflow` run without `--exclude gix-error`. - Evidence: `.github/workflows/ci.yml` still excludes `gix-error`. -- [ ] Replace `thiserror` with `gix-error` everywhere. - Evidence: 33 crates in this branch still carry a `thiserror` dependency and/or `thiserror::Error` usage. -- [x] Keep `NotARepository` distinct from generic open failures. - Evidence: `gix::open::Error::NotARepository` exists and is asserted in tests. -- [ ] Use `gix_error::Error` in tests when that simplifies `Exn`-heavy paths. - Evidence: partially adopted, but not clearly finished as a repo-wide sweep. -- [x] Make `gix-validate` failures identifiable as `gix_error::ValidationError`. - Evidence: `gix-error` exports `ValidationError`, and downstream crates already use it directly. - -## Current Snapshot - -Workspace scan basis: - -- `thiserror` dependency present in `Cargo.toml` -- `thiserror::Error` mentions under `src/**/*.rs` - -Result on 2026-04-22: - -- 32 crates are done -- 33 crates are still pending - -## Linked Upstream PRs - -- [x] [#2352](https://github.com/GitoxideLabs/gitoxide/pull/2352) `gix-error` punch-through -- [x] [#2373](https://github.com/GitoxideLabs/gitoxide/pull/2373) Convert more crates to `gix-error` -- [x] [#2378](https://github.com/GitoxideLabs/gitoxide/pull/2378) `gix-commitgraph` to `gix-error` -- [x] [#2383](https://github.com/GitoxideLabs/gitoxide/pull/2383) `anyhow` integration for `gix-error` -- [x] [#2389](https://github.com/GitoxideLabs/gitoxide/pull/2389) custom error implementation follow-up -- [x] [#2390](https://github.com/GitoxideLabs/gitoxide/pull/2390) make validate errors non-exhaustive -- [x] [#2396](https://github.com/GitoxideLabs/gitoxide/pull/2396) `gix-actor` -- [x] [#2400](https://github.com/GitoxideLabs/gitoxide/pull/2400) more `gix-error` -- [x] [#2423](https://github.com/GitoxideLabs/gitoxide/pull/2423) batch 1, part 1 - -## Migration Rules - -- Replace `thiserror` in `Cargo.toml` with `gix-error`. -- Prefer `pub type Error = gix_error::Exn;` unless the crate needs a more specific concrete error. -- Convert validation/parsing-only paths to `gix_error::ValidationError`. -- Replace `#[from]` / `#[source]` propagation with `.or_raise(...)` or `.ok_or_raise(...)`. -- Keep `gix_error::Error` as the erased boundary type, mainly at `gix` and in tests that benefit from downcasting or frame inspection. -- When migrating a crate, run its local checks and at least one downstream compile pass. - -## Execution Order - -### Batch 1: leaves - -- [ ] `gix-hash` - 7 -- [ ] `gix-url` - 3 -- [ ] `gix-packetline` - 3 -- [ ] `gix-features` - 3 -- [ ] `gix-path` - 2 -- [ ] `gix-attributes` - 2 -- [x] `gix-quote` -- [ ] `gix-lock` - 1 -- [x] `gix-fs` -- [x] `gix-bitmap` -- [x] `gix-mailmap` - -### Batch 2: simple dependents - -- [ ] `gix-object` - 11 -- [ ] `gix-config-value` - 2 -- [ ] `gix-shallow` - 2 -- [ ] `gix-refspec` - 1 - -### Batch 3: ref / filter layer - -- [ ] `gix-ref` - 22 -- [ ] `gix-filter` - 18 -- [ ] `gix-revwalk` - 4 -- [ ] `gix-pathspec` - 3 -- [ ] `gix-prompt` - 1 - -### Batch 4: config and discovery - -- [ ] `gix-traverse` - 3 -- [ ] `gix-config` - 11 -- [ ] `gix-credentials` - 5 -- [ ] `gix-discover` - 4 - -### Batch 5: transport and index-adjacent - -- [ ] `gix-index` - 11 -- [ ] `gix-transport` - 10 -- [x] `gix-worktree-stream` -- [ ] `gix-submodule` - 6 - -### Batch 6: diff / protocol tier - -- [ ] `gix-diff` - 8 -- [ ] `gix-protocol` - 8 -- [ ] `gix-dir` - 1 -- [ ] `gix-worktree-state` - 1 -- [x] `gix-archive` - -### Batch 7: heavier consumers - -- [ ] `gix-pack` - 23 -- [ ] `gix-merge` - 8 -- [ ] `gix-status` - 3 -- [ ] `gix-blame` - 1 - -### Batch 8: object database - -- [ ] `gix-odb` - 11 - -### Batch 9: top-level API - -- [ ] `gix` - 138 - -## Already Done Outside The Active Queue - -- [x] `gix-actor` -- [x] `gix-chunk` -- [x] `gix-command` -- [x] `gix-commitgraph` -- [x] `gix-date` -- [x] `gix-error` -- [x] `gix-fetchhead` -- [x] `gix-fsck` -- [x] `gix-glob` -- [x] `gix-hashtable` -- [x] `gix-ignore` -- [x] `gix-lfs` -- [x] `gix-macros` -- [x] `gix-negotiate` -- [x] `gix-note` -- [x] `gix-rebase` -- [x] `gix-revision` -- [x] `gix-sec` -- [x] `gix-sequencer` -- [x] `gix-tempfile` -- [x] `gix-tix` -- [x] `gix-trace` -- [x] `gix-tui` -- [x] `gix-utils` -- [x] `gix-validate` -- [x] `gix-worktree` - -## Immediate Next Moves - -- [ ] Finish Batch 1 in this branch before assuming the upstream batch-1 PR history is present locally. -- [ ] Remove the `gix-error` special-case from `.github/workflows/ci.yml`. -- [ ] Re-scan counts after each crate or mini-batch instead of trusting the original issue numbers. -- [ ] Only move `gix` itself after all plumbing crates beneath it are clean. - -## Exit Criteria - -- [ ] No crate in this workspace depends on `thiserror`. -- [ ] No `src/**/*.rs` file in this workspace mentions `thiserror::Error`. -- [ ] `cargo nextest --workflow` no longer excludes `gix-error`. -- [ ] The `gix` boundary still returns `gix_error::Error` where type erasure is desired. -- [ ] Validation-heavy crates still expose typed validation failures where callers need them. diff --git a/gitoxide-core/Cargo.toml b/gitoxide-core/Cargo.toml index e4329b29ff2..f3a166ed9ee 100644 --- a/gitoxide-core/Cargo.toml +++ b/gitoxide-core/Cargo.toml @@ -58,7 +58,6 @@ gix-fsck = { version = "^0.25.1", path = "../gix-fsck" } gix-error-for-configuration-only = { package = "gix-error", version = "^0.3.2", path = "../gix-error", features = ["anyhow"] } serde = { version = "1.0.114", optional = true, default-features = false, features = ["derive"] } anyhow = "1.0.102" -thiserror = "2.0.18" bytesize = "2.3.1" tempfile = "3.26.0" unicode-width = "0.2.2" @@ -131,6 +130,4 @@ ignored = [ "gix-transport-configuration-only", # Imported as crate `layout`, which does not match the package name `layout-rs`. "layout-rs", - # Enabled through `estimate-hours` feature wiring only. - "smallvec", ] diff --git a/gitoxide-core/src/discover.rs b/gitoxide-core/src/discover.rs index d1ab7d3d23c..aff67c84456 100644 --- a/gitoxide-core/src/discover.rs +++ b/gitoxide-core/src/discover.rs @@ -22,7 +22,7 @@ pub fn discover(repo: &Path, mut out: impl std::io::Write) -> anyhow::Result<()> writeln!(out)?; writeln!(out, "discover (plumbing) from {}:", repo.display())?; - has_err |= print_result(&mut out, gix::discover::upwards(repo))?; + has_err |= print_result(&mut out, gix::discover::upwards(repo).map_err(gix::Exn::into_error))?; if has_err { writeln!(out)?; diff --git a/gitoxide-core/src/hours/core.rs b/gitoxide-core/src/hours/core.rs index e4ad7f7c54e..268a90b5260 100644 --- a/gitoxide-core/src/hours/core.rs +++ b/gitoxide-core/src/hours/core.rs @@ -6,7 +6,7 @@ use std::{ }, }; -use gix::bstr::BStr; +use gix::{bstr::BStr, error::ResultExt}; use crate::hours::{ CommitIdx, FileStats, LineStats, WorkByEmail, WorkByPerson, @@ -168,11 +168,9 @@ pub fn spawn_tree_delta_threads<'scope>( (true, true) => { files.modified += 1; if let Some(cache) = cache.as_mut() { - let mut diff = change.diff(cache).map_err(std::io::Error::other)?; + let mut diff = change.diff(cache).or_erased()?; let mut nl = 0; - if let Some(counts) = - diff.line_counts().map_err(std::io::Error::other)? - { + if let Some(counts) = diff.line_counts().or_erased()? { nl += counts.insertions as usize + counts.removals as usize; lines.added += counts.insertions as usize; lines.removed += counts.removals as usize; @@ -182,7 +180,7 @@ pub fn spawn_tree_delta_threads<'scope>( } }, } - Ok::<_, std::io::Error>(std::ops::ControlFlow::Continue(())) + Ok::<_, gix::Exn>(std::ops::ControlFlow::Continue(())) })?; out.push((commit_idx, files, lines)); } diff --git a/gitoxide-core/src/hours/mod.rs b/gitoxide-core/src/hours/mod.rs index 8c5c29cc361..05ffcf17222 100644 --- a/gitoxide-core/src/hours/mod.rs +++ b/gitoxide-core/src/hours/mod.rs @@ -1,3 +1,4 @@ +use gix::ExnMessageResult; use std::{collections::BTreeSet, io, path::Path, time::Instant}; use anyhow::bail; @@ -85,7 +86,7 @@ fn parse_trailer_identity(trailer: gix::objs::commit::message::body::TrailerRef< fn commit_author_identities( commit_data: &[u8], object_hash: gix::hash::Kind, -) -> Result<(gix::actor::SignatureRef<'_>, SmallVec<[ParsedIdentity<'_>; 2]>), gix::objs::decode::Error> { +) -> ExnMessageResult<(gix::actor::SignatureRef<'_>, SmallVec<[ParsedIdentity<'_>; 2]>)> { let commit = gix::objs::CommitRef::from_bytes(commit_data, object_hash)?; let author = commit.author()?.trim(); let mut authors = smallvec![ParsedIdentity::Borrowed(gix::actor::IdentityRef::from(author))]; @@ -254,7 +255,7 @@ where } commit_idx += 1; } - Err(gix::traverse::commit::simple::Error::Find { .. }) => { + Err(err) if err.is_not_found() => { is_shallow = true; break; } diff --git a/gitoxide-core/src/index/checkout.rs b/gitoxide-core/src/index/checkout.rs index 8c71380e184..a0b747bc957 100644 --- a/gitoxide-core/src/index/checkout.rs +++ b/gitoxide-core/src/index/checkout.rs @@ -4,7 +4,7 @@ use std::{ }; use anyhow::bail; -use gix::{NestedProgress, Progress, objs::find::Error, worktree::state::checkout}; +use gix::{ExnResult, NestedProgress, Progress, worktree::state::checkout}; use crate::{ index, @@ -107,7 +107,8 @@ pub fn checkout_exclusive( should_interrupt, opts, ), - }?; + } + .map_err(gix::Exn::into_error)?; files.show_throughput(start); bytes.show_throughput(start); @@ -182,7 +183,7 @@ impl gix::objs::Find for EmptyOrDb where Find: gix::objs::Find, { - fn try_find<'a>(&self, id: &gix::oid, buf: &'a mut Vec) -> Result>, Error> { + fn try_find<'a>(&self, id: &gix::oid, buf: &'a mut Vec) -> ExnResult>> { if self.empty_files { // We always want to query the ODB here… let Some(kind) = self.db.try_find(id, buf)?.map(|d| d.kind) else { @@ -205,7 +206,7 @@ where struct Empty; impl gix::objs::Find for Empty { - fn try_find<'a>(&self, id: &gix::oid, buffer: &'a mut Vec) -> Result>, Error> { + fn try_find<'a>(&self, id: &gix::oid, buffer: &'a mut Vec) -> ExnResult>> { buffer.clear(); Ok(Some(gix::objs::Data { kind: gix::object::Kind::Blob, diff --git a/gitoxide-core/src/net.rs b/gitoxide-core/src/net.rs index 72231b07181..0e6c55ad2c9 100644 --- a/gitoxide-core/src/net.rs +++ b/gitoxide-core/src/net.rs @@ -1,5 +1,8 @@ use std::str::FromStr; +#[cfg(any(feature = "async-client", feature = "blocking-client"))] +use gix::ExnMessageResult; + #[cfg(feature = "async-client")] use gix::protocol::transport::client::async_io as io_mode; #[cfg(feature = "blocking-client")] @@ -45,7 +48,7 @@ mod impls { pub async fn connect( url: Url, options: io_mode::connect::Options, -) -> Result>, io_mode::connect::Error> +) -> ExnMessageResult>> where Url: TryInto, E: std::error::Error + Send + Sync + 'static, diff --git a/gitoxide-core/src/organize.rs b/gitoxide-core/src/organize.rs index 50d35734414..eae3ff688ef 100644 --- a/gitoxide-core/src/organize.rs +++ b/gitoxide-core/src/organize.rs @@ -209,8 +209,7 @@ fn handle( None => return Ok(()), }) .join(to_relative({ - let mut path = - gix_url::expand_path(None, url.path.as_bstr()).map_err(gix::url::expand_path::Error::into_error)?; + let mut path = gix_url::expand_path(None, url.path.as_bstr()).map_err(gix::Exn::into_error)?; match kind { gix::repository::Kind::Submodule => { unreachable!("BUG: We should not try to relocate submodules and not find them the first place") diff --git a/gitoxide-core/src/pack/create.rs b/gitoxide-core/src/pack/create.rs index 631db5376dc..837d2c0a303 100644 --- a/gitoxide-core/src/pack/create.rs +++ b/gitoxide-core/src/pack/create.rs @@ -5,6 +5,7 @@ use gix::{ Count, NestedProgress, Progress, hash, hash::ObjectId, interrupt, objs::bstr::ByteVec, odb::pack, parallel::InOrderIter, prelude::Finalize, progress, traverse, }; +use gix::{ExnResult, error::ResultExt}; use crate::OutputFormat; @@ -106,7 +107,7 @@ where P: NestedProgress, P::SubProgress: 'static, { - type ObjectIdIter = dyn Iterator>> + Send; + type ObjectIdIter = dyn Iterator> + Send; let repo = gix::discover(repository_path)?; let pack_compression = repo.pack_compression()?; @@ -133,7 +134,7 @@ where let handle = repo.objects.into_shared_arc().to_cache_arc(); let iter = Box::new( traverse::commit::Simple::new(tips, handle.clone()) - .map(|res| res.map_err(|err| Box::new(err) as Box<_>).map(|c| c.id)) + .map(|res| res.map(|c| c.id)) .inspect(move |_| progress.inc()), ); (handle, iter) @@ -149,8 +150,8 @@ where .lines() .map(|hex_id| { hex_id - .map_err(|err| Box::new(err) as Box<_>) - .and_then(|hex_id| ObjectId::from_hex(hex_id.as_bytes()).map_err(Into::into)) + .or_erased() + .and_then(|hex_id| ObjectId::from_hex(hex_id.as_bytes()).or_erased()) }) .inspect(move |_| progress.inc()), ), @@ -358,16 +359,3 @@ struct Statistics { counts: pack::data::output::count::objects::Outcome, entries: pack::data::output::entry::iter_from_counts::Outcome, } - -pub mod input_iteration { - use gix::{hash, traverse}; - #[derive(Debug, thiserror::Error)] - pub enum Error { - #[error("input objects couldn't be iterated completely")] - Iteration(#[from] traverse::commit::simple::Error), - #[error("An error occurred while reading hashes from standard input")] - InputLinesIo(#[from] std::io::Error), - #[error("Could not decode hex hash provided on standard input")] - HashDecode(#[from] hash::decode::Error), - } -} diff --git a/gitoxide-core/src/pack/explode.rs b/gitoxide-core/src/pack/explode.rs index 68ab02000b2..372003d0f66 100644 --- a/gitoxide-core/src/pack/explode.rs +++ b/gitoxide-core/src/pack/explode.rs @@ -6,10 +6,14 @@ use std::{ }; use anyhow::{Result, anyhow}; +use gix::{ + ExnResult, + error::{ErrorExt, ResultExt, message}, +}; use gix::{ NestedProgress, hash::ObjectId, - object, objs, odb, + object, odb, odb::{loose, pack}, prelude::Write, }; @@ -64,32 +68,6 @@ impl From for pack::index::traverse::SafetyCheck { } } -#[derive(Debug, thiserror::Error)] -enum Error { - #[error("An IO error occurred while writing an object")] - Io(#[from] std::io::Error), - #[error("An object could not be written to the database")] - OdbWrite(#[from] loose::write::Error), - #[error("Failed to write {kind} object {id}")] - Write { - source: Box, - kind: object::Kind, - id: ObjectId, - }, - #[error("Object didn't verify after right after writing it")] - Verify(#[from] objs::data::verify::Error), - #[error("{kind} object wasn't re-encoded without change")] - ObjectEncodeMismatch { - #[source] - source: gix::hash::verify::Error, - kind: object::Kind, - }, - #[error("The recently written file for loose object {id} could not be found")] - WrittenFileMissing { id: ObjectId }, - #[error("The recently written file for loose object {id} cold not be read")] - WrittenFileCorrupt { source: loose::find::Error, id: ObjectId }, -} - #[expect( clippy::large_enum_variant, reason = "will be removed once `gix-error` is used consistently" @@ -101,31 +79,21 @@ enum OutputWriter { } impl gix::objs::Write for OutputWriter { - fn write_buf(&self, kind: object::Kind, from: &[u8]) -> Result { + fn write_buf(&self, kind: object::Kind, from: &[u8]) -> ExnResult { match self { OutputWriter::Loose(db) => db.write_buf(kind, from), OutputWriter::Sink(db) => db.write_buf(kind, from), } } - fn write_buf_with_known_id( - &self, - kind: object::Kind, - from: &[u8], - id: ObjectId, - ) -> Result { + fn write_buf_with_known_id(&self, kind: object::Kind, from: &[u8], id: ObjectId) -> ExnResult { match self { OutputWriter::Loose(db) => db.write_buf_with_known_id(kind, from, id), OutputWriter::Sink(db) => db.write_buf_with_known_id(kind, from, id), } } - fn write_stream( - &self, - kind: object::Kind, - size: u64, - from: &mut dyn Read, - ) -> Result { + fn write_stream(&self, kind: object::Kind, size: u64, from: &mut dyn Read) -> ExnResult { match self { OutputWriter::Loose(db) => db.write_stream(kind, size, from), OutputWriter::Sink(db) => db.write_stream(kind, size, from), @@ -138,7 +106,7 @@ impl gix::objs::Write for OutputWriter { size: u64, from: &mut dyn Read, id: ObjectId, - ) -> Result { + ) -> ExnResult { match self { OutputWriter::Loose(db) => db.write_stream_with_known_id(kind, size, from, id), OutputWriter::Sink(db) => db.write_stream_with_known_id(kind, size, from, id), @@ -184,12 +152,14 @@ pub fn pack_or_pack_index( use anyhow::Context; let path = pack_path.as_ref(); - let bundle = pack::Bundle::at(path, object_hash).with_context(|| { - format!( - "Could not find .idx or .pack file from given file at '{}'", - path.display() - ) - })?; + let bundle = pack::Bundle::at(path, object_hash) + .map_err(gix::Exn::into_error) + .with_context(|| { + format!( + "Could not find .idx or .pack file from given file at '{}'", + path.display() + ) + })?; if !object_path.as_ref().is_none_or(|p| p.as_ref().is_dir()) { return Err(anyhow!( @@ -228,11 +198,14 @@ pub fn pack_or_pack_index( .flatten(); let mut read_buf = Vec::new(); move |object_kind, buf, index_entry, progress| { - let written_id = out.write_buf(object_kind, buf).map_err(|err| Error::Write { - source: err, - kind: object_kind, - id: index_entry.oid, - })?; + let written_id = out + .write_buf(object_kind, buf) + .or_raise_erased(|| { + message!( + "Failed to write {object_kind} object {}", + index_entry.oid + ) + })?; if let Err(err) = written_id.verify(&index_entry.oid) { if let object::Kind::Tree = object_kind { progress.info(format!( @@ -240,21 +213,26 @@ pub fn pack_or_pack_index( index_entry.oid, written_id )); } else { - return Err(Error::ObjectEncodeMismatch { - source: err, - kind: object_kind, - }); + return Err(err + .raise(message!("{object_kind} object wasn't re-encoded without change")) + .erased()); } } if let Some(verifier) = loose_odb.as_ref() { let obj = verifier .try_find(&written_id, &mut read_buf) - .map_err(|err| Error::WrittenFileCorrupt { - source: err, - id: written_id, + .or_raise_erased(|| { + message!( + "The recently written file for loose object {written_id} could not be read" + ) })? - .ok_or(Error::WrittenFileMissing { id: written_id })?; - obj.verify_checksum(&written_id)?; + .ok_or_else(|| { + gix::error::not_found(format!( + "The recently written file for loose object {written_id} could not be found" + )) + .raise_erased() + })?; + obj.verify_checksum(&written_id).or_erased()?; } Ok(()) } @@ -267,6 +245,7 @@ pub fn pack_or_pack_index( make_pack_lookup_cache: pack::cache::lru::StaticLinkedList::<64>::default, }, ) + .map_err(gix::Exn::into_error) .with_context(|| "Failed to explode the entire pack - some loose objects may have been created nonetheless")?; let (index_path, data_path) = (bundle.index.path().to_owned(), bundle.pack.path().to_owned()); diff --git a/gitoxide-core/src/pack/index.rs b/gitoxide-core/src/pack/index.rs index e22ccee4add..3e0af6a4470 100644 --- a/gitoxide-core/src/pack/index.rs +++ b/gitoxide-core/src/pack/index.rs @@ -112,6 +112,7 @@ pub fn from_pack( options, ), } + .map_err(gix::Exn::into_error) .with_context(|| "Failed to write pack and index")?; match format { OutputFormat::Human => drop(human_output(out, res)), diff --git a/gitoxide-core/src/pack/receive.rs b/gitoxide-core/src/pack/receive.rs index 23540759a7f..3b88dc221c8 100644 --- a/gitoxide-core/src/pack/receive.rs +++ b/gitoxide-core/src/pack/receive.rs @@ -9,7 +9,11 @@ use crate::{OutputFormat, net, pack::receive::protocol::fetch::negotiate}; use gix::protocol::transport::client::async_io::connect; #[cfg(feature = "blocking-client")] use gix::protocol::transport::client::blocking_io::connect; -use gix::{DynNestedProgress, config::tree::Key, protocol::bisync, remote::fetch::Error}; +use gix::{DynNestedProgress, config::tree::Key, protocol::bisync}; +use gix::{ + ExnMessageResult, + error::{ResultExt, message}, +}; pub use gix::{ NestedProgress, Progress, hash::ObjectId, @@ -71,14 +75,17 @@ where vec![("agent".into(), Some(agent.clone()))], &mut progress, ) - .await?; + .await + .map_err(gix::Exn::into_error)?; if wanted_refs.is_empty() { wanted_refs.push("refs/heads/*:refs/remotes/origin/*".into()); } let fetch_refspecs: Vec<_> = wanted_refs .into_iter() .map(|ref_name| { - gix::refspec::parse(ref_name.as_bstr(), gix::refspec::parse::Operation::Fetch).map(|r| r.to_owned()) + gix::refspec::parse(ref_name.as_bstr(), gix::refspec::parse::Operation::Fetch) + .map(|r| r.to_owned()) + .map_err(gix::Exn::into_error) }) .collect::>()?; let user_agent = ("agent", Some(agent.clone())); @@ -88,22 +95,32 @@ where extra_refspecs: vec![], }; - let fetch_refmap = handshake.prepare_lsrefs_or_extract_refmap(user_agent.clone(), true, context)?; + let fetch_refmap = handshake + .prepare_lsrefs_or_extract_refmap(user_agent.clone(), true, context) + .map_err(gix::Exn::into_error)?; #[cfg(feature = "async-client")] let refmap = fetch_refmap .fetch_async(&mut progress, &mut transport.inner, trace_packetlines) - .await?; + .await + .map_err(gix::Exn::into_error)?; #[cfg(feature = "blocking-client")] - let refmap = fetch_refmap.fetch_blocking(&mut progress, &mut transport.inner, trace_packetlines)?; + let refmap = fetch_refmap + .fetch_blocking(&mut progress, &mut transport.inner, trace_packetlines) + .map_err(gix::Exn::into_error)?; if refmap.is_missing_required_mapping() { - return Err(Error::NoMapping { - refspecs: refmap.refspecs.clone(), - num_remote_refs: refmap.remote_refs.len(), - } - .into()); + anyhow::bail!( + "None of the refspec(s) {} matched any of the {} refs on the remote", + refmap + .refspecs + .iter() + .map(|spec| spec.to_ref().instruction().to_bstring().to_string()) + .collect::>() + .join(", "), + refmap.remote_refs.len() + ); } let mut negotiate = Negotiate { refmap: &refmap }; @@ -122,6 +139,7 @@ where ctx.object_hash, ctx.format, ) + .or_raise_erased(|| message("Failed to receive the pack")) .map(|_| true) }, progress, @@ -139,7 +157,8 @@ where reject_shallow_remote: true, }, ) - .await?; + .await + .map_err(gix::Exn::into_error)?; Ok(()) } @@ -148,7 +167,7 @@ struct Negotiate<'a> { } impl gix::protocol::fetch::Negotiate for Negotiate<'_> { - fn mark_complete_and_common_ref(&mut self) -> Result { + fn mark_complete_and_common_ref(&mut self) -> ExnMessageResult { Ok(negotiate::Action::MustNegotiate { remote_ref_target_known: vec![], /* we don't really negotiate */ }) @@ -168,7 +187,7 @@ impl gix::protocol::fetch::Negotiate for Negotiate<'_> { _state: &mut negotiate::one_round::State, _arguments: &mut Arguments, _previous_response: Option<&Response>, - ) -> Result<(negotiate::Round, bool), negotiate::Error> { + ) -> ExnMessageResult<(negotiate::Round, bool)> { Ok(( negotiate::Round { haves_sent: 0, diff --git a/gitoxide-core/src/pack/verify.rs b/gitoxide-core/src/pack/verify.rs index 3aefaee09eb..edf3988cc03 100644 --- a/gitoxide-core/src/pack/verify.rs +++ b/gitoxide-core/src/pack/verify.rs @@ -119,13 +119,16 @@ where }; let res = match ext { "pack" => { - let pack = odb::pack::data::File::at(path, object_hash).with_context(|| "Could not open pack file")?; + let pack = odb::pack::data::File::at(path, object_hash) + .map_err(gix::Exn::into_error) + .with_context(|| "Could not open pack file")?; pack.verify_checksum(&mut progress.add_child("Sha1 of pack"), should_interrupt) .map(|id| (id, None))? } "idx" => { - let idx = - odb::pack::index::File::at(path, object_hash).with_context(|| "Could not open pack index file")?; + let idx = odb::pack::index::File::at(path, object_hash) + .map_err(gix::Exn::into_error) + .with_context(|| "Could not open pack index file")?; let packfile_path = path.with_extension("pack"); let pack = odb::pack::data::File::at(&packfile_path, object_hash) .map_err(|e| { @@ -154,6 +157,7 @@ where should_interrupt, ) .map(|o| (o.actual_index_checksum, o.pack_traverse_statistics)) + .map_err(gix::Exn::into_error) .with_context(|| "Verification failure")? } "" => match path.file_name() { diff --git a/gitoxide-core/src/query/engine/update.rs b/gitoxide-core/src/query/engine/update.rs index b345c62d9b4..69fad65d04f 100644 --- a/gitoxide-core/src/query/engine/update.rs +++ b/gitoxide-core/src/query/engine/update.rs @@ -7,11 +7,10 @@ use std::{ use anyhow::{anyhow, bail}; use gix::{ - Count, Progress, + Count, ExnResult, Progress, bstr::{BStr, BString, ByteSlice}, diff::{blob::platform::prepare_diff::Operation, rewrites::CopySource}, features::progress, - objs::find::Error, parallel::{InOrderIter, SequenceId}, prelude::ObjectIdExt, }; @@ -317,7 +316,7 @@ pub fn update( }); } } - Ok::<_, Infallible>(std::ops::ControlFlow::Continue(())) + Ok::<_, gix::Exn>(std::ops::ControlFlow::Continue(())) })?; out_chunk.push(CommitDiffStats { id: commit, @@ -385,7 +384,7 @@ pub fn update( where Find: gix::prelude::Find + Clone, { - fn try_find<'b>(&self, id: &gix::oid, buf: &'b mut Vec) -> Result>, Error> { + fn try_find<'b>(&self, id: &gix::oid, buf: &'b mut Vec) -> ExnResult>> { let obj = self.inner.try_find(id, buf)?; let Some(obj) = obj else { return Ok(None) }; if !obj.kind.is_commit() { @@ -441,7 +440,7 @@ pub fn update( break; } } - Err(gix::traverse::commit::simple::Error::Find { .. }) => { + Err(traverse_err) if traverse_err.is_not_found() => { writeln!(err, "shallow repository - commit history is truncated").ok(); break; } diff --git a/gitoxide-core/src/repository/credential.rs b/gitoxide-core/src/repository/credential.rs index 06ce86447f2..aff1ac697bc 100644 --- a/gitoxide-core/src/repository/credential.rs +++ b/gitoxide-core/src/repository/credential.rs @@ -1,34 +1,30 @@ -#[derive(Debug, thiserror::Error)] -enum Error { - #[error(transparent)] - UrlParse(#[from] gix::Error), - #[error(transparent)] - Configuration(#[from] gix::config::credential_helpers::Error), - #[error(transparent)] - Protocol(#[from] gix::credentials::protocol::Error), - #[error(transparent)] - ConfigLoad(#[from] gix::config::file::init::from_paths::Error), -} - +use gix::ExnResult; pub fn function(repo: Option, action: gix::credentials::program::main::Action) -> anyhow::Result<()> { use gix::credentials::program::main::Action::*; + use gix::error::{OptionExt, ResultExt, message}; gix::credentials::program::main( Some(action.as_str().into()), std::io::stdin(), std::io::stdout(), gix::credentials::protocol::ContextOptions::default(), - |action, context| -> Result<_, Error> { + |action, context| -> ExnResult<_> { let url = context .url .clone() .or_else(|| context.to_url()) - .ok_or(Error::Protocol(gix::credentials::protocol::Error::UrlMissing))?; + .ok_or_raise_erased(|| { + gix::error::validation("Either 'url' field or both 'protocol' and 'host' fields must be provided") + })?; - let url = gix::url::parse(&url).map_err(gix::Exn::into_error)?; + let url = gix::url::parse(&url).or_erased()?; let (mut cascade, _action, prompt_options) = match repo { - Some(ref repo) => repo.config_snapshot().credential_helpers(url)?, + Some(ref repo) => repo + .config_snapshot() + .credential_helpers(url) + .or_raise_erased(|| message("Could not configure credential helpers"))?, None => { - let config = gix::config::File::from_globals()?; + let config = gix::config::File::from_globals() + .or_raise_erased(|| message("Could not load global configuration"))?; let environment = gix::open::permissions::Environment::all(); gix::config::credential_helpers( url, @@ -37,7 +33,8 @@ pub fn function(repo: Option, action: gix::credentials::program |_| true, /* section filter */ environment, false, /* use http path (override, uses configuration now)*/ - )? + ) + .or_raise_erased(|| message("Could not configure credential helpers"))? } }; cascade @@ -50,8 +47,8 @@ pub fn function(repo: Option, action: gix::credentials::program prompt_options, ) .map(|outcome| outcome.and_then(|outcome| (&outcome.next).try_into().ok())) - .map_err(Into::into) }, ) - .map_err(Into::into) + .map_err(gix::Exn::into_error)?; + Ok(()) } diff --git a/gitoxide-core/src/repository/diff.rs b/gitoxide-core/src/repository/diff.rs index 35ab1773335..94ada38e02b 100644 --- a/gitoxide-core/src/repository/diff.rs +++ b/gitoxide-core/src/repository/diff.rs @@ -124,10 +124,10 @@ fn resolve_revspec( match result { Err(err) => { - // When the revspec is just a name, the delegate tries to resolve a reference which fails. - // We extract the error from the tree to learn the name, and treat it as file. - let not_found = err.downcast_any_ref::(); - if let Some(gix::refs::file::find::existing::Error::NotFound { name }) = not_found { + // `is_not_found()` also matches missing objects, which must not become filesystem paths. + // Extract the missing reference's name, which may differ from the revspec after following symbolic refs. + let not_found = err.downcast_any_ref::(); + if let Some(gix::refs::file::find::NotFound { name }) = not_found { let root = repo.workdir().map(ToOwned::to_owned); let name = gix::path::os_string_into_bstring(name.into())?; diff --git a/gitoxide-core/src/repository/merge/commit.rs b/gitoxide-core/src/repository/merge/commit.rs index 84541a33c9b..65a048b102f 100644 --- a/gitoxide-core/src/repository/merge/commit.rs +++ b/gitoxide-core/src/repository/merge/commit.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, anyhow, bail}; +use anyhow::{Context, bail}; use gix::{ bstr::{BString, ByteSlice}, merge::tree::TreatAsUnresolved, @@ -66,7 +66,7 @@ pub fn commit( written += 1; repo.write(tree) }) - .map_err(|err| anyhow!("{err}"))?; + .map_err(gix::Exn::into_error)?; writeln!(out, "{tree_id} (wrote {written} trees)")?; } diff --git a/gitoxide-core/src/repository/merge/file.rs b/gitoxide-core/src/repository/merge/file.rs index 4e6b70f1753..34c085e46f7 100644 --- a/gitoxide-core/src/repository/merge/file.rs +++ b/gitoxide-core/src/repository/merge/file.rs @@ -79,7 +79,7 @@ pub fn file( let (pick, resolution) = platform.merge(&mut buf, labels, &repo.command_context()?)?; let buf = platform .buffer_by_pick(pick) - .map_err(|_| anyhow!("Participating object was too large"))? + .map_err(|()| anyhow!("Participating object was too large"))? .unwrap_or(&buf); out.write_all(buf)?; diff --git a/gitoxide-core/src/repository/merge/tree.rs b/gitoxide-core/src/repository/merge/tree.rs index 78e6e1166ae..d91fab9da8b 100644 --- a/gitoxide-core/src/repository/merge/tree.rs +++ b/gitoxide-core/src/repository/merge/tree.rs @@ -14,7 +14,7 @@ pub(super) mod function { use std::collections::BTreeSet; - use anyhow::{Context, anyhow, bail}; + use anyhow::{Context, bail}; use gix::{ bstr::{BString, ByteSlice}, merge::tree::TreatAsUnresolved, @@ -100,7 +100,7 @@ pub(super) mod function { written += 1; repo.write(tree) }) - .map_err(|err| anyhow!("{err}"))?; + .map_err(gix::Exn::into_error)?; writeln!(out, "{tree_id} (wrote {written} trees)")?; tree_id }; @@ -139,7 +139,7 @@ pub(super) mod function { fn persist_in_memory_objects(repo: &mut gix::Repository) -> anyhow::Result<()> { let objects = repo.objects.take_object_memory().expect("always write in memory first"); for (_id, (kind, data)) in objects.iter() { - repo.write_buf(*kind, data).map_err(|err| anyhow!("{err}"))?; + repo.write_buf(*kind, data).map_err(gix::Exn::into_error)?; } Ok(()) } diff --git a/gitoxide-core/src/repository/revision/explain.rs b/gitoxide-core/src/repository/revision/explain.rs index f2c042e6c86..f5098eab6c3 100644 --- a/gitoxide-core/src/repository/revision/explain.rs +++ b/gitoxide-core/src/repository/revision/explain.rs @@ -1,6 +1,6 @@ use anyhow::bail; use gix::{ - Exn, + ExnResult, bstr::{BStr, BString}, revision::plumbing::{ spec, @@ -41,7 +41,7 @@ impl<'a> Explain<'a> { err: None, } } - fn prefix(&mut self) -> Result<(), Exn> { + fn prefix(&mut self) -> ExnResult { self.call += 1; write!(self.out, "{:02}. ", self.call).ok(); Ok(()) @@ -57,18 +57,14 @@ impl<'a> Explain<'a> { } impl delegate::Revision for Explain<'_> { - fn find_ref(&mut self, name: &BStr) -> Result<(), Exn> { + fn find_ref(&mut self, name: &BStr) -> ExnResult { self.prefix()?; self.ref_name = Some(name.into()); writeln!(self.out, "Lookup the '{name}' reference").ok(); Ok(()) } - fn disambiguate_prefix( - &mut self, - prefix: gix::hash::Prefix, - hint: Option>, - ) -> Result<(), Exn> { + fn disambiguate_prefix(&mut self, prefix: gix::hash::Prefix, hint: Option>) -> ExnResult { self.prefix()?; self.oid_prefix = Some(prefix); writeln!( @@ -86,7 +82,7 @@ impl delegate::Revision for Explain<'_> { Ok(()) } - fn reflog(&mut self, query: ReflogLookup) -> Result<(), Exn> { + fn reflog(&mut self, query: ReflogLookup) -> ExnResult { self.prefix()?; self.has_implicit_anchor = true; let ref_name: &BStr = self.ref_name.as_ref().map_or_else(|| "HEAD".into(), AsRef::as_ref); @@ -103,14 +99,14 @@ impl delegate::Revision for Explain<'_> { Ok(()) } - fn nth_checked_out_branch(&mut self, branch_no: usize) -> Result<(), Exn> { + fn nth_checked_out_branch(&mut self, branch_no: usize) -> ExnResult { self.prefix()?; self.has_implicit_anchor = true; writeln!(self.out, "Find the {branch_no}th checked-out branch of 'HEAD'").ok(); Ok(()) } - fn sibling_branch(&mut self, kind: SiblingBranch) -> Result<(), Exn> { + fn sibling_branch(&mut self, kind: SiblingBranch) -> ExnResult { self.prefix()?; self.has_implicit_anchor = true; let ref_info = match self.ref_name.as_ref() { @@ -132,7 +128,7 @@ impl delegate::Revision for Explain<'_> { } impl delegate::Navigate for Explain<'_> { - fn traverse(&mut self, kind: Traversal) -> Result<(), Exn> { + fn traverse(&mut self, kind: Traversal) -> ExnResult { self.prefix()?; let name = self.revision_name(); writeln!( @@ -147,7 +143,7 @@ impl delegate::Navigate for Explain<'_> { Ok(()) } - fn peel_until(&mut self, kind: PeelTo<'_>) -> Result<(), Exn> { + fn peel_until(&mut self, kind: PeelTo<'_>) -> ExnResult { self.prefix()?; writeln!( self.out, @@ -163,7 +159,7 @@ impl delegate::Navigate for Explain<'_> { Ok(()) } - fn find(&mut self, regex: &BStr, negated: bool) -> Result<(), Exn> { + fn find(&mut self, regex: &BStr, negated: bool) -> ExnResult { self.prefix()?; self.has_implicit_anchor = true; let negate_text = if negated { "does not match" } else { "matches" }; @@ -188,7 +184,7 @@ impl delegate::Navigate for Explain<'_> { Ok(()) } - fn index_lookup(&mut self, path: &BStr, stage: u8) -> Result<(), Exn> { + fn index_lookup(&mut self, path: &BStr, stage: u8) -> ExnResult { self.prefix()?; self.has_implicit_anchor = true; writeln!( @@ -210,7 +206,7 @@ impl delegate::Navigate for Explain<'_> { } impl delegate::Kind for Explain<'_> { - fn kind(&mut self, kind: spec::Kind) -> Result<(), Exn> { + fn kind(&mut self, kind: spec::Kind) -> ExnResult { self.prefix()?; self.call = 0; writeln!( @@ -232,7 +228,7 @@ impl delegate::Kind for Explain<'_> { } impl Delegate for Explain<'_> { - fn done(&mut self) -> Result<(), Exn> { + fn done(&mut self) -> ExnResult { if !self.has_implicit_anchor && self.ref_name.is_none() && self.oid_prefix.is_none() { self.err = Some("Incomplete specification lacks its anchor, like a reference or object name".into()); } diff --git a/gix-actor/Cargo.toml b/gix-actor/Cargo.toml index 53ed2a4b336..52c5e330fa8 100644 --- a/gix-actor/Cargo.toml +++ b/gix-actor/Cargo.toml @@ -33,6 +33,7 @@ serde = { version = "1.0.114", optional = true, default-features = false, featur document-features = { version = "0.2.0", optional = true } [dev-dependencies] +insta = "1.46.3" pretty_assertions = "1.0.0" gix-testtools = { path = "../tests/tools", default-features = false } gix-hash = { path = "../gix-hash" } diff --git a/gix-actor/src/identity.rs b/gix-actor/src/identity.rs index 81c09a598f9..3851e19246e 100644 --- a/gix-actor/src/identity.rs +++ b/gix-actor/src/identity.rs @@ -1,4 +1,5 @@ use bstr::ByteSlice; +use gix_error::ExnMessageResult; use crate::{Identity, IdentityRef, signature::decode}; @@ -6,7 +7,7 @@ impl<'a> IdentityRef<'a> { /// Deserialize an identity from the given `data`. /// /// Typical input is `Name 1700000000 +0000`. - pub fn from_bytes(mut data: &'a [u8]) -> Result { + pub fn from_bytes(mut data: &'a [u8]) -> ExnMessageResult { Self::from_bytes_consuming(&mut data) } @@ -14,7 +15,7 @@ impl<'a> IdentityRef<'a> { /// /// Typical input is `Name 1700000000 +0000`; on success, /// `data` points to the bytes immediately after the closing `>`. - pub fn from_bytes_consuming(data: &mut &'a [u8]) -> Result { + pub fn from_bytes_consuming(data: &mut &'a [u8]) -> ExnMessageResult { decode::identity(data) } @@ -41,6 +42,9 @@ mod write { /// Output impl Identity { /// Serialize this instance to `out` in the git serialization format for signatures (but without timestamp). + /// Invalid name or email bytes are retained as `input` in the I/O error. + /// After [wrapping](gix_error::Error::from_error()), inspect them with + /// [metadata](gix_error::Error::metadata()). pub fn write_to(&self, out: &mut dyn std::io::Write) -> std::io::Result<()> { self.to_ref().write_to(out) } @@ -48,6 +52,9 @@ mod write { impl IdentityRef<'_> { /// Serialize this instance to `out` in the git serialization format for signatures (but without timestamp). + /// Invalid name or email bytes are retained as `input` in the I/O error. + /// After [wrapping](gix_error::Error::from_error()), inspect them with + /// [metadata](gix_error::Error::metadata()). pub fn write_to(&self, out: &mut dyn std::io::Write) -> std::io::Result<()> { out.write_all(validated_token(self.name).map_err(std::io::Error::other)?)?; out.write_all(b" ")?; diff --git a/gix-actor/src/signature/decode.rs b/gix-actor/src/signature/decode.rs index 3889756734a..ba63165be49 100644 --- a/gix-actor/src/signature/decode.rs +++ b/gix-actor/src/signature/decode.rs @@ -1,11 +1,12 @@ pub(crate) mod function { use bstr::ByteSlice; - use gix_error::ValidationError; + use gix_error::ExnMessageResult; + use gix_error::validation; use crate::{IdentityRef, SignatureRef}; /// Parse a signature from the bytes input `i`, and change it to point to the unparsed bytes afterwards. - pub fn decode<'a>(i: &mut &'a [u8]) -> Result, ValidationError> { + pub fn decode<'a>(i: &mut &'a [u8]) -> ExnMessageResult> { let identity = identity(i)?; if i.first() == Some(&b' ') { *i = &i[1..]; @@ -26,23 +27,23 @@ pub(crate) mod function { } /// Parse an identity from the bytes input `i` (like `name `). - pub fn identity<'a>(i: &mut &'a [u8]) -> Result, ValidationError> { + pub fn identity<'a>(i: &mut &'a [u8]) -> ExnMessageResult> { let eol_idx = i.find_byte(b'\n').unwrap_or(i.len()); let right_delim_idx = i[..eol_idx] .rfind_byte(b'>') - .ok_or_else(|| ValidationError::new("Closing '>' not found"))?; + .ok_or_else(|| validation("Closing '>' not found"))?; let i_name_and_email = &i[..right_delim_idx]; let skip_from_right = i_name_and_email.iter().rev().take_while(|b| **b == b'>').count(); let left_delim_idx = i_name_and_email .find_byte(b'<') - .ok_or_else(|| ValidationError::new("Opening '<' not found"))?; + .ok_or_else(|| validation("Opening '<' not found"))?; let skip_from_left = i[left_delim_idx..].iter().take_while(|b| **b == b'<').count(); let mut name = i[..left_delim_idx].as_bstr(); name = name.strip_suffix(b" ").unwrap_or(name).as_bstr(); let email = i .get(left_delim_idx + skip_from_left..right_delim_idx - skip_from_right) - .ok_or_else(|| ValidationError::new("Skipped parts run into each other"))? + .ok_or_else(|| validation("Skipped parts run into each other"))? .as_bstr(); *i = i.get(right_delim_idx + 1..).unwrap_or(&[]); Ok(IdentityRef { name, email }) @@ -57,11 +58,11 @@ pub use function::identity; #[cfg(test)] mod tests { mod parse_signature { - use gix_error::ValidationError; + use gix_error::ExnMessageResult; use crate::SignatureRef; - fn decode(mut i: &[u8]) -> Result<(&[u8], SignatureRef<'_>), ValidationError> { + fn decode(mut i: &[u8]) -> ExnMessageResult<(&[u8], SignatureRef<'_>)> { SignatureRef::from_bytes_consuming(&mut i).map(|signature| (i, signature)) } @@ -142,12 +143,8 @@ mod tests { #[test] fn invalid_signature() { - assert_eq!( - decode(b"hello < 12345 -1215") - .expect_err("parse fails as > is missing") - .to_string(), - "Closing '>' not found" - ); + insta::assert_debug_snapshot!(decode(b"hello < 12345 -1215") + .expect_err("parse fails as > is missing"), "invalid signature", @"Closing '>' not found"); } #[test] diff --git a/gix-actor/src/signature/mod.rs b/gix-actor/src/signature/mod.rs index df909737064..71c4232b0d5 100644 --- a/gix-actor/src/signature/mod.rs +++ b/gix-actor/src/signature/mod.rs @@ -1,5 +1,6 @@ mod _ref { use bstr::ByteSlice; + use gix_error::ExnMessageResult; use crate::{IdentityRef, Signature, SignatureRef, signature::decode}; @@ -8,7 +9,7 @@ mod _ref { /// Deserialize a signature from the given `data`. /// /// Typical input is `Name 1700000000 +0000`. - pub fn from_bytes(mut data: &'a [u8]) -> Result, gix_error::ValidationError> { + pub fn from_bytes(mut data: &'a [u8]) -> ExnMessageResult> { Self::from_bytes_consuming(&mut data) } @@ -17,12 +18,12 @@ mod _ref { /// Typical input is `Name 1700000000 +0000`; on /// success, `data` points to the bytes immediately after the parsed /// signature. - pub fn from_bytes_consuming(data: &mut &'a [u8]) -> Result, gix_error::ValidationError> { + pub fn from_bytes_consuming(data: &mut &'a [u8]) -> ExnMessageResult> { decode(data) } /// Try to parse the timestamp and create an owned instance from this shared one. - pub fn to_owned(&self) -> Result { + pub fn to_owned(&self) -> ExnMessageResult { Ok(Signature { name: self.name.to_owned(), email: self.email.to_owned(), @@ -65,8 +66,8 @@ mod _ref { /// Parse the `time` field for access to the passed time since unix epoch, and the time offset. /// The format is expected to be [raw](gix_date::parse_header()). - pub fn time(&self) -> Result { - self.time.parse() + pub fn time(&self) -> ExnMessageResult { + Ok(self.time.parse()?) } } } @@ -104,12 +105,16 @@ mod convert { pub(crate) mod write { use bstr::{BStr, ByteSlice}; use gix_date::parse::TimeBuf; + use gix_error::ExnMessageResult; use crate::{Signature, SignatureRef}; /// Output impl Signature { /// Serialize this instance to `out` in the git serialization format for actors. + /// Invalid signature field bytes are retained as `input` in the I/O error. + /// After [wrapping](gix_error::Error::from_error()), inspect them with + /// [metadata](gix_error::Error::metadata()). pub fn write_to(&self, out: &mut dyn std::io::Write) -> std::io::Result<()> { let mut buf = TimeBuf::default(); self.to_ref(&mut buf).write_to(out) @@ -122,6 +127,9 @@ pub(crate) mod write { impl SignatureRef<'_> { /// Serialize this instance to `out` in the git serialization format for actors. + /// Invalid signature field bytes are retained as `input` in the I/O error. + /// After [wrapping](gix_error::Error::from_error()), inspect them with + /// [metadata](gix_error::Error::metadata()). pub fn write_to(&self, out: &mut dyn std::io::Write) -> std::io::Result<()> { out.write_all(validated_token(self.name).map_err(std::io::Error::other)?)?; out.write_all(b" ")?; @@ -136,12 +144,13 @@ pub(crate) mod write { } } - pub(crate) fn validated_token(name: &BStr) -> Result<&BStr, gix_error::ValidationError> { + pub(crate) fn validated_token(name: &BStr) -> ExnMessageResult<&BStr> { if name.find_byteset(b"<>\n").is_some() { - return Err(gix_error::ValidationError::new_with_input( - "Signature name or email must not contain '<', '>' or \\n", - name, - )); + return Err( + gix_error::validation("Signature name or email must not contain '<', '>' or \\n") + .with("input", name) + .into(), + ); } Ok(name) } diff --git a/gix-actor/tests/actor/identity.rs b/gix-actor/tests/actor/identity.rs index 7f600c01949..6a3a96bfe96 100644 --- a/gix-actor/tests/actor/identity.rs +++ b/gix-actor/tests/actor/identity.rs @@ -22,6 +22,7 @@ fn round_trip() -> gix_testtools::Result { #[test] fn lenient_parsing() -> gix_testtools::Result { + let mut error_snapshots = Vec::new(); for (input, expected_email) in [ ( "First Last<> >", @@ -41,11 +42,19 @@ fn lenient_parsing() -> gix_testtools::Result { let signature: Identity = identity.into(); let mut output = Vec::new(); let err = signature.write_to(&mut output).unwrap_err(); - assert_eq!( - err.to_string(), - format!(r#"Signature name or email must not contain '<', '>' or \n: {expected_email:?}"#), - "this isn't roundtrippable as the name is technically incorrect - must not contain brackets" - ); + error_snapshots.push(gix_testtools::redact_debug_snapshot(&(err), &[])); } + insta::assert_debug_snapshot!(error_snapshots, "lenient parsing", @r#" + [ + Custom { + kind: Other, + error: Signature name or email must not contain '<', '>' or \n, "input"="fl > ", + }, + Custom { + kind: Other, + error: Signature name or email must not contain '<', '>' or \n, "input"="fl ' or \\\\n\", input: Some(\"invalid < middlename\") } })" - ); + insta::assert_debug_snapshot!(signature.write_to(&mut Vec::new()).expect_err("the signature is invalid"), "signature names reject angle brackets", @r#" + Custom { + kind: Other, + error: Signature name or email must not contain '<', '>' or \n, "input"="invalid < middlename", + } + "#); } #[test] @@ -23,10 +25,12 @@ mod write_to { email: "server>.example.com".into(), time: Time::default(), }; - assert_eq!( - format!("{:?}", signature.write_to(&mut Vec::new())), - "Err(Custom { kind: Other, error: ValidationError { message: \"Signature name or email must not contain '<', '>' or \\\\n\", input: Some(\"server>.example.com\") } })" - ); + insta::assert_debug_snapshot!(signature.write_to(&mut Vec::new()).expect_err("the signature is invalid"), "signature email addresses reject angle brackets", @r#" + Custom { + kind: Other, + error: Signature name or email must not contain '<', '>' or \n, "input"="server>.example.com", + } + "#); } #[test] @@ -36,10 +40,12 @@ mod write_to { email: "name@example.com".into(), time: Time::default(), }; - assert_eq!( - format!("{:?}", signature.write_to(&mut Vec::new())), - "Err(Custom { kind: Other, error: ValidationError { message: \"Signature name or email must not contain '<', '>' or \\\\n\", input: Some(\"hello\\nnewline\") } })" - ); + insta::assert_debug_snapshot!(signature.write_to(&mut Vec::new()).expect_err("the signature is invalid"), "signature names reject newlines", @r#" + Custom { + kind: Other, + error: Signature name or email must not contain '<', '>' or \n, "input"="hello\nnewline", + } + "#); } } } diff --git a/gix-archive/src/lib.rs b/gix-archive/src/lib.rs index c93ed9de21b..e13884bd82a 100644 --- a/gix-archive/src/lib.rs +++ b/gix-archive/src/lib.rs @@ -18,9 +18,6 @@ use bstr::BString; -/// The error returned by [`write_stream()`]. -pub type Error = gix_error::Exn; - /// The supported container formats for use in [`write_stream()`]. #[derive(Default, PartialEq, Eq, Copy, Clone, Debug)] pub enum Format { diff --git a/gix-archive/src/write.rs b/gix-archive/src/write.rs index d8fcab29010..035a396cbac 100644 --- a/gix-archive/src/write.rs +++ b/gix-archive/src/write.rs @@ -1,9 +1,10 @@ #[cfg(any(feature = "tar", feature = "tar_gz", feature = "zip"))] use gix_error::ResultExt; use gix_error::{ErrorExt, message}; +use gix_error::{ExnMessageResult, ExnResult}; use gix_worktree_stream::{Entry, Stream}; -use crate::{Error, Format, Options}; +use crate::{Format, Options}; #[cfg(feature = "zip")] use std::io::Write; @@ -22,9 +23,9 @@ pub fn write_stream( mut next_entry: NextFn, out: impl std::io::Write, opts: Options, -) -> Result<(), Error> +) -> ExnMessageResult where - NextFn: FnMut(&mut Stream) -> Result>, gix_error::Exn>, + NextFn: FnMut(&mut Stream) -> ExnResult>>, { if opts.format == Format::InternalTransientNonPersistable { return Err(message("The internal format cannot be used as an archive, it's merely a debugging tool").raise()); @@ -39,7 +40,7 @@ where } impl State { - pub fn new(format: Format, mtime: gix_date::SecondsSinceUnixEpoch, out: W) -> Result { + pub fn new(format: Format, mtime: gix_date::SecondsSinceUnixEpoch, out: W) -> ExnMessageResult { match format { Format::InternalTransientNonPersistable => unreachable!("handled earlier"), Format::Zip { .. } => { @@ -148,9 +149,9 @@ pub fn write_stream_seek( mut next_entry: NextFn, out: impl std::io::Write + std::io::Seek, opts: Options, -) -> Result<(), Error> +) -> ExnMessageResult where - NextFn: FnMut(&mut Stream) -> Result>, gix_error::Exn>, + NextFn: FnMut(&mut Stream) -> ExnResult>>, { let compression_level = match opts.format { Format::Zip { compression_level } => compression_level.map(i64::from), @@ -204,7 +205,7 @@ fn append_zip_entry( mtime: rawzip::time::UtcDateTime, compression_level: Option, tree_prefix: Option<&bstr::BString>, -) -> Result<(), Error> { +) -> ExnMessageResult { use bstr::ByteSlice; let path = add_prefix(entry.relative_path(), tree_prefix).into_owned(); let unix_permissions = if entry.mode.is_executable() { 0o755 } else { 0o644 }; @@ -299,7 +300,7 @@ fn append_tar_entry( mut entry: gix_worktree_stream::Entry<'_>, mtime_seconds_since_epoch: i64, opts: &Options, -) -> Result<(), Error> { +) -> ExnMessageResult { let mut header = tar::Header::new_gnu(); header.set_mtime(mtime_seconds_since_epoch as u64); header.set_entry_type(tar_entry_type(entry.mode)); diff --git a/gix-attributes/Cargo.toml b/gix-attributes/Cargo.toml index 8d4bb8c49d7..4edcfdfe1c9 100644 --- a/gix-attributes/Cargo.toml +++ b/gix-attributes/Cargo.toml @@ -41,6 +41,7 @@ serde = { version = "1.0.114", optional = true, default-features = false, featur document-features = { version = "0.2.1", optional = true } [dev-dependencies] +insta = "1.46.3" criterion = "0.8.2" gix-testtools = { path = "../tests/tools", default-features = false, features = ["sha1"] } gix-fs = { path = "../gix-fs" } diff --git a/gix-attributes/src/name.rs b/gix-attributes/src/name.rs index 4cc4acb959a..300e77d090e 100644 --- a/gix-attributes/src/name.rs +++ b/gix-attributes/src/name.rs @@ -24,8 +24,10 @@ impl AsRef for NameRef<'_> { } impl<'a> TryFrom<&'a BStr> for NameRef<'a> { - type Error = Error; + type Error = gix_error::Message; + /// Invalid name bytes are stored as `input` in [`gix_error::Message::values`]. + /// After [wrapping](gix_error::Error::from_error()), inspect them with [metadata](gix_error::Error::metadata()). fn try_from(attr: &'a BStr) -> Result { fn attr_valid(attr: &BStr) -> bool { if attr.is_empty() || attr.first() == Some(&b'-') { @@ -38,7 +40,9 @@ impl<'a> TryFrom<&'a BStr> for NameRef<'a> { attr_valid(attr) .then(|| NameRef(attr.to_str().expect("no illformed utf8"))) - .ok_or_else(|| Error::new_with_input("Attribute has non-ascii characters or starts with '-'", attr)) + .ok_or_else(|| { + gix_error::validation("Attribute has non-ascii characters or starts with '-'").with("input", attr) + }) } } @@ -92,6 +96,3 @@ impl<'de> serde::Deserialize<'de> for Name { .map_err(serde::de::Error::custom) } } - -/// The error returned by [`parse::Iter`][crate::parse::Iter]. -pub type Error = gix_error::ValidationError; diff --git a/gix-attributes/src/parse.rs b/gix-attributes/src/parse.rs index 6a2cad26678..f3d9a26c801 100644 --- a/gix-attributes/src/parse.rs +++ b/gix-attributes/src/parse.rs @@ -1,9 +1,9 @@ use std::borrow::Cow; use bstr::{BStr, ByteSlice}; -use gix_error::{ErrorExt, ResultExt, ValidationError}; +use gix_error::{ErrorExt, ExnMessageResult, ResultExt, validation}; -use crate::{AssignmentRef, Name, NameRef, StateRef, name}; +use crate::{AssignmentRef, Name, NameRef, StateRef}; /// The kind of attribute that was parsed. #[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone)] @@ -15,9 +15,6 @@ pub enum Kind { Macro(Name), } -/// The error returned by [`parse::Lines`][crate::parse::Lines]. -pub type Error = gix_error::Exn; - /// An iterator over attribute assignments, parsed line by line. pub struct Lines<'a> { lines: bstr::Lines<'a>, @@ -31,13 +28,15 @@ pub struct Iter<'a> { impl<'a> Iter<'a> { /// Create a new instance to parse attribute assignments from `input`. + /// Iterator errors store invalid name bytes as `input` in [`gix_error::Message::values`]. + /// After [wrapping](gix_error::Error::from_error()), inspect them with [metadata](gix_error::Error::metadata()). pub fn new(input: &'a BStr) -> Self { Iter { attrs: input.split(is_blank as fn(&u8) -> bool), } } - fn parse_attr(&self, attr: &'a [u8]) -> Result, name::Error> { + fn parse_attr(&self, attr: &'a [u8]) -> ExnMessageResult> { let mut tokens = attr.splitn(2, |b| *b == b'='); let attr = tokens.next().expect("attr itself").as_bstr(); let possibly_value = tokens.next(); @@ -52,16 +51,16 @@ impl<'a> Iter<'a> { } } -fn check_attr(attr: &BStr) -> Result, name::Error> { - NameRef::try_from(attr).and_then(|name| { - (!name.as_str().starts_with("builtin_")) - .then_some(name) - .ok_or_else(|| name::Error::new_with_input("Attribute name uses the reserved 'builtin_' prefix", attr)) - }) +fn check_attr(attr: &BStr) -> ExnMessageResult> { + Ok(NameRef::try_from(attr).and_then(|name| { + (!name.as_str().starts_with("builtin_")).then_some(name).ok_or_else(|| { + gix_error::validation("Attribute name uses the reserved 'builtin_' prefix").with("input", attr) + }) + })?) } impl<'a> Iterator for Iter<'a> { - type Item = Result, name::Error>; + type Item = ExnMessageResult>; fn next(&mut self) -> Option { let attr = self.attrs.find(|a| !a.is_empty())?; @@ -72,6 +71,7 @@ impl<'a> Iterator for Iter<'a> { /// Instantiation impl<'a> Lines<'a> { /// Create a new instance to parse all attributes in all lines of the input `bytes`. + /// Iterator errors include invalid macro name or pattern bytes as `input` [metadata](gix_error::Exn::metadata()). pub fn new(bytes: &'a [u8]) -> Self { let bom = unicode_bom::Bom::from(bytes); Lines { @@ -82,7 +82,7 @@ impl<'a> Lines<'a> { } impl<'a> Iterator for Lines<'a> { - type Item = Result<(Kind, Iter<'a>, usize), Error>; + type Item = ExnMessageResult<(Kind, Iter<'a>, usize)>; fn next(&mut self) -> Option { fn skip_blanks(line: &BStr) -> &BStr { @@ -103,7 +103,7 @@ impl<'a> Iterator for Lines<'a> { } } -fn parse_line(line: &BStr, line_number: usize) -> Option, usize), Error>> { +fn parse_line(line: &BStr, line_number: usize) -> Option, usize)>> { if line.is_empty() { return None; } @@ -122,15 +122,15 @@ fn parse_line(line: &BStr, line_number: usize) -> Option, let kind_res = match line.strip_prefix(b"[attr]").filter(|name| !name.is_empty()) { Some(macro_name) => check_attr(macro_name.into()) - .or_raise(|| ValidationError::new(format!("Macro in line {line_number} has an invalid name"))) + .or_raise(|| validation(format!("Macro in line {line_number} has an invalid name"))) .map(|name| Kind::Macro(name.to_owned())), None => { let pattern = gix_glob::Pattern::from_bytes(line.as_ref())?; if pattern.mode.contains(gix_glob::pattern::Mode::NEGATIVE) { - Err(ValidationError::new_with_input( - format!(r"Line {line_number} has a negative pattern, for literal characters use \!"), - line.as_ref(), - ) + Err(validation(format!( + r"Line {line_number} has a negative pattern, for literal characters use \!" + )) + .with("input", line.as_ref()) .raise()) } else { Ok(Kind::Pattern(pattern)) diff --git a/gix-attributes/src/search/attributes.rs b/gix-attributes/src/search/attributes.rs index 32671ddca25..2cdcd321a22 100644 --- a/gix-attributes/src/search/attributes.rs +++ b/gix-attributes/src/search/attributes.rs @@ -1,3 +1,4 @@ +use gix_error::ExnMessageResult; use std::path::{Path, PathBuf}; use bstr::{BStr, ByteSlice}; @@ -128,7 +129,7 @@ impl Pattern for Attributes { fn bytes_to_patterns(&self, bytes: &[u8], _source: &std::path::Path) -> Vec> { fn into_owned_assignments<'a>( - attrs: impl Iterator, crate::name::Error>>, + attrs: impl Iterator>>, ) -> Option { let res = attrs .map(|res| { @@ -137,7 +138,7 @@ impl Pattern for Attributes { inner: a.to_owned(), }) }) - .collect::>(); + .collect::>(); match res { Ok(res) => Some(res), Err(_err) => { diff --git a/gix-attributes/tests/attributes/main.rs b/gix-attributes/tests/attributes/main.rs index fad4b9a5011..9deb66ed8ba 100644 --- a/gix-attributes/tests/attributes/main.rs +++ b/gix-attributes/tests/attributes/main.rs @@ -1,4 +1,3 @@ -pub use gix_testtools::TestResult as Result; mod assignment; mod parse; mod search; diff --git a/gix-attributes/tests/attributes/parse.rs b/gix-attributes/tests/attributes/parse.rs index 610a1054bea..61293844578 100644 --- a/gix-attributes/tests/attributes/parse.rs +++ b/gix-attributes/tests/attributes/parse.rs @@ -1,6 +1,7 @@ -use bstr::{BString, ByteSlice}; +use bstr::BString; use gix_attributes::{StateRef, parse, state::ValueRef}; -use gix_error::{ResultExt, ValidationError}; +use gix_error::ExnMessageResult; +use gix_error::{Message, ResultExt, validation}; use gix_glob::pattern::Mode; use gix_testtools::fixture_bytes; @@ -95,18 +96,9 @@ fn exclamation_marks_must_be_escaped_or_error_unlike_gitignore() { line(r"\!hello"), (pattern(r"!hello", Mode::NO_SUB_DIR, None), vec![], 1) ); - assert!(has_validation_message( - try_line(r"!hello"), - r"Line 1 has a negative pattern, for literal characters use \!" - )); + insta::assert_debug_snapshot!(assert_validation(try_line(r"!hello")), "exclamation marks must be escaped or error unlike gitignore", @r#"Line 1 has a negative pattern, for literal characters use \!, "input"="!hello""#); assert!(lenient_lines(r#"!hello"#).is_empty()); - assert!( - has_validation_message( - try_line(r#""!hello""#), - r"Line 1 has a negative pattern, for literal characters use \!" - ), - "even in quotes they trigger…" - ); + insta::assert_debug_snapshot!(assert_validation(try_line(r#""!hello""#)), "even in quotes they trigger…", @r#"Line 1 has a negative pattern, for literal characters use \!, "input"="!hello""#); assert!(lenient_lines(r#""!hello""#).is_empty()); assert_eq!( line(r#""\\!hello""#), @@ -196,84 +188,103 @@ fn the_macro_prefix_without_a_name_is_a_pattern() { #[test] fn custom_macros_must_be_valid_attribute_names() { - assert!(has_validation_message( - try_line(r"[attr]-prefixdash"), - "Macro in line 1 has an invalid name" - )); + insta::assert_debug_snapshot!(assert_validation(try_line(r"[attr]-prefixdash")), "custom macros must be valid attribute names", @r#" + Macro in line 1 has an invalid name + | + └─ Attribute has non-ascii characters or starts with '-', "input"="-prefixdash" + "#); assert!(lenient_lines(r"[attr]-prefixdash").is_empty()); - assert!(has_validation_message( - try_line(r"[attr]!exclamation"), - "Macro in line 1 has an invalid name" - )); - assert!(has_validation_message( - try_line(r"[attr]assignment=value"), - "Macro in line 1 has an invalid name" - )); - assert!(has_validation_message( - try_line(r"[attr]你好"), - "Macro in line 1 has an invalid name" - )); + insta::assert_debug_snapshot!(assert_validation(try_line(r"[attr]!exclamation")), "custom macros must be valid attribute names", @r#" + Macro in line 1 has an invalid name + | + └─ Attribute has non-ascii characters or starts with '-', "input"="!exclamation" + "#); + insta::assert_debug_snapshot!(assert_validation(try_line(r"[attr]assignment=value")), "custom macros must be valid attribute names", @r#" + Macro in line 1 has an invalid name + | + └─ Attribute has non-ascii characters or starts with '-', "input"="assignment=value" + "#); + insta::assert_debug_snapshot!(assert_validation(try_line(r"[attr]你好")), "custom macros must be valid attribute names", @r#" + Macro in line 1 has an invalid name + | + └─ Attribute has non-ascii characters or starts with '-', "input"="你好" + "#); assert!(lenient_lines(r"[attr]你好").is_empty()); } #[test] fn invalid_names_retain_line_context_and_the_validation_cause() { - for (input, context) in [ - ("p 你好", "Attribute in line 1 has an invalid name"), - ("[attr]你好", "Macro in line 1 has an invalid name"), - ] { + let mut error_snapshots = Vec::new(); + for input in ["p 你好", "[attr]你好"] { let err = try_line(input).unwrap_err(); - assert_eq!(err, context); + error_snapshots.push(gix_testtools::redact_debug_snapshot(&(err), &[])); let cause = err .iter() .skip(1) - .find_map(|frame| frame.error().downcast_ref::()) + .find_map(|frame| frame.error().downcast_ref::()) .expect("invalid names remain a typed validation cause"); - assert_eq!(cause.message, "Attribute has non-ascii characters or starts with '-'"); + assert_eq!(cause.class, Some(gix_error::Class::Validation)); assert_eq!( - cause.input.as_ref().map(|input| input.as_bstr()), - Some("你好".as_bytes().as_bstr()) + cause.values.get("input"), + Some(&gix_error::MetadataValue::Bytes("你好".into())) ); } + insta::assert_debug_snapshot!(error_snapshots, "invalid names retain line context and the validation cause", @r#" + [ + Attribute in line 1 has an invalid name + | + └─ Attribute has non-ascii characters or starts with '-', "input"="你好", + Macro in line 1 has an invalid name + | + └─ Attribute has non-ascii characters or starts with '-', "input"="你好", + ] + "#); } #[test] fn attribute_names_must_not_begin_with_dash_and_must_be_ascii_only() { - assert!(has_validation_message( - try_line(r"p !-a"), - "Attribute in line 1 has an invalid name" - )); + insta::assert_debug_snapshot!(assert_validation(try_line(r"p !-a")), "attribute names must not begin with dash and must be ascii only", @r#" + Attribute in line 1 has an invalid name + | + └─ Attribute has non-ascii characters or starts with '-', "input"="-a" + "#); assert!(lenient_lines(r"p !-a").is_empty()); - assert!( - has_validation_message(try_line(r#"p !!a"#), "Attribute in line 1 has an invalid name"), - "exclamation marks aren't allowed either" - ); + insta::assert_debug_snapshot!(assert_validation(try_line(r#"p !!a"#)), "exclamation marks aren't allowed either", @r#" + Attribute in line 1 has an invalid name + | + └─ Attribute has non-ascii characters or starts with '-', "input"="!a" + "#); assert!(lenient_lines(r#"p !!a"#).is_empty()); - assert!( - has_validation_message(try_line(r#"p 你好"#), "Attribute in line 1 has an invalid name"), - "nor is utf-8 encoded characters - gitoxide could consider to relax this when established" - ); + insta::assert_debug_snapshot!(assert_validation(try_line(r#"p 你好"#)), "nor is utf-8 encoded characters - gitoxide could consider to relax this when established", @r#" + Attribute in line 1 has an invalid name + | + └─ Attribute has non-ascii characters or starts with '-', "input"="你好" + "#); assert!(lenient_lines(r#"p 你好"#).is_empty()); } #[test] fn attribute_names_must_not_be_empty() { - assert!( - has_validation_message(try_line(r"p text =lf"), "Attribute in line 1 has an invalid name"), - "a blank in front of the equals sign leaves the assignment without a name" - ); - assert!( - has_validation_message(try_line(r"p ="), "Attribute in line 1 has an invalid name"), - "an assignment that is nothing but an equals sign has no name either" - ); - assert!( - has_validation_message(try_line(r"p -"), "Attribute in line 1 has an invalid name"), - "prefixes need a name to apply to" - ); - assert!( - has_validation_message(try_line(r"p !"), "Attribute in line 1 has an invalid name"), - "the unspecified prefix needs one as well" - ); + insta::assert_debug_snapshot!(assert_validation(try_line(r"p text =lf")), "a blank in front of the equals sign leaves the assignment without a name", @r#" + Attribute in line 1 has an invalid name + | + └─ Attribute has non-ascii characters or starts with '-', "input"="" + "#); + insta::assert_debug_snapshot!(assert_validation(try_line(r"p =")), "an assignment that is nothing but an equals sign has no name either", @r#" + Attribute in line 1 has an invalid name + | + └─ Attribute has non-ascii characters or starts with '-', "input"="" + "#); + insta::assert_debug_snapshot!(assert_validation(try_line(r"p -")), "prefixes need a name to apply to", @r#" + Attribute in line 1 has an invalid name + | + └─ Attribute has non-ascii characters or starts with '-', "input"="" + "#); + insta::assert_debug_snapshot!(assert_validation(try_line(r"p !")), "the unspecified prefix needs one as well", @r#" + Attribute in line 1 has an invalid name + | + └─ Attribute has non-ascii characters or starts with '-', "input"="" + "#); assert!( gix_attributes::NameRef::try_from(bstr::BStr::new(b"")).is_err(), "names can't be created empty either, which `attr_name_valid()` rejects via `namelen <= 0`" @@ -282,28 +293,22 @@ fn attribute_names_must_not_be_empty() { #[test] fn attribute_names_must_not_use_the_reserved_builtin_prefix() { - assert!( - has_validation_message( - try_line(r"p builtin_objectmode"), - "Attribute in line 1 has an invalid name" - ), - "Git reserves 'builtin_' for built-in attributes and drops lines that assign to it" - ); + insta::assert_debug_snapshot!(assert_validation(try_line(r"p builtin_objectmode")), "Git reserves 'builtin_' for built-in attributes and drops lines that assign to it", @r#" + Attribute in line 1 has an invalid name + | + └─ Attribute name uses the reserved 'builtin_' prefix, "input"="builtin_objectmode" + "#); assert!(lenient_lines(r"p builtin_objectmode").is_empty()); - assert!( - has_validation_message( - try_line(r"p -builtin_objectmode"), - "Attribute in line 1 has an invalid name" - ), - "the prefix is checked after '-' and '!' are stripped, just like in `parse_attr()`" - ); - assert!( - has_validation_message( - try_line(r"[attr]builtin_macro -text"), - "Macro in line 1 has an invalid name" - ), - "macro names are checked against the reserved namespace as well" - ); + insta::assert_debug_snapshot!(assert_validation(try_line(r"p -builtin_objectmode")), "the prefix is checked after '-' and '!' are stripped, just like in `parse_attr()`", @r#" + Attribute in line 1 has an invalid name + | + └─ Attribute name uses the reserved 'builtin_' prefix, "input"="builtin_objectmode" + "#); + insta::assert_debug_snapshot!(assert_validation(try_line(r"[attr]builtin_macro -text")), "macro names are checked against the reserved namespace as well", @r#" + Macro in line 1 has an invalid name + | + └─ Attribute name uses the reserved 'builtin_' prefix, "input"="builtin_macro" + "#); assert_eq!( line(r"p builtin"), (pattern("p", Mode::NO_SUB_DIR, None), vec![set("builtin")], 1), @@ -361,25 +366,26 @@ fn only_ascii_blanks_separate_attributes() { ), "a non-breaking space belongs to the value it appears in" ); - assert!( - has_validation_message( - try_line("p text\u{a0}eol=lf"), - "Attribute in line 1 has an invalid name" - ), - "in a name it makes the whole name invalid" - ); - assert!( - has_validation_message(try_line("p a\u{b}b"), "Attribute in line 1 has an invalid name"), - "a vertical tab is part of the name, not a separator" - ); - assert!( - has_validation_message(try_line("p a\u{c}b"), "Attribute in line 1 has an invalid name"), - "a form feed is part of the name, not a separator" - ); - assert!( - has_validation_message(try_line("p a\u{2028}b"), "Attribute in line 1 has an invalid name"), - "vertical tabs, form feeds and unicode line separators aren't blanks either" - ); + insta::assert_debug_snapshot!(assert_validation(try_line("p text\u{a0}eol=lf")), "in a name it makes the whole name invalid", @r#" + Attribute in line 1 has an invalid name + | + └─ Attribute has non-ascii characters or starts with '-', "input"="text\u{a0}eol" + "#); + insta::assert_debug_snapshot!(assert_validation(try_line("p a\u{b}b")), "a vertical tab is part of the name, not a separator", @r#" + Attribute in line 1 has an invalid name + | + └─ Attribute has non-ascii characters or starts with '-', "input"="a\x0bb" + "#); + insta::assert_debug_snapshot!(assert_validation(try_line("p a\u{c}b")), "a form feed is part of the name, not a separator", @r#" + Attribute in line 1 has an invalid name + | + └─ Attribute has non-ascii characters or starts with '-', "input"="a\x0cb" + "#); + insta::assert_debug_snapshot!(assert_validation(try_line("p a\u{2028}b")), "vertical tabs, form feeds and unicode line separators aren't blanks either", @r#" + Attribute in line 1 has an invalid name + | + └─ Attribute has non-ascii characters or starts with '-', "input"="a\u{2028}b" + "#); } #[test] @@ -483,11 +489,16 @@ fn pattern(name: &str, flags: gix_glob::pattern::Mode, first_wildcard_pos: Optio }) } -fn has_validation_message(result: Result, expected: &str) -> bool { - result.is_err_and(|err| err.message == expected) +fn assert_validation(result: ExnMessageResult) -> gix_error::Exn { + let err = result.err().expect("attribute input must be rejected"); + assert!( + err.is_validation(), + "invalid attribute syntax retains its classification" + ); + err } -fn try_line(input: &str) -> Result, parse::Error> { +fn try_line(input: &str) -> ExnMessageResult> { let mut lines = gix_attributes::parse(input.as_bytes()); let res = expand(lines.next().unwrap())?; assert!(lines.next().is_none(), "expected only one line"); @@ -502,7 +513,7 @@ fn byte_line(input: &[u8]) -> ExpandedAttribute<'_> { try_byte_line(input).unwrap() } -fn try_byte_line(input: &[u8]) -> Result, parse::Error> { +fn try_byte_line(input: &[u8]) -> ExnMessageResult> { let mut lines = gix_attributes::parse(input); let res = expand(lines.next().unwrap())?; assert!(lines.next().is_none(), "expected only one line"); @@ -516,17 +527,15 @@ fn lenient_lines(input: &str) -> Vec> { .collect() } -fn try_lines(input: &str) -> Result>, parse::Error> { +fn try_lines(input: &str) -> ExnMessageResult>> { gix_attributes::parse(input.as_bytes()).map(expand).collect() } -fn expand( - input: Result<(parse::Kind, parse::Iter<'_>, usize), parse::Error>, -) -> Result, parse::Error> { +fn expand(input: ExnMessageResult<(parse::Kind, parse::Iter<'_>, usize)>) -> ExnMessageResult> { let (pattern, attrs, line_no) = input?; let attrs = attrs .map(|r| r.map(|attr| (attr.name.as_str().into(), attr.state))) .collect::, _>>() - .or_raise(|| ValidationError::new(format!("Attribute in line {line_no} has an invalid name")))?; + .or_raise(|| validation(format!("Attribute in line {line_no} has an invalid name")))?; Ok((pattern, attrs, line_no)) } diff --git a/gix-attributes/tests/attributes/search.rs b/gix-attributes/tests/attributes/search.rs index 9a17ca7621e..e56acd9e571 100644 --- a/gix-attributes/tests/attributes/search.rs +++ b/gix-attributes/tests/attributes/search.rs @@ -63,7 +63,7 @@ mod specials { } #[test] -fn baseline() -> crate::Result { +fn baseline() -> gix_error::TestResult { let mut buf = Vec::new(); // Due to the way our setup differs from gits dynamic stack (which involves trying to read files from disk // by path) we can only test one case baseline, so we require multiple platforms (or filesystems) to run this. @@ -129,7 +129,7 @@ fn assert_references(out: &Outcome) { } #[test] -fn all_attributes_are_listed_in_declaration_order() -> crate::Result { +fn all_attributes_are_listed_in_declaration_order() -> gix_error::TestResult { let (mut group, mut collection, base, input) = baseline::user_attributes("lookup-order")?; let mut buf = Vec::new(); @@ -226,7 +226,7 @@ fn all_attributes_are_listed_in_declaration_order() -> crate::Result { } #[test] -fn given_attributes_are_made_available_in_given_order() -> crate::Result { +fn given_attributes_are_made_available_in_given_order() -> gix_error::TestResult { let (mut group, mut collection, base, input) = baseline::user_attributes_named_baseline("lookup-order", "baseline.selected")?; @@ -268,13 +268,13 @@ fn given_attributes_are_made_available_in_given_order() -> crate::Result { } #[test] -fn macro_attributes_expand_only_when_macro_is_set() -> crate::Result { +fn macro_attributes_expand_only_when_macro_is_set() -> gix_error::TestResult { assert_baseline("macro-expansion")?; Ok(()) } #[test] -fn attribute_tokenisation_matches_git() -> crate::Result { +fn attribute_tokenisation_matches_git() -> gix_error::TestResult { assert_baseline("tokenisation")?; Ok(()) } diff --git a/gix-bitmap/src/ewah.rs b/gix-bitmap/src/ewah.rs index f8b308cfd4a..b2f7fceece1 100644 --- a/gix-bitmap/src/ewah.rs +++ b/gix-bitmap/src/ewah.rs @@ -1,16 +1,11 @@ -/// -pub mod decode { - /// The error returned by [`decode()`](super::decode()). - pub type Error = gix_error::Exn; -} - +use gix_error::ExnMessageResult; /// Decode `data` as EWAH bitmap. -pub fn decode(data: &[u8]) -> Result<(Vec, &[u8]), decode::Error> { +pub fn decode(data: &[u8]) -> ExnMessageResult<(Vec, &[u8])> { use crate::decode; - use gix_error::{OptionExt, message}; + use gix_error::{OptionExt, validation}; - let (num_bits, data) = decode::u32(data).ok_or_raise(|| message("eof reading amount of bits").into())?; - let (len, data) = decode::u32(data).ok_or_raise(|| message("eof reading chunk length").into())?; + let (num_bits, data) = decode::u32(data).ok_or_raise(|| validation("eof reading amount of bits"))?; + let (len, data) = decode::u32(data).ok_or_raise(|| validation("eof reading chunk length"))?; let len = len as usize; // NOTE: git does this by copying all bytes first, and then it will change the endianness in a separate loop. @@ -18,10 +13,10 @@ pub fn decode(data: &[u8]) -> Result<(Vec, &[u8]), decode::Error> { // one day somebody will find out that it's worth it to use unsafe here. let word_bytes_len = len .checked_mul(std::mem::size_of::()) - .ok_or_raise(|| message("chunk length overflows size calculation").into())?; + .ok_or_raise(|| validation("chunk length overflows size calculation"))?; let (mut bits, data) = data .split_at_checked(word_bytes_len) - .ok_or_raise(|| message("eof while reading bit data").into())?; + .ok_or_raise(|| validation("eof while reading bit data"))?; let mut buf = std::vec::Vec::::with_capacity(len); for _ in 0..len { let (bit_num, rest) = bits.split_at(std::mem::size_of::()); @@ -29,7 +24,7 @@ pub fn decode(data: &[u8]) -> Result<(Vec, &[u8]), decode::Error> { buf.push(u64::from_be_bytes(bit_num.try_into().unwrap())); } - let (rlw, data) = decode::u32(data).ok_or_raise(|| message("eof while reading run length width").into())?; + let (rlw, data) = decode::u32(data).ok_or_raise(|| validation("eof while reading run length width"))?; Ok(( Vec { @@ -42,6 +37,8 @@ pub fn decode(data: &[u8]) -> Result<(Vec, &[u8]), decode::Error> { } mod access { + use gix_error::{ResultExt, validation}; + use super::Vec; impl Vec { @@ -77,15 +74,12 @@ mod access { /// /// These bytes can be parsed again with [`decode()`](super::decode()). pub fn write_to(&self, out: &mut impl std::io::Write) -> std::io::Result<()> { - let len: u32 = self.bits.len().try_into().map_err(|_| { - std::io::Error::new(std::io::ErrorKind::InvalidInput, "bit word count exceeds u32::MAX") - })?; - let rlw: u32 = self.rlw.try_into().map_err(|_| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "run length word offset exceeds u32::MAX", - ) - })?; + let len = u32::try_from(self.bits.len()) + .or_raise(|| validation("bit word count exceeds u32::MAX")) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err.into_error()))?; + let rlw = u32::try_from(self.rlw) + .or_raise(|| validation("run length word offset exceeds u32::MAX")) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err.into_error()))?; out.write_all(&self.num_bits.to_be_bytes())?; out.write_all(&len.to_be_bytes())?; diff --git a/gix-blame/Cargo.toml b/gix-blame/Cargo.toml index 82deb566de5..105af2bcc34 100644 --- a/gix-blame/Cargo.toml +++ b/gix-blame/Cargo.toml @@ -30,9 +30,9 @@ gix-worktree = { version = "^0.56.0", path = "../gix-worktree", default-features gix-traverse = { version = "^0.61.0", path = "../gix-traverse" } smallvec = "1.15.1" -thiserror = "2.0.18" [dev-dependencies] +insta = "1.46.3" gix-hash = { path = "../gix-hash", features = ["sha1", "sha256"] } gix-ref = { path = "../gix-ref", features = ["sha1", "sha256"] } gix-filter = { path = "../gix-filter" } diff --git a/gix-blame/src/error.rs b/gix-blame/src/error.rs deleted file mode 100644 index 9cedec7b600..00000000000 --- a/gix-blame/src/error.rs +++ /dev/null @@ -1,40 +0,0 @@ -use gix_object::bstr::BString; - -/// The error returned by [file()](crate::file()). -#[derive(Debug, thiserror::Error)] -#[expect(missing_docs)] -pub enum Error { - #[error("No commit was given")] - EmptyTraversal, - #[error(transparent)] - BlobDiffSetResource(#[from] gix_diff::blob::platform::set_resource::Error), - #[error(transparent)] - BlobDiffPrepare(#[from] gix_diff::blob::platform::prepare_diff::Error), - #[error("The file to blame at '{file_path}' wasn't found in the first commit at {commit_id}")] - FileMissing { - /// The file-path to the object to blame. - file_path: BString, - /// The commit whose tree didn't contain `file_path`. - commit_id: gix_hash::ObjectId, - }, - #[error("Couldn't find commit or tree in the object database")] - FindObject(#[from] gix_object::find::Error), - #[error("Could not find existing blob or commit")] - FindExistingObject(#[from] gix_object::find::existing_object::Error), - #[error("Could not find existing iterator over a tree")] - FindExistingIter(#[from] gix_object::find::existing_iter::Error), - #[error("Failed to obtain the next commit in the commit-graph traversal")] - Traverse(#[source] Box), - #[error(transparent)] - DiffTree(#[from] gix_diff::tree::Error), - #[error(transparent)] - DiffTreeWithRewrites(#[from] gix_diff::tree_with_rewrites::Error), - #[error( - "Invalid line range was given, line range is expected to be a 1-based inclusive range in the format ','" - )] - InvalidOneBasedLineRange, - #[error("Failure to decode commit during traversal")] - DecodeCommit(#[from] gix_object::decode::Error), - #[error("Failed to get parent from commitgraph during traversal")] - GetParentFromCommitGraph(#[from] gix_error::Message), -} diff --git a/gix-blame/src/file/function.rs b/gix-blame/src/file/function.rs index 047ed5a3384..4ab550e081f 100644 --- a/gix-blame/src/file/function.rs +++ b/gix-blame/src/file/function.rs @@ -1,6 +1,7 @@ use std::num::NonZeroU32; use gix_diff::{blob::TokenSource, tree::Visit}; +use gix_error::{ErrorExt, ExnResult, OptionExt, ResultExt, message, not_found}; use gix_hash::ObjectId; use gix_object::{ FindExt, @@ -11,7 +12,7 @@ use smallvec::SmallVec; use super::{Change, UnblamedHunk, process_changes}; use crate::{ - BlameEntry, Error, Options, Outcome, Statistics, + BlameEntry, Options, Outcome, Statistics, types::{BlamePathEntry, Start}, }; @@ -71,7 +72,7 @@ pub fn file( resource_cache: &mut gix_diff::blob::Platform, file_path: &BStr, options: Options, -) -> Result { +) -> ExnResult { let _span = gix_trace::coarse!("gix_blame::file()", ?file_path, ?start); let mut stats = Statistics::default(); @@ -107,8 +108,14 @@ pub fn file( gix_revwalk::PriorityQueue::new(); if let Some(first_suspect) = first_suspect { - let commit = find_commit(cache.as_ref(), &odb, &first_suspect, &mut buf)?; - queue.insert(commit.commit_time()?, first_suspect); + let commit = find_commit(cache.as_ref(), &odb, &first_suspect, &mut buf) + .or_raise_erased(|| message("Could not find existing iterator over a tree"))?; + queue.insert( + commit + .commit_time() + .or_raise_erased(|| message("Failure to decode commit during traversal"))?, + first_suspect, + ); } let mut diff_state = gix_diff::tree::State::default(); @@ -137,8 +144,11 @@ pub fn file( .clone() .unwrap_or_else(|| file_path.to_owned()); - let commit = find_commit(cache.as_ref(), &odb, &suspect, &mut buf)?; - let commit_time = commit.commit_time()?; + let commit = find_commit(cache.as_ref(), &odb, &suspect, &mut buf) + .or_raise_erased(|| message("Could not find existing iterator over a tree"))?; + let commit_time = commit + .commit_time() + .or_raise_erased(|| message("Failure to decode commit during traversal"))?; if let Some(since) = options.since && commit_time < since.seconds @@ -208,7 +218,11 @@ pub fn file( // identical to the corresponding lines in the *Source File*. #[cfg(debug_assertions)] { - let source_blob = odb.find_blob(&entry_id, &mut buf)?.data.to_vec(); + let source_blob = odb + .find_blob(&entry_id, &mut buf) + .or_raise_erased(|| message("Could not find existing blob or commit"))? + .data + .to_vec(); let mut source_interner = gix_diff::blob::Interner::new(source_blob.len() / 100); let source_lines_as_tokens: Vec<_> = tokens_for_diffing(&source_blob) .tokenize() @@ -566,15 +580,25 @@ fn tree_diff_at_file_path( lhs_tree_buf: &mut Vec, rhs_tree_buf: &mut Vec, rewrites: Option, -) -> Result, Error> { - let parent_tree_id = find_commit(cache, &odb, &parent_id, commit_buf)?.tree_id()?; - - let parent_tree_iter = odb.find_tree_iter(&parent_tree_id, lhs_tree_buf)?; +) -> ExnResult> { + let parent_tree_id = find_commit(cache, &odb, &parent_id, commit_buf) + .or_raise_erased(|| message("Could not find existing iterator over a tree"))? + .tree_id() + .or_raise_erased(|| message("Failure to decode commit during traversal"))?; + + let parent_tree_iter = odb + .find_tree_iter(&parent_tree_id, lhs_tree_buf) + .or_raise_erased(|| message("Could not find existing iterator over a tree"))?; stats.trees_decoded += 1; - let tree_id = find_commit(cache, &odb, &id, commit_buf)?.tree_id()?; + let tree_id = find_commit(cache, &odb, &id, commit_buf) + .or_raise_erased(|| message("Could not find existing iterator over a tree"))? + .tree_id() + .or_raise_erased(|| message("Failure to decode commit during traversal"))?; - let tree_iter = odb.find_tree_iter(&tree_id, rhs_tree_buf)?; + let tree_iter = odb + .find_tree_iter(&tree_id, rhs_tree_buf) + .or_raise_erased(|| message("Could not find existing iterator over a tree"))?; stats.trees_decoded += 1; let result = tree_diff_without_rewrites_at_file_path(&odb, file_path, stats, state, parent_tree_iter, tree_iter)?; @@ -613,7 +637,7 @@ fn tree_diff_without_rewrites_at_file_path( state: &mut gix_diff::tree::State, parent_tree_iter: gix_object::TreeRefIter<'_>, tree_iter: gix_object::TreeRefIter<'_>, -) -> Result, Error> { +) -> ExnResult> { struct FindChangeToPath { inner: gix_diff::tree::Recorder, interesting_path: BString, @@ -702,7 +726,7 @@ fn tree_diff_without_rewrites_at_file_path( match result { Ok(_) | Err(gix_diff::tree::Error::Cancelled) => Ok(recorder.change.map(Into::into)), - Err(error) => Err(Error::DiffTree(error)), + Err(error) => Err(error.raise_erased()), } } @@ -716,7 +740,7 @@ fn tree_diff_with_rewrites_at_file_path( parent_tree_iter: gix_object::TreeRefIter<'_>, tree_iter: gix_object::TreeRefIter<'_>, rewrites: gix_diff::Rewrites, -) -> Result, Error> { +) -> ExnResult> { let mut change: Option = None; let options: gix_diff::tree_with_rewrites::Options = gix_diff::tree_with_rewrites::Options { @@ -729,7 +753,7 @@ fn tree_diff_with_rewrites_at_file_path( resource_cache, state, &odb, - |change_ref| -> Result<_, std::convert::Infallible> { + |change_ref| -> ExnResult<_> { if change_ref.location() == file_path { change = Some(change_ref.into_owned()); Ok(std::ops::ControlFlow::Break(())) @@ -742,10 +766,8 @@ fn tree_diff_with_rewrites_at_file_path( stats.trees_diffed_with_rewrites += 1; match result { - Ok(_) | Err(gix_diff::tree_with_rewrites::Error::Diff(gix_diff::tree::Error::Cancelled)) => { - Ok(change.map(Into::into)) - } - Err(error) => Err(Error::DiffTreeWithRewrites(error)), + Ok(_) | Err(gix_diff::tree::Error::Cancelled) => Ok(change.map(Into::into)), + Err(error) => Err(error.raise_erased()), } } @@ -759,24 +781,28 @@ fn blob_changes( previous_file_path: &BStr, diff_algorithm: gix_diff::blob::Algorithm, stats: &mut Statistics, -) -> Result, Error> { - resource_cache.set_resource( - previous_oid, - // TODO(blame): add a test to show of symlink blaming works. - gix_object::tree::EntryKind::Blob, - previous_file_path, - gix_diff::blob::ResourceKind::OldOrSource, - &odb, - )?; - resource_cache.set_resource( - oid, - gix_object::tree::EntryKind::Blob, - file_path, - gix_diff::blob::ResourceKind::NewOrDestination, - &odb, - )?; - - let outcome = resource_cache.prepare_diff()?; +) -> ExnResult> { + resource_cache + .set_resource( + previous_oid, + // TODO(blame): add a test to show of symlink blaming works. + gix_object::tree::EntryKind::Blob, + previous_file_path, + gix_diff::blob::ResourceKind::OldOrSource, + &odb, + ) + .or_erased()?; + resource_cache + .set_resource( + oid, + gix_object::tree::EntryKind::Blob, + file_path, + gix_diff::blob::ResourceKind::NewOrDestination, + &odb, + ) + .or_erased()?; + + let outcome = resource_cache.prepare_diff().or_erased()?; Ok(blob_changes_from_data( outcome.old.data.as_slice().unwrap_or_default(), @@ -843,16 +869,23 @@ fn find_path_entry_in_commit( buf: &mut Vec, buf2: &mut Vec, stats: &mut Statistics, -) -> Result, Error> { - let tree_id = find_commit(cache, odb, commit, buf)?.tree_id()?; - let tree_iter = odb.find_tree_iter(&tree_id, buf)?; +) -> ExnResult> { + let tree_id = find_commit(cache, odb, commit, buf) + .or_raise_erased(|| message("Could not find existing iterator over a tree"))? + .tree_id() + .or_raise_erased(|| message("Failure to decode commit during traversal"))?; + let tree_iter = odb + .find_tree_iter(&tree_id, buf) + .or_raise_erased(|| message("Could not find existing iterator over a tree"))?; stats.trees_decoded += 1; - let res = tree_iter.lookup_entry( - odb, - buf2, - file_path.split(|b| *b == b'/').inspect(|_| stats.trees_decoded += 1), - )?; + let res = tree_iter + .lookup_entry( + odb, + buf2, + file_path.split(|b| *b == b'/').inspect(|_| stats.trees_decoded += 1), + ) + .or_raise_erased(|| message("Couldn't find commit or tree in the object database"))?; stats.trees_decoded -= 1; Ok(res.map(|e| e.oid)) } @@ -864,7 +897,7 @@ fn collect_parents( odb: &impl gix_object::Find, cache: Option<&gix_commitgraph::Graph>, buf: &mut Vec, -) -> Result { +) -> ExnResult { let mut parent_ids: ParentIds = Default::default(); match commit { gix_traverse::commit::Either::CachedCommit(commit) => { @@ -872,7 +905,9 @@ fn collect_parents( .as_ref() .expect("find returned a cached commit, so we expect cache to be present"); for parent_pos in commit.iter_parents() { - let parent = cache.commit_at(parent_pos?); + let parent = cache.commit_at( + parent_pos.or_raise_erased(|| message("Failed to get parent from commitgraph during traversal"))?, + ); parent_ids.push((parent.id().to_owned(), parent.committer_timestamp() as i64)); } } @@ -917,15 +952,20 @@ fn initial_state( buf: &mut Vec, buf2: &mut Vec, stats: &mut Statistics, -) -> Result { +) -> ExnResult { match start { Start::Commit(suspect) => { let blamed_file_entry_id = find_path_entry_in_commit(&odb, &suspect, file_path, cache, buf, buf2, stats)? - .ok_or_else(|| Error::FileMissing { - file_path: file_path.to_owned(), - commit_id: suspect, + .ok_or_raise_erased(|| { + not_found(format!( + "The file to blame at '{file_path}' wasn't found in the first commit at {suspect}" + )) })?; - let blamed_file_blob = odb.find_blob(&blamed_file_entry_id, buf)?.data.to_vec(); + let blamed_file_blob = odb + .find_blob(&blamed_file_entry_id, buf) + .or_raise_erased(|| message("Could not find existing blob or commit"))? + .data + .to_vec(); let num_lines_in_blamed = tokens_for_diffing(&blamed_file_blob).tokenize().count() as u32; // Binary or otherwise empty? @@ -995,7 +1035,11 @@ fn initial_state( }); }; - let first_suspect_blob = odb.find_blob(&first_suspect_entry_id, buf)?.data.to_vec(); + let first_suspect_blob = odb + .find_blob(&first_suspect_entry_id, buf) + .or_raise_erased(|| message("Could not find existing blob or commit"))? + .data + .to_vec(); let changes = blob_changes_from_data(&first_suspect_blob, &blamed_file_blob, options.diff_algorithm, stats); hunks_to_blame = process_changes(hunks_to_blame, changes, null_id, first_suspect); diff --git a/gix-blame/src/file/tests.rs b/gix-blame/src/file/tests.rs index 2b60af1d4a4..84dd21afa0e 100644 --- a/gix-blame/src/file/tests.rs +++ b/gix-blame/src/file/tests.rs @@ -986,13 +986,13 @@ mod process_changes { } mod blame_ranges { - use crate::{BlameRanges, Error}; + use crate::BlameRanges; #[test] fn create_with_invalid_range() { let ranges = BlameRanges::from_one_based_inclusive_range(0..=10); - assert!(matches!(ranges, Err(Error::InvalidOneBasedLineRange))); + insta::assert_debug_snapshot!(ranges.expect_err("zero isn't a valid one-based line number"), "create with invalid range", @"Invalid line range was given, line range is expected to be a 1-based inclusive range in the format ','"); } #[test] diff --git a/gix-blame/src/lib.rs b/gix-blame/src/lib.rs index 7495189e6fc..94109e0a4ee 100644 --- a/gix-blame/src/lib.rs +++ b/gix-blame/src/lib.rs @@ -14,8 +14,6 @@ #![deny(missing_docs)] #![forbid(unsafe_code)] -mod error; -pub use error::Error; mod types; pub use types::{BlameEntry, BlamePathEntry, BlameRanges, Options, Outcome, Start, Statistics}; diff --git a/gix-blame/src/types.rs b/gix-blame/src/types.rs index c82cf6d103c..c9d28350d75 100644 --- a/gix-blame/src/types.rs +++ b/gix-blame/src/types.rs @@ -1,3 +1,5 @@ +use gix_error::ExnMessageResult; +use gix_error::validation; use gix_hash::ObjectId; use gix_object::bstr::BString; use smallvec::SmallVec; @@ -7,7 +9,6 @@ use std::{ ops::{AddAssign, Range, SubAssign}, }; -use crate::Error; use crate::file::function::tokens_for_diffing; /// A type to represent one or more line ranges to blame in a file. @@ -54,7 +55,7 @@ impl BlameRanges { /// /// Note that the input range is 1-based inclusive, as used by git, and /// the output is a zero-based `BlameRanges` instance. - pub fn from_one_based_inclusive_range(range: RangeInclusive) -> Result { + pub fn from_one_based_inclusive_range(range: RangeInclusive) -> ExnMessageResult { let zero_based_range = Self::inclusive_to_zero_based_exclusive(range)?; Ok(Self::PartialFile(vec![zero_based_range])) } @@ -65,7 +66,7 @@ impl BlameRanges { /// the output is a zero-based `BlameRanges` instance. /// /// If the input vector is empty, the result will be `WholeFile`. - pub fn from_one_based_inclusive_ranges(ranges: Vec>) -> Result { + pub fn from_one_based_inclusive_ranges(ranges: Vec>) -> ExnMessageResult { if ranges.is_empty() { return Ok(Self::WholeFile); } @@ -82,9 +83,11 @@ impl BlameRanges { } /// Convert a 1-based inclusive range to a 0-based exclusive range. - fn inclusive_to_zero_based_exclusive(range: RangeInclusive) -> Result, Error> { + fn inclusive_to_zero_based_exclusive(range: RangeInclusive) -> ExnMessageResult> { if range.start() == &0 { - return Err(Error::InvalidOneBasedLineRange); + return Err(validation( + "Invalid line range was given, line range is expected to be a 1-based inclusive range in the format ','", + ).into()); } let start = range.start() - 1; let end = *range.end(); @@ -96,7 +99,7 @@ impl BlameRanges { /// Add a single range to blame. /// /// The new range will be merged with any overlapping existing ranges. - pub fn add_one_based_inclusive_range(&mut self, new_range: RangeInclusive) -> Result<(), Error> { + pub fn add_one_based_inclusive_range(&mut self, new_range: RangeInclusive) -> ExnMessageResult { let zero_based_range = Self::inclusive_to_zero_based_exclusive(new_range)?; self.merge_zero_based_exclusive_range(zero_based_range); diff --git a/gix-blame/tests/blame.rs b/gix-blame/tests/blame.rs index 99b14c9c34d..5bbcc66c04c 100644 --- a/gix-blame/tests/blame.rs +++ b/gix-blame/tests/blame.rs @@ -1,5 +1,7 @@ use std::{collections::BTreeMap, path::PathBuf}; +use gix_error::ExnResult; + use gix_blame::BlameRanges; use gix_hash::ObjectId; use gix_object::bstr; @@ -218,7 +220,7 @@ impl Fixture { &mut self, source_file_name: &bstr::BStr, options: gix_blame::Options, - ) -> Result { + ) -> ExnResult { gix_blame::file( &self.odb, gix_blame::Start::Commit(self.suspect), @@ -234,7 +236,7 @@ impl Fixture { source_file_name: &bstr::BStr, contents: Vec, options: gix_blame::Options, - ) -> Result { + ) -> ExnResult { gix_blame::file( &self.odb, gix_blame::Start::Contents { diff --git a/gix-chunk/src/file/decode.rs b/gix-chunk/src/file/decode.rs index 9f316066a19..e297aa36a64 100644 --- a/gix-chunk/src/file/decode.rs +++ b/gix-chunk/src/file/decode.rs @@ -1,5 +1,6 @@ -use gix_error::ValidationError; +use gix_error::ExnMessageResult; use gix_error::bstr::ByteSlice; +use gix_error::{ErrorExt, validation}; use std::ops::Range; use crate::{file, file::index}; @@ -7,11 +8,12 @@ use crate::{file, file::index}; impl file::Index { /// Provided a mapped file at the beginning via `data`, starting at `toc_offset` decode all chunk information to return /// an index with `num_chunks` chunks. - pub fn from_bytes(data: &[u8], toc_offset: usize, num_chunks: u32) -> Result { + pub fn from_bytes(data: &[u8], toc_offset: usize, num_chunks: u32) -> ExnMessageResult { if num_chunks == 0 { - return Err(ValidationError::new( + return Err(validation( "Empty chunk indices are not allowed as the point of chunked files is to have chunks.", - )); + ) + .into()); } let data_len: u64 = data.len() as u64; @@ -19,42 +21,47 @@ impl file::Index { let mut toc_entry = &data[toc_offset..]; let expected_min_size = (num_chunks as usize + 1) * file::Index::ENTRY_SIZE; if toc_entry.len() < expected_min_size { - Err(format!( + return Err(validation(format!( "The table of contents would be {expected_min_size} bytes, but got only {toc_entry_len}", toc_entry_len = toc_entry.len() - ))?; + )) + .raise()); } for chunk_idx in 0..num_chunks { let (kind, offset) = toc_entry.split_at(4); let kind = to_kind(kind); if kind == crate::SENTINEL { - Err(format!( + return Err(validation(format!( "Sentinel value encountered while processing chunks {chunk_idx} of {num_chunks}" - ))?; + )) + .raise()); } if chunks.iter().any(|c: &index::Entry| c.kind == kind) { - Err(format!( + return Err(validation(format!( "The chunk of kind '{}' was encountered more than once", kind.as_bstr() - ))?; + )) + .raise()); } let offset = be_u64(offset); if offset > data_len { - Err(format!( + return Err(validation(format!( "The chunk offset {offset} went past the file of length {data_len} - was it truncated?", - ))?; + )) + .raise()); } toc_entry = &toc_entry[file::Index::ENTRY_SIZE..]; let next_offset = be_u64(&toc_entry[4..]); if next_offset > data_len { - Err(format!( + return Err(validation(format!( "The chunk offset {next_offset} went past the file of length {data_len} - was it truncated?" - ))?; + )) + .raise()); } if next_offset <= offset { - Err("All chunk offsets must be incrementing.")?; + return Err(validation("All chunk offsets must be incrementing.").raise()); } chunks.push(index::Entry { kind, @@ -67,7 +74,7 @@ impl file::Index { let sentinel = to_kind(&toc_entry[..4]); if sentinel != crate::SENTINEL { - Err(format!("Sentinel value wasn't found, saw '{}'", sentinel.as_bstr()))?; + return Err(validation(format!("Sentinel value wasn't found, saw '{}'", sentinel.as_bstr())).raise()); } Ok(file::Index { diff --git a/gix-chunk/src/file/index.rs b/gix-chunk/src/file/index.rs index f2ead63d19c..1cc6877a2a7 100644 --- a/gix-chunk/src/file/index.rs +++ b/gix-chunk/src/file/index.rs @@ -1,3 +1,4 @@ +use gix_error::ExnMessageResult; use std::ops::Range; use crate::Id; @@ -25,11 +26,12 @@ impl Index { } /// Find a chunk of `kind` and return its offset into the data if found - pub fn offset_by_id(&self, kind: Id) -> Result, Message> { - self.chunks + pub fn offset_by_id(&self, kind: Id) -> ExnMessageResult> { + Ok(self + .chunks .iter() .find_map(|c| (c.kind == kind).then(|| c.offset.clone())) - .ok_or_else(make_message(kind)) + .ok_or_else(make_message(kind))?) } /// Find a chunk of `kind` and return its offset as usize range into the data if found. @@ -39,11 +41,12 @@ impl Index { /// /// - if the usize conversion fails, which isn't expected as memory maps can't be created if files are too large /// to require such offsets. - pub fn usize_offset_by_id(&self, kind: Id) -> Result, Message> { - self.chunks + pub fn usize_offset_by_id(&self, kind: Id) -> ExnMessageResult> { + Ok(self + .chunks .iter() .find_map(|c| (c.kind == kind).then(|| crate::range::into_usize_or_panic(c.offset.clone()))) - .ok_or_else(make_message(kind)) + .ok_or_else(make_message(kind))?) } /// Like [`Index::usize_offset_by_id()`] but with support for validation and transformation using a function. @@ -51,16 +54,17 @@ impl Index { &self, kind: Id, validate: impl FnOnce(Range) -> T, - ) -> Result { - self.chunks + ) -> ExnMessageResult { + Ok(self + .chunks .iter() .find_map(|c| (c.kind == kind).then(|| crate::range::into_usize_or_panic(c.offset.clone()))) .map(validate) - .ok_or_else(make_message(kind)) + .ok_or_else(make_message(kind))?) } /// Find a chunk of `kind` and return its data slice based on its offset. - pub fn data_by_id<'a>(&self, data: &'a [u8], kind: Id) -> Result<&'a [u8], Message> { + pub fn data_by_id<'a>(&self, data: &'a [u8], kind: Id) -> ExnMessageResult<&'a [u8]> { let offset = self.offset_by_id(kind)?; Ok(&data[crate::range::into_usize(offset) .ok_or_else(|| message("The offsets into the file couldn't be represented by usize"))?]) diff --git a/gix-chunk/src/lib.rs b/gix-chunk/src/lib.rs index 9cc8d27754c..fc0a8e70c24 100644 --- a/gix-chunk/src/lib.rs +++ b/gix-chunk/src/lib.rs @@ -5,7 +5,7 @@ //! ## Examples //! //! ``` -//! # fn main() -> Result<(), Box> { +//! # fn main() -> Result<(), Box> { //! use std::io::Write; //! //! let mut index = gix_chunk::file::Index::for_writing(); diff --git a/gix-chunk/tests/decode.rs b/gix-chunk/tests/decode.rs new file mode 100644 index 00000000000..b9b47153b71 --- /dev/null +++ b/gix-chunk/tests/decode.rs @@ -0,0 +1,79 @@ +use gix_chunk::{Id, SENTINEL, file::Index}; + +#[test] +fn malformed_chunk_tables_are_validation_errors() { + let cases = [ + ( + "empty index", + Vec::new(), + 0, + "Empty chunk indices are not allowed as the point of chunked files is to have chunks.", + ), + ( + "truncated table", + Vec::new(), + 1, + "The table of contents would be 24 bytes, but got only 0", + ), + ( + "early sentinel", + chunk_file(&[(SENTINEL, 24), (SENTINEL, 26)]), + 1, + "Sentinel value encountered while processing chunks 0 of 1", + ), + ( + "duplicate chunk", + chunk_file(&[(*b"DATA", 36), (*b"DATA", 37), (SENTINEL, 38)]), + 2, + "The chunk of kind 'DATA' was encountered more than once", + ), + ( + "chunk offset past the file", + chunk_file(&[(*b"DATA", 27), (SENTINEL, 26)]), + 1, + "The chunk offset 27 went past the file of length 26 - was it truncated?", + ), + ( + "next chunk offset past the file", + chunk_file(&[(*b"DATA", 24), (SENTINEL, 27)]), + 1, + "The chunk offset 27 went past the file of length 26 - was it truncated?", + ), + ( + "equal offsets", + chunk_file(&[(*b"DATA", 24), (SENTINEL, 24)]), + 1, + "All chunk offsets must be incrementing.", + ), + ( + "decreasing offsets", + chunk_file(&[(*b"DATA", 25), (SENTINEL, 24)]), + 1, + "All chunk offsets must be incrementing.", + ), + ( + "missing sentinel", + chunk_file(&[(*b"DATA", 24), (*b"MISS", 26)]), + 1, + "Sentinel value wasn't found, saw 'MISS'", + ), + ]; + + for (case, data, num_chunks, expected_message) in cases { + let err = Index::from_bytes(&data, 0, num_chunks) + .err() + .expect("malformed chunk tables must be rejected"); + assert_eq!(err.to_string(), expected_message, "{case} must retain its diagnostic"); + assert!(err.is_validation(), "{case} must be classified as invalid input: {err}"); + } +} + +fn chunk_file(entries: &[(Id, u64)]) -> Vec { + let mut data = Vec::new(); + for (kind, offset) in entries { + data.extend_from_slice(kind); + data.extend_from_slice(&offset.to_be_bytes()); + } + data.extend_from_slice(b"ab"); + data +} diff --git a/gix-command/Cargo.toml b/gix-command/Cargo.toml index a7511fb29fe..222227c9720 100644 --- a/gix-command/Cargo.toml +++ b/gix-command/Cargo.toml @@ -15,6 +15,7 @@ include = ["/src/*.rs", "/LICENSE-*"] doctest = true [dependencies] +gix-error = { version = "^0.3.0", path = "../gix-error" } gix-trace = { version = "^0.1.20", path = "../gix-trace" } gix-path = { version = "^0.12.6", path = "../gix-path" } gix-quote = { version = "^0.8.0", path = "../gix-quote" } @@ -22,4 +23,5 @@ gix-quote = { version = "^0.8.0", path = "../gix-quote" } bstr = { version = "1.12.0", default-features = false, features = ["std", "unicode"] } [dev-dependencies] +insta = "1.46.3" gix-testtools = { path = "../tests/tools", features = ["sha1"] } diff --git a/gix-command/src/parse.rs b/gix-command/src/parse.rs index 5c4b40dda95..7471fd4b9ca 100644 --- a/gix-command/src/parse.rs +++ b/gix-command/src/parse.rs @@ -1,6 +1,7 @@ use std::ffi::OsString; use bstr::{BStr, BString}; +use gix_error::{ExnResult, ResultExt}; /// The result of [`command_line()`]. #[derive(Debug, Clone, PartialEq, Eq)] @@ -14,6 +15,10 @@ pub struct Outcome { } /// The error returned when a command line cannot be parsed into a command. +/// +/// Its source is a classification-only [`gix_error::ClassificationMarker`]. +/// Use [`gix_error::classify()`] or `is_validation()` on [`gix_error::Exn`] and [`gix_error::Error`] to check the classification. +/// Downcast to this type for the parser failure. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Error { /// A quote was opened but never closed. @@ -39,7 +44,11 @@ impl std::fmt::Display for Error { } } -impl std::error::Error for Error {} +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(const { &gix_error::ClassificationMarker::VALIDATION }) + } +} #[derive(Clone, Copy, PartialEq, Eq)] enum Quote { @@ -61,7 +70,7 @@ struct Word { /// shell identifier. Assignment-only input is rejected because it contains no command to execute. Environment /// assignment names are strings, while their values, the command, and arguments are converted losslessly to OS /// strings or rejected if the platform cannot represent them. -pub fn command_line(input: &BStr) -> Result { +pub fn command_line(input: &BStr) -> ExnResult { let mut words = parse_words(input)?; let assignment_count = words .iter() @@ -79,7 +88,7 @@ pub fn command_line(input: &BStr) -> Result { into_os_string(word.value[separator + 1..].to_owned().into())?, )) }) - .collect::>()?; + .collect::>()?; Ok(Outcome { env, command, @@ -87,7 +96,7 @@ pub fn command_line(input: &BStr) -> Result { }) } -pub(crate) fn arguments(input: &BStr) -> Result, Error> { +pub(crate) fn arguments(input: &BStr) -> ExnResult, Error> { parse_words(input)? .into_iter() .map(|word| into_os_string(word.value)) @@ -187,8 +196,8 @@ fn push_unquoted( value.push(byte); } -fn into_os_string(value: BString) -> Result { +fn into_os_string(value: BString) -> ExnResult { gix_path::try_from_bstring(value) .map(std::path::PathBuf::into_os_string) - .map_err(|_| Error::UnrepresentableOsString) + .or_raise(|| Error::UnrepresentableOsString) } diff --git a/gix-command/tests/command/command_line.rs b/gix-command/tests/command/command_line.rs index b588322cbca..2b2f975e1f6 100644 --- a/gix-command/tests/command/command_line.rs +++ b/gix-command/tests/command/command_line.rs @@ -8,8 +8,8 @@ fn words_are_split_without_expansion() -> gix_testtools::Result { command_line( r#"cmd 'single quoted' "double \"quoted\"" escaped\ word "kept\q" "" # ignored next"#, - ), - Ok(Outcome { + )?, + Outcome { env: Vec::new(), command: "cmd".into(), args: args(&[ @@ -20,7 +20,7 @@ next"#, "", "next" ]), - }) + } ); assert_eq!( command_line("cmd one\\\ntwo")?.args, @@ -64,21 +64,105 @@ fn invalid_assignment_names_are_arguments() -> gix_testtools::Result { #[test] fn unterminated_quotes_are_rejected() { - assert_eq!(command_line("cmd '"), Err(parse::Error::MissingClosingQuote)); - assert_eq!(command_line("cmd \""), Err(parse::Error::MissingClosingQuote)); - assert_eq!(command_line("cmd \"\\"), Err(parse::Error::MissingClosingQuote)); + let mut error_snapshots = Vec::new(); + for input in ["cmd '", "cmd \"", "cmd \"\\"] { + let err = parse::command_line(input.into()).expect_err("unterminated quote"); + assert_eq!(*err, parse::Error::MissingClosingQuote); + error_snapshots.push((input, gix_testtools::redact_debug_snapshot(&err, &[]))); + } + insta::assert_debug_snapshot!(error_snapshots, "unterminated quotes are rejected", @r#" + [ + ( + "cmd '", + missing closing quote, + ), + ( + "cmd \"", + missing closing quote, + ), + ( + "cmd \"\\", + missing closing quote, + ), + ] + "#); } #[test] fn dangling_unquoted_escape_is_rejected() { - assert_eq!(command_line("cmd arg\\"), Err(parse::Error::MissingEscapedByte)); + let err = parse::command_line("cmd arg\\".into()).expect_err("dangling escape"); + assert_eq!(*err, parse::Error::MissingEscapedByte); + insta::assert_debug_snapshot!(err, "dangling unquoted escape is rejected", @"missing byte after escape"); } #[test] fn a_command_is_required() { + let mut error_snapshots = Vec::new(); for input in ["", " ", "\t\n", "# comment", "\\\n", "tool=name", "FOO=one BAR=two"] { - assert_eq!(command_line(input), Err(parse::Error::MissingCommand), "{input:?}"); + let err = parse::command_line(input.into()).expect_err("no command"); + assert_eq!(*err, parse::Error::MissingCommand, "{input:?}"); + error_snapshots.push((input, gix_testtools::redact_debug_snapshot(&err, &[]))); + } + insta::assert_debug_snapshot!(error_snapshots, "a command is required", @r##" + [ + ( + "", + missing command, + ), + ( + " ", + missing command, + ), + ( + "\t\n", + missing command, + ), + ( + "# comment", + missing command, + ), + ( + "\\\n", + missing command, + ), + ( + "tool=name", + missing command, + ), + ( + "FOO=one BAR=two", + missing command, + ), + ] + "##); +} + +#[test] +fn parse_errors_retain_their_classification() { + let mut error_snapshots = Vec::new(); + for input in ["cmd '", "cmd arg\\", "FOO=one"] { + let err = parse::command_line(input.into()).expect_err("the command line is invalid"); + let cause = *err; + error_snapshots.push(gix_testtools::redact_debug_snapshot(&(err), &[])); + assert!(err.is_validation(), "invalid commands classify as validation failures"); + assert_eq!( + err.probable_cause().downcast_ref::(), + Some(&cause), + "the parser error, not its classification marker, is the probable cause" + ); + assert_eq!( + err.downcast_any_ref::(), + Some(&cause), + "the original parser error remains available" + ); } + insta::assert_debug_snapshot!(error_snapshots, "parse errors retain their classification", @" + [ + missing closing quote, + missing byte after escape, + missing command, + ] + "); } #[test] @@ -88,7 +172,7 @@ fn non_utf8_input_is_preserved() -> gix_testtools::Result { use std::os::unix::ffi::OsStringExt; assert_eq!( - parse::command_line(b"FOO=\xff cmd \xfe".as_bstr())?, + parse::command_line(b"FOO=\xff cmd \xfe".as_bstr()).map_err(gix_error::Exn::into_error)?, Outcome { env: vec![("FOO".into(), OsString::from_vec(vec![0xff]))], command: "cmd".into(), @@ -98,8 +182,8 @@ fn non_utf8_input_is_preserved() -> gix_testtools::Result { Ok(()) } -fn command_line(input: &str) -> Result { - parse::command_line(input.into()) +fn command_line(input: &str) -> Result { + parse::command_line(input.into()).map_err(gix_error::Exn::into_error) } fn args(input: &[&str]) -> Vec { diff --git a/gix-command/tests/command/prepare.rs b/gix-command/tests/command/prepare.rs index 21a7ac983a3..388c5a9d50a 100644 --- a/gix-command/tests/command/prepare.rs +++ b/gix-command/tests/command/prepare.rs @@ -1,3 +1,4 @@ +use crate::Result; use std::sync::LazyLock; fn default_shell() -> &'static str { @@ -236,7 +237,7 @@ fn invalid_utf8_commands_are_checked_for_shell_syntax() { } #[test] -fn relative_existing_paths_with_shell_syntax_still_use_the_shell() -> crate::Result { +fn relative_existing_paths_with_shell_syntax_still_use_the_shell() -> Result { let temp = gix_testtools::tempfile::Builder::new() .prefix("$HOME") .tempdir_in(".")?; diff --git a/gix-command/tests/command/spawn.rs b/gix-command/tests/command/spawn.rs index 3a7cd7d1b0a..ed96f4ef86b 100644 --- a/gix-command/tests/command/spawn.rs +++ b/gix-command/tests/command/spawn.rs @@ -1,7 +1,8 @@ +use crate::Result; use bstr::ByteSlice; #[test] -fn environment_variables_are_passed_one_by_one() -> crate::Result { +fn environment_variables_are_passed_one_by_one() -> Result { let out = gix_command::prepare("echo $FIRST $SECOND") .env("FIRST", "first") .env("SECOND", "second") @@ -13,7 +14,7 @@ fn environment_variables_are_passed_one_by_one() -> crate::Result { } #[test] -fn disallow_shell() -> crate::Result { +fn disallow_shell() -> Result { let out = gix_command::prepare("PATH= echo hi") .command_may_be_shell_script_disallow_manual_argument_splitting() .spawn()? @@ -32,7 +33,7 @@ fn disallow_shell() -> crate::Result { } #[test] -fn script_with_dollar_at() -> crate::Result { +fn script_with_dollar_at() -> Result { let out = std::process::Command::from( gix_command::prepare(r#"echo "$@""#) .command_may_be_shell_script() @@ -49,7 +50,7 @@ fn script_with_dollar_at() -> crate::Result { } #[test] -fn direct_command_execution_searches_in_path() -> crate::Result { +fn direct_command_execution_searches_in_path() -> Result { assert!( gix_command::prepare(if cfg!(unix) { "ls" } else { "attrib.exe" }) .spawn()? @@ -61,16 +62,17 @@ fn direct_command_execution_searches_in_path() -> crate::Result { #[cfg(unix)] #[test] -fn direct_command_with_absolute_command_path() -> crate::Result { +fn direct_command_with_absolute_command_path() -> Result { assert!(gix_command::prepare("/usr/bin/env").spawn()?.wait()?.success()); Ok(()) } mod with_shell { + use crate::Result; use gix_testtools::bstr::ByteSlice; #[test] - fn command_in_path_with_args() -> crate::Result { + fn command_in_path_with_args() -> Result { // `ls` is occasionaly a builtin, as in busybox ash, but it is usually external. assert!( gix_command::prepare(if cfg!(unix) { "ls -l" } else { "attrib.exe /d" }) @@ -84,7 +86,7 @@ mod with_shell { #[cfg(unix)] #[test] - fn shell_builtin_or_command_in_path() -> crate::Result { + fn shell_builtin_or_command_in_path() -> Result { let out = gix_command::prepare("echo") .command_may_be_shell_script() .spawn()? @@ -96,7 +98,7 @@ mod with_shell { #[cfg(unix)] #[test] - fn shell_builtin_or_command_in_path_with_single_extra_arg() -> crate::Result { + fn shell_builtin_or_command_in_path_with_single_extra_arg() -> Result { let out = gix_command::prepare("printf") .command_may_be_shell_script() .arg("1") @@ -109,7 +111,7 @@ mod with_shell { #[cfg(unix)] #[test] - fn shell_builtin_or_command_in_path_with_multiple_extra_args() -> crate::Result { + fn shell_builtin_or_command_in_path_with_multiple_extra_args() -> Result { let out = gix_command::prepare("printf") .command_may_be_shell_script() .arg("%s") @@ -122,7 +124,7 @@ mod with_shell { } #[test] - fn force_shell_builtin() -> crate::Result { + fn force_shell_builtin() -> Result { let out = gix_command::prepare("echo").with_shell().spawn()?.wait_with_output()?; assert!(out.status.success()); assert_eq!(out.stdout.as_bstr(), "\n"); @@ -130,7 +132,7 @@ mod with_shell { } #[test] - fn force_shell_builtin_with_single_extra_arg() -> crate::Result { + fn force_shell_builtin_with_single_extra_arg() -> Result { let out = gix_command::prepare("printf") .with_shell() .arg("1") @@ -142,7 +144,7 @@ mod with_shell { } #[test] - fn force_shell_builtin_with_multiple_extra_args() -> crate::Result { + fn force_shell_builtin_with_multiple_extra_args() -> Result { let out = gix_command::prepare("printf") .with_shell() .arg("%s") @@ -155,7 +157,7 @@ mod with_shell { } #[test] - fn sh_shell_specific_script_code() -> crate::Result { + fn sh_shell_specific_script_code() -> Result { assert!( gix_command::prepare(":;:;:") .command_may_be_shell_script() @@ -167,7 +169,7 @@ mod with_shell { } #[test] - fn sh_shell_specific_script_code_with_single_extra_arg() -> crate::Result { + fn sh_shell_specific_script_code_with_single_extra_arg() -> Result { let out = gix_command::prepare(":;printf") .command_may_be_shell_script() .arg("1") @@ -179,7 +181,7 @@ mod with_shell { } #[test] - fn sh_shell_specific_script_code_with_multiple_extra_args() -> crate::Result { + fn sh_shell_specific_script_code_with_multiple_extra_args() -> Result { let out = gix_command::prepare(":;printf") .command_may_be_shell_script() .arg("%s") @@ -193,7 +195,7 @@ mod with_shell { #[cfg(unix)] #[test] - fn dollar_zero_in_minus_c_is_basename_of_default_shell() -> crate::Result { + fn dollar_zero_in_minus_c_is_basename_of_default_shell() -> Result { let out = gix_command::prepare(r#"printf %s "$0""#) .command_may_be_shell_script() .spawn()? @@ -209,7 +211,7 @@ mod with_shell { #[cfg(unix)] #[test] - fn dollar_zero_in_minus_c_reflects_with_shell_program() -> crate::Result { + fn dollar_zero_in_minus_c_reflects_with_shell_program() -> Result { let out = std::process::Command::from( gix_command::prepare(r#"printf %s "$0""#) .command_may_be_shell_script() diff --git a/gix-commitgraph/Cargo.toml b/gix-commitgraph/Cargo.toml index 164f4b11eeb..110488ada61 100644 --- a/gix-commitgraph/Cargo.toml +++ b/gix-commitgraph/Cargo.toml @@ -36,6 +36,7 @@ serde = { version = "1.0.114", optional = true, default-features = false, featur document-features = { version = "0.2.0", optional = true } [dev-dependencies] +insta = "1.46.3" gix-testtools = { path = "../tests/tools", default-features = false } gix-date = { path = "../gix-date" } gix-hash = { path = "../gix-hash", features = ["sha1", "sha256"] } diff --git a/gix-commitgraph/src/file/commit.rs b/gix-commitgraph/src/file/commit.rs index dee517ae361..0648ee0508b 100644 --- a/gix-commitgraph/src/file/commit.rs +++ b/gix-commitgraph/src/file/commit.rs @@ -3,7 +3,8 @@ use crate::{ File, Position, file::{self, EXTENDED_EDGES_MASK, LAST_EXTENDED_EDGE_MASK, NO_PARENT}, }; -use gix_error::{Message, message}; +use gix_error::ExnMessageResult; +use gix_error::message; use std::{ fmt::{Debug, Formatter}, slice::Chunks, @@ -77,7 +78,7 @@ impl<'a> Commit<'a> { } /// Returns the first parent of this commit. - pub fn parent1(&self) -> Result, Message> { + pub fn parent1(&self) -> ExnMessageResult> { self.iter_parents().next().transpose() } @@ -122,7 +123,7 @@ pub struct Parents<'a> { } impl Iterator for Parents<'_> { - type Item = Result; + type Item = ExnMessageResult; fn next(&mut self) -> Option { let state = std::mem::replace(&mut self.state, ParentIteratorState::Exhausted); @@ -133,7 +134,8 @@ impl Iterator for Parents<'_> { _ => Some(Err(message!( "commit {} has a second parent but not a first parent", self.commit_data.id() - ))), + ) + .into())), }, ParentEdge::GraphPosition(pos) => { self.state = ParentIteratorState::Second; @@ -142,7 +144,8 @@ impl Iterator for Parents<'_> { ParentEdge::ExtraEdgeIndex(_) => Some(Err(message!( "commit {}'s first parent is an extra edge index, which is invalid", self.commit_data.id(), - ))), + ) + .into())), }, ParentIteratorState::Second => match self.commit_data.parent2 { ParentEdge::None => None, @@ -164,13 +167,15 @@ impl Iterator for Parents<'_> { Some(Err(message!( "commit {}'s extra edges overflows the commit-graph file's extra edges list", self.commit_data.id() - ))) + ) + .into())) } } else { Some(Err(message!( "commit {} has extra edges, but commit-graph file has no extra edges list", self.commit_data.id() - ))) + ) + .into())) } } }, @@ -188,7 +193,8 @@ impl Iterator for Parents<'_> { Some(Err(message!( "commit {}'s extra edges overflows the commit-graph file's extra edges list", self.commit_data.id() - ))) + ) + .into())) } } ParentIteratorState::Exhausted => None, diff --git a/gix-commitgraph/src/file/init.rs b/gix-commitgraph/src/file/init.rs index ee98d0b7ad6..e74cf005595 100644 --- a/gix-commitgraph/src/file/init.rs +++ b/gix-commitgraph/src/file/init.rs @@ -1,6 +1,6 @@ use std::path::{Path, PathBuf}; -use gix_error::{ErrorExt, Exn, Message, ResultExt, message}; +use gix_error::{ErrorExt, Exn, ExnMessageResult, Message, ResultExt, message}; use crate::{ File, @@ -17,7 +17,7 @@ const MIN_FILE_SIZE: usize = HEADER_LEN impl File { /// Try to parse the commit graph file at `path`. - pub fn at(path: impl AsRef) -> Result> { + pub fn at(path: impl AsRef) -> ExnMessageResult { Self::try_from(path.as_ref()) } @@ -26,7 +26,7 @@ impl File { /// /// Note that `path` is only used for verification of the hash its basename contains, but otherwise /// is not of importance. - pub fn new(data: memmap2::Mmap, path: PathBuf) -> Result> { + pub fn new(data: memmap2::Mmap, path: PathBuf) -> ExnMessageResult { let data_size = data.len(); if data_size < MIN_FILE_SIZE { return Err(message("Commit-graph file too small even for an empty graph").raise()); diff --git a/gix-commitgraph/src/file/verify.rs b/gix-commitgraph/src/file/verify.rs index 73571946370..9138e3a2213 100644 --- a/gix-commitgraph/src/file/verify.rs +++ b/gix-commitgraph/src/file/verify.rs @@ -5,7 +5,7 @@ use std::{ path::Path, }; -use gix_error::{ErrorExt, Exn, Message, ResultExt, message}; +use gix_error::{ErrorExt, ExnMessageResult, ExnResult, ResultExt, message}; use crate::{File, GENERATION_NUMBER_INFINITY, GENERATION_NUMBER_MAX, file}; @@ -35,9 +35,9 @@ impl File { /// Traverse all [commits][file::Commit] stored in this file and call `processor(commit) -> Result<(), Error>` on it. /// /// If the `processor` fails, the iteration will be stopped and the entire call results in the respective error. - pub fn traverse<'a, Processor>(&'a self, mut processor: Processor) -> Result> + pub fn traverse<'a, Processor>(&'a self, mut processor: Processor) -> ExnMessageResult where - Processor: FnMut(&file::Commit<'a>) -> Result<(), Exn>, + Processor: FnMut(&file::Commit<'a>) -> ExnResult, { self.verify_checksum()?; verify_split_chain_filename_hash(&self.path, self.checksum())?; @@ -102,26 +102,24 @@ impl File { /// Assure the [`checksum`][File::checksum()] matches the actual checksum over all content of this file, excluding the trailing /// checksum itself. /// - /// Return the actual checksum on success or [`Exn`] if there is a mismatch. - pub fn verify_checksum(&self) -> Result> { - // Even though we could use gix_hash::bytes_of_file(…), this would require extending our - // Error type to support io::Error. As we only gain progress, there probably isn't much value - // as these files are usually small enough to process them in less than a second, even for the large ones. - // But it's possible, once a progress instance is passed. + /// Return the actual checksum on success or [`Exn`](gix_error::Exn) if there is a mismatch. + pub fn verify_checksum(&self) -> ExnMessageResult { let data_len_without_trailer = self.data.len() - self.hash_len; let mut hasher = gix_hash::hasher(self.object_hash()); hasher.update(&self.data[..data_len_without_trailer]); let actual = hasher .try_finalize() - .map_err(|e| message!("failed to hash commit graph file: {e}").raise())?; - actual.verify(self.checksum()).map_err(|e| message!("{e}").raise())?; + .or_raise(|| message("failed to hash commit graph file"))?; + actual + .verify(self.checksum()) + .or_raise(|| message("commit-graph checksum does not match"))?; Ok(actual) } } /// If the given path's filename matches "graph-{hash}.graph", check that `hash` matches the /// expected hash. -fn verify_split_chain_filename_hash(path: &Path, expected: &gix_hash::oid) -> Result<(), Exn> { +fn verify_split_chain_filename_hash(path: &Path, expected: &gix_hash::oid) -> ExnMessageResult { path.file_name() .and_then(std::ffi::OsStr::to_str) .and_then(|filename| filename.strip_suffix(".graph")) diff --git a/gix-commitgraph/src/init.rs b/gix-commitgraph/src/init.rs index ac0de2b3d5f..6d4b2fbde48 100644 --- a/gix-commitgraph/src/init.rs +++ b/gix-commitgraph/src/init.rs @@ -1,4 +1,5 @@ use crate::{File, Graph, MAX_COMMITS}; +use gix_error::ExnMessageResult; use gix_error::{ErrorExt, Exn, Message, ResultExt, message}; use std::{ io::{BufRead, BufReader}, @@ -10,12 +11,12 @@ impl Graph { /// Instantiate a commit graph from `path` which may be a directory containing graph files or the graph file itself. /// /// Filesystem errors retain their [`std::io::Error`] source, including when `path` does not exist. - pub fn at(path: &Path) -> Result> { + pub fn at(path: &Path) -> ExnMessageResult { Self::try_from(path) } /// Instantiate a commit graph from the directory containing all of its files. - pub fn from_commit_graphs_dir(path: &Path) -> Result> { + pub fn from_commit_graphs_dir(path: &Path) -> ExnMessageResult { let commit_graphs_dir = path; let chain_file_path = commit_graphs_dir.join("commit-graph-chain"); let chain_file = std::fs::File::open(&chain_file_path).or_raise(|| { @@ -38,31 +39,31 @@ impl Graph { .or_raise(|| message!("Could not open commit-graph file at '{}'", graph_file_path.display()))?, ); } - Ok(Self::new(files)?) + Self::new(files) } /// Instantiate a commit graph from a `.git/objects/info/commit-graph` or /// `.git/objects/info/commit-graphs/graph-*.graph` file. - pub fn from_file(path: &Path) -> Result> { + pub fn from_file(path: &Path) -> ExnMessageResult { let file = File::at(path).or_raise(|| message!("Could not open commit-graph file at '{}'", path.display()))?; - Ok(Self::new(vec![file])?) + Self::new(vec![file]) } /// Instantiate a commit graph from an `.git/objects/info` directory. - pub fn from_info_dir(info_dir: &Path) -> Result> { + pub fn from_info_dir(info_dir: &Path) -> ExnMessageResult { Self::from_file(&info_dir.join("commit-graph")) .or_else(|_| Self::from_commit_graphs_dir(&info_dir.join("commit-graphs"))) } /// Create a new commit graph from a list of `files`. - pub fn new(files: Vec) -> Result { + pub fn new(files: Vec) -> ExnMessageResult { let files = nonempty::NonEmpty::from_vec(files) .ok_or_else(|| message!("Commit-graph must contain at least one file"))?; let num_commits: u64 = files.iter().map(|f| u64::from(f.num_commits())).sum(); if num_commits > u64::from(MAX_COMMITS) { return Err(message!( "Commit-graph files contain {num_commits} commits altogether, but only {MAX_COMMITS} commits are allowed" - )); + ).into()); } let mut f1 = files.first(); @@ -74,7 +75,8 @@ impl Graph { hash1 = f1.object_hash(), path2 = f2.path().display(), hash2 = f2.object_hash(), - )); + ) + .into()); } f1 = f2; } @@ -86,7 +88,7 @@ impl Graph { impl TryFrom<&Path> for Graph { type Error = Exn; - fn try_from(path: &Path) -> Result { + fn try_from(path: &Path) -> std::result::Result { let metadata = path .metadata() .or_raise(|| message!("Could not access commit-graph path at '{}'", path.display()))?; diff --git a/gix-commitgraph/src/lib.rs b/gix-commitgraph/src/lib.rs index 7368250c25d..f1708eac173 100644 --- a/gix-commitgraph/src/lib.rs +++ b/gix-commitgraph/src/lib.rs @@ -15,9 +15,10 @@ #![cfg_attr(all(doc, feature = "document-features"), feature(doc_cfg))] #![deny(missing_docs, unsafe_code)] -use gix_error::{Exn, Message}; use std::path::Path; +use gix_error::ExnMessageResult; + /// A single commit-graph file. /// /// All operations on a `File` are local to that graph file. Since a commit graph can span multiple @@ -45,7 +46,7 @@ pub struct Graph { } /// Instantiate a commit graph from an `.git/objects/info` directory, or one of the various commit-graph files. -pub fn at(path: impl AsRef) -> Result> { +pub fn at(path: impl AsRef) -> ExnMessageResult { Graph::at(path.as_ref()) } diff --git a/gix-commitgraph/src/verify.rs b/gix-commitgraph/src/verify.rs index 52160867df7..e27c0a07538 100644 --- a/gix-commitgraph/src/verify.rs +++ b/gix-commitgraph/src/verify.rs @@ -4,7 +4,7 @@ use std::{ collections::BTreeMap, }; -use gix_error::{ErrorExt, Exn, Message, ResultExt, message}; +use gix_error::{ErrorExt, ExnMessageResult, ResultExt, message}; use crate::{ GENERATION_NUMBER_MAX, Graph, Position, @@ -34,7 +34,7 @@ impl Graph { pub fn verify_integrity( &self, mut processor: impl FnMut(&file::Commit<'_>) -> Result<(), E>, - ) -> Result> + ) -> ExnMessageResult where E: std::error::Error + Send + Sync + 'static, { @@ -93,7 +93,7 @@ impl Graph { let file_stats = file.traverse(|commit| { let mut max_parent_generation = 0u32; for parent_pos in commit.iter_parents() { - let parent_pos = parent_pos.map_err(|err| err.raise_erased())?; + let parent_pos = parent_pos.map_err(gix_error::Exn::erased)?; if parent_pos >= next_file_start_pos { return Err(message!( "Commit {} has parent position {parent_pos} that is out of range (should be in range 0-{})", diff --git a/gix-commitgraph/tests/commitgraph.rs b/gix-commitgraph/tests/commitgraph.rs index 07aa851dcc6..b736c40d2c8 100644 --- a/gix-commitgraph/tests/commitgraph.rs +++ b/gix-commitgraph/tests/commitgraph.rs @@ -11,21 +11,43 @@ use gix_testtools::scripted_fixture_read_only; mod access; #[test] -fn missing_path_keeps_io_error() -> gix_testtools::Result { +fn missing_path_is_not_found() -> gix_testtools::Result { let dir = gix_testtools::tempfile::tempdir()?; let err = gix_commitgraph::at(dir.path().join("missing")) .err() .expect("a missing path cannot contain a commit-graph"); - assert_eq!( - err.downcast_any_ref::() - .expect("the filesystem error is preserved") - .kind(), - std::io::ErrorKind::NotFound, + insta::assert_debug_snapshot!(gix_testtools::redact_debug_snapshot(&(err), &[(&(dir.path()).to_string_lossy(), "")]), "callers can distinguish a missing optional cache from other failures", @" + Could not access commit-graph path at '/missing' + | + └─ NotFound + "); + assert!( + err.is_not_found(), "callers can distinguish a missing optional cache from other failures" ); Ok(()) } +#[test] +fn checksum_mismatches_retain_their_classification() -> gix_testtools::Result { + let repo = gix_testtools::scripted_fixture_writable("single_commit.sh")?; + let mut data = std::fs::read(repo.path().join(".git/objects/info/commit-graph"))?; + *data.last_mut().expect("the graph has a checksum trailer") ^= 1; + // Git can make its graph read-only; corrupt a separate file. + let path = repo.path().join("corrupt-commit-graph"); + std::fs::write(&path, data)?; + + let graph = gix_commitgraph::File::at(path).map_err(gix_error::Exn::into_error)?; + let err = graph.verify_checksum().expect_err("the checksum no longer matches"); + insta::assert_debug_snapshot!(gix_testtools::redact_debug_snapshot(&(err), &[]), "a checksum mismatch is corruption", @" + commit-graph checksum does not match + | + └─ Hash was Oid(1), but should have been Oid(2) + "); + assert!(err.is_corrupted(), "a checksum mismatch is corruption"); + Ok(()) +} + pub fn check_common(cg: &Graph, expected: &HashMap) { cg.verify_integrity(|_| Ok::<_, gix_error::Message>(())) .expect("graph is valid"); diff --git a/gix-config-value/Cargo.toml b/gix-config-value/Cargo.toml index 730bc421b2e..cdfcf5508d7 100644 --- a/gix-config-value/Cargo.toml +++ b/gix-config-value/Cargo.toml @@ -20,8 +20,8 @@ serde = ["dep:serde", "bstr/serde"] [dependencies] gix-path = { version = "^0.12.4", path = "../gix-path" } +gix-error = { version = "^0.3.0", path = "../gix-error" } -thiserror = "2.0.18" bstr = { version = "1.12.0", default-features = false, features = ["std"] } serde = { version = "1.0.114", optional = true, default-features = false, features = ["derive"] } bitflags = "2" @@ -34,3 +34,7 @@ libc = "0.2" [package.metadata.docs.rs] all-features = true features = ["document-features"] + +[dev-dependencies] +gix-testtools = { path = "../tests/tools", default-features = false, features = ["sha1"] } +insta = "1.46.3" diff --git a/gix-config-value/fuzz/fuzz_targets/fuzz_value.rs b/gix-config-value/fuzz/fuzz_targets/fuzz_value.rs index f80d64ba319..664c2e5a712 100644 --- a/gix-config-value/fuzz/fuzz_targets/fuzz_value.rs +++ b/gix-config-value/fuzz/fuzz_targets/fuzz_value.rs @@ -24,19 +24,19 @@ struct Ctx<'a> { } fn fuzz(ctx: Ctx) -> Result<()> { - let b = Boolean::try_from(BStr::new(ctx.bool_str))?; + let b = Boolean::try_from(BStr::new(ctx.bool_str)).map_err(|err| err.into_error())?; _ = black_box(b.is_true()); - _ = black_box(Color::try_from(BStr::new(ctx.color_str)))?; + _ = black_box(Color::try_from(BStr::new(ctx.color_str))).map_err(|err| err.into_error())?; let mut buf = String::with_capacity(128); - let a = Attribute::from_str(ctx.attribute_str)?; + let a = Attribute::from_str(ctx.attribute_str).map_err(|err| err.into_error())?; _ = black_box(write!(&mut buf, "{a}")); - let name = Name::from_str(ctx.name_str)?; + let name = Name::from_str(ctx.name_str).map_err(|err| err.into_error())?; _ = black_box(write!(&mut buf, "{name}")); - let i = Integer::try_from(BStr::new(ctx.integer_str))?; + let i = Integer::try_from(BStr::new(ctx.integer_str)).map_err(|err| err.into_error())?; _ = black_box(i.to_decimal()); let p = Path::from(BStr::new(ctx.path_str)); diff --git a/gix-config-value/src/boolean.rs b/gix-config-value/src/boolean.rs index 444b929e827..006698ed0d3 100644 --- a/gix-config-value/src/boolean.rs +++ b/gix-config-value/src/boolean.rs @@ -1,22 +1,21 @@ use std::{borrow::Cow, ffi::OsString, fmt::Display}; use bstr::{BStr, BString}; +use gix_error::{ErrorExt, Message, ResultExt, validation}; -use crate::{Boolean, Error, Integer}; +use crate::{Boolean, Integer}; -fn bool_err(input: impl Into) -> Error { - Error::new( - "Booleans need to be 'no', 'off', 'false', '' or 'yes', 'on', 'true' or any number", - input, - ) +fn bool_err(input: impl Into) -> Message { + validation("Booleans need to be 'no', 'off', 'false', '' or 'yes', 'on', 'true' or any number") + .with("input", gix_error::MetadataValue::Bytes(input.into())) } impl TryFrom for Boolean { - type Error = Error; + type Error = gix_error::Exn; fn try_from(value: OsString) -> Result { let value = gix_path::os_str_into_bstr(&value) - .map_err(|_| Error::new("Illformed UTF-8", std::path::Path::new(&value).display().to_string()))?; + .or_raise(|| validation("Illformed UTF-8").with("input", value.as_encoded_bytes()))?; Self::try_from(value) } } @@ -36,7 +35,7 @@ impl TryFrom for Boolean { /// Instead of this, obtain booleans with `config.boolean(…)`, which handles the case were no separator is /// present correctly. impl TryFrom<&BStr> for Boolean { - type Error = Error; + type Error = gix_error::Exn; fn try_from(value: &BStr) -> Result { if parse_true(value) { @@ -46,13 +45,13 @@ impl TryFrom<&BStr> for Boolean { } else if let Some(integer) = Integer::try_from(value).ok().and_then(|integer| integer.to_decimal()) { Ok(Boolean(integer != 0)) } else { - Err(bool_err(value)) + Err(bool_err(value).raise()) } } } impl TryFrom<&str> for Boolean { - type Error = Error; + type Error = gix_error::Exn; fn try_from(value: &str) -> Result { Self::try_from(BStr::new(value)) @@ -69,14 +68,14 @@ impl Boolean { } impl TryFrom> for Boolean { - type Error = Error; + type Error = gix_error::Exn; fn try_from(c: Cow<'_, BStr>) -> Result { Self::try_from(c.as_ref()) } } impl TryFrom for Boolean { - type Error = Error; + type Error = gix_error::Exn; fn try_from(value: BString) -> Result { Self::try_from(BStr::new(&value)) } diff --git a/gix-config-value/src/color.rs b/gix-config-value/src/color.rs index c8da8b2f30c..c42335edacb 100644 --- a/gix-config-value/src/color.rs +++ b/gix-config-value/src/color.rs @@ -1,8 +1,9 @@ use std::{borrow::Cow, fmt::Display, str::FromStr}; use bstr::{BStr, BString}; +use gix_error::{ErrorExt, Message, ResultExt, validation}; -use crate::{Color, Error}; +use crate::Color; impl Display for Color { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -30,18 +31,16 @@ impl Display for Color { } } -fn color_err(input: impl Into) -> Error { - Error::new( - "Colors are specific color values and their attributes, like 'brightred', or 'blue'", - input, - ) +fn color_err(input: impl Into) -> Message { + validation("Colors are specific color values and their attributes, like 'brightred', or 'blue'") + .with("input", gix_error::MetadataValue::Bytes(input.into())) } impl TryFrom<&BStr> for Color { - type Error = Error; + type Error = gix_error::Exn; fn try_from(s: &BStr) -> Result { - let s = std::str::from_utf8(s).map_err(|err| color_err(s).with_err(err))?; + let s = std::str::from_utf8(s).or_raise(|| color_err(s))?; enum ColorItem { Value(Name), Attr(Attribute), @@ -71,12 +70,12 @@ impl TryFrom<&BStr> for Color { } else if background.is_none() { background = Some(v); } else { - return Err(color_err(s)); + return Err(color_err(s).raise()); } } ColorItem::Attr(a) => attributes |= a, }, - Err(_) => return Err(color_err(s)), + Err(_) => return Err(color_err(s).raise()), } } @@ -89,7 +88,7 @@ impl TryFrom<&BStr> for Color { } impl TryFrom<&str> for Color { - type Error = Error; + type Error = gix_error::Exn; fn try_from(value: &str) -> Result { Self::try_from(BStr::new(value)) @@ -97,7 +96,7 @@ impl TryFrom<&str> for Color { } impl TryFrom> for Color { - type Error = Error; + type Error = gix_error::Exn; fn try_from(c: Cow<'_, BStr>) -> Result { Self::try_from(c.as_ref()) @@ -105,7 +104,7 @@ impl TryFrom> for Color { } impl TryFrom for Color { - type Error = Error; + type Error = gix_error::Exn; fn try_from(value: BString) -> Result { Self::try_from(BStr::new(&value)) @@ -230,7 +229,7 @@ fn parse_hex(hex: &[u8]) -> Option<(u8, u8, u8)> { } impl FromStr for Name { - type Err = Error; + type Err = gix_error::Exn; fn from_str(s: &str) -> Result { const BASIC: &[(&str, Name, Name)] = &[ @@ -260,7 +259,7 @@ impl FromStr for Name { } if is_bright { - return Err(color_err(s)); + return Err(color_err(s).raise()); } if s.eq_ignore_ascii_case("normal") || s == "-1" { @@ -281,15 +280,15 @@ impl FromStr for Name { return Ok(Self::Rgb(r, g, b)); } - Err(color_err(s)) + Err(color_err(s).raise()) } } impl TryFrom<&BStr> for Name { - type Error = Error; + type Error = gix_error::Exn; fn try_from(s: &BStr) -> Result { - Self::from_str(std::str::from_utf8(s).map_err(|err| color_err(s).with_err(err))?) + Self::from_str(std::str::from_utf8(s).or_raise(|| color_err(s))?) } } @@ -383,7 +382,7 @@ impl serde::Serialize for Attribute { } impl FromStr for Attribute { - type Err = Error; + type Err = gix_error::Exn; fn from_str(mut s: &str) -> Result { let inverted = if let Some(rest) = s.strip_prefix("no-").or_else(|| s.strip_prefix("no")) { @@ -395,7 +394,7 @@ impl FromStr for Attribute { if s.eq_ignore_ascii_case("reset") { return if inverted { - Err(color_err(s)) + Err(color_err(s).raise()) } else { Ok(Attribute::RESET) }; @@ -416,15 +415,15 @@ impl FromStr for Attribute { "italic" if inverted => Ok(Attribute::NO_ITALIC), "strike" if !inverted => Ok(Attribute::STRIKE), "strike" if inverted => Ok(Attribute::NO_STRIKE), - _ => Err(color_err(s)), + _ => Err(color_err(s).raise()), } } } impl TryFrom<&BStr> for Attribute { - type Error = Error; + type Error = gix_error::Exn; fn try_from(s: &BStr) -> Result { - Self::from_str(std::str::from_utf8(s).map_err(|err| color_err(s).with_err(err))?) + Self::from_str(std::str::from_utf8(s).or_raise(|| color_err(s))?) } } diff --git a/gix-config-value/src/integer.rs b/gix-config-value/src/integer.rs index 81c7fd33e07..f48d9d2f140 100644 --- a/gix-config-value/src/integer.rs +++ b/gix-config-value/src/integer.rs @@ -1,8 +1,9 @@ use std::{borrow::Cow, fmt::Display, str::FromStr}; -use bstr::{BStr, BString}; +use bstr::{BStr, BString, ByteSlice}; +use gix_error::{ErrorExt, Message, ResultExt, validation}; -use crate::{Error, Integer}; +use crate::Integer; impl Integer { /// Canonicalize values as simple decimal numbers. @@ -47,11 +48,9 @@ impl serde::Serialize for Integer { } } -fn int_err(input: impl Into) -> Error { - Error::new( - "Integers needs to be positive or negative numbers which may have a suffix like 1k, 42, or 50G", - input, - ) +fn int_err(input: impl Into) -> Message { + validation("Integers needs to be positive or negative numbers which may have a suffix like 1k, 42, or 50G") + .with("input", gix_error::MetadataValue::Bytes(input.into())) } /// Parse `input` the way `git_parse_signed()` does, which hands the value to @@ -85,21 +84,21 @@ fn parse_like_git(input: &str) -> Option { } impl TryFrom<&BStr> for Integer { - type Error = Error; + type Error = gix_error::Exn; fn try_from(s: &BStr) -> Result { - let s = std::str::from_utf8(s).map_err(|err| int_err(s).with_err(err))?; + let s = std::str::from_utf8(s).or_raise(|| int_err(s))?; if let Some(value) = parse_like_git(s) { return Ok(Self { value, suffix: None }); } if s.len() <= 1 { - return Err(int_err(s)); + return Err(int_err(s).raise()); } let last_idx = s.len() - 1; if !s.is_char_boundary(last_idx) { - return Err(int_err(s)); + return Err(int_err(s).raise()); } let (number, suffix) = s.split_at(s.len() - 1); @@ -109,13 +108,13 @@ impl TryFrom<&BStr> for Integer { suffix: Some(suffix), }) } else { - Err(int_err(s)) + Err(int_err(s).raise()) } } } impl TryFrom<&str> for Integer { - type Error = Error; + type Error = gix_error::Exn; fn try_from(value: &str) -> Result { Self::try_from(BStr::new(value)) @@ -123,7 +122,7 @@ impl TryFrom<&str> for Integer { } impl TryFrom> for Integer { - type Error = Error; + type Error = gix_error::Exn; fn try_from(c: Cow<'_, BStr>) -> Result { Self::try_from(c.as_ref()) @@ -131,7 +130,7 @@ impl TryFrom> for Integer { } impl TryFrom for Integer { - type Error = Error; + type Error = gix_error::Exn; fn try_from(value: BString) -> Result { Self::try_from(BStr::new(&value)) @@ -191,12 +190,7 @@ impl FromStr for Suffix { type Err = (); fn from_str(s: &str) -> Result { - match s { - "k" | "K" => Ok(Self::Kibi), - "m" | "M" => Ok(Self::Mebi), - "g" | "G" => Ok(Self::Gibi), - _ => Err(()), - } + Self::try_from(BStr::new(s)) } } @@ -204,6 +198,11 @@ impl TryFrom<&BStr> for Suffix { type Error = (); fn try_from(s: &BStr) -> Result { - Self::from_str(std::str::from_utf8(s).map_err(|_| ())?) + match s.as_bytes() { + b"k" | b"K" => Ok(Self::Kibi), + b"m" | b"M" => Ok(Self::Mebi), + b"g" | b"G" => Ok(Self::Gibi), + _ => Err(()), + } } } diff --git a/gix-config-value/src/lib.rs b/gix-config-value/src/lib.rs index 763661a7e55..4073a457652 100644 --- a/gix-config-value/src/lib.rs +++ b/gix-config-value/src/lib.rs @@ -25,33 +25,6 @@ #![cfg_attr(all(doc, feature = "document-features"), feature(doc_cfg))] #![deny(missing_docs, unsafe_code)] -/// The error returned when any config value couldn't be instantiated due to malformed input. -#[derive(Debug, thiserror::Error, Eq, PartialEq)] -#[expect(missing_docs)] -#[error("Could not decode '{input}': {message}")] -pub struct Error { - pub message: &'static str, - pub input: bstr::BString, - #[source] - pub utf8_err: Option, -} - -impl Error { - /// Create a new value error from `message`, with `input` being what's causing the error. - pub fn new(message: &'static str, input: impl Into) -> Self { - Error { - message, - input: input.into(), - utf8_err: None, - } - } - - pub(crate) fn with_err(mut self, err: std::str::Utf8Error) -> Self { - self.utf8_err = Some(err); - self - } -} - mod boolean; /// Color value parsing and the supported color names and attributes. pub mod color; diff --git a/gix-config-value/src/path.rs b/gix-config-value/src/path.rs index f26fe5d872c..64299102617 100644 --- a/gix-config-value/src/path.rs +++ b/gix-config-value/src/path.rs @@ -1,6 +1,7 @@ use std::{borrow::Cow, path::PathBuf}; use bstr::{BStr, BString, ByteSlice}; +use gix_error::{ErrorExt, ExnResult, OptionExt, ResultExt, not_found, validation}; use crate::Path; @@ -30,22 +31,6 @@ pub mod interpolate { } } - /// The error returned by [`Path::interpolate()`][crate::Path::interpolate()]. - #[derive(Debug, thiserror::Error)] - #[expect(missing_docs)] - pub enum Error { - #[error("{} is missing", .what)] - Missing { what: &'static str }, - #[error("Ill-formed UTF-8 in {}", .what)] - Utf8Conversion { - what: &'static str, - #[source] - err: gix_path::Utf8Error, - }, - #[error("Ill-formed UTF-8 in username")] - UsernameConversion(#[from] std::str::Utf8Error), - } - /// Obtain the home directory for the given user `name` or return `None` if the user wasn't found /// or any other error occurred. /// It can be used as `home_for_user` parameter in [`Path::interpolate()`][crate::Path::interpolate()]. @@ -159,6 +144,8 @@ impl Path { /// /// Any other, non-empty path value is returned unchanged and error is returned in case of an empty path value or if the required /// input wasn't provided. + /// UTF-8 conversion failures include the invalid path or username bytes as `input` + /// [metadata](gix_error::Exn::metadata()). pub fn interpolate( self, interpolate::Context { @@ -166,23 +153,18 @@ impl Path { home_dir, home_for_user, }: interpolate::Context<'_>, - ) -> Result { + ) -> ExnResult { if self.is_empty() { - return Err(interpolate::Error::Missing { what: "path" }); + return Err(not_found("path is missing").raise_erased()); } const PREFIX: &[u8] = b"%(prefix)/"; if self.starts_with(PREFIX) { - let git_install_dir = git_install_dir.ok_or(interpolate::Error::Missing { - what: "git install dir", - })?; + let git_install_dir = git_install_dir.ok_or_raise_erased(|| not_found("git install dir is missing"))?; let (_prefix, path_without_trailing_slash) = self.split_at(PREFIX.len()); let path_without_trailing_slash = - gix_path::try_from_bstring(path_without_trailing_slash).map_err(|err| { - interpolate::Error::Utf8Conversion { - what: "path past %(prefix)", - err, - } + gix_path::try_from_bstring(path_without_trailing_slash).or_raise_erased(|| { + validation("Ill-formed UTF-8 in path past %(prefix)").with("input", path_without_trailing_slash) })?; Ok(git_install_dir.join(path_without_trailing_slash)) } else if let Some(val) = self.strip_prefix(b"~") { @@ -193,7 +175,7 @@ impl Path { let (mut home, what) = if username.is_empty() { ( home_dir - .ok_or(interpolate::Error::Missing { what: "home dir" })? + .ok_or_raise_erased(|| not_found("home dir is missing"))? .to_path_buf(), "path past ~/", ) @@ -201,9 +183,7 @@ impl Path { ( Self::home_for_username( username, - home_for_user.ok_or(interpolate::Error::Missing { - what: "home for user lookup", - })?, + home_for_user.ok_or_raise_erased(|| not_found("home for user lookup is missing"))?, )?, "path past ~user/", ) @@ -211,7 +191,7 @@ impl Path { if let Some(path) = path { home.push( gix_path::try_from_byte_slice(path) - .map_err(|err| interpolate::Error::Utf8Conversion { what, err })?, + .or_raise_erased(|| validation(format!("Ill-formed UTF-8 in {what}")).with("input", path))?, ); } Ok(home) @@ -220,11 +200,9 @@ impl Path { } } - fn home_for_username( - username: &[u8], - home_for_user: fn(&str) -> Option, - ) -> Result { - let username = std::str::from_utf8(username)?; - home_for_user(username).ok_or(interpolate::Error::Missing { what: "pwd user info" }) + fn home_for_username(username: &[u8], home_for_user: fn(&str) -> Option) -> ExnResult { + let username = std::str::from_utf8(username) + .or_raise_erased(|| validation("Ill-formed UTF-8 in username").with("input", username))?; + home_for_user(username).ok_or_raise_erased(|| not_found("pwd user info is missing")) } } diff --git a/gix-config-value/src/types.rs b/gix-config-value/src/types.rs index 4c2d1c399d6..99884f45aae 100644 --- a/gix-config-value/src/types.rs +++ b/gix-config-value/src/types.rs @@ -7,6 +7,7 @@ use crate::{color, integer}; /// Note that `git-config` allows color values to simply be a collection of /// [`color::Attribute`]s, and does not require a [`color::Name`] for either the /// foreground or background color. +/// Conversion errors expose invalid `input` bytes as [metadata](gix_error::Exn::metadata()). #[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)] pub struct Color { /// A provided foreground color @@ -25,6 +26,7 @@ pub struct Color { /// suffix after fetching the value. [`integer::Suffix`] provides /// [`bitwise_offset()`][integer::Suffix::bitwise_offset] to help with the /// math, or [`to_decimal()`][Integer::to_decimal()] for obtaining a usable value in one step. +/// Conversion errors expose invalid `input` bytes as [metadata](gix_error::Exn::metadata()). #[derive(Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] pub struct Integer { /// The value, without any suffix modification @@ -34,6 +36,7 @@ pub struct Integer { } /// Any value that can be interpreted as a boolean. +/// Conversion errors expose invalid `input` bytes as [metadata](gix_error::Exn::metadata()). #[derive(Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] pub struct Boolean( /// The interpreted boolean value. diff --git a/gix-config-value/tests/value/boolean.rs b/gix-config-value/tests/value/boolean.rs index f201dcbcedc..58799158b90 100644 --- a/gix-config-value/tests/value/boolean.rs +++ b/gix-config-value/tests/value/boolean.rs @@ -1,7 +1,8 @@ use gix_config_value::Boolean; +use gix_error::Result; #[test] -fn from_utf8_str() -> crate::Result { +fn from_utf8_str() -> Result { assert_eq!( Boolean::try_from("yes")?, Boolean(true), @@ -11,7 +12,7 @@ fn from_utf8_str() -> crate::Result { } #[test] -fn from_str_false() -> crate::Result { +fn from_str_false() -> Result { assert!(!Boolean::try_from("no")?.0); assert!(!Boolean::try_from("off")?.0); assert!(!Boolean::try_from("false")?.0); @@ -21,10 +22,10 @@ fn from_str_false() -> crate::Result { } #[test] -fn from_str_true() -> crate::Result { - assert_eq!(Boolean::try_from("yes").map(Into::into), Ok(true)); - assert_eq!(Boolean::try_from("on"), Ok(Boolean(true))); - assert_eq!(Boolean::try_from("true"), Ok(Boolean(true))); +fn from_str_true() -> Result { + assert!(Boolean::try_from("yes")?.0); + assert!(Boolean::try_from("on")?.0); + assert!(Boolean::try_from("true")?.0); assert!(Boolean::try_from("1")?.0); assert!(Boolean::try_from("+10")?.0); assert!(Boolean::try_from("-1")?.0); @@ -35,14 +36,16 @@ fn from_str_true() -> crate::Result { fn ignores_case() { // Random subset for word in &["no", "yes", "on", "off", "true", "false"] { - let first: bool = Boolean::try_from(*word).unwrap().into(); - let second: bool = Boolean::try_from(word.to_uppercase().as_str()).unwrap().into(); + let first: bool = Boolean::try_from(*word).expect("valid boolean").into(); + let second: bool = Boolean::try_from(word.to_uppercase().as_str()) + .expect("valid boolean") + .into(); assert_eq!(first, second); } } #[test] -fn numbers_are_parsed_as_integers() { +fn numbers_are_parsed_as_integers() -> Result { // Use the same bases, suffixes, and full `i64` range as `Integer`. for (input, expected) in [ ("0x10", true), @@ -68,11 +71,12 @@ fn numbers_are_parsed_as_integers() { ("-8589934592g", true), // i64::MIN after applying the suffix ] { assert_eq!( - Boolean::try_from(input).map(Into::into), - Ok(expected), + Boolean::try_from(input)?.0, + expected, "{input:?}: zero integers are false and nonzero integers are true" ); } + Ok(()) } #[test] diff --git a/gix-config-value/tests/value/color.rs b/gix-config-value/tests/value/color.rs index 3fea7d4d5f0..112e4b261d7 100644 --- a/gix-config-value/tests/value/color.rs +++ b/gix-config-value/tests/value/color.rs @@ -1,7 +1,8 @@ use gix_config_value::Color; +use gix_error::Result; #[test] -fn from_utf8_str() -> crate::Result { +fn from_utf8_str() -> Result { assert_eq!( Color::try_from("red bold")?.to_string(), "red bold", @@ -15,31 +16,35 @@ mod name { use gix_config_value::color::Name; + fn name(input: &str) -> Name { + Name::from_str(input).expect("valid color name") + } + #[test] fn non_bright() { - assert_eq!(Name::from_str("normal"), Ok(Name::Normal)); - assert_eq!(Name::from_str("-1"), Ok(Name::Normal)); - assert_eq!(Name::from_str("default"), Ok(Name::Default)); - assert_eq!(Name::from_str("black"), Ok(Name::Black)); - assert_eq!(Name::from_str("red"), Ok(Name::Red)); - assert_eq!(Name::from_str("green"), Ok(Name::Green)); - assert_eq!(Name::from_str("yellow"), Ok(Name::Yellow)); - assert_eq!(Name::from_str("blue"), Ok(Name::Blue)); - assert_eq!(Name::from_str("magenta"), Ok(Name::Magenta)); - assert_eq!(Name::from_str("cyan"), Ok(Name::Cyan)); - assert_eq!(Name::from_str("white"), Ok(Name::White)); + assert_eq!(name("normal"), Name::Normal); + assert_eq!(name("-1"), Name::Normal); + assert_eq!(name("default"), Name::Default); + assert_eq!(name("black"), Name::Black); + assert_eq!(name("red"), Name::Red); + assert_eq!(name("green"), Name::Green); + assert_eq!(name("yellow"), Name::Yellow); + assert_eq!(name("blue"), Name::Blue); + assert_eq!(name("magenta"), Name::Magenta); + assert_eq!(name("cyan"), Name::Cyan); + assert_eq!(name("white"), Name::White); } #[test] fn bright() { - assert_eq!(Name::from_str("brightblack"), Ok(Name::BrightBlack)); - assert_eq!(Name::from_str("brightred"), Ok(Name::BrightRed)); - assert_eq!(Name::from_str("brightgreen"), Ok(Name::BrightGreen)); - assert_eq!(Name::from_str("brightyellow"), Ok(Name::BrightYellow)); - assert_eq!(Name::from_str("brightblue"), Ok(Name::BrightBlue)); - assert_eq!(Name::from_str("brightmagenta"), Ok(Name::BrightMagenta)); - assert_eq!(Name::from_str("brightcyan"), Ok(Name::BrightCyan)); - assert_eq!(Name::from_str("brightwhite"), Ok(Name::BrightWhite)); + assert_eq!(name("brightblack"), Name::BrightBlack); + assert_eq!(name("brightred"), Name::BrightRed); + assert_eq!(name("brightgreen"), Name::BrightGreen); + assert_eq!(name("brightyellow"), Name::BrightYellow); + assert_eq!(name("brightblue"), Name::BrightBlue); + assert_eq!(name("brightmagenta"), Name::BrightMagenta); + assert_eq!(name("brightcyan"), Name::BrightCyan); + assert_eq!(name("brightwhite"), Name::BrightWhite); } #[test] @@ -53,8 +58,8 @@ mod name { ("BRIGHTWHITE", Name::BrightWhite), ] { assert_eq!( - Name::from_str(input), - Ok(expected), + name(input), + expected, "{input:?}: color names and the 'bright' prefix are case-insensitive, like in Git" ); } @@ -72,16 +77,16 @@ mod name { #[test] fn ansi() { - assert_eq!(Name::from_str("255"), Ok(Name::Ansi(255))); - assert_eq!(Name::from_str("0"), Ok(Name::Ansi(0))); + assert_eq!(name("255"), Name::Ansi(255)); + assert_eq!(name("0"), Name::Ansi(0)); } #[test] fn hex() { - assert_eq!(Name::from_str("#ff0010"), Ok(Name::Rgb(255, 0, 16))); - assert_eq!(Name::from_str("#ffffff"), Ok(Name::Rgb(255, 255, 255))); - assert_eq!(Name::from_str("#000000"), Ok(Name::Rgb(0, 0, 0))); - assert_eq!(Name::from_str("#FF0010"), Ok(Name::Rgb(255, 0, 16))); + assert_eq!(name("#ff0010"), Name::Rgb(255, 0, 16)); + assert_eq!(name("#ffffff"), Name::Rgb(255, 255, 255)); + assert_eq!(name("#000000"), Name::Rgb(0, 0, 0)); + assert_eq!(name("#FF0010"), Name::Rgb(255, 0, 16)); } #[test] @@ -95,15 +100,15 @@ mod name { ("#fff", Name::Rgb(0xff, 0xff, 0xff), "#ffffff"), ("#aBc", Name::Rgb(0xaa, 0xbb, 0xcc), "#aabbcc"), ] { - let actual = Name::from_str(input); - assert_eq!(actual, Ok(expected), "{input:?}"); + let actual = name(input); + assert_eq!(actual, expected, "{input:?}"); assert_eq!( actual, - Name::from_str(long_form), + name(long_form), "{input:?}: the shorthand and the long form it stands for are the same color" ); assert_eq!( - actual.expect("the shorthand parses, as asserted above").to_string(), + actual.to_string(), long_form, "{input:?}: a shorthand renders back as the long form, since `Name::Rgb` keeps no record of which spelling it came from" ); @@ -144,38 +149,42 @@ mod attribute { use gix_config_value::color::Attribute; + fn attribute(input: &str) -> Attribute { + Attribute::from_str(input).expect("valid color attribute") + } + #[test] fn non_inverted() { - assert_eq!(Attribute::from_str("reset"), Ok(Attribute::RESET)); - assert_eq!(Attribute::from_str("bold"), Ok(Attribute::BOLD)); - assert_eq!(Attribute::from_str("dim"), Ok(Attribute::DIM)); - assert_eq!(Attribute::from_str("ul"), Ok(Attribute::UL)); - assert_eq!(Attribute::from_str("blink"), Ok(Attribute::BLINK)); - assert_eq!(Attribute::from_str("reverse"), Ok(Attribute::REVERSE)); - assert_eq!(Attribute::from_str("italic"), Ok(Attribute::ITALIC)); - assert_eq!(Attribute::from_str("strike"), Ok(Attribute::STRIKE)); + assert_eq!(attribute("reset"), Attribute::RESET); + assert_eq!(attribute("bold"), Attribute::BOLD); + assert_eq!(attribute("dim"), Attribute::DIM); + assert_eq!(attribute("ul"), Attribute::UL); + assert_eq!(attribute("blink"), Attribute::BLINK); + assert_eq!(attribute("reverse"), Attribute::REVERSE); + assert_eq!(attribute("italic"), Attribute::ITALIC); + assert_eq!(attribute("strike"), Attribute::STRIKE); } #[test] fn inverted_no_dash() { - assert_eq!(Attribute::from_str("nobold"), Ok(Attribute::NO_BOLD)); - assert_eq!(Attribute::from_str("nodim"), Ok(Attribute::NO_DIM)); - assert_eq!(Attribute::from_str("noul"), Ok(Attribute::NO_UL)); - assert_eq!(Attribute::from_str("noblink"), Ok(Attribute::NO_BLINK)); - assert_eq!(Attribute::from_str("noreverse"), Ok(Attribute::NO_REVERSE)); - assert_eq!(Attribute::from_str("noitalic"), Ok(Attribute::NO_ITALIC)); - assert_eq!(Attribute::from_str("nostrike"), Ok(Attribute::NO_STRIKE)); + assert_eq!(attribute("nobold"), Attribute::NO_BOLD); + assert_eq!(attribute("nodim"), Attribute::NO_DIM); + assert_eq!(attribute("noul"), Attribute::NO_UL); + assert_eq!(attribute("noblink"), Attribute::NO_BLINK); + assert_eq!(attribute("noreverse"), Attribute::NO_REVERSE); + assert_eq!(attribute("noitalic"), Attribute::NO_ITALIC); + assert_eq!(attribute("nostrike"), Attribute::NO_STRIKE); } #[test] fn inverted_dashed() { - assert_eq!(Attribute::from_str("no-bold"), Ok(Attribute::NO_BOLD)); - assert_eq!(Attribute::from_str("no-dim"), Ok(Attribute::NO_DIM)); - assert_eq!(Attribute::from_str("no-ul"), Ok(Attribute::NO_UL)); - assert_eq!(Attribute::from_str("no-blink"), Ok(Attribute::NO_BLINK)); - assert_eq!(Attribute::from_str("no-reverse"), Ok(Attribute::NO_REVERSE)); - assert_eq!(Attribute::from_str("no-italic"), Ok(Attribute::NO_ITALIC)); - assert_eq!(Attribute::from_str("no-strike"), Ok(Attribute::NO_STRIKE)); + assert_eq!(attribute("no-bold"), Attribute::NO_BOLD); + assert_eq!(attribute("no-dim"), Attribute::NO_DIM); + assert_eq!(attribute("no-ul"), Attribute::NO_UL); + assert_eq!(attribute("no-blink"), Attribute::NO_BLINK); + assert_eq!(attribute("no-reverse"), Attribute::NO_REVERSE); + assert_eq!(attribute("no-italic"), Attribute::NO_ITALIC); + assert_eq!(attribute("no-strike"), Attribute::NO_STRIKE); } #[test] @@ -193,6 +202,7 @@ mod attribute { mod from_git { use bstr::BStr; use gix_config_value::Color; + use gix_error::Result; #[test] fn reset() { @@ -280,7 +290,7 @@ mod from_git { try_color(name).expect("input color is expected to be valid") } - fn try_color<'a>(name: impl Into<&'a BStr>) -> crate::Result { + fn try_color<'a>(name: impl Into<&'a BStr>) -> Result { Ok(Color::try_from(name.into())?.to_string()) } } diff --git a/gix-config-value/tests/value/integer.rs b/gix-config-value/tests/value/integer.rs index 4423ff70e4c..7ed18d3c239 100644 --- a/gix-config-value/tests/value/integer.rs +++ b/gix-config-value/tests/value/integer.rs @@ -1,7 +1,8 @@ use gix_config_value::{Integer, integer::Suffix}; +use gix_error::Result; #[test] -fn from_utf8_str() -> crate::Result { +fn from_utf8_str() -> Result { assert_eq!( Integer::try_from("1k")?, Integer { diff --git a/gix-config-value/tests/value/main.rs b/gix-config-value/tests/value/main.rs index 937346a2774..3d23b8e5374 100644 --- a/gix-config-value/tests/value/main.rs +++ b/gix-config-value/tests/value/main.rs @@ -1,5 +1,3 @@ -type Result = std::result::Result>; - mod boolean; mod color; mod integer; diff --git a/gix-config-value/tests/value/path.rs b/gix-config-value/tests/value/path.rs index be09051ed51..69f267d3f04 100644 --- a/gix-config-value/tests/value/path.rs +++ b/gix-config-value/tests/value/path.rs @@ -1,10 +1,14 @@ mod interpolate { + use gix_error::Result; use std::path::{Path, PathBuf}; + use gix_error::ExnResult; + + use bstr::BString; use gix_config_value::path; #[test] - fn backslash_is_not_special_and_they_are_not_escaping_anything() -> crate::Result { + fn backslash_is_not_special_and_they_are_not_escaping_anything() -> Result { for path in [r"C:\foo\bar", "/foo/bar"] { let actual = gix_config_value::Path::from(path).interpolate(Default::default())?; assert_eq!(actual, Path::new(path)); @@ -14,10 +18,9 @@ mod interpolate { #[test] fn empty_path_is_error() { - assert!(matches!( - interpolate_without_context(""), - Err(path::interpolate::Error::Missing { what: "path" }) - )); + let err = interpolate_without_context("").expect_err("empty paths are invalid"); + insta::assert_debug_snapshot!(err, "empty path is error", @"path is missing"); + assert!(err.is_not_found()); } #[test] @@ -32,7 +35,7 @@ mod interpolate { git_install_dir: Path::new(git_install_dir).into(), ..Default::default() }) - .unwrap(), + .expect("valid interpolation"), expected, "prefix interpolation keeps separators as they are" ); @@ -50,33 +53,37 @@ mod interpolate { git_install_dir: Path::new(git_install_dir).into(), ..Default::default() }) - .unwrap(), + .expect("valid interpolation"), Path::new(path) ); } #[test] - fn tilde_alone_substitutes_current_user() -> crate::Result { - let home = std::env::current_dir()?; + fn tilde_alone_substitutes_current_user() -> Result { + let home = std::env::current_dir().expect("current directory is available"); assert_eq!( - gix_config_value::Path::from("~") - .interpolate(path::interpolate::Context { - home_dir: Some(&home), - ..Default::default() - }) - .unwrap(), + gix_config_value::Path::from("~").interpolate(path::interpolate::Context { + home_dir: Some(&home), + ..Default::default() + })?, home ); - assert!(matches!( - interpolate_without_context("~"), - Err(path::interpolate::Error::Missing { what: "home dir" }) - )); + let err = interpolate_without_context("~").expect_err("tilde expansion needs the current user's home"); + insta::assert_debug_snapshot!(err.classify() + .find(|classification| classification.class() == gix_error::Class::NotFound) + .expect("missing home directories are classified as not found") + .error(), "tilde expansion reports the missing home directory", @r#" + Message { + message: "home dir is missing", + class: NotFound, + } + "#); Ok(()) } #[test] - fn tilde_slash_substitutes_current_user() -> crate::Result { - let home = std::env::current_dir()?; + fn tilde_slash_substitutes_current_user() -> Result { + let home = std::env::current_dir().expect("current directory is available"); for suffix in ["", "user/bar", r"user\bar", "/user/bar"] { let actual = gix_config_value::Path::from(format!("~/{suffix}").as_str()).interpolate( path::interpolate::Context { @@ -95,8 +102,9 @@ mod interpolate { } #[test] - fn tilde_with_given_user() -> crate::Result { - let home = std::env::current_dir()?; + fn tilde_with_given_user() -> Result { + let mut error_snapshots = Vec::new(); + let home = std::env::current_dir().expect("current directory is available"); for path_suffix in &["foo/bar", r"foo\bar", ""] { let path = format!("~user/{path_suffix}"); @@ -114,20 +122,38 @@ mod interpolate { home.join("user"), "~user without trailing slash is expanded like git does" ); - assert!(matches!( - interpolate_without_context("~nonexistent"), - Err(path::interpolate::Error::Missing { what: "pwd user info" }) - )); - assert!(matches!( - interpolate_without_context("~nonexistent/foo"), - Err(path::interpolate::Error::Missing { what: "pwd user info" }) - )); + for path in ["~nonexistent", "~nonexistent/foo"] { + let err = interpolate_without_context(path).expect_err("the named user does not exist"); + error_snapshots.push(gix_testtools::redact_debug_snapshot(&(err), &[])); + assert!(err.is_not_found(), "named-user expansion classifies missing users"); + } + insta::assert_debug_snapshot!(error_snapshots, "tilde with given user", @" + [ + pwd user info is missing, + pwd user info is missing, + ] + "); Ok(()) } - fn interpolate_without_context( - path: impl AsRef, - ) -> Result { + #[test] + fn malformed_usernames_are_validation_errors_with_the_utf8_cause() { + let err = gix_config_value::Path::from(BString::from(vec![b'~', 0xff, b'/', b'x'])) + .interpolate(path::interpolate::Context { + home_for_user: Some(home_for_user), + ..Default::default() + }) + .expect_err("the username is not UTF-8"); + insta::assert_debug_snapshot!(err, "malformed usernames are validation errors with the utf8 cause", @r#" + Ill-formed UTF-8 in username, "input"="\xff" + | + └─ invalid utf-8 sequence of 1 bytes from index 0 + "#); + assert!(err.is_validation()); + assert!(err.downcast_any_ref::().is_some()); + } + + fn interpolate_without_context(path: impl AsRef) -> ExnResult { gix_config_value::Path::from(path.as_ref()).interpolate(path::interpolate::Context { home_for_user: Some(home_for_user), ..Default::default() @@ -138,7 +164,10 @@ mod interpolate { if name == "nonexistent" { return None; } - std::env::current_dir().unwrap().join(name).into() + std::env::current_dir() + .expect("current directory is available") + .join(name) + .into() } } diff --git a/gix-config/Cargo.toml b/gix-config/Cargo.toml index 3a99d2aba68..39792670ffd 100644 --- a/gix-config/Cargo.toml +++ b/gix-config/Cargo.toml @@ -24,13 +24,13 @@ serde = ["dep:serde", "bstr/serde", "gix-sec/serde", "gix-ref/serde", "gix-glob/ [dependencies] gix-features = { version = "^0.49.1", path = "../gix-features" } gix-config-value = { version = "^0.19.1", path = "../gix-config-value" } +gix-error = { version = "^0.3.0", path = "../gix-error" } gix-path = { version = "^0.12.6", path = "../gix-path" } gix-sec = { version = "^0.14.2", path = "../gix-sec" } gix-ref = { version = "^0.67.0", path = "../gix-ref" } gix-glob = { version = "^0.27.1", path = "../gix-glob" } gix-utils = { version = "^0.3.6", path = "../gix-utils", features = ["bstr"] } -thiserror = "2.0.18" unicode-bom = { version = "2.0.3" } bstr = { version = "1.12.0", default-features = false, features = ["std"] } serde = { version = "1.0.114", optional = true, default-features = false, features = ["derive"] } diff --git a/gix-config/fuzz/fuzz_targets/fuzz_file.rs b/gix-config/fuzz/fuzz_targets/fuzz_file.rs index 8ab93ddbfe5..8fb23b31550 100644 --- a/gix-config/fuzz/fuzz_targets/fuzz_file.rs +++ b/gix-config/fuzz/fuzz_targets/fuzz_file.rs @@ -34,7 +34,9 @@ fn fuzz_mutable_section( // Mutate section. let section_id = { - let mut section = file.section_mut(section_name, subsection_name)?; + let mut section = file + .section_mut(section_name, subsection_name) + .map_err(|err| err.into_error())?; let key = section.value_names().next(); if let Some(key) = key { @@ -94,7 +96,8 @@ fn fuzz_mutable_section( fn fuzz(input: &[u8]) -> Result<()> { let meta = Metadata::default(); let options = Options::default(); - let file = gix_config::File::from_bytes_no_includes(input, meta.clone(), options)?; + let file = + gix_config::File::from_bytes_no_includes(input, meta.clone(), options).map_err(|err| err.into_error())?; // Sections and frontmatter. _ = black_box(file.sections_and_ids().count()); @@ -120,11 +123,10 @@ fn fuzz(input: &[u8]) -> Result<()> { } _ = black_box(mutated_file.append(file)); - _ = black_box(gix_config::File::from_bytes_no_includes( - &mutated_file.to_bstring(), - meta, - options, - )?); + _ = black_box( + gix_config::File::from_bytes_no_includes(&mutated_file.to_bstring(), meta, options) + .map_err(|err| err.into_error())?, + ); Ok(()) } diff --git a/gix-config/src/file/access/comfort.rs b/gix-config/src/file/access/comfort.rs index 9ba42d581f0..6d0ea859541 100644 --- a/gix-config/src/file/access/comfort.rs +++ b/gix-config/src/file/access/comfort.rs @@ -1,6 +1,8 @@ use bstr::{BStr, BString}; +use gix_error::ExnMessageResult; +use gix_error::{ErrorExt, validation}; -use crate::{AsBStrOpt, AsKey, File, file::Metadata, value}; +use crate::{AsBStrOpt, AsKey, File, file::Metadata}; /// Comfortable API for accessing values impl File { @@ -85,7 +87,7 @@ impl File { } /// Like [`boolean_by()`](File::boolean_by()), but suitable for statically known `key`s like `remote.origin.url`. - pub fn boolean(&self, key: impl AsKey) -> Result, value::Error> { + pub fn boolean(&self, key: impl AsKey) -> ExnMessageResult> { self.boolean_filter(key, |_| true) } @@ -95,7 +97,7 @@ impl File { section_name: impl AsRef, subsection_name: impl AsBStrOpt, value_name: impl AsRef, - ) -> Result, value::Error> { + ) -> ExnMessageResult> { self.boolean_filter_by(section_name, subsection_name, value_name, |_| true) } @@ -104,7 +106,7 @@ impl File { &self, key: impl AsKey, filter: impl FnMut(&Metadata) -> bool, - ) -> Result, value::Error> { + ) -> ExnMessageResult> { let Some(key) = key.try_as_key() else { return Ok(None); }; @@ -118,7 +120,7 @@ impl File { subsection_name: impl AsBStrOpt, value_name: impl AsRef, mut filter: impl FnMut(&Metadata) -> bool, - ) -> Result, value::Error> { + ) -> ExnMessageResult> { let section_name = section_name.as_ref(); let section_ids = self .section_ids_by_name_and_subname(section_name, subsection_name.as_bstr_opt()) @@ -142,7 +144,7 @@ impl File { } /// Like [`integer_by()`](File::integer_by()), but suitable for statically known `key`s like `remote.origin.url`. - pub fn integer(&self, key: impl AsKey) -> Result, value::Error> { + pub fn integer(&self, key: impl AsKey) -> ExnMessageResult> { self.integer_filter(key, |_| true) } @@ -152,7 +154,7 @@ impl File { section_name: impl AsRef, subsection_name: impl AsBStrOpt, value_name: impl AsRef, - ) -> Result, value::Error> { + ) -> ExnMessageResult> { self.integer_filter_by(section_name, subsection_name, value_name, |_| true) } @@ -161,7 +163,7 @@ impl File { &self, key: impl AsKey, filter: impl FnMut(&Metadata) -> bool, - ) -> Result, value::Error> { + ) -> ExnMessageResult> { let Some(key) = key.try_as_key() else { return Ok(None); }; @@ -169,13 +171,14 @@ impl File { } /// Like [`integer_by()`](File::integer_by()), but the section containing the returned value must pass `filter` as well. + /// Invalid or overflowing values include their bytes as `input` [metadata](gix_error::Exn::metadata()). pub fn integer_filter_by( &self, section_name: impl AsRef, subsection_name: impl AsBStrOpt, value_name: impl AsRef, filter: impl FnMut(&Metadata) -> bool, - ) -> Result, value::Error> { + ) -> ExnMessageResult> { let Some(int) = self .raw_value_filter_by(section_name, subsection_name, value_name, filter) .ok() @@ -183,7 +186,10 @@ impl File { return Ok(None); }; crate::Integer::try_from(BStr::new(&int)) - .and_then(|b| b.to_decimal().ok_or_else(|| value::Error::new("Integer overflow", int))) + .and_then(|b| { + b.to_decimal() + .ok_or_else(|| validation("Integer overflow").with("input", BStr::new(&int)).raise()) + }) .map(Some) } @@ -222,7 +228,7 @@ impl File { } /// Like [`integers()`](File::integers()), but suitable for statically known `key`s like `remote.origin.url`. - pub fn integers(&self, key: impl AsKey) -> Result>, value::Error> { + pub fn integers(&self, key: impl AsKey) -> ExnMessageResult>> { self.integers_filter(key, |_| true) } @@ -233,7 +239,7 @@ impl File { section_name: impl AsRef, subsection_name: impl AsBStrOpt, value_name: impl AsRef, - ) -> Result>, value::Error> { + ) -> ExnMessageResult>> { self.integers_filter_by(section_name, subsection_name, value_name, |_| true) } @@ -242,7 +248,7 @@ impl File { &self, key: impl AsKey, filter: impl FnMut(&Metadata) -> bool, - ) -> Result>, value::Error> { + ) -> ExnMessageResult>> { let Some(key) = key.try_as_key() else { return Ok(None); }; @@ -251,13 +257,14 @@ impl File { /// Similar to [`integers_by(…)`](File::integers_by()) but all integers are in sections that passed `filter` /// and that are not overflowing. + /// Invalid or overflowing values include their bytes as `input` [metadata](gix_error::Exn::metadata()). pub fn integers_filter_by( &self, section_name: impl AsRef, subsection_name: impl AsBStrOpt, value_name: impl AsRef, filter: impl FnMut(&Metadata) -> bool, - ) -> Result>, value::Error> { + ) -> ExnMessageResult>> { let Some(values) = self .raw_values_filter_by(section_name, subsection_name, value_name, filter) .ok() @@ -267,8 +274,10 @@ impl File { values .into_iter() .map(|v| { - crate::Integer::try_from(BStr::new(&v)) - .and_then(|int| int.to_decimal().ok_or_else(|| value::Error::new("Integer overflow", v))) + crate::Integer::try_from(BStr::new(&v)).and_then(|int| { + int.to_decimal() + .ok_or_else(|| validation("Integer overflow").with("input", BStr::new(&v)).raise()) + }) }) .collect::, _>>() .map(Some) diff --git a/gix-config/src/file/access/mutate.rs b/gix-config/src/file/access/mutate.rs index 95c8fb20a8e..5f2c6786bfe 100644 --- a/gix-config/src/file/access/mutate.rs +++ b/gix-config/src/file/access/mutate.rs @@ -1,9 +1,11 @@ use bstr::BStr; +use gix_error::ExnMessageResult; +use gix_error::ExnResult; use gix_features::threading::OwnShared; use crate::{ AsBStrOpt, File, - file::{self, IntoBStringOpt, Metadata, SectionId, SectionMut, rename_section, write::ends_with_newline}, + file::{self, IntoBStringOpt, Metadata, SectionId, SectionMut, write::ends_with_newline}, lookup, parse::{Event, FrontMatterEvents, Span, section}, }; @@ -47,19 +49,11 @@ impl IntoBStringOpt for &T { /// Mutating low-level access methods. impl File { /// Returns the last mutable section with a given `name` and optional `subsection_name`, _if it exists_. - pub fn section_mut( - &mut self, - name: impl AsRef, - subsection_name: impl AsBStrOpt, - ) -> Result, lookup::existing::Error> { + pub fn section_mut(&mut self, name: impl AsRef, subsection_name: impl AsBStrOpt) -> ExnResult> { self.section_mut_inner(name.as_ref(), subsection_name.as_bstr_opt()) } - fn section_mut_inner<'a>( - &'a mut self, - name: &str, - subsection_name: Option<&BStr>, - ) -> Result, lookup::existing::Error> { + fn section_mut_inner<'a>(&'a mut self, name: &str, subsection_name: Option<&BStr>) -> ExnResult> { let id = self .section_ids_by_name_and_subname(name, subsection_name)? .next_back() @@ -71,8 +65,8 @@ impl File { } /// Returns the last found mutable section with a given `key`, identifying the name and subsection name like `core` or `remote.origin`. - pub fn section_mut_by_key(&mut self, key: impl crate::AsBStr) -> Result, lookup::existing::Error> { - let key = section::unvalidated::KeyRef::parse(&key).ok_or(lookup::existing::Error::KeyMissing)?; + pub fn section_mut_by_key(&mut self, key: impl crate::AsBStr) -> ExnResult> { + let key = section::unvalidated::KeyRef::parse(&key).ok_or_else(lookup::existing::key_missing)?; self.section_mut_inner(key.section_name, key.subsection_name) } @@ -89,7 +83,7 @@ impl File { &mut self, name: impl AsRef, subsection_name: impl AsBStrOpt, - ) -> Result, section::header::Error> { + ) -> ExnMessageResult> { self.section_mut_or_create_new_inner(name.as_ref(), subsection_name.as_bstr_opt()) } @@ -97,7 +91,7 @@ impl File { &'a mut self, name: &str, subsection_name: Option<&BStr>, - ) -> Result, section::header::Error> { + ) -> ExnMessageResult> { self.section_mut_or_create_new_filter_inner(name, subsection_name, |_| true) } @@ -108,7 +102,7 @@ impl File { name: impl AsRef, subsection_name: impl AsBStrOpt, filter: impl FnMut(&Metadata) -> bool, - ) -> Result, section::header::Error> { + ) -> ExnMessageResult> { self.section_mut_or_create_new_filter_inner(name.as_ref(), subsection_name.as_bstr_opt(), filter) } @@ -117,7 +111,7 @@ impl File { name: &str, subsection_name: Option<&BStr>, mut filter: impl FnMut(&Metadata) -> bool, - ) -> Result, section::header::Error> { + ) -> ExnMessageResult> { match self .section_ids_by_name_and_subname(name, subsection_name) .ok() @@ -144,7 +138,7 @@ impl File { name: impl AsRef, subsection_name: impl AsBStrOpt, filter: impl FnMut(&Metadata) -> bool, - ) -> Result>, lookup::existing::Error> { + ) -> ExnResult>> { self.section_mut_filter_inner(name.as_ref(), subsection_name.as_bstr_opt(), filter) } @@ -153,7 +147,7 @@ impl File { name: &str, subsection_name: Option<&BStr>, mut filter: impl FnMut(&Metadata) -> bool, - ) -> Result>, lookup::existing::Error> { + ) -> ExnResult>> { let id = self .section_ids_by_name_and_subname(name, subsection_name)? .rev() @@ -171,8 +165,8 @@ impl File { &mut self, key: impl crate::AsBStr, filter: impl FnMut(&Metadata) -> bool, - ) -> Result>, lookup::existing::Error> { - let key = section::unvalidated::KeyRef::parse(&key).ok_or(lookup::existing::Error::KeyMissing)?; + ) -> ExnResult>> { + let key = section::unvalidated::KeyRef::parse(&key).ok_or_else(lookup::existing::key_missing)?; self.section_mut_filter_inner(key.section_name, key.subsection_name, filter) } @@ -191,7 +185,7 @@ impl File { /// let section = git_config.new_section("hello", "world")?; /// let nl = section.newline().to_owned(); /// assert_eq!(git_config.to_string(), format!("[hello \"world\"]{nl}")); - /// # Ok::<(), Box>(()) + /// # Ok::<(), Box>(()) /// ``` /// /// Creating a new empty section and adding values to it: @@ -208,21 +202,17 @@ impl File { /// assert_eq!(git_config.to_string(), format!("[hello \"world\"]{nl}\ta = b{nl}")); /// let _section = git_config.new_section("core", None); /// assert_eq!(git_config.to_string(), format!("[hello \"world\"]{nl}\ta = b{nl}[core]{nl}")); - /// # Ok::<(), Box>(()) + /// # Ok::<(), Box>(()) /// ``` pub fn new_section( &mut self, name: impl AsRef, subsection: impl IntoBStringOpt, - ) -> Result, section::header::Error> { + ) -> ExnMessageResult> { self.new_section_inner(name.as_ref(), subsection.into_bstring_opt()) } - fn new_section_inner( - &mut self, - name: &str, - subsection: Option, - ) -> Result, section::header::Error> { + fn new_section_inner(&mut self, name: &str, subsection: Option) -> ExnMessageResult> { let section = file::SectionData::new(name, subsection, OwnShared::clone(&self.meta), &mut self.backing)?; let id = self.push_section_internal(section); let nl = self.detect_newline_style_smallvec(); @@ -250,7 +240,7 @@ impl File { /// let section = git_config.remove_section("hello", "world"); /// assert!(section.is_some()); /// assert_eq!(git_config.to_string(), ""); - /// # Ok::<(), Box>(()) + /// # Ok::<(), Box>(()) /// ``` /// /// Precedence example for removing sections with the same name: @@ -268,7 +258,7 @@ impl File { /// let section = git_config.remove_section("hello", "world"); /// assert!(section.is_some()); /// assert_eq!(git_config.to_string(), "[hello \"world\"]\n some-value = 4\n"); - /// # Ok::<(), Box>(()) + /// # Ok::<(), Box>(()) /// ``` pub fn remove_section(&mut self, name: impl AsRef, subsection_name: impl AsBStrOpt) -> Option { let id = self @@ -332,7 +322,7 @@ impl File { /// Adds the provided `section` to the config, returning a mutable reference to it for immediate editing. /// Note that its meta-data will remain as is. - pub fn push_section(&mut self, section: file::Section) -> Result, crate::parse::span::Error> { + pub fn push_section(&mut self, section: file::Section) -> ExnMessageResult> { let section = section.into_data(&mut self.backing)?; let id = self.push_section_internal(section); let nl = self.detect_newline_style_smallvec(); @@ -349,7 +339,7 @@ impl File { subsection_name: impl AsBStrOpt, new_name: impl AsRef, new_subsection_name: impl IntoBStringOpt, - ) -> Result<(), rename_section::Error> { + ) -> ExnResult { self.rename_section_filter(name, subsection_name, new_name, new_subsection_name, |_| true) } @@ -358,8 +348,7 @@ impl File { /// /// Existing sections with the target name are preserved. /// - /// Note that the otherwise unused [`lookup::existing::Error::KeyMissing`] variant is used to indicate - /// that the `filter` rejected all candidates, leading to no section being renamed after all. + /// A not-found error indicates that the `filter` rejected all candidates, leading to no section being renamed. pub fn rename_section_filter( &mut self, name: impl AsRef, @@ -367,15 +356,17 @@ impl File { new_name: impl AsRef, new_subsection_name: impl IntoBStringOpt, mut filter: impl FnMut(&Metadata) -> bool, - ) -> Result<(), rename_section::Error> { + ) -> ExnResult { let ids: Vec<_> = self .section_ids_by_name_and_subname(name.as_ref(), subsection_name.as_bstr_opt())? .filter(|id| filter(&self.sections.get(id).expect("each id has a section").meta)) .collect(); if ids.is_empty() { - return Err(rename_section::Error::Lookup(lookup::existing::Error::KeyMissing)); + return Err(lookup::existing::key_missing()); } - let header = section::HeaderData::new_in(new_name, new_subsection_name.into_bstring_opt(), &mut self.backing)?; + use gix_error::ResultExt; + let header = section::HeaderData::new_in(new_name, new_subsection_name.into_bstring_opt(), &mut self.backing) + .or_erased()?; for id in ids { file::util::set_section_header( self.sections @@ -391,7 +382,7 @@ impl File { } /// Append another File to the end of ourselves, without losing any information. - pub fn append(&mut self, other: Self) -> Result<&mut Self, crate::parse::span::Error> { + pub fn append(&mut self, other: Self) -> ExnMessageResult<&mut Self> { self.append_or_insert(other, None) } @@ -400,7 +391,7 @@ impl File { &mut self, mut other: Self, mut insert_after: Option, - ) -> Result<&mut Self, crate::parse::span::Error> { + ) -> ExnMessageResult<&mut Self> { let nl = self.detect_newline_style_smallvec(); let our_last_section_before_append = insert_after.or_else(|| (self.next_section_id != 0).then(|| SectionId(self.next_section_id - 1))); @@ -464,7 +455,7 @@ impl File { Ok(self) } - fn rebase_events(&mut self, offset: usize) -> Result<(), crate::parse::span::Error> { + fn rebase_events(&mut self, offset: usize) -> ExnMessageResult { for event in &mut self.frontmatter_events { event.rebase(offset)?; } diff --git a/gix-config/src/file/access/raw.rs b/gix-config/src/file/access/raw.rs index 2155da7f69a..a6dc076bf48 100644 --- a/gix-config/src/file/access/raw.rs +++ b/gix-config/src/file/access/raw.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use bstr::{BStr, BString}; +use gix_error::{ExnResult, ResultExt}; use smallvec::ToSmallVec; use crate::{ @@ -18,7 +19,7 @@ impl File { /// /// Consider [`Self::raw_values()`] if you want to get all values of /// a multivar instead. - pub fn raw_value(&self, key: impl AsKey) -> Result { + pub fn raw_value(&self, key: impl AsKey) -> ExnResult { let key = key.as_key(); self.raw_value_filter_by(key.section_name, key.subsection_name, key.value_name, |_| true) } @@ -33,7 +34,7 @@ impl File { section_name: impl AsRef, subsection_name: impl AsBStrOpt, value_name: impl AsRef, - ) -> Result { + ) -> ExnResult { self.raw_value_filter_by(section_name, subsection_name, value_name, |_| true) } @@ -41,10 +42,7 @@ impl File { /// /// Resolution is identical to [`raw_value()`][Self::raw_value()]: the last explicit value wins, even across /// multiple matching sections. - pub fn raw_value_with_section( - &self, - key: impl AsKey, - ) -> Result<(BString, file::SectionRef<'_>), lookup::existing::Error> { + pub fn raw_value_with_section(&self, key: impl AsKey) -> ExnResult<(BString, file::SectionRef<'_>)> { let key = key.as_key(); self.raw_value_with_section_by(key.section_name, key.subsection_name, key.value_name) } @@ -58,7 +56,7 @@ impl File { section_name: impl AsRef, subsection_name: impl AsBStrOpt, value_name: impl AsRef, - ) -> Result<(BString, file::SectionRef<'_>), lookup::existing::Error> { + ) -> ExnResult<(BString, file::SectionRef<'_>)> { self.raw_value_with_section_filter_by(section_name, subsection_name, value_name, |_| true) } @@ -70,7 +68,7 @@ impl File { &self, key: impl AsKey, filter: impl FnMut(&Metadata) -> bool, - ) -> Result<(BString, file::SectionRef<'_>), lookup::existing::Error> { + ) -> ExnResult<(BString, file::SectionRef<'_>)> { let key = key.as_key(); self.raw_value_with_section_filter_by(key.section_name, key.subsection_name, key.value_name, filter) } @@ -83,7 +81,7 @@ impl File { subsection_name: impl AsBStrOpt, value_name: impl AsRef, filter: impl FnMut(&Metadata) -> bool, - ) -> Result<(BString, file::SectionRef<'_>), lookup::existing::Error> { + ) -> ExnResult<(BString, file::SectionRef<'_>)> { self.raw_value_with_section_filter_inner( section_name.as_ref(), subsection_name.as_bstr_opt(), @@ -96,11 +94,7 @@ impl File { /// /// Consider [`Self::raw_values()`] if you want to get all values of /// a multivar instead. - pub fn raw_value_filter( - &self, - key: impl AsKey, - filter: impl FnMut(&Metadata) -> bool, - ) -> Result { + pub fn raw_value_filter(&self, key: impl AsKey, filter: impl FnMut(&Metadata) -> bool) -> ExnResult { let key = key.as_key(); self.raw_value_filter_by(key.section_name, key.subsection_name, key.value_name, filter) } @@ -116,7 +110,7 @@ impl File { subsection_name: impl AsBStrOpt, value_name: impl AsRef, filter: impl FnMut(&Metadata) -> bool, - ) -> Result { + ) -> ExnResult { self.raw_value_filter_inner( section_name.as_ref(), subsection_name.as_bstr_opt(), @@ -131,7 +125,7 @@ impl File { subsection_name: Option<&BStr>, value_name: &str, filter: impl FnMut(&Metadata) -> bool, - ) -> Result { + ) -> ExnResult { self.raw_value_with_section_filter_inner(section_name, subsection_name, value_name, filter) .map(|(value, _section)| value) } @@ -142,7 +136,7 @@ impl File { subsection_name: Option<&BStr>, value_name: &str, mut filter: impl FnMut(&Metadata) -> bool, - ) -> Result<(BString, file::SectionRef<'_>), lookup::existing::Error> { + ) -> ExnResult<(BString, file::SectionRef<'_>)> { let section_ids = self.section_ids_by_name_and_subname(section_name, subsection_name)?; for section_id in section_ids.rev() { let section = self.sections.get(§ion_id).expect("known section id"); @@ -154,14 +148,14 @@ impl File { } } - Err(lookup::existing::Error::KeyMissing) + Err(lookup::existing::key_missing()) } /// Returns a mutable reference to an uninterpreted value given a `key`. /// /// Consider [`Self::raw_values_mut`] if you want to get mutable /// references to all values of a multivar instead. - pub fn raw_value_mut(&mut self, key: impl AsKey) -> Result, lookup::existing::Error> { + pub fn raw_value_mut(&mut self, key: impl AsKey) -> ExnResult> { let key = key.as_key(); self.raw_value_mut_filter_inner(key.section_name, key.subsection_name, key.value_name, |_| true) } @@ -176,7 +170,7 @@ impl File { section_name: impl AsRef, subsection_name: impl AsBStrOpt, value_name: impl AsRef, - ) -> Result, lookup::existing::Error> { + ) -> ExnResult> { self.raw_value_mut_filter_by(section_name, subsection_name, value_name, |_| true) } @@ -188,7 +182,7 @@ impl File { &mut self, key: impl AsKey, filter: impl FnMut(&Metadata) -> bool, - ) -> Result, lookup::existing::Error> { + ) -> ExnResult> { let key = key.as_key(); self.raw_value_mut_filter_inner(key.section_name, key.subsection_name, key.value_name, filter) } @@ -203,7 +197,7 @@ impl File { subsection_name: impl AsBStrOpt, value_name: impl AsRef, filter: impl FnMut(&Metadata) -> bool, - ) -> Result, lookup::existing::Error> { + ) -> ExnResult> { self.raw_value_mut_filter_inner( section_name.as_ref(), subsection_name.as_bstr_opt(), @@ -218,11 +212,11 @@ impl File { subsection_name: Option<&BStr>, value_name: &str, mut filter: impl FnMut(&Metadata) -> bool, - ) -> Result, lookup::existing::Error> { + ) -> ExnResult> { let mut section_ids = self .section_ids_by_name_and_subname(section_name, subsection_name)? .rev(); - let key = section::ValueName::try_from(value_name)?; + let key = section::ValueName::try_from(value_name).or_erased()?; while let Some(section_id) = section_ids.next() { let mut index = 0; @@ -271,7 +265,7 @@ impl File { }); } - Err(lookup::existing::Error::KeyMissing) + Err(lookup::existing::key_missing()) } /// Returns all uninterpreted values given a `key`. @@ -309,7 +303,7 @@ impl File { /// /// Consider [`Self::raw_value`] if you want to get the resolved single /// value for a given key, if your value does not support multi-valued values. - pub fn raw_values(&self, key: impl AsKey) -> Result, lookup::existing::Error> { + pub fn raw_values(&self, key: impl AsKey) -> ExnResult> { let key = key.as_key(); self.raw_values_by(key.section_name, key.subsection_name, key.value_name) } @@ -355,15 +349,12 @@ impl File { section_name: impl AsRef, subsection_name: impl AsBStrOpt, value_name: impl AsRef, - ) -> Result, lookup::existing::Error> { + ) -> ExnResult> { self.raw_values_filter_by(section_name, subsection_name, value_name, |_| true) } /// Returns all uninterpreted values and their containing sections given a `key`, in order of occurrence. - pub fn raw_values_with_sections( - &self, - key: impl AsKey, - ) -> Result)>, lookup::existing::Error> { + pub fn raw_values_with_sections(&self, key: impl AsKey) -> ExnResult)>> { let key = key.as_key(); self.raw_values_with_sections_by(key.section_name, key.subsection_name, key.value_name) } @@ -375,7 +366,7 @@ impl File { section_name: impl AsRef, subsection_name: impl AsBStrOpt, value_name: impl AsRef, - ) -> Result)>, lookup::existing::Error> { + ) -> ExnResult)>> { self.raw_values_with_sections_filter_by(section_name, subsection_name, value_name, |_| true) } @@ -385,7 +376,7 @@ impl File { &self, key: impl AsKey, filter: impl FnMut(&Metadata) -> bool, - ) -> Result)>, lookup::existing::Error> { + ) -> ExnResult)>> { let key = key.as_key(); self.raw_values_with_sections_filter_by(key.section_name, key.subsection_name, key.value_name, filter) } @@ -398,7 +389,7 @@ impl File { subsection_name: impl AsBStrOpt, value_name: impl AsRef, filter: impl FnMut(&Metadata) -> bool, - ) -> Result)>, lookup::existing::Error> { + ) -> ExnResult)>> { self.raw_values_with_sections_filter_inner( section_name.as_ref(), subsection_name.as_bstr_opt(), @@ -411,11 +402,7 @@ impl File { /// /// The ordering means that the last of the returned values is the one that would be the /// value used in the single-value case. - pub fn raw_values_filter( - &self, - key: impl AsKey, - filter: impl FnMut(&Metadata) -> bool, - ) -> Result, lookup::existing::Error> { + pub fn raw_values_filter(&self, key: impl AsKey, filter: impl FnMut(&Metadata) -> bool) -> ExnResult> { let key = key.as_key(); self.raw_values_filter_by(key.section_name, key.subsection_name, key.value_name, filter) } @@ -431,7 +418,7 @@ impl File { subsection_name: impl AsBStrOpt, value_name: impl AsRef, filter: impl FnMut(&Metadata) -> bool, - ) -> Result, lookup::existing::Error> { + ) -> ExnResult> { self.raw_values_filter_inner( section_name.as_ref(), subsection_name.as_bstr_opt(), @@ -446,7 +433,7 @@ impl File { subsection_name: Option<&BStr>, value_name: &str, filter: impl FnMut(&Metadata) -> bool, - ) -> Result, lookup::existing::Error> { + ) -> ExnResult> { self.raw_values_with_sections_filter_inner(section_name, subsection_name, value_name, filter) .map(|values| values.into_iter().map(|(value, _section)| value).collect()) } @@ -457,7 +444,7 @@ impl File { subsection_name: Option<&BStr>, value_name: &str, mut filter: impl FnMut(&Metadata) -> bool, - ) -> Result)>, lookup::existing::Error> { + ) -> ExnResult)>> { let mut values = Vec::new(); let section_ids = self.section_ids_by_name_and_subname(section_name, subsection_name)?; for section_id in section_ids { @@ -476,7 +463,7 @@ impl File { } if values.is_empty() { - Err(lookup::existing::Error::KeyMissing) + Err(lookup::existing::key_missing()) } else { Ok(values) } @@ -503,7 +490,7 @@ impl File { /// # use std::convert::TryFrom; /// # let mut git_config = gix_config::File::try_from("[core]a=b\n[core]\na=c\na=d").unwrap(); /// assert_eq!( - /// git_config.raw_values("core.a")?, + /// git_config.raw_values("core.a").expect("values exist"), /// vec![ /// bstr::BString::from("b"), /// bstr::BString::from("c"), @@ -511,10 +498,10 @@ impl File { /// ] /// ); /// - /// git_config.raw_values_mut("core.a")?.set_all("g"); + /// git_config.raw_values_mut("core.a").expect("values exist").set_all("g"); /// /// assert_eq!( - /// git_config.raw_values("core.a")?, + /// git_config.raw_values("core.a").expect("values exist"), /// vec![ /// bstr::BString::from("g"), /// bstr::BString::from("g"), @@ -529,7 +516,7 @@ impl File { /// /// Note that this operation is relatively expensive, requiring a full /// traversal of the config. - pub fn raw_values_mut(&mut self, key: impl AsKey) -> Result, lookup::existing::Error> { + pub fn raw_values_mut(&mut self, key: impl AsKey) -> ExnResult> { let key = key.as_key(); self.raw_values_mut_filter_inner(key.section_name, key.subsection_name, key.value_name, |_| true) } @@ -556,7 +543,7 @@ impl File { /// # use std::convert::TryFrom; /// # let mut git_config = gix_config::File::try_from("[core]a=b\n[core]\na=c\na=d").unwrap(); /// assert_eq!( - /// git_config.raw_values("core.a")?, + /// git_config.raw_values("core.a").expect("values exist"), /// vec![ /// bstr::BString::from("b"), /// bstr::BString::from("c"), @@ -564,10 +551,10 @@ impl File { /// ] /// ); /// - /// git_config.raw_values_mut_by("core", None, "a")?.set_all("g"); + /// git_config.raw_values_mut_by("core", None, "a").expect("values exist").set_all("g"); /// /// assert_eq!( - /// git_config.raw_values("core.a")?, + /// git_config.raw_values("core.a").expect("values exist"), /// vec![ /// bstr::BString::from("g"), /// bstr::BString::from("g"), @@ -587,7 +574,7 @@ impl File { section_name: impl AsRef, subsection_name: impl AsBStrOpt, value_name: impl AsRef, - ) -> Result, lookup::existing::Error> { + ) -> ExnResult> { self.raw_values_mut_filter_by(section_name, subsection_name, value_name, |_| true) } @@ -597,7 +584,7 @@ impl File { &mut self, key: impl AsKey, filter: impl FnMut(&Metadata) -> bool, - ) -> Result, lookup::existing::Error> { + ) -> ExnResult> { let key = key.as_key(); self.raw_values_mut_filter_inner(key.section_name, key.subsection_name, key.value_name, filter) } @@ -610,7 +597,7 @@ impl File { subsection_name: impl AsBStrOpt, value_name: impl AsRef, filter: impl FnMut(&Metadata) -> bool, - ) -> Result, lookup::existing::Error> { + ) -> ExnResult> { self.raw_values_mut_filter_inner( section_name.as_ref(), subsection_name.as_bstr_opt(), @@ -625,9 +612,9 @@ impl File { subsection_name: Option<&BStr>, value_name: &str, mut filter: impl FnMut(&Metadata) -> bool, - ) -> Result, lookup::existing::Error> { + ) -> ExnResult> { let section_ids = self.section_ids_by_name_and_subname(section_name, subsection_name)?; - let key = section::ValueName::try_from(value_name)?; + let key = section::ValueName::try_from(value_name).or_erased()?; let mut offsets = HashMap::new(); let mut entries = Vec::new(); @@ -671,7 +658,7 @@ impl File { entries.sort(); if entries.is_empty() { - Err(lookup::existing::Error::KeyMissing) + Err(lookup::existing::key_missing()) } else { Ok(MultiValueMut { section: &mut self.sections, @@ -705,10 +692,10 @@ impl File { /// # use gix_config::File; /// # use std::convert::TryFrom; /// # let mut git_config = gix_config::File::try_from("[core]a=b\n[core]\na=c\na=d").unwrap(); - /// git_config.set_existing_raw_value("core.a", "e")?; - /// assert_eq!(git_config.raw_value("core.a")?, "e"); + /// git_config.set_existing_raw_value("core.a", "e").expect("value exists"); + /// assert_eq!(git_config.raw_value("core.a").expect("value exists"), "e"); /// assert_eq!( - /// git_config.raw_values("core.a")?, + /// git_config.raw_values("core.a").expect("values exist"), /// vec![ /// bstr::BString::from("b"), /// bstr::BString::from("c"), @@ -717,14 +704,11 @@ impl File { /// ); /// # Ok::<(), Box>(()) /// ``` - pub fn set_existing_raw_value( - &mut self, - key: impl AsKey, - new_value: impl crate::AsBStr, - ) -> Result<(), crate::file::set_raw_value::Error> { + pub fn set_existing_raw_value(&mut self, key: impl AsKey, new_value: impl crate::AsBStr) -> ExnResult { let key = key.as_key(); self.raw_value_mut_filter_inner(key.section_name, key.subsection_name, key.value_name, |_| true)? - .set(new_value)?; + .set(new_value) + .or_erased()?; Ok(()) } @@ -750,10 +734,10 @@ impl File { /// # use gix_config::File; /// # use std::convert::TryFrom; /// # let mut git_config = gix_config::File::try_from("[core]a=b\n[core]\na=c\na=d").unwrap(); - /// git_config.set_existing_raw_value_by("core", None, "a", "e")?; - /// assert_eq!(git_config.raw_value("core.a")?, "e"); + /// git_config.set_existing_raw_value_by("core", None, "a", "e").expect("value exists"); + /// assert_eq!(git_config.raw_value("core.a").expect("value exists"), "e"); /// assert_eq!( - /// git_config.raw_values("core.a")?, + /// git_config.raw_values("core.a").expect("values exist"), /// vec![ /// bstr::BString::from("b"), /// bstr::BString::from("c"), @@ -768,9 +752,10 @@ impl File { subsection_name: impl AsBStrOpt, value_name: impl AsRef, new_value: impl crate::AsBStr, - ) -> Result<(), crate::file::set_raw_value::Error> { + ) -> ExnResult { self.raw_value_mut_by(section_name, subsection_name, value_name)? - .set(new_value)?; + .set(new_value) + .or_erased()?; Ok(()) } @@ -791,18 +776,14 @@ impl File { /// ``` /// # use gix_config::File; /// # let mut git_config = gix_config::File::try_from("[core]a=b").unwrap(); - /// let prev = git_config.set_raw_value(&"core.a", "e")?; - /// git_config.set_raw_value(&"core.b", "f")?; + /// let prev = git_config.set_raw_value(&"core.a", "e").expect("valid value"); + /// git_config.set_raw_value(&"core.b", "f").expect("valid value"); /// assert_eq!(prev.expect("present"), "b"); - /// assert_eq!(git_config.raw_value("core.a")?, "e"); - /// assert_eq!(git_config.raw_value("core.b")?, "f"); + /// assert_eq!(git_config.raw_value("core.a").expect("value exists"), "e"); + /// assert_eq!(git_config.raw_value("core.b").expect("value exists"), "f"); /// # Ok::<(), Box>(()) /// ``` - pub fn set_raw_value( - &mut self, - key: impl AsKey, - new_value: impl crate::AsBStr, - ) -> Result, crate::file::set_raw_value::Error> { + pub fn set_raw_value(&mut self, key: impl AsKey, new_value: impl crate::AsBStr) -> ExnResult> { self.set_raw_value_filter(key, new_value, |_| true) } @@ -823,11 +804,11 @@ impl File { /// ``` /// # use gix_config::File; /// # let mut git_config = gix_config::File::try_from("[core]a=b").unwrap(); - /// let prev = git_config.set_raw_value_by("core", None, "a", "e")?; - /// git_config.set_raw_value_by("core", None, "b", "f")?; + /// let prev = git_config.set_raw_value_by("core", None, "a", "e").expect("valid value"); + /// git_config.set_raw_value_by("core", None, "b", "f").expect("valid value"); /// assert_eq!(prev.expect("present"), "b"); - /// assert_eq!(git_config.raw_value("core.a")?, "e"); - /// assert_eq!(git_config.raw_value("core.b")?, "f"); + /// assert_eq!(git_config.raw_value("core.a").expect("value exists"), "e"); + /// assert_eq!(git_config.raw_value("core.b").expect("value exists"), "f"); /// # Ok::<(), Box>(()) /// ``` pub fn set_raw_value_by( @@ -836,7 +817,7 @@ impl File { subsection_name: impl AsBStrOpt, value_name: impl AsRef, new_value: impl crate::AsBStr, - ) -> Result, crate::file::set_raw_value::Error> { + ) -> ExnResult> { self.set_raw_value_filter_by(section_name, subsection_name, value_name, new_value, |_| true) } @@ -847,7 +828,7 @@ impl File { key: impl AsKey, new_value: impl crate::AsBStr, filter: impl FnMut(&Metadata) -> bool, - ) -> Result, crate::file::set_raw_value::Error> { + ) -> ExnResult> { let key = key.as_key(); self.set_raw_value_filter_by_inner(key.section_name, key.subsection_name, key.value_name, new_value, filter) } @@ -861,7 +842,7 @@ impl File { value_name: impl AsRef, new_value: impl crate::AsBStr, filter: impl FnMut(&Metadata) -> bool, - ) -> Result, crate::file::set_raw_value::Error> { + ) -> ExnResult> { self.set_raw_value_filter_by_inner( section_name.as_ref(), subsection_name.as_bstr_opt(), @@ -878,10 +859,12 @@ impl File { value_name: &str, new_value: impl crate::AsBStr, filter: impl FnMut(&Metadata) -> bool, - ) -> Result, crate::file::set_raw_value::Error> { - let key = section::ValueName::try_from(value_name)?; - let mut section = self.section_mut_or_create_new_filter_inner(section_name, subsection_name, filter)?; - section.set_inner(key, new_value.as_bstr()).map_err(Into::into) + ) -> ExnResult> { + let key = section::ValueName::try_from(value_name).or_erased()?; + let mut section = self + .section_mut_or_create_new_filter_inner(section_name, subsection_name, filter) + .or_erased()?; + section.set_inner(key, new_value.as_bstr()).or_erased() } /// Sets a multivar in a given `key`. @@ -921,8 +904,8 @@ impl File { /// "y", /// "z", /// ]; - /// git_config.set_existing_raw_multi_value("core.a", new_values.into_iter())?; - /// let fetched_config = git_config.raw_values("core.a")?; + /// git_config.set_existing_raw_multi_value("core.a", new_values.into_iter()).expect("values exist"); + /// let fetched_config = git_config.raw_values("core.a").expect("values exist"); /// assert!(fetched_config.iter().any(|v| v == "x")); /// assert!(fetched_config.iter().any(|v| v == "y")); /// assert!(fetched_config.iter().any(|v| v == "z")); @@ -939,8 +922,8 @@ impl File { /// "x", /// "y", /// ]; - /// git_config.set_existing_raw_multi_value("core.a", new_values.into_iter())?; - /// let fetched_config = git_config.raw_values("core.a")?; + /// git_config.set_existing_raw_multi_value("core.a", new_values.into_iter()).expect("values exist"); + /// let fetched_config = git_config.raw_values("core.a").expect("values exist"); /// assert!(fetched_config.iter().any(|v| v == "x")); /// assert!(fetched_config.iter().any(|v| v == "y")); /// # Ok::<(), Box>(()) @@ -958,22 +941,19 @@ impl File { /// "z", /// "discarded", /// ]; - /// git_config.set_existing_raw_multi_value("core.a", new_values)?; - /// assert!(!git_config.raw_values("core.a")?.iter().any(|v| v == "discarded")); + /// git_config.set_existing_raw_multi_value("core.a", new_values).expect("values exist"); + /// assert!(!git_config.raw_values("core.a").expect("values exist").iter().any(|v| v == "discarded")); /// # Ok::<(), Box>(()) /// ``` - pub fn set_existing_raw_multi_value( - &mut self, - key: impl AsKey, - new_values: Iter, - ) -> Result<(), crate::file::set_raw_value::Error> + pub fn set_existing_raw_multi_value(&mut self, key: impl AsKey, new_values: Iter) -> ExnResult where Iter: IntoIterator, Item: crate::AsBStr, { let key = key.as_key(); self.raw_values_mut_filter_inner(key.section_name, key.subsection_name, key.value_name, |_| true)? - .set_values(new_values)?; + .set_values(new_values) + .or_erased()?; Ok(()) } @@ -1014,8 +994,8 @@ impl File { /// "y", /// "z", /// ]; - /// git_config.set_existing_raw_multi_value_by("core", None, "a", new_values.into_iter())?; - /// let fetched_config = git_config.raw_values("core.a")?; + /// git_config.set_existing_raw_multi_value_by("core", None, "a", new_values.into_iter()).expect("values exist"); + /// let fetched_config = git_config.raw_values("core.a").expect("values exist"); /// assert!(fetched_config.iter().any(|v| v == "x")); /// assert!(fetched_config.iter().any(|v| v == "y")); /// assert!(fetched_config.iter().any(|v| v == "z")); @@ -1032,8 +1012,8 @@ impl File { /// "x", /// "y", /// ]; - /// git_config.set_existing_raw_multi_value_by("core", None, "a", new_values.into_iter())?; - /// let fetched_config = git_config.raw_values("core.a")?; + /// git_config.set_existing_raw_multi_value_by("core", None, "a", new_values.into_iter()).expect("values exist"); + /// let fetched_config = git_config.raw_values("core.a").expect("values exist"); /// assert!(fetched_config.iter().any(|v| v == "x")); /// assert!(fetched_config.iter().any(|v| v == "y")); /// # Ok::<(), Box>(()) @@ -1051,8 +1031,8 @@ impl File { /// "z", /// "discarded", /// ]; - /// git_config.set_existing_raw_multi_value_by("core", None, "a", new_values)?; - /// assert!(!git_config.raw_values("core.a")?.iter().any(|v| v == "discarded")); + /// git_config.set_existing_raw_multi_value_by("core", None, "a", new_values).expect("values exist"); + /// assert!(!git_config.raw_values("core.a").expect("values exist").iter().any(|v| v == "discarded")); /// # Ok::<(), Box>(()) /// ``` pub fn set_existing_raw_multi_value_by( @@ -1061,13 +1041,14 @@ impl File { subsection_name: impl AsBStrOpt, value_name: impl AsRef, new_values: Iter, - ) -> Result<(), crate::file::set_raw_value::Error> + ) -> ExnResult where Iter: IntoIterator, Item: crate::AsBStr, { self.raw_values_mut_by(section_name, subsection_name, value_name)? - .set_values(new_values)?; + .set_values(new_values) + .or_erased()?; Ok(()) } } diff --git a/gix-config/src/file/access/read_only.rs b/gix-config/src/file/access/read_only.rs index 10fbfc45f5b..ff225a5adc8 100644 --- a/gix-config/src/file/access/read_only.rs +++ b/gix-config/src/file/access/read_only.rs @@ -1,4 +1,5 @@ use bstr::{BStr, BString, ByteSlice}; +use gix_error::ExnResult; use gix_features::threading::OwnShared; use smallvec::SmallVec; @@ -37,11 +38,11 @@ impl File { /// a = 10k /// c = false /// "#; - /// let git_config = gix_config::File::try_from(config)?; + /// let git_config = gix_config::File::try_from(config).expect("valid config"); /// // You can either use the turbofish to determine the type... - /// let a_value = git_config.value::("core.a")?; + /// let a_value = git_config.value::("core.a").expect("valid value"); /// // ... or explicitly declare the type to avoid the turbofish - /// let c_value: Boolean = git_config.value("core.c")?; + /// let c_value: Boolean = git_config.value("core.c").expect("valid value"); /// # Ok::<(), Box>(()) /// ``` pub fn value>(&self, key: impl AsKey) -> Result> { @@ -71,11 +72,11 @@ impl File { /// a = 10k /// c = false /// "#; - /// let git_config = gix_config::File::try_from(config)?; + /// let git_config = gix_config::File::try_from(config).expect("valid config"); /// // You can either use the turbofish to determine the type... - /// let a_value = git_config.value_by::("core", None, "a")?; + /// let a_value = git_config.value_by::("core", None, "a").expect("valid value"); /// // ... or explicitly declare the type to avoid the turbofish - /// let c_value: Boolean = git_config.value_by("core", None, "c")?; + /// let c_value: Boolean = git_config.value_by("core", None, "c").expect("valid value"); /// # Ok::<(), Box>(()) /// ``` pub fn value_by>( @@ -162,9 +163,9 @@ impl File { /// a /// a = false /// "#; - /// let git_config = gix_config::File::try_from(config).unwrap(); + /// let git_config = gix_config::File::try_from(config).expect("valid config"); /// // You can either use the turbofish to determine the type... - /// let a_value = git_config.values::("core.a")?; + /// let a_value = git_config.values::("core.a").expect("valid values"); /// assert_eq!( /// a_value, /// vec![ @@ -174,9 +175,9 @@ impl File { /// ] /// ); /// // ... or explicitly declare the type to avoid the turbofish - /// let c_value: Vec = git_config.values("core.c").unwrap(); + /// let c_value: Vec = git_config.values("core.c")?; /// assert_eq!(c_value, vec![Boolean(false)]); - /// # Ok::<(), Box>(()) + /// # Ok::<(), gix_config::lookup::Error>>(()) /// ``` /// /// [`value`]: crate::value @@ -216,9 +217,9 @@ impl File { /// a /// a = false /// "#; - /// let git_config = gix_config::File::try_from(config).unwrap(); + /// let git_config = gix_config::File::try_from(config).expect("valid config"); /// // You can either use the turbofish to determine the type... - /// let a_value = git_config.values_by::("core", None, "a")?; + /// let a_value = git_config.values_by::("core", None, "a").expect("valid values"); /// assert_eq!( /// a_value, /// vec![ @@ -228,9 +229,9 @@ impl File { /// ] /// ); /// // ... or explicitly declare the type to avoid the turbofish - /// let c_value: Vec = git_config.values_by("core", None, "c").unwrap(); + /// let c_value: Vec = git_config.values_by("core", None, "c")?; /// assert_eq!(c_value, vec![Boolean(false)]); - /// # Ok::<(), Box>(()) + /// # Ok::<(), gix_config::lookup::Error>>(()) /// ``` /// /// [`value`]: crate::value @@ -273,23 +274,16 @@ impl File { } /// Returns the last found immutable section with a given `name` and optional `subsection_name`. - pub fn section( - &self, - name: impl AsRef, - subsection_name: impl AsBStrOpt, - ) -> Result, lookup::existing::Error> { + pub fn section(&self, name: impl AsRef, subsection_name: impl AsBStrOpt) -> ExnResult> { self.section_filter(name, subsection_name, |_| true)? - .ok_or(lookup::existing::Error::SectionMissing) + .ok_or_else(lookup::existing::section_missing) } /// Returns the last found immutable section with a given `section_key`, identifying the name and subsection name like `core` /// or `remote.origin`. - pub fn section_by_key( - &self, - section_key: impl crate::AsBStr, - ) -> Result, lookup::existing::Error> { + pub fn section_by_key(&self, section_key: impl crate::AsBStr) -> ExnResult> { let key = crate::parse::section::unvalidated::KeyRef::parse(section_key.as_bstr()) - .ok_or(lookup::existing::Error::KeyMissing)?; + .ok_or_else(lookup::existing::key_missing)?; self.section(key.section_name, key.subsection_name) } @@ -302,7 +296,7 @@ impl File { name: impl AsRef, subsection_name: impl AsBStrOpt, mut filter: impl FnMut(&Metadata) -> bool, - ) -> Result>, lookup::existing::Error> { + ) -> ExnResult>> { Ok(self .section_ids_by_name_and_subname(name.as_ref(), subsection_name.as_bstr_opt())? .rev() @@ -320,9 +314,9 @@ impl File { &self, section_key: impl crate::AsBStr, filter: impl FnMut(&Metadata) -> bool, - ) -> Result>, lookup::existing::Error> { + ) -> ExnResult>> { let key = crate::parse::section::unvalidated::KeyRef::parse(section_key.as_bstr()) - .ok_or(lookup::existing::Error::KeyMissing)?; + .ok_or_else(lookup::existing::key_missing)?; self.section_filter(key.section_name, key.subsection_name, filter) } diff --git a/gix-config/src/file/includes/mod.rs b/gix-config/src/file/includes/mod.rs index 073f4ee8793..f89ed13d0f7 100644 --- a/gix-config/src/file/includes/mod.rs +++ b/gix-config/src/file/includes/mod.rs @@ -1,13 +1,13 @@ use std::path::{Path, PathBuf}; use bstr::{BStr, BString, ByteSlice, ByteVec}; +use gix_error::{ErrorExt, ExnResult, OptionExt, ResultExt, message, not_found, validation}; use gix_features::threading::OwnShared; use gix_ref::Category; use crate::{ File, file, file::{Metadata, SectionId, includes, init}, - path, }; impl File { @@ -30,7 +30,7 @@ impl File { /// We can fix this by 'splitting' the include section if needed so the included sections are put into the right place. /// - `hasconfig:remote.*.url` will not prevent itself to include files with `[remote "name"]\nurl = x` values, but it also /// won't match them, i.e. one cannot include something that will cause the condition to match or to always be true. - pub fn resolve_includes(&mut self, options: init::Options<'_>) -> Result<(), Error> { + pub fn resolve_includes(&mut self, options: init::Options<'_>) -> ExnResult { if options.includes.max_depth == 0 { return Ok(()); } @@ -39,7 +39,7 @@ impl File { } } -pub(crate) fn resolve(config: &mut File, buf: &mut Vec, options: init::Options<'_>) -> Result<(), Error> { +pub(crate) fn resolve(config: &mut File, buf: &mut Vec, options: init::Options<'_>) -> ExnResult { resolve_includes_recursive(None, config, 0, buf, options) } @@ -49,12 +49,14 @@ fn resolve_includes_recursive( depth: u8, buf: &mut Vec, options: init::Options<'_>, -) -> Result<(), Error> { +) -> ExnResult { if depth == options.includes.max_depth { return if options.includes.err_on_max_depth_exceeded { - Err(Error::IncludeDepthExceeded { - max_depth: options.includes.max_depth, - }) + Err(validation(format!( + "The maximum allowed length {} of the file include chain built by following nested resolve_includes is exceeded", + options.includes.max_depth + )) + .raise_erased()) } else { Ok(()) }; @@ -94,7 +96,7 @@ fn insert_includes_recursively( depth: u8, options: init::Options<'_>, buf: &mut Vec, -) -> Result<(), Error> { +) -> ExnResult { for (section_id, config_path) in section_ids_and_include_paths { let meta = OwnShared::clone(&target_config.sections[§ion_id].meta); let target_config_path = meta.path.as_deref(); @@ -108,13 +110,15 @@ fn insert_includes_recursively( buf.clear(); std::io::copy( - &mut std::fs::File::open(&config_path).map_err(|err| Error::Io { - source: err, - path: config_path.to_owned(), + &mut std::fs::File::open(&config_path).or_raise_erased(|| { + message!( + "Could not read included configuration file at '{}'", + config_path.display() + ) })?, buf, ) - .map_err(Error::CopyBuffer)?; + .or_raise_erased(|| message("Failed to copy configuration file into buffer"))?; let config_meta = Metadata { path: Some(config_path), trust: meta.trust, @@ -126,16 +130,13 @@ fn insert_includes_recursively( ..options }; - let mut include_config = - File::from_bytes_owned(buf, config_meta, no_follow_options).map_err(|err| match err { - init::Error::Parse(err) => Error::Parse(err), - init::Error::Interpolate(err) => Error::Interpolate(err), - init::Error::Span(err) => Error::Span(err), - init::Error::Includes(_) => unreachable!("BUG: {:?} not possible due to no-follow options", err), - })?; + let mut include_config = File::from_bytes_owned(buf, config_meta, no_follow_options) + .or_raise_erased(|| message("Could not parse included configuration file"))?; resolve_includes_recursive(Some(target_config), &mut include_config, depth + 1, buf, options)?; - target_config.append_or_insert(include_config, Some(section_id))?; + target_config + .append_or_insert(include_config, Some(section_id)) + .or_raise_erased(|| message("Could not append included configuration"))?; } Ok(()) } @@ -154,7 +155,7 @@ fn include_condition_match( target_config_path: Option<&Path>, search_config: &File, options: Options<'_>, -) -> Result { +) -> ExnResult { let mut tokens = condition.splitn(2, |b| *b == b':'); let (prefix, condition) = match (tokens.next(), tokens.next()) { (Some(a), Some(b)) => (a, b), @@ -241,16 +242,20 @@ fn gitdir_matches( .. }: Options<'_>, wildmatch_mode: gix_glob::wildmatch::Mode, -) -> Result { +) -> ExnResult { if !err_on_interpolation_failure && git_dir.is_none() { return Ok(false); } - let git_dir = gix_path::to_unix_separators_on_windows(gix_path::into_bstr(git_dir.ok_or(Error::MissingGitDir)?)); + let git_dir = gix_path::to_unix_separators_on_windows(gix_path::into_bstr(git_dir.ok_or_raise_erased(|| { + not_found("The git directory must be provided to support `gitdir:` conditional includes") + })?)); let mut pattern_path = match check_interpolation_result( err_on_interpolation_failure, crate::Path::from(condition_path.to_owned()).interpolate(context), - )? { + ) + .or_raise_erased(|| message("Could not interpolate conditional include path"))? + { Some(path) => gix_path::into_bstr(path).into_owned(), // Git keeps the original condition pattern when interpolation fails. None => condition_path.to_owned(), @@ -265,7 +270,11 @@ fn gitdir_matches( return Ok(false); } let parent_dir = target_config_path - .ok_or(Error::MissingConfigPath)? + .ok_or_raise_erased(|| { + not_found( + "Include paths from environment variables must not be relative as no config file path exists as root", + ) + })? .parent() .expect("config path can never be /"); let mut joined_path = gix_path::to_unix_separators_on_windows(gix_path::into_bstr(parent_dir)).into_owned(); @@ -290,9 +299,10 @@ fn gitdir_matches( return Ok(true); } - let expanded_git_dir = gix_path::to_unix_separators_on_windows(gix_path::into_bstr(gix_path::realpath( - gix_path::from_byte_slice(&git_dir), - )?)); + let expanded_git_dir = gix_path::to_unix_separators_on_windows(gix_path::into_bstr( + gix_path::realpath(gix_path::from_byte_slice(&git_dir)) + .or_raise_erased(|| message("Could not resolve the git directory to its real path"))?, + )); Ok(gix_glob::wildmatch( pattern_path.as_bstr(), expanded_git_dir.as_ref(), @@ -300,21 +310,14 @@ fn gitdir_matches( )) } -fn check_interpolation_result( - disable: bool, - res: Result, path::interpolate::Error>, -) -> Result, path::interpolate::Error> { +fn check_interpolation_result(disable: bool, res: ExnResult>) -> ExnResult> { if disable { return res.map(|path| Some(path.into())); } match res { Ok(good) => Ok(Some(good.into())), - Err(err) => match err { - path::interpolate::Error::Missing { .. } => Ok(None), - path::interpolate::Error::UsernameConversion(_) | path::interpolate::Error::Utf8Conversion { .. } => { - Err(err) - } - }, + Err(err) if err.is_validation() => Err(err), + Err(_) => Ok(None), } } @@ -327,8 +330,10 @@ fn resolve_path( err_on_missing_config_path, .. }: includes::Options<'_>, -) -> Result, Error> { - let path = match check_interpolation_result(err_on_interpolation_failure, path.interpolate(context))? { +) -> ExnResult> { + let path = match check_interpolation_result(err_on_interpolation_failure, path.interpolate(context)) + .or_raise_erased(|| message("Could not interpolate include path"))? + { Some(p) => p, None => return Ok(None), }; @@ -337,7 +342,11 @@ fn resolve_path( return Ok(None); } target_config_path - .ok_or(Error::MissingConfigPath)? + .ok_or_raise_erased(|| { + not_found( + "Include paths from environment variables must not be relative as no config file path exists as root", + ) + })? .parent() .expect("path is a config file which naturally lives in a directory") .join(path) @@ -348,4 +357,4 @@ fn resolve_path( } mod types; -pub use types::{Error, Options, conditional}; +pub use types::{Options, conditional}; diff --git a/gix-config/src/file/includes/types.rs b/gix-config/src/file/includes/types.rs index 66aa6752b19..7f8aa5fcbe3 100644 --- a/gix-config/src/file/includes/types.rs +++ b/gix-config/src/file/includes/types.rs @@ -1,30 +1,4 @@ -use std::path::PathBuf; - -use crate::{parse, path::interpolate}; - -/// The error returned when following includes. -#[derive(Debug, thiserror::Error)] -#[expect(missing_docs)] -pub enum Error { - #[error("Failed to copy configuration file into buffer")] - CopyBuffer(#[source] std::io::Error), - #[error("Could not read included configuration file at '{}'", path.display())] - Io { path: PathBuf, source: std::io::Error }, - #[error(transparent)] - Parse(#[from] parse::Error), - #[error(transparent)] - Span(#[from] parse::span::Error), - #[error(transparent)] - Interpolate(#[from] interpolate::Error), - #[error("The maximum allowed length {} of the file include chain built by following nested resolve_includes is exceeded", .max_depth)] - IncludeDepthExceeded { max_depth: u8 }, - #[error("Include paths from environment variables must not be relative as no config file paths exists as root")] - MissingConfigPath, - #[error("The git directory must be provided to support `gitdir:` conditional includes")] - MissingGitDir, - #[error(transparent)] - Realpath(#[from] gix_path::realpath::Error), -} +use crate::path::interpolate; /// Options to handle includes, like `include.path` or `includeIf..path`, #[derive(Clone, Copy)] diff --git a/gix-config/src/file/init/comfort.rs b/gix-config/src/file/init/comfort.rs index 275a7b0e53c..13e8eadad07 100644 --- a/gix-config/src/file/init/comfort.rs +++ b/gix-config/src/file/init/comfort.rs @@ -3,6 +3,7 @@ use crate::{ file::{Metadata, init}, path, source, }; +use gix_error::{ExnMessageResult, ExnResult}; /// Easy-instantiation of typical non-repository git configuration files with all configuration defaulting to typical values. /// @@ -23,7 +24,7 @@ impl File { /// which excludes repository local configuration, as well as override-configuration from environment variables. /// /// Note that the file might [be empty][File::is_void()] in case no configuration file was found. - pub fn from_globals() -> Result { + pub fn from_globals() -> ExnMessageResult { let metas = [ source::Kind::GitInstallation, source::Kind::System, @@ -60,7 +61,7 @@ impl File { /// See [`git-config`'s documentation] for more information on the environment variables in question. /// /// [`git-config`'s documentation]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-GITCONFIGCOUNT - pub fn from_environment_overrides() -> Result { + pub fn from_environment_overrides() -> ExnResult { let home = gix_path::env::home_dir(); let options = init::Options { includes: init::includes::Options::follow_without_conditional(home.as_deref()), @@ -84,7 +85,9 @@ impl File { /// /// Includes will be resolved within limits as some information like the git installation directory is missing to interpolate /// paths with as well as git repository information like the branch name. - pub fn from_git_dir(dir: std::path::PathBuf) -> Result { + pub fn from_git_dir(dir: std::path::PathBuf) -> ExnMessageResult { + use gix_error::{ResultExt, message}; + let (mut local, git_dir) = { let source = Source::Local; let mut path = dir; @@ -93,7 +96,8 @@ impl File { .storage_location(&mut gix_path::env::var) .expect("location available for local"), ); - let local = Self::from_path_no_includes(path.clone(), source)?; + let local = Self::from_path_no_includes(path.clone(), source) + .or_raise(|| message("Could not read repository-local configuration"))?; path.pop(); (local, path) }; @@ -110,7 +114,8 @@ impl File { }), _ => None, } - .transpose()?; + .transpose() + .or_raise(|| message("Could not read worktree configuration"))?; let home = gix_path::env::home_dir(); let options = init::Options { @@ -127,37 +132,31 @@ impl File { ..Default::default() }; - let mut globals = Self::from_globals()?; - globals.resolve_includes(options)?; - local.resolve_includes(options)?; + let mut globals = Self::from_globals().or_raise(|| message("Could not read global configuration"))?; + globals + .resolve_includes(options) + .or_raise(|| message("Could not resolve includes in global configuration"))?; + local + .resolve_includes(options) + .or_raise(|| message("Could not resolve includes in repository-local configuration"))?; - globals.append(local)?; + globals + .append(local) + .or_raise(|| message("Could not append repository-local configuration"))?; if let Some(mut worktree) = worktree { - worktree.resolve_includes(options)?; - globals.append(worktree)?; + worktree + .resolve_includes(options) + .or_raise(|| message("Could not resolve includes in worktree configuration"))?; + globals + .append(worktree) + .or_raise(|| message("Could not append worktree configuration"))?; } - globals.append(Self::from_environment_overrides()?)?; + let environment = + Self::from_environment_overrides().or_raise(|| message("Could not read environment configuration"))?; + globals + .append(environment) + .or_raise(|| message("Could not append environment configuration"))?; Ok(globals) } } - -/// -pub mod from_git_dir { - use crate::file::init; - - /// The error returned by [`File::from_git_dir()`][crate::File::from_git_dir()]. - #[derive(Debug, thiserror::Error)] - pub enum Error { - #[error(transparent)] - FromPaths(#[from] init::from_paths::Error), - #[error(transparent)] - FromEnv(#[from] init::from_env::Error), - #[error(transparent)] - Init(#[from] init::Error), - #[error(transparent)] - Includes(#[from] init::includes::Error), - #[error(transparent)] - Span(#[from] crate::parse::span::Error), - } -} diff --git a/gix-config/src/file/init/from_env.rs b/gix-config/src/file/init/from_env.rs index 32effaa2418..1f1a547da30 100644 --- a/gix-config/src/file/init/from_env.rs +++ b/gix-config/src/file/init/from_env.rs @@ -1,30 +1,7 @@ use bstr::ByteSlice; +use gix_error::ExnResult; -use crate::{File, KeyRef, file, file::init, parse::section, path::interpolate}; - -/// Represents the errors that may occur when calling [`File::from_env()`]. -#[derive(Debug, thiserror::Error)] -#[expect(missing_docs)] -pub enum Error { - #[error("Configuration {kind} at index {index} contained illformed UTF-8")] - IllformedUtf8 { index: usize, kind: &'static str }, - #[error("GIT_CONFIG_COUNT was not a positive integer: {}", .input)] - InvalidConfigCount { input: String }, - #[error("GIT_CONFIG_KEY_{} was not set", .key_id)] - InvalidKeyId { key_id: usize }, - #[error("GIT_CONFIG_KEY_{} was set to an invalid value: {}", .key_id, .key_val)] - InvalidKeyValue { key_id: usize, key_val: String }, - #[error("GIT_CONFIG_VALUE_{} was not set", .value_id)] - InvalidValueId { value_id: usize }, - #[error(transparent)] - PathInterpolationError(#[from] interpolate::Error), - #[error(transparent)] - Includes(#[from] init::includes::Error), - #[error(transparent)] - Section(#[from] section::header::Error), - #[error(transparent)] - SectionValue(#[from] file::section::value::Error), -} +use crate::{File, KeyRef, file, file::init}; /// Instantiation from environment variables impl File { @@ -32,12 +9,18 @@ impl File { /// See [`git-config`'s documentation] for more information on the environment variables in question. /// /// With `options` configured, it's possible to resolve `include.path` or `includeIf..path` directives as well. + /// Integer parsing failures for `GIT_CONFIG_COUNT` and key parsing failures for `GIT_CONFIG_KEY_*` include their + /// bytes as `input` + /// [metadata](gix_error::Exn::metadata()). /// /// [`git-config`'s documentation]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-GITCONFIGCOUNT - pub fn from_env(options: init::Options<'_>) -> Result, Error> { + pub fn from_env(options: init::Options<'_>) -> ExnResult> { + use gix_error::{ErrorExt, OptionExt, ResultExt, message, not_found, validation}; use std::env; let count: usize = match env::var("GIT_CONFIG_COUNT") { - Ok(v) => v.parse().map_err(|_| Error::InvalidConfigCount { input: v })?, + Ok(v) => v.parse::().or_raise_erased(|| { + validation("GIT_CONFIG_COUNT was not a positive integer").with("input", v.into_bytes()) + })?, Err(_) => return Ok(None), }; @@ -54,33 +37,38 @@ impl File { let mut config = File::new(meta); for i in 0..count { let key = gix_path::os_string_into_bstring( - env::var_os(format!("GIT_CONFIG_KEY_{i}")).ok_or(Error::InvalidKeyId { key_id: i })?, + env::var_os(format!("GIT_CONFIG_KEY_{i}")) + .ok_or_raise_erased(|| not_found(format!("GIT_CONFIG_KEY_{i} was not set")))?, ) - .map_err(|_| Error::IllformedUtf8 { index: i, kind: "key" })?; - let value = env::var_os(format!("GIT_CONFIG_VALUE_{i}")).ok_or(Error::InvalidValueId { value_id: i })?; - let key = KeyRef::parse_unvalidated(key.as_ref()).ok_or_else(|| Error::InvalidKeyValue { - key_id: i, - key_val: key.to_string(), + .or_raise_erased(|| validation(format!("Configuration key at index {i} contained illformed UTF-8")))?; + let value = env::var_os(format!("GIT_CONFIG_VALUE_{i}")) + .ok_or_raise_erased(|| not_found(format!("GIT_CONFIG_VALUE_{i} was not set")))?; + let key = KeyRef::parse_unvalidated(key.as_ref()).ok_or_else(|| { + validation(format!("GIT_CONFIG_KEY_{i} was set to an invalid value")) + .with("input", key.as_bstr()) + .raise_erased() })?; config - .section_mut_or_create_new_inner(key.section_name, key.subsection_name)? + .section_mut_or_create_new_inner(key.section_name, key.subsection_name) + .or_erased()? .push( key.value_name, Some( gix_path::os_str_into_bstr(&value) - .map_err(|_| Error::IllformedUtf8 { - index: i, - kind: "value", + .or_raise_erased(|| { + validation(format!("Configuration value at index {i} contained illformed UTF-8")) })? .as_bytes() .into(), ), - )?; + ) + .or_erased()?; } let mut buf = Vec::new(); - init::includes::resolve(&mut config, &mut buf, options)?; + init::includes::resolve(&mut config, &mut buf, options) + .or_raise_erased(|| message("Could not resolve includes in environment configuration"))?; Ok(Some(config)) } } diff --git a/gix-config/src/file/init/from_paths.rs b/gix-config/src/file/init/from_paths.rs index 63227112aa9..bcde512c817 100644 --- a/gix-config/src/file/init/from_paths.rs +++ b/gix-config/src/file/init/from_paths.rs @@ -1,51 +1,38 @@ use std::collections::BTreeSet; +use gix_error::ExnMessageResult; + use crate::{ File, - file::{Metadata, init, init::Options}, + file::{Metadata, init::Options}, }; -/// The error returned by [`File::from_paths_metadata()`] and [`File::from_path_no_includes()`]. -#[derive(Debug, thiserror::Error)] -#[expect(missing_docs)] -pub enum Error { - #[error("The configuration file at \"{}\" could not be read", path.display())] - Io { - source: std::io::Error, - path: std::path::PathBuf, - }, - #[error(transparent)] - Init(#[from] init::Error), -} - /// Instantiation from one or more paths impl File { /// Load the single file at `path` with `source` without following include directives. /// /// Note that the path will be checked for ownership to derive trust. - pub fn from_path_no_includes(path: std::path::PathBuf, source: crate::Source) -> Result { - let trust = match gix_sec::Trust::from_path_ownership(&path) { - Ok(t) => t, - Err(err) => return Err(Error::Io { source: err, path }), - }; + pub fn from_path_no_includes(path: std::path::PathBuf, source: crate::Source) -> ExnMessageResult { + use gix_error::{ResultExt, message}; + let trust = gix_sec::Trust::from_path_ownership(&path).or_raise(|| { + message!( + "The configuration file at \"{}\" could not be inspected", + path.display() + ) + })?; let mut buf = Vec::new(); - match std::io::copy( - &mut match std::fs::File::open(&path) { - Ok(f) => f, - Err(err) => return Err(Error::Io { source: err, path }), - }, - &mut buf, - ) { - Ok(_) => {} - Err(err) => return Err(Error::Io { source: err, path }), - } + let mut file = std::fs::File::open(&path) + .or_raise(|| message!("The configuration file at \"{}\" could not be read", path.display()))?; + std::io::copy(&mut file, &mut buf) + .or_raise(|| message!("The configuration file at \"{}\" could not be read", path.display()))?; - Ok(File::from_bytes_owned( + File::from_bytes_owned( &mut buf, Metadata::from(source).at(path).with(trust), Default::default(), - )?) + ) + .or_raise(|| message("Could not initialize configuration from a path")) } /// Constructs a `git-config` file from the provided metadata, which must include a path to read from or be ignored. @@ -56,7 +43,7 @@ impl File { pub fn from_paths_metadata( path_meta: impl IntoIterator>, options: Options<'_>, - ) -> Result, Error> { + ) -> ExnMessageResult> { let mut buf = Vec::with_capacity(512); let err_on_nonexisting_paths = true; Self::from_paths_metadata_buf( @@ -76,7 +63,8 @@ impl File { buf: &mut Vec, err_on_non_existing_paths: bool, options: Options<'_>, - ) -> Result, Error> { + ) -> ExnMessageResult> { + use gix_error::{ErrorExt, ResultExt, message}; let mut target = None; let mut seen = BTreeSet::default(); for (path, mut meta) in path_meta.filter_map(|mut meta| meta.path.take().map(|p| (p, meta))) { @@ -90,7 +78,10 @@ impl File { Ok(f) => f, Err(err) if !err_on_non_existing_paths && err.kind() == std::io::ErrorKind::NotFound => continue, Err(err) => { - let err = Error::Io { source: err, path }; + let err = err.and_raise(message!( + "The configuration file at \"{}\" could not be read", + path.display() + )); if options.ignore_io_errors { gix_features::trace::warn!("ignoring: {err:#?}"); continue; @@ -103,29 +94,30 @@ impl File { ) { Ok(_) => {} Err(err) => { + let err = err.and_raise(message!( + "The configuration file at \"{}\" could not be read", + path.display() + )); if options.ignore_io_errors { - gix_features::trace::warn!( - "ignoring: {:#?}", - Error::Io { - source: err, - path: path.clone() - } - ); + gix_features::trace::warn!("ignoring: {err:#?}"); buf.clear(); } else { - return Err(Error::Io { source: err, path }); + return Err(err); } } } meta.path = Some(path); - let config = Self::from_bytes_owned(buf, meta, options)?; + let config = Self::from_bytes_owned(buf, meta, options) + .or_raise(|| message("Could not initialize configuration from a path"))?; match &mut target { None => { target = Some(config); } Some(target) => { - target.append(config).map_err(init::Error::from)?; + target + .append(config) + .or_raise(|| message("Could not append configuration from a path"))?; } } } diff --git a/gix-config/src/file/init/mod.rs b/gix-config/src/file/init/mod.rs index 356b3a9c5d4..46321604950 100644 --- a/gix-config/src/file/init/mod.rs +++ b/gix-config/src/file/init/mod.rs @@ -1,3 +1,4 @@ +use gix_error::ExnResult; use gix_features::threading::OwnShared; use crate::{ @@ -7,7 +8,7 @@ use crate::{ }; mod types; -pub use types::{Error, Options}; +pub use types::Options; mod comfort; /// @@ -36,10 +37,12 @@ impl File { input: &[u8], meta: impl Into>, options: Options<'_>, - ) -> Result { + ) -> ExnResult { + use gix_error::{ResultExt, message}; let meta = meta.into(); Ok(Self::from_parse_events_no_includes( - parse::Events::from_bytes(input, options.to_event_filter())?, + parse::Events::from_bytes(input, options.to_event_filter()) + .or_raise_erased(|| message("Could not parse configuration"))?, meta, )) } @@ -82,13 +85,16 @@ impl File { input_and_buf: &mut Vec, meta: impl Into>, options: Options<'_>, - ) -> Result { + ) -> ExnResult { + use gix_error::{ResultExt, message}; let mut config = Self::from_parse_events_no_includes( - parse::Events::from_bytes(input_and_buf, options.to_event_filter()).map_err(Error::from)?, + parse::Events::from_bytes(input_and_buf, options.to_event_filter()) + .or_raise_erased(|| message("Could not parse configuration"))?, meta, ); - includes::resolve(&mut config, input_and_buf, options).map_err(Error::from)?; + includes::resolve(&mut config, input_and_buf, options) + .or_raise_erased(|| message("Could not resolve configuration includes"))?; Ok(config) } } diff --git a/gix-config/src/file/init/types.rs b/gix-config/src/file/init/types.rs index c1b4f32e518..ca397605f0e 100644 --- a/gix-config/src/file/init/types.rs +++ b/gix-config/src/file/init/types.rs @@ -1,18 +1,4 @@ -use crate::{file::init, parse, parse::EventRef, path::interpolate}; - -/// The error returned by [`File::from_bytes_no_includes()`][crate::File::from_bytes_no_includes()]. -#[derive(Debug, thiserror::Error)] -#[expect(missing_docs)] -pub enum Error { - #[error(transparent)] - Parse(#[from] parse::Error), - #[error(transparent)] - Interpolate(#[from] interpolate::Error), - #[error(transparent)] - Includes(#[from] init::includes::Error), - #[error(transparent)] - Span(#[from] parse::span::Error), -} +use crate::{file::init, parse::EventRef}; /// Options when loading git config using [`File::from_paths_metadata()`][crate::File::from_paths_metadata()]. #[derive(Clone, Copy, Default)] diff --git a/gix-config/src/file/mod.rs b/gix-config/src/file/mod.rs index 5f4c4350906..fc6a00bbade 100644 --- a/gix-config/src/file/mod.rs +++ b/gix-config/src/file/mod.rs @@ -24,36 +24,6 @@ mod util; /// pub mod section; -/// -pub mod rename_section { - /// The error returned by [`File::rename_section(…)`][crate::File::rename_section()]. - #[derive(Debug, thiserror::Error)] - #[expect(missing_docs)] - pub enum Error { - #[error(transparent)] - Lookup(#[from] crate::lookup::existing::Error), - #[error(transparent)] - Section(#[from] crate::parse::section::header::Error), - } -} - -/// -pub mod set_raw_value { - /// The error returned by [`File::set_raw_value(…)`][crate::File::set_raw_value()]. - #[derive(Debug, thiserror::Error)] - #[expect(missing_docs)] - pub enum Error { - #[error(transparent)] - Lookup(#[from] crate::lookup::existing::Error), - #[error(transparent)] - Header(#[from] crate::parse::section::header::Error), - #[error(transparent)] - ValueName(#[from] crate::parse::section::value_name::Error), - #[error(transparent)] - Span(#[from] crate::parse::span::Error), - } -} - /// Convert ergonomic subsection inputs into an optional owned name. pub trait IntoBStringOpt { /// Convert into an optional owned subsection name. diff --git a/gix-config/src/file/mutable/mod.rs b/gix-config/src/file/mutable/mod.rs index e6e5ad9fa5b..3011adf8030 100644 --- a/gix-config/src/file/mutable/mod.rs +++ b/gix-config/src/file/mutable/mod.rs @@ -1,4 +1,5 @@ use bstr::{BStr, BString, ByteSlice, ByteVec}; +use gix_error::ExnMessageResult; use crate::{file, parse::Event}; @@ -53,7 +54,7 @@ impl Default for Whitespace { } impl Whitespace { - fn key_value_separators(&self, backing: &mut Vec) -> Result, crate::parse::span::Error> { + fn key_value_separators(&self, backing: &mut Vec) -> ExnMessageResult> { let mut out = Vec::with_capacity(3); if let Some(ws) = &self.pre_sep { out.push(Event::Whitespace(crate::parse::Span::append(backing, ws)?)); diff --git a/gix-config/src/file/mutable/multi_value.rs b/gix-config/src/file/mutable/multi_value.rs index 19711e20bfb..50a3f785090 100644 --- a/gix-config/src/file/mutable/multi_value.rs +++ b/gix-config/src/file/mutable/multi_value.rs @@ -1,5 +1,8 @@ +use gix_error::ExnMessageResult; use std::{collections::HashMap, ops::DerefMut}; +use gix_error::ExnResult; + use bstr::{BStr, BString, ByteVec}; use crate::{ @@ -37,7 +40,7 @@ pub struct MultiValueMut<'borrow> { impl MultiValueMut<'_> { /// Returns the actual values. - pub fn get(&self) -> Result, lookup::existing::Error> { + pub fn get(&self) -> ExnResult> { let mut expect_value = false; let mut values = Vec::new(); let mut concatenated_value = BString::default(); @@ -74,7 +77,7 @@ impl MultiValueMut<'_> { } if values.is_empty() { - return Err(lookup::existing::Error::KeyMissing); + return Err(lookup::existing::key_missing()); } Ok(values) @@ -98,7 +101,7 @@ impl MultiValueMut<'_> { /// # Safety /// /// This will panic if the index is out of range. - pub fn set_string_at(&mut self, index: usize, value: impl AsRef) -> Result<(), crate::parse::span::Error> { + pub fn set_string_at(&mut self, index: usize, value: impl AsRef) -> ExnMessageResult { self.set_at(index, value.as_ref()) } @@ -107,7 +110,7 @@ impl MultiValueMut<'_> { /// # Safety /// /// This will panic if the index is out of range. - pub fn set_at(&mut self, index: usize, value: impl crate::AsBStr) -> Result<(), crate::parse::span::Error> { + pub fn set_at(&mut self, index: usize, value: impl crate::AsBStr) -> ExnMessageResult { let EntryData { section_id, offset_index, @@ -130,7 +133,7 @@ impl MultiValueMut<'_> { /// remaining values are ignored. /// /// [`zip`]: std::iter::Iterator::zip - pub fn set_values(&mut self, values: Iter) -> Result<(), crate::parse::span::Error> + pub fn set_values(&mut self, values: Iter) -> ExnMessageResult where Iter: IntoIterator, Item: crate::AsBStr, @@ -158,7 +161,7 @@ impl MultiValueMut<'_> { /// Sets all values in this multivar to the provided one without owning the /// provided input. - pub fn set_all(&mut self, input: impl crate::AsBStr) -> Result<(), crate::parse::span::Error> { + pub fn set_all(&mut self, input: impl crate::AsBStr) -> ExnMessageResult { let input = input.as_bstr(); for EntryData { section_id, @@ -186,7 +189,7 @@ impl MultiValueMut<'_> { section_id: SectionId, offset_index: usize, value: &BStr, - ) -> Result<(), crate::parse::span::Error> { + ) -> ExnMessageResult { let (offset, size) = MultiValueMut::index_and_size(offsets, section_id, offset_index); let whitespace = Whitespace::from_body(section, backing); let value = crate::parse::Span::append(backing, &escape_value(value))?; diff --git a/gix-config/src/file/mutable/section.rs b/gix-config/src/file/mutable/section.rs index 3f8ba026f0f..a8f1fc87a2b 100644 --- a/gix-config/src/file/mutable/section.rs +++ b/gix-config/src/file/mutable/section.rs @@ -1,5 +1,8 @@ +use gix_error::ExnMessageResult; use std::{collections::HashMap, ops::Range}; +use gix_error::ExnResult; + use bstr::{BStr, BString, ByteSlice, ByteVec}; use gix_sec::Trust; use smallvec::SmallVec; @@ -42,7 +45,7 @@ impl SectionMut<'_> { &mut self, name: impl AsRef, subsection_name: impl IntoBStringOpt, - ) -> Result<&mut Self, parse::section::header::Error> { + ) -> ExnMessageResult<&mut Self> { let header = parse::section::HeaderData::new_in(name, subsection_name.into_bstring_opt(), self.backing)?; self.set_header(header); Ok(self) @@ -50,11 +53,7 @@ impl SectionMut<'_> { /// Adds an entry to the end of this section name `value_name` and `value`. If `value` is `None`, no equal sign will be written leaving /// just the key. This is useful for boolean values which are true if merely the key exists. - pub fn push( - &mut self, - value_name: impl AsRef, - value: impl AsBStrOpt, - ) -> Result<&mut Self, file::section::value::Error> { + pub fn push(&mut self, value_name: impl AsRef, value: impl AsBStrOpt) -> ExnMessageResult<&mut Self> { let value_name = ValueName::try_from(value_name.as_ref())?; self.push_with_comment_inner(value_name, value.as_bstr_opt(), None)?; Ok(self) @@ -69,7 +68,7 @@ impl SectionMut<'_> { value_name: impl AsRef, value: impl AsBStrOpt, comment: impl crate::AsBStr, - ) -> Result<&mut Self, file::section::value::Error> { + ) -> ExnMessageResult<&mut Self> { let value_name = ValueName::try_from(value_name.as_ref())?; self.push_with_comment_inner(value_name, value.as_bstr_opt(), Some(comment.as_bstr()))?; Ok(self) @@ -80,7 +79,7 @@ impl SectionMut<'_> { value_name: ValueName, value: Option<&BStr>, comment: Option<&BStr>, - ) -> Result<(), parse::span::Error> { + ) -> ExnMessageResult { let mut events = Vec::new(); if let Some(ws) = &self.whitespace.pre_key { events.push(Event::Whitespace(Span::append(self.backing, ws)?)); @@ -169,20 +168,12 @@ impl SectionMut<'_> { /// Sets the last key value pair if it exists, or adds the new value. /// Returns the previous value if it replaced a value, or None if it adds /// the value. - pub fn set( - &mut self, - value_name: impl AsRef, - value: impl crate::AsBStr, - ) -> Result, file::section::value::Error> { + pub fn set(&mut self, value_name: impl AsRef, value: impl crate::AsBStr) -> ExnMessageResult> { let value_name = ValueName::try_from(value_name.as_ref())?; - self.set_inner(value_name, value.as_bstr()).map_err(Into::into) + self.set_inner(value_name, value.as_bstr()) } - pub(crate) fn set_inner( - &mut self, - value_name: ValueName, - value: &BStr, - ) -> Result, parse::span::Error> { + pub(crate) fn set_inner(&mut self, value_name: ValueName, value: &BStr) -> ExnMessageResult> { match self.section.body.key_and_value_range_by_in(self.backing, &value_name) { None => { self.push_with_comment_inner(value_name, Some(value), None)?; @@ -216,7 +207,7 @@ impl SectionMut<'_> { /// Adds a new line event. Note that you don't need to call this unless /// you've disabled implicit newlines. - pub fn push_newline(&mut self) -> Result<&mut Self, parse::span::Error> { + pub fn push_newline(&mut self) -> ExnMessageResult<&mut Self> { let newline = Span::append(self.backing, &self.newline)?; self.section.body.0.push(Event::Newline(newline)); Ok(self) @@ -394,7 +385,7 @@ impl<'a> SectionMut<'a> { } } - pub(crate) fn get(&self, key: &ValueName, start: Index, end: Index) -> Result { + pub(crate) fn get(&self, key: &ValueName, start: Index, end: Index) -> ExnResult { let mut expect_value = false; let mut concatenated_value = BString::default(); @@ -419,7 +410,7 @@ impl<'a> SectionMut<'a> { } } - Err(lookup::existing::Error::KeyMissing) + Err(lookup::existing::key_missing()) } pub(crate) fn delete(&mut self, start: Index, end: Index) { @@ -454,12 +445,7 @@ impl<'a> SectionMut<'a> { } } - pub(crate) fn set_internal( - &mut self, - index: Index, - key: ValueName, - value: &BStr, - ) -> Result { + pub(crate) fn set_internal(&mut self, index: Index, key: ValueName, value: &BStr) -> ExnMessageResult { let mut size = 0; let value = Span::append(self.backing, &escape_value(value))?; let sep_events = self.whitespace.key_value_separators(self.backing)?; diff --git a/gix-config/src/file/mutable/value.rs b/gix-config/src/file/mutable/value.rs index 00da62e8b25..20b7e830642 100644 --- a/gix-config/src/file/mutable/value.rs +++ b/gix-config/src/file/mutable/value.rs @@ -1,9 +1,10 @@ use bstr::BString; +use gix_error::ExnMessageResult; +use gix_error::ExnResult; use crate::{ file, file::{Index, Size, mutable::section::SectionMut}, - lookup, parse::section, }; @@ -19,21 +20,21 @@ pub struct ValueMut<'borrow> { impl<'borrow> ValueMut<'borrow> { /// Returns the actual value. This is computed each time this is called /// requiring an allocation for multi-line values. - pub fn get(&self) -> Result { + pub fn get(&self) -> ExnResult { self.section.get(&self.key, self.index, self.index + self.size) } /// Update the value to the provided one. This modifies the value such that /// the Value event(s) are replaced with a single new event containing the /// new value. - pub fn set_string(&mut self, input: impl AsRef) -> Result<(), crate::parse::span::Error> { + pub fn set_string(&mut self, input: impl AsRef) -> ExnMessageResult { self.set(input.as_ref()) } /// Update the value to the provided one. This modifies the value such that /// the Value event(s) are replaced with a single new event containing the /// new value. - pub fn set(&mut self, input: impl crate::AsBStr) -> Result<(), crate::parse::span::Error> { + pub fn set(&mut self, input: impl crate::AsBStr) -> ExnMessageResult { let new_size = self .section .set_internal(self.index, self.key.to_owned(), input.as_bstr())?; diff --git a/gix-config/src/file/section/body.rs b/gix-config/src/file/section/body.rs index 8b33aa4014b..01fa4f48981 100644 --- a/gix-config/src/file/section/body.rs +++ b/gix-config/src/file/section/body.rs @@ -1,3 +1,4 @@ +use gix_error::ExnMessageResult; use std::{borrow::Cow, iter::FusedIterator, ops::Range, slice}; use bstr::{BStr, BString, ByteSlice, ByteVec}; @@ -221,16 +222,12 @@ impl BodyData { }) } - pub(crate) fn copy_to_backing_in( - &self, - source: &[u8], - target: &mut Vec, - ) -> Result { + pub(crate) fn copy_to_backing_in(&self, source: &[u8], target: &mut Vec) -> ExnMessageResult { Ok(BodyData( self.0 .iter() .map(|event| event.copy_to_backing_in(source, target)) - .collect::>()?, + .collect::>()?, )) } } diff --git a/gix-config/src/file/section/mod.rs b/gix-config/src/file/section/mod.rs index a7770c8a3d7..4dc63eacbe3 100644 --- a/gix-config/src/file/section/mod.rs +++ b/gix-config/src/file/section/mod.rs @@ -1,4 +1,5 @@ use bstr::{BStr, BString, ByteSlice}; +use gix_error::ExnMessageResult; use smallvec::SmallVec; use crate::{ @@ -15,19 +16,6 @@ use gix_features::threading::OwnShared; use crate::file::{SectionId, write::platform_newline}; -/// Errors related to changing values in a section. -pub mod value { - /// The error returned when adding or changing a value in a section. - #[derive(Debug, thiserror::Error)] - #[allow(missing_docs)] - pub enum Error { - #[error(transparent)] - ValueName(#[from] crate::parse::section::value_name::Error), - #[error(transparent)] - Span(#[from] crate::parse::span::Error), - } -} - impl std::ops::Deref for SectionData { type Target = BodyData; @@ -85,11 +73,13 @@ impl<'file> SectionRef<'file> { impl Section { /// Create an owned section with an empty body. + /// Invalid section or subsection name bytes are stored as `input` in [`gix_error::Message::values`]. + /// After [wrapping](gix_error::Error::from_error()), inspect them with [metadata](gix_error::Error::metadata()). pub fn new( name: impl AsRef, subsection: impl IntoBStringOpt, meta: impl Into>, - ) -> Result { + ) -> ExnMessageResult { let mut backing = Vec::new(); let data = SectionData::new(name, subsection.into_bstring_opt(), meta, &mut backing)?; Ok(Section { backing, data }) @@ -120,7 +110,7 @@ impl Section { Section { backing, data } } - pub(crate) fn into_data(self, target: &mut Vec) -> Result { + pub(crate) fn into_data(self, target: &mut Vec) -> ExnMessageResult { self.data.copy_to_backing_in(&self.backing, target) } } @@ -132,7 +122,7 @@ impl SectionData { subsection: impl Into>, meta: impl Into>, backing: &mut Vec, - ) -> Result { + ) -> ExnMessageResult { Ok(SectionData { header: parse::section::HeaderData::new_in(name, subsection, backing)?, body: Default::default(), @@ -155,7 +145,7 @@ impl SectionData { &self.meta } - pub(crate) fn copy_to_backing_in(&self, source: &[u8], target: &mut Vec) -> Result { + pub(crate) fn copy_to_backing_in(&self, source: &[u8], target: &mut Vec) -> ExnMessageResult { Ok(SectionData { header: self.header.copy_to_backing_in(source, target)?, body: self.body.copy_to_backing_in(source, target)?, diff --git a/gix-config/src/file/util.rs b/gix-config/src/file/util.rs index 4fa0a516250..4a234d16e0f 100644 --- a/gix-config/src/file/util.rs +++ b/gix-config/src/file/util.rs @@ -1,5 +1,7 @@ use std::collections::HashMap; +use gix_error::ExnResult; + use bstr::{BStr, BString, ByteSlice}; use crate::{ @@ -62,29 +64,29 @@ impl File { &'a self, section_name: &'a str, subsection_name: Option<&BStr>, - ) -> Result + DoubleEndedIterator + 'a, lookup::existing::Error> { + ) -> ExnResult + DoubleEndedIterator + 'a> { let section_name = section::Name::from_str_unchecked(section_name); let lookup = self .section_lookup_tree .get(§ion_name) - .ok_or(lookup::existing::Error::SectionMissing)?; + .ok_or_else(lookup::existing::section_missing)?; match subsection_name { Some(name) => lookup.by_subsection.get(name), None => (!lookup.without_subsection.is_empty()).then_some(&lookup.without_subsection), } - .ok_or(lookup::existing::Error::SubSectionMissing) + .ok_or_else(lookup::existing::subsection_missing) .map(|ids| ids.iter().copied()) } pub(crate) fn section_ids_by_name<'a>( &'a self, section_name: &str, - ) -> Result + 'a + use<'a>, lookup::existing::Error> { + ) -> ExnResult + 'a + use<'a>> { let lookup_name = section::Name::from_str_unchecked(section_name); let lookup = self .section_lookup_tree .get(&lookup_name) - .ok_or(lookup::existing::Error::SectionMissing)?; + .ok_or_else(lookup::existing::section_missing)?; let mut ids = Vec::with_capacity(self.section_order.len()); ids.extend_from_slice(&lookup.without_subsection); ids.extend(lookup.by_subsection.values().flatten().copied()); diff --git a/gix-config/src/lookup.rs b/gix-config/src/lookup.rs index f16d073a39e..f2ba7c738e9 100644 --- a/gix-config/src/lookup.rs +++ b/gix-config/src/lookup.rs @@ -1,26 +1,62 @@ /// The error when looking up a value, for example via [`File::try_value()`][crate::File::try_value()]. -#[derive(Debug, thiserror::Error)] +#[derive(Debug)] #[expect(missing_docs)] pub enum Error { - #[error(transparent)] - ValueMissing(#[from] existing::Error), - #[error(transparent)] + ValueMissing(gix_error::Error), FailedConversion(E), } +impl> Error { + /// Convert this lookup error into a standard error, retaining the inner exception's context. + pub fn into_error(self) -> gix_error::Error { + match self { + Error::ValueMissing(err) => err, + Error::FailedConversion(err) => err.into(), + } + } +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::ValueMissing(err) => std::fmt::Display::fmt(err, f), + Error::FailedConversion(err) => std::fmt::Display::fmt(err, f), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Error::ValueMissing(err) => Some(err), + Error::FailedConversion(err) => Some(err), + } + } +} + +impl From for Error { + fn from(err: gix_error::Exn) -> Self { + Error::ValueMissing(err.into_error()) + } +} + /// pub mod existing { - /// The error when looking up a value that doesn't exist, for example via [`File::value()`][crate::File::value()]. - #[derive(Debug, thiserror::Error)] - #[expect(missing_docs)] - pub enum Error { - #[error("The requested section does not exist")] - SectionMissing, - #[error("The requested subsection does not exist")] - SubSectionMissing, - #[error("The key does not exist in the requested section")] - KeyMissing, - #[error(transparent)] - ValueName(#[from] crate::parse::section::value_name::Error), + + pub(crate) fn section_missing() -> gix_error::Exn { + not_found("The requested section does not exist") + } + + pub(crate) fn subsection_missing() -> gix_error::Exn { + not_found("The requested subsection does not exist") + } + + pub(crate) fn key_missing() -> gix_error::Exn { + not_found("The key does not exist in the requested section") + } + + fn not_found(message: &'static str) -> gix_error::Exn { + use gix_error::ErrorExt; + gix_error::not_found(message).raise_erased() } } diff --git a/gix-config/src/parse/comment.rs b/gix-config/src/parse/comment.rs index 3f8a26bec15..225f750fd0b 100644 --- a/gix-config/src/parse/comment.rs +++ b/gix-config/src/parse/comment.rs @@ -1,11 +1,8 @@ use crate::parse::Comment; +use gix_error::ExnMessageResult; impl Comment { - pub(crate) fn copy_to_backing_in( - &self, - source: &[u8], - target: &mut Vec, - ) -> Result { + pub(crate) fn copy_to_backing_in(&self, source: &[u8], target: &mut Vec) -> ExnMessageResult { Ok(Comment { tag: self.tag, text: self.text.copy_to_backing_in(source, target)?, diff --git a/gix-config/src/parse/error.rs b/gix-config/src/parse/error.rs index 611568c39e5..dd2431d2815 100644 --- a/gix-config/src/parse/error.rs +++ b/gix-config/src/parse/error.rs @@ -109,4 +109,8 @@ impl Display for Error { } } -impl std::error::Error for Error {} +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(const { &gix_error::ClassificationMarker::VALIDATION }) + } +} diff --git a/gix-config/src/parse/event.rs b/gix-config/src/parse/event.rs index be328595048..4d6fb60e5dd 100644 --- a/gix-config/src/parse/event.rs +++ b/gix-config/src/parse/event.rs @@ -1,3 +1,4 @@ +use gix_error::ExnMessageResult; use std::fmt::Display; use bstr::{BStr, BString}; @@ -6,7 +7,7 @@ use crate::parse::{Event, EventRef}; impl Event { /// Shift all backing-buffer spans in this event forward by `offset` bytes. - pub(crate) fn rebase(&mut self, offset: usize) -> Result<(), crate::parse::span::Error> { + pub(crate) fn rebase(&mut self, offset: usize) -> ExnMessageResult { match self { Event::Comment(comment) => comment.text.rebase(offset), Event::SectionHeader(header) => header.rebase(offset), @@ -20,11 +21,7 @@ impl Event { } } - pub(crate) fn copy_to_backing_in( - &self, - source: &[u8], - target: &mut Vec, - ) -> Result { + pub(crate) fn copy_to_backing_in(&self, source: &[u8], target: &mut Vec) -> ExnMessageResult { Ok(match self { Event::Comment(comment) => Event::Comment(comment.copy_to_backing_in(source, target)?), Event::SectionHeader(header) => Event::SectionHeader(header.copy_to_backing_in(source, target)?), diff --git a/gix-config/src/parse/format.rs b/gix-config/src/parse/format.rs index 85b0ab0bc6d..a8a89010ca7 100644 --- a/gix-config/src/parse/format.rs +++ b/gix-config/src/parse/format.rs @@ -8,6 +8,7 @@ //! newlines and the `=` separator are rewritten according to [`Options`](crate::parse::format::Options). use bstr::BString; +use gix_error::ExnMessageResult; use crate::parse::{self, EventRef}; @@ -76,8 +77,8 @@ impl Default for Options { /// /// # Errors /// -/// Returns a [`parse::Error`] if `input` is not a syntactically valid git-config file. -pub fn normalize(input: &[u8], options: &Options) -> Result { +/// Returns an error classified as [`gix_error::Class::Validation`] if `input` is not a syntactically valid git-config file. +pub fn normalize(input: &[u8], options: &Options) -> ExnMessageResult { let parsed = parse::Events::from_bytes(input, None)?; let events: Vec<_> = parsed.iter().collect(); Ok(normalize_events(&events, options)) diff --git a/gix-config/src/parse/from_bytes/mod.rs b/gix-config/src/parse/from_bytes/mod.rs index a8585bdbe9f..c51e8eb1a2a 100644 --- a/gix-config/src/parse/from_bytes/mod.rs +++ b/gix-config/src/parse/from_bytes/mod.rs @@ -39,7 +39,7 @@ pub(crate) fn from_bytes(mut input: &[u8], dispatch: &mut dyn FnMut(Event)) -> R } else if !input.starts_with(b"[") { let mut node = ParseNode::SectionHeader; key_value_pair(backing, &mut input, &mut node, dispatch) - .map_err(|_| Error::parse(newlines_from(backing, input), node, input.as_bstr().into()))?; + .map_err(|()| Error::parse(newlines_from(backing, input), node, input.as_bstr().into()))?; } if input.len() == before.len() { break; @@ -53,7 +53,7 @@ pub(crate) fn from_bytes(mut input: &[u8], dispatch: &mut dyn FnMut(Event)) -> R let mut node = ParseNode::SectionHeader; while !input.is_empty() { section(backing, &mut input, &mut node, dispatch) - .map_err(|_| Error::parse(newlines_from(backing, input), node, input.as_bstr().into()))?; + .map_err(|()| Error::parse(newlines_from(backing, input), node, input.as_bstr().into()))?; } Ok(()) } diff --git a/gix-config/src/parse/from_bytes/tests.rs b/gix-config/src/parse/from_bytes/tests.rs index 8bf40c48ddc..e43a1f506fb 100644 --- a/gix-config/src/parse/from_bytes/tests.rs +++ b/gix-config/src/parse/from_bytes/tests.rs @@ -8,12 +8,10 @@ fn input_size_is_limited_by_span_representation() { let err = ensure_supported_input_size(actual).expect_err("inputs above the span limit must be rejected"); assert_eq!(err.line_number(), 1); assert!(err.remaining_data().is_empty()); - assert_eq!( - err.to_string(), - format!( - "Configuration input is {actual} bytes large, but at most {} bytes are supported", - u32::MAX - ) + insta::assert_debug_snapshot!(format_args!("{err}"), "oversized input reports the actual byte count and the supported limit", @"Configuration input is 4294967296 bytes large, but at most 4294967295 bytes are supported"); + assert!( + gix_error::Error::from_error(err).is_validation(), + "input outside the parser's supported size range is invalid input" ); } diff --git a/gix-config/src/parse/mod.rs b/gix-config/src/parse/mod.rs index c6717261de0..0077f687987 100644 --- a/gix-config/src/parse/mod.rs +++ b/gix-config/src/parse/mod.rs @@ -11,6 +11,7 @@ //! [`File`]: crate::File use bstr::{BStr, BString, ByteSlice}; +use gix_error::ExnMessageResult; mod from_bytes; @@ -36,10 +37,13 @@ pub(crate) struct Span { /// Errors produced when a span cannot be represented. pub mod span { - /// A span offset or length exceeded the supported 32-bit representation. - #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, thiserror::Error)] - #[error("configuration data exceeds the supported span size of {} bytes", u32::MAX)] - pub struct Error; + + pub(crate) fn error() -> gix_error::Message { + gix_error::validation(format!( + "configuration data exceeds the supported span size of {} bytes", + u32::MAX + )) + } } /// A raw span whose semantic value may have required decoding while parsing. @@ -74,11 +78,11 @@ impl MaybeDecoded { .map_or_else(|| self.raw.as_bstr_in(backing), |value| value.as_bstr()) } - pub(crate) fn rebase(&mut self, offset: usize) -> Result<(), span::Error> { + pub(crate) fn rebase(&mut self, offset: usize) -> ExnMessageResult { self.raw.rebase(offset) } - pub(crate) fn copy_to_backing_in(&self, source: &[u8], target: &mut Vec) -> Result { + pub(crate) fn copy_to_backing_in(&self, source: &[u8], target: &mut Vec) -> ExnMessageResult { Ok(Self { raw: self.raw.copy_to_backing_in(source, target)?, decoded: self.decoded.clone(), @@ -87,18 +91,21 @@ impl MaybeDecoded { } impl Span { - pub(crate) fn append(backing: &mut Vec, bytes: &[u8]) -> Result { + pub(crate) fn append(backing: &mut Vec, bytes: &[u8]) -> ExnMessageResult { let start = backing.len(); let span = Self::range(start, bytes.len())?; - backing.len().checked_add(bytes.len()).ok_or(span::Error)?; + backing.len().checked_add(bytes.len()).ok_or_else(span::error)?; backing.extend_from_slice(bytes); Ok(span) } - pub(crate) fn range(start: usize, len: usize) -> Result { + pub(crate) fn range(start: usize, len: usize) -> ExnMessageResult { + if start > u32::MAX as usize || len > u32::MAX as usize { + return Err(span::error().into()); + } Ok(Span { - start: start.try_into().map_err(|_| span::Error)?, - len: len.try_into().map_err(|_| span::Error)?, + start: start as u32, + len: len as u32, }) } @@ -132,15 +139,15 @@ impl Span { self.as_slice_in(backing).into() } - pub(crate) fn copy_to_backing_in(&self, source: &[u8], target: &mut Vec) -> Result { + pub(crate) fn copy_to_backing_in(&self, source: &[u8], target: &mut Vec) -> ExnMessageResult { Span::append(target, self.as_slice_in(source)) } - pub(crate) fn rebase(&mut self, offset: usize) -> Result<(), span::Error> { + pub(crate) fn rebase(&mut self, offset: usize) -> ExnMessageResult { self.start = (self.start as usize) .checked_add(offset) .and_then(|start| start.try_into().ok()) - .ok_or(span::Error)?; + .ok_or_else(span::error)?; Ok(()) } } @@ -255,6 +262,9 @@ pub(crate) struct Comment { /// A parser error reports the one-indexed line number where the parsing error /// occurred, as well as the last parser node and the remaining data to be /// parsed. +/// Its source is a classification-only [`gix_error::ClassificationMarker`]. +/// Use [`gix_error::classify()`] or `is_validation()` on [`gix_error::Exn`] and [`gix_error::Error`] to check the +/// classification. Downcast to this type for parser details. #[derive(PartialEq, Debug)] pub struct Error { kind: error::Kind, diff --git a/gix-config/src/parse/section/header.rs b/gix-config/src/parse/section/header.rs index e6629898500..ac5f7e6467b 100644 --- a/gix-config/src/parse/section/header.rs +++ b/gix-config/src/parse/section/header.rs @@ -1,25 +1,16 @@ use bstr::{BStr, BString, ByteSlice}; +use gix_error::ExnMessageResult; use crate::parse::{Span, section::HeaderData}; -/// The error returned when creating a section header. -#[derive(Debug, PartialOrd, PartialEq, Eq, thiserror::Error)] -#[expect(missing_docs)] -pub enum Error { - #[error("section names can only be ascii, '-'")] - InvalidName, - #[error("sub-section names must not contain newlines or null bytes")] - InvalidSubSection, - #[error(transparent)] - Span(#[from] crate::parse::span::Error), -} - impl HeaderData { + /// Invalid section or subsection name bytes are stored as `input` in [`gix_error::Message::values`]. + /// After [wrapping](gix_error::Error::from_error()), inspect them with [metadata](gix_error::Error::metadata()). pub(crate) fn new_in( name: impl AsRef, subsection: impl Into>, backing: &mut Vec, - ) -> Result { + ) -> ExnMessageResult { let name = validated_name(name.as_ref().as_bytes().as_bstr())?; let name = Span::append(backing, &name)?; let (separator, subsection_name) = match subsection.into() { @@ -50,21 +41,27 @@ pub fn is_valid_subsection(name: impl crate::AsBStr) -> bool { name.as_bstr().find_byteset(b"\n\0").is_none() } -fn validated_subsection(name: &BStr) -> Result { - is_valid_subsection(name) - .then(|| name.into()) - .ok_or(Error::InvalidSubSection) +fn validated_subsection(name: &BStr) -> ExnMessageResult { + is_valid_subsection(name).then(|| name.into()).ok_or_else(|| { + gix_error::validation("sub-section names must not contain newlines or null bytes") + .with("input", name) + .into() + }) } -fn validated_name(name: &BStr) -> Result { +fn validated_name(name: &BStr) -> ExnMessageResult { name.iter() .all(|b| b.is_ascii_alphanumeric() || *b == b'-') .then(|| name.into()) - .ok_or(Error::InvalidName) + .ok_or_else(|| { + gix_error::validation("section names can only be ascii, '-'") + .with("input", name) + .into() + }) } impl HeaderData { - pub(crate) fn rebase(&mut self, offset: usize) -> Result<(), crate::parse::span::Error> { + pub(crate) fn rebase(&mut self, offset: usize) -> ExnMessageResult { self.name.rebase(offset)?; if let Some(separator) = &mut self.separator { separator.rebase(offset)?; @@ -75,11 +72,7 @@ impl HeaderData { Ok(()) } - pub(crate) fn copy_to_backing_in( - &self, - source: &[u8], - target: &mut Vec, - ) -> Result { + pub(crate) fn copy_to_backing_in(&self, source: &[u8], target: &mut Vec) -> ExnMessageResult { Ok(HeaderData { name: self.name.copy_to_backing_in(source, target)?, separator: self diff --git a/gix-config/src/parse/section/mod.rs b/gix-config/src/parse/section/mod.rs index 0e1d0f68d2f..c9d4222beb0 100644 --- a/gix-config/src/parse/section/mod.rs +++ b/gix-config/src/parse/section/mod.rs @@ -23,16 +23,12 @@ mod types { use bstr::ByteSlice; macro_rules! generate_case_insensitive { - ($name:ident, $module:ident, $err_doc:literal, $validate:ident, $cow_inner_type:ty, $comment:literal) => { - /// - pub mod $module { - /// The error returned when `TryFrom` is invoked to create an instance. - #[derive(Debug, thiserror::Error, Copy, Clone)] - #[error($err_doc)] - pub struct Error; - } - + ($name:ident, $err_doc:literal, $validate:ident, $cow_inner_type:ty, $comment:literal) => { #[doc = $comment] + /// + /// Conversion errors store invalid name bytes as `input` in [`gix_error::Message::values`]. + /// After [wrapping](gix_error::Error::from_error()), inspect them with + /// [metadata](gix_error::Error::metadata()). #[derive(Clone, Eq, Debug, Default)] pub struct $name(pub(crate) bstr::BString); @@ -82,7 +78,7 @@ mod types { } impl std::convert::TryFrom<&str> for $name { - type Error = $module::Error; + type Error = gix_error::Message; fn try_from(s: &str) -> Result { Self::try_from(bstr::ByteSlice::as_bstr(s.as_bytes())) @@ -90,7 +86,7 @@ mod types { } impl std::convert::TryFrom for $name { - type Error = $module::Error; + type Error = gix_error::Message; fn try_from(s: String) -> Result { Self::try_from(bstr::BString::from(s)) @@ -98,25 +94,25 @@ mod types { } impl std::convert::TryFrom for $name { - type Error = $module::Error; + type Error = gix_error::Message; fn try_from(s: bstr::BString) -> Result { if $validate(s.as_slice().as_bstr()) { Ok(Self(s.into())) } else { - Err($module::Error) + Err(gix_error::validation($err_doc).with("input", s)) } } } impl std::convert::TryFrom<&bstr::BStr> for $name { - type Error = $module::Error; + type Error = gix_error::Message; fn try_from(s: &bstr::BStr) -> Result { if $validate(s) { Ok(Self(s.into())) } else { - Err($module::Error) + Err(gix_error::validation($err_doc).with("input", s)) } } } @@ -146,7 +142,6 @@ mod types { generate_case_insensitive!( Name, - name, "Valid names consist of alphanumeric characters or dashes.", is_valid_name, bstr::BStr, @@ -155,15 +150,14 @@ mod types { generate_case_insensitive!( ValueName, - value_name, "Valid value names consist of alphanumeric characters or dashes, starting with an alphabetic character.", is_valid_value_name, bstr::BStr, "Wrapper struct for value names, like `path` in `include.path`, since keys are case-insensitive." ); } +pub use types::Name; pub(crate) use types::ValueName; -pub use types::{Name, name, value_name}; #[cfg(test)] mod tests { diff --git a/gix-config/src/parse/tests.rs b/gix-config/src/parse/tests.rs index da8b6702afe..5f08a3abf08 100644 --- a/gix-config/src/parse/tests.rs +++ b/gix-config/src/parse/tests.rs @@ -67,7 +67,7 @@ mod span { assert!(Span::range(0, u32::MAX as usize + 1).is_err()); let mut span = Span::range(u32::MAX as usize, 0).expect("the maximum offset is representable"); - assert_eq!(span.rebase(1), Err(crate::parse::span::Error)); + insta::assert_debug_snapshot!(span.rebase(1).expect_err("rebasing beyond u32 must fail"), "reports range and rebase overflow", @"configuration data exceeds the supported span size of 4294967295 bytes"); } } diff --git a/gix-config/src/value/mod.rs b/gix-config/src/value/mod.rs index 56d02a94981..61a9857ad4d 100644 --- a/gix-config/src/value/mod.rs +++ b/gix-config/src/value/mod.rs @@ -1,4 +1,2 @@ -pub use gix_config_value::Error; - mod normalize; pub use normalize::normalize; diff --git a/gix-config/tests/config/file/access/mutate.rs b/gix-config/tests/config/file/access/mutate.rs index 2c4b320001c..c9ee4fe6d20 100644 --- a/gix-config/tests/config/file/access/mutate.rs +++ b/gix-config/tests/config/file/access/mutate.rs @@ -1,6 +1,8 @@ mod new_section { + use crate::Result; + #[test] - fn accepts_a_borrowed_subsection_name() -> crate::Result { + fn accepts_a_borrowed_subsection_name() -> Result { let mut file = gix_config::File::default(); file.new_section("remote", "origin")?; file.new_section("branch", "main")?; @@ -15,7 +17,7 @@ mod new_section { } #[test] - fn owned_sections_accept_a_borrowed_subsection_name() -> crate::Result { + fn owned_sections_accept_a_borrowed_subsection_name() -> Result { let section = gix_config::file::Section::new("remote", "origin", gix_config::file::Metadata::default())?; assert_eq!(section.to_ref().header().subsection_name(), Some("origin".into())); Ok(()) @@ -23,6 +25,8 @@ mod new_section { } mod remove_section { + use crate::Result; + #[test] fn removal_of_all_sections_programmatically_with_sections_and_ids_by_name() { let mut file = gix_config::File::try_from("[core] \na = b\nb=c\n\n[core \"name\"]\nd = 1\ne = 2").unwrap(); @@ -71,7 +75,7 @@ mod remove_section { } #[test] - fn removing_lookup_buckets_preserves_siblings_and_drops_the_final_name() -> crate::Result { + fn removing_lookup_buckets_preserves_siblings_and_drops_the_final_name() -> Result { let mut file = gix_config::File::try_from( "[core] key=plain\n\ [core \"a\"] key=a\n\ @@ -79,28 +83,23 @@ mod remove_section { )?; file.remove_section("core", None).expect("plain section exists"); - assert!( - matches!( - file.section("core", None), - Err(gix_config::lookup::existing::Error::SubSectionMissing) - ), - "the `core` section name still exists through its siblings, but its no-subsection bucket was removed" - ); + let err = file.section("core", None).unwrap_err(); + assert!(err.is_not_found()); + insta::assert_debug_snapshot!(err, "the `core` section name still exists through its siblings, but its no-subsection bucket was removed", @"The requested subsection does not exist"); assert_eq!(file.section("core", "a")?.value("key"), Some("a".into())); file.remove_section("core", "a").expect("first subsection exists"); assert_eq!(file.section("core", "b")?.value("key"), Some("b".into())); file.remove_section("core", "b").expect("final subsection exists"); - assert!(matches!( - file.section("core", "b"), - Err(gix_config::lookup::existing::Error::SectionMissing) - )); + let err = file.section("core", "b").unwrap_err(); + assert!(err.is_not_found()); + insta::assert_debug_snapshot!(err, "removing lookup buckets preserves siblings and drops the final name", @"The requested section does not exist"); Ok(()) } #[test] - fn removed_sections_can_be_mutated_and_reinserted() -> crate::Result { + fn removed_sections_can_be_mutated_and_reinserted() -> Result { let mut file = gix_config::File::try_from("[core]\na = b\n")?; let mut section = file.remove_section("core", None).expect("section is present"); let removed_id = section.to_ref().id(); @@ -145,26 +144,22 @@ mod remove_section_filter { } mod rename_section { - use gix_config::{file::rename_section, parse::section}; + use crate::Result; #[test] fn section_renaming_validates_new_name() { let mut file = gix_config::File::try_from("[core] a = b").unwrap(); - assert!(matches!( - file.rename_section("core", None, "new_core", None), - Err(rename_section::Error::Section(section::header::Error::InvalidName)) - )); - - assert!(matches!( - file.rename_section("core", None, "new-core", "a\nb"), - Err(rename_section::Error::Section( - section::header::Error::InvalidSubSection - )) - )); + let err = file.rename_section("core", None, "new_core", None).unwrap_err(); + assert!(err.is_validation()); + insta::assert_debug_snapshot!(err, "section renaming validates new name", @r#"section names can only be ascii, '-', "input"="new_core""#); + + let err = file.rename_section("core", None, "new-core", "a\nb").unwrap_err(); + assert!(err.is_validation()); + insta::assert_debug_snapshot!(err, "section renaming validates new name", @r#"sub-section names must not contain newlines or null bytes, "input"="a\nb""#); } #[test] - fn accepts_borrowed_new_subsection_names() -> crate::Result { + fn accepts_borrowed_new_subsection_names() -> Result { let mut file = gix_config::File::try_from("[core] a = b")?; file.rename_section("core", None, "remote", "origin")?; assert_eq!( @@ -182,7 +177,7 @@ mod rename_section { } #[test] - fn all_matching_sections_are_renamed_and_target_collisions_are_preserved() -> crate::Result { + fn all_matching_sections_are_renamed_and_target_collisions_are_preserved() -> Result { let mut file = gix_config::File::try_from( "[branch \"source\"] key = one\n\ [some \"gar\"] key = unrelated\n\ @@ -206,7 +201,7 @@ mod rename_section { } #[test] - fn filter_renames_every_accepted_section() -> crate::Result { + fn filter_renames_every_accepted_section() -> Result { let mut file = gix_config::File::try_from( "[branch \"source\"] key = one\n\ [branch \"source\"] key = two\n\ @@ -238,15 +233,11 @@ mod rename_section { "#); let prev = file.to_string(); - assert!( - matches!( - file.rename_section_filter("branch", "source", "branch", "other", |_| false), - Err(rename_section::Error::Lookup( - gix_config::lookup::existing::Error::KeyMissing - )), - ), - "matching nothing causes an error" - ); + let err = file + .rename_section_filter("branch", "source", "branch", "other", |_| false) + .unwrap_err(); + assert!(err.is_not_found()); + insta::assert_debug_snapshot!(err, "matching nothing causes an error", @"The key does not exist in the requested section"); assert_eq!( file.to_string(), prev, @@ -256,7 +247,7 @@ mod rename_section { } #[test] - fn renaming_to_the_same_identity_updates_all_headers() -> crate::Result { + fn renaming_to_the_same_identity_updates_all_headers() -> Result { let mut file = gix_config::File::try_from( "[branch.source] one = 1\n\ [branch.source] two = 2\n", @@ -272,23 +263,21 @@ mod rename_section { } #[test] - fn an_empty_lookup_bucket_is_reported_as_missing() -> crate::Result { + fn an_empty_lookup_bucket_is_reported_as_missing() -> Result { let mut file = gix_config::File::try_from("[core] key = value\n")?; file.remove_section("core", None).expect("section exists"); - assert!(matches!( - file.rename_section("core", None, "other", None), - Err(rename_section::Error::Lookup( - gix_config::lookup::existing::Error::SectionMissing - )) - )); + let err = file.rename_section("core", None, "other", None).unwrap_err(); + assert!(err.is_not_found()); + insta::assert_debug_snapshot!(err, "an empty lookup bucket is reported as missing", @"The requested section does not exist"); Ok(()) } } mod set_meta { + use crate::Result; use gix_config::file; #[test] - fn affects_newly_added_sections() -> crate::Result { + fn affects_newly_added_sections() -> Result { let mut file = gix_config::File::default(); let expected = &file::Metadata::api(); assert_eq!(file.meta(), expected); diff --git a/gix-config/tests/config/file/access/raw/raw_multi_value.rs b/gix-config/tests/config/file/access/raw/raw_multi_value.rs index b8c1fcaf2ab..dca64d1c664 100644 --- a/gix-config/tests/config/file/access/raw/raw_multi_value.rs +++ b/gix-config/tests/config/file/access/raw/raw_multi_value.rs @@ -1,23 +1,24 @@ -use gix_config::{File, lookup}; +use crate::Result; +use gix_config::File; use crate::file::bstring; #[test] -fn single_value_is_identical_to_single_value_query() -> crate::Result { +fn single_value_is_identical_to_single_value_query() -> Result { let config = File::try_from("[core]\na=b\nc=d")?; assert_eq!(vec![config.raw_value("core.a")?], config.raw_values("core.a")?); Ok(()) } #[test] -fn multi_value_in_section() -> crate::Result { +fn multi_value_in_section() -> Result { let config = File::try_from("[core]\na=b\na=c")?; assert_eq!(config.raw_values("core.a")?, vec![bstring("b"), bstring("c")]); Ok(()) } #[test] -fn multi_value_across_sections() -> crate::Result { +fn multi_value_across_sections() -> Result { let config = File::try_from( "[core]\n\ a=b\n\ @@ -32,7 +33,7 @@ fn multi_value_across_sections() -> crate::Result { } #[test] -fn values_with_sections_identify_each_values_section_in_file_order() -> crate::Result { +fn values_with_sections_identify_each_values_section_in_file_order() -> Result { let config = File::try_from( "[core]\n\ a=b\n\ @@ -61,7 +62,7 @@ fn values_with_sections_identify_each_values_section_in_file_order() -> crate::R } #[test] -fn values_with_sections_filter_returns_values_from_accepted_sections() -> crate::Result { +fn values_with_sections_filter_returns_values_from_accepted_sections() -> Result { let config = File::try_from( "[core]\n\ a=b\n\ @@ -92,37 +93,34 @@ fn values_with_sections_filter_returns_values_from_accepted_sections() -> crate: } #[test] -fn section_not_found() -> crate::Result { +fn section_not_found() -> Result { let config = File::try_from("[core]\na=b\nc=d")?; - assert!(matches!( - config.raw_values("foo.a"), - Err(lookup::existing::Error::SectionMissing) - )); + let err = config.raw_values("foo.a").unwrap_err(); + assert!(err.is_not_found()); + insta::assert_debug_snapshot!(err, "section not found", @"The requested section does not exist"); Ok(()) } #[test] -fn subsection_not_found() -> crate::Result { +fn subsection_not_found() -> Result { let config = File::try_from("[core]\na=b\nc=d")?; - assert!(matches!( - config.raw_values("core.a.a"), - Err(lookup::existing::Error::SubSectionMissing) - )); + let err = config.raw_values("core.a.a").unwrap_err(); + assert!(err.is_not_found()); + insta::assert_debug_snapshot!(err, "subsection not found", @"The requested subsection does not exist"); Ok(()) } #[test] -fn key_not_found() -> crate::Result { +fn key_not_found() -> Result { let config = File::try_from("[core]\na=b\nc=d")?; - assert!(matches!( - config.raw_values("core.aaaaaa"), - Err(lookup::existing::Error::KeyMissing) - )); + let err = config.raw_values("core.aaaaaa").unwrap_err(); + assert!(err.is_not_found()); + insta::assert_debug_snapshot!(err, "key not found", @"The key does not exist in the requested section"); Ok(()) } #[test] -fn subsection_must_be_respected() -> crate::Result { +fn subsection_must_be_respected() -> Result { let config = File::try_from("[core]a=b\n[core.a]a=c")?; assert_eq!(config.raw_values("core.a")?, vec![bstring("b")]); assert_eq!(config.raw_values("core.a.a")?, vec![bstring("c")]); @@ -130,7 +128,7 @@ fn subsection_must_be_respected() -> crate::Result { } #[test] -fn non_relevant_subsection_is_ignored() -> crate::Result { +fn non_relevant_subsection_is_ignored() -> Result { let config = File::try_from("[core]\na=b\na=c\n[core]a=d\n[core]g=g")?; assert_eq!( config.raw_values("core.a")?, diff --git a/gix-config/tests/config/file/access/raw/raw_value.rs b/gix-config/tests/config/file/access/raw/raw_value.rs index df76bd10fe4..d3520902b3d 100644 --- a/gix-config/tests/config/file/access/raw/raw_value.rs +++ b/gix-config/tests/config/file/access/raw/raw_value.rs @@ -1,7 +1,8 @@ -use gix_config::{File, lookup}; +use crate::Result; +use gix_config::File; #[test] -fn single_section() -> crate::Result { +fn single_section() -> Result { let config = File::try_from("[core]\na=b\nc=d")?; assert_eq!(config.raw_value("core.a")?, "b"); assert_eq!(config.raw_value_by("core", None, "c")?, "d"); @@ -9,32 +10,28 @@ fn single_section() -> crate::Result { } #[test] -fn global_property_uses_empty_section_name() -> crate::Result { +fn global_property_uses_empty_section_name() -> Result { let config = File::try_from("a=b\n[core]\na=c")?; - assert_eq!( - config.raw_value_by("", None, "a").unwrap_err().to_string(), - "The requested section does not exist", - "these are not readable because the supporting this adds a lot of complexity" - ); + insta::assert_debug_snapshot!(config.raw_value_by("", None, "a").expect_err("these are not readable because the supporting this adds a lot of complexity"), "these are not readable because the supporting this adds a lot of complexity", @"The requested section does not exist"); Ok(()) } #[test] -fn last_one_wins_respected_in_section() -> crate::Result { +fn last_one_wins_respected_in_section() -> Result { let config = File::try_from("[core]\na=b\na=d")?; assert_eq!(config.raw_value("core.a")?, "d"); Ok(()) } #[test] -fn last_one_wins_respected_across_section() -> crate::Result { +fn last_one_wins_respected_across_section() -> Result { let config = File::try_from("[core]\na=b\n[core]\na=d")?; assert_eq!(config.raw_value("core.a")?, "d"); Ok(()) } #[test] -fn value_with_section_identifies_the_section_containing_the_resolved_value() -> crate::Result { +fn value_with_section_identifies_the_section_containing_the_resolved_value() -> Result { let config = File::try_from( "[core]\n\ a=first\n\ @@ -54,7 +51,7 @@ fn value_with_section_identifies_the_section_containing_the_resolved_value() -> } #[test] -fn value_with_section_filter_identifies_the_section_containing_the_resolved_value() -> crate::Result { +fn value_with_section_filter_identifies_the_section_containing_the_resolved_value() -> Result { let config = File::try_from( "[core]\n\ a=first\n\ @@ -76,7 +73,7 @@ fn value_with_section_filter_identifies_the_section_containing_the_resolved_valu } #[test] -fn mutable_value_filters_have_key_and_component_variants() -> crate::Result { +fn mutable_value_filters_have_key_and_component_variants() -> Result { let mut config = File::try_from( "[core]\n\ a=first\n\ @@ -113,51 +110,46 @@ fn mutable_value_filters_have_key_and_component_variants() -> crate::Result { } #[test] -fn section_not_found() -> crate::Result { +fn section_not_found() -> Result { let config = File::try_from("[core]\na=b\nc=d")?; - assert!(matches!( - config.raw_value("foo.a"), - Err(lookup::existing::Error::SectionMissing) - )); + let err = config.raw_value("foo.a").unwrap_err(); + assert!(err.is_not_found()); + insta::assert_debug_snapshot!(err, "section not found", @"The requested section does not exist"); Ok(()) } #[test] -fn subsection_not_found() -> crate::Result { +fn subsection_not_found() -> Result { let config = File::try_from("[core]\na=b\nc=d")?; - assert!(matches!( - config.raw_value("core.a.a"), - Err(lookup::existing::Error::SubSectionMissing) - )); + let err = config.raw_value("core.a.a").unwrap_err(); + assert!(err.is_not_found()); + insta::assert_debug_snapshot!(err, "subsection not found", @"The requested subsection does not exist"); Ok(()) } #[test] -fn key_not_found() -> crate::Result { +fn key_not_found() -> Result { let config = File::try_from("[core]\na=b\nc=d")?; - assert!(matches!( - config.raw_value("core.aaaaaa"), - Err(lookup::existing::Error::KeyMissing) - )); + let err = config.raw_value("core.aaaaaa").unwrap_err(); + assert!(err.is_not_found()); + insta::assert_debug_snapshot!(err, "key not found", @"The key does not exist in the requested section"); Ok(()) } #[test] -fn invalid_value_names_are_reported_by_mutable_lookups() -> crate::Result { +fn invalid_value_names_are_reported_by_mutable_lookups() -> Result { let mut config = File::try_from("[core]\na=b")?; - assert!(matches!( - config.raw_value_mut_by("core", None, "1invalid"), - Err(lookup::existing::Error::ValueName(_)) - )); - assert!(matches!( - config.raw_values_mut_by("core", None, "contains.dot"), - Err(lookup::existing::Error::ValueName(_)) - )); + let err = config.raw_value_mut_by("core", None, "1invalid").unwrap_err(); + assert!(err.is_validation()); + insta::assert_debug_snapshot!(err, "invalid value names are reported by mutable lookups", @r#"Valid value names consist of alphanumeric characters or dashes, starting with an alphabetic character., "input"="1invalid""#); + let err = config.raw_values_mut_by("core", None, "contains.dot").unwrap_err(); + assert!(err.is_validation()); + insta::assert_debug_snapshot!(err, "invalid value names are reported by mutable lookups", @r#"Valid value names consist of alphanumeric characters or dashes, starting with an alphabetic character., "input"="contains.dot""#); Ok(()) } #[test] -fn subsection_must_be_respected() -> crate::Result { +fn subsection_must_be_respected() -> Result { let config = File::try_from("[core]a=b\n[core.a]a=c")?; assert_eq!(config.raw_value("core.a")?, "b"); assert_eq!(config.raw_value("core.a.a")?, "c"); diff --git a/gix-config/tests/config/file/access/raw/set_existing_raw_value.rs b/gix-config/tests/config/file/access/raw/set_existing_raw_value.rs index 02c53ebbea5..792cd2a1c2c 100644 --- a/gix-config/tests/config/file/access/raw/set_existing_raw_value.rs +++ b/gix-config/tests/config/file/access/raw/set_existing_raw_value.rs @@ -1,3 +1,5 @@ +use crate::Result; + fn file(input: &str) -> gix_config::File { input.parse().unwrap() } @@ -23,14 +25,10 @@ fn single_line() { } #[test] -fn global_property_uses_empty_section_name() -> crate::Result { +fn global_property_uses_empty_section_name() -> Result { let mut file = file("a=b\n[core]\na=c"); let err = file.set_existing_raw_value_by("", None, "a", "d").unwrap_err(); - assert_eq!( - err.to_string(), - "The requested section does not exist", - "cannot set global values" - ); + insta::assert_debug_snapshot!(err, "cannot set global values", @"The requested section does not exist"); Ok(()) } diff --git a/gix-config/tests/config/file/access/raw/set_raw_value.rs b/gix-config/tests/config/file/access/raw/set_raw_value.rs index 7bcf9820ca0..ba8b523eb84 100644 --- a/gix-config/tests/config/file/access/raw/set_raw_value.rs +++ b/gix-config/tests/config/file/access/raw/set_raw_value.rs @@ -1,3 +1,5 @@ +use crate::Result; + fn file(input: &str) -> gix_config::File { input.parse().unwrap() } @@ -51,7 +53,7 @@ fn comment_included() { } #[test] -fn non_existing_values_cannot_be_set() -> crate::Result { +fn non_existing_values_cannot_be_set() -> Result { let mut file = gix_config::File::default(); file.set_raw_value_by("new", None, "key", "value")?; file.set_raw_value_by("new", "subsection", "key", "subsection-value")?; @@ -66,7 +68,7 @@ fn non_existing_values_cannot_be_set() -> crate::Result { } #[test] -fn accepts_short_lived_keys() -> crate::Result { +fn accepts_short_lived_keys() -> Result { let mut file = gix_config::File::default(); let key = String::from("new.key"); @@ -79,9 +81,8 @@ fn accepts_short_lived_keys() -> crate::Result { #[test] fn invalid_value_names_fail_without_creating_a_section() { let mut file = gix_config::File::default(); - assert!(matches!( - file.set_raw_value_by("new", None, "not.valid", "value"), - Err(gix_config::file::set_raw_value::Error::ValueName(_)) - )); + let err = file.set_raw_value_by("new", None, "not.valid", "value").unwrap_err(); + assert!(err.is_validation()); + insta::assert_debug_snapshot!(err, "invalid value names fail without creating a section", @r#"Valid value names consist of alphanumeric characters or dashes, starting with an alphabetic character., "input"="not.valid""#); assert_eq!(file.sections().count(), 0, "validation precedes section creation"); } diff --git a/gix-config/tests/config/file/access/read_only.rs b/gix-config/tests/config/file/access/read_only.rs index 4bd3fabd4c1..ff81f13600e 100644 --- a/gix-config/tests/config/file/access/read_only.rs +++ b/gix-config/tests/config/file/access/read_only.rs @@ -1,3 +1,4 @@ +use crate::Result; use std::fs; use bstr::{BString, ByteSlice}; @@ -9,8 +10,53 @@ use gix_config::{ use crate::file::bstring; +fn lookup_error(err: gix_config::lookup::Error>) -> gix_error::Error { + err.into_error() +} + +#[test] +fn typed_lookup_errors_can_be_erased() -> Result { + let mut error_snapshots = Vec::new(); + use gix_error::ResultExt; + + let config = File::try_from("[core]\nvalue = invalid\n")?; + for result in [ + config.value::("core.value").map(|_| ()), + config.value::("core.value").map(|_| ()), + config.value::("core.value").map(|_| ()), + config.values::("core.value").map(|_| ()), + config.values::("core.value").map(|_| ()), + config.values::("core.value").map(|_| ()), + ] { + let err = result + .map_err(gix_config::lookup::Error::into_error) + .or_erased() + .expect_err("invalid typed values must fail conversion") + .into_error(); + error_snapshots.push(gix_testtools::redact_debug_snapshot(&(err), &[])); + assert!(err.is_validation(), "erasure retains the conversion error"); + } + let err = config + .value::("core.missing") + .expect_err("the key does not exist") + .into_error(); + insta::assert_debug_snapshot!(err, "erasure retains missing-value classification", @"The key does not exist in the requested section"); + assert!(err.is_not_found(), "erasure retains missing-value classification"); + insta::assert_debug_snapshot!(error_snapshots, "typed lookup errors can be erased", @r#" + [ + Booleans need to be 'no', 'off', 'false', '' or 'yes', 'on', 'true' or any number, "input"="invalid", + Integers needs to be positive or negative numbers which may have a suffix like 1k, 42, or 50G, "input"="invalid", + Colors are specific color values and their attributes, like 'brightred', or 'blue', "input"="invalid", + Booleans need to be 'no', 'off', 'false', '' or 'yes', 'on', 'true' or any number, "input"="invalid", + Integers needs to be positive or negative numbers which may have a suffix like 1k, 42, or 50G, "input"="invalid", + Colors are specific color values and their attributes, like 'brightred', or 'blue', "input"="invalid", + ] + "#); + Ok(()) +} + #[test] -fn parsed_section_header_legacy_check_uses_backing_buffer() -> crate::Result { +fn parsed_section_header_legacy_check_uses_backing_buffer() -> Result { let config = File::try_from( "[remote.origin]\n\turl = https://example.com\n[remote \"upstream\"]\n\turl = https://example.com\n", )?; @@ -24,7 +70,7 @@ fn parsed_section_header_legacy_check_uses_backing_buffer() -> crate::Result { /// Asserts we can cast into all variants of our type #[test] -fn get_value_for_all_provided_values() -> crate::Result { +fn get_value_for_all_provided_values() -> Result { let config = r#" [core] other-quoted = "hello" @@ -53,7 +99,7 @@ fn get_value_for_all_provided_values() -> crate::Result { }, )?; - assert!(!config.value::("core.bool-explicit")?.0); + assert!(!config.value::("core.bool-explicit").map_err(lookup_error)?.0); assert!(!config.boolean("core.bool-explicit")?.expect("exists")); assert!(!config.boolean("core.bool-explicit")?.expect("exists")); @@ -105,7 +151,9 @@ fn get_value_for_all_provided_values() -> crate::Result { assert_eq!(config.string("doesn't.exist"), None); assert_eq!( - config.value::("core.integer-no-prefix")?, + config + .value::("core.integer-no-prefix") + .map_err(lookup_error)?, Integer { value: 10, suffix: None @@ -113,7 +161,9 @@ fn get_value_for_all_provided_values() -> crate::Result { ); assert_eq!( - config.value::("core.integer-no-prefix")?, + config + .value::("core.integer-no-prefix") + .map_err(lookup_error)?, Integer { value: 10, suffix: None @@ -121,7 +171,7 @@ fn get_value_for_all_provided_values() -> crate::Result { ); assert_eq!( - config.value::("core.integer-prefix")?, + config.value::("core.integer-prefix").map_err(lookup_error)?, Integer { value: 10, suffix: Some(integer::Suffix::Gibi), @@ -129,7 +179,7 @@ fn get_value_for_all_provided_values() -> crate::Result { ); assert_eq!( - config.value::("core.color")?, + config.value::("core.color").map_err(lookup_error)?, Color { foreground: Some(color::Name::BrightGreen), background: Some(color::Name::Red), @@ -188,7 +238,7 @@ fn get_value_for_all_provided_values() -> crate::Result { } #[test] -fn get_value_looks_up_all_sections_before_failing() -> crate::Result { +fn get_value_looks_up_all_sections_before_failing() -> Result { let config = r#" [core] bool-explicit = false @@ -201,7 +251,7 @@ fn get_value_looks_up_all_sections_before_failing() -> crate::Result { // Checks that we check the last entry first still assert!( - !file.value::("core.bool-implicit")?.0, + !file.value::("core.bool-implicit").map_err(lookup_error)?.0, "implicit bool is invisible to `value` and boolean is the only value we want. Would have to special case it." ); assert!( @@ -210,7 +260,7 @@ fn get_value_looks_up_all_sections_before_failing() -> crate::Result { ); assert!( - !file.value::("core.bool-explicit")?.0, + !file.value::("core.bool-explicit").map_err(lookup_error)?.0, "explicit values always work" ); @@ -218,7 +268,7 @@ fn get_value_looks_up_all_sections_before_failing() -> crate::Result { } #[test] -fn interpreted_values_can_be_returned_with_their_sections() -> crate::Result { +fn interpreted_values_can_be_returned_with_their_sections() -> Result { let file = File::try_from( "[core]\n\ a=1\n\ @@ -228,52 +278,59 @@ fn interpreted_values_can_be_returned_with_their_sections() -> crate::Result { )?; let section_ids: Vec<_> = file.sections().map(|section| section.id()).collect(); - let (value, section) = file.value_with_section::("core.a")?; + let (value, section) = file.value_with_section::("core.a").map_err(lookup_error)?; assert_eq!(value.value, 3); assert_eq!(section.id(), section_ids[1]); - let values = file.values_with_sections::("core.a")?; + let values = file.values_with_sections::("core.a").map_err(lookup_error)?; let actual: Vec<_> = values .into_iter() .map(|(value, section)| (value.value, section.id())) .collect(); assert_eq!(actual, [(1, section_ids[0]), (2, section_ids[0]), (3, section_ids[1])]); - let (value, section) = file.value_with_section_by::("core", None, "a")?; + let (value, section) = file + .value_with_section_by::("core", None, "a") + .map_err(lookup_error)?; assert_eq!((value.value, section.id()), (3, section_ids[1])); - assert_eq!(file.values_with_sections_by::("core", None, "a")?.len(), 3); + assert_eq!( + file.values_with_sections_by::("core", None, "a") + .map_err(lookup_error)? + .len(), + 3 + ); Ok(()) } #[test] -fn section_names_are_case_insensitive() -> crate::Result { +fn section_names_are_case_insensitive() -> Result { let config = "[core] a=true"; let file = File::try_from(config)?; assert_eq!( - file.value::("core.a").unwrap(), - file.value::("CORE.a").unwrap() + file.value::("core.a").map_err(lookup_error)?, + file.value::("CORE.a").map_err(lookup_error)? ); Ok(()) } #[test] -fn value_names_are_case_insensitive() -> crate::Result { +fn value_names_are_case_insensitive() -> Result { let config = "[core] a = true A = false"; let file = File::try_from(config)?; - assert_eq!(file.values::("core.a")?.len(), 2); + assert_eq!(file.values::("core.a").map_err(lookup_error)?.len(), 2); assert_eq!( - file.value::("core.a").unwrap(), - file.value::("core.A").unwrap() + file.value::("core.a").map_err(lookup_error)?, + file.value::("core.A").map_err(lookup_error)? ); Ok(()) } #[test] -fn section_value_access_is_case_insensitive() -> crate::Result { +fn section_value_access_is_case_insensitive() -> Result { let file = File::try_from("[core]\nMixedCase = one\nMIXEDCASE = two")?; let section = file.section("core", None)?; @@ -301,7 +358,7 @@ fn single_section() { } #[test] -fn sections_by_name() -> crate::Result { +fn sections_by_name() -> Result { let config = r#" [core] repositoryformatversion = 0 @@ -320,7 +377,7 @@ fn sections_by_name() -> crate::Result { } #[test] -fn sections_by_name_ignores_subsections_and_preserves_file_order() -> crate::Result { +fn sections_by_name_ignores_subsections_and_preserves_file_order() -> Result { let config = File::try_from( "[remote] marker=plain\n\ [other] marker=unrelated\n\ @@ -348,22 +405,20 @@ fn sections_by_name_ignores_subsections_and_preserves_file_order() -> crate::Res } #[test] -fn unknown_section() -> crate::Result { +fn unknown_section() -> Result { let config = File::default(); - assert!(matches!( - config.section("missing", None).unwrap_err(), - gix_config::lookup::existing::Error::SectionMissing - )); + let err = config.section("missing", None).unwrap_err(); + assert!(err.is_not_found()); + insta::assert_debug_snapshot!(err, "unknown section", @"The requested section does not exist"); let config = r#" [present] key = false "#; let mut config = File::try_from(config)?; - assert!(matches!( - config.section("present", Some("subsection".into())).unwrap_err(), - gix_config::lookup::existing::Error::SubSectionMissing - )); + let err = config.section("present", Some("subsection".into())).unwrap_err(); + assert!(err.is_not_found()); + insta::assert_debug_snapshot!(err, "unknown section", @"The requested subsection does not exist"); config.set_raw_value_by("present", "subsection", "key", "value")?; assert!(config.section("present", Some("subsection".into())).is_ok()); @@ -374,10 +429,9 @@ fn unknown_section() -> crate::Result { for id in config.sections_and_ids().map(|(_, id)| id).collect::>() { assert!(config.remove_section_by_id(id).is_some()); } - assert!(matches!( - config.section("present", None).unwrap_err(), - gix_config::lookup::existing::Error::SectionMissing - )); + let err = config.section("present", None).unwrap_err(); + assert!(err.is_not_found()); + insta::assert_debug_snapshot!(err, "unknown section", @"The requested section does not exist"); Ok(()) } @@ -487,7 +541,7 @@ fn multi_line_value_with_empty_continuation_line() { } #[test] -fn multi_line_value_starting_on_a_continuation_line_is_not_indented() -> crate::Result { +fn multi_line_value_starting_on_a_continuation_line_is_not_indented() -> Result { let baseline = crate::scripted_fixture_read_only("make_value_whitespace_baseline.sh")?; let baseline = fs::read(baseline.join("baseline.git"))?; let baseline = baseline @@ -522,12 +576,16 @@ fn overrides_with_implicit_booleans_work_in_single_section() { b = false b "#; - let config = File::try_from(config).unwrap(); - assert_eq!(config.boolean("a.b"), Ok(Some(true)), "empty implicit booleans "); + let config = File::try_from(config).expect("valid config"); + assert_eq!( + config.boolean("a.b").expect("valid boolean"), + Some(true), + "empty implicit booleans " + ); } #[test] -fn implicit_booleans_may_be_followed_by_whitespace() -> crate::Result { +fn implicit_booleans_may_be_followed_by_whitespace() -> Result { for config in [ "[a]\n\tb \n", "[a]\n\tb\t\n", @@ -539,8 +597,8 @@ fn implicit_booleans_may_be_followed_by_whitespace() -> crate::Result { ] { let file = File::try_from(config)?; assert_eq!( - file.boolean("a.b"), - Ok(Some(true)), + file.boolean("a.b")?, + Some(true), "Git sees no separator in {config:?}, so the value is an implicit boolean and thus true" ); assert_eq!( @@ -553,8 +611,8 @@ fn implicit_booleans_may_be_followed_by_whitespace() -> crate::Result { for config in ["[a]\n\tb =\n", "[a]\n\tb = \n", "[a]\n\tb=\"\"\n", "[a]\n\tb ="] { let file = File::try_from(config)?; assert_eq!( - file.boolean("a.b"), - Ok(Some(false)), + file.boolean("a.b")?, + Some(false), "a separator in {config:?} makes the value explicitly empty, and an empty value is false" ); assert_eq!( @@ -575,6 +633,10 @@ fn overrides_with_implicit_booleans_work_across_sections() { [a] b "#; - let config = File::try_from(config).unwrap(); - assert_eq!(config.boolean("a.b"), Ok(Some(true)), "empty implicit booleans "); + let config = File::try_from(config).expect("valid config"); + assert_eq!( + config.boolean("a.b").expect("valid boolean"), + Some(true), + "empty implicit booleans " + ); } diff --git a/gix-config/tests/config/file/impls.rs b/gix-config/tests/config/file/impls.rs index dddc5ba9d2d..fdc101e46bd 100644 --- a/gix-config/tests/config/file/impls.rs +++ b/gix-config/tests/config/file/impls.rs @@ -1,3 +1,4 @@ +use crate::Result; use gix_config::File; #[test] @@ -70,7 +71,7 @@ fn can_reconstruct_configs_without_whitespace_in_middle() { } #[test] -fn equality_ignores_section_and_value_name_case_but_not_subsection_case() -> crate::Result { +fn equality_ignores_section_and_value_name_case_but_not_subsection_case() -> Result { let mixed_case = File::try_from("[Core]\nMixedCase = value\n[Remote \"Origin\"]\nURL = location\n")?; let equivalent = File::try_from("[core]\nmixedcase = value\n[remote \"Origin\"]\nurl = location\n")?; assert_eq!(mixed_case, equivalent, "section and value names are case-insensitive"); diff --git a/gix-config/tests/config/file/init/comfort.rs b/gix-config/tests/config/file/init/comfort.rs index 740f209eb3f..a7a5f4cd8f6 100644 --- a/gix-config/tests/config/file/init/comfort.rs +++ b/gix-config/tests/config/file/init/comfort.rs @@ -1,10 +1,11 @@ +use crate::Result; use gix_config::source; use serial_test::serial; #[test] #[serial] -fn from_globals() -> crate::Result { +fn from_globals() -> Result { let _environment = gix_testtools::isolate_git_environment()?; let worktree_dir = crate::scripted_fixture_read_only("make_config_repo.sh")?.canonicalize()?; let _environment = _environment.set( @@ -22,7 +23,7 @@ fn from_globals() -> crate::Result { #[test] #[serial] -fn from_environment_overrides() -> crate::Result { +fn from_environment_overrides() -> Result { let _environment = gix_testtools::isolate_git_environment()?.set("GIT_CONFIG_COUNT", "0"); let config = gix_config::File::from_environment_overrides()?; assert!(config.is_void()); @@ -31,7 +32,7 @@ fn from_environment_overrides() -> crate::Result { #[test] #[serial] -fn from_git_dir() -> crate::Result { +fn from_git_dir() -> Result { let _environment = gix_testtools::isolate_git_environment()?; let worktree_dir = crate::scripted_fixture_read_only("make_config_repo.sh")?; let git_dir = worktree_dir.join(".git"); @@ -96,7 +97,7 @@ fn from_git_dir() -> crate::Result { #[test] #[serial] -fn from_git_dir_with_worktree_extension() -> crate::Result { +fn from_git_dir_with_worktree_extension() -> Result { let _environment = gix_testtools::isolate_git_environment()?; let git_dir = crate::scripted_fixture_read_only("config_with_worktree_extension.sh")? .join("main-worktree") diff --git a/gix-config/tests/config/file/init/from_env.rs b/gix-config/tests/config/file/init/from_env.rs index d00eb43cb07..830ad573156 100644 --- a/gix-config/tests/config/file/init/from_env.rs +++ b/gix-config/tests/config/file/init/from_env.rs @@ -1,8 +1,9 @@ +use crate::Result; use std::fs; use gix_config::{ File, - file::{includes, init, init::from_env}, + file::{includes, init}, }; use gix_testtools::tempfile::tempdir; use serial_test::serial; @@ -11,7 +12,7 @@ use crate::file::init::from_paths::escape_backslashes; #[test] #[serial] -fn empty_without_relevant_environment() -> crate::Result { +fn empty_without_relevant_environment() -> Result { let _environment = gix_testtools::isolate_git_environment()?.unset("GIT_CONFIG_COUNT"); let config = File::from_env(Default::default())?; assert!(config.is_none()); @@ -20,7 +21,7 @@ fn empty_without_relevant_environment() -> crate::Result { #[test] #[serial] -fn empty_with_zero_count() -> crate::Result { +fn empty_with_zero_count() -> Result { let _environment = gix_testtools::isolate_git_environment()?.set("GIT_CONFIG_COUNT", "0"); let config = File::from_env(Default::default())?; assert!(config.is_none()); @@ -29,16 +30,21 @@ fn empty_with_zero_count() -> crate::Result { #[test] #[serial] -fn parse_error_with_invalid_count() -> crate::Result { +fn parse_error_with_invalid_count() -> Result { let _environment = gix_testtools::isolate_git_environment()?.set("GIT_CONFIG_COUNT", "invalid"); - let err = File::from_env(Default::default()).unwrap_err(); - assert!(matches!(err, from_env::Error::InvalidConfigCount { .. })); + let err = File::from_env(Default::default()).expect_err("the configuration count is not an integer"); + assert!(err.is_validation(), "invalid counts are validation errors"); + insta::assert_debug_snapshot!(err, "parse error with invalid count", @r#" + GIT_CONFIG_COUNT was not a positive integer, "input"="invalid" + | + └─ invalid digit found in string + "#); Ok(()) } #[test] #[serial] -fn single_key_value_pair() -> crate::Result { +fn single_key_value_pair() -> Result { let _environment = gix_testtools::isolate_git_environment()? .set("GIT_CONFIG_COUNT", "1") .set("GIT_CONFIG_KEY_0", "core.key") @@ -57,7 +63,7 @@ fn single_key_value_pair() -> crate::Result { #[test] #[serial] -fn multiple_key_value_pairs() -> crate::Result { +fn multiple_key_value_pairs() -> Result { let _environment = gix_testtools::isolate_git_environment()? .set("GIT_CONFIG_COUNT", "3") .set("GIT_CONFIG_KEY_0", "core.a") @@ -78,7 +84,7 @@ fn multiple_key_value_pairs() -> crate::Result { #[test] #[serial] -fn error_on_relative_paths_in_include_paths() -> crate::Result { +fn error_on_relative_paths_in_include_paths() -> Result { let _environment = gix_testtools::isolate_git_environment()? .set("GIT_CONFIG_COUNT", "1") .set("GIT_CONFIG_KEY_0", "include.path") @@ -92,16 +98,22 @@ fn error_on_relative_paths_in_include_paths() -> crate::Result { .strict(), ..Default::default() }); - assert!(matches!( - res, - Err(from_env::Error::Includes(includes::Error::MissingConfigPath)) - )); + let err = res.expect_err("relative includes without a configuration path must fail"); + insta::assert_debug_snapshot!(err.classify() + .find(|classification| classification.class() == gix_error::Class::NotFound) + .expect("the missing configuration path is retained") + .error(), "error on relative paths in include paths", @r#" + Message { + message: "Include paths from environment variables must not be relative as no config file path exists as root", + class: NotFound, + } + "#); Ok(()) } #[test] #[serial] -fn follow_include_paths() -> crate::Result { +fn follow_include_paths() -> Result { let _environment = gix_testtools::isolate_git_environment()?; let dir = tempdir().unwrap(); let a_path = dir.path().join("a"); diff --git a/gix-config/tests/config/file/init/from_paths/includes/conditional/gitdir/mod.rs b/gix-config/tests/config/file/init/from_paths/includes/conditional/gitdir/mod.rs index ca99033df1a..77178610acb 100644 --- a/gix-config/tests/config/file/init/from_paths/includes/conditional/gitdir/mod.rs +++ b/gix-config/tests/config/file/init/from_paths/includes/conditional/gitdir/mod.rs @@ -1,5 +1,6 @@ mod util; +use crate::Result; use gix_testtools::Env; use serial_test::serial; use util::{Condition, GitEnv, assert_section_value}; @@ -7,12 +8,12 @@ use util::{Condition, GitEnv, assert_section_value}; use crate::file::init::from_paths::escape_backslashes; #[test] -fn relative_path_with_trailing_slash_matches_like_star_star() -> crate::Result { +fn relative_path_with_trailing_slash_matches_like_star_star() -> Result { assert_section_value(Condition::new("gitdir:worktree/"), GitEnv::repo_name("worktree")?) } #[test] -fn relative_path_without_trailing_slash_does_not_match() -> crate::Result { +fn relative_path_without_trailing_slash_does_not_match() -> Result { assert_section_value( Condition::new("gitdir:worktree").expect_original_value(), GitEnv::repo_name("worktree")?, @@ -20,18 +21,18 @@ fn relative_path_without_trailing_slash_does_not_match() -> crate::Result { } #[test] -fn relative_path_without_trailing_slash_and_dot_git_suffix_matches() -> crate::Result { +fn relative_path_without_trailing_slash_and_dot_git_suffix_matches() -> Result { assert_section_value(Condition::new("gitdir:worktree/.git"), GitEnv::repo_name("worktree")?) } #[test] -fn tilde_slash_expands_the_current_user_home() -> crate::Result { +fn tilde_slash_expands_the_current_user_home() -> Result { let env = GitEnv::repo_name(std::path::Path::new("subdir").join("worktree"))?; assert_section_value(Condition::new("gitdir:~/subdir/worktree/"), env) } #[test] -fn failed_user_expansion_matches_the_literal_pattern() -> crate::Result { +fn failed_user_expansion_matches_the_literal_pattern() -> Result { let temp = gix_testtools::tempfile::tempdir()?; let name = format!( "~gix-config-{}", @@ -86,18 +87,18 @@ path = included.config } #[test] -fn tilde_alone_does_not_match_even_if_home_is_git_directory() -> crate::Result { +fn tilde_alone_does_not_match_even_if_home_is_git_directory() -> Result { let env = GitEnv::repo_in_home()?; assert_section_value(Condition::new("gitdir:~").expect_original_value(), env) } #[test] -fn explicit_star_star_prefix_and_suffix_match_zero_or_more_path_components() -> crate::Result { +fn explicit_star_star_prefix_and_suffix_match_zero_or_more_path_components() -> Result { assert_section_value(Condition::new("gitdir:**/worktree/**"), GitEnv::repo_name("worktree")?) } #[test] -fn double_slash_does_not_match() -> crate::Result { +fn double_slash_does_not_match() -> Result { assert_section_value( Condition::new("gitdir://worktree").expect_original_value(), GitEnv::repo_name("worktree")?, @@ -105,7 +106,7 @@ fn double_slash_does_not_match() -> crate::Result { } #[test] -fn absolute_git_dir_with_os_separators_match() -> crate::Result { +fn absolute_git_dir_with_os_separators_match() -> Result { assert_section_value( original_value_on_windows(Condition::new("gitdir:$gitdir")), GitEnv::repo_name("worktree")?, @@ -113,7 +114,7 @@ fn absolute_git_dir_with_os_separators_match() -> crate::Result { } #[test] -fn absolute_worktree_dir_with_os_separators_does_not_match_if_trailing_slash_is_missing() -> crate::Result { +fn absolute_worktree_dir_with_os_separators_does_not_match_if_trailing_slash_is_missing() -> Result { assert_section_value( Condition::new("gitdir:$worktree").expect_original_value(), GitEnv::repo_name("worktree")?, @@ -121,7 +122,7 @@ fn absolute_worktree_dir_with_os_separators_does_not_match_if_trailing_slash_is_ } #[test] -fn absolute_worktree_dir_with_os_separators_matches_with_trailing_glob() -> crate::Result { +fn absolute_worktree_dir_with_os_separators_matches_with_trailing_glob() -> Result { assert_section_value( original_value_on_windows(Condition::new(format!( "gitdir:$worktree{}**", @@ -132,7 +133,7 @@ fn absolute_worktree_dir_with_os_separators_matches_with_trailing_glob() -> crat } #[test] -fn dot_slash_path_is_replaced_with_directory_containing_the_including_config_file() -> crate::Result { +fn dot_slash_path_is_replaced_with_directory_containing_the_including_config_file() -> Result { assert_section_value( Condition::new("gitdir:./").set_user_config_instead_of_repo_config(), GitEnv::repo_name("worktree")?, @@ -142,7 +143,7 @@ fn dot_slash_path_is_replaced_with_directory_containing_the_including_config_fil #[test] #[serial] -fn dot_slash_from_environment_causes_error() -> crate::Result { +fn dot_slash_from_environment_causes_error() -> Result { let _isolated_environment = gix_testtools::isolate_git_environment()?; let env = GitEnv::repo_name("worktree")?; // Only slashes can be used as matches, even on Windows. @@ -155,15 +156,16 @@ fn dot_slash_from_environment_causes_error() -> crate::Result { .set("GIT_CONFIG_VALUE_0", "./include.path"); let res = gix_config::File::from_env(env.to_init_options()); + let err = res.expect_err("resolving the relative include path must fail"); assert!( - matches!( - res, - Err(gix_config::file::init::from_env::Error::Includes( - gix_config::file::includes::Error::MissingConfigPath - )) - ), - "this is a failure of resolving the include path, after trying to include it" + err.is_not_found(), + "relative environment includes have no configuration-file base" ); + insta::assert_debug_snapshot!(err, "relative environment includes and patterns require a containing configuration file", @" + Could not resolve includes in environment configuration + | + └─ Include paths from environment variables must not be relative as no config file path exists as root + "); } let absolute_path = escape_backslashes(env.home_dir().join("include.config")); @@ -174,15 +176,16 @@ fn dot_slash_from_environment_causes_error() -> crate::Result { .set("GIT_CONFIG_VALUE_0", &absolute_path); let res = gix_config::File::from_env(env.to_init_options()); + let err = res.expect_err("resolving the relative pattern must fail"); assert!( - matches!( - res, - Err(gix_config::file::init::from_env::Error::Includes( - gix_config::file::includes::Error::MissingConfigPath - )) - ), - "here the pattern path tries to be resolved and fails as target config isn't set" + err.is_not_found(), + "relative environment includes have no configuration-file base" ); + insta::assert_debug_snapshot!(err, "relative environment includes and patterns require a containing configuration file", @" + Could not resolve includes in environment configuration + | + └─ Include paths from environment variables must not be relative as no config file path exists as root + "); } { @@ -199,7 +202,7 @@ fn dot_slash_from_environment_causes_error() -> crate::Result { } #[test] -fn dot_dot_slash_prefixes_are_not_special_and_are_not_what_you_want() -> crate::Result { +fn dot_dot_slash_prefixes_are_not_special_and_are_not_what_you_want() -> Result { assert_section_value( Condition::new("gitdir:../") .set_user_config_instead_of_repo_config() @@ -209,12 +212,12 @@ fn dot_dot_slash_prefixes_are_not_special_and_are_not_what_you_want() -> crate:: } #[test] -fn leading_dots_are_not_special() -> crate::Result { +fn leading_dots_are_not_special() -> Result { assert_section_value(Condition::new("gitdir:.hidden/"), GitEnv::repo_name(".hidden")?) } #[test] -fn dot_slash_path_with_dot_git_suffix_matches() -> crate::Result { +fn dot_slash_path_with_dot_git_suffix_matches() -> Result { assert_section_value( Condition::new("gitdir:./worktree/.git").set_user_config_instead_of_repo_config(), GitEnv::repo_name("worktree")?, @@ -222,7 +225,7 @@ fn dot_slash_path_with_dot_git_suffix_matches() -> crate::Result { } #[test] -fn globbing_and_wildcards() -> crate::Result { +fn globbing_and_wildcards() -> Result { assert_section_value( Condition::new("gitdir:stan?ard/glo*ng/[xwz]ildcards/.git").set_user_config_instead_of_repo_config(), GitEnv::repo_name("standard/globbing/wildcards")?, @@ -230,7 +233,7 @@ fn globbing_and_wildcards() -> crate::Result { } #[test] -fn case_insensitive_matches_any_case() -> crate::Result { +fn case_insensitive_matches_any_case() -> Result { assert_section_value(Condition::new("gitdir/i:WORKTREE/"), GitEnv::repo_name("worktree")?)?; assert_section_value( Condition::new("gitdir:WORKTREE/").expect_original_value(), @@ -239,7 +242,7 @@ fn case_insensitive_matches_any_case() -> crate::Result { } #[test] -fn pattern_with_escaped_backslash() -> crate::Result { +fn pattern_with_escaped_backslash() -> Result { assert_section_value( original_value_on_windows(Condition::new(r"gitdir:\\work\\tree\\/")), GitEnv::repo_name("worktree")?, @@ -247,12 +250,12 @@ fn pattern_with_escaped_backslash() -> crate::Result { } #[test] -fn pattern_with_backslash() -> crate::Result { +fn pattern_with_backslash() -> Result { assert_section_value(Condition::new(r"gitdir:work\tree/"), GitEnv::repo_name("worktree")?) } #[test] -fn star_star_in_the_middle() -> crate::Result { +fn star_star_in_the_middle() -> Result { assert_section_value( Condition::new("gitdir:**/dir/**/worktree/**"), GitEnv::repo_name("dir/worktree")?, @@ -261,14 +264,14 @@ fn star_star_in_the_middle() -> crate::Result { #[test] #[cfg(not(windows))] -fn tilde_expansion_with_symlink() -> crate::Result { +fn tilde_expansion_with_symlink() -> Result { let env = util::git_env_with_symlinked_repo()?; assert_section_value(Condition::new("gitdir:~/worktree/"), env) } #[test] #[cfg(not(windows))] -fn dot_path_with_symlink() -> crate::Result { +fn dot_path_with_symlink() -> Result { let env = util::git_env_with_symlinked_repo()?; assert_section_value( Condition::new("gitdir:./symlink-worktree/.git").set_user_config_instead_of_repo_config(), @@ -278,7 +281,7 @@ fn dot_path_with_symlink() -> crate::Result { #[test] #[cfg(not(windows))] -fn relative_path_matching_symlink() -> crate::Result { +fn relative_path_matching_symlink() -> Result { let env = util::git_env_with_symlinked_repo()?; assert_section_value( Condition::new("gitdir:symlink-worktree/").set_user_config_instead_of_repo_config(), @@ -288,7 +291,7 @@ fn relative_path_matching_symlink() -> crate::Result { #[test] #[cfg(not(windows))] -fn dot_path_matching_symlink_with_icase() -> crate::Result { +fn dot_path_matching_symlink_with_icase() -> Result { let env = util::git_env_with_symlinked_repo()?; assert_section_value( Condition::new("gitdir/i:SYMLINK-WORKTREE/").set_user_config_instead_of_repo_config(), diff --git a/gix-config/tests/config/file/init/from_paths/includes/conditional/gitdir/util.rs b/gix-config/tests/config/file/init/from_paths/includes/conditional/gitdir/util.rs index 8d49ad8d9bf..41c9fd95719 100644 --- a/gix-config/tests/config/file/init/from_paths/includes/conditional/gitdir/util.rs +++ b/gix-config/tests/config/file/init/from_paths/includes/conditional/gitdir/util.rs @@ -1,5 +1,6 @@ #![cfg_attr(windows, allow(dead_code))] +use crate::Result; use std::{ io::Write, path::{Path, PathBuf}, @@ -66,7 +67,7 @@ impl Condition { } impl GitEnv { - pub fn repo_name(repo_name: impl AsRef) -> crate::Result { + pub fn repo_name(repo_name: impl AsRef) -> Result { let tempdir = gix_testtools::tempfile::tempdir()?; let root_dir = gix_path::realpath(tempdir.path())?; let worktree_dir = root_dir.join(repo_name); @@ -80,7 +81,7 @@ impl GitEnv { }) } - pub fn repo_in_home() -> crate::Result { + pub fn repo_in_home() -> Result { Self::repo_name("") } } @@ -118,7 +119,7 @@ pub fn assert_section_value( config_location, }: Condition, env: GitEnv, -) -> crate::Result { +) -> Result { write_config(condition, &env, config_location)?; let mut paths = vec![env.git_dir().join("config")]; @@ -147,7 +148,7 @@ pub fn assert_section_value( assure_git_agrees(expected, env) } -pub fn git_env_with_symlinked_repo() -> crate::Result { +pub fn git_env_with_symlinked_repo() -> Result { let mut env = GitEnv::repo_name("worktree")?; let link_destination = env.root_dir().join("symlink-worktree"); crate::file::init::from_paths::includes::conditional::create_symlink(&link_destination, env.worktree_dir()); @@ -157,7 +158,7 @@ pub fn git_env_with_symlinked_repo() -> crate::Result { Ok(env) } -fn assure_git_agrees(expected: Option, env: GitEnv) -> crate::Result { +fn assure_git_agrees(expected: Option, env: GitEnv) -> Result { let output = gix_testtools::git_command(env.worktree_dir()) .args(["config", "--get", "section.value"]) .env("HOME", env.home_dir()) @@ -187,18 +188,18 @@ fn assure_git_agrees(expected: Option, env: GitEnv) -> crate::Result { Ok(()) } -fn write_config(condition: impl AsRef, env: &GitEnv, overwrite_config_location: ConfigLocation) -> crate::Result { +fn write_config(condition: impl AsRef, env: &GitEnv, overwrite_config_location: ConfigLocation) -> Result { let include_config = write_included_config(env)?; write_main_config(condition, include_config, env, overwrite_config_location) } -fn write_included_config(env: &GitEnv) -> crate::Result { +fn write_included_config(env: &GitEnv) -> Result { let include_path = env.worktree_dir().join("include.path"); write_append_config_value(&include_path, "override-value")?; Ok(include_path) } -fn write_append_config_value(path: impl AsRef, value: &str) -> crate::Result { +fn write_append_config_value(path: impl AsRef, value: &str) -> Result { let mut file = std::fs::OpenOptions::new().append(true).create(true).open(path)?; file.write_all( format!( @@ -216,7 +217,7 @@ fn write_main_config( include_file_path: PathBuf, env: &GitEnv, overwrite_config_location: ConfigLocation, -) -> crate::Result { +) -> Result { git_init(env.worktree_dir(), false)?; if overwrite_config_location == ConfigLocation::Repo { diff --git a/gix-config/tests/config/file/init/from_paths/includes/conditional/hasconfig.rs b/gix-config/tests/config/file/init/from_paths/includes/conditional/hasconfig.rs index 1e6844d344c..527fe1bd82a 100644 --- a/gix-config/tests/config/file/init/from_paths/includes/conditional/hasconfig.rs +++ b/gix-config/tests/config/file/init/from_paths/includes/conditional/hasconfig.rs @@ -1,9 +1,10 @@ +use crate::Result; use std::path::{Path, PathBuf}; use gix_config::file::{includes, init}; #[test] -fn simple() -> crate::Result { +fn simple() -> Result { let (config, root) = config_with_includes("basic")?; compare_baseline(&config, "user.this", root.join("expected")); assert_eq!(config.string("user.that"), None); @@ -11,7 +12,7 @@ fn simple() -> crate::Result { } #[test] -fn inclusion_order() -> crate::Result { +fn inclusion_order() -> Result { let (config, root) = config_with_includes("inclusion-order")?; for key in ["one", "two", "three"] { compare_baseline(&config, format!("user.{key}"), root.join(format!("expected.{key}"))); @@ -20,7 +21,7 @@ fn inclusion_order() -> crate::Result { } #[test] -fn globs() -> crate::Result { +fn globs() -> Result { let (config, root) = config_with_includes("globs")?; for key in ["dss", "dse", "dsm", "ssm"] { compare_baseline(&config, format!("user.{key}"), root.join(format!("expected.{key}"))); @@ -30,7 +31,7 @@ fn globs() -> crate::Result { } #[test] -fn cycle_breaker() -> crate::Result { +fn cycle_breaker() -> Result { for name in ["cycle-breaker-direct", "cycle-breaker-indirect"] { let (_config, _root) = config_with_includes(name)?; } @@ -39,7 +40,7 @@ fn cycle_breaker() -> crate::Result { } #[test] -fn no_cycle() -> crate::Result { +fn no_cycle() -> Result { let (config, root) = config_with_includes("no-cycle")?; compare_baseline(&config, "user.name", root.join("expected")); Ok(()) @@ -59,7 +60,7 @@ fn compare_baseline(config: &gix_config::File, key: impl AsRef, expected: i ); } -fn config_with_includes(name: &str) -> crate::Result<(gix_config::File, PathBuf)> { +fn config_with_includes(name: &str) -> Result<(gix_config::File, PathBuf)> { let root = crate::scripted_fixture_read_only("hasconfig.sh")?.join(name); let options = init::Options { includes: includes::Options::follow(Default::default(), Default::default()), diff --git a/gix-config/tests/config/file/init/from_paths/includes/conditional/mod.rs b/gix-config/tests/config/file/init/from_paths/includes/conditional/mod.rs index a21beb98d18..4b63be11267 100644 --- a/gix-config/tests/config/file/init/from_paths/includes/conditional/mod.rs +++ b/gix-config/tests/config/file/init/from_paths/includes/conditional/mod.rs @@ -1,3 +1,4 @@ +use crate::Result; use std::{fs, path::Path, str::FromStr}; use gix_config::{ @@ -14,7 +15,7 @@ mod hasconfig; mod onbranch; #[test] -fn include_and_includeif_correct_inclusion_order_and_delayed_resolve_include() -> crate::Result { +fn include_and_includeif_correct_inclusion_order_and_delayed_resolve_include() -> Result { let dir = tempdir()?; let config_path = dir.path().join("root"); let first_include_path = dir.path().join("first-incl"); @@ -139,7 +140,7 @@ fn options_with_git_dir(git_dir: &Path) -> init::Options<'_> { } } -fn git_init(dir: impl AsRef, bare: bool) -> crate::Result { +fn git_init(dir: impl AsRef, bare: bool) -> Result { let dir = dir.as_ref(); let mut args = vec!["init"]; if bare { diff --git a/gix-config/tests/config/file/init/from_paths/includes/conditional/onbranch.rs b/gix-config/tests/config/file/init/from_paths/includes/conditional/onbranch.rs index 3c3fb09a169..694e822aee0 100644 --- a/gix-config/tests/config/file/init/from_paths/includes/conditional/onbranch.rs +++ b/gix-config/tests/config/file/init/from_paths/includes/conditional/onbranch.rs @@ -1,3 +1,4 @@ +use crate::Result; use std::fs; use bstr::{BString, ByteSlice}; @@ -11,8 +12,6 @@ use gix_testtools::tempfile::tempdir; use crate::file::{bstring, init::from_paths::includes::conditional::git_init}; -type Result = crate::Result; - #[test] fn literal_branch_names_match() -> Result { assert_section_value( @@ -178,7 +177,7 @@ struct GitEnv { } impl GitEnv { - fn new() -> crate::Result { + fn new() -> Result { let dir = tempdir()?; git_init(dir.path(), true)?; Ok(GitEnv { dir }) @@ -191,7 +190,7 @@ struct Options<'a> { expect: Value, } -fn assert_section_value(opts: Options, env: &mut GitEnv) -> crate::Result { +fn assert_section_value(opts: Options, env: &mut GitEnv) -> Result { assert_section_value_msg(opts, env, None) } @@ -203,7 +202,7 @@ fn assert_section_value_msg( }: Options, GitEnv { dir }: &mut GitEnv, message: Option<&str>, -) -> crate::Result<()> { +) -> Result<()> { let root_config = dir.path().join("config"); let included_config = dir.path().join("include.config"); @@ -274,7 +273,7 @@ value = branch-override-by-include Ok(()) } -fn assure_git_agrees(expected: Value, dir: &mut gix_testtools::tempfile::TempDir) -> crate::Result { +fn assure_git_agrees(expected: Value, dir: &mut gix_testtools::tempfile::TempDir) -> Result { let git_dir = dir.path(); let output = gix_testtools::git_command(git_dir) .args(["config", "--get", "section.value"]) diff --git a/gix-config/tests/config/file/init/from_paths/includes/unconditional.rs b/gix-config/tests/config/file/init/from_paths/includes/unconditional.rs index 2a1697b5350..7a75811b9bb 100644 --- a/gix-config/tests/config/file/init/from_paths/includes/unconditional.rs +++ b/gix-config/tests/config/file/init/from_paths/includes/unconditional.rs @@ -1,8 +1,9 @@ +use crate::Result; use std::fs; use gix_config::{ File, - file::{includes, init, init::from_paths}, + file::{includes, init}, }; use gix_testtools::tempfile::tempdir; @@ -18,8 +19,16 @@ fn follow_options() -> init::Options<'static> { } } +fn assert_include_depth(err: gix_error::Exn) -> gix_error::Exn { + assert!( + err.is_validation(), + "exceeding the include depth is invalid configuration" + ); + err +} + #[test] -fn multiple() -> crate::Result { +fn multiple() -> Result { let dir = tempdir()?; let a_path = dir.path().join("a"); @@ -77,16 +86,16 @@ fn multiple() -> crate::Result { let config = File::from_paths_metadata(into_meta(vec![c_path]), follow_options())?.expect("non-empty"); assert_eq!(config.string_by("core", None, "c"), Some(bstring("12"))); - assert_eq!(config.integer_by("core", None, "d"), Ok(Some(41))); - assert_eq!(config.boolean_by("http", None, "sslVerify"), Ok(Some(false))); - assert_eq!(config.boolean_by("diff", None, "renames"), Ok(Some(true))); - assert_eq!(config.boolean_by("core", None, "a"), Ok(Some(false))); + assert_eq!(config.integer_by("core", None, "d")?, Some(41)); + assert_eq!(config.boolean_by("http", None, "sslVerify")?, Some(false)); + assert_eq!(config.boolean_by("diff", None, "renames")?, Some(true)); + assert_eq!(config.boolean_by("core", None, "a")?, Some(false)); Ok(()) } #[test] -fn respect_max_depth() -> crate::Result { +fn respect_max_depth() -> Result { let dir = tempdir()?; // 0 includes 1 - base level @@ -121,8 +130,8 @@ fn respect_max_depth() -> crate::Result { let config = File::from_paths_metadata(into_meta(vec![dir.path().join("0")]), follow_options())?.expect("non-empty"); - assert_eq!(config.integers_by("core", None, "i"), Ok(Some(vec![0, 1, 2, 3, 4]))); - assert_eq!(config.integers("core.i"), Ok(Some(vec![0, 1, 2, 3, 4]))); + assert_eq!(config.integers_by("core", None, "i")?, Some(vec![0, 1, 2, 3, 4])); + assert_eq!(config.integers("core.i")?, Some(vec![0, 1, 2, 3, 4])); fn make_options(max_depth: u8, error_on_max_depth_exceeded: bool) -> init::Options<'static> { init::Options { @@ -139,8 +148,8 @@ fn respect_max_depth() -> crate::Result { // this is equivalent to running git with --no-includes option let options = make_options(1, false); let config = File::from_paths_metadata(into_meta(vec![dir.path().join("0")]), options)?.expect("non-empty"); - assert_eq!(config.integer_by("core", None, "i"), Ok(Some(1))); - assert_eq!(config.integer("core.i"), Ok(Some(1))); + assert_eq!(config.integer_by("core", None, "i")?, Some(1)); + assert_eq!(config.integer("core.i")?, Some(1)); // with default max_allowed_depth of 10 and 4 levels of includes, last level is read let options = init::Options { @@ -148,42 +157,44 @@ fn respect_max_depth() -> crate::Result { ..Default::default() }; let config = File::from_paths_metadata(into_meta(vec![dir.path().join("0")]), options)?.expect("non-empty"); - assert_eq!(config.integer_by("core", None, "i"), Ok(Some(4))); + assert_eq!(config.integer_by("core", None, "i")?, Some(4)); // with max_allowed_depth of 5, the base and 4 levels of includes, last level is read let options = make_options(5, false); let config = File::from_paths_metadata(into_meta(vec![dir.path().join("0")]), options)?.expect("non-empty"); - assert_eq!(config.integer_by("core", None, "i"), Ok(Some(4))); + assert_eq!(config.integer_by("core", None, "i")?, Some(4)); // with max_allowed_depth of 2 and 4 levels of includes, max_allowed_depth is exceeded and error is returned let options = make_options(2, true); let config = File::from_paths_metadata(into_meta(vec![dir.path().join("0")]), options); - assert!(matches!( - config.unwrap_err(), - from_paths::Error::Init(init::Error::Includes(includes::Error::IncludeDepthExceeded { - max_depth: 2 - })) - )); + insta::assert_debug_snapshot!(assert_include_depth(config.expect_err("the configured include depth must be enforced")), "include-depth limits report the configured maximum", @" + Could not initialize configuration from a path + | + └─ Could not resolve configuration includes + | + └─ The maximum allowed length 2 of the file include chain built by following nested resolve_includes is exceeded + "); // with max_allowed_depth of 2 and 4 levels of includes and error_on_max_depth_exceeded: false , max_allowed_depth is exceeded and the value of level 2 is returned let options = make_options(2, false); let config = File::from_paths_metadata(into_meta(vec![dir.path().join("0")]), options)?.expect("non-empty"); - assert_eq!(config.integer_by("core", None, "i"), Ok(Some(2))); + assert_eq!(config.integer_by("core", None, "i")?, Some(2)); // with max_allowed_depth of 0 and 4 levels of includes, max_allowed_depth is exceeded and error is returned let options = make_options(0, true); let config = File::from_paths_metadata(into_meta(vec![dir.path().join("0")]), options); - assert!(matches!( - config.unwrap_err(), - from_paths::Error::Init(init::Error::Includes(includes::Error::IncludeDepthExceeded { - max_depth: 0 - })) - )); + insta::assert_debug_snapshot!(assert_include_depth(config.expect_err("the configured include depth must be enforced")), "include-depth limits report the configured maximum", @" + Could not initialize configuration from a path + | + └─ Could not resolve configuration includes + | + └─ The maximum allowed length 0 of the file include chain built by following nested resolve_includes is exceeded + "); Ok(()) } #[test] -fn simple() -> crate::Result { +fn simple() -> Result { let dir = tempdir()?; let a_path = dir.path().join("a"); @@ -214,12 +225,12 @@ fn simple() -> crate::Result { )?; let config = File::from_paths_metadata(into_meta(vec![a_path]), follow_options())?.expect("non-empty"); - assert_eq!(config.boolean_by("core", None, "b"), Ok(Some(false))); + assert_eq!(config.boolean_by("core", None, "b")?, Some(false)); Ok(()) } #[test] -fn cycle_detection() -> crate::Result { +fn cycle_detection() -> Result { let dir = tempdir()?; let a_path = dir.path().join("a"); @@ -258,12 +269,13 @@ fn cycle_detection() -> crate::Result { ..Default::default() }; let config = File::from_paths_metadata(into_meta(vec![a_path.clone()]), options); - assert!(matches!( - config.unwrap_err(), - from_paths::Error::Init(init::Error::Includes(includes::Error::IncludeDepthExceeded { - max_depth: 4 - })) - )); + insta::assert_debug_snapshot!(assert_include_depth(config.expect_err("the configured include depth must be enforced")), "include-depth limits report the configured maximum", @" + Could not initialize configuration from a path + | + └─ Could not resolve configuration includes + | + └─ The maximum allowed length 4 of the file include chain built by following nested resolve_includes is exceeded + "); let options = init::Options { includes: includes::Options { @@ -274,12 +286,12 @@ fn cycle_detection() -> crate::Result { ..Default::default() }; let config = File::from_paths_metadata(into_meta(vec![a_path]), options)?.expect("non-empty"); - assert_eq!(config.integers_by("core", None, "b"), Ok(Some(vec![0, 1, 0, 1, 0]))); + assert_eq!(config.integers_by("core", None, "b")?, Some(vec![0, 1, 0, 1, 0])); Ok(()) } #[test] -fn nested() -> crate::Result { +fn nested() -> Result { let dir = tempdir()?; let a_path = dir.path().join("a"); @@ -319,8 +331,8 @@ fn nested() -> crate::Result { let config = File::from_paths_metadata(into_meta(vec![c_path]), follow_options())?.expect("non-empty"); - assert_eq!(config.integer_by("core", None, "c"), Ok(Some(1))); - assert_eq!(config.boolean_by("core", None, "b"), Ok(Some(true))); - assert_eq!(config.boolean_by("core", None, "a"), Ok(Some(false))); + assert_eq!(config.integer_by("core", None, "c")?, Some(1)); + assert_eq!(config.boolean_by("core", None, "b")?, Some(true)); + assert_eq!(config.boolean_by("core", None, "a")?, Some(false)); Ok(()) } diff --git a/gix-config/tests/config/file/init/from_paths/mod.rs b/gix-config/tests/config/file/init/from_paths/mod.rs index 9cdf3526db7..9f8eea45fce 100644 --- a/gix-config/tests/config/file/init/from_paths/mod.rs +++ b/gix-config/tests/config/file/init/from_paths/mod.rs @@ -1,3 +1,4 @@ +use crate::Result; use std::{fs, path::PathBuf}; use gix_config::{File, Source}; @@ -17,8 +18,25 @@ mod from_path_no_includes { let config_path = dir.path().join("config"); let err = gix_config::File::from_path_no_includes(config_path, gix_config::Source::Local).unwrap_err(); - assert!( - matches!(err, gix_config::file::init::from_paths::Error::Io{source: io_error, ..} if io_error.kind() == std::io::ErrorKind::NotFound) + #[cfg(not(windows))] + insta::assert_debug_snapshot!(gix_testtools::redact_debug_snapshot(&(err), &[(&(dir.path()).to_string_lossy(), "")]), "file not found", @r#" + The configuration file at "/config" could not be inspected + | + └─ NotFound + "#); + #[cfg(windows)] + insta::assert_debug_snapshot!(gix_testtools::redact_debug_snapshot(&(err), &[(&(dir.path()).to_string_lossy(), "")]), "file not found", @r#" + The configuration file at "/config" could not be inspected + | + └─ I/O error (NotFound) + | + └─ "/config" does not exist. + "#); + assert_eq!( + err.downcast_any_ref::() + .expect("the I/O source is retained") + .kind(), + std::io::ErrorKind::NotFound ); } @@ -36,7 +54,7 @@ mod from_path_no_includes { } #[test] -fn multiple_paths_single_value() -> crate::Result { +fn multiple_paths_single_value() -> Result { let dir = tempdir()?; let a_path = dir.path().join("a"); @@ -54,9 +72,9 @@ fn multiple_paths_single_value() -> crate::Result { let paths = vec![a_path, b_path, c_path, d_path]; let config = File::from_paths_metadata(into_meta(paths), Default::default())?.expect("non-empty"); - assert_eq!(config.boolean("core.a"), Ok(Some(false))); - assert_eq!(config.boolean("core.b"), Ok(Some(true))); - assert_eq!(config.boolean("core.c"), Ok(Some(true))); + assert_eq!(config.boolean("core.a")?, Some(false)); + assert_eq!(config.boolean("core.b")?, Some(true)); + assert_eq!(config.boolean("core.c")?, Some(true)); assert_eq!(config.num_values(), 4); assert_eq!(config.sections().count(), 4, "each value is in a dedicated section"); @@ -64,7 +82,7 @@ fn multiple_paths_single_value() -> crate::Result { } #[test] -fn frontmatter_is_maintained_in_multiple_files() -> crate::Result { +fn frontmatter_is_maintained_in_multiple_files() -> Result { let dir = tempdir()?; let a_path = dir.path().join("a"); @@ -125,7 +143,7 @@ fn frontmatter_is_maintained_in_multiple_files() -> crate::Result { } #[test] -fn multiple_paths_multi_value_and_filter() -> crate::Result { +fn multiple_paths_multi_value_and_filter() -> Result { let dir = tempdir()?; let a_path = dir.path().join("a"); diff --git a/gix-config/tests/config/file/init/from_str.rs b/gix-config/tests/config/file/init/from_str.rs index da15368d7ab..6ceb2464e22 100644 --- a/gix-config/tests/config/file/init/from_str.rs +++ b/gix-config/tests/config/file/init/from_str.rs @@ -1,5 +1,7 @@ +use crate::Result; + #[test] -fn empty_yields_default_file() -> crate::Result { +fn empty_yields_default_file() -> Result { let a: gix_config::File = "".parse()?; assert_eq!(a, gix_config::File::default()); assert_eq!(a.to_string(), ""); @@ -7,7 +9,7 @@ fn empty_yields_default_file() -> crate::Result { } #[test] -fn whitespace_without_section_contains_front_matter() -> crate::Result { +fn whitespace_without_section_contains_front_matter() -> Result { let input = " \t"; let a: gix_config::File = input.parse()?; assert_eq!(a.to_string(), input); diff --git a/gix-config/tests/config/file/mod.rs b/gix-config/tests/config/file/mod.rs index 47ca46a0df8..1c017039269 100644 --- a/gix-config/tests/config/file/mod.rs +++ b/gix-config/tests/config/file/mod.rs @@ -1,3 +1,4 @@ +use crate::Result; use std::path::PathBuf; use bstr::BString; @@ -73,7 +74,7 @@ fn fuzzed_stackoverflow() { } #[test] -fn fuzzed_long_runtime() -> crate::Result { +fn fuzzed_long_runtime() -> Result { let config = std::fs::read(fixture_path("fuzzed/long-parsetime.config"))?; let file = File::from_bytes_no_includes(&config, gix_config::file::Metadata::default(), Default::default())?; assert_eq!(file.sections().count(), 52); diff --git a/gix-config/tests/config/file/mutable/multi_value.rs b/gix-config/tests/config/file/mutable/multi_value.rs index 6a9cbc2fd48..e221602e87f 100644 --- a/gix-config/tests/config/file/mutable/multi_value.rs +++ b/gix-config/tests/config/file/mutable/multi_value.rs @@ -1,8 +1,9 @@ mod get { + use crate::Result; use crate::file::{bstring, mutable::multi_value::init_config}; #[test] - fn single_lines() -> crate::Result { + fn single_lines() -> Result { let mut config = init_config(); let value = config.raw_values_mut_by("core", None, "a")?; @@ -11,7 +12,7 @@ mod get { } #[test] - fn multi_line() -> crate::Result { + fn multi_line() -> Result { let mut config: gix_config::File = r#"[core] a=b\ "100" @@ -36,7 +37,7 @@ c } #[test] - fn value_names_are_case_insensitive() -> crate::Result { + fn value_names_are_case_insensitive() -> Result { let mut config: gix_config::File = "[core]\nMixedCase = one\nMIXEDCASE = two".parse()?; assert_eq!( config.raw_values_mut_by("core", None, "mixedcase")?.get()?, @@ -47,10 +48,11 @@ c } mod access { + use crate::Result; use crate::file::mutable::multi_value::init_config; #[test] - fn non_empty_sizes() -> crate::Result { + fn non_empty_sizes() -> Result { let mut config = init_config(); assert_eq!(config.raw_values_mut_by("core", None, "a")?.len(), 3); assert!(!config.raw_values_mut_by("core", None, "a")?.is_empty()); @@ -59,10 +61,11 @@ mod access { } mod set { + use crate::Result; use crate::file::{bstring, mutable::multi_value::init_config}; #[test] - fn values_are_escaped() -> crate::Result { + fn values_are_escaped() -> Result { for value in ["a b", " a b", "a b\t", ";c", "#c", "a\nb\n\tc"] { let mut config = init_config(); let mut values = config.raw_values_mut_by("core", None, "a")?; @@ -80,7 +83,7 @@ mod set { } #[test] - fn single_at_start() -> crate::Result { + fn single_at_start() -> Result { let mut config = init_config(); let mut values = config.raw_values_mut_by("core", None, "a")?; values.set_string_at(0, "Hello")?; @@ -92,7 +95,7 @@ mod set { } #[test] - fn single_at_end() -> crate::Result { + fn single_at_end() -> Result { let mut config = init_config(); let mut values = config.raw_values_mut_by("core", None, "a")?; values.set_string_at(2, "Hello")?; @@ -104,7 +107,7 @@ mod set { } #[test] - fn all() -> crate::Result { + fn all() -> Result { let mut config = init_config(); let mut values = config.raw_values_mut_by("core", None, "a")?; values.set_all("Hello")?; @@ -116,7 +119,7 @@ mod set { } #[test] - fn all_empty() -> crate::Result { + fn all_empty() -> Result { let mut config = init_config(); let mut values = config.raw_values_mut_by("core", None, "a")?; values.set_all("")?; @@ -129,10 +132,11 @@ mod set { } mod delete { + use crate::Result; use crate::file::mutable::multi_value::init_config; #[test] - fn single_at_start_and_end() -> crate::Result { + fn single_at_start_and_end() -> Result { let mut config = init_config(); { let mut values = config.raw_values_mut_by("core", None, "a")?; @@ -150,7 +154,7 @@ mod delete { } #[test] - fn all() -> crate::Result { + fn all() -> Result { let mut config = init_config(); let mut values = config.raw_values_mut_by("core", None, "a")?; values.delete_all(); diff --git a/gix-config/tests/config/file/mutable/section.rs b/gix-config/tests/config/file/mutable/section.rs index 9d0b971e4e0..2f461be38ac 100644 --- a/gix-config/tests/config/file/mutable/section.rs +++ b/gix-config/tests/config/file/mutable/section.rs @@ -1,3 +1,5 @@ +use crate::Result; + #[test] fn section_mut_must_exist_as_section_is_not_created_automatically() { let mut config = multi_value_section(); @@ -5,7 +7,7 @@ fn section_mut_must_exist_as_section_is_not_created_automatically() { } #[test] -fn section_mut_or_create_new_is_infallible() -> crate::Result { +fn section_mut_or_create_new_is_infallible() -> Result { let mut config = multi_value_section(); let section = config.section_mut_or_create_new("name", "subsection")?; assert_eq!(section.header().name(), "name"); @@ -14,7 +16,7 @@ fn section_mut_or_create_new_is_infallible() -> crate::Result { } #[test] -fn section_mut_or_create_new_filter_may_reject_existing_sections() -> crate::Result { +fn section_mut_or_create_new_filter_may_reject_existing_sections() -> Result { let mut config = multi_value_section(); let section = config.section_mut_or_create_new_filter("a", None, |_| false)?; assert_eq!(section.header().name(), "a"); @@ -38,10 +40,11 @@ fn section_mut_by_id() { } mod rename { + use crate::Result; use bstr::ByteSlice; #[test] - fn detached_sections_can_be_renamed() -> crate::Result { + fn detached_sections_can_be_renamed() -> Result { let mut section = gix_config::file::Section::new("remote", "origin", gix_config::file::Metadata::default())?; section.to_mut().rename("branch", "main")?; @@ -52,7 +55,7 @@ mod rename { } #[test] - fn attached_sections_are_renamed_unambiguously_and_update_lookups() -> crate::Result { + fn attached_sections_are_renamed_unambiguously_and_update_lookups() -> Result { let mut file = gix_config::File::try_from( "[target \"same\"] key = first\n\ [source \"old\"] key = selected\n\ @@ -90,7 +93,7 @@ mod rename { } #[test] - fn invalid_names_leave_attached_sections_unchanged() -> crate::Result { + fn invalid_names_leave_attached_sections_unchanged() -> Result { let mut file = gix_config::File::try_from("[core] key = value\n")?; assert!(file.section_mut("core", None)?.rename("not_valid", None).is_err()); assert_eq!( @@ -108,9 +111,10 @@ mod rename { mod remove { use super::multi_value_section; + use crate::Result; #[test] - fn all() -> crate::Result { + fn all() -> Result { let mut config = multi_value_section(); let mut section = config.section_mut("a", None)?; @@ -134,9 +138,10 @@ mod remove { mod pop { use super::multi_value_section; + use crate::Result; #[test] - fn all() -> crate::Result { + fn all() -> Result { let mut config = multi_value_section(); let mut section = config.section_mut_by_key("a")?; @@ -160,9 +165,10 @@ mod pop { mod set { use super::multi_value_section; + use crate::Result; #[test] - fn various_escapes_onto_various_kinds_of_values() -> crate::Result { + fn various_escapes_onto_various_kinds_of_values() -> Result { let mut config = multi_value_section(); let mut section = config.section_mut("a", None)?; let values = vec!["", " a", "b\t", "; comment", "a\n\tc d\\ \"x\""]; @@ -189,31 +195,49 @@ mod set { } mod value_name_validation { - use gix_config::file::section::value; + use crate::Result; #[test] - fn mutations_validate_names_and_leave_the_section_unchanged_on_error() -> crate::Result { + fn mutations_validate_names_and_leave_the_section_unchanged_on_error() -> Result { let mut config = gix_config::File::default(); let mut section = config.new_section("core", None)?; - assert!(matches!( - section.push("not.valid", Some("value".into())), - Err(value::Error::ValueName(_)) - )); - assert!(matches!( - section.push_with_comment("1invalid", Some("value".into()), "comment"), - Err(value::Error::ValueName(_)) - )); - assert!(matches!( - section.set("also invalid", "value"), - Err(value::Error::ValueName(_)) - )); + let err: gix_error::Message = section + .push("not.valid", Some("value".into())) + .unwrap_err() + .into_inner(); + insta::assert_debug_snapshot!(err, "mutations validate names and leave the section unchanged on error", @r#" + Message { + message: "Valid value names consist of alphanumeric characters or dashes, starting with an alphabetic character.", + class: Validation, + values: {"input": Bytes("not.valid")}, + } + "#); + let err: gix_error::Message = section + .push_with_comment("1invalid", Some("value".into()), "comment") + .unwrap_err() + .into_inner(); + insta::assert_debug_snapshot!(err, "mutations validate names and leave the section unchanged on error", @r#" + Message { + message: "Valid value names consist of alphanumeric characters or dashes, starting with an alphabetic character.", + class: Validation, + values: {"input": Bytes("1invalid")}, + } + "#); + let err: gix_error::Message = section.set("also invalid", "value").unwrap_err().into_inner(); + insta::assert_debug_snapshot!(err, "mutations validate names and leave the section unchanged on error", @r#" + Message { + message: "Valid value names consist of alphanumeric characters or dashes, starting with an alphabetic character.", + class: Validation, + values: {"input": Bytes("also invalid")}, + } + "#); assert_eq!(section.num_values(), 0, "validation happens before mutation"); Ok(()) } #[test] - fn names_returned_by_public_apis_are_strings() -> crate::Result { + fn names_returned_by_public_apis_are_strings() -> Result { let mut config = super::multi_value_section(); let mut section = config.section_mut("a", None)?; let names: Vec = section.value_names().collect(); @@ -226,10 +250,11 @@ mod value_name_validation { } mod push { + use crate::Result; use crate::file::bstring; #[test] - fn none_as_value_omits_the_key_value_separator() -> crate::Result { + fn none_as_value_omits_the_key_value_separator() -> Result { let mut file = gix_config::File::default(); let mut section = file.section_mut_or_create_new("a", "sub")?; section.push("key", None)?; @@ -245,7 +270,7 @@ mod push { } #[test] - fn whitespace_is_derived_from_whitespace_before_first_value() -> crate::Result { + fn whitespace_is_derived_from_whitespace_before_first_value() -> Result { for (input, expected_pre_key, expected_sep) in [ ("[a]\n\t\tb=c", Some("\t\t".into()), (None, None)), ("[a]\nb= c", None, (None, Some(" "))), @@ -333,8 +358,10 @@ mod push_with_comment { } mod set_leading_whitespace { + use crate::Result; + #[test] - fn any_whitespace_is_ok() -> crate::Result { + fn any_whitespace_is_ok() -> Result { let mut config = gix_config::File::default(); let mut section = config.new_section("core", None)?; diff --git a/gix-config/tests/config/file/mutable/value.rs b/gix-config/tests/config/file/mutable/value.rs index d2668dc5a0c..8b6569c1b29 100644 --- a/gix-config/tests/config/file/mutable/value.rs +++ b/gix-config/tests/config/file/mutable/value.rs @@ -1,4 +1,5 @@ mod get { + use crate::Result; use bstr::BString; use crate::file::mutable::value::init_config; @@ -29,7 +30,7 @@ mod get { } #[test] - fn value_is_correct() -> crate::Result { + fn value_is_correct() -> Result { let mut config = init_config(); let value = config.raw_value_mut_by("core", None, "a")?; @@ -38,7 +39,7 @@ mod get { } #[test] - fn value_names_are_case_insensitive() -> crate::Result { + fn value_names_are_case_insensitive() -> Result { let mut config: gix_config::File = "[core]\nMixedCase = value".parse()?; assert_eq!(config.raw_value_mut_by("core", None, "mIxEdCaSe")?.get()?, "value"); Ok(()) @@ -46,6 +47,7 @@ mod get { } mod set_string { + use crate::Result; use crate::file::mutable::value::init_config; fn assert_set_string(expected: &str) { @@ -125,7 +127,7 @@ mod set_string { } #[test] - fn unquoted_comments_end_continued_values_and_survive_replacement() -> crate::Result { + fn unquoted_comments_end_continued_values_and_survive_replacement() -> Result { for newline in ["\n", "\r\n"] { for comment in ["# comment", "; comment"] { let mut config: gix_config::File = @@ -144,7 +146,7 @@ mod set_string { } #[test] - fn quoted_comment_markers_in_continued_values_are_value_content() -> crate::Result { + fn quoted_comment_markers_in_continued_values_are_value_content() -> Result { let mut config: gix_config::File = r#"[a] k="one\ #not;comments" @@ -166,7 +168,7 @@ next=value"# } #[test] - fn simple_value_and_empty_string() -> crate::Result { + fn simple_value_and_empty_string() -> Result { let mut config = init_config(); let mut value = config.raw_value_mut_by("core", None, "a")?; @@ -198,9 +200,10 @@ next=value"# mod delete { use super::init_config; + use crate::Result; #[test] - fn single_line_value() -> crate::Result { + fn single_line_value() -> Result { let mut config = init_config(); let mut value = config.raw_value_mut_by("core", None, "a")?; @@ -220,7 +223,7 @@ mod delete { } #[test] - fn get_value_after_deleted() -> crate::Result { + fn get_value_after_deleted() -> Result { let mut config = init_config(); let mut value = config.raw_value_mut_by("core", None, "a")?; @@ -230,7 +233,7 @@ mod delete { } #[test] - fn set_string_after_deleted() -> crate::Result { + fn set_string_after_deleted() -> Result { let mut config = init_config(); let mut value = config.raw_value_mut_by("core", None, "a")?; @@ -249,7 +252,7 @@ mod delete { } #[test] - fn idempotency() -> crate::Result { + fn idempotency() -> Result { let mut config = init_config(); let mut value = config.raw_value_mut_by("core", None, "a")?; @@ -264,7 +267,7 @@ mod delete { } #[test] - fn multi_line_value() -> crate::Result { + fn multi_line_value() -> Result { let mut config: gix_config::File = r#"[core] a=b"100"\ c\ diff --git a/gix-config/tests/config/file/resolve_includes.rs b/gix-config/tests/config/file/resolve_includes.rs index 4493d2e8b69..a91ec08a5c2 100644 --- a/gix-config/tests/config/file/resolve_includes.rs +++ b/gix-config/tests/config/file/resolve_includes.rs @@ -1,7 +1,8 @@ +use crate::Result; use gix_config::{file, file::init}; #[test] -fn missing_includes_are_ignored_by_default() -> crate::Result { +fn missing_includes_are_ignored_by_default() -> Result { let input = r#" [include] path = /etc/absolute/missing.config diff --git a/gix-config/tests/config/file/write.rs b/gix-config/tests/config/file/write.rs index 223ce1089f2..5355c47257b 100644 --- a/gix-config/tests/config/file/write.rs +++ b/gix-config/tests/config/file/write.rs @@ -1,3 +1,4 @@ +use crate::Result; use bstr::ByteVec; use gix_config::file::{Metadata, init}; @@ -77,7 +78,7 @@ fn inserted_newlines_use_each_sections_newline_style() { } #[test] -fn crlf_after_a_comment_is_detected_and_used_for_insertions() -> crate::Result { +fn crlf_after_a_comment_is_detected_and_used_for_insertions() -> Result { let input = "; root\r\n[core]\nkey=value\n"; let mut config = gix_config::File::try_from(input)?; assert_eq!( @@ -163,11 +164,12 @@ fn complex_lossless_roundtrip() { } mod to_filter { + use crate::Result; use bstr::ByteSlice; use gix_config::file::Metadata; #[test] - fn allows_only_selected_sections() -> crate::Result { + fn allows_only_selected_sections() -> Result { let mut config = gix_config::File::new(Metadata::api()); config.set_raw_value_by("a", None, "b", "c")?; diff --git a/gix-config/tests/config/parse/error.rs b/gix-config/tests/config/parse/error.rs index b9ece239e4e..8ea9539984a 100644 --- a/gix-config/tests/config/parse/error.rs +++ b/gix-config/tests/config/parse/error.rs @@ -2,59 +2,50 @@ use crate::parse::Events; #[test] fn line_no_is_one_indexed() { - assert_eq!(Events::from_str("[hello").unwrap_err().line_number(), 1); + let err = Events::from_str("[hello").expect_err("the section header is unterminated"); + assert_eq!(err.line_number(), 1); + insta::assert_debug_snapshot!(format_args!("{err}"), "parser diagnostics use one-based line numbers", @"Got an unexpected token on line 1 while trying to parse a section header: '[hello'"); } #[test] -fn remaining_data_contains_bad_tokens() { - assert_eq!(Events::from_str("[hello").unwrap_err().remaining_data(), b"[hello"); +fn malformed_input_retains_validation_and_bad_tokens() { + use gix_error::ErrorExt; + + let err = Events::from_str("[hello") + .expect_err("the section header is unterminated") + .raise(); + insta::assert_debug_snapshot!(err, "malformed configuration retains the offending input", @"Got an unexpected token on line 1 while trying to parse a section header: '[hello'"); + assert!(err.is_validation(), "malformed configuration is invalid input"); + assert!( + err.probable_cause().is::(), + "the parser error, not its classification marker, is the probable cause" + ); + assert_eq!(err.remaining_data(), b"[hello", "the parser retains the unparsed input"); } #[test] fn to_string_truncates_extra_values() { - assert_eq!( - Events::from_str("[1234567890").unwrap_err().to_string(), - "Got an unexpected token on line 1 while trying to parse a section header: '[123456789' ... (1 characters omitted)" - ); + let err = Events::from_str("[1234567890").expect_err("the section header is unterminated"); + insta::assert_debug_snapshot!(format_args!("{err}"), "long invalid tokens show ten characters and the omitted length", @"Got an unexpected token on line 1 while trying to parse a section header: '[123456789' ... (1 characters omitted)"); } #[test] fn to_string() { - let input = "[a_b]\n c=d"; - assert_eq!( - Events::from_str(input).unwrap_err().to_string(), - "Got an unexpected token on line 1 while trying to parse a section header: '[a_b]\n c=d'", - "underscores in section names aren't allowed and will be rejected by git" - ); - let input = "[core] a=b\\\n cd\n[core]\n\n 4a=3"; - assert_eq!( - Events::from_str(input).unwrap_err().to_string(), - "Got an unexpected token on line 5 while trying to parse a name: '4a=3'" - ); - let input = "[core] a=b\\\n cd\n 4a=3"; - assert_eq!( - Events::from_str(input).unwrap_err().to_string(), - "Got an unexpected token on line 3 while trying to parse a name: '4a=3'" - ); - let input = "[core] a=b\n 4a=3"; - assert_eq!( - Events::from_str(input).unwrap_err().to_string(), - "Got an unexpected token on line 2 while trying to parse a name: '4a=3'" - ); - let input = "[core] a=b\n =3"; - assert_eq!( - Events::from_str(input).unwrap_err().to_string(), - "Got an unexpected token on line 2 while trying to parse a name: '=3'" - ); - let input = "[core"; - assert_eq!( - Events::from_str(input).unwrap_err().to_string(), - "Got an unexpected token on line 1 while trying to parse a section header: '[core'" - ); - let input = "[a]\n\tb \u{8}\n"; - assert_eq!( - Events::from_str(input).unwrap_err().to_string(), - "Got an unexpected token on line 2 while trying to parse a name: '\u{8}\n'", - "Git rejects backspace as trailing whitespace after an implicit boolean" - ); + let err = Events::from_str("[a_b]\n c=d").expect_err("underscores are invalid in section names"); + insta::assert_debug_snapshot!(format_args!("{err}"), "underscores in section names are rejected by Git", @" + Got an unexpected token on line 1 while trying to parse a section header: '[a_b] + c=d' + "); + let err = Events::from_str("[core] a=b\\\n cd\n[core]\n\n 4a=3").expect_err("names cannot start with a digit"); + insta::assert_debug_snapshot!(format_args!("{err}"), "line numbers include continuations, section headers, and blank lines", @"Got an unexpected token on line 5 while trying to parse a name: '4a=3'"); + let err = Events::from_str("[core] a=b\\\n cd\n 4a=3").expect_err("names cannot start with a digit"); + insta::assert_debug_snapshot!(format_args!("{err}"), "line numbers include continued values", @"Got an unexpected token on line 3 while trying to parse a name: '4a=3'"); + let err = Events::from_str("[core] a=b\n 4a=3").expect_err("names cannot start with a digit"); + insta::assert_debug_snapshot!(format_args!("{err}"), "an invalid name is reported with its line and unparsed input", @"Got an unexpected token on line 2 while trying to parse a name: '4a=3'"); + let err = Events::from_str("[core] a=b\n =3").expect_err("names cannot be empty"); + insta::assert_debug_snapshot!(format_args!("{err}"), "a missing name reports the remaining assignment", @"Got an unexpected token on line 2 while trying to parse a name: '=3'"); + let err = Events::from_str("[core").expect_err("the section header is unterminated"); + insta::assert_debug_snapshot!(format_args!("{err}"), "an unterminated header identifies the section parser", @"Got an unexpected token on line 1 while trying to parse a section header: '[core'"); + let err = Events::from_str("[a]\n\tb \u{8}\n").expect_err("backspace is not trailing whitespace"); + insta::assert_debug_snapshot!(format_args!("{err}"), "Git rejects backspace as trailing whitespace after an implicit boolean", @"Got an unexpected token on line 2 while trying to parse a name: '\u{8}\n'"); } diff --git a/gix-config/tests/config/parse/section.rs b/gix-config/tests/config/parse/section.rs index f61d71b97f8..977bb6c550f 100644 --- a/gix-config/tests/config/parse/section.rs +++ b/gix-config/tests/config/parse/section.rs @@ -11,48 +11,68 @@ pub fn header_event(name: &'static str, subsection: impl Into Result { + fn serialized(name: &str, subsection: impl IntoBStringOpt) -> ExnMessageResult { let mut config = gix_config::File::default(); let section = config.new_section(name, subsection.into_bstring_opt())?; Ok(section.header().to_bstring()) } mod write_to { + use crate::Result; use crate::parse::section::header::serialized; #[test] - fn subsection_backslashes_and_quotes_are_escaped() -> crate::Result { + fn subsection_backslashes_and_quotes_are_escaped() -> Result { assert_eq!(serialized("core", r"a\b")?, r#"[core "a\\b"]"#); assert_eq!(serialized("core", r#"a:"b""#)?, r#"[core "a:\"b\""]"#); Ok(()) } #[test] - fn everything_is_allowed() -> crate::Result { + fn everything_is_allowed() -> Result { assert_eq!(serialized("core", "a/b \t\t a\\b")?, "[core \"a/b \t\t a\\\\b\"]"); Ok(()) } } mod new { - use gix_config::parse::section; - use crate::parse::section::header::serialized; #[test] fn names_must_be_mostly_ascii() { + let mut message_diagnostics = Vec::new(); for name in ["🤗", "x.y", "x y", "x\ny"] { - assert_eq!(serialized(name, None), Err(section::header::Error::InvalidName)); + message_diagnostics.push(gix_testtools::redact_debug_snapshot( + &(serialized(name, None).expect_err("name must be rejected")), + &[], + )); } + insta::assert_debug_snapshot!(message_diagnostics, "names must be mostly ascii", @r#" + [ + section names can only be ascii, '-', "input"="🤗", + section names can only be ascii, '-', "input"="x.y", + section names can only be ascii, '-', "input"="x y", + section names can only be ascii, '-', "input"="x\ny", + ] + "#); } #[test] fn subsections_with_newlines_and_null_bytes_are_rejected() { - assert_eq!(serialized("a", "a\nb"), Err(section::header::Error::InvalidSubSection)); - assert_eq!(serialized("a", "a\0b"), Err(section::header::Error::InvalidSubSection)); + let mut message_diagnostics = Vec::new(); + for subsection in ["a\nb", "a\0b"] { + message_diagnostics.push(gix_testtools::redact_debug_snapshot( + &(serialized("a", subsection).expect_err("subsection must be rejected")), + &[], + )); + } + insta::assert_debug_snapshot!(message_diagnostics, "subsections with newlines and null bytes are rejected", @r#" + [ + sub-section names must not contain newlines or null bytes, "input"="a\nb", + sub-section names must not contain newlines or null bytes, "input"="a\0b", + ] + "#); } } } diff --git a/gix-credentials/Cargo.toml b/gix-credentials/Cargo.toml index b001e539398..5a30219d164 100644 --- a/gix-credentials/Cargo.toml +++ b/gix-credentials/Cargo.toml @@ -30,7 +30,6 @@ gix-prompt = { version = "^0.17.0", path = "../gix-prompt" } gix-date = { version = "^0.16.0", path = "../gix-date" } gix-trace = { version = "^0.1.21", path = "../gix-trace" } -thiserror = "2.0.18" serde = { version = "1.0.114", optional = true, default-features = false, features = ["derive"] } bstr = { version = "1.12.0", default-features = false, features = ["std"] } @@ -39,6 +38,7 @@ bstr = { version = "1.12.0", default-features = false, features = ["std"] } document-features = { version = "0.2.1", optional = true } [dev-dependencies] +insta = "1.46.3" gix-sec = { path = "../gix-sec" } gix-testtools = { path = "../tests/tools", features = ["sha1"] } diff --git a/gix-credentials/examples/custom-helper.rs b/gix-credentials/examples/custom-helper.rs index a678b9193c9..ea830f8ed30 100644 --- a/gix-credentials/examples/custom-helper.rs +++ b/gix-credentials/examples/custom-helper.rs @@ -1,22 +1,24 @@ use gix_credentials::{program, protocol}; +use gix_error::ErrorExt; +use gix_error::ExnResult; /// Run like this `echo url=https://example.com | cargo run --example custom-helper -- get` -pub fn main() -> Result<(), gix_credentials::program::main::Error> { +pub fn main() -> ExnResult { gix_credentials::program::main( std::env::args_os().skip(1), std::io::stdin(), std::io::stdout(), protocol::ContextOptions::default(), - |action, context| -> std::io::Result<_> { + |action, context| -> ExnResult<_> { match action { program::main::Action::Get => Ok(Some(protocol::Context { username: Some("user".into()), password: Some("pass".into()), ..context })), - program::main::Action::Erase => Err(std::io::Error::other( - "Refusing to delete credentials for demo purposes", - )), + program::main::Action::Erase => { + Err(gix_error::message("Refusing to delete credentials for demo purposes").raise_erased()) + } program::main::Action::Store => Ok(None), } }, diff --git a/gix-credentials/examples/git-credential-lite.rs b/gix-credentials/examples/git-credential-lite.rs index 8c7c2fb2b7b..c53a7a62c98 100644 --- a/gix-credentials/examples/git-credential-lite.rs +++ b/gix-credentials/examples/git-credential-lite.rs @@ -1,5 +1,6 @@ +use gix_error::ExnResult; /// Run like this `echo url=https://example.com | cargo run --example git-credential-light -- fill` -pub fn main() -> Result<(), gix_credentials::program::main::Error> { +pub fn main() -> ExnResult { gix_credentials::program::main( std::env::args_os().skip(1), std::io::stdin(), diff --git a/gix-credentials/examples/invoke-git-credential.rs b/gix-credentials/examples/invoke-git-credential.rs index 5d4f780f7aa..cc6a14e9fed 100644 --- a/gix-credentials/examples/invoke-git-credential.rs +++ b/gix-credentials/examples/invoke-git-credential.rs @@ -1,5 +1,5 @@ /// Invokes `git credential` with the passed url as argument and prints obtained credentials. -pub fn main() -> Result<(), Box> { +pub fn main() -> Result<(), Box> { let out = gix_credentials::builtin(gix_credentials::helper::Action::get_for_url( std::env::args() .nth(1) diff --git a/gix-credentials/src/helper/cascade.rs b/gix-credentials/src/helper/cascade.rs index 42cb4073354..74126888c59 100644 --- a/gix-credentials/src/helper/cascade.rs +++ b/gix-credentials/src/helper/cascade.rs @@ -4,6 +4,7 @@ use crate::{ protocol, protocol::{Context, ContextOptions}, }; +use gix_error::ResultExt; impl Default for Cascade { fn default() -> Self { @@ -75,14 +76,10 @@ impl Cascade { /// When _getting_ credentials, all programs are asked until the credentials are complete, stopping the cascade. /// When _storing_ or _erasing_ all programs are instructed in order. /// The input context is validated even if no helpers are available. - #[expect( - clippy::result_large_err, - reason = "will be removed once `gix-error` is used consistently" - )] pub fn invoke(&mut self, mut action: helper::Action, mut prompt: gix_prompt::Options) -> protocol::Result { if let Some(ctx) = action.context_mut() { ctx.options = self.context_options; - ctx.write_to(std::io::sink()).map_err(helper::Error::from)?; + ctx.write_to(std::io::sink()).or_erased()?; } let mut url = action .context_mut() @@ -119,7 +116,7 @@ impl Cascade { www_authenticate: _, url: ctx_url, quit, - } = Context::from_bytes(&stdout, self.context_options)?; + } = Context::from_bytes(&stdout, self.context_options).or_erased()?; if let Some(dst_ctx) = action.context_mut() { if let Some(src) = path { dst_ctx.path = Some(src); @@ -158,8 +155,8 @@ impl Cascade { } } } - Err(helper::Error::CredentialsHelperFailed { .. }) => continue, // ignore helpers that we can't call - Err(err) if action.context().is_some() => return Err(err.into()), // communication errors are fatal when getting credentials + Err(err) if err.is_retryable() => continue, + Err(err) if action.context().is_some() => return Err(err), // communication errors are fatal when getting credentials Err(_) => {} // for other actions, ignore everything, try the operation } } @@ -172,20 +169,14 @@ impl Cascade { let message = ctx.to_prompt("Username"); prompt.mode = gix_prompt::Mode::Visible; ctx.username = gix_prompt::ask(&message, &prompt) - .map_err(|err| protocol::Error::Prompt { - prompt: message, - source: err, - })? + .or_raise_erased(|| gix_error::message!("Couldn't obtain {message}"))? .into(); } if ctx.password.is_none() { let message = ctx.to_prompt("Password"); prompt.mode = gix_prompt::Mode::Hidden; ctx.password = gix_prompt::ask(&message, &prompt) - .map_err(|err| protocol::Error::Prompt { - prompt: message, - source: err, - })? + .or_raise_erased(|| gix_error::message!("Couldn't obtain {message}"))? .into(); } } diff --git a/gix-credentials/src/helper/invoke.rs b/gix-credentials/src/helper/invoke.rs index e922af0ad68..1b019709ebf 100644 --- a/gix-credentials/src/helper/invoke.rs +++ b/gix-credentials/src/helper/invoke.rs @@ -1,6 +1,8 @@ use std::io::Read; -use crate::helper::{Action, Context, Error, NextAction, Outcome, Result}; +use gix_error::{Class, ClassificationMarker, ErrorExt, ExnResult, ResultExt, message}; + +use crate::helper::{Action, Context, NextAction, Outcome, Result}; impl Action { /// Send ourselves to the given `write` which is expected to be credentials-helper compatible @@ -27,7 +29,7 @@ pub fn invoke(helper: &mut crate::Program, action: &Action) -> Result { match raw(helper, action)? { None => Ok(None), Some(stdout) => { - let ctx = Context::from_bytes(stdout.as_slice(), options)?; + let ctx = Context::from_bytes(stdout.as_slice(), options).or_erased()?; Ok(Some(Outcome { username: ctx.username, password: ctx.password, @@ -42,12 +44,13 @@ pub fn invoke(helper: &mut crate::Program, action: &Action) -> Result { } } -pub(crate) fn raw(helper: &mut crate::Program, action: &Action) -> std::result::Result>, Error> { - let (mut stdin, stdout) = helper.start(action)?; +pub(crate) fn raw(helper: &mut crate::Program, action: &Action) -> ExnResult>> { + let communication_error = || message("An IO error occurred while communicating to the credentials helper"); + let (mut stdin, stdout) = helper.start(action).or_raise_erased(communication_error)?; if let (Action::Get(_), None) = (&action, &stdout) { panic!("BUG: `Helper` impls must return an output handle to read output from if Action::Get is provided") } - action.send(&mut stdin)?; + action.send(&mut stdin).or_raise_erased(communication_error)?; drop(stdin); let stdout = stdout .map(|mut stdout| { @@ -55,12 +58,12 @@ pub(crate) fn raw(helper: &mut crate::Program, action: &Action) -> std::result:: stdout.read_to_end(&mut buf).map(|_| buf) }) .transpose() - .map_err(|err| Error::CredentialsHelperFailed { source: err })?; + .map_err(|err| ClassificationMarker::with_source(Class::Retryable, err).raise_erased())?; helper.finish().map_err(|err| { if err.kind() == std::io::ErrorKind::Other { - Error::CredentialsHelperFailed { source: err } + ClassificationMarker::with_source(Class::Retryable, err).raise_erased() } else { - err.into() + err.and_raise(communication_error()).erased() } })?; diff --git a/gix-credentials/src/helper/mod.rs b/gix-credentials/src/helper/mod.rs index 49641470606..73c7b0668df 100644 --- a/gix-credentials/src/helper/mod.rs +++ b/gix-credentials/src/helper/mod.rs @@ -1,4 +1,5 @@ use bstr::{BStr, BString}; +use gix_error::{Exn, ExnResult, Message}; use crate::{Program, protocol, protocol::Context}; @@ -54,19 +55,7 @@ impl Outcome { } /// The Result type used in [`invoke()`][crate::helper::invoke()]. -pub type Result = std::result::Result, Error>; - -/// The error used in the [credentials helper invocation][crate::helper::invoke()]. -#[derive(Debug, thiserror::Error)] -#[expect(missing_docs)] -pub enum Error { - #[error(transparent)] - ContextDecode(#[from] protocol::context::decode::Error), - #[error("An IO error occurred while communicating to the credentials helper")] - Io(#[from] std::io::Error), - #[error(transparent)] - CredentialsHelperFailed { source: std::io::Error }, -} +pub type Result = ExnResult>; /// The action to perform by the credentials [helper][`crate::helper::invoke()`]. #[derive(Clone, Debug)] @@ -147,7 +136,7 @@ pub struct NextAction { } impl TryFrom<&NextAction> for Context { - type Error = protocol::context::decode::Error; + type Error = Exn; fn try_from(value: &NextAction) -> std::result::Result { Context::from_bytes(value.previous_output.as_ref(), value.options) diff --git a/gix-credentials/src/lib.rs b/gix-credentials/src/lib.rs index 039bf978189..9500800004b 100644 --- a/gix-credentials/src/lib.rs +++ b/gix-credentials/src/lib.rs @@ -33,10 +33,6 @@ pub mod protocol; /// and does everything `git` typically does. The `action` should have been created with [`helper::Action::get_for_url()`] to /// contain only the URL to kick off the process, or should be created by [`helper::NextAction`]. /// If more control is required, use the [`Cascade`][helper::Cascade] type. -#[expect( - clippy::result_large_err, - reason = "will be removed once `gix-error` is used consistently" -)] pub fn builtin(action: helper::Action) -> protocol::Result { protocol::helper_outcome_to_result( helper::invoke(&mut Program::from_kind(program::Kind::Builtin), &action)?, diff --git a/gix-credentials/src/program/main.rs b/gix-credentials/src/program/main.rs index e7ef34cfc0a..4a99104b81a 100644 --- a/gix-credentials/src/program/main.rs +++ b/gix-credentials/src/program/main.rs @@ -1,6 +1,6 @@ use std::ffi::OsString; -use bstr::BString; +use gix_error::{Message, validation}; /// The action passed to the credential helper implementation in [`main()`][crate::program::main()]. #[derive(Debug, Copy, Clone)] @@ -14,14 +14,21 @@ pub enum Action { } impl TryFrom for Action { - type Error = Error; + type Error = Message; + /// Invalid action bytes are stored as `input` in [`Message::values`]. + /// After [wrapping](gix_error::Error::from_error()), inspect them with [metadata](gix_error::Error::metadata()). fn try_from(value: OsString) -> Result { Ok(match value.to_str() { Some("fill" | "get") => Action::Get, Some("approve" | "store") => Action::Store, Some("reject" | "erase") => Action::Erase, - _ => return Err(Error::ActionInvalid { name: value }), + _ => { + return Err(validation( + "Action is invalid, need 'get', 'store', 'erase' or 'fill', 'approve', 'reject'", + ) + .with("input", value.as_encoded_bytes())); + } }) } } @@ -37,33 +44,13 @@ impl Action { } } -/// The error of [`main()`][crate::program::main()]. -#[derive(Debug, thiserror::Error)] -#[expect(missing_docs)] -pub enum Error { - #[error("Action named {name:?} is invalid, need 'get', 'store', 'erase' or 'fill', 'approve', 'reject'")] - ActionInvalid { name: OsString }, - #[error("The first argument must be the action to perform")] - ActionMissing, - #[error(transparent)] - Helper { - source: Box, - }, - #[error(transparent)] - Io(#[from] std::io::Error), - #[error(transparent)] - Context(#[from] crate::protocol::context::decode::Error), - #[error("Credentials for {url:?} could not be obtained")] - CredentialsMissing { url: BString }, - #[error("Either 'url' field or both 'protocol' and 'host' fields must be provided")] - UrlMissing, -} - pub(crate) mod function { use std::ffi::OsString; + use gix_error::{ErrorExt, ExnResult, ResultExt, validation}; + use crate::{ - program::main::{Action, Error}, + program::main::Action, protocol::{Context, ContextOptions}, }; @@ -75,25 +62,30 @@ pub(crate) mod function { /// /// Call this function from a programs `main`, passing `std::env::args_os()`, `stdin()` and `stdout` accordingly, along with /// the context encoding `options` and your own helper implementation. - pub fn main( + pub fn main( args: impl IntoIterator, mut stdin: impl std::io::Read, stdout: impl std::io::Write, options: ContextOptions, credentials: CredentialsFn, - ) -> Result<(), Error> + ) -> ExnResult where - CredentialsFn: FnOnce(Action, Context) -> Result, E>, - E: std::error::Error + Send + Sync + 'static, + CredentialsFn: FnOnce(Action, Context) -> ExnResult>, { - let action: Action = args.into_iter().next().ok_or(Error::ActionMissing)?.try_into()?; + let action = args + .into_iter() + .next() + .ok_or_else(|| validation("The first argument must be the action to perform").raise_erased())?; + let action = Action::try_from(action).or_erased()?; let mut buf = Vec::::with_capacity(512); - stdin.read_to_end(&mut buf)?; - let ctx = Context::from_bytes(&buf, options)?; + stdin.read_to_end(&mut buf).or_erased()?; + let ctx = Context::from_bytes(&buf, options).or_erased()?; if ctx.url.is_none() && (ctx.protocol.is_none() || ctx.host.is_none()) { - return Err(Error::UrlMissing); + return Err( + validation("Either 'url' field or both 'protocol' and 'host' fields must be provided").raise_erased(), + ); } - let res = credentials(action, ctx.clone()).map_err(|err| Error::Helper { source: Box::new(err) })?; + let res = credentials(action, ctx.clone())?; match (action, res) { (Action::Get, None) => { let ctx_for_error = ctx; @@ -102,11 +94,13 @@ pub(crate) mod function { .clone() .or_else(|| ctx_for_error.to_url()) .expect("URL is available either directly or via protocol+host which we checked for"); - return Err(Error::CredentialsMissing { url }); + return Err( + gix_error::not_found(format!("Credentials for {url:?} could not be obtained")).raise_erased(), + ); } (Action::Get, Some(mut ctx)) => { ctx.options = options; - ctx.write_to(stdout)?; + ctx.write_to(stdout).or_erased()?; } (Action::Erase | Action::Store, None) => {} (Action::Erase | Action::Store, Some(_)) => { diff --git a/gix-credentials/src/protocol/context/mod.rs b/gix-credentials/src/protocol/context/mod.rs index f71d124aa95..dae6e3029fe 100644 --- a/gix-credentials/src/protocol/context/mod.rs +++ b/gix-credentials/src/protocol/context/mod.rs @@ -2,14 +2,6 @@ use bstr::BString; use crate::protocol::{Context, ContextOptions}; -/// Indicates key or values contain errors that can't be encoded. -#[derive(Debug, thiserror::Error)] -#[expect(missing_docs)] -pub enum Error { - #[error("{key:?}={value:?} must not contain null bytes or newlines neither in key nor in value.")] - Encoding { key: String, value: BString }, -} - impl Context { /// Create a context containing `url`, encoded and decoded according to `options`. pub fn from_url(url: impl Into, options: ContextOptions) -> Self { @@ -99,25 +91,24 @@ mod access { mod mutate { use bstr::ByteSlice; + use gix_error::ExnResult; + use gix_error::{OptionExt, ResultExt, validation}; - use crate::{protocol, protocol::Context}; + use crate::protocol::Context; /// In-place mutation impl Context { /// Destructure the url at our `url` field into parts like protocol, host, username and path and store /// them in our respective fields. If `use_http_path` is set, http paths are significant even though /// normally this isn't the case. - #[expect( - clippy::result_large_err, - reason = "will be removed once `gix-error` is used consistently" - )] - pub fn destructure_url_in_place(&mut self, use_http_path: bool) -> Result<&mut Self, protocol::Error> { + pub fn destructure_url_in_place(&mut self, use_http_path: bool) -> ExnResult<&mut Self> { if self.url.is_none() { - self.url = Some(self.to_url().ok_or(protocol::Error::UrlMissing)?); + self.url = Some(self.to_url().ok_or_raise_erased(|| { + validation("Either 'url' field or both 'protocol' and 'host' fields must be provided") + })?); } - let url = gix_url::parse(self.url.as_ref().expect("URL is present after check above")) - .map_err(gix_error::Exn::into_error)?; + let url = gix_url::parse(self.url.as_ref().expect("URL is present after check above")).or_erased()?; self.protocol = Some(url.scheme.as_str().into()); self.username = url.user().map(ToOwned::to_owned); self.password = url.password().map(ToOwned::to_owned); diff --git a/gix-credentials/src/protocol/context/serde.rs b/gix-credentials/src/protocol/context/serde.rs index b66bdf0062f..f543f0816fa 100644 --- a/gix-credentials/src/protocol/context/serde.rs +++ b/gix-credentials/src/protocol/context/serde.rs @@ -1,6 +1,5 @@ use bstr::BStr; - -use crate::protocol::context::Error; +use gix_error::ExnMessageResult; mod write { use bstr::{BStr, BString}; @@ -9,6 +8,9 @@ mod write { impl Context { /// Write ourselves to `out` such that [`from_bytes()`][Self::from_bytes()] can decode it losslessly. + /// Invalid field values are retained as `input` bytes in the I/O error. + /// After [wrapping](gix_error::Error::from_error()), inspect them with + /// [metadata](gix_error::Error::metadata()). pub fn write_to(&self, mut out: impl std::io::Write) -> std::io::Result<()> { use bstr::ByteSlice; fn write_key(out: &mut impl std::io::Write, key: &str, value: &BStr) -> std::io::Result<()> { @@ -75,26 +77,19 @@ mod write { /// pub mod decode { - use bstr::{BString, ByteSlice}; - - use crate::protocol::{Context, ContextOptions, context, context::serde::validate}; + use bstr::ByteSlice; + use gix_error::ExnMessageResult; + use gix_error::validation; - /// The error returned by [`from_bytes()`][Context::from_bytes()]. - #[derive(Debug, thiserror::Error)] - #[expect(missing_docs)] - pub enum Error { - #[error("Illformed UTF-8 in value of key {key:?}: {value:?}")] - IllformedUtf8InValue { key: String, value: BString }, - #[error(transparent)] - Encoding(#[from] context::Error), - #[error("Invalid format in line {line:?}, expecting key=value")] - Syntax { line: BString }, - } + use crate::protocol::{Context, ContextOptions, context::serde::validate}; impl Context { /// Decode ourselves from `input` which is the format written by [`write_to()`][Self::write_to()]. /// `options` control what to support during deserialization. - pub fn from_bytes(input: &[u8], options: ContextOptions) -> Result { + /// Invalid line or value bytes are stored as `input` in [`gix_error::Message::values`]. + /// After [wrapping](gix_error::Error::from_error()), inspect them with + /// [metadata](gix_error::Error::metadata()). + pub fn from_bytes(input: &[u8], options: ContextOptions) -> ExnMessageResult { let mut ctx = Context { options, ..Context::default() @@ -118,17 +113,21 @@ pub mod decode { it.next().and_then(|k| k.to_str().ok()), it.next().map(ByteSlice::as_bstr), ) { - (Some(key), Some(value)) => validate(key, value, options.protect_protocol) - .map(|_| (key, value.to_owned())) - .map_err(Into::into), - _ => Err(Error::Syntax { line: line.into() }), + (Some(key), Some(value)) => { + validate(key, value, options.protect_protocol).map(|_| (key, value.to_owned())) + } + _ => Err(validation("Invalid format, expecting key=value") + .with("input", line) + .into()), } }) { let (key, value) = res?; match key { "protocol" | "host" | "username" | "password" | "oauth_refresh_token" => { if !value.is_utf8() { - return Err(Error::IllformedUtf8InValue { key: key.into(), value }); + return Err(validation(format!("Illformed UTF-8 in value of key {key:?}")) + .with("input", value) + .into()); } let value = value.to_string(); *match key { @@ -159,7 +158,7 @@ pub mod decode { } } -fn validate(key: &str, value: &BStr, protect_protocol: bool) -> Result<(), Error> { +fn validate(key: &str, value: &BStr, protect_protocol: bool) -> ExnMessageResult { if key.contains('\0') || key.contains('\n') || key.contains('\r') @@ -167,10 +166,11 @@ fn validate(key: &str, value: &BStr, protect_protocol: bool) -> Result<(), Error || value.contains(&b'\n') || (protect_protocol && value.contains(&b'\r')) { - return Err(Error::Encoding { - key: key.to_owned(), - value: value.to_owned(), - }); + return Err(gix_error::validation(format!( + "{key:?}={value:?} must not contain null bytes or newlines neither in key nor in value." + )) + .with("input", value) + .into()); } Ok(()) } diff --git a/gix-credentials/src/protocol/mod.rs b/gix-credentials/src/protocol/mod.rs index 9279e2c1113..f1c84419b15 100644 --- a/gix-credentials/src/protocol/mod.rs +++ b/gix-credentials/src/protocol/mod.rs @@ -1,4 +1,6 @@ use bstr::BString; +use gix_error::ErrorExt; +use gix_error::ExnResult; use crate::helper; @@ -12,32 +14,7 @@ pub struct Outcome { } /// The Result type used in credentials top-level functions to obtain a complete identity. -pub type Result = std::result::Result, Error>; - -/// The error returned top-level credential functions. -#[derive(Debug, thiserror::Error)] -#[expect(missing_docs)] -pub enum Error { - #[error(transparent)] - UrlParse(#[from] gix_error::Error), - #[error("Either 'url' field or both 'protocol' and 'host' fields must be provided")] - UrlMissing, - #[error(transparent)] - ContextDecode(#[from] context::decode::Error), - #[error(transparent)] - InvokeHelper(#[from] helper::Error), - #[error("Could not configure credential helpers")] - ConfigureCredentialHelpers { - #[source] - source: Box, - }, - #[error("Could not obtain identity for context: {}", { let mut buf = Vec::::new(); context.write_to(&mut buf).ok(); String::from_utf8_lossy(&buf).into_owned() })] - IdentityMissing { context: Context }, - #[error("The handler asked to stop trying to obtain credentials")] - Quit, - #[error("Couldn't obtain {prompt}")] - Prompt { prompt: String, source: gix_prompt::Error }, -} +pub type Result = ExnResult>; /// Additional context to be passed to the credentials helper. #[derive(Debug, Default, Clone, Eq, PartialEq)] @@ -88,31 +65,34 @@ impl Default for ContextOptions { } /// Convert the outcome of a helper invocation to a helper result, assuring that the identity is complete in the process. -#[expect( - clippy::result_large_err, - reason = "will be removed once `gix-error` is used consistently" -)] pub fn helper_outcome_to_result(outcome: Option, action: helper::Action) -> Result { match (action, outcome) { - (helper::Action::Get(ctx), None) => Err(Error::IdentityMissing { - context: ctx.redacted(), - }), + (helper::Action::Get(ctx), None) => Err(identity_missing(ctx)), (helper::Action::Get(ctx), Some(mut outcome)) => match outcome.consume_identity() { Some(identity) => Ok(Some(Outcome { identity, next: outcome.next, })), None => Err(if outcome.quit { - Error::Quit + gix_error::message("The handler asked to stop trying to obtain credentials").raise_erased() } else { - Error::IdentityMissing { - context: ctx.redacted(), - } + identity_missing(ctx) }), }, (helper::Action::Store(_) | helper::Action::Erase(_), _ignore) => Ok(None), } } +fn identity_missing(context: Context) -> gix_error::Exn { + let mut buf = Vec::new(); + // Invalid protocol values must not prevent reporting the missing identity. + context.redacted().write_to(&mut buf).ok(); + gix_error::not_found(format!( + "Could not obtain identity for context: {}", + String::from_utf8_lossy(&buf) + )) + .raise_erased() +} + /// pub mod context; diff --git a/gix-credentials/tests/helper/cascade.rs b/gix-credentials/tests/helper/cascade.rs index 26dc02d084e..de37ff3998c 100644 --- a/gix-credentials/tests/helper/cascade.rs +++ b/gix-credentials/tests/helper/cascade.rs @@ -1,4 +1,5 @@ mod invoke { + use crate::Result; use bstr::ByteSlice; use gix_credentials::{ Program, @@ -10,6 +11,7 @@ mod invoke { #[test] fn invalid_authentication_challenges_fail_without_helpers() { + let mut error_snapshots = Vec::new(); for value in [ b"Basic realm=\"a\rb\"".as_slice(), b"Basic\nusername=other", @@ -24,18 +26,29 @@ mod invoke { }), ) .expect_err("malformed authentication challenges must fail without panicking"); + error_snapshots.push(gix_testtools::redact_debug_snapshot(&(err), &[])); assert!( - matches!( - err, - protocol::Error::InvokeHelper(gix_credentials::helper::Error::Io(_)) - ), + err.downcast_any_ref::().is_some(), "protocol validation must run even when no helper is configured and prompting is disabled" ); } + insta::assert_debug_snapshot!(error_snapshots, "invalid authentication challenges fail without helpers", @r#" + [ + I/O error (Other) + | + └─ "wwwauth[]"="Basic realm=\"a\rb\"" must not contain null bytes or newlines neither in key nor in value., "input"="Basic realm=\"a\rb\"", + I/O error (Other) + | + └─ "wwwauth[]"="Basic\nusername=other" must not contain null bytes or newlines neither in key nor in value., "input"="Basic\nusername=other", + I/O error (Other) + | + └─ "wwwauth[]"="Basic\0realm=example" must not contain null bytes or newlines neither in key nor in value., "input"="Basic\0realm=example", + ] + "#); } #[test] - fn a_helper_closing_its_input_does_not_prevent_fallback_with_challenges() -> crate::Result { + fn a_helper_closing_its_input_does_not_prevent_fallback_with_challenges() -> Result { let outcome = Cascade::default() .extend([ Program::from_custom_definition("!f() { exit 1; }; f"), @@ -54,7 +67,8 @@ mod invoke { mode: gix_prompt::Mode::Disable, askpass: None, }, - )? + ) + .map_err(gix_error::Exn::into_error)? .expect("the fallback helper supplies a complete credential"); assert_eq!( outcome.identity, @@ -65,7 +79,7 @@ mod invoke { } #[test] - fn authentication_challenges_reach_all_helpers_until_credentials_are_complete() -> crate::Result { + fn authentication_challenges_reach_all_helpers_until_credentials_are_complete() -> Result { let outcome = Cascade::default() .extend([ Program::from_custom_definition("!f() { cat >/dev/null; echo username=user; }; f"), @@ -89,7 +103,8 @@ mod invoke { mode: gix_prompt::Mode::Disable, askpass: None, }, - )? + ) + .map_err(gix_error::Exn::into_error)? .expect("both helpers contribute to the credential"); assert_eq!( outcome.identity, @@ -297,7 +312,6 @@ mod invoke { } } - #[expect(clippy::result_large_err)] fn invoke_cascade<'a>(names: impl IntoIterator, action: Action) -> protocol::Result { Cascade::default().use_http_path(true).extend(fixtures(names)).invoke( action, diff --git a/gix-credentials/tests/helper/context.rs b/gix-credentials/tests/helper/context.rs index da8dc96238b..98ac8a8593d 100644 --- a/gix-credentials/tests/helper/context.rs +++ b/gix-credentials/tests/helper/context.rs @@ -1,8 +1,9 @@ +use crate::Result; use bstr::ByteSlice; use gix_credentials::protocol::{Context, ContextOptions}; #[test] -fn authentication_challenges_survive_a_protocol_roundtrip() -> crate::Result { +fn authentication_challenges_survive_a_protocol_roundtrip() -> Result { let input = b"protocol=https host=github.com wwwauth[]=Basic realm=\"GitHub\" domain_hint=\"example\" @@ -86,6 +87,7 @@ mod write_to { #[test] fn record_delimiters_are_invalid() { + let mut error_snapshots = Vec::new(); for input in [&b"foo\0"[..], b"foo\n", b"foo\r"] { let ctx = Context { url: Some(input.into()), @@ -93,8 +95,25 @@ mod write_to { }; let mut buf = Vec::::new(); let err = ctx.write_to(&mut buf).unwrap_err(); + error_snapshots.push(gix_testtools::redact_debug_snapshot(&(err), &[])); assert_eq!(err.kind(), std::io::ErrorKind::Other); } + insta::assert_debug_snapshot!(error_snapshots, "record delimiters are invalid", @r#" + [ + Custom { + kind: Other, + error: "url"="foo\0" must not contain null bytes or newlines neither in key nor in value., "input"="foo\0", + }, + Custom { + kind: Other, + error: "url"="foo\n" must not contain null bytes or newlines neither in key nor in value., "input"="foo\n", + }, + Custom { + kind: Other, + error: "url"="foo\r" must not contain null bytes or newlines neither in key nor in value., "input"="foo\r", + }, + ] + "#); } #[test] @@ -173,10 +192,7 @@ username=bob"; #[test] fn null_bytes_when_decoding() { let err = Context::from_bytes(b"url=https://foo\0", ContextOptions::default()).unwrap_err(); - assert!(matches!( - err, - gix_credentials::protocol::context::decode::Error::Encoding(_) - )); + insta::assert_debug_snapshot!(err, "null bytes when decoding", @r#""url"="https://foo\0" must not contain null bytes or newlines neither in key nor in value., "input"="https://foo\0""#); } #[test] diff --git a/gix-credentials/tests/helper/invoke.rs b/gix-credentials/tests/helper/invoke.rs index b4176e70442..c76031560c6 100644 --- a/gix-credentials/tests/helper/invoke.rs +++ b/gix-credentials/tests/helper/invoke.rs @@ -67,27 +67,31 @@ fn store_and_reject() { } mod program { + use crate::Result; use gix_credentials::{Program, helper, program::Kind}; use crate::helper::script_helper; #[test] - fn builtin() -> crate::Result { + fn builtin() -> Result { // Other tests resolve fixture paths relative to the working directory, so change it only in a child. if gix_testtools::run_in_isolated_process()? { return Ok(()); } let temp = gix_testtools::tempfile::tempdir()?; let _cwd = gix_testtools::set_current_dir(temp.path())?; + let err = gix_credentials::helper::invoke( + &mut Program::from_kind(Kind::Builtin).suppress_stderr(), + &helper::Action::get_for_url("/path/without/scheme/fails/with/error"), + ) + .expect_err("the builtin helper rejects a URL without a scheme"); + insta::assert_debug_snapshot!(err, "this failure indicates we could launch the helper, even though it wasn't happy which is fine. It doesn't like the URL", @" + I/O error (Other) + | + └─ Credentials helper program failed with status code Some(128) + "); assert!( - matches!( - gix_credentials::helper::invoke( - &mut Program::from_kind(Kind::Builtin).suppress_stderr(), - &helper::Action::get_for_url("/path/without/scheme/fails/with/error"), - ) - .unwrap_err(), - helper::Error::CredentialsHelperFailed { .. } - ), + err.is_retryable(), "this failure indicates we could launch the helper, even though it wasn't happy which is fine. It doesn't like the URL" ); Ok(()) @@ -138,7 +142,7 @@ mod program { } #[test] - fn path_to_helper_as_script_to_workaround_executable_bits() -> crate::Result { + fn path_to_helper_as_script_to_workaround_executable_bits() -> Result { assert_eq!( gix_credentials::helper::invoke( &mut script_helper("custom-helper"), diff --git a/gix-credentials/tests/helper/mod.rs b/gix-credentials/tests/helper/mod.rs index 867675c05fd..e3db117bd83 100644 --- a/gix-credentials/tests/helper/mod.rs +++ b/gix-credentials/tests/helper/mod.rs @@ -19,7 +19,40 @@ mod invoke_outcome_to_helper_result { action, ) .unwrap_err(); - assert!(matches!(err, protocol::Error::IdentityMissing { .. })); + insta::assert_debug_snapshot!(err, "missing username or password causes failure with get action", @"Could not obtain identity for context: url=does/not/matter"); + assert!(err.is_not_found()); + } + + #[test] + fn invalid_context_still_reports_missing_identity() { + let mut error_snapshots = Vec::new(); + for value in ["invalid\nvalue", "invalid\0value", "invalid\rvalue"] { + for context in [ + protocol::Context::from_url(value, Default::default()), + protocol::Context { + path: Some(value.into()), + ..Default::default() + }, + ] { + let err = helper_outcome_to_result(None, helper::Action::Get(context)) + .expect_err("Missing credentials must return an error even when the context is invalid"); + error_snapshots.push(gix_testtools::redact_debug_snapshot(&(err), &[])); + assert!( + err.is_not_found(), + "Invalid context must not replace the missing-credentials classification" + ); + } + } + insta::assert_debug_snapshot!(error_snapshots, "invalid context still reports missing identity", @" + [ + Could not obtain identity for context: , + Could not obtain identity for context: , + Could not obtain identity for context: , + Could not obtain identity for context: , + Could not obtain identity for context: , + Could not obtain identity for context: , + ] + "); } #[test] @@ -36,7 +69,7 @@ mod invoke_outcome_to_helper_result { action, ) .unwrap_err(); - assert!(matches!(err, protocol::Error::Quit)); + insta::assert_debug_snapshot!(err, "quit message in context causes special error ignoring missing identity", @"The handler asked to stop trying to obtain credentials"); } } diff --git a/gix-credentials/tests/program/main.rs b/gix-credentials/tests/program/main.rs index 3b45dc8edfc..00596aca511 100644 --- a/gix-credentials/tests/program/main.rs +++ b/gix-credentials/tests/program/main.rs @@ -1,9 +1,19 @@ use gix_credentials::program::main; +use gix_error::ExnResult; use std::io::Cursor; -#[derive(Debug, thiserror::Error)] -#[error("Test error")] -struct TestError; +#[test] +#[cfg(unix)] +fn invalid_non_utf8_action_is_preserved() { + use std::{ffi::OsString, os::unix::ffi::OsStringExt}; + + let err = main::Action::try_from(OsString::from_vec(vec![0xff])).expect_err("the action is invalid"); + assert_eq!( + err.values.get("input"), + Some(&gix_error::MetadataValue::Bytes(vec![0xff].into())), + "the invalid action is retained" + ); +} #[test] fn context_options_apply_to_input_and_output() { @@ -18,7 +28,7 @@ fn context_options_apply_to_input_and_output() { Cursor::new(input), &mut output, options, - |_action, context| -> Result, TestError> { + |_action, context| -> ExnResult> { assert_eq!( context.url.as_ref().map(|url| url.as_slice()), Some(&input[4..input.len() - 1]) @@ -45,7 +55,7 @@ fn protocol_and_host_without_url_is_valid() { Cursor::new(input), &mut output, gix_credentials::protocol::ContextOptions::default(), - |_action, context| -> Result, TestError> { + |_action, context| -> ExnResult> { assert_eq!(context.protocol.as_deref(), Some("https")); assert_eq!(context.host.as_deref(), Some("github.com")); assert_eq!(context.url, None, "the URL isn't automatically populated"); @@ -57,19 +67,18 @@ fn protocol_and_host_without_url_is_valid() { // This should fail because our mock helper returned None (no credentials found) // but it should NOT fail because of missing URL - match result { - Err(gix_credentials::program::main::Error::CredentialsMissing { .. }) => { - assert!( - called, - "The helper gets called, but as nothing is provided in the function it ulimately fails" - ); - } - other => panic!("Expected CredentialsMissing error, got: {other:?}"), - } + let err = result.expect_err("missing credentials must fail"); + insta::assert_debug_snapshot!(err, "protocol and host without url is valid", @r#"Credentials for "https://github.com" could not be obtained"#); + assert!(err.is_not_found()); + assert!( + called, + "The helper gets called, but as nothing is provided in the function it ultimately fails" + ); } #[test] fn missing_protocol_with_only_host_or_protocol_fails() { + let mut error_snapshots = Vec::new(); for input in ["host=github.com\n", "protocol=https\n"] { let mut output = Vec::new(); @@ -79,19 +88,23 @@ fn missing_protocol_with_only_host_or_protocol_fails() { Cursor::new(input), &mut output, gix_credentials::protocol::ContextOptions::default(), - |_action, _context| -> Result, TestError> { + |_action, _context| -> ExnResult> { called = true; Ok(None) }, ); - match result { - Err(gix_credentials::program::main::Error::UrlMissing) => { - assert!(!called, "the context is lacking, hence nothing gets called"); - } - other => panic!("Expected UrlMissing error, got: {other:?}"), - } + let err = result.expect_err("incomplete URL must fail validation"); + error_snapshots.push(gix_testtools::redact_debug_snapshot(&(err), &[])); + assert!(err.is_validation()); + assert!(!called, "the context is lacking, hence nothing gets called"); } + insta::assert_debug_snapshot!(error_snapshots, "missing protocol with only host or protocol fails", @" + [ + Either 'url' field or both 'protocol' and 'host' fields must be provided, + Either 'url' field or both 'protocol' and 'host' fields must be provided, + ] + "); } #[test] @@ -105,7 +118,7 @@ fn url_alone_is_valid() { Cursor::new(input), &mut output, gix_credentials::protocol::ContextOptions::default(), - |_action, context| -> Result, TestError> { + |_action, context| -> ExnResult> { called = true; assert_eq!(context.url.unwrap(), "https://github.com"); assert_eq!(context.host, None, "not auto-populated"); @@ -117,10 +130,8 @@ fn url_alone_is_valid() { // This should fail because our mock helper returned None (no credentials found) // but it should NOT fail because of missing URL - match result { - Err(gix_credentials::program::main::Error::CredentialsMissing { .. }) => { - assert!(called); - } - other => panic!("Expected CredentialsMissing error, got: {other:?}"), - } + let err = result.expect_err("missing credentials must fail"); + insta::assert_debug_snapshot!(err, "url alone is valid", @r#"Credentials for "https://github.com" could not be obtained"#); + assert!(err.is_not_found()); + assert!(called); } diff --git a/gix-credentials/tests/protocol/context.rs b/gix-credentials/tests/protocol/context.rs index 8a021f1ef6b..ad76fc7ecee 100644 --- a/gix-credentials/tests/protocol/context.rs +++ b/gix-credentials/tests/protocol/context.rs @@ -1,4 +1,5 @@ mod destructure_url_in_place { + use crate::Result; use gix_credentials::protocol::Context; fn url_ctx(url: &str) -> Context { @@ -38,7 +39,7 @@ mod destructure_url_in_place { } #[test] - fn passwords_are_placed_in_context_too() -> crate::Result { + fn passwords_are_placed_in_context_too() -> Result { let mut ctx = url_ctx("http://user:password@host/path"); ctx.destructure_url_in_place(false)?; assert_eq!(ctx.password.as_deref(), Some("password")); @@ -108,10 +109,7 @@ mod destructure_url_in_place { host: Some("github.com".into()), ..Default::default() }; - assert_eq!( - ctx_no_protocol.destructure_url_in_place(false).unwrap_err().to_string(), - "Either 'url' field or both 'protocol' and 'host' fields must be provided" - ); + insta::assert_debug_snapshot!(ctx_no_protocol.destructure_url_in_place(false).expect_err("missing protocol or host without url fails"), "missing protocol or host without url fails", @"Either 'url' field or both 'protocol' and 'host' fields must be provided"); let mut ctx_no_host = Context { protocol: Some("https".into()), diff --git a/gix-date/Cargo.toml b/gix-date/Cargo.toml index 27611d0aa3e..383cbc147c6 100644 --- a/gix-date/Cargo.toml +++ b/gix-date/Cargo.toml @@ -28,6 +28,7 @@ jiff = "0.2.25" document-features = { version = "0.2.0", optional = true } [dev-dependencies] +insta = "1.46.3" gix-hash = { path = "../gix-hash" } gix-testtools = { path = "../tests/tools", default-features = false, features = ["sha1"] } pretty_assertions = "1.4.1" diff --git a/gix-date/src/lib.rs b/gix-date/src/lib.rs index d61a94dd1ab..22de767aa63 100644 --- a/gix-date/src/lib.rs +++ b/gix-date/src/lib.rs @@ -34,8 +34,6 @@ pub mod parse; pub use jiff::Zoned; pub use parse::function::{parse, parse_header}; -pub use gix_error::ValidationError as Error; - /// A timestamp with timezone. #[derive(Default, PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone, Copy)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] diff --git a/gix-date/src/parse/function.rs b/gix-date/src/parse/function.rs index bf803adf533..54dac5c32ee 100644 --- a/gix-date/src/parse/function.rs +++ b/gix-date/src/parse/function.rs @@ -5,17 +5,18 @@ use jiff::{Zoned, civil::Date, fmt::rfc2822, tz::TimeZone}; use crate::parse::git::parse_git_date_format; use crate::parse::raw::parse_raw; use crate::{ - Error, OffsetInSeconds, SecondsSinceUnixEpoch, Time, + OffsetInSeconds, SecondsSinceUnixEpoch, Time, parse::relative, time::format::{DEFAULT, GITOXIDE, ISO8601, ISO8601_STRICT, SHORT}, }; -use gix_error::{Exn, ResultExt}; +use gix_error::{ExnMessageResult, ResultExt}; /// The widest timezone offset git reads, as `match_tz()` in `date.c` takes the four digits as a /// clock time: hours below 24 and minutes below 60, so `+2359` is the last offset it accepts. const MAX_OFFSET_IN_SECONDS: i32 = 23 * 3600 + 59 * 60; /// Parse `input` as any time that Git can parse when inputting a date. +/// Unknown formats and timezone conversion failures include `input` bytes as [metadata](gix_error::Exn::metadata()). /// /// ## Examples /// @@ -101,7 +102,7 @@ const MAX_OFFSET_IN_SECONDS: i32 = 23 * 3600 + 59 * 60; /// /// In any of these formats, a timezone offset wider than `±23:59` is not a timezone to Git, so it /// is not accepted here either. -pub fn parse(input: &str, now: Option) -> Result> { +pub fn parse(input: &str, now: Option) -> ExnMessageResult