nsip 0.7.2

NSIP Search API client for nsipsearch.nsip.org/api
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
//! RFC 9457 Problem Details envelope (`application/problem+json`).
//!
//! Every `Error` maps to a [`ProblemDetails`] object so that LLM
//! agents orchestrating `nsip` (via the CLI or the MCP server) receive a
//! spec-exact, machine-parseable error contract instead of a bare string.
//! Humans keep the `miette` graphical rendering (see `crates/render.rs`).
//!
//! The struct is hand-rolled rather than depending on the `rfc9457` crate
//! (0.1.0 as of 2026-06): the five RFC 9457 core members plus the
//! `nsip`-specific extensions are the complete public surface. Applicability
//! markers live in the documentation catalog (`docs/reference/errors/README.md`)
//! keyed by `type`, not inline, to hold the envelope under the 1 KB cap.
//!
//! See [`Error::to_problem_details`](crate::Error::to_problem_details) for the
//! variant → envelope mapping and `docs/reference/ERROR-ENVELOPE.md` for the
//! field schema.

use std::borrow::Cow;

use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::{Error, ValidationKind};

/// Resolve a slug from a compile-time override (set by `build.rs` from
/// `[package.metadata.nsip.error-slugs]`) or fall back to the in-source default.
const fn slug_or(over: Option<&'static str>, default: &'static str) -> &'static str {
    match over {
        Some(s) => s,
        None => default,
    }
}

/// `<domain>/<slug>` path for a [`ValidationKind`]; the per-operation problem
/// type. Each slug is overridable via `[package.metadata.nsip.error-slugs]`.
const fn validation_slug(kind: ValidationKind) -> &'static str {
    match kind {
        ValidationKind::EmptyLpnId => {
            slug_or(option_env!("NSIP_SLUG_EMPTY_LPN_ID"), "cli/empty-lpn-id")
        },
        ValidationKind::InvalidBreedId => slug_or(
            option_env!("NSIP_SLUG_INVALID_BREED_ID"),
            "cli/invalid-breed-id",
        ),
        ValidationKind::PageRange => slug_or(option_env!("NSIP_SLUG_PAGE_RANGE"), "cli/page-range"),
        ValidationKind::EmptySearch => {
            slug_or(option_env!("NSIP_SLUG_EMPTY_SEARCH"), "cli/empty-search")
        },
        ValidationKind::CompareArity => {
            slug_or(option_env!("NSIP_SLUG_COMPARE_ARITY"), "cli/compare-arity")
        },
        ValidationKind::MissingArgument => slug_or(
            option_env!("NSIP_SLUG_MISSING_ARGUMENT"),
            "mcp/missing-argument",
        ),
        ValidationKind::UnknownResource => slug_or(
            option_env!("NSIP_SLUG_UNKNOWN_RESOURCE"),
            "mcp/unknown-resource",
        ),
        ValidationKind::InvalidCursor => slug_or(
            option_env!("NSIP_SLUG_INVALID_CURSOR"),
            "mcp/invalid-cursor",
        ),
        ValidationKind::UnknownTransport => slug_or(
            option_env!("NSIP_SLUG_UNKNOWN_TRANSPORT"),
            "cli/unknown-transport",
        ),
        ValidationKind::Other => slug_or(option_env!("NSIP_SLUG_VALIDATION"), "cli/validation"),
    }
}

/// Stable, type-level title for a [`ValidationKind`].
const fn validation_title(kind: ValidationKind) -> &'static str {
    match kind {
        ValidationKind::EmptyLpnId => "LPN ID must not be empty",
        ValidationKind::InvalidBreedId => "Breed ID is invalid",
        ValidationKind::PageRange => "Pagination parameter out of range",
        ValidationKind::EmptySearch => "Search request has no filter",
        ValidationKind::CompareArity => "Comparison requires 2 to 5 animals",
        ValidationKind::MissingArgument => "Required argument is missing",
        ValidationKind::UnknownResource => "Unknown resource URI",
        ValidationKind::InvalidCursor => "Invalid pagination cursor",
        ValidationKind::UnknownTransport => "Unknown MCP transport",
        ValidationKind::Other => "Invalid input parameters",
    }
}

