use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
const ENTRY_HEADING: &str = "## GAP-SG-";
const STRUCTURAL_HEADINGS: &[&str] = &[
"## Escopo deste documento",
"## Correção de rumo antes de tudo",
"## PARTE ",
];
const STATUS_MARKER: &str = "- Status:";
const CAVEAT_MARKERS: &[&str] = &[
"com resíduo",
"com restrição",
"restrição técnica declarada",
"parcialmente",
"exceto",
];
fn read_gaps() -> String {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("gaps.md");
std::fs::read_to_string(&path).expect("gaps.md must be readable from the workspace root")
}
fn entries(gaps: &str) -> BTreeMap<String, String> {
let mut out: BTreeMap<String, String> = BTreeMap::new();
let mut current: Option<String> = None;
let mut buffer = String::new();
for line in gaps.lines() {
if line.trim_start().starts_with(ENTRY_HEADING) {
if let Some(id) = current.take() {
out.insert(id, std::mem::take(&mut buffer));
}
current = line
.split_whitespace()
.nth(1)
.map(|token| token.trim_end_matches('—').trim().to_string());
}
buffer.push_str(line);
buffer.push('\n');
}
if let Some(id) = current {
out.insert(id, buffer);
}
out
}
fn status_of(body: &str) -> Option<String> {
body.lines()
.map(str::trim_start)
.find_map(|l| l.strip_prefix(STATUS_MARKER))
.map(|rest| rest.trim().to_string())
}
fn admits_a_leftover(status: &str) -> bool {
let lowered = status.to_lowercase();
CAVEAT_MARKERS.iter().any(|m| lowered.contains(m))
}
const DESTINATION_MARKERS: &[&str] = &[
"resíduo em",
"resíduo rastreado em",
"rastreado em",
"resíduo extraído para",
"continua em",
"transferido para",
];
fn names_a_destination(id: &str, body: &str) -> bool {
body.lines().any(|line| {
let lowered = line.to_lowercase();
if !DESTINATION_MARKERS.iter().any(|m| lowered.contains(m)) {
return false;
}
line.match_indices("GAP-SG-")
.filter_map(|(at, _)| line.get(at..at + 10))
.any(|reference| reference != id)
})
}
fn level_two_headings(gaps: &str) -> Vec<&str> {
gaps.lines()
.map(str::trim_start)
.filter(|line| line.starts_with("## "))
.collect()
}
#[test]
fn the_gate_found_the_entries_it_is_supposed_to_read() {
let gaps = read_gaps();
let parsed = entries(&gaps);
let all = level_two_headings(&gaps);
let undeclared: Vec<&str> = all
.iter()
.copied()
.filter(|line| {
!line.starts_with(ENTRY_HEADING)
&& !STRUCTURAL_HEADINGS
.iter()
.any(|known| line.starts_with(known))
})
.collect();
assert!(
undeclared.is_empty(),
"these level-two headings are neither a `{ENTRY_HEADING}` entry nor one \
of the declared structural sections, so every check below skips them \
in silence. Make each one an entry, or add it to \
`STRUCTURAL_HEADINGS` with the reason.\n{}",
undeclared.join("\n")
);
let headings = all
.iter()
.filter(|line| line.starts_with(ENTRY_HEADING))
.count();
assert_eq!(
parsed.len(),
headings,
"gaps.md carries {headings} `{ENTRY_HEADING}` heading(s) but the \
splitter produced {} entry(ies), so a heading was dropped or two \
collapsed onto one id, and every assertion below would pass by not \
looking",
parsed.len()
);
let without_status: Vec<&str> = parsed
.iter()
.filter(|(_, body)| status_of(body).is_none())
.map(|(id, _)| id.as_str())
.collect();
assert!(
without_status.is_empty(),
"every entry must carry a `{STATUS_MARKER}` bullet, or the caveat check \
below passes it over in silence instead of judging it. Missing on: {}",
without_status.join(", ")
);
}
#[test]
fn the_gate_separates_a_tracked_leftover_from_an_orphaned_one() {
assert!(admits_a_leftover("RESOLVIDO na v1.2.2 com resíduo aberto"));
assert!(names_a_destination(
"GAP-SG-141",
"- Status: RESOLVIDO com resíduo aberto\n- B2 com resíduo em GAP-SG-156\n"
));
assert!(
!names_a_destination(
"GAP-SG-162",
"- Status: RESOLVIDO com restrição técnica declarada\n\
- Limite honesto: a medição não foi executada\n\
- Relação: resíduo do GAP-SG-147\n"
),
"naming the entry this one DESCENDS from is not naming where its own \
leftover went"
);
assert!(names_a_destination(
"GAP-SG-162",
"- Status: RESOLVIDO com restrição técnica declarada\n\
- Relação: resíduo do GAP-SG-147\n\
- Resíduo rastreado em GAP-SG-185\n"
));
assert!(!names_a_destination(
"GAP-SG-162",
"- Resíduo rastreado em GAP-SG-162\n"
));
assert!(!admits_a_leftover("RESOLVIDO na v1.2.2 e verificado"));
assert!(!admits_a_leftover("FECHADO COMO NÃO APLICÁVEL"));
}
#[test]
fn a_caveated_verdict_names_where_the_leftover_is_tracked() {
let entries = entries(&read_gaps());
let mut orphaned = Vec::new();
for (id, body) in &entries {
let Some(status) = status_of(body) else {
continue;
};
if !admits_a_leftover(&status) {
continue;
}
if !names_a_destination(id, body) {
orphaned.push(format!("{id}: {status}"));
}
}
assert!(
orphaned.is_empty(),
"these entries closed while admitting a leftover, and name no other \
GAP-SG entry that owns it. Either point the leftover at a tracked \
entry, or change the verdict to PARCIAL — a caveat with no destination \
is the shape of `documentar NÃO é resolver` that this document's own \
convention forbids.\n{}",
orphaned.join("\n")
);
}
const HISTORICAL_LOSS_BELOW: (u32, &str) = (
203,
"gaps.md declares in its own header that entries older than GAP-SG-203 were \
lost when the file was overwritten on 2026-08-13 and were never recovered",
);
const SELF: &str = "tests/gaps_caveat_gate.rs";
fn rust_files(root: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(root) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
rust_files(&path, out);
} else if path.extension().is_some_and(|ext| ext == "rs") {
out.push(path);
}
}
}
fn cited_ids(text: &str, out: &mut BTreeSet<u32>) {
const NEEDLE: &str = "GAP-SG-";
for (at, _) in text.match_indices(NEEDLE) {
let rest = &text[at + NEEDLE.len()..];
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
if let Ok(id) = digits.parse::<u32>() {
out.insert(id);
}
}
}
fn accounted_for(id: u32, declared: &BTreeSet<u32>) -> bool {
id < HISTORICAL_LOSS_BELOW.0 || declared.contains(&id)
}
fn declared_ids() -> BTreeSet<u32> {
entries(&read_gaps())
.keys()
.filter_map(|id| id.rsplit('-').next()?.parse::<u32>().ok())
.collect()
}
#[test]
fn every_gap_cited_in_the_tree_exists_in_the_document() {
let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let mut files = Vec::new();
rust_files(&repo.join("src"), &mut files);
rust_files(&repo.join("tests"), &mut files);
assert!(
files.len() > 100,
"the scan found {} file(s), which is too few to be the real tree — the \
walk is broken and this gate is passing on an empty set",
files.len()
);
let declared = declared_ids();
assert!(
!declared.is_empty(),
"gaps.md declared no entries at all, so every citation below would look \
orphaned and the failure would point at the wrong file"
);
let mut cited: BTreeSet<u32> = BTreeSet::new();
for path in &files {
if path.to_string_lossy().replace('\\', "/").ends_with(SELF) {
continue;
}
let Ok(text) = std::fs::read_to_string(path) else {
continue;
};
cited_ids(&text, &mut cited);
}
let orphaned: Vec<String> = cited
.iter()
.filter(|id| !accounted_for(**id, &declared))
.map(|id| format!("GAP-SG-{id}"))
.collect();
assert!(
orphaned.is_empty(),
"these identifiers are cited in `src/` or `tests/` and exist nowhere in \
gaps.md as an entry. They sit ABOVE GAP-SG-{}, so the historical loss \
the document admits does not cover them — {}. A doc-comment that names \
a gap the document never records sends the next reader looking for a \
rationale that was never written down.\n{}",
HISTORICAL_LOSS_BELOW.0,
HISTORICAL_LOSS_BELOW.1,
orphaned.join("\n")
);
}
#[test]
fn the_orphan_detector_fires_above_the_floor_and_forgives_below_it() {
let mut found = BTreeSet::new();
cited_ids(
"//! GAP-SG-999: an identifier no entry declares.\n\
//! GAP-SG-042: lost when the file was overwritten.\n",
&mut found,
);
assert_eq!(
found,
BTreeSet::from([42, 999]),
"the scanner must read both identifiers off ordinary doc-comment text"
);
let declared = BTreeSet::from([203, 216]);
assert!(
!accounted_for(999, &declared),
"GAP-SG-999 is above the historical floor and undeclared, so it MUST be \
reported — a detector that stays quiet here would have passed over the \
four orphans GAP-SG-233 measured"
);
assert!(
accounted_for(42, &declared),
"GAP-SG-042 is below GAP-SG-{}, the loss gaps.md declares in its own \
header, so it MUST be absolved",
HISTORICAL_LOSS_BELOW.0
);
assert!(accounted_for(216, &declared));
assert!(!accounted_for(217, &declared));
}