#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
use regex::Regex;
use serde::Deserialize;
use omni_dev::data::context::ScopeDefinition;
use omni_dev::git::parse_subject;
#[derive(Deserialize)]
struct ScopesConfig {
scopes: Vec<ScopeDefinition>,
}
fn omni_dev_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".omni-dev")
}
fn yaml_scopes() -> BTreeMap<String, String> {
let path = omni_dev_dir().join("scopes.yaml");
let raw =
fs::read_to_string(&path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
let config: ScopesConfig = serde_yaml::from_str(&raw)
.unwrap_or_else(|e| panic!("cannot parse {}: {e}", path.display()));
config
.scopes
.into_iter()
.map(|scope| (scope.name, scope.description))
.collect()
}
fn commit_guidelines() -> String {
let path = omni_dev_dir().join("commit-guidelines.md");
fs::read_to_string(&path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()))
}
fn section<'a>(markdown: &'a str, heading: &str) -> &'a str {
let marker = format!("\n## {heading}\n");
let start = markdown
.find(&marker)
.unwrap_or_else(|| panic!("commit-guidelines.md has no `## {heading}` section"))
+ marker.len();
let end = markdown[start..]
.find("\n## ")
.map_or(markdown.len(), |offset| start + offset);
&markdown[start..end]
}
fn markdown_scopes(body: &str) -> BTreeMap<String, String> {
let bullet = Regex::new(r"^- `([a-z0-9-]+)` - (.+)$").unwrap();
body.lines()
.filter_map(|line| bullet.captures(line.trim_end()))
.map(|caps| (caps[1].to_string(), caps[2].trim().to_string()))
.collect()
}
#[test]
fn scope_list_matches_scopes_yaml() {
let yaml = yaml_scopes();
let guidelines = commit_guidelines();
let markdown = markdown_scopes(section(&guidelines, "Scopes"));
let mut problems = Vec::new();
for (name, description) in &yaml {
match markdown.get(name) {
None => problems.push(format!(
"missing from commit-guidelines.md: `{name}` - {description}"
)),
Some(listed) if listed != description => problems.push(format!(
"description differs for `{name}`:\n\
\x20 scopes.yaml: {description}\n\
\x20 commit-guidelines.md: {listed}"
)),
Some(_) => {}
}
}
for name in markdown.keys() {
if !yaml.contains_key(name) {
problems.push(format!(
"listed in commit-guidelines.md but not defined in scopes.yaml: `{name}`"
));
}
}
assert!(
problems.is_empty(),
"the `## Scopes` list in .omni-dev/commit-guidelines.md has drifted from \
.omni-dev/scopes.yaml (STYLE-0007):\n {}\n\n\
scopes.yaml is the single source of truth — update the markdown list to match it.",
problems.join("\n ")
);
}
#[test]
fn example_scopes_are_defined_in_scopes_yaml() {
let yaml = yaml_scopes();
let guidelines = commit_guidelines();
let examples = section(&guidelines, "Examples");
let header = Regex::new(r"(?m)^([a-z]+)\(([^)]+)\)!?:").unwrap();
let mut undefined = Vec::new();
for caps in header.captures_iter(examples) {
for scope in caps[2].split(',') {
let scope = scope.trim();
if !yaml.contains_key(scope) {
undefined.push(format!("`{scope}` used in `{}`", caps[0].trim()));
}
}
}
assert!(
undefined.is_empty(),
"the `## Examples` section of .omni-dev/commit-guidelines.md uses scopes that \
are not defined in .omni-dev/scopes.yaml (STYLE-0007):\n {}",
undefined.join("\n ")
);
}
fn example_subjects(body: &str) -> Vec<&str> {
let mut subjects = Vec::new();
let mut in_block = false;
let mut expecting_subject = false;
for line in body.lines() {
if line.trim_start().starts_with("```") {
in_block = !in_block;
expecting_subject = in_block;
} else if expecting_subject {
subjects.push(line);
expecting_subject = false;
}
}
subjects
}
#[test]
fn example_subjects_are_parseable_by_parse_subject() {
let guidelines = commit_guidelines();
let subjects = example_subjects(section(&guidelines, "Examples"));
assert!(
!subjects.is_empty(),
"no fenced examples found in `## Examples` — the block scraper is broken, \
which would let this test pass vacuously"
);
assert!(
subjects.iter().any(|subject| subject.contains(")!:")),
"no `type(scope)!:` breaking-change example found in `## Examples` — that form \
is the reason this test exists (#1473), so its absence must fail loudly"
);
let unparseable: Vec<&str> = subjects
.iter()
.copied()
.filter(|subject| parse_subject(subject).and_then(|p| p.scope).is_none())
.collect();
assert!(
unparseable.is_empty(),
"`omni_dev::git::parse_subject` cannot parse a scope out of these subjects \
demonstrated in the `## Examples` section of .omni-dev/commit-guidelines.md:\n {}\n\n\
the guidelines are the source of truth — the parser must handle every scoped \
form they teach.",
unparseable.join("\n ")
);
}
#[test]
fn example_subjects_takes_the_first_line_of_each_fenced_block() {
let body = "\n### Simple\n```\nfix(git): a subject\n```\n\n\
### With body\n```\nfeat(cli)!: another subject\n\n\
BREAKING CHANGE: not a subject\n```\n";
assert_eq!(
example_subjects(body),
vec!["fix(git): a subject", "feat(cli)!: another subject"]
);
}
#[test]
fn section_stops_at_the_next_heading_but_not_subheadings() {
let markdown = "# Title\n\n## One\n\nbody\n\n### Sub\n\nmore\n\n## Two\n\nother\n";
assert_eq!(section(markdown, "One"), "\nbody\n\n### Sub\n\nmore\n");
assert_eq!(section(markdown, "Two"), "\nother\n");
}
#[test]
fn markdown_scopes_ignores_prose() {
let body = "\n- `cli` - Command-line interface and argument parsing\n\
\nIn addition to the scopes above, `cargo` is also accepted.\n";
let parsed = markdown_scopes(body);
assert_eq!(parsed.len(), 1);
assert_eq!(
parsed.get("cli").map(String::as_str),
Some("Command-line interface and argument parsing")
);
}