code_moniker_check/scenario/
run.rs1use 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}