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
// SPDX-License-Identifier: GPL-3.0-only
//! `dtoml` — THE single shared `doctrine.toml` reader (SL-057 PHASE-02, design D2).
//!
//! One parser owns the whole `doctrine.toml` shape so the file is read once and
//! split into its sub-configs: the `[conduct]` table ([`crate::conduct`]) and the
//! `[verification]` table ([`crate::verify`]), plus the `[estimation]` and
//! `[value]` tables. Every field is `#[serde(default)]`, so an absent table
//! parses to its sub-config's default (tolerant — the conduct precedent). Every
//! other top-level key is ignored.
//!
//! **Pure leaf (ADR-001).** The file *read* lives in the shell; [`parse`] takes
//! owned text only.
use serde::Deserialize;
/// The outer `doctrine.toml` shape — the union of every read sub-config. Absent
/// tables fall to their sub-config defaults (`#[serde(default)]`); unknown
/// top-level keys are ignored (tolerant parse).
#[derive(Debug, Default, Deserialize)]
pub(crate) struct DoctrineToml {
/// The `[conduct]` table — LIVE (consumed by [`crate::conduct::parse`]).
#[serde(default)]
pub(crate) conduct: crate::conduct::ConductConfig,
/// The `[verification]` table — consumed by the verifier + the record handler
/// (SL-057 PHASE-05) through the shared `coverage_store::load_config` reader.
#[serde(default)]
pub(crate) verification: crate::verify::VerificationConfig,
/// The `[estimation]` table — project-wide display/default unit + confidence
/// bounds for estimation facets. The unit is resolved by the catalog shell
/// (`scan_catalog`) into the top-level `Units` block (SL-103 PHASE-02).
#[serde(default)]
pub(crate) estimation: crate::estimate::EstimationConfig,
/// The `[value]` table — project-wide display/default unit for value facets.
/// The unit is resolved by the catalog shell into `Units` (SL-103 PHASE-02).
#[serde(default)]
pub(crate) value: crate::value::ValueConfig,
/// The `[dispatch]` table — consumed by the dispatch orchestrator to select
/// the spawn arm (SL-108 design D3 / IMP-101).
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "consumed by a future dispatch-config display slice"
)
)]
#[serde(default)]
pub(crate) dispatch: crate::dispatch_config::DispatchConfig,
}
/// Parse a project `doctrine.toml` body into its sub-configs (PURE). The shell
/// owns the file read; this is the ONLY `doctrine.toml` parser.
pub(crate) fn parse(text: &str) -> anyhow::Result<DoctrineToml> {
// Design §3.3: confidence bounds are "purely informational until consumed" —
// no runtime effect in this slice. We deliberately do NOT eagerly validate
// [estimation] here: parse() is the shared reader for conduct, verification,
// and coverage_store config, so propagating confidence validation would
// couple those unrelated reads to estimation-config validity. Consumers that
// need the bounds call `estimate::resolve_confidence` themselves.
let doc: DoctrineToml = toml::from_str(text)?;
Ok(doc)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn absent_tables_yield_defaults() {
let doc = parse("").unwrap();
assert_eq!(doc.conduct, crate::conduct::ConductConfig::default());
assert_eq!(
doc.verification,
crate::verify::VerificationConfig::default()
);
assert_eq!(doc.estimation, crate::estimate::EstimationConfig::default());
assert_eq!(doc.value, crate::value::ValueConfig::default());
assert_eq!(
doc.dispatch,
crate::dispatch_config::DispatchConfig::default()
);
}
#[test]
fn estimation_and_value_tables_parse() {
let doc = parse("[estimation]\nunit=\"x\"\n[value]\nunit=\"y\"").unwrap();
assert_eq!(doc.estimation.unit.as_deref(), Some("x"));
assert_eq!(doc.value.unit.as_deref(), Some("y"));
}
#[test]
fn dispatch_table_roundtrip() {
// The full round-trip through the shared dtoml::parse — not just the
// DispatchConfig unit tests. Prove a populated [dispatch] survives the
// outer TOML deserialize, and that a missing key within [dispatch]
// defaults to codex.
let doc = parse("[dispatch]\npreferred-subprocess-harness = \"pi\"\n").unwrap();
use crate::dispatch_config::SubprocessHarness;
assert_eq!(
doc.dispatch.preferred_subprocess_harness,
SubprocessHarness::Pi
);
// [dispatch] present but key absent → default (codex)
let doc2 = parse("[dispatch]\n").unwrap();
assert_eq!(
doc2.dispatch.preferred_subprocess_harness,
SubprocessHarness::Codex
);
}
#[test]
fn dispatch_table_combined_keys() {
// SL-117: prove both dispatch keys survive the full dtoml::parse round-trip.
let doc =
parse("[dispatch]\npreferred-subprocess-harness = \"pi\"\nclaude-force-subprocess-dispatch = true\n")
.unwrap();
use crate::dispatch_config::SubprocessHarness;
assert_eq!(
doc.dispatch.preferred_subprocess_harness,
SubprocessHarness::Pi
);
assert!(doc.dispatch.claude_force_subprocess_dispatch);
}
// RV-085 F-1 regression: a malformed [estimation] confidence config must NOT
// fail the shared config read. parse() is the reader for conduct, verification,
// and coverage_store; coupling those to estimation validity violates design §3.3
// ("no runtime effect in this slice"). Confidence validation belongs to the
// consumer that needs the bounds, not to every doctrine.toml read.
#[test]
fn malformed_estimation_confidence_does_not_block_config_read() {
let doc = parse("[estimation]\nlower_confidence=0.5\nupper_confidence=0.3\n[conduct]\n")
.expect("malformed estimation confidence must not block the shared config read");
// estimation table is still parsed (tolerated); only eager validation is gone
assert_eq!(doc.estimation.lower_confidence, Some(0.5));
assert_eq!(doc.estimation.upper_confidence, Some(0.3));
}
}