Skip to main content

code_moniker_check/scenario/
run.rs

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