gitsnitch 0.3.4

Lints your Git commit history against a declarative ruleset
use super::incremental_deepen_step;
use crate::config;

fn sample_commit_context() -> super::CommitContext {
    super::CommitContext {
        raw_message: "feat(parser): add tokenizer\n\nline one\nline two".to_owned(),
        title: "feat(parser): add tokenizer".to_owned(),
        body: "line one\nline two".to_owned(),
        diff_raw: "diff --git a/src/main.rs b/src/main.rs\n+let value = 1;".to_owned(),
        diff_files_joined: "src/main.rs\nsrc/lib.rs".to_owned(),
        diff_lines_joined: "+let value = 1;\n-let old = 0;".to_owned(),
        line_count: 12,
        file_count: 2,
        branches_joined: "main\nfeature/decorative".to_owned(),
    }
}

fn sample_assertion(condition: config::Condition) -> config::Assertion {
    config::Assertion {
        alias: "a1".to_owned(),
        skip: false,
        description: "desc".to_owned(),
        banner: String::new(),
        hint: String::new(),
        severity: 10,
        must_satisfy: config::ConditionContainer { condition },
        skip_if: None,
        custom_meta: config::CustomMeta::new(),
    }
}

#[test]
fn evaluate_condition_supports_message_modes() {
    let commit = sample_commit_context();

    let raw = config::Condition::MsgMatchAny(config::MsgMatchCondition {
        name: "raw".to_owned(),
        mode: config::MsgMode::Raw,
        patterns: vec!["tokenizer".to_owned()],
    });
    let title = config::Condition::MsgMatchAny(config::MsgMatchCondition {
        name: "title".to_owned(),
        mode: config::MsgMode::Title,
        patterns: vec!["^feat".to_owned()],
    });
    let body = config::Condition::MsgMatchAny(config::MsgMatchCondition {
        name: "body".to_owned(),
        mode: config::MsgMode::Body,
        patterns: vec!["line two".to_owned()],
    });

    assert_eq!(super::evaluate_condition(&raw, &commit).ok(), Some(true));
    assert_eq!(super::evaluate_condition(&title, &commit).ok(), Some(true));
    assert_eq!(super::evaluate_condition(&body, &commit).ok(), Some(true));
}

#[test]
fn evaluate_condition_supports_message_none_mode() {
    let commit = sample_commit_context();
    let condition = config::Condition::MsgMatchNone(config::MsgMatchCondition {
        name: "body-none".to_owned(),
        mode: config::MsgMode::Body,
        patterns: vec!["DO NOT MERGE".to_owned()],
    });

    assert_eq!(
        super::evaluate_condition(&condition, &commit).ok(),
        Some(true)
    );
}

#[test]
fn evaluate_condition_supports_message_none_raw_and_title_modes() {
    let commit = sample_commit_context();
    let raw = config::Condition::MsgMatchNone(config::MsgMatchCondition {
        name: "raw-none".to_owned(),
        mode: config::MsgMode::Raw,
        patterns: vec!["breaking change".to_owned()],
    });
    let title = config::Condition::MsgMatchNone(config::MsgMatchCondition {
        name: "title-none".to_owned(),
        mode: config::MsgMode::Title,
        patterns: vec!["^fix".to_owned()],
    });

    assert_eq!(super::evaluate_condition(&raw, &commit).ok(), Some(true));
    assert_eq!(super::evaluate_condition(&title, &commit).ok(), Some(true));
}

#[test]
fn evaluate_condition_supports_diff_modes() {
    let commit = sample_commit_context();
    let raw = config::Condition::DiffMatchAny(config::DiffMatchCondition {
        name: "diff-raw".to_owned(),
        mode: config::DiffMode::Raw,
        patterns: vec!["diff --git".to_owned()],
    });
    let file = config::Condition::DiffMatchAny(config::DiffMatchCondition {
        name: "diff-file".to_owned(),
        mode: config::DiffMode::File,
        patterns: vec!["src/lib\\.rs".to_owned()],
    });
    let line = config::Condition::DiffMatchAny(config::DiffMatchCondition {
        name: "diff-line".to_owned(),
        mode: config::DiffMode::Line,
        patterns: vec!["\\+let value".to_owned()],
    });

    assert_eq!(super::evaluate_condition(&raw, &commit).ok(), Some(true));
    assert_eq!(super::evaluate_condition(&file, &commit).ok(), Some(true));
    assert_eq!(super::evaluate_condition(&line, &commit).ok(), Some(true));
}