/// Tailored, per-operation `suggested_fix` for a [`ValidationKind`]. Every kind
/// has a deterministic fix; applicability markers are catalogued in
/// `docs/reference/errors/README.md` keyed by `type`.
fn validation_fix(kind: ValidationKind, message: &str) -> String {
    match kind {
        ValidationKind::EmptyLpnId => "provide a non-empty LPN ID".to_owned(),
        ValidationKind::InvalidBreedId => {
            "provide a positive integer breed id (see the breed_groups tool)".to_owned()
        },
        ValidationKind::PageRange => "use page >= 1 and page_size between 1 and 100".to_owned(),
        ValidationKind::EmptySearch => {
            "provide a non-empty query (an LPN ID or name) or at least one search filter".to_owned()
        },
        ValidationKind::CompareArity => "pass between 2 and 5 LPN IDs".to_owned(),
        ValidationKind::MissingArgument => format!("provide the required argument: {message}"),
        ValidationKind::UnknownResource => {
            "use a documented nsip:// resource URI (see nsip://glossary)".to_owned()
        },
        ValidationKind::InvalidCursor => {
            "restart pagination without a cursor (begin from the first page)".to_owned()
        },
        ValidationKind::UnknownTransport => "use --transport stdio or --transport http".to_owned(),
        ValidationKind::Other => format!("correct the input and retry: {message}"),
    }
}

/// Base for `type`/`docs_url` URIs, configurable via
/// `[package.metadata.nsip].error-type-uri-base` (resolved in `build.rs`);
/// defaults to the repository's stable docs path. Per the committed policy the
/// URI carries no path version; semantic changes are tracked in the
/// documentation changelog. See `docs/adr` and `docs/reference/ERROR-ENVELOPE.md`.
const TYPE_URI_BASE: &str = env!("NSIP_ERROR_TYPE_URI_BASE");

/// Maximum byte length of the envelope `detail`. The `Api` variant's message is
/// the raw upstream response body (unbounded — see `client.rs`), so it is
/// truncated here to keep the whole envelope within the ~1 KB budget the module
/// documents. The other members (type, title, instance, fixed-string fixes) are
/// short and bounded by construction.
const MAX_DETAIL_LEN: usize = 480;

/// Truncate `detail` to at most [`MAX_DETAIL_LEN`] bytes on a `char` boundary,
/// appending an ellipsis when truncation occurs. Never splits a multi-byte
/// `char` (which would yield invalid UTF-8).
fn truncate_detail(detail: &str) -> String {
    if detail.len() <= MAX_DETAIL_LEN {
        return detail.to_owned();
    }
    let mut end = MAX_DETAIL_LEN;
    while !detail.is_char_boundary(end) {
        end -= 1;
    }
    format!("{}", &detail[..end])
}

/// RFC 9457 Problem Details object (`application/problem+json`).
///
/// The five RFC 9457 standard members (`type`, `title`, `status`, `detail`,
/// `instance`) plus the agent extensions (`exit_code`, `suggested_fix`,
/// `code_actions`, `retry_after`, `docs_url`). Empty/absent optional members
/// are omitted from the JSON for token economy.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProblemDetails {
    /// Stable URI identifying the problem type. The agent's dispatch key.
    #[serde(rename = "type")]
    pub type_uri: String,

    /// One-sentence, human-readable summary of the problem type (stable per `type`).
    pub title: String,

    /// HTTP-class status: 4xx for caller/upstream-client errors, 5xx for
    /// transient/environment failures.
    pub status: u16,

    /// One to three sentences specific to this occurrence.
    pub detail: String,

    /// Per-occurrence correlation handle, `urn:nsip:<command>:<uuid>`.
    pub instance: String,

    /// Process exit code (`sysexits.h`-aligned where applicable).
    pub exit_code: i32,

    /// Free-text recovery action. Omitted when no deterministic fix exists.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub suggested_fix: Option<String>,

    /// LSP-style structured edits. Omitted when empty (the common case).
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    pub code_actions: Vec<CodeAction>,

    /// Delta-seconds or RFC 3339 timestamp. Populated only on transient errors.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub retry_after: Option<RetryAfter>,

    /// Stable documentation URL for this problem type (same as `type` by default).
    pub docs_url: String,
}

