ito_core/validate_repo/rule.rs
1//! [`Rule`] trait and supporting types.
2//!
3//! A [`Rule`] is a single named, config-gated check that runs against the
4//! repository and produces zero or more [`crate::validate::ValidationIssue`]
5//! values. The engine in [`super::run_repo_validation`] dispatches each
6//! active rule and merges results into a single
7//! [`crate::validate::ValidationReport`].
8//!
9//! Rules are deliberately small: they own their identifier, severity, an
10//! activation predicate over [`ito_config::types::ItoConfig`], and a check
11//! function. They do **not** own their own report builder — they just emit
12//! raw issues.
13
14use std::fmt;
15use std::path::Path;
16
17use ito_config::types::ItoConfig;
18
19use crate::errors::CoreError;
20use crate::process::ProcessRunner;
21use crate::validate::ValidationIssue;
22
23use super::staged::StagedFiles;
24
25/// Stable identifier for a [`Rule`].
26///
27/// Identifiers follow a `category/kebab-case-name` convention
28/// (e.g. `coordination/symlinks-wired`). They are stored as
29/// `&'static str` because every built-in rule is known at compile time.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
31pub struct RuleId(&'static str);
32
33impl RuleId {
34 /// Construct a [`RuleId`] from a static string.
35 ///
36 /// Callers are expected to pass a literal that follows the
37 /// `category/kebab-case-name` convention.
38 #[must_use]
39 pub const fn new(id: &'static str) -> Self {
40 Self(id)
41 }
42
43 /// Return the underlying string slice.
44 #[must_use]
45 pub const fn as_str(self) -> &'static str {
46 self.0
47 }
48}
49
50impl fmt::Display for RuleId {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 f.write_str(self.0)
53 }
54}
55
56impl AsRef<str> for RuleId {
57 fn as_ref(&self) -> &str {
58 self.0
59 }
60}
61
62/// Nominal severity declared by a [`Rule`].
63///
64/// This is metadata used by [`super::list_active_rules`] and hook output.
65/// The severity actually applied to emitted issues is determined by the rule
66/// itself when constructing the [`ValidationIssue`] (via the
67/// [`crate::validate::error`] / [`crate::validate::warning`] /
68/// [`crate::validate::info`] helpers). Strict mode applied at the report
69/// layer can promote warnings to errors but never weakens an existing error.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum RuleSeverity {
72 /// The rule fails the repository when violated.
73 Error,
74 /// The rule reports a violation but does not fail the repository unless
75 /// `--strict` is set.
76 Warning,
77 /// The rule reports informational findings only.
78 Info,
79}
80
81impl RuleSeverity {
82 /// Return the uppercase string form used by
83 /// [`crate::validate::ValidationLevel`] (`"ERROR"`, `"WARNING"`,
84 /// `"INFO"`).
85 #[must_use]
86 pub const fn as_str(self) -> &'static str {
87 match self {
88 Self::Error => "ERROR",
89 Self::Warning => "WARNING",
90 Self::Info => "INFO",
91 }
92 }
93}
94
95impl fmt::Display for RuleSeverity {
96 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97 f.write_str(self.as_str())
98 }
99}
100
101/// Inputs available to a [`Rule`] during a single check.
102///
103/// The context is borrowed for the duration of the check and never mutated
104/// by rules. Rules SHOULD treat all fields as read-only; in particular they
105/// MUST NOT spawn background tasks holding any of these references.
106///
107/// Marked `#[non_exhaustive]` so future fields (for example a resolved
108/// worktree root for layout rules) can be added without breaking external
109/// constructors.
110#[non_exhaustive]
111pub struct RuleContext<'a> {
112 /// Resolved Ito configuration.
113 pub config: &'a ItoConfig,
114 /// Absolute path to the project root.
115 pub project_root: &'a Path,
116 /// Snapshot of files currently staged in the git index.
117 pub staged: &'a StagedFiles,
118 /// Process runner for shelling out to `git` and other tools.
119 pub runner: &'a dyn ProcessRunner,
120}
121
122impl<'a> RuleContext<'a> {
123 /// Construct a [`RuleContext`].
124 #[must_use]
125 pub fn new(
126 config: &'a ItoConfig,
127 project_root: &'a Path,
128 staged: &'a StagedFiles,
129 runner: &'a dyn ProcessRunner,
130 ) -> Self {
131 Self {
132 config,
133 project_root,
134 staged,
135 runner,
136 }
137 }
138}
139
140/// A repository validation rule.
141///
142/// Implementations are typically zero-sized structs — all per-call state
143/// lives in the [`RuleContext`].
144pub trait Rule: Send + Sync {
145 /// Stable identifier for this rule (e.g. `coordination/symlinks-wired`).
146 fn id(&self) -> RuleId;
147
148 /// Nominal severity for this rule.
149 fn severity(&self) -> RuleSeverity;
150
151 /// Short human-readable description of what the rule checks.
152 ///
153 /// Used by `ito validate repo --list-rules` and `--explain`.
154 fn description(&self) -> &'static str;
155
156 /// Optional human-readable description of the activation gate, suitable
157 /// for surfacing in `--list-rules` output.
158 ///
159 /// Examples:
160 /// - `"changes.coordination_branch.storage == worktree"`
161 /// - `"worktrees.enabled == true"`
162 /// - `None` for rules that are always active.
163 ///
164 /// Defaults to `None`. Rules that gate themselves on configuration
165 /// SHOULD override this so introspection output is informative.
166 fn gate(&self) -> Option<&'static str> {
167 None
168 }
169
170 /// Whether this rule is active for the given config.
171 ///
172 /// Rules SHOULD return `false` quickly when they have nothing to check
173 /// (for example, coordination rules return `false` when storage is
174 /// embedded). Inactive rules are skipped before [`Rule::check`] runs.
175 fn is_active(&self, config: &ItoConfig) -> bool;
176
177 /// Run the rule against the given [`RuleContext`] and return any issues.
178 ///
179 /// Returning an empty `Vec` means the rule passed. Rules SHOULD construct
180 /// issues using the helpers in [`crate::validate`] so the rule id is
181 /// consistently attached.
182 ///
183 /// # Errors
184 ///
185 /// Rules that need to invoke external tooling (for example
186 /// `git check-ignore`) MAY return [`CoreError`] when the tool itself
187 /// fails. The engine surfaces such errors as `ERROR`-level issues
188 /// pointing at the rule, rather than aborting the whole validation run.
189 fn check(&self, ctx: &RuleContext<'_>) -> Result<Vec<ValidationIssue>, CoreError>;
190}
191
192#[cfg(test)]
193#[path = "rule_tests.rs"]
194mod rule_tests;