arcform 0.2.2

Local-first data pipeline engine for analytical workflows
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
use std::path::PathBuf;

/// Every failure `arc` reports, from spec loading through to the registry client.
///
/// Published as part of the spec contract, so it is `#[non_exhaustive]`: a caller
/// matching on it must carry a `_` arm, and adding a variant is therefore not a
/// breaking change for them. Only the manifest variants can be raised by the loading
/// and validation surface — the rest belong to the private engine and are reachable
/// through this type only because they share it.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
    #[error("arcform.yaml not found in current directory")]
    ManifestNotFound,

    #[error("failed to read {path}: {source}")]
    FileRead {
        path: PathBuf,
        source: std::io::Error,
    },

    #[error("failed to parse arcform.yaml: {0}")]
    ManifestParse(#[from] serde_yaml::Error),

    #[error("invalid manifest: {0}")]
    ManifestValidation(String),

    // The spec write path's refusal for an edit that cannot be applied: the
    // route did not resolve, the fragment was missing or ambiguous, or the op
    // does not fit the shape it found. Distinct from `ManifestValidation`,
    // which is the refusal for an edit that applied but produced a spec that
    // will not load.
    #[error("edit: {path}: {detail}")]
    EditTarget { path: String, detail: String },

    #[error("a spec already exists at {0} — edit it rather than re-creating it")]
    SpecExists(PathBuf),

    // The record path's create-mode collision: a generated SQL file is only
    // ever written where no file exists, because an existing file may carry
    // authorship this tool must not destroy.
    #[error("a sql file already exists at {0} — a recorded step never overwrites a model")]
    GeneratedSqlExists(PathBuf),

    // The record path's ownership refusal: the file lacks the generated
    // marker, so its bytes were not machine-authored and are never machine-
    // rewritten. The remedy is offered in the message because the caller is
    // expected to surface it to a person.
    #[error(
        "step '{step}': {path} is hand-authored (no generated marker) and will not be \
         rewritten — record a new step downstream of it instead"
    )]
    HandAuthoredSql { step: String, path: PathBuf },

    #[error("step '{step}': sql file not found: {path}")]
    SqlFileNotFound { step: String, path: PathBuf },

    // Local history cannot resolve a store root: no $ARCFORM_HISTORY_DIR and
    // no home directory. The remedy is the env var, so the message names it.
    #[error(
        "history: no store root (set ARCFORM_HISTORY_DIR to a writable directory, \
         or ensure a home directory exists)"
    )]
    HistoryRootMissing,

    // A Protocol that names no `db:` keeps its database in arc's data folder, and that
    // cannot be resolved: no $ARCFORM_DB_DIR and no home directory. The remedy is the
    // env var, or a `db:` line, so the message names both.
    #[error(
        "database: no data folder for a Protocol that names no db: (set ARCFORM_DB_DIR \
         to a writable directory, ensure a home directory exists, or name a db: in arcform.yaml)"
    )]
    DbRootMissing,

    // A Protocol that names no `db:` keeps `<name>.duckdb` in a directory keyed to it in
    // arc's data folder; a name that is not a plain file name would put the database
    // outside that directory, where another Protocol could reach it.
    #[error(
        "database: name '{name}' is not a plain file name, so arc cannot keep its database \
         in the data folder (give the Protocol a name with no '/', or name a db: in arcform.yaml)"
    )]
    DbNameNotAFileName { name: String },

    // Writing in arc's data folder failed: the directory keyed to a Protocol, or the
    // `protocol-path` file that names the Protocol in it.
    #[error("database: could not write {path} in arc's data folder: {source}")]
    DbFolderWrite {
        path: PathBuf,
        source: std::io::Error,
    },

    #[error("history: no entry '{id}' for this spec (see `arc history list`)")]
    HistoryEntryNotFound { id: String },

    // A word that cannot be the way arc was reached: not one word arc can keep
    // in an entry's file name, or one of arc's own ways spelt by a caller.
    #[error("history: {way:?} cannot name the way arc was reached: {detail}")]
    HistoryWay { way: String, detail: String },

    #[error("engine '{engine}' not found on PATH or not executable")]
    EngineNotFound { engine: String },

    #[error("engine version mismatch: requires {required}, found {found}")]
    VersionMismatch { required: String, found: String },

    // The engine arc was told to run is unusable. The search path is deliberately not
    // tried instead: a told engine that silently becomes whichever `duckdb` sorts first
    // on PATH is two installs deciding which one writes your data.
    #[error(
        "{var} names '{}', which is not a usable DuckDB executable ({reason}); \
         point it at one, or unset it to use the `duckdb` on PATH",
        path.display()
    )]
    EngineBinInvalid {
        var: &'static str,
        path: PathBuf,
        reason: String,
    },

    #[error(
        "engine DuckDB {found} is outside the versions arc is tested on ({range}); \
         use a DuckDB in that range, or set {override_var}=1 to run on {found} at your own risk"
    )]
    UntestedEngine {
        found: String,
        range: &'static str,
        override_var: &'static str,
    },

    // Older than the oldest DuckDB arc accepts. Said apart from `UntestedEngine`, because
    // what is wrong with an older engine is known rather than untested, and no override
    // lifts it.
    #[error(
        "engine DuckDB {found} is older than {oldest}, the oldest DuckDB arc accepts ({range}): \
         arc links DuckDB 2.0, and a database it creates cannot be opened by DuckDB 1.x; \
         use DuckDB {oldest} or later, or install arc 0.2.1, the release for DuckDB 1.5 (`cargo install arcform \
         --version 0.2.1`)"
    )]
    EngineTooOld {
        found: String,
        oldest: &'static str,
        range: &'static str,
    },

    // A Protocol's SQL installs a DuckDB extension from somewhere arc has not vetted. One
    // line per statement found, so a Protocol with three is put right in one pass.
    #[error(
        "this Protocol's SQL installs a DuckDB extension arc has not vetted, so no step was run:\n{}\n\
         A step or hook may install a community extension on the vetted list, FROM community. \
         The list, and what this check does not read, are at {}",
        indented(refusals),
        crate::engine::VETTED_EXTENSIONS_DOC
    )]
    ExtensionRefused { refusals: Vec<String> },

    // A community extension the Protocol's SQL installs is not the build `arcform.yaml`
    // pins for this DuckDB and platform, or arc could not install it to compare. Checked
    // before any step or hook runs, so nothing ran; one line per extension, and each line
    // says which of the two it was, so the first line claims neither.
    #[error(
        "arc could not confirm that each extension this Protocol installs is the build its arcform.yaml pins, so no step or hook was run:\n{}\n\
         A pin sits under the extensions: key of arcform.yaml. See {}",
        indented(refusals),
        crate::engine::VETTED_EXTENSIONS_DOC
    )]
    ExtensionPinRefused { refusals: Vec<String> },

    // A step's or hook's SQL file, read again just before it ran, installs a DuckDB extension
    // from somewhere arc has not vetted: a step earlier in the run wrote over the file after
    // the check before the run read it, or wrote it where there was none. A step refused here
    // is a step that fails, so the message does not say that no step ran: the steps before it
    // ran and their tables stay, and it and the steps after it do not run. `step` is `None`
    // for a hook.
    #[error(
        "{place} was refused and did not run: its SQL file, read again just before it ran, installs a DuckDB extension arc has not vetted:\n{}\n\
         A step or hook may install a community extension on the vetted list, FROM community. \
         The list, and what this check does not read, are at {}",
        indented(refusals),
        crate::engine::VETTED_EXTENSIONS_DOC
    )]
    ExtensionRefusedBeforeItRan {
        place: String,
        step: Option<String>,
        refusals: Vec<String>,
    },

    // The DuckDB the steps run on gave no tokens for a step's or hook's SQL file, or tokens
    // arc cannot read, before the run or just before the step or hook ran. The refusals read
    // a file from those tokens alone, so the file is refused rather than read some other
    // way; nothing in it ran. `step` is `None` for a hook.
    #[error(
        "{place} ({file}) was refused and did not run: arc could not read the tokens of its SQL from DuckDB: {reason}\n\
         arc reads each SQL file's tokens from the DuckDB that runs it, to check what the file installs, and runs no file it could not read. See {}",
        crate::engine::VETTED_EXTENSIONS_DOC
    )]
    SqlNotRead {
        place: String,
        step: Option<String>,
        file: String,
        reason: String,
    },

    // The DuckDB the steps run on gives no tokens at all, not even for `SELECT 1`: it was
    // built without DuckDB's autocomplete extension, where `sql_tokenize` lives. Refused
    // before any step runs, in place of the refusal of the first file it gave none for, so
    // the message names the cause and the fix. `which` names the DuckDB and its version, and
    // `error` is what it said.
    #[error(
        "{} No step was run. {}",
        crate::engine::no_tokenizer_cause(which, error),
        crate::engine::release_install_fix()
    )]
    NoTokenizer { which: String, error: String },

    // A community extension that a step's or hook's SQL file installs, first found when the
    // file was read again just before it ran, is not the build `arcform.yaml` pins for this
    // DuckDB and platform, or arc could not install it to compare. Refused as
    // `ExtensionRefusedBeforeItRan` is.
    #[error(
        "{place} was refused and did not run: arc could not confirm that each extension its SQL file, read again just before it ran, installs is the build its arcform.yaml pins:\n{}\n\
         A pin sits under the extensions: key of arcform.yaml. See {}",
        indented(refusals),
        crate::engine::VETTED_EXTENSIONS_DOC
    )]
    ExtensionPinRefusedBeforeItRan {
        place: String,
        step: Option<String>,
        refusals: Vec<String>,
    },

    // `arc upgrade` was asked to pin an extension that is not on the vetted list. Refused
    // before DuckDB is asked anything.
    #[error(
        "arc upgrade pins a community extension on the vetted list, and {name} is not on it, so arcform.yaml was not changed. The list is at {}",
        crate::engine::VETTED_EXTENSIONS_DOC
    )]
    ExtensionNotVetted { name: String },

    // `arc upgrade` could not find the build to pin: DuckDB's version or platform, the
    // install, the load, or the installed file. Nothing was written.
    #[error("arc could not pin {name}, so arcform.yaml was not changed: {reason}")]
    ExtensionUpgradeFailed { name: String, reason: String },

    // A pinned extension's file differs from its pin when the run ends: a step replaced
    // it, and steps after that one may have loaded it.
    #[error(
        "an extension this Protocol installs changed during the run, so the run failed:\n{}\n\
         A step that runs FORCE INSTALL, UPDATE EXTENSIONS or a command can replace an installed extension, \
         and steps after it may have loaded the file. See {}",
        indented(changes),
        crate::engine::VETTED_EXTENSIONS_DOC
    )]
    ExtensionChanged { changes: Vec<String> },

    // DuckDB, asked by arc itself rather than by a step, did not answer.
    #[error("DuckDB could not {what}: {reason}")]
    EngineQuery { what: String, reason: String },

    // `ARC_SQL_READER` names no reader, or chooses DuckDB's parse, as it does unset, and the
    // DuckDB arc runs is not a 2.x build that gives it. The message names the variable and
    // what it takes.
    #[error("{0}")]
    SqlReader(String),

    #[error("step '{step}' failed (exit code {code}):\n{stderr}")]
    StepFailed {
        step: String,
        code: i32,
        stderr: String,
    },

    #[error("step '{step}' failed: {source}")]
    StepExecution {
        step: String,
        source: std::io::Error,
    },

    #[error("project directory already exists: {0}")]
    ProjectExists(PathBuf),

    #[error(
        "dependency order violation: step '{reader}' reads asset '{asset}' but '{asset}' is produced by step '{producer}' which runs after it"
    )]
    DependencyOrder {
        reader: String,
        asset: String,
        producer: String,
    },

    #[error(
        "precondition error for step '{step}': command '{command}' failed to execute: {detail}"
    )]
    Precondition {
        step: String,
        command: String,
        detail: String,
    },

    /// A `tool:` precondition could not establish what the tool it names currently is.
    /// Carries the step, the declaration as written, and where the lookup got to — the
    /// resolved path when there was one, otherwise what was searched.
    #[error("precondition error for step '{step}': tool {tool} could not be identified: {detail}")]
    ToolPrecondition {
        step: String,
        tool: String,
        detail: String,
    },

    #[error("missing required parameter '{name}' (no default, not in dotenv or CLI)")]
    MissingParam { name: String },

    #[error("step '{step}' timed out")]
    StepTimeout { step: String },

    #[error("pipeline timeout after {elapsed_sec:.1}s — step '{step}' was running")]
    PipelineTimeout { step: String, elapsed_sec: f64 },

    #[error("state backend error: {0}")]
    StateBackend(String),

    // Constructed by FixtureTransport (cfg(test)) and by the production transport's
    // sister-work fetch path. Allowed because non-test builds today only see the
    // cfg(test) construction site.
    #[allow(dead_code)]
    #[error("registry: failed to fetch index from {url}: {detail}")]
    RegistryIndexFetch { url: String, detail: String },

    #[error("registry: failed to parse index: {detail}")]
    RegistryIndexParse { detail: String },

    #[error("registry: unknown entry '{query}' (try `arc registry list`)")]
    RegistryUnknownEntry { query: String },

    #[error("registry: malformed query '{query}' (expected `<name>` or `<owner>/<name>`)")]
    RegistryAmbiguousQuery { query: String },

    #[error("registry: transport error: {detail}")]
    RegistryTransport { detail: String },

    #[error("registry: cache I/O at {path}: {source}")]
    RegistryCacheIo {
        path: PathBuf,
        source: std::io::Error,
    },

    #[error(
        "registry: cache root unavailable (set ARCFORM_REGISTRY_CACHE to a writable directory)"
    )]
    RegistryCacheRootMissing,

    #[error("registry: '{feature}' is not implemented in v1")]
    RegistryUnimplemented { feature: String },

    #[error("{0}")]
    Io(#[from] std::io::Error),
}