#[test]
fn evaluate_condition_supports_diff_none_mode() {
    let commit = sample_commit_context();
    let condition = config::Condition::DiffMatchNone(config::DiffMatchCondition {
        name: "diff-none".to_owned(),
        mode: config::DiffMode::Line,
        patterns: vec!["password".to_owned()],
    });

    assert_eq!(
        super::evaluate_condition(&condition, &commit).ok(),
        Some(true)
    );
}

#[test]
fn evaluate_condition_supports_diff_none_raw_and_file_modes() {
    let commit = sample_commit_context();
    let raw = config::Condition::DiffMatchNone(config::DiffMatchCondition {
        name: "diff-raw-none".to_owned(),
        mode: config::DiffMode::Raw,
        patterns: vec!["password".to_owned()],
    });
    let file = config::Condition::DiffMatchNone(config::DiffMatchCondition {
        name: "diff-file-none".to_owned(),
        mode: config::DiffMode::File,
        patterns: vec!["Cargo\\.lock".to_owned()],
    });

    assert_eq!(super::evaluate_condition(&raw, &commit).ok(), Some(true));
    assert_eq!(super::evaluate_condition(&file, &commit).ok(), Some(true));
}

#[test]
fn evaluate_condition_supports_branch_match() {
    let commit = sample_commit_context();
    let condition = config::Condition::BranchMatch(config::BranchMatchCondition {
        name: "branch".to_owned(),
        patterns: vec!["feature/decorative".to_owned()],
    });

    assert_eq!(
        super::evaluate_condition(&condition, &commit).ok(),
        Some(true)
    );
}

#[test]
fn evaluate_condition_supports_threshold_compare_for_both_metrics() {
    let commit = sample_commit_context();
    let line_threshold = config::Condition::ThresholdCompare(config::ThresholdCondition {
        name: "line-count".to_owned(),
        metric: config::ThresholdMetric::LineCount,
        operator: config::ThresholdOperator::Gte,
        value: 10,
    });
    let file_threshold = config::Condition::ThresholdCompare(config::ThresholdCondition {
        name: "file-count".to_owned(),
        metric: config::ThresholdMetric::FileCount,
        operator: config::ThresholdOperator::Lte,
        value: 2,
    });

    assert_eq!(
        super::evaluate_condition(&line_threshold, &commit).ok(),
        Some(true)
    );
    assert_eq!(
        super::evaluate_condition(&file_threshold, &commit).ok(),
        Some(true)
    );
}

#[test]
fn evaluate_condition_returns_error_for_invalid_regex() {
    let commit = sample_commit_context();
    let condition = config::Condition::MsgMatchAny(config::MsgMatchCondition {
        name: "invalid".to_owned(),
        mode: config::MsgMode::Title,
        patterns: vec!["(".to_owned()],
    });

    let result = super::evaluate_condition(&condition, &commit);
    assert!(result.is_err());

    let error_message = match result {
        Err(crate::AppError::Message(message)) => message,
        Ok(_) | Err(_) => String::new(),
    };
    assert!(error_message.contains("invalid regex pattern"));
}

#[test]
fn body_from_raw_message_handles_title_only_and_title_plus_body() {
    let title_only = super::body_from_raw_message("feat: title only");
    let with_body = super::body_from_raw_message("feat: title\n\nbody line\nsecond");

    assert!(title_only.is_empty());
    assert_eq!(with_body, "\nbody line\nsecond");
}

#[test]
fn collect_diff_lines_ignores_headers_and_keeps_added_removed_lines() {
    let diff = "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1,2 +1,2 @@\n-old\n+new\n context";
    let lines = super::collect_diff_lines(diff);
    assert_eq!(lines, "-old\n+new");
}

#[test]
fn parse_numstat_totals_handles_regular_and_binary_rows() {
    let numstat = "1\t2\tsrc/main.rs\n-\t-\tassets/logo.png\n";
    let totals = super::parse_numstat_totals(numstat);
    assert_eq!(totals.ok(), Some((3, 2)));
}

#[test]
fn parse_numstat_totals_ignores_blank_and_pathless_rows() {
    let numstat = "\n1\t2\tsrc/main.rs\n7\t8\n\n";
    let totals = super::parse_numstat_totals(numstat);
    assert_eq!(totals.ok(), Some((3, 1)));
}

#[test]
fn parse_numstat_totals_rejects_invalid_added_number() {
    let numstat = "abc\t2\tsrc/main.rs\n";
    let result = super::parse_numstat_totals(numstat);
    assert!(result.is_err());

    let message = match result {
        Err(crate::AppError::Message(message)) => message,
        Ok(_) | Err(_) => String::new(),
    };
    assert!(message.contains("failed to parse numstat added value"));
}

