code-moniker-check 0.7.1

Rules engine for code-moniker: DSL, rule config/profiles, evaluation over the symbol graph, and suppression.
Documentation
use std::cmp::Ordering;
use std::collections::BTreeMap;
use std::path::Path;

use super::Scenario;
use super::expect::ExpectedViolation;
use super::{ExpectedRuleVerdict, RuleVerdictMismatch};
use crate::RuleVerdict;
use crate::check::command::{
	CheckRun, FileError, FileReport, MemoryCheckWorkspace, check_project_files_workspace,
	check_project_workspace,
};
use crate::check::config;
use code_moniker_core::lang::Lang;
use code_moniker_workspace::lang::path_to_lang;

const RULES_FILE: &str = ".code-moniker.toml";

#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ScenarioRun {
	pub actual: Vec<ExpectedViolation>,
	pub missing: Vec<ExpectedViolation>,
	pub unexpected: Vec<ExpectedViolation>,
	pub verdict_mismatches: Vec<RuleVerdictMismatch>,
	pub errors: Vec<String>,
	pub silent_rules: Vec<String>,
	pub stale_undemonstrated: Vec<String>,
}

impl ScenarioRun {
	fn from_check(run: &CheckRun, root: &Path, scenario: &Scenario) -> Self {
		let actual = collect_actual(run, root);
		let (missing, unexpected) = diff_expectations(&scenario.expects, &actual);
		let verdict_mismatches = diff_verdict_expectations(run, &scenario.verdicts);
		let (silent_rules, stale_undemonstrated) = coverage(run, scenario);
		Self {
			actual,
			missing,
			unexpected,
			verdict_mismatches,
			errors: collect_errors(run, root),
			silent_rules,
			stale_undemonstrated,
		}
	}

	pub fn is_match(&self) -> bool {
		self.missing.is_empty()
			&& self.unexpected.is_empty()
			&& self.verdict_mismatches.is_empty()
			&& self.errors.is_empty()
	}

	pub fn mismatch_summary(&self) -> String {
		let mut lines = Vec::new();
		for missing in &self.missing {
			lines.push(format!("missing:    {missing}"));
		}
		for unexpected in &self.unexpected {
			lines.push(format!("unexpected: {unexpected}"));
		}
		lines.extend(self.verdict_mismatches.iter().map(ToString::to_string));
		for error in &self.errors {
			lines.push(format!("error:      {error}"));
		}
		lines.join("\n")
	}
}

impl Scenario {
	pub fn run(&self, root: &Path, scheme: &str) -> anyhow::Result<ScenarioRun> {
		let run = self.check(root, &[], scheme, true)?;
		Ok(ScenarioRun::from_check(&run, root, self))
	}

	pub fn check(
		&self,
		root: &Path,
		files: &[std::path::PathBuf],
		scheme: &str,
		report: bool,
	) -> anyhow::Result<CheckRun> {
		let cfg = config::load_from_str(
			self.rules.as_deref().unwrap_or(""),
			RULES_FILE,
			Some(self.effective_default_rules()),
		)?;
		let workspace = self.memory_workspace(root)?;
		let (reports, errors) = check_scenario_workspace(&workspace, files, &cfg, scheme, report)?;
		Ok(CheckRun {
			reports,
			errors,
			elapsed_ms: 0,
			skip_reason: None,
		})
	}

	fn memory_workspace(&self, root: &Path) -> anyhow::Result<MemoryCheckWorkspace> {
		let mut workspace = MemoryCheckWorkspace::new(root);
		for file in &self.files {
			let Some(lang) = scenario_file_lang(file)? else {
				continue;
			};
			workspace = workspace.with_file(Path::new(&file.path), &file.body, lang);
		}
		Ok(workspace)
	}

	pub fn bless(&self, document: &str, actual: &[ExpectedViolation]) -> String {
		let mut entries: Vec<String> = self
			.undemonstrated
			.iter()
			.map(|rule| format!("! {} {}", rule.rule_id, rule.reason))
			.collect();
		entries.extend(self.verdicts.iter().map(ToString::to_string));
		entries.extend(actual.iter().map(ToString::to_string));
		let mut body = entries.join("\n");
		if !body.is_empty() {
			body.push('\n');
		}
		match self.expect_span {
			Some((start, end)) => format!("{}{}{}", &document[..start], body, &document[end..]),
			None => {
				let separator = if document.ends_with('\n') { "" } else { "\n" };
				format!("{document}{separator}\n```cm:expect\n{body}```\n")
			}
		}
	}
}

