Skip to main content

code_moniker_check/scenario/
run.rs

1use std::cmp::Ordering;
2use std::collections::BTreeMap;
3use std::path::Path;
4
5use super::Scenario;
6use super::expect::ExpectedViolation;
7use super::{ExpectedRuleVerdict, RuleVerdictMismatch};
8use crate::RuleVerdict;
9use crate::check::command::{
10	CheckRun, FileError, FileReport, MemoryCheckWorkspace, check_project_files_workspace,
11	check_project_workspace,
12};
13use crate::check::config;
14use code_moniker_core::lang::Lang;
15use code_moniker_workspace::lang::path_to_lang;
16
17const RULES_FILE: &str = ".code-moniker.toml";
18
19#[derive(Clone, Debug, Default, Eq, PartialEq)]
20pub struct ScenarioRun {
21	pub actual: Vec<ExpectedViolation>,
22	pub missing: Vec<ExpectedViolation>,
23	pub unexpected: Vec<ExpectedViolation>,
24	pub verdict_mismatches: Vec<RuleVerdictMismatch>,
25	pub errors: Vec<String>,
26	pub silent_rules: Vec<String>,
27	pub stale_undemonstrated: Vec<String>,
28}
29
30impl ScenarioRun {
31	fn from_check(run: &CheckRun, root: &Path, scenario: &Scenario) -> Self {
32		let actual = collect_actual(run, root);
33		let (missing, unexpected) = diff_expectations(&scenario.expects, &actual);
34		let verdict_mismatches = diff_verdict_expectations(run, &scenario.verdicts);
35		let (silent_rules, stale_undemonstrated) = coverage(run, scenario);
36		Self {
37			actual,
38			missing,
39			unexpected,
40			verdict_mismatches,
41			errors: collect_errors(run, root),
42			silent_rules,
43			stale_undemonstrated,
44		}
45	}
46
47	pub fn is_match(&self) -> bool {
48		self.missing.is_empty()
49			&& self.unexpected.is_empty()
50			&& self.verdict_mismatches.is_empty()
51			&& self.errors.is_empty()
52	}
53
54	pub fn mismatch_summary(&self) -> String {
55		let mut lines = Vec::new();
56		for missing in &self.missing {
57			lines.push(format!("missing:    {missing}"));
58		}
59		for unexpected in &self.unexpected {
60			lines.push(format!("unexpected: {unexpected}"));
61		}
62		lines.extend(self.verdict_mismatches.iter().map(ToString::to_string));
63		for error in &self.errors {
64			lines.push(format!("error:      {error}"));
65		}
66		lines.join("\n")
67	}
68}
69
70impl Scenario {
71	pub fn run(&self, root: &Path, scheme: &str) -> anyhow::Result<ScenarioRun> {
72		let run = self.check(root, &[], scheme, true)?;
73		Ok(ScenarioRun::from_check(&run, root, self))
74	}
75
76	pub fn check(
77		&self,
78		root: &Path,
79		files: &[std::path::PathBuf],
80		scheme: &str,
81		report: bool,
82	) -> anyhow::Result<CheckRun> {
83		let cfg = config::load_from_str(
84			self.rules.as_deref().unwrap_or(""),
85			RULES_FILE,
86			Some(self.effective_default_rules()),
87		)?;
88		let workspace = self.memory_workspace(root)?;
89		let (reports, errors) = check_scenario_workspace(&workspace, files, &cfg, scheme, report)?;
90		Ok(CheckRun {
91			reports,
92			errors,
93			elapsed_ms: 0,
94			skip_reason: None,
95		})
96	}
97
98	fn memory_workspace(&self, root: &Path) -> anyhow::Result<MemoryCheckWorkspace> {
99		let mut workspace = MemoryCheckWorkspace::new(root);
100		for file in &self.files {
101			let Some(lang) = scenario_file_lang(file)? else {
102				continue;
103			};
104			workspace = workspace.with_file(Path::new(&file.path), &file.body, lang);
105		}
106		Ok(workspace)
107	}
108
109	pub fn bless(&self, document: &str, actual: &[ExpectedViolation]) -> String {
110		let mut entries: Vec<String> = self
111			.undemonstrated
112			.iter()
113			.map(|rule| format!("! {} {}", rule.rule_id, rule.reason))
114			.collect();
115		entries.extend(self.verdicts.iter().map(ToString::to_string));
116		entries.extend(actual.iter().map(ToString::to_string));
117		let mut body = entries.join("\n");
118		if !body.is_empty() {
119			body.push('\n');
120		}
121		match self.expect_span {
122			Some((start, end)) => format!("{}{}{}", &document[..start], body, &document[end..]),
123			None => {
124				let separator = if document.ends_with('\n') { "" } else { "\n" };
125				format!("{document}{separator}\n```cm:expect\n{body}```\n")
126			}
127		}
128	}
129}
130
131fn check_scenario_workspace(
132	workspace: &MemoryCheckWorkspace,
133	files: &[std::path::PathBuf],
134	cfg: &crate::check::Config,
135	scheme: &str,
136	report: bool,
137) -> anyhow::Result<(Vec<FileReport>, Vec<FileError>)> {
138	if files.is_empty() {
139		return check_project_workspace(workspace.root(), cfg, scheme, report, workspace);
140	}
141	check_project_files_workspace(workspace.root(), files, cfg, scheme, report, workspace)
142}
143
144fn scenario_file_lang(file: &super::ScenarioFile) -> anyhow::Result<Option<Lang>> {
145	match file.fence.as_str() {
146		"" => Ok(Some(path_to_lang(Path::new(&file.path))?)),
147		"rust" | "rs" => Ok(Some(Lang::Rs)),
148		"ts" | "typescript" => Ok(Some(Lang::Ts)),
149		"python" | "py" => Ok(Some(Lang::Python)),
150		"go" => Ok(Some(Lang::Go)),
151		"java" => Ok(Some(Lang::Java)),
152		"cs" | "csharp" => Ok(Some(Lang::Cs)),
153		"sql" | "plpgsql" => Ok(Some(Lang::Sql)),
154		"text" | "txt" | "md" | "markdown" => Ok(None),
155		_ => Ok(Some(path_to_lang(Path::new(&file.path))?)),
156	}
157}
158
159fn collect_actual(run: &CheckRun, root: &Path) -> Vec<ExpectedViolation> {
160	let mut actual: Vec<_> = run
161		.file_violations()
162		.map(|(path, violation)| ExpectedViolation {
163			rule_id: violation.rule_id.clone(),
164			path: relative_display(path, root),
165			lines: violation.lines,
166		})
167		.collect();
168	actual.sort();
169	actual
170}
171
172fn collect_errors(run: &CheckRun, root: &Path) -> Vec<String> {
173	run.error_summaries()
174		.map(|(path, error)| format!("{}: {error}", relative_display(path, root)))
175		.collect()
176}
177
178fn coverage(run: &CheckRun, scenario: &Scenario) -> (Vec<String>, Vec<String>) {
179	let undemonstrated: Vec<&str> = scenario
180		.undemonstrated
181		.iter()
182		.map(|rule| rule.rule_id.as_str())
183		.collect();
184	let demonstrated: Vec<&str> = scenario
185		.verdicts
186		.iter()
187		.map(|rule| rule.rule_id.as_str())
188		.collect();
189	let silent = collect_silent_rules(run);
190	let stale = undemonstrated
191		.iter()
192		.filter(|rule_id| !silent.iter().any(|silent| silent == *rule_id))
193		.map(ToString::to_string)
194		.collect();
195	let silent = silent
196		.into_iter()
197		.filter(|rule_id| {
198			!undemonstrated.contains(&rule_id.as_str()) && !demonstrated.contains(&rule_id.as_str())
199		})
200		.collect();
201	(silent, stale)
202}
203
204fn diff_verdict_expectations(
205	run: &CheckRun,
206	expected: &[ExpectedRuleVerdict],
207) -> Vec<RuleVerdictMismatch> {
208	let actual = collect_rule_verdicts(run);
209	expected
210		.iter()
211		.filter_map(|expected| {
212			let actual = actual.get(&expected.rule_id).copied();
213			(actual != Some(expected.verdict)).then(|| RuleVerdictMismatch {
214				rule_id: expected.rule_id.clone(),
215				expected: expected.verdict,
216				actual,
217			})
218		})
219		.collect()
220}
221
222fn collect_rule_verdicts(run: &CheckRun) -> BTreeMap<String, RuleVerdict> {
223	let mut verdicts = BTreeMap::new();
224	for report in run.reports.iter().flat_map(|report| &report.rule_reports) {
225		if let Some(verdict) = report.verdict {
226			verdicts.insert(report.rule_id.clone(), verdict);
227		}
228	}
229	verdicts
230}
231
232fn collect_silent_rules(run: &CheckRun) -> Vec<String> {
233	run.rule_violation_totals()
234		.into_iter()
235		.filter(|(_, violations)| *violations == 0)
236		.map(|(rule_id, _)| rule_id.to_string())
237		.collect()
238}
239
240fn relative_display(path: &Path, root: &Path) -> String {
241	path.strip_prefix(root)
242		.unwrap_or(path)
243		.display()
244		.to_string()
245		.replace('\\', "/")
246}
247
248fn diff_expectations(
249	expected: &[ExpectedViolation],
250	actual: &[ExpectedViolation],
251) -> (Vec<ExpectedViolation>, Vec<ExpectedViolation>) {
252	let mut missing = Vec::new();
253	let mut unexpected = Vec::new();
254	let mut left = expected.iter().peekable();
255	let mut right = actual.iter().peekable();
256	while let (Some(expected), Some(actual)) = (left.peek(), right.peek()) {
257		match expected.cmp(actual) {
258			Ordering::Equal => {
259				left.next();
260				right.next();
261			}
262			Ordering::Less => missing.extend(left.next().cloned()),
263			Ordering::Greater => unexpected.extend(right.next().cloned()),
264		}
265	}
266	missing.extend(left.cloned());
267	unexpected.extend(right.cloned());
268	(missing, unexpected)
269}