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() -> String {
26 crate::services::verifier::read_manifest(Utf8Path::new("."))
27 .map_or_else(|_| "_docs".to_string(), |m| m.docs_root.to_string())
28}
29
30#[allow(clippy::literal_string_with_formatting_args)]
32fn substitute_root(pattern: &str, docs_root: &str) -> String {
33 pattern.replace("{docs_root}", docs_root)
34}
35
36fn filter_for(
43 id: GateId,
44 declaration: &InstanceConfig,
45 docs_root: &str,
46 include: &[String],
47 exclude: &[String],
48) -> Result<PathFilter, AppError> {
49 let row = spec(id);
50 let registry_include: Vec<String> = row
51 .include
52 .iter()
53 .map(|g| substitute_root(g, docs_root))
54 .collect();
55 let registry_exclude: Vec<String> = row
56 .exclude
57 .iter()
58 .map(|g| substitute_root(g, docs_root))
59 .collect();
60 instance_config::resolve(
61 ®istry_include,
62 ®istry_exclude,
63 declaration.for_gate(id),
64 include,
65 exclude,
66 &declaration.reserved,
67 )
68 .map_err(|error| AppError::Usage(error.to_string()))
69}
70
71fn declaration() -> Result<InstanceConfig, AppError> {
73 InstanceConfig::read(Utf8Path::new(".")).map_err(|error| AppError::Usage(error.to_string()))
74}
75
76fn contained(path: &str) -> Result<(), AppError> {
85 let candidate = Utf8Path::new(path);
86 if candidate.components().any(|part| part.as_str() == "..") {
87 return Err(AppError::Usage(format!(
88 "{path} climbs out of the repository"
89 )));
90 }
91 if candidate.is_absolute() {
92 return Ok(());
93 }
94 if let (Ok(resolved), Ok(root)) = (std::fs::canonicalize(path), std::fs::canonicalize(".")) {
97 if !resolved.starts_with(&root) {
98 return Err(AppError::Usage(format!(
99 "{path} is reached through a link that leaves the repository"
100 )));
101 }
102 }
103 Ok(())
104}
105
106fn declared_globs(filter: &PathFilter) -> String {
108 filter
109 .includes()
110 .iter()
111 .map(|pattern| pattern.glob.clone())
112 .collect::<Vec<_>>()
113 .join(", ")
114}
115
116fn explain(path: &str) -> Result<(), AppError> {
123 contained(path)?;
124 let declaration = declaration()?;
125 let docs_root = docs_root();
126 let relative = crate::domain::path_filter::project(Utf8Path::new(path), Utf8Path::new("."));
129 let subject = relative.as_path();
130 for gate in GATES {
131 let filter = filter_for(gate.id, &declaration, &docs_root, &[], &[])?;
132 let types = gate
133 .types
134 .map_or_else(String::new, |types| format!(" types: [{types}]"));
135 let line = if gate.discovers {
143 match filter.decide(subject) {
144 Decision::Skipped(pattern) => format!(
145 "skipped {} exclude {} ({}){types}",
146 gate.id, pattern.glob, pattern.layer
147 ),
148 _ if !filter.retains(subject) => format!(
149 "not included {} include {}{types}",
150 gate.id,
151 declared_globs(&filter)
152 ),
153 Decision::Read => format!("judges {}{types}", gate.id),
154 Decision::NotIncluded => format!(
155 "not discovered {} its set is {}{types}",
156 gate.id,
157 declared_globs(&filter)
158 ),
159 }
160 } else {
161 match filter.decide(subject) {
162 Decision::Read => format!("judges {}{types}", gate.id),
163 Decision::Skipped(pattern) => format!(
164 "skipped {} exclude {} ({}){types}",
165 gate.id, pattern.glob, pattern.layer
166 ),
167 Decision::NotIncluded => format!(
168 "not included {} include {}{types}",
169 gate.id,
170 declared_globs(&filter)
171 ),
172 }
173 };
174 output::line(line);
175 }
176 output::line("note: pre-commit also applies each row's types:, which this answer does not.");
177 output::line(
178 "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.",
179 );
180 Ok(())
181}
182
183pub fn run(_ctx: &AppContext, args: GateArgs) -> Result<(), AppError> {
190 let GateArgs {
191 id,
192 files,
193 list,
194 explain: explain_path,
195 include,
196 exclude,
197 } = args;
198 if list {
199 for gate in GATES {
200 output::line(format!("{}: {}", gate.id, gate.name));
201 }
202 return Ok(());
203 }
204 if let Some(path) = explain_path {
205 return explain(&path);
206 }
207 let Some(id) = id else {
208 return Err(AppError::Usage(
209 "a gate id, --list, or --explain is required".to_string(),
210 ));
211 };
212
213 for path in &files {
214 contained(path)?;
215 }
216 let filter = filter_for(id, &declaration()?, &docs_root(), &include, &exclude)?;
217 let gate_ctx = GateCtx::with_filter(".", filter);
222 let (roots, subjects): (Vec<String>, Vec<String>) = files
229 .into_iter()
230 .partition(|path| gate_ctx.path(path).is_dir());
231 let judged: Vec<String> = roots
232 .into_iter()
233 .chain(
234 gate_ctx
235 .subjects(subjects.iter().map(Utf8Path::new))
236 .into_iter()
237 .map(ToString::to_string),
238 )
239 .collect();
240
241 let violations = (spec(id).run)(&gate_ctx, &judged)?;
242 if violations.is_empty() {
243 return Ok(());
244 }
245 for violation in &violations {
246 output::line(violation);
247 }
248 Err(AppError::Violations {
249 count: violations.len(),
250 })
251}