#![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;
#[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 ")
);
}
#[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")
);
}