1use crate::{CheckInfo, Htl, parent_dir, write_if_changed};
10use anyhow::{Context, Result};
11use mlua::{Function, Table, Value};
12use std::path::{Path, PathBuf};
13
14const TEST_LUA: &str = include_str!("../lua/test.lua");
15const TEST_DTL: &str = include_str!("../lua/test.d.tl");
16
17pub const DEFAULT_LIB: &str = "htl.test";
19
20pub fn lib_dir() -> Result<PathBuf> {
23 let dir = std::env::temp_dir().join(format!("htl-lib-{}", env!("CARGO_PKG_VERSION")));
24 write_if_changed(&dir.join("htl").join("test.d.tl"), TEST_DTL)
25 .with_context(|| format!("writing bundled declarations under {}", dir.display()))?;
26 Ok(dir)
27}
28
29impl Htl {
30 pub fn install_test_lib(&self) -> Result<()> {
32 self.preload(DEFAULT_LIB, TEST_LUA)?;
33 self.add_path(&lib_dir()?)?;
34 Ok(())
35 }
36}
37
38#[derive(Debug, Default)]
40pub struct FileReport {
41 pub path: PathBuf,
42 pub check: CheckInfo,
43 pub error: Option<String>,
45 pub passed: usize,
46 pub failed: usize,
47 pub failures: Vec<String>,
48 pub file_level: bool,
50 pub tests: Vec<TestResult>,
52 pub duration_ms: f64,
54}
55
56#[derive(Debug, Clone, Default)]
58pub struct TestResult {
59 pub name: String,
60 pub ok: bool,
61 pub ms: f64,
62}
63
64#[derive(Debug, Clone, Default)]
66pub struct RunOptions {
67 pub fail_fast: bool,
69}
70
71impl FileReport {
72 pub fn ok(&self) -> bool {
73 self.check.ok() && self.error.is_none() && self.failed == 0
74 }
75}
76
77pub fn discover_tests(paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
81 let mut out = Vec::new();
82 for p in paths {
83 if p.is_file() {
84 out.push(p.clone());
85 continue;
86 }
87 let extra = crate::project_skip_dirs(p);
88 let root = p.clone();
89 let walker = walkdir::WalkDir::new(p)
90 .sort_by_file_name()
91 .into_iter()
92 .filter_entry(move |e| e.path() == root || !crate::is_skipped_dir(e.path(), &extra));
93 for e in walker {
94 let e = e?;
95 let path = e.path();
96 if !crate::is_tl_source(path) {
97 continue;
98 }
99 let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
100 let in_tests_dir = path
101 .strip_prefix(p)
102 .ok()
103 .map(|rel| rel.components().any(|c| c.as_os_str() == "tests"))
104 .unwrap_or(false);
105 if name.ends_with("_test.tl") || in_tests_dir {
106 out.push(path.to_path_buf());
107 }
108 }
109 }
110 out.sort();
111 out.dedup();
112 Ok(out)
113}
114
115pub fn run_test_file(
118 path: &Path,
119 filter: Option<&str>,
120 lib: &str,
121 lint_spec: Option<&str>,
122 opts: &RunOptions,
123) -> Result<FileReport> {
124 TestSession::new(lint_spec, lib, filter, opts.clone())?.run_file(path)
125}
126
127pub struct TestSession {
131 checker: Htl,
132 lib: String,
133 filter: Option<String>,
134 opts: RunOptions,
135}
136
137impl TestSession {
138 pub fn new(lint_spec: Option<&str>, lib: &str, filter: Option<&str>, opts: RunOptions) -> Result<Self> {
139 let checker = Htl::new()?;
140 if let Some(spec) = lint_spec {
141 checker.configure_lints(spec)?;
142 }
143 Ok(Self { checker, lib: lib.to_string(), filter: filter.map(String::from), opts })
144 }
145
146 pub fn run_file(&self, path: &Path) -> Result<FileReport> {
150 let started = std::time::Instant::now();
151 let saved = self.checker.search_path()?;
152 let h = Htl::with_checker(&self.checker)?;
153 let out = run_in(&h, path, self.filter.as_deref(), &self.lib, &self.opts);
154 self.checker.set_search_path(&saved)?;
155 let mut rep = out?;
156 rep.duration_ms = started.elapsed().as_secs_f64() * 1000.0;
157 Ok(rep)
158 }
159}
160
161fn run_in(h: &Htl, path: &Path, filter: Option<&str>, lib: &str, opts: &RunOptions) -> Result<FileReport> {
162 let mut rep = FileReport { path: path.to_path_buf(), ..Default::default() };
163 let profile = std::env::var_os("HTL_PROFILE").is_some();
164 let mut t0 = std::time::Instant::now();
165 let phase = |label: &str, t0: &mut std::time::Instant| {
166 if profile {
167 eprintln!("profile: {label:<8} {:7.1} ms {}", t0.elapsed().as_secs_f64() * 1000.0, path.display());
168 }
169 *t0 = std::time::Instant::now();
170 };
171 phase("state", &mut t0);
172 h.install_test_lib()?;
173 let dir = parent_dir(path);
174 h.add_path(&dir)?;
175 #[cfg(feature = "pkg")]
176 if let Some(p) = crate::pkg::Project::find(path) {
177 h.apply_project(&p)?;
178 }
179 if let Some((cfg_path, cfg)) = crate::config::HtlConfig::find(path)? {
180 h.apply_config(&parent_dir(&cfg_path), &cfg)?;
181 }
182 if dir.file_name().is_some_and(|n| n == "tests")
184 && let Some(root) = dir.parent()
185 {
186 h.add_path(root)?;
187 h.add_path(&root.join("src"))?;
188 }
189 h.install_searcher()?;
190 h.set_arg(&path.to_string_lossy(), &[])?;
191 phase("setup", &mut t0);
192
193 let (code, check) = h.gen_lua(path)?;
194 phase("gen_lua", &mut t0);
195 rep.check = check;
196 let Some(code) = code else { return Ok(rep) };
197 if let Err(e) = h.exec(&code, &format!("@{}", path.display()), &[]) {
198 rep.error = Some(crate::user_message(&e));
199 return Ok(rep);
200 }
201 phase("exec", &mut t0);
202
203 let package: Table = h.lua().globals().get("package")?;
205 let loaded: Table = package.get("loaded")?;
206 match loaded.get::<Value>(lib)? {
207 Value::Table(t) => {
208 let run: Function = t.get("run")?;
209 let lua_opts = h.lua().create_table()?;
210 lua_opts.set("fail_fast", opts.fail_fast)?;
211 let report: Table = run.call((filter, lua_opts))?;
212 phase("run", &mut t0);
213 rep.passed = report.get::<Option<usize>>("passed")?.unwrap_or(0);
214 rep.failed = report.get::<Option<usize>>("failed")?.unwrap_or(0);
215 if let Ok(f) = report.get::<Table>("failures") {
216 rep.failures = f.sequence_values::<String>().collect::<mlua::Result<_>>()?;
217 }
218 if let Ok(tests) = report.get::<Table>("tests") {
219 for tr in tests.sequence_values::<Table>() {
220 let tr = tr?;
221 rep.tests.push(TestResult {
222 name: tr.get::<Option<String>>("name")?.unwrap_or_default(),
223 ok: tr.get::<Option<bool>>("ok")?.unwrap_or(false),
224 ms: tr.get::<Option<f64>>("ms")?.unwrap_or(0.0),
225 });
226 }
227 }
228 }
229 _ => {
230 rep.file_level = true;
231 rep.passed = 1;
232 }
233 }
234 Ok(rep)
235}