deps-core 2.0.0

Core abstractions for deps-lsp: caching, errors, and traits
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
use thiserror::Error;

use crate::package::InvalidPackageName;
use crate::redact::{RedactedName, RedactedUrl};

/// Whether a [`DepsError::RateLimited`] classification is backed by explicit server evidence
/// or merely inferred from a status code and request context alone (#1295).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RateLimitEvidence {
    /// Backed by explicit server evidence, e.g. a confirmed `X-RateLimit-Remaining: 0`
    /// response header (checked in `crate::cache`).
    Confirmed,
    /// Inferred from a status code and request context alone — could be an
    /// abuse-detection false positive, a secondary rate limit, or an access-restricted repo.
    Inferred,
}

/// Reconstructs the "{status} {reason}" text `reqwest::StatusCode`'s `Display`
/// produces, since `HttpStatus` stores a bare `u16` for structural matching
/// and loses the canonical reason phrase otherwise.
///
/// `url` is already a [`RedactedUrl`] (#789: `HttpStatus::url`'s field type itself makes the
/// raw value unreachable, so there is nothing left to redact here) — this is the only place
/// `HttpStatus`'s `Display` text is built.
fn http_status_message(status: u16, url: &RedactedUrl) -> String {
    let reason = reqwest::StatusCode::from_u16(status)
        .ok()
        .and_then(|s| s.canonical_reason());
    reason.map_or_else(
        || format!("HTTP {status} for {url}"),
        |reason| format!("HTTP {status} {reason} for {url}"),
    )
}

/// Builds [`DepsError::RegistryError`]'s `Display` text from its already-[`RedactedUrl`]
/// `package` field (#767, #789).
///
/// [`DepsError::RegistryError`]'s `package` field is documented as a package name, but
/// several `deps-core::cache` call sites populate it with a URL instead, so it is stored as
/// [`RedactedUrl`] rather than a plain `String` — a no-op for an actual package name
/// (including an npm-scoped one like `@types/node`, which an earlier revision of this
/// function mangled into `***@types/node` before
/// [`crate::redact::redact_userinfo`]'s empty-userinfo false positive was fixed at the
/// root — #767 M1/code-review follow-up). `source`'s own `Display` can no longer re-embed the
/// raw URL either: [`SanitizedRegistryError`]'s only constructor strips it unconditionally.
fn registry_error_message(package: &RedactedUrl, source: &SanitizedRegistryError) -> String {
    format!("registry request failed for {package}: {source}")
}

/// Builds [`DepsError::ResponseTooLarge`]'s `Display` text from its already-[`RedactedUrl`]
/// `url` field (#767, #789).
fn response_too_large_message(url: &RedactedUrl, limit: usize) -> String {
    format!("response body for {url} exceeds {limit} byte limit")
}

/// Builds [`DepsError::Offline`]'s `Display` text from its already-[`RedactedUrl`] `url`
/// field (#767, #789).
fn offline_message(url: &RedactedUrl) -> String {
    format!("offline: request to {url} was blocked by network.offline")
}

/// Wraps a `reqwest::Error` with its embedded request URL stripped, for storage in
/// [`DepsError::RegistryError`]'s `source` field.
///
/// `reqwest::Error`'s own `Display` appends `" for url (...)"` when the underlying error
/// carries a URL — `reqwest::Error::without_url()` strips this, but relying on every
/// `RegistryError`-construction site to remember to call it is exactly the discipline gap
/// this type closes (issue #789, see [`RedactedUrl`]'s own docs for the same problem on the
/// URL-string side). The only constructor (`From<reqwest::Error>`) applies `.without_url()`
/// unconditionally, so a raw URL can never reach `DepsError`'s `Display`/`Debug` through
/// `{source}` forwarding, even when a future call site forgets.
///
/// **`self.0`'s own `source` chain is never exposed**, through either `Debug` or
/// [`std::error::Error::source`] — this is deliberate, not an oversight. `.without_url()`
/// only clears the *outer* error's own `url` field; `reqwest`'s `source` field is
/// independent of it, and that source is not always a URL-free `hyper`/`io` error: reqwest
/// 0.13.4's redirect policy (`src/redirect.rs`, the `https_only` check in
/// `TowerPolicy::redirect`) rejects an `http://` redirect target by building
/// `crate::error::redirect(crate::error::url_bad_scheme(next_url.clone()), next_url)` — the
/// *inner* `url_bad_scheme(...)` error is itself a full `reqwest::Error` with `next_url`
/// populated via `.with_url(...)`, nested as the *outer* error's `source`. That inner `url`
/// is a field `.without_url()` on the outer error never touches, and `reqwest::Error`'s own
/// derived-style `Debug` impl recursively prints `source`'s `Debug` (including that inner
/// `url`) — so both a manual `.source()` walk and `{:?}` on the raw `reqwest::Error` can leak
/// it. Since `reqwest` exposes no `source_mut()`-style API to reach in and strip that nested
/// URL, this type treats itself as a leaf node instead: its [`std::error::Error::source`]
/// impl always returns `None`, and `Debug` is hand-written to forward to the (already-safe)
/// `Display` text rather than to `self.0`'s own `Debug`.
///
/// **Precision note on what is actually verified today**: this project's own client
/// configuration never calls `reqwest::ClientBuilder::https_only` (`grep -rn '\.https_only('
/// crates/` finds no hits), so the nested-URL shape above cannot currently occur through this
/// crate's own request paths — the regression test below instead exercises a genuinely
/// populated source chain via a real connection-refused (io-level) failure, which is what
/// this project's client config can actually produce, and confirms it is discarded. The
/// `https_only`/redirect mechanism itself is read directly from `reqwest` 0.13.4's pinned
/// source (`redirect.rs`), not reproduced live here (doing so would need a TLS test harness
/// this project does not otherwise have). `source()` returning `None` unconditionally — not
/// only when a URL-bearing nested error is possible — is what makes this correct regardless:
/// if a future change enables `https_only` on a shared client, this type's contract does not
/// need re-auditing.
///
/// # Examples
///
/// ```
/// use deps_core::error::SanitizedRegistryError;
///
/// // A builder-only `reqwest::Error` never carries a URL in the first place, so this
/// // demonstrates the wrapper's `Display`/`Debug` forwarding without needing a real request.
/// let raw = reqwest::Client::new().get("not a url").build().unwrap_err();
/// let sanitized: SanitizedRegistryError = raw.into();
/// assert!(!sanitized.to_string().is_empty());
/// ```
pub struct SanitizedRegistryError(reqwest::Error);

impl From<reqwest::Error> for SanitizedRegistryError {
    fn from(error: reqwest::Error) -> Self {
        Self(error.without_url())
    }
}

impl std::fmt::Display for SanitizedRegistryError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        std::fmt::Display::fmt(&self.0, f)
    }
}

