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
// 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.
//!
//! **Layering (ADR-001).** [`parse`] is the pure leaf — owned text in, no IO.
//! [`load_doctrine_toml`] is the one thin impure shell seam co-located here (read
//! file → `parse` → absent ⇒ default), so every consumer shares a single reader.
use anyhow::Context;
use serde::Deserialize;
use std::path::Path;
/// 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).
#[serde(default)]
pub(crate) dispatch: crate::dispatch_config::DispatchConfig,
/// The `[install]` table — parameterises the printed post-install plugin /
/// npx-skills instructions (SL-152 PHASE-06).
#[serde(default)]
pub(crate) install: crate::install_config::InstallConfig,
}
/// 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)
}
/// Parse an entity TOML with canonical-id error context.
///
/// Wraps `toml::from_str`. On parse failure, injects the entity's canonical
/// id so the user sees which entity is broken. The raw `toml` error already
/// describes *what* went wrong.
///
/// Pure leaf (ADR-001): owned text in, no IO, no config dependency.
pub(crate) fn parse_entity_toml<T: serde::de::DeserializeOwned>(
text: &str,
prefix: &str,
id: u32,
) -> anyhow::Result<T> {
toml::from_str(text).with_context(|| format!("{prefix}-{id:03}: TOML parse failed"))
}
/// The project config filename — lives under `.doctrine/`, the single
/// canonical home for project-local config (ISS-055).
pub(crate) const DOCTRINE_TOML: &str = ".doctrine/doctrine.toml";
/// Read the raw `doctrine.toml` body at `root` (IMPURE shell seam) — `None` when
/// the file is absent (a genuine read error still surfaces). The single file-read
/// seam shared by [`load_doctrine_toml`] and any consumer that projects its own
/// section out-of-band of [`DoctrineToml`] (SL-148 `reserve`: keeps `[reservation]`
/// parsing inside the engine-tier consumer so no `leaf → engine` import is forced).
pub(crate) fn read_doctrine_toml_text(root: &Path) -> anyhow::Result<Option<String>> {
let path = root.join(DOCTRINE_TOML);
match std::fs::read_to_string(&path) {
Ok(text) => Ok(Some(text)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("Failed to read {}", path.display())),
}
}
/// Read + parse the project `doctrine.toml` (IMPURE shell seam, ADR-001).
/// Absent file -> `DoctrineToml::default()`; present -> tolerant [`parse`];
/// genuinely malformed TOML errors with context. The single reader shared by
/// the close-integration gate, the sync handler, the `deliver-to` verb, and
/// `load_conduct`.
pub(crate) fn load_doctrine_toml(root: &Path) -> anyhow::Result<DoctrineToml> {
match read_doctrine_toml_text(root)? {
Some(text) => parse(&text)
.with_context(|| format!("Failed to parse {}", root.join(DOCTRINE_TOML).display())),
None => Ok(DoctrineToml::default()),
}
}
#[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()
);
assert_eq!(doc.install, crate::install_config::InstallConfig::default());
}
#[test]
fn install_table_roundtrip() {
// SL-152 PHASE-06: the [install] repo survives the full DoctrineToml parse;
// absent → default davidlee/doctrine.
let doc = parse("[install]\nrepo = \"acme/doctrine\"\n").unwrap();
assert_eq!(doc.install.repo, "acme/doctrine");
let doc2 = parse("").unwrap();
assert_eq!(doc2.install.repo, "davidlee/doctrine");
}
#[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_deliver_to_roundtrip() {
// VT-3: deliver-to survives the full DoctrineToml parse.
let doc = parse("[dispatch]\ndeliver-to = \"refs/heads/release\"\n").unwrap();
assert_eq!(doc.dispatch.deliver_to, "refs/heads/release");
let doc2 = parse("[dispatch]\n").unwrap();
assert_eq!(doc2.dispatch.deliver_to, "refs/heads/main");
}
#[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));
}
}