Skip to main content

spec_driven_docs/commands/
gate.rs

1//! `gate` subcommand: runtime-shape.
2//!
3//! Runs one delivered gate against the invoking repository, printing every
4//! violation to stdout, lists the registry, or explains which gates judge a
5//! path. Gate semantics live in `gates`; this handler resolves the subject
6//! filter, holds the containment boundary, and reports.
7
8use camino::Utf8Path;
9
10use crate::cli::gate::GateArgs;
11use crate::context::AppContext;
12use crate::domain::gate_id::GateId;
13use crate::domain::instance_config::{self, InstanceConfig};
14use crate::domain::path_filter::{Decision, PathFilter};
15use crate::error::AppError;
16use crate::gates::{GATES, GateCtx, spec};
17use crate::output;
18
19/// The documentation root patterns are templated against.
20///
21/// It comes from the instance's own record, because the two profiles use
22/// different roots. Templating against a fixed `_docs` would build patterns
23/// no path in a `codebase` instance matches, and every filename-selected
24/// gate there would silently judge nothing.
25fn docs_root_at(root: &Utf8Path) -> String {
26    crate::services::verifier::read_manifest(root)
27        .map_or_else(|_| "_docs".to_string(), |m| m.docs_root.to_string())
28}
29
30#[allow(
31    clippy::literal_string_with_formatting_args,
32    reason = "the braces are the wiring template's placeholder, not a formatting argument"
33)]
34fn substitute_root(pattern: &str, docs_root: &str) -> String {
35    pattern.replace("{docs_root}", docs_root)
36}
37
38/// Build one gate's subject filter from every layer.
39///
40/// The registry is the first layer, the project's declaration the second and
41/// the fourth, and the command-line flags the third.
42/// [`instance_config::resolve`] owns the algorithm, so the command line and
43/// the renderer cannot disagree about it.
44fn filter_for(
45    id: GateId,
46    declaration: &InstanceConfig,
47    docs_root: &str,
48    include: &[String],
49    exclude: &[String],
50) -> Result<PathFilter, AppError> {
51    let row = spec(id);
52    let registry_include: Vec<String> = row
53        .include
54        .iter()
55        .map(|g| substitute_root(g, docs_root))
56        .collect();
57    let registry_exclude: Vec<String> = row
58        .exclude
59        .iter()
60        .map(|g| substitute_root(g, docs_root))
61        .collect();
62    instance_config::resolve(
63        &registry_include,
64        &registry_exclude,
65        declaration.for_gate(id),
66        include,
67        exclude,
68        &declaration.reserved,
69    )
70    .map_err(|error| AppError::Usage(error.to_string()))
71}
72
73/// The declaration a repository root carries.
74fn declaration_at(root: &Utf8Path) -> Result<InstanceConfig, AppError> {
75    InstanceConfig::read(root).map_err(|error| AppError::Usage(error.to_string()))
76}
77
78/// One gate's context at a repository root: the project's declaration
79/// applied, and no flag.
80///
81/// This is how a verb other than `gate` measures with a gate, so a debt
82/// baseline and a hook run judge the same subject set.
83///
84/// # Errors
85///
86/// [`AppError::Usage`] when the declaration does not parse.
87pub(crate) fn context_at(root: &Utf8Path, id: GateId) -> Result<GateCtx, AppError> {
88    let filter = filter_for(id, &declaration_at(root)?, &docs_root_at(root), &[], &[])?;
89    Ok(GateCtx::with_filter(root, filter))
90}
91
92/// Refuse a path this command must not follow.
93///
94/// A relative path that climbs out of the repository is refused, and so is
95/// one reached through a link that leaves it. An absolute path is not:
96/// pre-commit hands the message file at the `commit-msg` stage by absolute
97/// path, and it sits outside the working tree by design, so refusing
98/// absolutes would break a delivered wiring. What a gate reads stays bounded
99/// by what pre-commit or the operator names, which was already true.
100fn contained(path: &str) -> Result<(), AppError> {
101    let candidate = Utf8Path::new(path);
102    if candidate.components().any(|part| part.as_str() == "..") {
103        return Err(AppError::Usage(format!(
104            "{path} climbs out of the repository"
105        )));
106    }
107    if candidate.is_absolute() {
108        return Ok(());
109    }
110    // A relative path that resolves outside the repository got there through
111    // a link, which is the case the `..` check cannot see.
112    if let (Ok(resolved), Ok(root)) = (std::fs::canonicalize(path), std::fs::canonicalize(".")) {
113        if !resolved.starts_with(&root) {
114            return Err(AppError::Usage(format!(
115                "{path} is reached through a link that leaves the repository"
116            )));
117        }
118    }
119    Ok(())
120}
121
122/// Every include pattern a filter carries, for the explain output.
123fn declared_globs(filter: &PathFilter) -> String {
124    filter
125        .includes()
126        .iter()
127        .map(|pattern| pattern.glob.clone())
128        .collect::<Vec<_>>()
129        .join(", ")
130}
131
132/// Report which gates judge one path, and what decided each answer.
133///
134/// This answers path-declaration eligibility. It is not a prediction of what
135/// pre-commit will run: pre-commit applies `types:` in addition to the
136/// rendered patterns and this command does not, so a row's `types:` is
137/// printed rather than folded into the answer.
138fn explain(path: &str) -> Result<(), AppError> {
139    contained(path)?;
140    let declaration = declaration_at(Utf8Path::new("."))?;
141    let docs_root = docs_root_at(Utf8Path::new("."));
142    // Decide on the form a pattern speaks. `GateCtx` does this for the run
143    // path; this one answers without a context.
144    let relative = crate::domain::path_filter::project(Utf8Path::new(path), Utf8Path::new("."));
145    let subject = relative.as_path();
146    for gate in GATES {
147        let filter = filter_for(gate.id, &declaration, &docs_root, &[], &[])?;
148        let types = gate
149            .types
150            .map_or_else(String::new, |types| format!("  types: [{types}]"));
151        // Three answers for a discovering row, because two would lie. The
152        // gate judges what its discovery produces, and it also judges a
153        // path an operator points it at through a support root. So a path
154        // outside its registry include is not "not included" — that is the
155        // whitelist answer and this gate does not apply one — and it is not
156        // "judges" either, because the discovery will not produce it on its
157        // own.
158        let line = if gate.discovers {
159            match filter.decide(subject) {
160                Decision::Skipped(pattern) => format!(
161                    "skipped      {}  exclude {}  ({}){types}",
162                    gate.id, pattern.glob, pattern.layer
163                ),
164                _ if !filter.retains(subject) => format!(
165                    "not included {}  include {}{types}",
166                    gate.id,
167                    declared_globs(&filter)
168                ),
169                Decision::Read => format!("judges       {}{types}", gate.id),
170                Decision::NotIncluded => format!(
171                    "not discovered {}  its set is {}{types}",
172                    gate.id,
173                    declared_globs(&filter)
174                ),
175            }
176        } else {
177            match filter.decide(subject) {
178                Decision::Read => format!("judges       {}{types}", gate.id),
179                Decision::Skipped(pattern) => format!(
180                    "skipped      {}  exclude {}  ({}){types}",
181                    gate.id, pattern.glob, pattern.layer
182                ),
183                Decision::NotIncluded => format!(
184                    "not included {}  include {}{types}",
185                    gate.id,
186                    declared_globs(&filter)
187                ),
188            }
189        };
190        output::line(line);
191    }
192    output::line("note: pre-commit also applies each row's types:, which this answer does not.");
193    output::line(
194        "note: `not discovered` means outside the gate's own set. Some of these take an explicit record root and would then judge it; others read one fixed location and never will.",
195    );
196    Ok(())
197}
198
199/// Run, list, or explain.
200///
201/// # Errors
202///
203/// [`AppError::Violations`] when the gate found any; [`AppError::Usage`] for
204/// a refused path or a malformed pattern; I/O errors when it could not run.
205pub fn run(_ctx: &AppContext, args: GateArgs) -> Result<(), AppError> {
206    let GateArgs {
207        id,
208        files,
209        list,
210        explain: explain_path,
211        include,
212        exclude,
213    } = args;
214    if list {
215        for gate in GATES {
216            output::line(format!("{}: {}", gate.id, gate.name));
217        }
218        return Ok(());
219    }
220    if let Some(path) = explain_path {
221        return explain(&path);
222    }
223    let Some(id) = id else {
224        return Err(AppError::Usage(
225            "a gate id, --list, or --explain is required".to_string(),
226        ));
227    };
228
229    for path in &files {
230        contained(path)?;
231    }
232    let here = Utf8Path::new(".");
233    let filter = filter_for(
234        id,
235        &declaration_at(here)?,
236        &docs_root_at(here),
237        &include,
238        &exclude,
239    )?;
240    // Filter the passed paths always. Ruff carries `--force-exclude`
241    // because the opposite default surprised people under pre-commit, which
242    // passes changed files explicitly, and pre-commit is this tool's only
243    // caller.
244    let gate_ctx = GateCtx::with_filter(".", filter);
245    // A positional value is a file to judge or a record root to resolve,
246    // and `src/cli/gate.rs` says so. Filter the files; pass a directory
247    // through untouched, because it is a support root and the records
248    // discovered beneath it are filtered where the gate resolves them.
249    // Filtering the root itself would silently drop it and let the gate
250    // fall back to the default location, reporting nothing.
251    let (roots, subjects): (Vec<String>, Vec<String>) = files
252        .into_iter()
253        .partition(|path| gate_ctx.path(path).is_dir());
254    let judged: Vec<String> = roots
255        .into_iter()
256        .chain(
257            gate_ctx
258                .subjects(subjects.iter().map(Utf8Path::new))
259                .into_iter()
260                .map(ToString::to_string),
261        )
262        .collect();
263
264    let violations = (spec(id).run)(&gate_ctx, &judged)?;
265    if violations.is_empty() {
266        return Ok(());
267    }
268    for violation in &violations {
269        output::line(violation);
270    }
271    Err(AppError::Violations {
272        count: violations.len(),
273    })
274}