pub fn declares_version(text: &str) -> bool {
text.lines().any(|l| top_level_version_value(l).is_some())
}
fn top_level_version_value(line: &str) -> Option<&str> {
if line.starts_with([' ', '\t']) {
return None;
}
let rest = line.strip_prefix("version")?;
let rest = rest.trim_start_matches([' ', '\t']);
let value = rest.strip_prefix(':')?.trim();
Some(unquote(value))
}
fn unquote(value: &str) -> &str {
for q in ['"', '\''] {
if let Some(inner) = value.strip_prefix(q).and_then(|v| v.strip_suffix(q)) {
return inner;
}
}
value
}
fn added_version(diff: &str) -> Option<&str> {
diff.lines()
.filter_map(|l| l.strip_prefix('+'))
.find_map(top_level_version_value)
}
fn removed_version(diff: &str) -> Option<&str> {
diff.lines()
.filter_map(|l| l.strip_prefix('-'))
.find_map(top_level_version_value)
}
pub fn is_bumped(diff: &str) -> bool {
match added_version(diff) {
None => false,
Some(added) => removed_version(diff).is_none_or(|removed| added != removed),
}
}
pub fn changed_without_bump(diff: Option<&str>) -> bool {
matches!(diff, Some(d) if !d.trim().is_empty() && !is_bumped(d))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn declares_version_only_for_a_top_level_key() {
assert!(declares_version("version: 1\nrules: []\n"));
assert!(declares_version("rules: []\nversion: 1.2.3\n"));
assert!(declares_version("version : 2\n")); assert!(!declares_version("agents:\n a:\n version: 1\n"));
assert!(!declares_version("rules: []\n"));
assert!(!declares_version("versioning: on\n"));
}
#[test]
fn top_level_version_value_trims_and_ignores_indent() {
assert_eq!(top_level_version_value("version: 1.1"), Some("1.1"));
assert_eq!(top_level_version_value("version:1.1"), Some("1.1"));
assert_eq!(top_level_version_value("version: 2 "), Some("2"));
assert_eq!(top_level_version_value(" version: 1"), None);
assert_eq!(top_level_version_value("versionfoo: 1"), None);
}
fn diff(body: &str) -> String {
format!("diff --git a/x.yml b/x.yml\nindex abc..def 100644\n--- a/x.yml\n+++ b/x.yml\n@@ -1,3 +1,3 @@\n{body}")
}
#[test]
fn bumped_when_the_version_value_changes() {
let d = diff(" rules: []\n-version: 1\n+version: 2\n");
assert!(is_bumped(&d));
assert!(!changed_without_bump(Some(&d)));
}
#[test]
fn bumped_on_a_minor_component_change() {
let d = diff("-version: 1\n+version: 1.1\n rules: []\n");
assert!(is_bumped(&d));
}
#[test]
fn not_bumped_when_content_changes_but_version_untouched() {
let d = diff(" version: 1\n- - name: a\n+ - name: b\n");
assert!(!is_bumped(&d));
assert!(changed_without_bump(Some(&d)));
}
#[test]
fn not_bumped_when_the_version_line_is_only_reformatted_to_the_same_value() {
let d = diff("-version: 1\n+version: \"1\"\n rules: []\n");
assert!(!is_bumped(&d));
}
#[test]
fn a_new_file_counts_as_setting_the_version() {
let d = "diff --git a/x.yml b/x.yml\nnew file mode 100644\n--- /dev/null\n+++ b/x.yml\n@@ -0,0 +1,2 @@\n+version: 1\n+rules: []\n";
assert!(is_bumped(d));
assert!(!changed_without_bump(Some(d)));
}
#[test]
fn the_diff_headers_never_count_as_a_version_change() {
let d = diff("-a: 1\n+a: 2\n");
assert!(!is_bumped(&d));
assert!(changed_without_bump(Some(&d)));
}
#[test]
fn unchanged_files_pass_trivially() {
assert!(!changed_without_bump(None));
assert!(!changed_without_bump(Some("")));
assert!(!changed_without_bump(Some(" \n")));
}
}