impl Error {
    /// The process exit code `cli_main` reports for this error — two buckets, not one:
    ///
    /// - **1, "cannot run"**: `arc` (or this step) never got to attempt the substantive
    ///   work — a manifest that will not load or validate, a config or input that is
    ///   missing or malformed, infrastructure (the state backend, the registry
    ///   transport) that is unavailable. Nothing was tried; nothing to distinguish.
    /// - **2, "found a problem"**: `arc` attempted the work, and the attempt itself is
    ///   what failed — a step's command or SQL, a precondition check, a timeout. This
    ///   is the bucket a step forced to re-run by a corrected staleness decision lands
    ///   in if that re-run then fails for a real reason.
    ///
    /// Two refusals made before any step runs are 2 all the same, by design. `SqlNotRead` is
    /// shared by the check before the run and the check just before a step or hook (where 2
    /// is plain), so a file DuckDB gives no readable tokens for exits 2 whichever check found
    /// it. `NoTokenizer`, a DuckDB that gives no tokens for any file, is said in place of the
    /// first `SqlNotRead` it would otherwise be, and keeps that code. Other refusals before
    /// the run, such as `ExtensionRefused`, are 1. `docs/ARCHITECTURE.md` states both.
    ///
    /// Kept distinguishable on purpose: a mutation test on the staleness gate that
    /// reverts the fix and finds the run still exits 1 either way would prove nothing —
    /// see [`crate::runner`]'s `is_hash_stale`, the gate this distinction exists for.
    pub(crate) fn exit_code(&self) -> i32 {
        match self {
            Error::StepFailed { .. }
            | Error::StepExecution { .. }
            | Error::Precondition { .. }
            | Error::ToolPrecondition { .. }
            | Error::StepTimeout { .. }
            | Error::PipelineTimeout { .. }
            | Error::ExtensionChanged { .. }
            | Error::ExtensionRefusedBeforeItRan { .. }
            | Error::ExtensionPinRefusedBeforeItRan { .. }
            | Error::SqlNotRead { .. }
            | Error::NoTokenizer { .. } => 2,
            _ => 1,
        }
    }

