1use crate::collect::Collection;
9use crate::diagnostic::{Diagnostic, DiagnosticCode};
10use crate::lint::LintResult;
11use crate::model::{
12 AiMode, CaseIndex, EventInfo, Fixture, FixtureName, GameDate, HelperFile, NodeId,
13 SessionRequest, Tag, TestCase,
14};
15use crate::select::Selector;
16use crate::source::SourceSpan;
17use serde::{Deserialize, Serialize};
18use std::collections::{BTreeMap, BTreeSet};
19
20#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum IgnoredMode {
23 #[default]
25 Exclude,
26 Include,
28 Only,
30}
31
32#[derive(Clone, Debug, Serialize, Deserialize)]
33pub struct ExpandOptions {
34 pub selector: Selector,
35 pub ignored: IgnoredMode,
36 pub max_cases: usize,
38}
39
40impl Default for ExpandOptions {
41 fn default() -> Self {
42 Self {
43 selector: Selector::default(),
44 ignored: IgnoredMode::default(),
45 max_cases: 200,
46 }
47 }
48}
49
50#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
51#[serde(tag = "reason", rename_all = "snake_case")]
52pub enum NotRunReason {
53 Deselected,
54 Skipped { message: Option<String> },
55 Ignored { message: Option<String> },
56}
57
58#[derive(Clone, Debug, Serialize, Deserialize)]
59pub struct NotRun {
60 pub node: NodeId,
61 pub origin: SourceSpan,
62 pub reason: NotRunReason,
63}
64
65#[derive(Clone, Debug, Serialize, Deserialize)]
68pub struct PlannedCase {
69 pub index: CaseIndex,
70 pub case: TestCase,
71 pub fixtures: Vec<Fixture>,
72}
73
74#[derive(Clone, Debug, Serialize, Deserialize)]
75pub struct Expanded {
76 pub cases: Vec<PlannedCase>,
77 pub not_run: Vec<NotRun>,
78 pub events: Vec<EventInfo>,
79 pub helpers: Vec<HelperFile>,
80}
81
82pub fn expand(
83 collection: &Collection,
84 options: &ExpandOptions,
85) -> Result<Expanded, Vec<Diagnostic>> {
86 let fixtures: BTreeMap<&FixtureName, &Fixture> = collection
87 .fixtures
88 .iter()
89 .map(|fixture| (&fixture.name, fixture))
90 .collect();
91 let mut diagnostics = Vec::new();
92 for fixture in &collection.fixtures {
93 if let Err(diagnostic) = resolve(
94 &fixture.uses,
95 &fixtures,
96 &[&fixture.name],
97 Some(&fixture.origin),
98 ) {
99 diagnostics.push(diagnostic);
100 }
101 }
102 let mut cases = Vec::new();
103 let mut not_run = Vec::new();
104 for case in &collection.cases {
105 let ignored = case.marks.ignore.is_some();
106 let reason = if !options.selector.matches(case)
107 || (options.ignored == IgnoredMode::Only && !ignored)
108 {
109 Some(NotRunReason::Deselected)
110 } else if let Some(skip) = &case.marks.skip {
111 Some(NotRunReason::Skipped {
112 message: skip.reason.clone(),
113 })
114 } else if ignored && options.ignored == IgnoredMode::Exclude {
115 Some(NotRunReason::Ignored {
116 message: case
117 .marks
118 .ignore
119 .as_ref()
120 .and_then(|marker| marker.reason.clone()),
121 })
122 } else {
123 None
124 };
125 if let Some(reason) = reason {
126 not_run.push(NotRun {
127 node: case.node.clone(),
128 origin: case.origin.clone(),
129 reason,
130 });
131 continue;
132 }
133 match resolve(&case.uses, &fixtures, &[], Some(&case.origin)) {
134 Ok(resolved) => cases.push(PlannedCase {
135 index: CaseIndex(cases.len()),
136 case: case.clone(),
137 fixtures: resolved,
138 }),
139 Err(diagnostic) => diagnostics.push(diagnostic),
140 }
141 }
142 if cases.len() > options.max_cases {
143 diagnostics.push(Diagnostic::error(
144 DiagnosticCode::TooManyCases,
145 None,
146 format!(
147 "{} cases selected, more than the budget of {}; narrow the selection or raise the budget",
148 cases.len(),
149 options.max_cases
150 ),
151 ));
152 }
153 if !diagnostics.is_empty() {
154 return Err(diagnostics);
155 }
156 Ok(Expanded {
157 cases,
158 not_run,
159 events: collection.events.clone(),
160 helpers: collection.helpers.clone(),
161 })
162}
163
164fn resolve(
166 uses: &[FixtureName],
167 fixtures: &BTreeMap<&FixtureName, &Fixture>,
168 stack: &[&FixtureName],
169 origin: Option<&SourceSpan>,
170) -> Result<Vec<Fixture>, Diagnostic> {
171 fn visit<'a>(
172 name: &'a FixtureName,
173 fixtures: &BTreeMap<&'a FixtureName, &'a Fixture>,
174 stack: &mut Vec<&'a FixtureName>,
175 done: &mut Vec<Fixture>,
176 origin: Option<&SourceSpan>,
177 ) -> Result<(), Diagnostic> {
178 if done.iter().any(|fixture| &fixture.name == name) {
179 return Ok(());
180 }
181 if stack.contains(&name) {
182 let cycle: Vec<_> = stack
183 .iter()
184 .map(ToString::to_string)
185 .chain([name.to_string()])
186 .collect();
187 return Err(Diagnostic::error(
188 DiagnosticCode::FixtureCycle,
189 origin.cloned(),
190 format!("fixture dependency cycle: {}", cycle.join(" -> ")),
191 ));
192 }
193 let fixture = fixtures.get(name).ok_or_else(|| {
194 Diagnostic::error(
195 DiagnosticCode::UnknownFixture,
196 origin.cloned(),
197 format!("unknown fixture {name}"),
198 )
199 })?;
200 stack.push(name);
201 for dependency in &fixture.uses {
202 visit(dependency, fixtures, stack, done, Some(&fixture.origin))?;
203 }
204 stack.pop();
205 done.push((*fixture).clone());
206 Ok(())
207 }
208 let mut stack = stack.to_vec();
209 let mut done = Vec::new();
210 for name in uses {
211 visit(name, fixtures, &mut stack, &mut done, origin)?;
212 }
213 Ok(done)
214}
215
216#[derive(Clone, Debug, Serialize, Deserialize)]
217pub struct GroupOptions {
218 pub default_session: SessionRequest,
220 pub force_isolate: bool,
222 pub runner_shared_sessions: bool,
224 pub max_session_cases: usize,
225}
226
227impl Default for GroupOptions {
228 fn default() -> Self {
229 Self {
230 default_session: SessionRequest::Auto,
231 force_isolate: false,
232 runner_shared_sessions: false,
233 max_session_cases: 32,
234 }
235 }
236}
237
238#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
239#[serde(transparent)]
240pub struct SessionId(pub usize);
241
242#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
243#[serde(rename_all = "snake_case")]
244pub enum Isolation {
245 Dedicated,
246 Shared,
247}
248
249#[derive(Clone, Debug, Serialize, Deserialize)]
251pub struct Session {
252 pub id: SessionId,
253 pub date: GameDate,
254 pub ai: AiMode,
255 pub player: Tag,
258 pub cases: Vec<CaseIndex>,
259 pub isolation: Isolation,
260}
261
262#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
264#[serde(tag = "reason", rename_all = "snake_case")]
265pub enum AloneReason {
266 UserDeclared,
267 ProjectDefault,
268 ForcedByCli,
269 RunnerUnsupported,
270 AiEnabled,
271 RequiresPlayer,
272 UnknownEffect {
274 detail: String,
275 },
276 GlobalState,
278 SameTag {
279 other: CaseIndex,
280 },
281 Overlap {
282 other: CaseIndex,
283 },
284 OnlyCandidate,
286 IsolatedRerun,
288}
289
290#[derive(Clone, Debug, Serialize, Deserialize)]
291pub struct Plan {
292 pub cases: Vec<PlannedCase>,
293 pub not_run: Vec<NotRun>,
294 pub sessions: Vec<Session>,
295 pub alone_reasons: BTreeMap<CaseIndex, AloneReason>,
296 pub events: Vec<EventInfo>,
297 pub helpers: Vec<HelperFile>,
298}
299
300impl Plan {
301 pub fn case(&self, index: CaseIndex) -> &PlannedCase {
302 &self.cases[index.0]
303 }
304
305 pub fn session(&self, id: SessionId) -> &Session {
306 &self.sessions[id.0]
307 }
308
309 pub fn isolated_rerun(&self, cases: &[CaseIndex]) -> Plan {
312 let mut sessions = Vec::new();
313 let mut alone_reasons = BTreeMap::new();
314 for &index in cases {
315 let case = &self.case(index).case;
316 alone_reasons.insert(index, AloneReason::IsolatedRerun);
317 sessions.push(Session {
318 id: SessionId(sessions.len()),
319 date: case.start.date,
320 ai: case.ai,
321 player: case.start.tag.clone(),
322 cases: vec![index],
323 isolation: Isolation::Dedicated,
324 });
325 }
326 Plan {
327 cases: self.cases.clone(),
328 not_run: Vec::new(),
329 sessions,
330 alone_reasons,
331 events: self.events.clone(),
332 helpers: self.helpers.clone(),
333 }
334 }
335}
336
337pub fn group(expanded: Expanded, lint: &LintResult, options: &GroupOptions) -> Plan {
338 let mut alone_reasons = BTreeMap::new();
339 let mut groups: Vec<(GameDate, Vec<CaseIndex>, BTreeSet<Tag>)> = Vec::new();
341 let mut conflicts: BTreeMap<CaseIndex, AloneReason> = BTreeMap::new();
342 for planned in &expanded.cases {
343 let case = &planned.case;
344 let index = planned.index;
345 let footprint = lint.footprints.get(&index);
346 let reason = if case.marks.session == Some(SessionRequest::Alone) {
347 Some(AloneReason::UserDeclared)
348 } else if options.force_isolate {
349 Some(AloneReason::ForcedByCli)
350 } else if options.default_session == SessionRequest::Alone && case.marks.session.is_none() {
351 Some(AloneReason::ProjectDefault)
352 } else if !options.runner_shared_sessions {
353 Some(AloneReason::RunnerUnsupported)
354 } else if case.ai == AiMode::On {
355 Some(AloneReason::AiEnabled)
356 } else if case.marks.requires_player {
357 Some(AloneReason::RequiresPlayer)
358 } else {
359 match footprint {
360 None => Some(AloneReason::UnknownEffect {
361 detail: "no static analysis result".into(),
362 }),
363 Some(footprint) if footprint.opaque.is_some() => Some(AloneReason::UnknownEffect {
364 detail: footprint.opaque.clone().unwrap_or_default(),
365 }),
366 Some(footprint) if footprint.global => Some(AloneReason::GlobalState),
367 Some(_) => None,
368 }
369 };
370 if let Some(reason) = reason {
371 alone_reasons.insert(index, reason);
372 continue;
373 }
374 let tags = &footprint.expect("checked above").tags;
375 let mut placed = false;
376 for (date, members, used) in &mut groups {
377 if *date != case.start.date || members.len() >= options.max_session_cases {
378 continue;
379 }
380 if let Some(&other) = members
381 .iter()
382 .find(|&&other| expanded.cases[other.0].case.start.tag == case.start.tag)
383 {
384 conflicts
385 .entry(index)
386 .or_insert(AloneReason::SameTag { other });
387 continue;
388 }
389 if !used.is_disjoint(tags) {
390 let other = members
391 .iter()
392 .copied()
393 .find(|other| !lint.footprints[other].tags.is_disjoint(tags))
394 .unwrap_or(members[0]);
395 conflicts
396 .entry(index)
397 .or_insert(AloneReason::Overlap { other });
398 continue;
399 }
400 members.push(index);
401 used.extend(tags.iter().cloned());
402 placed = true;
403 break;
404 }
405 if !placed {
406 groups.push((case.start.date, vec![index], tags.clone()));
407 }
408 }
409
410 let mut sessions = Vec::new();
411 let mut shared_members = BTreeSet::new();
412 for (date, members, _) in &groups {
413 if members.len() > 1 {
414 shared_members.extend(members.iter().copied());
415 sessions.push(Session {
416 id: SessionId(sessions.len()),
417 date: *date,
418 ai: AiMode::Off,
419 player: expanded.cases[members[0].0].case.start.tag.clone(),
420 cases: members.clone(),
421 isolation: Isolation::Shared,
422 });
423 }
424 }
425 for planned in &expanded.cases {
426 let index = planned.index;
427 if shared_members.contains(&index) {
428 continue;
429 }
430 alone_reasons.entry(index).or_insert_with(|| {
431 conflicts
432 .remove(&index)
433 .unwrap_or(AloneReason::OnlyCandidate)
434 });
435 sessions.push(Session {
436 id: SessionId(sessions.len()),
437 date: planned.case.start.date,
438 ai: planned.case.ai,
439 player: planned.case.start.tag.clone(),
440 cases: vec![index],
441 isolation: Isolation::Dedicated,
442 });
443 }
444 Plan {
445 cases: expanded.cases,
446 not_run: expanded.not_run,
447 sessions,
448 alone_reasons,
449 events: expanded.events,
450 helpers: expanded.helpers,
451 }
452}