Skip to main content

coding_agent_search/model/
cli_error_kind.rs

1//! Typed `CliError.kind` enum (`coding_agent_session_search-dxnmb`).
2//!
3//! `CliError.kind` is currently a `&'static str` field with 86 unique
4//! values scattered as string literals across `src/lib.rs`. There is
5//! no compile-time exhaustiveness check, no naming-convention guard,
6//! and no rename-safety. A hurried maintainer can:
7//!
8//! - typo a kind ("db_error" vs "db-error") without compiler error,
9//! - introduce a new kind that shadows an existing one,
10//! - use inconsistent casing (the existing literal set already has
11//!   4 snake_case stragglers — `failed_seed_bundle_file`,
12//!   `lexical_generation`, `lexical_shard`, `retained_publish_backup`
13//!   — alongside the canonical kebab-case majority).
14//!
15//! That inconsistency caused 3 real duplicates pinned by bead `al19b`.
16//!
17//! This module ships the **vocabulary slice** of the dxnmb fix:
18//! a single source-of-truth enum that:
19//!
20//! 1. enumerates every kind currently emitted by `src/lib.rs`
21//!    (audited at landing time via `grep -oE 'kind: "[a-z_-]+"'`),
22//! 2. exposes a `kind_str()` accessor that returns the canonical
23//!    kebab-case (or, for the four snake_case stragglers, the exact
24//!    legacy literal — preserving wire compatibility with golden
25//!    tests + downstream agents until those four are migrated in a
26//!    separate slice),
27//! 3. exposes a `from_kind_str()` lookup so JSON-mode consumers
28//!    (and golden tests) can round-trip the kind cleanly.
29//!
30//! The actual migration of the 223 call sites in `src/lib.rs` (each
31//! `CliError { kind: "...", ... }` literal → `CliError { kind:
32//! ErrorKind::Foo.as_str(), ... }`) is the *follow-up* slice; it
33//! requires write access to `src/lib.rs` which is currently held by
34//! another agent's exclusive file reservation. Landing the
35//! vocabulary first lets that follow-up slice land as a pure
36//! mechanical replacement gated by the golden test below.
37//!
38//! # Variant naming
39//!
40//! Variants use Rust's standard CamelCase. The mapping to the
41//! wire-format string is held by `kind_str()` rather than by
42//! `#[serde(rename = "...")]` because the four snake_case stragglers
43//! cannot be auto-generated from CamelCase by serde's
44//! `rename_all = "kebab-case"` (e.g. `LexicalGeneration` would
45//! serialize as `lexical-generation`, breaking the existing
46//! `kind: "lexical_generation"` wire contract). The audit golden
47//! test pins both the kebab-case canonical kinds AND the snake_case
48//! exemptions so a future cleanup slice that migrates the four
49//! stragglers to kebab-case has an explicit place to flip the
50//! contract.
51
52use serde::{Deserialize, Serialize};
53
54/// Typed counterpart to `CliError.kind`. Every variant maps to the
55/// exact wire-format string emitted today; new kinds added by future
56/// CLI surfaces should be added here AND covered by the golden test
57/// at the bottom of this module.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
59pub enum ErrorKind {
60    AmbiguousSource,
61    ArchiveAnalyticsRebuild,
62    ArchiveCount,
63    ArchiveDailyStatsRebuild,
64    ArchiveFtsRebuild,
65    ArchivePurge,
66    ArchiveTokenDailyStatsRebuild,
67    Config,
68    CursorDecode,
69    CursorParse,
70    Daemon,
71    DbError,
72    DbOpen,
73    DbQuery,
74    Doctor,
75    Download,
76    EmbedderUnavailable,
77    EmptyFile,
78    EmptySession,
79    EncodeJson,
80    ExportFailed,
81    /// Snake-case wire literal (legacy): `failed_seed_bundle_file`.
82    /// Kept exact until the cross-cutting kebab-case migration ships.
83    FailedSeedBundleFile,
84    FileCreate,
85    FileNotFound,
86    FileOpen,
87    FileRead,
88    FileWrite,
89    Health,
90    IdempotencyMismatch,
91    Index,
92    IndexBusy,
93    IndexMissing,
94    IndexedSessionRequired,
95    InvalidAgent,
96    InvalidFilename,
97    InvalidLine,
98    Io,
99    IoError,
100    LexicalRebuild,
101    /// Snake-case wire literal (legacy): `lexical_generation`.
102    LexicalGeneration,
103    /// Snake-case wire literal (legacy): `lexical_shard`.
104    LexicalShard,
105    LineNotFound,
106    LineOutOfRange,
107    Local,
108    Mapping,
109    MissingDb,
110    MissingIndex,
111    Model,
112    NotFound,
113    OpenIndex,
114    OpencodeParse,
115    OpencodeSqliteParse,
116    OutputNotWritable,
117    PackEmptyQuery,
118    PackInvalidField,
119    PackInvalidLimit,
120    PackNoEvidence,
121    PackUnsupportedFormat,
122    Pages,
123    ParseError,
124    PasswordReadError,
125    PasswordRequired,
126    RebuildError,
127    RepairError,
128    ResumeEmptyCommand,
129    ResumeExecFailed,
130    /// Snake-case wire literal (legacy): `retained_publish_backup`.
131    RetainedPublishBackup,
132    Search,
133    SemanticBackfill,
134    SemanticManifest,
135    SemanticUnavailable,
136    SerializeMessage,
137    SessionFileUnreadable,
138    SessionIdNotFound,
139    SessionNotFound,
140    SessionParse,
141    SessionsFrom,
142    Setup,
143    Source,
144    Ssh,
145    Storage,
146    StorageFingerprint,
147    Timeout,
148    Tui,
149    TuiHeadlessOnce,
150    TuiResetState,
151    Unknown,
152    UnknownAgent,
153    UpdateCheck,
154    Usage,
155    WriteFailed,
156}
157
158impl ErrorKind {
159    /// Returns the wire-format string emitted in `CliError.kind`.
160    /// **MUST** match the literal currently used in `src/lib.rs` for
161    /// every variant — the golden test below asserts this. Adding a
162    /// new variant without updating this match is a compile error
163    /// (no `_ => ...` catch-all).
164    pub fn kind_str(self) -> &'static str {
165        match self {
166            Self::AmbiguousSource => "ambiguous-source",
167            Self::ArchiveAnalyticsRebuild => "archive-analytics-rebuild",
168            Self::ArchiveCount => "archive-count",
169            Self::ArchiveDailyStatsRebuild => "archive-daily-stats-rebuild",
170            Self::ArchiveFtsRebuild => "archive-fts-rebuild",
171            Self::ArchivePurge => "archive-purge",
172            Self::ArchiveTokenDailyStatsRebuild => "archive-token-daily-stats-rebuild",
173            Self::Config => "config",
174            Self::CursorDecode => "cursor-decode",
175            Self::CursorParse => "cursor-parse",
176            Self::Daemon => "daemon",
177            Self::DbError => "db-error",
178            Self::DbOpen => "db-open",
179            Self::DbQuery => "db-query",
180            Self::Doctor => "doctor",
181            Self::Download => "download",
182            Self::EmbedderUnavailable => "embedder-unavailable",
183            Self::EmptyFile => "empty-file",
184            Self::EmptySession => "empty-session",
185            Self::EncodeJson => "encode-json",
186            Self::ExportFailed => "export-failed",
187            Self::FailedSeedBundleFile => "failed_seed_bundle_file",
188            Self::FileCreate => "file-create",
189            Self::FileNotFound => "file-not-found",
190            Self::FileOpen => "file-open",
191            Self::FileRead => "file-read",
192            Self::FileWrite => "file-write",
193            Self::Health => "health",
194            Self::IdempotencyMismatch => "idempotency-mismatch",
195            Self::Index => "index",
196            Self::IndexBusy => "index-busy",
197            Self::IndexMissing => "index-missing",
198            Self::IndexedSessionRequired => "indexed-session-required",
199            Self::InvalidAgent => "invalid-agent",
200            Self::InvalidFilename => "invalid-filename",
201            Self::InvalidLine => "invalid-line",
202            Self::Io => "io",
203            Self::IoError => "io-error",
204            Self::LexicalRebuild => "lexical-rebuild",
205            Self::LexicalGeneration => "lexical_generation",
206            Self::LexicalShard => "lexical_shard",
207            Self::LineNotFound => "line-not-found",
208            Self::LineOutOfRange => "line-out-of-range",
209            Self::Local => "local",
210            Self::Mapping => "mapping",
211            Self::MissingDb => "missing-db",
212            Self::MissingIndex => "missing-index",
213            Self::Model => "model",
214            Self::NotFound => "not-found",
215            Self::OpenIndex => "open-index",
216            Self::OpencodeParse => "opencode-parse",
217            Self::OpencodeSqliteParse => "opencode-sqlite-parse",
218            Self::OutputNotWritable => "output-not-writable",
219            Self::PackEmptyQuery => "pack-empty-query",
220            Self::PackInvalidField => "pack-invalid-field",
221            Self::PackInvalidLimit => "pack-invalid-limit",
222            Self::PackNoEvidence => "pack-no-evidence",
223            Self::PackUnsupportedFormat => "pack-unsupported-format",
224            Self::Pages => "pages",
225            Self::ParseError => "parse-error",
226            Self::PasswordReadError => "password-read-error",
227            Self::PasswordRequired => "password-required",
228            Self::RebuildError => "rebuild-error",
229            Self::RepairError => "repair-error",
230            Self::ResumeEmptyCommand => "resume-empty-command",
231            Self::ResumeExecFailed => "resume-exec-failed",
232            Self::RetainedPublishBackup => "retained_publish_backup",
233            Self::Search => "search",
234            Self::SemanticBackfill => "semantic-backfill",
235            Self::SemanticManifest => "semantic-manifest",
236            Self::SemanticUnavailable => "semantic-unavailable",
237            Self::SerializeMessage => "serialize-message",
238            Self::SessionFileUnreadable => "session-file-unreadable",
239            Self::SessionIdNotFound => "session-id-not-found",
240            Self::SessionNotFound => "session-not-found",
241            Self::SessionParse => "session-parse",
242            Self::SessionsFrom => "sessions-from",
243            Self::Setup => "setup",
244            Self::Source => "source",
245            Self::Ssh => "ssh",
246            Self::Storage => "storage",
247            Self::StorageFingerprint => "storage-fingerprint",
248            Self::Timeout => "timeout",
249            Self::Tui => "tui",
250            Self::TuiHeadlessOnce => "tui-headless-once",
251            Self::TuiResetState => "tui-reset-state",
252            Self::Unknown => "unknown",
253            Self::UnknownAgent => "unknown-agent",
254            Self::UpdateCheck => "update-check",
255            Self::Usage => "usage",
256            Self::WriteFailed => "write-failed",
257        }
258    }
259
260    /// Reverse lookup: parse a wire-format kind string back into the
261    /// typed enum. Returns `None` on unknown kinds. Used by JSON-mode
262    /// deserialization paths that need to branch on `err.kind` and by
263    /// the golden test that asserts every variant round-trips.
264    pub fn from_kind_str(kind: &str) -> Option<Self> {
265        Some(match kind {
266            "ambiguous-source" => Self::AmbiguousSource,
267            "archive-analytics-rebuild" => Self::ArchiveAnalyticsRebuild,
268            "archive-count" => Self::ArchiveCount,
269            "archive-daily-stats-rebuild" => Self::ArchiveDailyStatsRebuild,
270            "archive-fts-rebuild" => Self::ArchiveFtsRebuild,
271            "archive-purge" => Self::ArchivePurge,
272            "archive-token-daily-stats-rebuild" => Self::ArchiveTokenDailyStatsRebuild,
273            "config" => Self::Config,
274            "cursor-decode" => Self::CursorDecode,
275            "cursor-parse" => Self::CursorParse,
276            "daemon" => Self::Daemon,
277            "db-error" => Self::DbError,
278            "db-open" => Self::DbOpen,
279            "db-query" => Self::DbQuery,
280            "doctor" => Self::Doctor,
281            "download" => Self::Download,
282            "embedder-unavailable" => Self::EmbedderUnavailable,
283            "empty-file" => Self::EmptyFile,
284            "empty-session" => Self::EmptySession,
285            "encode-json" => Self::EncodeJson,
286            "export-failed" => Self::ExportFailed,
287            "failed_seed_bundle_file" => Self::FailedSeedBundleFile,
288            "file-create" => Self::FileCreate,
289            "file-not-found" => Self::FileNotFound,
290            "file-open" => Self::FileOpen,
291            "file-read" => Self::FileRead,
292            "file-write" => Self::FileWrite,
293            "health" => Self::Health,
294            "idempotency-mismatch" => Self::IdempotencyMismatch,
295            "index" => Self::Index,
296            "index-busy" => Self::IndexBusy,
297            "index-missing" => Self::IndexMissing,
298            "indexed-session-required" => Self::IndexedSessionRequired,
299            "invalid-agent" => Self::InvalidAgent,
300            "invalid-filename" => Self::InvalidFilename,
301            "invalid-line" => Self::InvalidLine,
302            "io" => Self::Io,
303            "io-error" => Self::IoError,
304            "lexical-rebuild" => Self::LexicalRebuild,
305            "lexical_generation" => Self::LexicalGeneration,
306            "lexical_shard" => Self::LexicalShard,
307            "line-not-found" => Self::LineNotFound,
308            "line-out-of-range" => Self::LineOutOfRange,
309            "local" => Self::Local,
310            "mapping" => Self::Mapping,
311            "missing-db" => Self::MissingDb,
312            "missing-index" => Self::MissingIndex,
313            "model" => Self::Model,
314            "not-found" => Self::NotFound,
315            "open-index" => Self::OpenIndex,
316            "opencode-parse" => Self::OpencodeParse,
317            "opencode-sqlite-parse" => Self::OpencodeSqliteParse,
318            "output-not-writable" => Self::OutputNotWritable,
319            "pack-empty-query" => Self::PackEmptyQuery,
320            "pack-invalid-field" => Self::PackInvalidField,
321            "pack-invalid-limit" => Self::PackInvalidLimit,
322            "pack-no-evidence" => Self::PackNoEvidence,
323            "pack-unsupported-format" => Self::PackUnsupportedFormat,
324            "pages" => Self::Pages,
325            "parse-error" => Self::ParseError,
326            "password-read-error" => Self::PasswordReadError,
327            "password-required" => Self::PasswordRequired,
328            "rebuild-error" => Self::RebuildError,
329            "repair-error" => Self::RepairError,
330            "resume-empty-command" => Self::ResumeEmptyCommand,
331            "resume-exec-failed" => Self::ResumeExecFailed,
332            "retained_publish_backup" => Self::RetainedPublishBackup,
333            "search" => Self::Search,
334            "semantic-backfill" => Self::SemanticBackfill,
335            "semantic-manifest" => Self::SemanticManifest,
336            "semantic-unavailable" => Self::SemanticUnavailable,
337            "serialize-message" => Self::SerializeMessage,
338            "session-file-unreadable" => Self::SessionFileUnreadable,
339            "session-id-not-found" => Self::SessionIdNotFound,
340            "session-not-found" => Self::SessionNotFound,
341            "session-parse" => Self::SessionParse,
342            "sessions-from" => Self::SessionsFrom,
343            "setup" => Self::Setup,
344            "source" => Self::Source,
345            "ssh" => Self::Ssh,
346            "storage" => Self::Storage,
347            "storage-fingerprint" => Self::StorageFingerprint,
348            "timeout" => Self::Timeout,
349            "tui" => Self::Tui,
350            "tui-headless-once" => Self::TuiHeadlessOnce,
351            "tui-reset-state" => Self::TuiResetState,
352            "unknown" => Self::Unknown,
353            "unknown-agent" => Self::UnknownAgent,
354            "update-check" => Self::UpdateCheck,
355            "usage" => Self::Usage,
356            "write-failed" => Self::WriteFailed,
357            _ => return None,
358        })
359    }
360
361    /// Returns every variant in declaration order. Used by the
362    /// golden test to assert every variant has both a `kind_str()`
363    /// arm AND a `from_kind_str()` arm.
364    pub fn all_variants() -> &'static [Self] {
365        &[
366            Self::AmbiguousSource,
367            Self::ArchiveAnalyticsRebuild,
368            Self::ArchiveCount,
369            Self::ArchiveDailyStatsRebuild,
370            Self::ArchiveFtsRebuild,
371            Self::ArchivePurge,
372            Self::ArchiveTokenDailyStatsRebuild,
373            Self::Config,
374            Self::CursorDecode,
375            Self::CursorParse,
376            Self::Daemon,
377            Self::DbError,
378            Self::DbOpen,
379            Self::DbQuery,
380            Self::Doctor,
381            Self::Download,
382            Self::EmbedderUnavailable,
383            Self::EmptyFile,
384            Self::EmptySession,
385            Self::EncodeJson,
386            Self::ExportFailed,
387            Self::FailedSeedBundleFile,
388            Self::FileCreate,
389            Self::FileNotFound,
390            Self::FileOpen,
391            Self::FileRead,
392            Self::FileWrite,
393            Self::Health,
394            Self::IdempotencyMismatch,
395            Self::Index,
396            Self::IndexBusy,
397            Self::IndexMissing,
398            Self::IndexedSessionRequired,
399            Self::InvalidAgent,
400            Self::InvalidFilename,
401            Self::InvalidLine,
402            Self::Io,
403            Self::IoError,
404            Self::LexicalRebuild,
405            Self::LexicalGeneration,
406            Self::LexicalShard,
407            Self::LineNotFound,
408            Self::LineOutOfRange,
409            Self::Local,
410            Self::Mapping,
411            Self::MissingDb,
412            Self::MissingIndex,
413            Self::Model,
414            Self::NotFound,
415            Self::OpenIndex,
416            Self::OpencodeParse,
417            Self::OpencodeSqliteParse,
418            Self::OutputNotWritable,
419            Self::PackEmptyQuery,
420            Self::PackInvalidField,
421            Self::PackInvalidLimit,
422            Self::PackNoEvidence,
423            Self::PackUnsupportedFormat,
424            Self::Pages,
425            Self::ParseError,
426            Self::PasswordReadError,
427            Self::PasswordRequired,
428            Self::RebuildError,
429            Self::RepairError,
430            Self::ResumeEmptyCommand,
431            Self::ResumeExecFailed,
432            Self::RetainedPublishBackup,
433            Self::Search,
434            Self::SemanticBackfill,
435            Self::SemanticManifest,
436            Self::SemanticUnavailable,
437            Self::SerializeMessage,
438            Self::SessionFileUnreadable,
439            Self::SessionIdNotFound,
440            Self::SessionNotFound,
441            Self::SessionParse,
442            Self::SessionsFrom,
443            Self::Setup,
444            Self::Source,
445            Self::Ssh,
446            Self::Storage,
447            Self::StorageFingerprint,
448            Self::Timeout,
449            Self::Tui,
450            Self::TuiHeadlessOnce,
451            Self::TuiResetState,
452            Self::Unknown,
453            Self::UnknownAgent,
454            Self::UpdateCheck,
455            Self::Usage,
456            Self::WriteFailed,
457        ]
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use std::collections::HashSet;
465
466    /// `coding_agent_session_search-dxnmb` golden gate: every variant
467    /// in `all_variants()` must round-trip through `kind_str()` →
468    /// `from_kind_str()` and yield the same variant. A new variant
469    /// added without registering it in both arms fails this gate.
470    #[test]
471    fn every_error_kind_round_trips_through_kind_str() {
472        for variant in ErrorKind::all_variants() {
473            let kind = variant.kind_str();
474            let parsed = ErrorKind::from_kind_str(kind).unwrap_or_else(|| {
475                panic!(
476                    "ErrorKind::{:?}.kind_str() = {:?} but from_kind_str returned None — \
477                     missing from_kind_str arm",
478                    variant, kind
479                )
480            });
481            assert_eq!(
482                parsed, *variant,
483                "round-trip mismatch: {:?}.kind_str() → {:?} → {:?}",
484                variant, kind, parsed
485            );
486        }
487    }
488
489    /// All wire strings must be unique. A regression that mapped two
490    /// variants to the same kind_str() (e.g. the historical "db_error"
491    /// vs "db-error" duplicate from bead al19b) trips this gate.
492    #[test]
493    fn every_kind_str_is_unique() {
494        let mut seen: HashSet<&'static str> = HashSet::new();
495        for variant in ErrorKind::all_variants() {
496            let kind = variant.kind_str();
497            assert!(
498                seen.insert(kind),
499                "duplicate kind_str detected: {:?} maps to {:?} which was already \
500                 registered by an earlier variant",
501                variant,
502                kind
503            );
504        }
505    }
506
507    /// The vocabulary covers every kind currently emitted by
508    /// src/lib.rs at landing time (audited via
509    /// `grep -oE 'kind: \"[a-z_-]+\"' src/lib.rs | sort -u`). A
510    /// regression that added a new kind to lib.rs without adding it
511    /// here would be invisible until a future enum migration site
512    /// hit a missing variant; pinning the count here surfaces the
513    /// drift immediately at CI time.
514    #[test]
515    fn variant_count_matches_audited_lib_rs_kind_literals() {
516        // 91 unique kinds at landing time (commit before the pack
517        // landed). If lib.rs grows a new kind, bump this count AND
518        // add the variant + arms above.
519        const AUDITED_KIND_COUNT: usize = 91;
520        assert_eq!(
521            ErrorKind::all_variants().len(),
522            AUDITED_KIND_COUNT,
523            "ErrorKind variant count drifted from the audited lib.rs literal set; \
524             re-run `grep -oE 'kind: \"[a-z_-]+\"' src/lib.rs | sort -u | wc -l` and \
525             update the constant + add the missing variant"
526        );
527    }
528
529    /// Pin the four legacy snake_case stragglers explicitly so a
530    /// future "rename to kebab-case" cleanup slice has a single place
531    /// to flip the contract. Pinning them here also surfaces an
532    /// accidental flip-back from kebab-case to snake_case.
533    #[test]
534    fn snake_case_stragglers_preserve_legacy_wire_format() {
535        assert_eq!(
536            ErrorKind::FailedSeedBundleFile.kind_str(),
537            "failed_seed_bundle_file"
538        );
539        assert_eq!(
540            ErrorKind::LexicalGeneration.kind_str(),
541            "lexical_generation"
542        );
543        assert_eq!(ErrorKind::LexicalShard.kind_str(), "lexical_shard");
544        assert_eq!(
545            ErrorKind::RetainedPublishBackup.kind_str(),
546            "retained_publish_backup"
547        );
548    }
549
550    /// Unknown kinds return None (not a default Unknown variant);
551    /// callers must explicitly handle the parse failure.
552    #[test]
553    fn from_kind_str_returns_none_for_unknown_inputs() {
554        assert_eq!(ErrorKind::from_kind_str(""), None);
555        assert_eq!(ErrorKind::from_kind_str("not-a-real-kind"), None);
556        // Casing matters: the wire format is exact.
557        assert_eq!(ErrorKind::from_kind_str("DB-ERROR"), None);
558        assert_eq!(ErrorKind::from_kind_str("Db-Error"), None);
559        // Sanity: the well-known "unknown" kind IS distinct from
560        // "not-a-real-kind" and parses cleanly.
561        assert_eq!(
562            ErrorKind::from_kind_str("unknown"),
563            Some(ErrorKind::Unknown)
564        );
565    }
566
567    /// Serde round-trip via JSON works (callers can use the enum as
568    /// a serde-serializable field). Default rename uses CamelCase
569    /// for serde, but downstream consumers that need the wire-format
570    /// kebab-case string call `kind_str()` directly. This test pins
571    /// that the enum is at least serializable / deserializable so
572    /// callers wanting the typed form (e.g. error envelopes for
573    /// telemetry sinks) can opt in.
574    #[test]
575    fn error_kind_is_serde_compatible() {
576        let json = serde_json::to_string(&ErrorKind::DbError).expect("serialize");
577        let parsed: ErrorKind = serde_json::from_str(&json).expect("deserialize");
578        assert_eq!(parsed, ErrorKind::DbError);
579    }
580}