/// LSP-style code action carrying a structured edit suggestion.
///
/// Reserved for future use (e.g. corrected CLI flags); the current error set
/// emits no structured edits, so the field is omitted from serialized output.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodeAction {
    /// Human-readable title for the action.
    pub title: String,
    /// LSP `CodeActionKind` (e.g. `"quickfix"`).
    pub kind: Cow<'static, str>,
    /// The structured edit payload.
    pub edit: serde_json::Value,
    /// Whether the agent should prefer this action.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_preferred: Option<bool>,
}

/// Either a delta-seconds duration or an absolute RFC 3339 timestamp, matching
/// the two forms of the HTTP `Retry-After` header (RFC 7231 §7.1.3).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum RetryAfter {
    /// Delta-seconds: retry is safe after this many seconds.
    Seconds(u32),
    /// Absolute RFC 3339 timestamp after which retry is safe.
    Timestamp(String),
}

impl Error {
    /// `<domain>/<slug>` path component for this error's type URI, e.g.
    /// `api/timeout`. One slug per variant (the `Api` variant carries its HTTP
    /// status in the `status` field rather than splitting into sub-slugs).
    #[must_use]
    pub const fn slug_path(&self) -> &'static str {
        match self {
            Self::Validation { kind, .. } => validation_slug(*kind),
            Self::Api { .. } => slug_or(option_env!("NSIP_SLUG_API_ERROR"), "api/error"),
            Self::NotFound(_) => slug_or(option_env!("NSIP_SLUG_API_NOT_FOUND"), "api/not-found"),
            Self::Timeout { .. } => slug_or(option_env!("NSIP_SLUG_API_TIMEOUT"), "api/timeout"),
            Self::Connection { .. } => {
                slug_or(option_env!("NSIP_SLUG_API_CONNECTION"), "api/connection")
            },
            Self::Parse { .. } => slug_or(
                option_env!("NSIP_SLUG_API_UPSTREAM_PARSE"),
                "api/upstream-parse",
            ),
        }
    }

    /// Stable type URI under the repository's `docs/reference/errors/` tree.
    #[must_use]
    pub fn type_uri(&self) -> String {
        format!("{TYPE_URI_BASE}/{}.md", self.slug_path())
    }

    /// One-sentence, type-level title (stable across occurrences).
    #[must_use]
    pub const fn title(&self) -> &'static str {
        match self {
            Self::Validation { kind, .. } => validation_title(*kind),
            Self::Api { .. } => "Upstream API returned an error",
            Self::NotFound(_) => "Requested resource was not found",
            Self::Timeout { .. } => "Request to the NSIP API timed out",
            Self::Connection { .. } => "Could not connect to the NSIP API",
            Self::Parse { .. } => "Could not parse the NSIP API response",
        }
    }

    /// Process exit code for this error.
    ///
    /// Aligned with `sysexits.h` where applicable:
    /// * `1` — caller error (bad input, 4xx, not found).
    /// * `3` — environment error (unparseable upstream payload).
    /// * `75` — `EX_TEMPFAIL`, transient (timeout, connection, 429, 5xx);
    ///   `retry_after` is populated where a delay is known.
    #[must_use]
    pub const fn exit_code(&self) -> i32 {
        self.exit_and_status().0
    }

    /// HTTP-class status for the Problem Details envelope.
    #[must_use]
    pub const fn status_code(&self) -> u16 {
        self.exit_and_status().1
    }

    /// Joint `(exit_code, status)` lookup. The `Api` variant reflects the real
    /// upstream HTTP status and classifies 429/5xx as transient.
    const fn exit_and_status(&self) -> (i32, u16) {
        match self {
            Self::Validation { .. } => (1, 400),
            Self::NotFound(_) => (1, 404),
            Self::Parse { .. } => (3, 502),
            Self::Timeout { .. } => (75, 504),
            Self::Connection { .. } => (75, 503),
            Self::Api { status, .. } => {
                if *status == 429 || *status >= 500 {
                    (75, *status)
                } else {
                    (1, *status)
                }
            },
        }
    }

    /// Free-text recovery action for this error, or `None` when there is no
    /// deterministic fix. Applicability markers for each fix are catalogued in
    /// `docs/reference/errors/README.md`, keyed by `type`.
    #[must_use]
    pub fn suggested_fix(&self) -> Option<String> {
        let s = match self {
            Self::Validation { kind, message } => return Some(validation_fix(*kind, message)),
            Self::NotFound(_) => {
                "verify the identifier exists in the NSIP database (try `nsip search`)".to_owned()
            },
            Self::Timeout { .. } => {
                "retry the request; increase the client timeout if this persists".to_owned()
            },
            Self::Connection { .. } => {
                "check network connectivity to nsipsearch.nsip.org and retry".to_owned()
            },
            Self::Api { status, .. } if *status == 429 => {
                "wait for the retry_after interval before retrying".to_owned()
            },
            Self::Api { status, .. } if *status >= 500 => {
                "the NSIP API is failing; retry after a short delay".to_owned()
            },
            // 4xx (non-429) client errors and upstream parse failures have no
            // deterministic local fix.
            _ => return None,
        };
        Some(s)
    }

    /// Retry delay for transient errors, sourced from the upstream
    /// `Retry-After` header where available. `None` for terminal errors.
    #[must_use]
    pub fn retry_after(&self) -> Option<RetryAfter> {
        match self {
            Self::Api { retry_after, .. }
            | Self::Timeout { retry_after, .. }
            | Self::Connection { retry_after, .. } => retry_after.clone(),
            Self::Validation { .. } | Self::NotFound(_) | Self::Parse { .. } => None,
        }
    }

    /// Build the RFC 9457 Problem Details object for this error.
    ///
    /// `command` seeds the `instance` URN — pass the CLI subcommand or MCP tool
    /// name (e.g. `"search"`), or `"nsip"` if unknown.
    ///
    /// # Examples
    ///
    /// ```
    /// let err = nsip::Error::NotFound("LPN 123".to_string());
    /// let pd = err.to_problem_details("details");
    /// assert_eq!(pd.status, 404);
    /// assert!(pd.instance.starts_with("urn:nsip:details:"));
    /// ```
    #[must_use]
    pub fn to_problem_details(&self, command: &str) -> ProblemDetails {
        let type_uri = self.type_uri();
        let instance = format!("urn:nsip:{command}:{}", Uuid::new_v4());
        ProblemDetails {
            docs_url: type_uri.clone(),
            type_uri,
            title: self.title().to_owned(),
            status: self.status_code(),
            detail: truncate_detail(&self.to_string()),
            instance,
            exit_code: self.exit_code(),
            suggested_fix: self.suggested_fix(),
            code_actions: Vec::new(),
            retry_after: self.retry_after(),
        }
    }
}