    /// Whether the check just before a step or hook runs refused it.
    pub(crate) fn refused_before_it_ran(&self) -> bool {
        matches!(
            self,
            Error::ExtensionRefusedBeforeItRan { .. }
                | Error::ExtensionPinRefusedBeforeItRan { .. }
                | Error::SqlNotRead { .. }
        )
    }
}

pub type Result<T> = std::result::Result<T, Error>;

/// Each line on its own line, indented, for a message that lists several.
fn indented(lines: &[String]) -> String {
    lines
        .iter()
        .map(|line| format!("  {line}"))
        .collect::<Vec<_>>()
        .join("\n")
}

#[cfg(test)]
mod format_tests {
    //! format each new registry variant; assert single-line default
    //! and that the message carries the `registry:` prefix so callers can
    //! distinguish registry surface errors from other arcform error families.
    //!
    //! These tests are deliberately narrow — they check Display output, not
    //! the runtime construction sites (those are exercised by the registry
    //! module's own unit tests).
    use super::*;
    use std::io;
    use std::path::PathBuf;

    fn assert_single_line_registry(err: &Error) {
        let s = err.to_string();
        assert!(!s.contains('\n'), "Display must be single-line: {:?}", s);
        assert!(
            s.starts_with("registry:"),
            "expected `registry:` prefix, got: {:?}",
            s
        );
    }