#[test]
fn parse_numstat_totals_rejects_invalid_removed_number() {
    let numstat = "1\txyz\tsrc/main.rs\n";
    let result = super::parse_numstat_totals(numstat);
    assert!(result.is_err());

    let message = match result {
        Err(crate::AppError::Message(message)) => message,
        Ok(_) | Err(_) => String::new(),
    };
    assert!(message.contains("failed to parse numstat removed value"));
}

#[test]
fn parse_numstat_totals_rejects_line_count_overflow() {
    let numstat = format!("{}\t1\ta.txt\n1\t0\tb.txt\n", u32::MAX);
    let result = super::parse_numstat_totals(&numstat);
    assert!(result.is_err());

    let message = match result {
        Err(crate::AppError::Message(message)) => message,
        Ok(_) | Err(_) => String::new(),
    };
    assert!(message.contains("line count overflow while parsing git numstat"));
}

#[test]
fn matches_any_regex_returns_false_when_no_patterns_match() {
    let patterns = vec!["foo".to_owned(), "bar".to_owned()];
    let result = super::matches_any_regex(&patterns, "baz");
    assert_eq!(result.ok(), Some(false));
}

#[test]
fn add_context_prefixes_message_errors() {
    let error = crate::AppError::Message("inner".to_owned());
    let contextual = super::add_context(error, "outer");

    let message = match contextual {
        crate::AppError::Message(message) => message,
        _ => String::new(),
    };
    assert_eq!(message, "outer: inner");
}

#[test]
fn add_context_preserves_non_message_errors() {
    let error = crate::AppError::Config(config::ConfigError::Semantic("inner".to_owned()));
    let contextual = super::add_context(error, "outer");

    let message = match contextual {
        crate::AppError::Config(config::ConfigError::Semantic(message)) => message,
        _ => String::new(),
    };
    assert_eq!(message, "inner");
}

#[test]
fn assertion_violated_returns_false_when_assertion_is_skipped() {
    let commit = sample_commit_context();
    let mut assertion =
        sample_assertion(config::Condition::MsgMatchAny(config::MsgMatchCondition {
            name: String::new(),
            mode: config::MsgMode::Title,
            patterns: vec!["^feat".to_owned()],
        }));
    assertion.skip = true;

    let result = super::assertion_violated(&assertion, &commit);
    assert_eq!(result.ok(), Some(false));
}

#[test]
fn assertion_violated_respects_skip_if() {
    let commit = sample_commit_context();
    let mut assertion =
        sample_assertion(config::Condition::MsgMatchAny(config::MsgMatchCondition {
            name: String::new(),
            mode: config::MsgMode::Title,
            patterns: vec!["^fix".to_owned()],
        }));
    assertion.skip_if = Some(config::ConditionContainer {
        condition: config::Condition::BranchMatch(config::BranchMatchCondition {
            name: String::new(),
            patterns: vec!["feature/decorative".to_owned()],
        }),
    });

    let result = super::assertion_violated(&assertion, &commit);
    assert_eq!(result.ok(), Some(false));
}

#[test]
fn assertion_violated_returns_true_when_must_satisfy_fails() {
    let commit = sample_commit_context();
    let assertion = sample_assertion(config::Condition::MsgMatchAny(config::MsgMatchCondition {
        name: String::new(),
        mode: config::MsgMode::Title,
        patterns: vec!["^fix".to_owned()],
    }));

    let result = super::assertion_violated(&assertion, &commit);
    assert_eq!(result.ok(), Some(true));
}

#[test]
fn collect_violations_returns_empty_when_no_assertions_are_provided() {
    let scope = crate::LintScope::CommitSha("deadbeef".to_owned());
    let history = config::History::default();
    let result = super::collect_violations(&scope, &[], &history, 0);
    assert!(result.is_ok());

    let lint = result.unwrap_or(super::LintResult {
        violations: Vec::new(),
    });
    assert!(lint.violations.is_empty());
}

#[test]
fn incremental_deepen_step_grows_exponentially_from_base_shift() {
    let first = incremental_deepen_step(10, 0);
    let second = incremental_deepen_step(10, 1);
    let third = incremental_deepen_step(10, 2);

    assert_eq!(first.ok(), Some(10));
    assert_eq!(second.ok(), Some(20));
    assert_eq!(third.ok(), Some(40));
}

#[test]
fn incremental_deepen_step_returns_error_on_overflow() {
    let value = incremental_deepen_step(u32::MAX, 1);
    assert!(value.is_err());
}