#[cfg(test)]
#[allow(
    clippy::unwrap_used,
    clippy::expect_used,
    reason = "tests may panic on setup failure"
)]
mod tests {
    use super::*;

    /// One representative of every variant, for exhaustive envelope checks.
    fn all_variants() -> Vec<Error> {
        vec![
            Error::validation("bad breed_id"),
            Error::api(404, "missing"),
            Error::api(429, "slow down"),
            Error::api(503, "upstream down"),
            Error::not_found("LPN 999"),
            Error::timeout("30s exceeded"),
            Error::connection("refused"),
            Error::parse("bad json"),
        ]
    }

    /// Every variant produces a complete, spec-valid envelope with a unique
    /// `urn:nsip:` instance.
    #[test]
    fn envelope_populated_for_every_variant() {
        let mut seen = std::collections::HashSet::new();
        for err in all_variants() {
            let pd = err.to_problem_details("test");
            assert!(
                pd.type_uri.starts_with(
                    "https://github.com/zircote/nsip/blob/main/docs/reference/errors/"
                ),
                "type_uri: {}",
                pd.type_uri
            );
            // Slug ends in `<word>.md`; check the suffix via the last 3 chars
            // rather than `ends_with(".md")` to avoid clippy's path-extension lint.
            assert_eq!(&pd.type_uri[pd.type_uri.len() - 3..], ".md");
            assert!(!pd.title.is_empty());
            assert!(pd.status >= 400);
            assert!(!pd.detail.is_empty());
            assert!(pd.instance.starts_with("urn:nsip:test:"));
            assert!(pd.exit_code > 0);
            assert_eq!(pd.docs_url, pd.type_uri);
            assert!(
                seen.insert(pd.instance.clone()),
                "instance URN must be unique per call"
            );
        }
    }