impl std::fmt::Debug for SanitizedRegistryError {
    /// Hand-written, not derived: forwards to `Display` (safe — `reqwest::Error`'s own
    /// `Display` never recurses into its `source`'s text) instead of `self.0`'s own `Debug`,
    /// which does recurse into `source` and would reopen the nested-URL leak this type's own
    /// docs describe.
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "SanitizedRegistryError({})", self.0)
    }
}

impl std::error::Error for SanitizedRegistryError {
    /// Always `None` — see this type's own docs for why the wrapped error's source chain is
    /// never safe to expose, even via this trait's usual chain-walking contract.
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        None
    }
}

/// Core error types for deps-lsp.
///
/// Extended from Phase 1 to support multiple ecosystems (Cargo, npm, PyPI).
/// All errors provide structured error handling with source error tracking.
///
/// # Examples
///
/// ```
/// use deps_core::error::{DepsError, Result};
///
/// fn parse_file(content: &str, file_type: &str) -> Result<()> {
///     // Parsing errors are automatically wrapped
///     if content.is_empty() {
///         return Err(DepsError::parse_error(
///             file_type,
///             &std::io::Error::new(std::io::ErrorKind::InvalidData, "empty content"),
///         ));
///     }
///     Ok(())
/// }
/// ```
#[non_exhaustive]
#[derive(Error)]
pub enum DepsError {
    /// A manifest or lockfile failed to parse.
    ///
    /// `#[non_exhaustive]` on the variant itself (#1250, mirrors [`Self::RateLimited`]'s
    /// precedent): the only way to construct this from outside `deps-core` is
    /// [`Self::parse_error`], which always routes `source` through
    /// [`crate::redact::parse_error_source`] — closing the recurring gap (#1240, #1243,
    /// #1249) where a new call site hand-built this variant with an unredacted source.
    ///
    /// An external crate cannot build this variant as a struct literal — this fails to
    /// compile with E0639 (`#[non_exhaustive]` variant constructed outside its defining
    /// crate), not for some unrelated reason:
    ///
    /// ```compile_fail
    /// use deps_core::DepsError;
    ///
    /// let _ = DepsError::ParseError {
    ///     file_type: "Cargo.toml".into(),
    ///     source: Box::new(std::io::Error::other("bad")),
    /// };
    /// ```
    #[error("failed to parse {file_type}: {source}")]
    #[non_exhaustive]
    ParseError {
        /// Ecosystem/file kind being parsed (e.g. `"Cargo.toml"`), for the error message.
        file_type: String,
        /// The underlying parser error.
        #[source]
        source: Box<dyn std::error::Error + Send + Sync>,
    },

    /// A registry HTTP request failed at the transport layer.
    #[error("{}", registry_error_message(package, source))]
    RegistryError {
        /// Name of the package the request was for — or, at several `deps-core::cache` call
        /// sites, the request URL instead (see [`RedactedUrl`]'s own docs). Redaction is safe
        /// to apply unconditionally: a genuine URL is redacted for real, and a genuine package
        /// name is left alone unless it happens to contain a `:` or a non-leading `@` (a
        /// leading `@`, e.g. an npm-scoped name like `@types/node`, is left untouched), in
        /// which case it is redacted the same way a credential-bearing value would be (e.g. a
        /// Maven/Gradle `group:artifact` coordinate becomes `group:***`) — a cosmetic false
        /// positive, never a correctness issue, since no current call site populates this
        /// field with a coordinate (only `cache.rs` does, always with a URL).
        package: RedactedUrl,
        /// The underlying `reqwest` error, with its embedded request URL stripped.
        #[source]
        source: SanitizedRegistryError,
    },

    /// The cache layer itself failed (e.g. a poisoned lock), independent of any registry request.
    #[error("cache error: {0}")]
    CacheError(String),

    /// A registry request was rejected for exceeding a rate limit. Unlike other variants,
    /// `message` is a pre-vetted, IP-free, actionable hint safe to surface verbatim in a
    /// per-dependency diagnostic (see [`Self::fetch_failure`]) — never build one from a raw
    /// registry error body, which can embed the caller's public IP (`github.rs:332-346`).
    ///
    /// `#[non_exhaustive]` on the variant itself (#1295 critic M1, added alongside `verified`):
    /// a future field addition to this variant specifically should not need to be a breaking
    /// change again — unlike the enum-level `#[non_exhaustive]` above, which only blocks an
    /// exhaustive top-level `match` on [`DepsError`], not an exhaustive struct-literal pattern
    /// on this one variant's own fields.
    #[error("{message}")]
    #[non_exhaustive]
    RateLimited {
        /// Pre-vetted, IP-free message safe to surface verbatim in a diagnostic.
        message: String,
        /// Whether this classification is backed by explicit server evidence (e.g. a
        /// confirmed `X-RateLimit-Remaining: 0` response header, checked in
        /// `crate::cache`) rather than merely inferred from a status code and request
        /// context alone (#1295). An unauthenticated GitHub 403 with no such evidence —
        /// which could be an abuse-detection false positive, a secondary rate limit, or an
        /// access-restricted repo — still gets classified as `RateLimited` for its actionable
        /// hint, but with `verified: RateLimitEvidence::Inferred`, so a caller like
        /// `test_util::unwrap_or_skip_github_rate_limit` can tell a confirmed exhaustion apart
        /// from an assumed one instead of silently treating both as the same expected case.
        verified: RateLimitEvidence,
        /// The HTTP status this classification was built from, when known (#1295 critic N1).
        /// `Some(403)`/`Some(429)` for a `crate::cache`-classified confirmed rate limit —
        /// `None` for a canned, inference-only construction (e.g.
        /// `crate::github::github_rate_limit_error`) that never saw a live response. Exists
        /// so a caller like the authenticated pinned-tier cache-eviction guard (FR-015/
        /// NFR-004, `crate::cache`) can restrict itself to a genuine 401/403
        /// credential-rejection signal without also matching a 429 (mere throttling, not a
        /// credential-revocation signal) that happens to also classify as `RateLimited`.
        source_status: Option<u16>,
    },

    /// A package name was not found on the given registry.
    #[error("{package} not found on {registry}")]
    PackageNotFound {
        /// Name of the package that was looked up — stored redacted (#1209): the raw value
        /// is unreachable from this field's type.
        package: RedactedName,
        /// Name of the registry that reported the package as missing.
        registry: &'static str,
    },

    /// A registry HTTP request returned a non-success status code.
    #[error("{}", http_status_message(*status, url))]
    HttpStatus {
        /// URL that was requested — stored redacted (#789): the raw value is unreachable
        /// from this field's type.
        url: RedactedUrl,
        /// HTTP status code returned.
        status: u16,
    },

    /// A registry's response body failed to deserialize as JSON.
    #[error("failed to parse {registry} response for {package}: {source}")]
    ApiResponse {
        /// Name of the package whose response failed to parse — stored redacted (#1209): the
        /// raw value is unreachable from this field's type.
        package: RedactedName,
        /// Name of the registry the response came from.
        registry: &'static str,
        /// The underlying JSON deserialization error.
        #[source]
        source: serde_json::Error,
    },

