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