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(
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() -> Result<InstanceConfig, AppError> {
75 InstanceConfig::read(Utf8Path::new(".")).map_err(|error| AppError::Usage(error.to_string()))
76}
77
78fn contained(path: &str) -> Result<(), AppError> {
87 let candidate = Utf8Path::new(path);
88 if candidate.components().any(|part| part.as_str() == "..") {
89 return Err(AppError::Usage(format!(
90 "{path} climbs out of the repository"
91 )));
92 }
93 if candidate.is_absolute() {
94 return Ok(());
95 }
96 if let (Ok(resolved), Ok(root)) = (std::fs::canonicalize(path), std::fs::canonicalize(".")) {
99 if !resolved.starts_with(&root) {
100 return Err(AppError::Usage(format!(
101 "{path} is reached through a link that leaves the repository"
102 )));
103 }
104 }
105 Ok(())
106}
107
108fn declared_globs(filter: &PathFilter) -> String {
110 filter
111 .includes()
112 .iter()
113 .map(|pattern| pattern.glob.clone())
114 .collect::<Vec<_>>()
115 .join(", ")
116}
117
118fn explain(path: &str) -> Result<(), AppError> {
125 contained(path)?;
126 let declaration = declaration()?;
127 let docs_root = docs_root();
128 let relative = crate::domain::path_filter::project(Utf8Path::new(path), Utf8Path::new("."));
131 let subject = relative.as_path();
132 for gate in GATES {
133 let filter = filter_for(gate.id, &declaration, &docs_root, &[], &[])?;
134 let types = gate
135 .types
136 .map_or_else(String::new, |types| format!(" types: [{types}]"));
137 let line = if gate.discovers {
145 match filter.decide(subject) {
146 Decision::Skipped(pattern) => format!(
147 "skipped {} exclude {} ({}){types}",
148 gate.id, pattern.glob, pattern.layer
149 ),
150 _ if !filter.retains(subject) => format!(
151 "not included {} include {}{types}",
152 gate.id,
153 declared_globs(&filter)
154 ),
155 Decision::Read => format!("judges {}{types}", gate.id),
156 Decision::NotIncluded => format!(
157 "not discovered {} its set is {}{types}",
158 gate.id,
159 declared_globs(&filter)
160 ),
161 }
162 } else {
163 match filter.decide(subject) {
164 Decision::Read => format!("judges {}{types}", gate.id),
165 Decision::Skipped(pattern) => format!(
166 "skipped {} exclude {} ({}){types}",
167 gate.id, pattern.glob, pattern.layer
168 ),
169 Decision::NotIncluded => format!(
170 "not included {} include {}{types}",
171 gate.id,
172 declared_globs(&filter)
173 ),
174 }
175 };
176 output::line(line);
177 }
178 output::line("note: pre-commit also applies each row's types:, which this answer does not.");
179 output::line(
180 "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.",
181 );
182 Ok(())
183}
184
185pub fn run(_ctx: &AppContext, args: GateArgs) -> Result<(), AppError> {
192 let GateArgs {
193 id,
194 files,
195 list,
196 explain: explain_path,
197 include,
198 exclude,
199 } = args;
200 if list {
201 for gate in GATES {
202 output::line(format!("{}: {}", gate.id, gate.name));
203 }
204 return Ok(());
205 }
206 if let Some(path) = explain_path {
207 return explain(&path);
208 }
209 let Some(id) = id else {
210 return Err(AppError::Usage(
211 "a gate id, --list, or --explain is required".to_string(),
212 ));
213 };
214
215 for path in &files {
216 contained(path)?;
217 }
218 let filter = filter_for(id, &declaration()?, &docs_root(), &include, &exclude)?;
219 let gate_ctx = GateCtx::with_filter(".", filter);
224 let (roots, subjects): (Vec<String>, Vec<String>) = files
231 .into_iter()
232 .partition(|path| gate_ctx.path(path).is_dir());
233 let judged: Vec<String> = roots
234 .into_iter()
235 .chain(
236 gate_ctx
237 .subjects(subjects.iter().map(Utf8Path::new))
238 .into_iter()
239 .map(ToString::to_string),
240 )
241 .collect();
242
243 let violations = (spec(id).run)(&gate_ctx, &judged)?;
244 if violations.is_empty() {
245 return Ok(());
246 }
247 for violation in &violations {
248 output::line(violation);
249 }
250 Err(AppError::Violations {
251 count: violations.len(),
252 })
253}