    /// A response body exceeded the configured size cap and was rejected before full download.
    #[error("{}", response_too_large_message(url, *limit))]
    ResponseTooLarge {
        /// URL the oversized response came from — stored redacted (#789): the raw value is
        /// unreachable from this field's type.
        url: RedactedUrl,
        /// The size cap, in bytes, that was exceeded.
        limit: usize,
    },

    /// A malformed version-requirement string, for any ecosystem.
    ///
    /// Previously also carried `deps-go`'s malformed-module-path rejections (#399 deferred
    /// that split); those now use [`Self::InvalidPackageName`] instead (#1514) — a module path
    /// is a package identifier, not a version requirement, and consumers that only expect
    /// version-requirement text (e.g. `GoFormatter::validate_package_name`) previously needed
    /// a documented `unreachable!()` arm to rule the version-requirement shape back out.
    #[error("invalid version requirement: {0}")]
    InvalidVersionReq(String),

    /// A registry request's target package/module name failed a structural validation gate
    /// (e.g. empty, oversized, or containing a `.`/`..` path segment) — distinct from
    /// [`Self::InvalidVersionReq`], which is for a malformed version-requirement string, not a
    /// malformed name. `deps-go`'s `validate_module_path` is the first caller (#1514).
    ///
    /// # Examples
    ///
    /// ```
    /// use deps_core::{DepsError, InvalidPackageName};
    ///
    /// let err: DepsError = InvalidPackageName::new("module path is empty").into();
    /// assert!(matches!(err, DepsError::InvalidPackageName(_)));
    /// ```
    #[error("invalid package name: {0}")]
    InvalidPackageName(#[from] InvalidPackageName),

    /// A filesystem I/O operation failed.
    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),

    /// A JSON parsing operation failed outside of a registry response context.
    #[error("JSON error: {0}")]
    Json(#[from] serde_json::Error),

    /// The manifest file's ecosystem could not be determined from any registered router.
    #[error("unsupported ecosystem: {0}")]
    UnsupportedEcosystem(String),

    /// More than one ecosystem's routing rules matched the same manifest path.
    #[error("ambiguous ecosystem detection for file: {0}")]
    AmbiguousEcosystem(String),

    /// A URI supplied by the client or a manifest could not be parsed.
    #[error("invalid URI: {0}")]
    InvalidUri(String),

    /// Returned by `deps_core::cache::HttpCache`'s 4 send sites (issue #483) when
    /// `network.offline` is set, instead of attempting the request. `url` is the request
    /// that was blocked, for diagnostic/logging purposes.
    #[error("{}", offline_message(url))]
    Offline {
        /// The request URL that was blocked — stored redacted (#789): the raw value is
        /// unreachable from this field's type.
        url: RedactedUrl,
    },

    /// A multi-hop alternate/private-index chain's resolution was halted because a hop
    /// returned a genuine transport error (5xx, timeout, connection failure) rather than a
    /// clean "not found" — the chain deliberately does not fall through to a further, less
    /// trusted hop in this case (`deps_pypi`'s FR-005(c)/NFR-003(3), #513). Carries no
    /// arbitrary error text — mirrors [`Self::RateLimited`]'s pre-vetted-message precedent
    /// (see [`Self::fetch_failure`]'s security-load-bearing invariant) — so its
    /// classification there can safely be [`FetchFailure::Actionable`] with a fixed, safe
    /// message, surfacing this case in hover/diagnostics instead of only a `tracing::warn!`.
    #[error(
        "index chain resolution halted by a transport error on one hop — not falling back \
         to a less-trusted index"
    )]
    ChainResolutionHalted,
}

/// Hand-written, not derived: originally because a derived `Debug` would have printed
/// `HttpStatus.url`, `Offline.url`, `ResponseTooLarge.url`, and `RegistryError.package` raw
/// and unredacted (#767 code-review follow-up) — any `tracing::warn!(?err, ...)`/`{err:?}`
/// call site, a common and arguably more idiomatic alternative to `%err`, would have bypassed
/// the hand-written `Display` text entirely and reintroduced the raw URL/query string. Since
/// #789, those four fields are typed [`RedactedUrl`] directly, so a derived `Debug` would
/// actually be safe too (each field's own `Debug` already forwards to redacted text) — this
/// impl is kept hand-written anyway as defense-in-depth against a future field-type
/// regression, not because it is still load-bearing on its own. Every field is still shown
/// (this is not a summary).
impl std::fmt::Debug for DepsError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::ParseError { file_type, source } => f
                .debug_struct("ParseError")
                .field("file_type", file_type)
                .field("source", source)
                .finish(),
            Self::RegistryError { package, source } => f
                .debug_struct("RegistryError")
                .field("package", package)
                .field("source", source)
                .finish(),
            Self::CacheError(message) => f.debug_tuple("CacheError").field(message).finish(),
            Self::RateLimited {
                message,
                verified,
                source_status,
            } => f
                .debug_struct("RateLimited")
                .field("message", message)
                .field("verified", verified)
                .field("source_status", source_status)
                .finish(),
            Self::PackageNotFound { package, registry } => f
                .debug_struct("PackageNotFound")
                .field("package", package)
                .field("registry", registry)
                .finish(),
            Self::HttpStatus { url, status } => f
                .debug_struct("HttpStatus")
                .field("url", url)
                .field("status", status)
                .finish(),
            Self::ApiResponse {
                package,
                registry,
                source,
            } => f
                .debug_struct("ApiResponse")
                .field("package", package)
                .field("registry", registry)
                .field("source", source)
                .finish(),
            Self::ResponseTooLarge { url, limit } => f
                .debug_struct("ResponseTooLarge")
                .field("url", url)
                .field("limit", limit)
                .finish(),
            Self::InvalidVersionReq(req) => f.debug_tuple("InvalidVersionReq").field(req).finish(),
            Self::InvalidPackageName(err) => {
                f.debug_tuple("InvalidPackageName").field(err).finish()
            }
            Self::Io(source) => f.debug_tuple("Io").field(source).finish(),
            Self::Json(source) => f.debug_tuple("Json").field(source).finish(),
            Self::UnsupportedEcosystem(ecosystem) => f
                .debug_tuple("UnsupportedEcosystem")
                .field(ecosystem)
                .finish(),
            Self::AmbiguousEcosystem(path) => {
                f.debug_tuple("AmbiguousEcosystem").field(path).finish()
            }
            Self::InvalidUri(uri) => f.debug_tuple("InvalidUri").field(uri).finish(),
            Self::Offline { url } => f.debug_struct("Offline").field("url", url).finish(),
            Self::ChainResolutionHalted => f.write_str("ChainResolutionHalted"),
        }
    }
}

