Skip to main content

spec_driven_docs/domain/
rule_id.rs

1//! Rule identifiers: one variant per requirement the specs define.
2//!
3//! A rule ID is an address — `domain:rule` — that a failure message cites and
4//! a reader follows to the sentence that binds. This enum is the compile-time
5//! form of every `` ### `domain:rule` `` heading under the canon's specs; a
6//! parity test in `embedded` holds the two sets equal. Rule prose lives in
7//! the specs, never here.
8
9use std::fmt;
10
11macro_rules! rule_ids {
12    ($($variant:ident => $id:literal,)+) => {
13        /// A `domain:rule` requirement address defined by a spec.
14        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
15        pub enum RuleId {
16            $(
17                #[doc = $id]
18                $variant,
19            )+
20        }
21
22        impl RuleId {
23            /// Every rule the specs define, in slug order.
24            pub const ALL: &'static [Self] = &[$(Self::$variant),+];
25
26            /// The `domain:rule` slug pair this variant addresses.
27            #[must_use]
28            pub const fn as_str(self) -> &'static str {
29                match self {
30                    $(Self::$variant => $id),+
31                }
32            }
33
34            /// The rule one slug pair addresses, where a spec defines it.
35            #[must_use]
36            pub fn parse(id: &str) -> Option<Self> {
37                match id {
38                    $($id => Some(Self::$variant),)+
39                    _ => None,
40                }
41            }
42        }
43
44        impl serde::Serialize for RuleId {
45            fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
46                serializer.serialize_str(self.as_str())
47            }
48        }
49
50        impl<'de> serde::Deserialize<'de> for RuleId {
51            fn deserialize<D: serde::Deserializer<'de>>(
52                deserializer: D,
53            ) -> Result<Self, D::Error> {
54                let id = <std::borrow::Cow<'_, str> as serde::Deserialize>::deserialize(
55                    deserializer,
56                )?;
57                Self::parse(&id).ok_or_else(|| {
58                    serde::de::Error::custom(format!("no spec defines the rule {id}"))
59                })
60            }
61        }
62    };
63}
64
65rule_ids! {
66    FragmentNamesTheVenueForm => "acquisition:a-fragment-names-the-venue-form",
67    CleanRemovesOnlyANamedLeftover => "acquisition:clean-removes-only-a-named-leftover",
68    EveryPairCarriesAVerdict => "acquisition:every-pair-carries-a-verdict",
69    OneTargetRunsOneMechanism => "acquisition:one-target-runs-one-mechanism",
70    StatusReportsAndNeverJudges => "acquisition:status-reports-and-never-judges",
71    TheOperatorCallerReportsEveryOutcome => "acquisition:the-operator-caller-reports-every-outcome",
72    ThePinMovesInOneTransaction => "acquisition:the-pin-moves-in-one-transaction",
73    TheSetupPathOffersTheWire => "acquisition:the-setup-path-offers-the-wire",
74    TheShellEntryCallerIsRateLimitedAndSilent => "acquisition:the-shell-entry-caller-is-rate-limited-and-silent",
75    TheSyncLeavesADiffNobodyCommitted => "acquisition:the-sync-leaves-a-diff-nobody-committed",
76    TheToolServesAndDoesNotEdit => "acquisition:the-tool-serves-and-does-not-edit",
77    RecordedDimensionOnlyShrinks => "budget-debt:a-recorded-dimension-only-shrinks",
78    DebtIsCreatedByAnExplicitAct => "budget-debt:debt-is-created-by-an-explicit-act",
79    CellCarriesOneReference => "comparison-docs:a-cell-carries-one-reference",
80    ComparisonCarriesALegend => "comparison-docs:a-comparison-carries-a-legend",
81    VerdictCarriesItsWord => "comparison-docs:a-verdict-carries-its-word",
82    EveryTableIsDated => "comparison-docs:every-table-is-dated",
83    TablePipesAreEscaped => "comparison-docs:table-pipes-are-escaped",
84    CitationResolvesToARule => "decision-records:a-citation-resolves-to-a-rule",
85    BodyStaysWithinWordCap => "decision-records:body-stays-within-350-words",
86    FilenameCarriesNoDigit => "decision-records:filename-carries-no-digit",
87    MergedRecordIsPermanent => "decision-records:merged-record-is-permanent",
88    RecordIsNotRevised => "decision-records:record-is-not-revised",
89    DeclaredLocationIsNamedByItsVariable => "distribution:a-declared-location-is-named-by-its-variable",
90    LandingClassifiesItsTargetFirst => "distribution:a-landing-classifies-its-target-first",
91    SeededRuleRunsNoCanonCommand => "distribution:a-seeded-rule-runs-no-canon-command",
92    SkillChecksItsHostBeforeItPlans => "distribution:a-skill-checks-its-host-before-it-plans",
93    SkillHasOneOwner => "distribution:a-skill-has-one-owner",
94    SkillInstallRestoresOnFailure => "distribution:a-skill-install-restores-on-failure",
95    SkillObeysThePortableFormat => "distribution:a-skill-obeys-the-portable-format",
96    SkillPlansBeforeItActs => "distribution:a-skill-plans-before-it-acts",
97    InstallSweepsWhatThePayloadDropped => "distribution:an-install-sweeps-what-the-payload-dropped",
98    InitializationPreservesProjectContent => "distribution:initialization-preserves-project-content",
99    InstancesOperateOffline => "distribution:instances-operate-offline",
100    ManifestIdentifiesEveryOwnedFile => "distribution:manifest-identifies-every-owned-file",
101    DeclarationIsSeededOnceAndThenOwned => "distribution:the-declaration-is-seeded-once-and-then-owned",
102    SkillPackageIsSelfContained => "distribution:a-skill-package-is-self-contained",
103    SkillInstallPreviewsBeforeWriting => "distribution:skill-install-previews-before-writing",
104    SkillUninstallRemovesOnlyWhatItWrote => "distribution:skill-uninstall-removes-only-what-it-wrote",
105    SkillsArePartOfThePayload => "distribution:skills-are-part-of-the-payload",
106    TheDoctorAnswersForTheInstalledSkills => "distribution:the-doctor-answers-for-the-installed-skills",
107    ThePayloadNamesNoOtherProject => "distribution:the-payload-names-no-other-project",
108    ThePayloadRootsAreDeclaredOnce => "distribution:the-payload-roots-are-declared-once",
109    UpgradeConflictsAreAtomic => "distribution:upgrade-conflicts-are-atomic",
110    UserScopeFilesStayUnrecorded => "distribution:user-scope-files-stay-unrecorded",
111    UserScopeReceiptIsRequiredState => "distribution:a-user-scope-receipt-is-required-state",
112    MachineScopeRecipeDropsASessionVariable => "release:a-machine-scope-recipe-drops-a-session-variable",
113    PlanIsStoredAndAppliedByItsId => "reconcile:a-plan-is-stored-and-applied-by-its-id",
114    ApplyRefusesAPlanWhoseInputsMoved => "reconcile:an-apply-refuses-a-plan-whose-inputs-moved",
115    OneWriterHoldsATarget => "reconcile:one-writer-holds-a-target",
116    PlanWritesNothing => "reconcile:a-plan-writes-nothing",
117    DecisionPrecedesWhatDependsOnIt => "reconcile:a-decision-precedes-what-depends-on-it",
118    FindingIsSomethingTheProgramProved => "reconcile:a-finding-is-something-the-program-proved",
119    WriteIntoAdoptedStateIsAnOperatorAct => "reconcile:a-write-into-adopted-state-is-an-operator-act",
120    IncrementalScopeLeavesNoStructuralFinding => "reconcile:an-incremental-scope-leaves-no-structural-finding",
121    OnePlanIsTheInputToEveryWrite => "reconcile:one-plan-is-the-input-to-every-write",
122    ReadinessIsTheWorstPrecondition => "reconcile:readiness-is-the-worst-precondition",
123    FingerprintCoversWhatTheApplyWouldDo => "reconcile:the-fingerprint-covers-what-the-apply-would-do",
124    PlannerIsDeterministic => "reconcile:the-planner-is-deterministic",
125    TargetDecidesItsClassification => "reconcile:the-target-decides-its-classification",
126    ReleaseIsReadThroughOneSeam => "bundle:a-release-is-read-through-one-seam",
127    ReleaseDeclaresWhatItLands => "bundle:a-release-declares-what-it-lands",
128    ProtocolVersionIsARangeTheEngineDeclares => "bundle:the-protocol-version-is-a-range-the-engine-declares",
129    PreSchemaReleaseIsCatalogedOrUnavailable => "bundle:a-pre-schema-release-is-cataloged-or-unavailable",
130    FetchedArchiveIsVerifiedBeforeItIsRead => "bundle:a-fetched-archive-is-verified-before-it-is-read",
131    ResolutionHappensOnceAndWritesOnlyTheCache => "bundle:resolution-happens-once-and-writes-only-the-cache",
132    RunThatDidNotFinishIsRolledBack => "reconcile:a-run-that-did-not-finish-is-rolled-back",
133    ApplyProvesEveryPostconditionItReports => "reconcile:an-apply-proves-every-postcondition-it-reports",
134    DestinationIsContainedBeforeItIsWritten => "reconcile:a-destination-is-contained-before-it-is-written",
135    PlanIdIsAFingerprintAndNeverAPath => "reconcile:a-plan-id-is-a-fingerprint-and-never-a-path",
136    ApplyResolvesNothing => "reconcile:an-apply-resolves-nothing",
137    TargetRecordsTheReleaseItHolds => "reconcile:a-target-records-the-release-it-holds",
138    ReleaseDeclaresWhatItAsksOfItsOperator => "reconcile:a-release-declares-what-it-asks-of-its-operator",
139    BinaryBuildsForEveryDeclaredTarget => "release:the-binary-builds-for-every-declared-target",
140    OneCatalogDescribesEveryServedDocument => "docs-discovery:one-catalog-describes-every-served-document",
141    InstanceIsRoutedToTheIndex => "docs-discovery:an-instance-is-routed-to-the-index",
142    AuthorInstructionsStayWithinBudget => "docs-format:author-instructions-stay-within-budget",
143    ChapterStaysWithinLineCap => "docs-format:chapter-stays-within-200-lines",
144    DocumentStatesThePresent => "docs-format:document-states-the-present",
145    DocumentUsesStructuralMarkdownOnly => "docs-format:document-uses-structural-markdown-only",
146    EveryBudgetCarriesAGate => "docs-format:every-budget-carries-a-gate",
147    FenceDeclaresALanguage => "docs-format:fence-declares-a-language",
148    ProseStaysUnwrapped => "docs-format:prose-stays-unwrapped",
149    DocumentCarriesNoPersonalPath => "docs-foundations:a-document-carries-no-personal-path",
150    DocumentDirectoryExplainsItself => "docs-foundations:a-document-directory-explains-itself",
151    DocumentOwnsWhatItGoverns => "docs-foundations:a-document-owns-what-it-governs",
152    KindPrefixCarriesASlug => "docs-foundations:a-kind-prefix-carries-a-slug",
153    ArtifactFilenamesCarryAKindPrefix => "docs-foundations:artifact-filenames-carry-a-kind-prefix",
154    CompanionArtifactsShareTheSpecName => "docs-foundations:companion-artifacts-share-the-spec-name",
155    SpecStatesThePresent => "docs-foundations:spec-states-the-present",
156    SpecWinsOverRecord => "docs-foundations:spec-wins-over-record",
157    SpecsAreCentralized => "docs-foundations:specs-are-centralized",
158    ProhibitionsAreCapped => "docs-specs:prohibitions-are-capped",
159    RequirementCarriesAVerification => "docs-specs:requirement-carries-a-verification",
160    RequirementCarriesFiveParts => "docs-specs:requirement-carries-five-parts",
161    RuleIdIsUniqueAndSlugged => "docs-specs:rule-id-is-unique-and-slugged",
162    RuleIdOutlivesItsSentence => "docs-specs:rule-id-outlives-its-sentence",
163    SpecStaysWithinLineCap => "docs-specs:spec-stays-within-300-lines",
164    StatementUsesAnEarsPattern => "docs-specs:statement-uses-an-ears-pattern",
165    UnenforcedRulesAreDeclared => "docs-specs:unenforced-rules-are-declared",
166    VerificationNamesALiveHook => "docs-specs:verification-names-a-live-hook",
167    DivergentResultNamesItsDestination => "guides:a-divergent-result-names-its-destination",
168    ManualStepEnumeratesItsInteraction => "guides:a-manual-step-enumerates-its-interaction",
169    StepFollowsItsProducers => "guides:a-step-follows-its-producers",
170    StepIsOneImperativeAction => "guides:a-step-is-one-imperative-action",
171    ExternalFactIsVerifiedUpstream => "guides:an-external-fact-is-verified-upstream",
172    CitationsLiveInTheReferenceZone => "guides:citations-live-in-the-reference-zone",
173    EveryStepCarriesItsCheck => "guides:every-step-carries-its-check",
174    PreconditionsOpenAndVerificationCloses => "guides:preconditions-open-and-verification-closes",
175    TheManifestStaysReadable => "instance:the-manifest-stays-readable",
176    AgentsBlockStaysManaged => "instance:the-agents-block-stays-managed",
177    TrackingRegistryStaysValid => "instance:the-tracking-registry-stays-valid",
178    ProjectDeclaresWhatItsGatesJudge => "instance:the-project-declares-what-its-gates-judge",
179    ManagedBlockAgreesWithTheDeclaration => "instance:the-managed-block-agrees-with-the-declaration",
180    BugzillaReportBodyFitsReportWidth => "known-issues:a-bugzilla-report-body-fits-in-79-columns",
181    FiledRecordCarriesItsReport => "known-issues:a-filed-record-carries-its-report",
182    RecordCarriesItsRetirementCondition => "known-issues:a-record-carries-its-retirement-condition",
183    RecordCarriesOneFilingState => "known-issues:a-record-carries-one-filing-state",
184    RecordCarriesOneState => "known-issues:a-record-carries-one-state",
185    RecordRecordsItsLastCheck => "known-issues:a-record-records-its-last-check",
186    RecordWalksTheMechanism => "known-issues:a-record-walks-the-mechanism",
187    CaseIdIsASlug => "known-issues:case-id-is-a-slug",
188    CanonGateIsNotDelivered => "release:a-canon-gate-is-not-delivered",
189    DeliveredGateReadsWhatTheConventionOwns => "release:a-delivered-gate-reads-what-the-convention-owns",
190    ReleasedVersionIsNotReAuthored => "release:a-released-version-is-not-re-authored",
191    TagDerivesFromTheVersionFile => "release:a-tag-derives-from-the-version-file",
192    EveryReleaseDeclaresWhatItAsks => "release:every-release-declares-what-it-asks",
193    LicenseDeclaresBothHalves => "release:license-declares-both-halves",
194    CanonRecordDescribesItsTree => "release:the-canon-record-describes-its-tree",
195    RkPinHasTwoFactsAndOneMover => "release:the-rk-pin-has-two-facts-and-one-mover",
196    DeliveredGateSetIsDeclaredOnce => "release:the-delivered-gate-set-is-declared-once",
197    ThirdPartyNoticesTravelWithThePayload => "release:third-party-notices-travel-with-the-payload",
198    VersionsAreSemanticAndAligned => "release:versions-are-semantic-and-aligned",
199    CommentCitesTheRule => "spec-to-code:a-comment-cites-the-rule",
200    CommentNamesNoRecord => "spec-to-code:a-comment-names-no-record",
201    GateMessageCitesTheRule => "spec-to-code:a-gate-message-cites-the-rule",
202    SpecMayLeadItsCode => "spec-to-code:a-spec-may-lead-its-code",
203    SuppressionNamesItsCase => "spec-to-code:a-suppression-names-its-case",
204    DeclaredDependentExists => "tracking:a-declared-dependent-exists",
205    PerishableSourceIsRegistered => "tracking:a-perishable-source-is-registered",
206    EntryDeclaresHowToRevalidate => "tracking:an-entry-declares-how-to-revalidate",
207    OverdueEntryBlocks => "tracking:an-overdue-entry-blocks",
208    UpstreamCheckDoesNotEditTheTree => "tracking:an-upstream-check-does-not-edit-the-tree",
209    UpstreamDerivationPinsARevision => "tracking:an-upstream-derivation-pins-a-revision",
210    RegistryHasOneReadableShape => "tracking:the-registry-has-one-readable-shape",
211    ExistingDocumentConvertsWhenEdited => "writing-style:an-existing-document-converts-when-edited",
212    NoDeliveredGateJudgesProse => "writing-style:no-delivered-gate-judges-prose",
213    SourcesNameTheRevisionRead => "writing-style:sources-name-the-revision-read",
214    DocumentationBlockRoutesToTheStyle => "writing-style:the-documentation-block-routes-to-the-style",
215    StyleLivesInOneDocument => "writing-style:the-style-lives-in-one-document",
216    NoneImposesNoObligation => "writing-policy:none-imposes-no-obligation",
217    ProjectSelectsOneSource => "writing-policy:the-project-selects-one-source",
218}
219
220impl fmt::Display for RuleId {
221    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222        f.write_str(self.as_str())
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn slugs_are_well_formed_and_unique() {
232        let mut seen = std::collections::BTreeSet::new();
233        for rule in RuleId::ALL {
234            let id = rule.as_str();
235            let (domain, name) = id.split_once(':').unwrap();
236            let assert_slug = |part: &str| {
237                assert!(!part.is_empty(), "{id} has an empty half");
238                assert!(
239                    part.bytes()
240                        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'),
241                    "{id} is not a slug pair"
242                );
243            };
244            assert_slug(domain);
245            assert_slug(name);
246            assert!(seen.insert(id), "{id} is duplicated");
247        }
248    }
249
250    #[test]
251    fn display_renders_the_slug_pair() {
252        assert_eq!(
253            RuleId::ChapterStaysWithinLineCap.to_string(),
254            "docs-format:chapter-stays-within-200-lines"
255        );
256    }
257}