Skip to main content

spec_driven_docs/plan/
finding.rs

1//! What the corpus says about itself, as far as a program can prove it.
2//!
3//! Brownfield was one word for three kinds of debt, and only one of them
4//! is the reason the sweep exists. A structural finding is what makes two
5//! conventions coexist, so it forces the sweep. A budget finding is a
6//! measurement over a cap, so it becomes debt. Style is neither: no
7//! delivered gate judges prose, and the engine does not get to claim a
8//! document's prose is wrong because it was written first.
9
10use serde::{Deserialize, Serialize};
11
12use crate::plan::operation::TargetPath;
13
14/// Which kind of debt a finding is.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "kebab-case")]
17pub enum FindingKind {
18    /// Two conventions would coexist. Only a sweep resolves it.
19    Structural,
20    /// A measurement is over a cap. Debt records it.
21    Budget,
22}
23
24/// One thing the corpus shows.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct Finding {
27    /// Which kind of debt it is.
28    pub kind: FindingKind,
29    /// Where.
30    pub path: TargetPath,
31    /// The detector that found it, as a stable slug.
32    pub rule: String,
33    /// One sentence a person reads.
34    pub statement: String,
35    /// What was measured, where the finding is a measurement.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub measurement: Option<Measurement>,
38}
39
40/// A number over a cap.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct Measurement {
43    /// What was counted.
44    pub dimension: String,
45    /// How much of it there is.
46    pub found: u64,
47    /// How much the convention allows.
48    pub cap: u64,
49}
50
51/// A document written before the instance, which nothing here judges.
52///
53/// Named rather than measured. `writing-style:no-delivered-gate-judges-prose`
54/// forbids a delivered gate from judging prose, and an engine that reported
55/// a style finding would be doing by another name what that rule refuses.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct StyleCandidate {
58    /// Where.
59    pub path: TargetPath,
60    /// Why it is a candidate, never why it is wrong.
61    pub reason: String,
62}
63
64/// The structural detectors, by the slug each finding cites.
65pub mod detector {
66    /// A populated documentation root that is not the selected profile's.
67    pub const FOREIGN_DOCS_ROOT: &str = "foreign-documentation-root";
68    /// A document shaped like a specification that defines no rule ID.
69    pub const SPEC_WITHOUT_RULE_ID: &str = "spec-without-a-rule-id";
70    /// A document named by its position rather than by its subject.
71    pub const ORDINAL_FILENAME: &str = "ordinal-filename";
72    /// A decision record outside the decisions directory.
73    pub const RECORD_OUTSIDE_DECISIONS: &str = "record-outside-the-decisions-directory";
74    /// A settled corpus with no specifications directory.
75    pub const NO_SPECS_DIRECTORY: &str = "no-specifications-directory";
76}
77
78/// Whether a filename is named by its position rather than its subject.
79///
80/// A number is an identity two branches can both claim, and a slug is not.
81/// A subject that starts with a number keeps it: `roadmap-2026` is a
82/// subject, and `01-intro` is a position.
83#[must_use]
84pub fn is_ordinal_name(name: &str) -> bool {
85    let stem = name.strip_suffix(".md").unwrap_or(name);
86    let Some((head, rest)) = stem.split_once('-') else {
87        return false;
88    };
89    !head.is_empty() && head.bytes().all(|byte| byte.is_ascii_digit()) && !rest.is_empty()
90}
91
92/// Whether a document is shaped like a specification of this convention.
93///
94/// The shape is the filename, because a generic document under `specs/` is
95/// the project's own and is not this convention's specification missing a
96/// rule. Calling that structural would sweep files nobody adopted.
97#[must_use]
98pub fn is_spec_shaped(name: &str) -> bool {
99    name.starts_with("SPEC-") && is_markdown(name)
100}
101
102/// Whether a filename is a markdown document.
103///
104/// Case-sensitive on purpose: the convention's own filenames are, and a
105/// document named with a shouted extension is not one of them.
106fn is_markdown(name: &str) -> bool {
107    std::path::Path::new(name)
108        .extension()
109        .is_some_and(|extension| extension == "md")
110}
111
112/// Whether a document is shaped like a decision record.
113#[must_use]
114pub fn is_record_shaped(name: &str) -> bool {
115    name.starts_with("ADR-") && is_markdown(name)
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn a_position_is_an_ordinal_name_and_a_subject_is_not() {
124        assert!(is_ordinal_name("01-intro.md"));
125        assert!(is_ordinal_name("0007-thing.md"));
126        assert!(!is_ordinal_name("roadmap-2026.md"));
127        assert!(!is_ordinal_name("intro.md"));
128        assert!(!is_ordinal_name("README.md"));
129        assert!(!is_ordinal_name("2026.md"));
130        assert!(!is_ordinal_name("01-.md"));
131    }
132
133    #[test]
134    fn only_this_conventions_shapes_are_recognized() {
135        assert!(is_spec_shaped("SPEC-distribution.md"));
136        assert!(!is_spec_shaped("notes.md"));
137        assert!(!is_spec_shaped("SPEC-distribution.rst"));
138        assert!(is_record_shaped("ADR-a-choice.md"));
139        assert!(!is_record_shaped("decision.md"));
140    }
141}