impl DepsError {
    /// Constructs a [`Self::RateLimited`] error.
    ///
    /// The only way to build this `#[non_exhaustive]` variant from outside this crate (#1295
    /// critic M1: `#[non_exhaustive]` on the variant blocks a downstream crate's struct
    /// literal, not just an exhaustive match) — an ecosystem crate with its own rate-limit
    /// classification distinct from `crate::github`'s (e.g. `deps-gitlab-ci`) uses this
    /// instead. `message` must be a pre-vetted, IP-free, actionable hint (see the variant's own
    /// doc); `verified` should be [`RateLimitEvidence::Confirmed`] only when the caller has
    /// confirmed genuine exhaustion from explicit server evidence, not merely inferred it.
    ///
    /// # Examples
    ///
    /// ```
    /// use deps_core::{DepsError, RateLimitEvidence};
    ///
    /// let err = DepsError::rate_limited("set MY_TOKEN to increase the limit", RateLimitEvidence::Inferred);
    /// assert!(matches!(err, DepsError::RateLimited { verified: RateLimitEvidence::Inferred, .. }));
    /// ```
    #[must_use]
    pub fn rate_limited(message: impl Into<String>, verified: RateLimitEvidence) -> Self {
        Self::RateLimited {
            message: message.into(),
            verified,
            // No live response is available at this generic construction site — only
            // `crate::cache`'s own confirmed-evidence classification knows a real status
            // (#1295 critic N1).
            source_status: None,
        }
    }

    /// Constructs a [`Self::ParseError`], routing `source` through
    /// [`crate::redact::parse_error_source`] so a credential-shaped parser error can never
    /// reach `Debug`/`Display` unredacted (#1250).
    ///
    /// The only way to build this `#[non_exhaustive]` variant from outside this crate —
    /// mirrors [`Self::rate_limited`]'s precedent exactly.
    ///
    /// # Examples
    ///
    /// ```
    /// use deps_core::DepsError;
    ///
    /// let err = DepsError::parse_error("Cargo.toml", &"duplicate key: `serde`");
    /// assert!(matches!(err, DepsError::ParseError { .. }));
    /// ```
    #[must_use]
    pub fn parse_error(file_type: impl Into<String>, source: &dyn std::fmt::Display) -> Self {
        Self::ParseError {
            file_type: file_type.into(),
            source: crate::redact::parse_error_source(source),
        }
    }

    /// Returns `true` when this error means the registry was successfully asked and
    /// answered "this package doesn't exist", as opposed to the registry not having
    /// been answerable at all (network failure, timeout, malformed response, 5xx).
    ///
    /// Distinguishing the two matters for diagnostics (#267): a genuine not-found is
    /// evidence the package name is wrong, while any other error is evidence only that
    /// this particular request failed — reporting the latter as "Unknown package" would
    /// mislabel a transient registry outage as a nonexistent dependency. Covers
    /// [`DepsError::PackageNotFound`] (the ecosystems that map a 404 to it explicitly:
    /// npm, PyPI, Go, Swift) and a bare [`DepsError::HttpStatus`] with `status == 404`
    /// (the ecosystems that propagate the raw HTTP status instead: Cargo, Maven, Gradle,
    /// Bundler, Dart, Composer, NuGet).
    ///
    /// # Examples
    ///
    /// ```
    /// use deps_core::DepsError;
    ///
    /// let not_found = DepsError::PackageNotFound {
    ///     package: "left-pad".into(),
    ///     registry: "npm",
    /// };
    /// assert!(not_found.is_not_found());
    ///
    /// let http_404 = DepsError::HttpStatus {
    ///     url: "https://crates.io/api/v1/crates/left-pad".into(),
    ///     status: 404,
    /// };
    /// assert!(http_404.is_not_found());
    ///
    /// let outage = DepsError::HttpStatus {
    ///     url: "https://crates.io/api/v1/crates/serde".into(),
    ///     status: 503,
    /// };
    /// assert!(!outage.is_not_found());
    ///
    /// let cache_err = DepsError::CacheError("connection reset".into());
    /// assert!(!cache_err.is_not_found());
    /// ```
    #[must_use]
    pub const fn is_not_found(&self) -> bool {
        matches!(
            self,
            Self::PackageNotFound { .. } | Self::HttpStatus { status: 404, .. }
        )
    }

    /// Classifies this error for the per-dependency "registry lookup failed" diagnostic
    /// (#478), distinguishing a failure with a safe, actionable hint to show the user from
    /// one whose raw text must never reach a diagnostic.
    ///
    /// **Security-load-bearing invariant**: [`FetchFailure::Actionable`] is produced only from
    /// a fixed, pre-vetted, IP-free message — never by calling `.to_string()`/`Display` on an
    /// arbitrary `DepsError` to build one, since a raw `HttpStatus` or `RegistryError` body can
    /// embed the caller's public IP (`github.rs:332-346`, exercised by the `github` crate's
    /// `test_parse_tags_page_github_rate_limit_returns_error`).
    ///
    /// **Exhaustive by design, no wildcard arm** (#1244 — the same bug class already fixed
    /// twice for `CompletionContext` (#793, #819) and designed against for `EcosystemId`
    /// (#118)): every current and future [`DepsError`] variant must be listed explicitly, so a
    /// new variant that should carry [`FetchFailure::Actionable`] guidance cannot silently fall
    /// through to [`FetchFailure::Transient`] just by being added after this match was written.
    /// `#[non_exhaustive]` on this enum does not block an exhaustive match here, since this
    /// method is defined in the same crate that declares the enum.
    ///
    /// # Examples
    ///
    /// ```
    /// use deps_core::error::{DepsError, FetchFailure};
    /// use deps_core::RateLimitEvidence;
    ///
    /// let rate_limited = DepsError::rate_limited("set GITHUB_TOKEN", RateLimitEvidence::Confirmed);
    /// assert_eq!(
    ///     rate_limited.fetch_failure(),
    ///     FetchFailure::Actionable("set GITHUB_TOKEN".into())
    /// );
    ///
    /// let other = DepsError::CacheError("connection reset".into());
    /// assert_eq!(other.fetch_failure(), FetchFailure::Transient);
    /// ```
    #[must_use]
    pub fn fetch_failure(&self) -> FetchFailure {
        match self {
            // `verified` does not change the classification: even an unverified rate-limit
            // guess still carries a safe, actionable hint worth showing (#1295).
            Self::RateLimited { message, .. } => FetchFailure::Actionable(message.clone()),
            // Fixed, pre-vetted message — see `Self::ChainResolutionHalted`'s own doc for why
            // this is safe to build as `Actionable` the same way `RateLimited` is.
            Self::ChainResolutionHalted => FetchFailure::Actionable(
                "index unreachable — resolution halted, not falling back to a less-trusted \
                 index"
                    .to_string(),
            ),
            // Deliberately `Transient`, not `Actionable` (#1295 critic S5, reverted from an
            // earlier `Actionable` attempt): `lsp_helpers::diagnostics` already suppresses
            // every per-dependency fetch-failure message while `versions.offline` is set,
            // rendering a single file-level notice instead
            // (`test_generate_diagnostics_from_cache_offline_suppresses_per_dependency_warning`)
            // — an `Actionable` message here would be unreachable in the normal case and would
            // reintroduce exactly the per-dependency duplication that test forbids if it ever
            // did render (a config-flip race between fetch and render). #1244 only asks for
            // exhaustiveness, not a behavior change here.
            Self::Offline { .. }
            | Self::ParseError { .. }
            | Self::RegistryError { .. }
            | Self::CacheError(_)
            | Self::PackageNotFound { .. }
            | Self::HttpStatus { .. }
            | Self::ApiResponse { .. }
            | Self::ResponseTooLarge { .. }
            | Self::InvalidVersionReq(_)
            | Self::InvalidPackageName(_)
            | Self::Io(_)
            | Self::Json(_)
            | Self::UnsupportedEcosystem(_)
            | Self::AmbiguousEcosystem(_)
            | Self::InvalidUri(_) => FetchFailure::Transient,
        }
    }

