use camino::Utf8Path;
use crate::cli::gate::GateArgs;
use crate::context::AppContext;
use crate::domain::gate_id::GateId;
use crate::domain::instance_config::{self, InstanceConfig};
use crate::domain::path_filter::{Decision, PathFilter};
use crate::error::AppError;
use crate::gates::{GATES, GateCtx, spec};
use crate::output;
fn docs_root_at(root: &Utf8Path) -> String {
crate::services::verifier::read_manifest(root)
.map_or_else(|_| "_docs".to_string(), |m| m.docs_root.to_string())
}
#[allow(
clippy::literal_string_with_formatting_args,
reason = "the braces are the wiring template's placeholder, not a formatting argument"
)]
fn substitute_root(pattern: &str, docs_root: &str) -> String {
pattern.replace("{docs_root}", docs_root)
}
fn filter_for(
id: GateId,
declaration: &InstanceConfig,
docs_root: &str,
include: &[String],
exclude: &[String],
) -> Result<PathFilter, AppError> {
let row = spec(id);
let registry_include: Vec<String> = row
.include
.iter()
.map(|g| substitute_root(g, docs_root))
.collect();
let registry_exclude: Vec<String> = row
.exclude
.iter()
.map(|g| substitute_root(g, docs_root))
.collect();
instance_config::resolve(
®istry_include,
®istry_exclude,
declaration.for_gate(id),
include,
exclude,
&declaration.reserved,
)
.map_err(|error| AppError::Usage(error.to_string()))
}
fn declaration_at(root: &Utf8Path) -> Result<InstanceConfig, AppError> {
InstanceConfig::read(root).map_err(|error| AppError::Usage(error.to_string()))
}
pub(crate) fn context_at(root: &Utf8Path, id: GateId) -> Result<GateCtx, AppError> {
let filter = filter_for(id, &declaration_at(root)?, &docs_root_at(root), &[], &[])?;
Ok(GateCtx::with_filter(root, filter))
}
fn contained(path: &str) -> Result<(), AppError> {
let candidate = Utf8Path::new(path);
if candidate.components().any(|part| part.as_str() == "..") {
return Err(AppError::Usage(format!(
"{path} climbs out of the repository"
)));
}
if candidate.is_absolute() {
return Ok(());
}
if let (Ok(resolved), Ok(root)) = (std::fs::canonicalize(path), std::fs::canonicalize(".")) {
if !resolved.starts_with(&root) {
return Err(AppError::Usage(format!(
"{path} is reached through a link that leaves the repository"
)));
}
}
Ok(())
}
fn declared_globs(filter: &PathFilter) -> String {
filter
.includes()
.iter()
.map(|pattern| pattern.glob.clone())
.collect::<Vec<_>>()
.join(", ")
}
fn explain(path: &str) -> Result<(), AppError> {
contained(path)?;
let declaration = declaration_at(Utf8Path::new("."))?;
let docs_root = docs_root_at(Utf8Path::new("."));
let relative = crate::domain::path_filter::project(Utf8Path::new(path), Utf8Path::new("."));
let subject = relative.as_path();
for gate in GATES {
let filter = filter_for(gate.id, &declaration, &docs_root, &[], &[])?;
let types = gate
.types
.map_or_else(String::new, |types| format!(" types: [{types}]"));
let line = if gate.discovers {
match filter.decide(subject) {
Decision::Skipped(pattern) => format!(
"skipped {} exclude {} ({}){types}",
gate.id, pattern.glob, pattern.layer
),
_ if !filter.retains(subject) => format!(
"not included {} include {}{types}",
gate.id,
declared_globs(&filter)
),
Decision::Read => format!("judges {}{types}", gate.id),
Decision::NotIncluded => format!(
"not discovered {} its set is {}{types}",
gate.id,
declared_globs(&filter)
),
}
} else {
match filter.decide(subject) {
Decision::Read => format!("judges {}{types}", gate.id),
Decision::Skipped(pattern) => format!(
"skipped {} exclude {} ({}){types}",
gate.id, pattern.glob, pattern.layer
),
Decision::NotIncluded => format!(
"not included {} include {}{types}",
gate.id,
declared_globs(&filter)
),
}
};
output::line(line);
}
output::line("note: pre-commit also applies each row's types:, which this answer does not.");
output::line(
"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.",
);
Ok(())
}
pub fn run(_ctx: &AppContext, args: GateArgs) -> Result<(), AppError> {
let GateArgs {
id,
files,
list,
explain: explain_path,
include,
exclude,
} = args;
if list {
for gate in GATES {
output::line(format!("{}: {}", gate.id, gate.name));
}
return Ok(());
}
if let Some(path) = explain_path {
return explain(&path);
}
let Some(id) = id else {
return Err(AppError::Usage(
"a gate id, --list, or --explain is required".to_string(),
));
};
for path in &files {
contained(path)?;
}
let here = Utf8Path::new(".");
let filter = filter_for(
id,
&declaration_at(here)?,
&docs_root_at(here),
&include,
&exclude,
)?;
let gate_ctx = GateCtx::with_filter(".", filter);
let (roots, subjects): (Vec<String>, Vec<String>) = files
.into_iter()
.partition(|path| gate_ctx.path(path).is_dir());
let judged: Vec<String> = roots
.into_iter()
.chain(
gate_ctx
.subjects(subjects.iter().map(Utf8Path::new))
.into_iter()
.map(ToString::to_string),
)
.collect();
let violations = (spec(id).run)(&gate_ctx, &judged)?;
if violations.is_empty() {
return Ok(());
}
for violation in &violations {
output::line(violation);
}
Err(AppError::Violations {
count: violations.len(),
})
}