1use itertools::Itertools;
2
3use miette::Diagnostic;
4use path_slash::PathExt;
5use serde::{Deserialize, Deserializer};
6use serde_enum_str::Serialize_enum_str;
7use std::{convert::Infallible, path::PathBuf};
8use thiserror::Error;
9
10use crate::lua_version::LuaVersion;
11
12use crate::{
13 config::{Config, ConfigBuilder, ConfigError},
14 lua_rockspec::per_platform_from_intermediate,
15 package::PackageReq,
16 project::{project_toml::LocalProjectTomlValidationError, Project},
17 rockspec::Rockspec,
18};
19
20use super::{PartialOverride, PerPlatform, PlatformOverridable};
21
22#[cfg(target_family = "unix")]
23const NLUA_EXE: &str = "nlua";
24#[cfg(target_family = "windows")]
25const NLUA_EXE: &str = "nlua.bat";
26
27#[derive(Error, Debug, Diagnostic)]
28pub enum TestSpecDecodeError {
29 #[error("the 'command' test type must specify either a 'command' or 'script' field")]
30 NoCommandOrScript,
31 #[error("the 'command' test type cannot have both 'command' and 'script' fields")]
32 CommandAndScript,
33}
34
35#[derive(Error, Debug, Diagnostic)]
36pub enum TestSpecError {
37 #[error("could not auto-detect the test spec. Please add one to your lux.toml")]
38 NoTestSpecDetected,
39 #[error("project validation failed:\n{0}")]
40 #[diagnostic(forward(0))]
41 LocalProjectTomlValidation(#[from] LocalProjectTomlValidationError),
42}
43
44#[derive(Clone, Debug, PartialEq, Default)]
45pub enum TestSpec {
46 #[default]
47 AutoDetect,
48 Busted(BustedTestSpec),
49 BustedNlua(BustedTestSpec),
50 Command(CommandTestSpec),
51 Script(LuaScriptTestSpec),
52}
53
54#[derive(Clone, Debug, PartialEq)]
55pub(crate) enum ValidatedTestSpec {
56 Busted {
57 spec: BustedTestSpec,
58 dependencies: Vec<PackageReq>,
59 },
60 BustedNlua {
61 spec: BustedTestSpec,
62 dependencies: Vec<PackageReq>,
63 },
64 Command(CommandTestSpec),
65 LuaScript(LuaScriptTestSpec),
66}
67
68impl TestSpec {
69 pub(crate) fn test_dependencies(&self, project: &Project) -> Vec<PackageReq> {
70 self.to_validated(project)
71 .ok()
72 .iter()
73 .flat_map(|spec| spec.test_dependencies())
74 .collect_vec()
75 }
76
77 pub(crate) fn to_validated(
78 &self,
79 project: &Project,
80 ) -> Result<ValidatedTestSpec, TestSpecError> {
81 let project_root = project.root();
82 let toml = project.toml().into_local()?;
83 let test_dependencies = toml.test_dependencies().current_platform();
84 let busted_dependency = test_dependencies
85 .iter()
86 .find(|dep| dep.name().to_string() == "busted");
87 let busted = busted_dependency
88 .map(|dep| dep.package_req.clone())
89 .unwrap_or_else(|| unsafe { PackageReq::new_unchecked("busted".into(), None) });
90 let nlua_dependency = test_dependencies
91 .iter()
92 .find(|dep| dep.name().to_string() == "nlua");
93 let nlua = nlua_dependency
94 .map(|dep| dep.package_req.clone())
95 .unwrap_or_else(|| unsafe { PackageReq::new_unchecked("nlua".into(), None) });
96 let is_busted = project_root.join(".busted").is_file() || busted_dependency.is_some();
97 match self {
98 Self::AutoDetect if is_busted => {
99 if nlua_dependency.is_some() {
100 Ok(ValidatedTestSpec::BustedNlua {
101 spec: BustedTestSpec::default(),
102 dependencies: vec![busted, nlua],
103 })
104 } else {
105 Ok(ValidatedTestSpec::Busted {
106 spec: BustedTestSpec::default(),
107 dependencies: vec![busted],
108 })
109 }
110 }
111 Self::Busted(spec) => Ok(ValidatedTestSpec::Busted {
112 spec: spec.clone(),
113 dependencies: vec![busted],
114 }),
115 Self::BustedNlua(spec) => Ok(ValidatedTestSpec::BustedNlua {
116 spec: spec.clone(),
117 dependencies: vec![busted, nlua],
118 }),
119 Self::Command(spec) => Ok(ValidatedTestSpec::Command(spec.clone())),
120 Self::Script(spec) => Ok(ValidatedTestSpec::LuaScript(spec.clone())),
121 Self::AutoDetect => Err(TestSpecError::NoTestSpecDetected),
122 }
123 }
124}
125
126impl ValidatedTestSpec {
127 pub fn args(&self) -> Vec<String> {
128 match self {
129 Self::Busted {
130 spec,
131 dependencies: _,
132 } => spec.flags.clone(),
133 Self::BustedNlua {
134 spec,
135 dependencies: _,
136 } => {
137 let mut flags = spec.flags.clone();
138 flags.push("--ignore-lua".into());
142 flags
143 }
144 Self::Command(spec) => spec.flags.clone(),
145 Self::LuaScript(spec) => std::iter::once(spec.script.to_slash_lossy().to_string())
146 .chain(spec.flags.clone())
147 .collect_vec(),
148 }
149 }
150
151 pub(crate) fn test_config(&self, config: &Config) -> Result<Config, ConfigError> {
152 match self {
153 Self::BustedNlua { .. } => {
154 let config_builder: ConfigBuilder = config.clone().into();
155
156 #[cfg(not(any(target_os = "macos", target_env = "msvc")))]
160 let lua_version = LuaVersion::Lua51;
161
162 #[cfg(any(target_os = "macos", target_env = "msvc"))]
163 let lua_version = LuaVersion::LuaJIT;
164
165 Ok(config_builder
166 .lua_version(Some(lua_version))
167 .variables(Some(
168 vec![("LUA".to_string(), NLUA_EXE.to_string())]
169 .into_iter()
170 .collect(),
171 ))
172 .build()?)
173 }
174 _ => Ok(config.clone()),
175 }
176 }
177
178 fn test_dependencies(&self) -> Vec<PackageReq> {
179 match self {
180 Self::Busted {
181 spec: _,
182 dependencies,
183 } => dependencies.clone(),
184 Self::BustedNlua {
185 spec: _,
186 dependencies,
187 } => dependencies.clone(),
188 Self::Command(_) => Vec::new(),
189 Self::LuaScript(_) => Vec::new(),
190 }
191 }
192}
193
194impl TryFrom<TestSpecInternal> for TestSpec {
195 type Error = TestSpecDecodeError;
196
197 fn try_from(internal: TestSpecInternal) -> Result<Self, Self::Error> {
198 let test_spec = match internal.test_type {
199 Some(TestType::Busted) => Ok(Self::Busted(BustedTestSpec {
200 flags: internal.flags.unwrap_or_default(),
201 })),
202 Some(TestType::BustedNlua) => Ok(Self::BustedNlua(BustedTestSpec {
203 flags: internal.flags.unwrap_or_default(),
204 })),
205 Some(TestType::Command) => match (internal.command, internal.lua_script) {
206 (None, None) => Err(TestSpecDecodeError::NoCommandOrScript),
207 (None, Some(script)) => Ok(Self::Script(LuaScriptTestSpec {
208 script,
209 flags: internal.flags.unwrap_or_default(),
210 })),
211 (Some(command), None) => Ok(Self::Command(CommandTestSpec {
212 command,
213 flags: internal.flags.unwrap_or_default(),
214 })),
215 (Some(_), Some(_)) => Err(TestSpecDecodeError::CommandAndScript),
216 },
217 None => Ok(Self::default()),
218 }?;
219 Ok(test_spec)
220 }
221}
222
223impl<'de> Deserialize<'de> for PerPlatform<TestSpec> {
224 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
225 where
226 D: Deserializer<'de>,
227 {
228 per_platform_from_intermediate::<_, TestSpecInternal, _>(deserializer)
229 }
230}
231
232impl<'de> Deserialize<'de> for TestSpec {
233 fn deserialize<D>(deserializer: D) -> Result<TestSpec, D::Error>
234 where
235 D: Deserializer<'de>,
236 {
237 let internal = TestSpecInternal::deserialize(deserializer)?;
238 let test_spec = TestSpec::try_from(internal).map_err(serde::de::Error::custom)?;
239 Ok(test_spec)
240 }
241}
242
243#[derive(Clone, Debug, PartialEq, Default)]
245pub struct BustedTestSpec {
246 pub(crate) flags: Vec<String>,
248}
249
250impl BustedTestSpec {
251 pub fn flags(&self) -> &Vec<String> {
252 &self.flags
253 }
254}
255
256#[derive(Clone, Debug, PartialEq)]
258pub struct CommandTestSpec {
259 pub(crate) command: String,
261 pub(crate) flags: Vec<String>,
263}
264
265impl CommandTestSpec {
266 pub fn flags(&self) -> &Vec<String> {
267 &self.flags
268 }
269
270 pub fn command(&self) -> &str {
271 &self.command
272 }
273}
274
275#[derive(Clone, Debug, PartialEq)]
277pub struct LuaScriptTestSpec {
278 pub(crate) script: PathBuf,
280 pub(crate) flags: Vec<String>,
282}
283
284impl LuaScriptTestSpec {
285 pub fn flags(&self) -> &Vec<String> {
286 &self.flags
287 }
288
289 pub fn script(&self) -> &PathBuf {
290 &self.script
291 }
292}
293
294#[derive(Debug, Deserialize, Serialize_enum_str, PartialEq, Clone)]
295#[serde(rename_all = "kebab-case")]
296pub(crate) enum TestType {
297 Busted,
298 BustedNlua,
299 Command,
300}
301
302#[derive(Debug, PartialEq, Deserialize, Default, Clone)]
303pub(crate) struct TestSpecInternal {
304 #[serde(default, rename = "type")]
305 pub(crate) test_type: Option<TestType>,
306 #[serde(default)]
307 pub(crate) flags: Option<Vec<String>>,
308 #[serde(default)]
309 pub(crate) command: Option<String>,
310 #[serde(default, rename = "script", alias = "lua_script")]
311 pub(crate) lua_script: Option<PathBuf>,
312}
313
314impl PartialOverride for TestSpecInternal {
315 type Err = Infallible;
316
317 fn apply_overrides(&self, override_spec: &Self) -> Result<Self, Self::Err> {
318 Ok(TestSpecInternal {
319 test_type: override_opt(&override_spec.test_type, &self.test_type),
320 flags: match (override_spec.flags.clone(), self.flags.clone()) {
321 (Some(override_vec), Some(base_vec)) => {
322 let merged: Vec<String> =
323 base_vec.into_iter().chain(override_vec).unique().collect();
324 Some(merged)
325 }
326 (None, base_vec @ Some(_)) => base_vec,
327 (override_vec @ Some(_), None) => override_vec,
328 _ => None,
329 },
330 command: match override_spec.lua_script.clone() {
331 Some(_) => None,
332 None => override_opt(&override_spec.command, &self.command),
333 },
334 lua_script: match override_spec.command.clone() {
335 Some(_) => None,
336 None => override_opt(&override_spec.lua_script, &self.lua_script),
337 },
338 })
339 }
340}
341
342impl PlatformOverridable for TestSpecInternal {
343 type Err = Infallible;
344
345 fn on_nil<T>() -> Result<PerPlatform<T>, <Self as PlatformOverridable>::Err>
346 where
347 T: PlatformOverridable,
348 T: Default,
349 {
350 Ok(PerPlatform::default())
351 }
352}
353
354fn override_opt<T: Clone>(override_opt: &Option<T>, base: &Option<T>) -> Option<T> {
355 match override_opt.clone() {
356 override_val @ Some(_) => override_val,
357 None => base.clone(),
358 }
359}
360
361#[cfg(test)]
362mod tests {
363
364 use ottavino::{Closure, Executor, Fuel, Lua};
365 use ottavino_util::serde::from_value;
366
367 use crate::lua_rockspec::PlatformIdentifier;
368
369 use super::*;
370
371 fn exec_lua<T: serde::de::DeserializeOwned>(
372 code: &str,
373 key: &'static str,
374 ) -> Result<T, ottavino::ExternError> {
375 Lua::core().try_enter(|ctx| {
376 let closure = Closure::load(ctx, None, code.as_bytes())?;
377 let executor = Executor::start(ctx, closure.into(), ());
378 executor.step(ctx, &mut Fuel::with(i32::MAX))?;
379 from_value(ctx.globals().get_value(ctx, key)).map_err(ottavino::Error::from)
380 })
381 }
382
383 #[tokio::test]
384 pub async fn test_spec_from_lua() {
385 let lua_content = "
386 test = {\n
387 }\n
388 ";
389 let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
390 assert!(matches!(test_spec.default, TestSpec::AutoDetect));
391 let lua_content = "
392 test = {\n
393 type = 'busted',\n
394 }\n
395 ";
396 let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
397 assert_eq!(
398 test_spec.default,
399 TestSpec::Busted(BustedTestSpec::default())
400 );
401 let lua_content = "
402 test = {\n
403 type = 'busted',\n
404 flags = { 'foo', 'bar' },\n
405 }\n
406 ";
407 let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
408 assert_eq!(
409 test_spec.default,
410 TestSpec::Busted(BustedTestSpec {
411 flags: vec!["foo".into(), "bar".into()],
412 })
413 );
414 let lua_content = "
415 test = {\n
416 type = 'command',\n
417 }\n
418 ";
419 let result: Result<PerPlatform<TestSpec>, _> = exec_lua(lua_content, "test");
420 let _err = result.unwrap_err();
421 let lua_content = "
422 test = {\n
423 type = 'command',\n
424 command = 'foo',\n
425 script = 'bar',\n
426 }\n
427 ";
428 let result: Result<PerPlatform<TestSpec>, _> = exec_lua(lua_content, "test");
429 let _err = result.unwrap_err();
430 let lua_content = "
431 test = {\n
432 type = 'command',\n
433 command = 'baz',\n
434 flags = { 'foo', 'bar' },\n
435 }\n
436 ";
437 let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
438 assert_eq!(
439 test_spec.default,
440 TestSpec::Command(CommandTestSpec {
441 command: "baz".into(),
442 flags: vec!["foo".into(), "bar".into()],
443 })
444 );
445 let lua_content = "
446 test = {\n
447 type = 'command',\n
448 script = 'test.lua',\n
449 flags = { 'foo', 'bar' },\n
450 }\n
451 ";
452 let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
453 assert_eq!(
454 test_spec.default,
455 TestSpec::Script(LuaScriptTestSpec {
456 script: PathBuf::from("test.lua"),
457 flags: vec!["foo".into(), "bar".into()],
458 })
459 );
460 let lua_content = "
461 test = {\n
462 type = 'command',\n
463 command = 'baz',\n
464 flags = { 'foo', 'bar' },\n
465 platforms = {\n
466 unix = { flags = { 'baz' }, },\n
467 macosx = {\n
468 script = 'bat.lua',\n
469 flags = { 'bat' },\n
470 },\n
471 linux = { type = 'busted' },\n
472 },\n
473 }\n
474 ";
475 let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
476 assert_eq!(
477 test_spec.default,
478 TestSpec::Command(CommandTestSpec {
479 command: "baz".into(),
480 flags: vec!["foo".into(), "bar".into()],
481 })
482 );
483 let unix = test_spec
484 .per_platform
485 .get(&PlatformIdentifier::Unix)
486 .unwrap();
487 assert_eq!(
488 *unix,
489 TestSpec::Command(CommandTestSpec {
490 command: "baz".into(),
491 flags: vec!["foo".into(), "bar".into(), "baz".into()],
492 })
493 );
494 let macosx = test_spec
495 .per_platform
496 .get(&PlatformIdentifier::MacOSX)
497 .unwrap();
498 assert_eq!(
499 *macosx,
500 TestSpec::Script(LuaScriptTestSpec {
501 script: "bat.lua".into(),
502 flags: vec!["foo".into(), "bar".into(), "bat".into(), "baz".into()],
503 })
504 );
505 let linux = test_spec
506 .per_platform
507 .get(&PlatformIdentifier::Linux)
508 .unwrap();
509 assert_eq!(
510 *linux,
511 TestSpec::Busted(BustedTestSpec {
512 flags: vec!["foo".into(), "bar".into(), "baz".into()],
513 })
514 );
515 let lua_content = "
516 test = {\n
517 type = 'busted-nlua',\n
518 }";
519 let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
520 assert_eq!(
521 test_spec.default,
522 TestSpec::BustedNlua(BustedTestSpec { flags: Vec::new() })
523 );
524 }
525}