use crate::check::context::Ctx;
use crate::check::finding::Finding;
use crate::check::rule::{Rule, RuleInfo};
use crate::graph::Resolution;
use crate::markdown::body;
use crate::model::Tier;
use ignore::WalkBuilder;
pub struct NotInPublic;
pub struct NoLocalCites;
pub struct ExternalCites;
fn is_spec_file(name: &str, types: &[&str]) -> bool {
types.iter().any(|t| name.ends_with(&format!(".{t}.md")))
}
impl Rule for NotInPublic {
fn info(&self) -> &RuleInfo {
&RuleInfo {
id: "V1",
title: "no spec documents under public paths",
explain: "Paths listed in [public] are published. Design and planning material about them is stored with their private companion ([public.companion]) or at the root, never inside them. `spec mv <id>` moves a document to where it belongs.",
on_commit: true,
}
}
fn run(&self, ctx: &Ctx, out: &mut Vec<Finding>) {
let cfg = &ctx.corpus.cfg;
let types = cfg.registry.names();
for p in &cfg.raw.public.paths {
let base = crate::placement::literal_base(p);
let dir = cfg.root.join(&base);
if !dir.is_dir() {
continue;
}
for e in WalkBuilder::new(&dir).hidden(false).require_git(false).build().flatten() {
let Some(rel) = e.path().strip_prefix(&cfg.root).ok().map(|r| r.to_string_lossy().replace('\\', "/"))
else {
continue;
};
let name = e.file_name().to_string_lossy();
let is_spec_dir = e.file_type().is_some_and(|t| t.is_dir()) && name == cfg.spec_dir();
let is_doc = e.file_type().is_some_and(|t| t.is_file()) && is_spec_file(&name, &types);
let in_scope = if is_spec_dir { ctx.scope.is_all() } else { is_doc && ctx.scope.covers_path(&rel) };
if in_scope {
out.push(Finding::error("V1", &rel, 1, format!("{rel} is inside the public path {p}")));
}
}
}
}
}
impl Rule for NoLocalCites {
fn info(&self) -> &RuleInfo {
&RuleInfo {
id: "V2",
title: "committed documents do not cite local ones",
explain: "The local directory is ignored by git, so a committed reference to it breaks for everyone else. Commit the cited document first, or drop the reference.",
on_commit: true,
}
}
fn run(&self, ctx: &Ctx, out: &mut Vec<Finding>) {
for (i, d) in ctx.corpus.docs.iter().enumerate() {
if d.tier != Tier::Committed || !ctx.scope.covers_links(i) {
continue;
}
for l in &d.links {
if let Resolution::Doc { idx, .. } = ctx.graph.resolve(&l.item)
&& ctx.corpus.docs[idx].tier == Tier::Local
{
out.push(Finding::error(
"V2",
&l.path,
l.line,
format!("cites the local document {}", ctx.corpus.docs[idx].id),
));
}
}
}
}
}
impl Rule for ExternalCites {
fn info(&self) -> &RuleInfo {
&RuleInfo {
id: "V3",
title: "external notes cite documents that exist",
explain: "External roots (an agent's memory, personal notes) are read but never checked into the graph. With --external, their [[references]] and spec-path citations are checked so stale notes can be fixed.",
on_commit: false,
}
}
fn run(&self, ctx: &Ctx, out: &mut Vec<Finding>) {
if !ctx.opts.external {
return;
}
let cfg = &ctx.corpus.cfg;
let re = body::path_cite_re(cfg.spec_dir());
for (name, ext) in &cfg.raw.external {
let dir = cfg.expand_path(&ext.path);
for e in WalkBuilder::new(&dir).standard_filters(false).build().flatten() {
if !e.file_type().is_some_and(|t| t.is_file()) || e.path().extension().is_none_or(|x| x != "md") {
continue;
}
let Ok(text) = std::fs::read_to_string(e.path()) else { continue };
let shown = format!("{name}:{}", e.path().strip_prefix(&dir).unwrap_or(e.path()).display());
let scan = body::scan(&text, 1, &re);
for l in scan.links.iter().filter(|l| l.text.contains(':') || l.text.contains('/')) {
if matches!(ctx.graph.resolve(&l.text), Resolution::Missing | Resolution::MissingAnchor { .. }) {
out.push(Finding::warn("V3", &shown, l.line, format!("[[{}]] names no document", l.text)));
}
}
for c in &scan.path_cites {
match ctx.graph.resolve(&c.text) {
Resolution::Doc { idx, .. } => out.push(
Finding::warn("V3", &shown, c.line, format!("cites the path {}", c.text))
.fix_replace(c.text.clone(), format!("[[{}]]", ctx.corpus.docs[idx].id)),
),
_ => out.push(Finding::warn(
"V3",
&shown,
c.line,
format!("cites {} which does not exist", c.text),
)),
}
}
}
}
}
}