    #[test]
    fn registry_index_fetch() {
        let e = Error::RegistryIndexFetch {
            url: "https://example/index.yaml".into(),
            detail: "boom".into(),
        };
        assert_single_line_registry(&e);
    }

    #[test]
    fn registry_index_parse() {
        let e = Error::RegistryIndexParse {
            detail: "bad yaml".into(),
        };
        assert_single_line_registry(&e);
    }

    #[test]
    fn registry_unknown_entry() {
        let e = Error::RegistryUnknownEntry {
            query: "nope".into(),
        };
        assert_single_line_registry(&e);
        assert!(e.to_string().contains("nope"));
    }

    #[test]
    fn registry_ambiguous_query() {
        let e = Error::RegistryAmbiguousQuery {
            query: "//bad".into(),
        };
        assert_single_line_registry(&e);
    }

    #[test]
    fn registry_transport() {
        let e = Error::RegistryTransport {
            detail: "tarball walked outside <dest>".into(),
        };
        assert_single_line_registry(&e);
    }

    #[test]
    fn registry_cache_io() {
        let e = Error::RegistryCacheIo {
            path: PathBuf::from("/tmp/cache/index.yaml"),
            source: io::Error::new(io::ErrorKind::PermissionDenied, "denied"),
        };
        assert_single_line_registry(&e);
    }

