spec_driven_docs/commands/
gate.rs1use 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
19fn 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
38fn 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 ®istry_include,
64 ®istry_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
73fn declaration_at(root: &Utf8Path) -> Result<InstanceConfig, AppError> {
75 InstanceConfig::read(root).map_err(|error| AppError::Usage(error.to_string()))
76}
77
78pub(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
92fn 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 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
122fn declared_globs(filter: &PathFilter) -> String {
124 filter
125 .includes()
126 .iter()
127 .map(|pattern| pattern.glob.clone())
128 .collect::<Vec<_>>()
129 .join(", ")
130}
131
132fn 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 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 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
199pub 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 let gate_ctx = GateCtx::with_filter(".", filter);
245 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}