    /// Exit-code / status map matches the committed catalog table.
    #[test]
    fn exit_and_status_map_matches_catalog() {
        assert_eq!(Error::validation("x").exit_code(), 1);
        assert_eq!(Error::validation("x").status_code(), 400);
        assert_eq!(Error::not_found("x").status_code(), 404);
        assert_eq!(Error::parse("x").exit_code(), 3);
        assert_eq!(Error::parse("x").status_code(), 502);
        assert_eq!(Error::timeout("x").exit_code(), 75);
        assert_eq!(Error::timeout("x").status_code(), 504);
        assert_eq!(Error::connection("x").exit_code(), 75);
        assert_eq!(Error::connection("x").status_code(), 503);
        // Api reflects the real upstream status; 4xx terminal, 429/5xx transient.
        assert_eq!(Error::api(400, "x").exit_code(), 1);
        assert_eq!(Error::api(429, "x").exit_code(), 75);
        assert_eq!(Error::api(503, "x").exit_code(), 75);
        assert_eq!(Error::api(418, "x").status_code(), 418);
    }

    /// Transient variants surface a populated `retry_after`; terminal ones do not.
    #[test]
    fn retry_after_only_on_transient() {
        let transient = Error::Api {
            status: 429,
            message: "rate limited".to_owned(),
            retry_after: Some(RetryAfter::Seconds(30)),
            source: None,
        };
        let pd = transient.to_problem_details("search");
        assert_eq!(pd.retry_after, Some(RetryAfter::Seconds(30)));

        // Terminal classes never carry a retry hint.
        assert!(
            Error::validation("x")
                .to_problem_details("x")
                .retry_after
                .is_none()
        );
        assert!(
            Error::not_found("x")
                .to_problem_details("x")
                .retry_after
                .is_none()
        );
        assert!(
            Error::parse("x")
                .to_problem_details("x")
                .retry_after
                .is_none()
        );
    }

    /// Empty `code_actions` is omitted from the JSON for token economy, and the
    /// envelope stays under the 1 KB cap.
    #[test]
    fn json_is_compact() {
        for err in all_variants() {
            let pd = err.to_problem_details("cmd");
            let json = serde_json::to_string(&pd).expect("serialize");
            assert!(
                !json.contains("\"code_actions\""),
                "empty code_actions should be omitted: {json}"
            );
            assert!(
                json.len() <= 1024,
                "payload {} bytes exceeds 1 KB cap",
                json.len()
            );
        }
    }

    /// Wrapped variants preserve the originating cause via `source()`.
    #[test]
    fn cause_chain_preserved() {
        let io = std::io::Error::new(std::io::ErrorKind::TimedOut, "underlying");
        let err = Error::Parse {
            message: "failed to parse response".to_owned(),
            source: Some(Box::new(io)),
        };
        let cause = std::error::Error::source(&err);
        assert!(cause.is_some(), "Parse with source must expose source()");
        assert!(cause.unwrap().to_string().contains("underlying"));

        // A constructor-built error (no upstream) has no source — that's fine.
        assert!(std::error::Error::source(&Error::validation("x")).is_none());
    }

    /// Every `ValidationKind` yields a distinct, well-formed envelope: a
    /// `cli/` or `mcp/` slug, a non-empty tailored title and fix, status 400,
    /// exit code 1, and no retry hint.
    #[test]
    fn every_validation_kind_maps_cleanly() {
        let kinds = [
            ValidationKind::EmptyLpnId,
            ValidationKind::InvalidBreedId,
            ValidationKind::PageRange,
            ValidationKind::EmptySearch,
            ValidationKind::CompareArity,
            ValidationKind::MissingArgument,
            ValidationKind::UnknownResource,
            ValidationKind::InvalidCursor,
            ValidationKind::UnknownTransport,
            ValidationKind::Other,
        ];
        let mut slugs = std::collections::HashSet::new();
        for kind in kinds {
            let err = Error::validation_kind(kind, "field");
            let pd = err.to_problem_details("op");
            let slug = err.slug_path();
            assert!(
                slug.starts_with("cli/") || slug.starts_with("mcp/"),
                "{kind:?} slug: {slug}"
            );
            assert!(slugs.insert(slug), "duplicate slug for {kind:?}: {slug}");
            assert_eq!(pd.status, 400);
            assert_eq!(pd.exit_code, 1);
            assert!(!pd.title.is_empty());
            assert!(pd.suggested_fix.is_some(), "{kind:?} must have a fix");
            assert!(pd.retry_after.is_none());
        }
    }