    #[test]
    fn registry_cache_root_missing() {
        let e = Error::RegistryCacheRootMissing;
        assert_single_line_registry(&e);
        // The remediation hint must surface in the default Display.
        assert!(
            e.to_string().contains("ARCFORM_REGISTRY_CACHE"),
            "remediation env var must appear: {:?}",
            e.to_string()
        );
    }

    // The history variants follow the same discipline: single-line Display,
    // a `history:` family prefix, and the remediation in the message.
    #[test]
    fn history_root_missing_names_the_env_var() {
        let e = Error::HistoryRootMissing;
        let s = e.to_string();
        assert!(!s.contains('\n'), "Display must be single-line: {:?}", s);
        assert!(s.starts_with("history:"), "family prefix: {:?}", s);
        assert!(
            s.contains("ARCFORM_HISTORY_DIR"),
            "remediation env var must appear: {:?}",
            s
        );
    }

    #[test]
    fn db_name_not_a_file_name_names_the_name_and_both_remedies() {
        let s = Error::DbNameNotAFileName {
            name: "../shared".into(),
        }
        .to_string();
        assert!(!s.contains('\n'), "Display must be single-line: {:?}", s);
        assert!(s.starts_with("database:"), "family prefix: {:?}", s);
        assert!(s.contains("'../shared'"), "the name: {:?}", s);
        assert!(s.contains("db:"), "the db: remedy: {:?}", s);
    }

    #[test]
    fn db_folder_write_names_the_path_and_the_cause() {
        let s = Error::DbFolderWrite {
            path: PathBuf::from("/data/k/protocol-path"),
            source: std::io::Error::other("Is a directory"),
        }
        .to_string();
        assert!(!s.contains('\n'), "Display must be single-line: {:?}", s);
        assert!(s.starts_with("database:"), "family prefix: {:?}", s);
        assert!(s.contains("/data/k/protocol-path"), "the path: {:?}", s);
        assert!(s.contains("Is a directory"), "the cause: {:?}", s);
    }

    #[test]
    fn db_root_missing_names_the_env_var_and_the_db_line() {
        let s = Error::DbRootMissing.to_string();
        assert!(!s.contains('\n'), "Display must be single-line: {:?}", s);
        assert!(s.starts_with("database:"), "family prefix: {:?}", s);
        assert!(s.contains("ARCFORM_DB_DIR"), "remediation env var: {:?}", s);
        assert!(s.contains("db:"), "the other remedy, a db: line: {:?}", s);
    }

    #[test]
    fn history_entry_not_found_names_the_entry_and_the_listing() {
        let e = Error::HistoryEntryNotFound {
            id: "1700000000000-000-save".into(),
        };
        let s = e.to_string();
        assert!(!s.contains('\n'), "Display must be single-line: {:?}", s);
        assert!(s.starts_with("history:"), "family prefix: {:?}", s);
        assert!(s.contains("1700000000000-000-save"));
        assert!(s.contains("arc history list"));
    }