fn check_scenario_workspace(
	workspace: &MemoryCheckWorkspace,
	files: &[std::path::PathBuf],
	cfg: &crate::check::Config,
	scheme: &str,
	report: bool,
) -> anyhow::Result<(Vec<FileReport>, Vec<FileError>)> {
	if files.is_empty() {
		return check_project_workspace(workspace.root(), cfg, scheme, report, workspace);
	}
	check_project_files_workspace(workspace.root(), files, cfg, scheme, report, workspace)
}

fn scenario_file_lang(file: &super::ScenarioFile) -> anyhow::Result<Option<Lang>> {
	match file.fence.as_str() {
		"" => Ok(Some(path_to_lang(Path::new(&file.path))?)),
		"rust" | "rs" => Ok(Some(Lang::Rs)),
		"ts" | "typescript" => Ok(Some(Lang::Ts)),
		"python" | "py" => Ok(Some(Lang::Python)),
		"go" => Ok(Some(Lang::Go)),
		"java" => Ok(Some(Lang::Java)),
		"cs" | "csharp" => Ok(Some(Lang::Cs)),
		"sql" | "plpgsql" => Ok(Some(Lang::Sql)),
		"text" | "txt" | "md" | "markdown" => Ok(None),
		_ => Ok(Some(path_to_lang(Path::new(&file.path))?)),
	}
}

fn collect_actual(run: &CheckRun, root: &Path) -> Vec<ExpectedViolation> {
	let mut actual: Vec<_> = run
		.file_violations()
		.map(|(path, violation)| ExpectedViolation {
			rule_id: violation.rule_id.clone(),
			path: relative_display(path, root),
			lines: violation.lines,
		})
		.collect();
	actual.sort();
	actual
}

fn collect_errors(run: &CheckRun, root: &Path) -> Vec<String> {
	run.error_summaries()
		.map(|(path, error)| format!("{}: {error}", relative_display(path, root)))
		.collect()
}

fn coverage(run: &CheckRun, scenario: &Scenario) -> (Vec<String>, Vec<String>) {
	let undemonstrated: Vec<&str> = scenario
		.undemonstrated
		.iter()
		.map(|rule| rule.rule_id.as_str())
		.collect();
	let demonstrated: Vec<&str> = scenario
		.verdicts
		.iter()
		.map(|rule| rule.rule_id.as_str())
		.collect();
	let silent = collect_silent_rules(run);
	let stale = undemonstrated
		.iter()
		.filter(|rule_id| !silent.iter().any(|silent| silent == *rule_id))
		.map(ToString::to_string)
		.collect();
	let silent = silent
		.into_iter()
		.filter(|rule_id| {
			!undemonstrated.contains(&rule_id.as_str()) && !demonstrated.contains(&rule_id.as_str())
		})
		.collect();
	(silent, stale)
}

fn diff_verdict_expectations(
	run: &CheckRun,
	expected: &[ExpectedRuleVerdict],
) -> Vec<RuleVerdictMismatch> {
	let actual = collect_rule_verdicts(run);
	expected
		.iter()
		.filter_map(|expected| {
			let actual = actual.get(&expected.rule_id).copied();
			(actual != Some(expected.verdict)).then(|| RuleVerdictMismatch {
				rule_id: expected.rule_id.clone(),
				expected: expected.verdict,
				actual,
			})
		})
		.collect()
}

fn collect_rule_verdicts(run: &CheckRun) -> BTreeMap<String, RuleVerdict> {
	let mut verdicts = BTreeMap::new();
	for report in run.reports.iter().flat_map(|report| &report.rule_reports) {
		if let Some(verdict) = report.verdict {
			verdicts.insert(report.rule_id.clone(), verdict);
		}
	}
	verdicts
}

fn collect_silent_rules(run: &CheckRun) -> Vec<String> {
	run.rule_violation_totals()
		.into_iter()
		.filter(|(_, violations)| *violations == 0)
		.map(|(rule_id, _)| rule_id.to_string())
		.collect()
}

fn relative_display(path: &Path, root: &Path) -> String {
	path.strip_prefix(root)
		.unwrap_or(path)
		.display()
		.to_string()
		.replace('\\', "/")
}

fn diff_expectations(
	expected: &[ExpectedViolation],
	actual: &[ExpectedViolation],
) -> (Vec<ExpectedViolation>, Vec<ExpectedViolation>) {
	let mut missing = Vec::new();
	let mut unexpected = Vec::new();
	let mut left = expected.iter().peekable();
	let mut right = actual.iter().peekable();
	while let (Some(expected), Some(actual)) = (left.peek(), right.peek()) {
		match expected.cmp(actual) {
			Ordering::Equal => {
				left.next();
				right.next();
			}
			Ordering::Less => missing.extend(left.next().cloned()),
			Ordering::Greater => unexpected.extend(right.next().cloned()),
		}
	}
	missing.extend(left.cloned());
	unexpected.extend(right.cloned());
	(missing, unexpected)
}