    /// Returns `true` when this error means a request was blocked by `network.offline`
    /// (issue #483), as opposed to any other network or registry failure.
    ///
    /// Used by `deps_maven::registry` to skip poisoning its negative-search-failure
    /// cache with an offline block, so toggling `network.offline` back to `false` takes
    /// effect immediately instead of being masked by `RECENT_FAILURE_TTL`.
    ///
    /// # Examples
    ///
    /// ```
    /// use deps_core::DepsError;
    ///
    /// let offline = DepsError::Offline { url: "https://crates.io/".into() };
    /// assert!(offline.is_offline());
    ///
    /// let other = DepsError::CacheError("connection reset".into());
    /// assert!(!other.is_offline());
    /// ```
    #[must_use]
    pub const fn is_offline(&self) -> bool {
        matches!(self, Self::Offline { .. })
    }

    /// A URL-free summary of this error, safe to attach to a `tracing` field or log line at
    /// an outbound-request chokepoint. [`Self::HttpStatus`], [`Self::Offline`],
    /// [`Self::ResponseTooLarge`], and [`Self::RegistryError`]'s own `Display` now redact
    /// their URL via [`RedactedUrl`] (#767, #789), but this summary deliberately still never
    /// derives from `self`'s `Display`/`Debug`: a future variant added here should not be
    /// able to reintroduce a leak just by being included in a `{self}` interpolation.
    /// [`Self::RegistryError`]'s wrapped [`SanitizedRegistryError`] can no longer re-embed
    /// the raw URL through its own `Display` either way, since its only constructor strips
    /// it unconditionally.
    ///
    /// Returns the HTTP status code when this is [`Self::HttpStatus`], plus a coarse,
    /// URL-free cause discriminant for every variant — so a routine transport
    /// failure/timeout still carries some triage signal instead of collapsing to `status =
    /// None` with nothing else.
    ///
    /// **Exhaustive by design, no wildcard arm** (#1244 — see [`Self::fetch_failure`]'s doc for
    /// why): kept in lockstep with that method so the two classifiers cannot silently drift
    /// apart on which variants they know about.
    #[must_use]
    pub(crate) const fn safe_tracing_summary(&self) -> (Option<u16>, &'static str) {
        match self {
            Self::HttpStatus { status, .. } => (Some(*status), "http-status"),
            Self::RegistryError { .. } => (None, "transport"),
            Self::CacheError(_) => (None, "cache"),
            Self::Offline { .. } => (None, "offline"),
            Self::ResponseTooLarge { .. } => (None, "response-too-large"),
            // Split by `verified` (#1295 critic S2): a human triaging logs can tell a
            // confirmed rate limit apart from an unverified guess at this label alone,
            // without needing to also inspect the message text.
            Self::RateLimited {
                verified: RateLimitEvidence::Confirmed,
                ..
            } => (None, "rate-limited"),
            Self::RateLimited {
                verified: RateLimitEvidence::Inferred,
                ..
            } => (None, "rate-limited-unverified"),
            Self::ApiResponse { .. } => (None, "api-response"),
            Self::PackageNotFound { .. } => (None, "not-found"),
            Self::ParseError { .. } => (None, "parse-error"),
            Self::InvalidVersionReq(_) => (None, "invalid-version-req"),
            Self::InvalidPackageName(_) => (None, "invalid-package-name"),
            Self::Io(_) => (None, "io"),
            Self::Json(_) => (None, "json"),
            Self::UnsupportedEcosystem(_) => (None, "unsupported-ecosystem"),
            Self::AmbiguousEcosystem(_) => (None, "ambiguous-ecosystem"),
            Self::InvalidUri(_) => (None, "invalid-uri"),
            Self::ChainResolutionHalted => (None, "chain-resolution-halted"),
        }
    }
}

/// Outcome of a registry fetch attempt for one dependency, as recorded in
/// `DocumentState::signals.outcomes` (`deps-lsp`) and rendered by
/// [`crate::lsp_helpers::generate_diagnostics_from_cache`] (#478).
///
/// Replaces a bare `HashSet<PackageName>` membership check so the per-dependency diagnostic
/// can distinguish a failure with a safe, user-actionable hint from an opaque one, without
/// ever threading raw, potentially IP-bearing error text into the diagnostic (see
/// [`DepsError::fetch_failure`]).
#[non_exhaustive]
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FetchFailure {
    /// The fetch failed with a pre-vetted, safe-to-display hint — produced from
    /// [`DepsError::RateLimited`] or [`DepsError::ChainResolutionHalted`] (see
    /// [`DepsError::fetch_failure`] for the exhaustive, deliberately-chosen list of which
    /// variants produce this versus [`Self::Transient`]).
    Actionable(String),
    /// The fetch failed for a reason with no safe user-facing detail to show — the
    /// diagnostic falls back to a generic "lookup failed" message.
    Transient,
    /// The dependency was never actually queried (e.g. a name/source collision detected
    /// before the fetch, see `deps-lsp`'s `dedup_dependencies_by_source`) — renders the
    /// same generic "lookup failed" message as [`Self::Transient`], since the absence of
    /// an attempt is not evidence the package doesn't exist.
    NotAttempted,
}

/// Convenience type alias for `Result<T, DepsError>`.
///
/// This is the standard `Result` type used throughout the deps-lsp codebase.
/// It simplifies function signatures by defaulting the error type to `DepsError`.
///
/// # Examples
///
/// ```
/// use deps_core::error::Result;
///
/// fn get_version(name: &str) -> Result<String> {
///     if name.is_empty() {
///         return Err(deps_core::error::DepsError::CacheError("empty name".into()));
///     }
///     Ok("1.0.0".into())
/// }
/// ```
pub type Result<T> = std::result::Result<T, DepsError>;

#[cfg(test)]
mod tests {
    use super::*;
    use std::error::Error as StdError;

    #[test]
    fn test_error_display() {
        let error = DepsError::CacheError("test error".into());
        assert_eq!(error.to_string(), "cache error: test error");
    }