    /// `retry_after` round-trips through both JSON forms (untagged enum).
    #[test]
    fn retry_after_serde_forms() {
        let secs = serde_json::to_string(&RetryAfter::Seconds(12)).unwrap();
        assert_eq!(secs, "12");
        let ts =
            serde_json::to_string(&RetryAfter::Timestamp("2026-06-01T00:00:00Z".into())).unwrap();
        assert_eq!(ts, "\"2026-06-01T00:00:00Z\"");
    }

    /// The type-URI base is wired from `build.rs`
    /// (`[package.metadata.nsip].error-type-uri-base`) via `env!`. The repo sets
    /// it to the canonical default, so the resolved value must match — proving the
    /// build-script → env → const path works and produced a non-empty base.
    #[test]
    fn type_uri_base_resolves_from_build_metadata() {
        assert_eq!(
            TYPE_URI_BASE,
            "https://github.com/zircote/nsip/blob/main/docs/reference/errors"
        );
    }

    /// `slug_or` prefers a build-time override and falls back to the default —
    /// the resolution logic behind per-error slug configuration.
    #[test]
    fn slug_or_prefers_override_then_default() {
        assert_eq!(
            slug_or(Some("errors/custom"), "api/timeout"),
            "errors/custom"
        );
        assert_eq!(slug_or(None, "api/timeout"), "api/timeout");
    }

    /// A verbose upstream body (the `Api` message is the raw response body, which
    /// is unbounded) is truncated so the envelope stays within the 1 KB budget.
    /// `json_is_compact` only ever feeds tiny fixtures, so it does not exercise
    /// truncation — this does.
    #[test]
    fn detail_truncated_keeps_envelope_under_cap() {
        let err = Error::Api {
            status: 500,
            message: "x".repeat(5000),
            retry_after: None,
            source: None,
        };
        let pd = err.to_problem_details("date-updated");
        assert!(
            pd.detail.len() <= MAX_DETAIL_LEN + 4,
            "detail not truncated: {} bytes",
            pd.detail.len()
        );
        assert!(
            pd.detail.ends_with(''),
            "truncated detail should end with an ellipsis: {}",
            pd.detail
        );
        let json = serde_json::to_string(&pd).expect("serialize");
        assert!(
            json.len() <= 1024,
            "envelope {} bytes exceeds 1 KB cap",
            json.len()
        );
    }

    /// The `type` URI is declared twice — `lib.rs` `#[diagnostic(url(...))]` (the
    /// human/miette path) and `problem.rs` `type_uri()` (the agent envelope). For
    /// every non-`Validation` variant they must resolve to the SAME doc page;
    /// pin them together so the two sources cannot drift silently.
    #[test]
    fn miette_url_matches_envelope_type_uri() {
        use miette::Diagnostic as _;
        for err in [
            Error::api(500, "x"),
            Error::not_found("x"),
            Error::timeout("x"),
            Error::connection("x"),
            Error::parse("x"),
        ] {
            let url = err.url().map(|u| u.to_string());
            assert_eq!(
                url.as_deref(),
                Some(err.type_uri().as_str()),
                "miette url and envelope type diverged for {err:?}"
            );
        }

        // `Validation` is intentionally NOT pinned to equality: a variant-level
        // miette `url` attribute cannot see `kind`, so the human path carries one
        // coarse `cli/validation.md` while the envelope carries the precise
        // per-operation slug. Pin that intended divergence so it stays a
        // documented decision rather than an accident.
        let empty = Error::empty_lpn_id();
        assert_eq!(
            empty.url().map(|u| u.to_string()).as_deref(),
            Some(
                "https://github.com/zircote/nsip/blob/main/docs/reference/errors/cli/validation.md"
            )
        );
        assert!(empty.type_uri().ends_with("/cli/empty-lpn-id.md"));
    }
}