    #[test]
    fn history_way_names_the_word_and_why_on_one_line() {
        let e = Error::HistoryWay {
            way: "a\nb".into(),
            detail: "'\\n' is not an ASCII letter, a digit, `-` or `_`".into(),
        };
        let s = e.to_string();
        assert!(!s.contains('\n'), "Display must be single-line: {:?}", s);
        assert!(s.starts_with("history:"), "family prefix: {:?}", s);
        assert!(s.contains(r#""a\nb""#), "the word, escaped: {:?}", s);
        assert!(s.contains("is not an ASCII letter"), "why: {:?}", s);
    }

    #[test]
    fn registry_unimplemented() {
        let e = Error::RegistryUnimplemented {
            feature: "--latest rolling resolution".into(),
        };
        assert_single_line_registry(&e);
        assert!(e.to_string().contains("--latest rolling resolution"));
    }
}

#[cfg(test)]
mod exit_code_tests {
    //! `cannot run` (1) and `found a problem` (2) must stay two different numbers, or
    //! a mutation test on whichever check raises one of these variants cannot tell "the
    //! gate did not fire" from "something unrelated stopped the run before it could."
    use super::*;

    #[test]
    fn manifest_and_config_problems_cannot_run() {
        for e in [
            Error::ManifestNotFound,
            Error::ManifestValidation("bad".into()),
            Error::SqlFileNotFound {
                step: "s".into(),
                path: PathBuf::from("missing.sql"),
            },
            Error::MissingParam { name: "p".into() },
            Error::EngineNotFound {
                engine: "duckdb".into(),
            },
            Error::EngineBinInvalid {
                var: "ARC_DUCKDB_BIN",
                path: PathBuf::from("/nope/duckdb"),
                reason: "no such file".into(),
            },
            Error::UntestedEngine {
                found: "3.0.0".into(),
                range: crate::engine::SUPPORTED_ENGINE_RANGE,
                override_var: "ARC_ALLOW_UNTESTED_ENGINE",
            },
            Error::EngineTooOld {
                found: "1.5.6".into(),
                oldest: crate::engine::OLDEST_ENGINE,
                range: crate::engine::SUPPORTED_ENGINE_RANGE,
            },
            Error::StateBackend("locked".into()),
            Error::ExtensionPinRefused {
                refusals: vec!["mlpack".into()],
            },
        ] {
            assert_eq!(e.exit_code(), 1, "expected 'cannot run' (1) for {e:?}");
        }
    }

    #[test]
    fn execution_failures_found_a_problem() {
        for e in [
            Error::StepFailed {
                step: "s".into(),
                code: 1,
                stderr: "boom".into(),
            },
            Error::Precondition {
                step: "s".into(),
                command: "test -f x".into(),
                detail: "exit 1".into(),
            },
            Error::ToolPrecondition {
                step: "s".into(),
                tool: "duckdb".into(),
                detail: "not found".into(),
            },
            Error::StepTimeout { step: "s".into() },
            Error::PipelineTimeout {
                step: "s".into(),
                elapsed_sec: 3.0,
            },
            Error::ExtensionChanged {
                changes: vec!["mlpack".into()],
            },
            Error::ExtensionRefusedBeforeItRan {
                place: "step 's'".into(),
                step: Some("s".into()),
                refusals: vec!["anofox_forecast".into()],
            },
            Error::SqlNotRead {
                place: "step 's'".into(),
                step: Some("s".into()),
                file: "models/s.sql".into(),
                reason: "it printed nothing".into(),
            },
            Error::ExtensionPinRefusedBeforeItRan {
                place: "hook on_success 'h'".into(),
                step: None,
                refusals: vec!["mlpack".into()],
            },
        ] {
            assert_eq!(e.exit_code(), 2, "expected 'found a problem' (2) for {e:?}");
        }
    }

    #[test]
    fn the_two_codes_are_actually_different() {
        // A test with no non-trivial assertion (`1 != 1`) would pass whichever integer
        // the two buckets happened to share — this pins that they are not the same.
        assert_ne!(
            Error::ManifestNotFound.exit_code(),
            Error::StepTimeout { step: "s".into() }.exit_code()
        );
    }
}