    /// #789: `SanitizedRegistryError`'s own `Display` must never re-embed the request URL,
    /// even when the wrapped `reqwest::Error` genuinely carries one — a builder-only error
    /// like `Client::get("not a url").build().unwrap_err()` never populates `.url()`, so this
    /// needs a real transport-level failure (connection refused on a closed loopback port) to
    /// actually exercise the strip, mirroring `deps_core::cache`'s own
    /// `test_registry_error_source_redacts_url_on_real_transport_error`.
    #[tokio::test]
    async fn test_sanitized_registry_error_strips_url_on_real_transport_error() {
        let port = {
            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
            listener.local_addr().unwrap().port()
        };
        let url = format!("http://127.0.0.1:{port}/pkg?token=super-secret-value");

        let reqwest_err = reqwest::Client::new().get(&url).send().await.unwrap_err();
        assert!(
            reqwest_err.url().is_some(),
            "test setup invariant: the underlying reqwest::Error must carry a URL"
        );

        let sanitized: SanitizedRegistryError = reqwest_err.into();
        assert!(
            !sanitized.to_string().contains("super-secret-value"),
            "sanitized: {sanitized}"
        );
        assert!(
            !format!("{sanitized:?}").contains("super-secret-value"),
            "sanitized debug: {sanitized:?}"
        );
    }

    /// #789 S2: `SanitizedRegistryError` must treat itself as a leaf node for source-chaining
    /// purposes — `reqwest`'s redirect policy can nest a *second* `reqwest::Error` (with its
    /// own populated `url` field) as the outer error's `source` (see the type's own docs for
    /// the exact `https_only`/redirect code path), which `.without_url()` on the outer error
    /// alone does not touch. This proves the wrapper discards a real, populated source chain
    /// (not merely that one never existed): the *raw* wrapped `reqwest::Error` behind
    /// `sanitized.0` (accessible here since `mod tests` is a child of `error`) genuinely has
    /// `.source().is_some()` for a real connection-refused failure, yet `SanitizedRegistryError`'s
    /// own `source()` is unconditionally `None`.
    #[tokio::test]
    async fn test_sanitized_registry_error_source_is_always_none() {
        let port = {
            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
            listener.local_addr().unwrap().port()
        };
        let url = format!("http://127.0.0.1:{port}/pkg?token=super-secret-value");

        let reqwest_err = reqwest::Client::new().get(&url).send().await.unwrap_err();
        let sanitized: SanitizedRegistryError = reqwest_err.into();
        assert!(
            sanitized.0.source().is_some(),
            "test setup invariant: a connection-refused error must have a populated source chain"
        );
        assert!(
            StdError::source(&sanitized).is_none(),
            "SanitizedRegistryError::source() must always be None"
        );
    }

    /// #756 code-review finding: `safe_tracing_summary` must never derive its output from
    /// `self`'s own `Display`/`Debug` (both embed the raw, unredacted request URL for several
    /// variants) — pinning the exact `(status, cause)` pairs for every variant a `HttpCache`/
    /// `OsvClient` call site can actually produce, so a routine transport failure still
    /// carries triage signal (`cause`) instead of collapsing to `(None, "other")`.
    ///
    /// #1295 critic M2: genuinely exhaustive now, matching the method's own no-wildcard-arm
    /// match — every `DepsError` variant gets its own assertion, not a handful of spot checks,
    /// so a typo'd or missing label can't ship silently.
    #[test]
    fn test_safe_tracing_summary_covers_every_reachable_variant() {
        assert_eq!(
            DepsError::HttpStatus {
                url: "https://example.com/pkg?token=secret".into(),
                status: 503,
            }
            .safe_tracing_summary(),
            (Some(503), "http-status")
        );
        assert_eq!(
            DepsError::RegistryError {
                package: "https://example.com/pkg?token=secret".into(),
                source: reqwest::Client::new()
                    .get("not a url")
                    .build()
                    .unwrap_err()
                    .into(),
            }
            .safe_tracing_summary(),
            (None, "transport")
        );
        assert_eq!(
            DepsError::CacheError("poisoned lock".into()).safe_tracing_summary(),
            (None, "cache")
        );
        assert_eq!(
            DepsError::Offline {
                url: "https://example.com/pkg?token=secret".into(),
            }
            .safe_tracing_summary(),
            (None, "offline")
        );
        assert_eq!(
            DepsError::ResponseTooLarge {
                url: "https://example.com/pkg?token=secret".into(),
                limit: 1024,
            }
            .safe_tracing_summary(),
            (None, "response-too-large")
        );
        assert_eq!(
            DepsError::RateLimited {
                message: "set GITHUB_TOKEN".into(),
                verified: RateLimitEvidence::Confirmed,
                source_status: Some(403),
            }
            .safe_tracing_summary(),
            (None, "rate-limited")
        );
        assert_eq!(
            DepsError::RateLimited {
                message: "set GITHUB_TOKEN".into(),
                verified: RateLimitEvidence::Inferred,
                source_status: None,
            }
            .safe_tracing_summary(),
            (None, "rate-limited-unverified")
        );
        assert_eq!(
            DepsError::ApiResponse {
                package: "flask".into(),
                registry: "PyPI",
                source: serde_json::from_str::<serde_json::Value>("{invalid}").unwrap_err(),
            }
            .safe_tracing_summary(),
            (None, "api-response")
        );
        assert_eq!(
            DepsError::PackageNotFound {
                package: "flask".into(),
                registry: "PyPI",
            }
            .safe_tracing_summary(),
            (None, "not-found")
        );
        assert_eq!(
            DepsError::ParseError {
                file_type: "Cargo.toml".into(),
                source: Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, "bad")),
            }
            .safe_tracing_summary(),
            (None, "parse-error")
        );
        assert_eq!(
            DepsError::InvalidVersionReq("bad range".into()).safe_tracing_summary(),
            (None, "invalid-version-req")
        );
        assert_eq!(
            DepsError::Io(std::io::Error::new(
                std::io::ErrorKind::NotFound,
                "not found"
            ))
            .safe_tracing_summary(),
            (None, "io")
        );
        assert_eq!(
            DepsError::Json(serde_json::from_str::<serde_json::Value>("{bad}").unwrap_err())
                .safe_tracing_summary(),
            (None, "json")
        );
        assert_eq!(
            DepsError::UnsupportedEcosystem("unknown".into()).safe_tracing_summary(),
            (None, "unsupported-ecosystem")
        );
        assert_eq!(
            DepsError::AmbiguousEcosystem("file.txt".into()).safe_tracing_summary(),
            (None, "ambiguous-ecosystem")
        );
        assert_eq!(
            DepsError::InvalidUri("not a uri".into()).safe_tracing_summary(),
            (None, "invalid-uri")
        );
        assert_eq!(
            DepsError::ChainResolutionHalted.safe_tracing_summary(),
            (None, "chain-resolution-halted")
        );
    }

    /// A `(status, cause)` pair must never itself carry the URL — the whole point of this
    /// method — even when the source error's own `Display` would have.
    #[test]
    fn test_safe_tracing_summary_output_never_contains_the_url() {
        let error = DepsError::HttpStatus {
            url: "https://npm.internal/pkg?token=super-secret-value".into(),
            status: 503,
        };
        let (status, cause) = error.safe_tracing_summary();
        assert_eq!(status, Some(503));
        assert!(!cause.contains("super-secret-value"));
        assert!(!cause.contains("npm.internal"));
    }

    #[test]
    fn test_response_too_large() {
        let error = DepsError::ResponseTooLarge {
            url: "https://example.com/data".into(),
            limit: 32 * 1024 * 1024,
        };
        assert_eq!(
            error.to_string(),
            "response body for https://example.com/data exceeds 33554432 byte limit"
        );
    }

    /// #767: `HttpStatus`'s `Display` is surfaced verbatim through `window/showMessage`
    /// (`deps-lsp`'s fetch-failure toast), so a query-string credential (e.g. an `.npmrc`
    /// `?_authToken=...` value after `${VAR}` expansion) must never reach it.
    #[test]
    fn test_http_status_display_redacts_query_string() {
        let error = DepsError::HttpStatus {
            url: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
            status: 503,
        };
        let message = error.to_string();
        assert!(
            !message.contains("super-secret-value"),
            "message: {message}"
        );
        assert_eq!(
            message,
            "HTTP 503 Service Unavailable for https://npm.internal/pkg"
        );
    }

    /// #767 companion for [`DepsError::RegistryError`], whose `package` field is frequently
    /// populated with a raw URL rather than a package name (`deps-core::cache`'s
    /// `read_body_capped`/`get_cached_with_headers_via`/`post_json`/`get_cached_bytes`).
    #[test]
    fn test_registry_error_display_redacts_query_string() {
        let error = DepsError::RegistryError {
            package: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
            source: reqwest::Client::new()
                .get("not a url")
                .build()
                .unwrap_err()
                .into(),
        };
        let message = error.to_string();
        assert!(
            !message.contains("super-secret-value"),
            "message: {message}"
        );
        assert!(message.starts_with("registry request failed for https://npm.internal/pkg:"));
    }

    /// #767 M1: `RegistryError::package` is documented as a package name, and an npm-scoped
    /// name (leading `@`, no scheme) must never be mangled by the URL redaction meant for
    /// call sites that populate this field with a URL instead — reproduced false positive:
    /// `url_for_tracing`'s unparseable-URL fallback previously misread `@types/node` as
    /// `user@host`-shaped userinfo and rendered `***@types/node`.
    #[test]
    fn test_registry_error_display_does_not_mangle_scoped_package_name() {
        let error = DepsError::RegistryError {
            package: "@types/node".into(),
            source: reqwest::Client::new()
                .get("not a url")
                .build()
                .unwrap_err()
                .into(),
        };
        let message = error.to_string();
        assert!(
            message.starts_with("registry request failed for @types/node:"),
            "message: {message}"
        );
    }

    /// #767: `Offline`'s `Display` also embeds the blocked request URL — same redaction
    /// applies.
    #[test]
    fn test_offline_display_redacts_query_string() {
        let error = DepsError::Offline {
            url: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
        };
        let message = error.to_string();
        assert!(
            !message.contains("super-secret-value"),
            "message: {message}"
        );
    }

    /// #767: `ResponseTooLarge`'s `Display` also embeds the source URL — same redaction
    /// applies.
    #[test]
    fn test_response_too_large_display_redacts_query_string() {
        let error = DepsError::ResponseTooLarge {
            url: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
            limit: 1024,
        };
        let message = error.to_string();
        assert!(
            !message.contains("super-secret-value"),
            "message: {message}"
        );
    }

    /// Code-review follow-up on #767: `DepsError` derives `Debug` no longer — a derived
    /// impl would print `HttpStatus.url`/`Offline.url`/`ResponseTooLarge.url`/
    /// `RegistryError.package` raw, so any `tracing::warn!(?err, ...)`/`{err:?}` call site
    /// (a common, arguably more idiomatic alternative to `%err`) would bypass every
    /// hand-written `Display` redaction above and reintroduce the leak. Covers all four
    /// URL-shaped variants via `{:?}`.
    #[test]
    fn test_debug_redacts_query_string_for_every_url_bearing_variant() {
        let credential_bearing = [
            DepsError::HttpStatus {
                url: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
                status: 503,
            },
            DepsError::Offline {
                url: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
            },
            DepsError::ResponseTooLarge {
                url: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
                limit: 1024,
            },
            DepsError::RegistryError {
                package: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
                source: reqwest::Client::new()
                    .get("not a url")
                    .build()
                    .unwrap_err()
                    .into(),
            },
        ];
        for error in credential_bearing {
            let debug = format!("{error:?}");
            assert!(
                !debug.contains("super-secret-value"),
                "Debug leaked the credential for {error}: {debug}"
            );
        }
    }

    /// #789 S1 regression: destructuring `HttpStatus.url` directly (bypassing `Display`/
    /// `Debug` entirely) must still yield only redacted text — the field's type itself
    /// (`RedactedUrl`, not `String`) is what makes this true, not any redaction step at the
    /// access site. There is no way to get the raw string back out even via direct field
    /// access, because nothing raw was ever stored in the field to begin with.
    #[test]
    fn test_http_status_url_field_is_redacted_even_via_direct_destructure() {
        let error = DepsError::HttpStatus {
            url: RedactedUrl::new("https://npm.internal/pkg?_authToken=super-secret-value"),
            status: 401,
        };
        let DepsError::HttpStatus { url, .. } = error else {
            unreachable!()
        };
        assert_eq!(url.to_string(), "https://npm.internal/pkg");
        assert!(!url.to_string().contains("super-secret-value"));
    }

    /// `Debug` must still show every field for the ordinary, non-URL-bearing variants —
    /// this is a redaction fix, not a summary, so field values unrelated to a URL must
    /// come through unchanged.
    #[test]
    fn test_debug_still_shows_non_url_fields() {
        let error = DepsError::PackageNotFound {
            package: "left-pad".into(),
            registry: "npm",
        };
        let debug = format!("{error:?}");
        assert!(debug.contains("left-pad"), "debug: {debug}");
        assert!(debug.contains("npm"), "debug: {debug}");
    }

    #[test]
    fn test_invalid_version_req() {
        let error = DepsError::InvalidVersionReq("invalid".into());
        assert_eq!(error.to_string(), "invalid version requirement: invalid");
    }

    #[test]
    fn test_parse_error() {
        let io_err = std::io::Error::new(std::io::ErrorKind::InvalidData, "bad data");
        let error = DepsError::ParseError {
            file_type: "Cargo.toml".into(),
            source: Box::new(io_err),
        };
        assert!(error.to_string().contains("failed to parse Cargo.toml"));
    }

    #[test]
    fn test_io_error_conversion() {
        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
        let error: DepsError = io_err.into();
        assert!(error.to_string().contains("I/O error"));
    }

    #[test]
    fn test_unsupported_ecosystem() {
        let error = DepsError::UnsupportedEcosystem("unknown".into());
        assert_eq!(error.to_string(), "unsupported ecosystem: unknown");
    }

    #[test]
    fn test_ambiguous_ecosystem() {
        let error = DepsError::AmbiguousEcosystem("file.txt".into());
        assert_eq!(
            error.to_string(),
            "ambiguous ecosystem detection for file: file.txt"
        );
    }

    #[test]
    fn test_invalid_uri() {
        let error = DepsError::InvalidUri("http://example.com".into());
        assert_eq!(error.to_string(), "invalid URI: http://example.com");
    }

    #[test]
    fn test_offline_error_display_and_predicate() {
        let error = DepsError::Offline {
            url: "https://crates.io/api/v1/crates/serde".into(),
        };
        assert!(error.to_string().contains("offline"));
        assert!(error.is_offline());
        assert!(!error.is_not_found());

        let other = DepsError::CacheError("boom".into());
        assert!(!other.is_offline());
    }

    #[test]
    fn test_package_not_found() {
        let error = DepsError::PackageNotFound {
            package: "flask".into(),
            registry: "PyPI",
        };
        assert_eq!(error.to_string(), "flask not found on PyPI");
    }

    /// #1209: `PackageNotFound.package` is redacted at construction (via [`RedactedName`]),
    /// so a credential embedded in a manifest's name-shaped field (e.g. Maven property
    /// interpolation producing `group:artifact:secret@host`) must never reach this variant's
    /// `Display`/`Debug` — the sink a server log or an LSP client toast (`window/showMessage`)
    /// reads verbatim.
    #[test]
    fn test_package_not_found_display_redacts_credential_shaped_name() {
        let error = DepsError::PackageNotFound {
            package: "com.example:deploy:AUDITSENTINEL0000@git.internal.corp".into(),
            registry: "Maven Central",
        };
        assert!(
            !error.to_string().contains("AUDITSENTINEL0000"),
            "Display leaked a credential-shaped package name: {error}"
        );
        assert!(
            !format!("{error:?}").contains("AUDITSENTINEL0000"),
            "Debug leaked a credential-shaped package name: {error:?}"
        );
        assert!(error.to_string().contains("git.internal.corp"));
    }

    /// Same sink, `ApiResponse` variant (#1209).
    #[test]
    fn test_api_response_display_redacts_credential_shaped_name() {
        let json_err = serde_json::from_str::<serde_json::Value>("{invalid}").unwrap_err();
        let error = DepsError::ApiResponse {
            package: "com.example:deploy:AUDITSENTINEL0000@git.internal.corp".into(),
            registry: "Maven Central",
            source: json_err,
        };
        assert!(
            !error.to_string().contains("AUDITSENTINEL0000"),
            "Display leaked a credential-shaped package name: {error}"
        );
        assert!(
            !format!("{error:?}").contains("AUDITSENTINEL0000"),
            "Debug leaked a credential-shaped package name: {error:?}"
        );
    }

    #[test]
    fn test_http_status_with_known_reason() {
        let error = DepsError::HttpStatus {
            url: "https://example.com/data".into(),
            status: 404,
        };
        assert_eq!(
            error.to_string(),
            "HTTP 404 Not Found for https://example.com/data"
        );
    }

    #[test]
    fn test_http_status_with_unknown_code() {
        let error = DepsError::HttpStatus {
            url: "https://example.com/data".into(),
            status: 599,
        };
        assert_eq!(error.to_string(), "HTTP 599 for https://example.com/data");
    }

    #[test]
    fn test_api_response_error() {
        let json_err = serde_json::from_str::<serde_json::Value>("{invalid}").unwrap_err();
        let error = DepsError::ApiResponse {
            package: "flask".into(),
            registry: "PyPI",
            source: json_err,
        };
        assert!(
            error
                .to_string()
                .starts_with("failed to parse PyPI response for flask:")
        );
    }

    /// Exhaustive companion to the doc-test on [`DepsError::fetch_failure`]: every variant
    /// other than [`DepsError::RateLimited`] and [`DepsError::ChainResolutionHalted`] must
    /// classify as [`FetchFailure::Transient`] — including [`DepsError::Offline`] (#1295
    /// critic S5: deliberately *not* `Actionable`, see `fetch_failure`'s own comment on that
    /// arm). This is the invariant the doc comment calls security-load-bearing (a future
    /// variant wired to `Actionable` by mistake could leak raw, potentially IP-bearing error
    /// text into a diagnostic), so it must be a real test enumerating every variant, not just a
    /// handful of spot checks. The two exempted variants carry no arbitrary payload, only a
    /// fixed, pre-vetted message, so each is safe to be an `Actionable`-producing variant (see
    /// their own docs, and #513's M2 fix for `ChainResolutionHalted`).
    #[test]
    fn test_fetch_failure_classifies_every_non_rate_limited_variant_as_transient() {
        // A `reqwest::Error` built from an invalid URL — `RequestBuilder::build`
        // is synchronous and fails on URL parsing alone, so this needs no network
        // access or async runtime.
        let reqwest_err = reqwest::Client::new()
            .get("not a valid url")
            .build()
            .unwrap_err();
        let json_err = serde_json::from_str::<serde_json::Value>("{invalid}").unwrap_err();
        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");

        let non_rate_limited = [
            DepsError::ParseError {
                file_type: "Cargo.toml".into(),
                source: Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, "bad")),
            },
            DepsError::RegistryError {
                package: "flask".into(),
                source: reqwest_err.into(),
            },
            DepsError::CacheError("connection reset".into()),
            DepsError::PackageNotFound {
                package: "flask".into(),
                registry: "PyPI",
            },
            DepsError::HttpStatus {
                url: "https://example.com".into(),
                status: 500,
            },
            DepsError::ApiResponse {
                package: "flask".into(),
                registry: "PyPI",
                source: json_err,
            },
            DepsError::ResponseTooLarge {
                url: "https://example.com".into(),
                limit: 1024,
            },
            DepsError::InvalidVersionReq("bad range".into()),
            DepsError::Io(io_err),
            DepsError::Json(serde_json::from_str::<serde_json::Value>("{bad}").unwrap_err()),
            DepsError::UnsupportedEcosystem("unknown".into()),
            DepsError::AmbiguousEcosystem("file.txt".into()),
            DepsError::InvalidUri("not a uri".into()),
            DepsError::Offline {
                url: "https://example.com".into(),
            },
        ];

        for error in non_rate_limited {
            assert_eq!(
                error.fetch_failure(),
                FetchFailure::Transient,
                "expected Transient for {error:?}"
            );
        }

        for verified in [RateLimitEvidence::Confirmed, RateLimitEvidence::Inferred] {
            let rate_limited = DepsError::RateLimited {
                message: "set GITHUB_TOKEN to increase the rate limit".into(),
                verified,
                source_status: None,
            };
            assert_eq!(
                rate_limited.fetch_failure(),
                FetchFailure::Actionable("set GITHUB_TOKEN to increase the rate limit".into()),
                "verified={verified:?}"
            );
        }

        assert_eq!(
            DepsError::ChainResolutionHalted.fetch_failure(),
            FetchFailure::Actionable(
                "index unreachable — resolution halted, not falling back to a less-trusted \
                 index"
                    .to_string()
            )
        );
    }
}