1use std::collections::BTreeSet;
22use std::path::{Path, PathBuf};
23use std::time::Instant;
24
25use mlua::Lua;
26use mlua_lspec::framework;
27use serde_json::{json, Value};
28
29use super::super::AppService;
30
31impl AppService {
32 #[allow(clippy::too_many_arguments)]
66 pub async fn pkg_test(
67 &self,
68 pkg: Option<String>,
69 code_file: Option<String>,
70 code: Option<String>,
71 spec_dir: Option<String>,
72 filter: Option<String>,
73 search_paths: Option<Vec<String>>,
74 _project_root: Option<String>,
75 ) -> Result<String, String> {
76 let input_count = pkg.is_some() as u8 + code_file.is_some() as u8 + code.is_some() as u8;
78 if input_count != 1 {
79 return Err("pkg_test: provide exactly one of pkg, code_file, code".to_string());
80 }
81
82 let extra_search_paths: Vec<String> = search_paths.unwrap_or_default();
83
84 if let Some(inline_code) = code {
85 run_inline(inline_code, extra_search_paths).await
87 } else if let Some(file_path) = code_file {
88 let abs_path = PathBuf::from(&file_path);
90 let src = std::fs::read_to_string(&abs_path)
91 .map_err(|e| format!("pkg_test: failed to read {file_path}: {e}"))?;
92 let parent = abs_path
93 .parent()
94 .map(|p| p.to_string_lossy().into_owned())
95 .unwrap_or_default();
96 let chunk_name = format!("@{file_path}");
97 let mut paths = vec![parent];
98 paths.extend(extra_search_paths);
99 run_single_spec(src, chunk_name, paths).await
100 } else {
101 let Some(pkg_name) = pkg else {
105 unreachable!("pkg must be Some: input_count==1 and code/code_file are None")
106 };
107 let init_path = self
108 .pkg_resolve_init_path(&pkg_name)
109 .map_err(|e| format!("pkg_test: {e}"))?
110 .ok_or_else(|| {
111 format!(
112 "pkg_test: package '{pkg_name}' not found in alc.toml or ~/.algocline/packages/"
113 )
114 })?;
115 let pkg_root = init_path
116 .parent()
117 .map(|p| p.to_path_buf())
118 .unwrap_or_else(|| init_path.clone());
119
120 let spec_subdir = spec_dir.as_deref().unwrap_or("spec");
121 let spec_dir_path = pkg_root.join(spec_subdir);
122
123 let spec_files = collect_spec_files(&spec_dir_path, filter.as_deref())?;
125
126 let pkg_root_str = pkg_root.to_string_lossy().into_owned();
127 let mut search = vec![pkg_root_str];
128 search.extend(extra_search_paths);
129
130 run_pkg_specs(spec_files, search).await
131 }
132 }
133}
134
135fn collect_spec_files(spec_dir_path: &Path, filter: Option<&str>) -> Result<Vec<PathBuf>, String> {
152 if !spec_dir_path.exists() {
153 return Err(format!(
154 "pkg_test: no spec files found in {} (looked for *_spec.lua)",
155 spec_dir_path.display()
156 ));
157 }
158
159 let read_result = std::fs::read_dir(spec_dir_path).map_err(|e| {
160 format!(
161 "pkg_test: failed to read spec dir {}: {e}",
162 spec_dir_path.display()
163 )
164 })?;
165
166 let mut set = BTreeSet::new();
167 for entry in read_result.flatten() {
168 let fname = entry.file_name();
169 let name_str = fname.to_string_lossy();
170 if name_str.ends_with("_spec.lua") {
171 if let Some(f) = filter {
172 let stem = name_str.trim_end_matches("_spec.lua");
173 if !stem.contains(f) {
174 continue;
175 }
176 }
177 set.insert(entry.path());
178 }
179 }
180
181 if set.is_empty() {
182 return Err(format!(
183 "pkg_test: no spec files found in {} (looked for *_spec.lua)",
184 spec_dir_path.display()
185 ));
186 }
187
188 Ok(set.into_iter().collect())
189}
190
191async fn run_inline(code: String, search_paths: Vec<String>) -> Result<String, String> {
201 run_single_spec(code, "@inline.lua".to_string(), search_paths).await
202}
203
204async fn run_single_spec(
220 code: String,
221 chunk_name: String,
222 search_paths: Vec<String>,
223) -> Result<String, String> {
224 let total_start = Instant::now();
225
226 let (spec_file_entry, agg_passed, agg_failed) = tokio::task::spawn_blocking(move || {
227 let lua = Lua::new();
229
230 let search_refs: Vec<&str> = search_paths.iter().map(|s| s.as_str()).collect();
231 let spec_start = Instant::now();
232
233 let (tests_json, passed, failed) =
234 match framework::run_tests(&code, &chunk_name, &search_refs) {
235 Ok(summary) => {
236 let tests: Vec<Value> = summary
237 .tests
238 .iter()
239 .map(|t| {
240 json!({
241 "suite": t.suite,
242 "name": t.name,
243 "passed": t.passed,
244 "pending": false,
245 "error": t.error
246 })
247 })
248 .collect();
249 (tests, summary.passed, summary.failed)
250 }
251 Err(e) => {
252 let tests = vec![json!({
254 "suite": chunk_name,
255 "name": "<top-level>",
256 "passed": false,
257 "pending": false,
258 "error": e.to_string()
259 })];
260 (tests, 0usize, 1usize)
261 }
262 };
263
264 let spec_duration_ms = spec_start.elapsed().as_millis() as u64;
265 let total_tests = passed + failed;
266
267 let spec_entry = json!({
268 "path": chunk_name,
269 "passed": passed,
270 "failed": failed,
271 "total": total_tests,
272 "duration_ms": spec_duration_ms,
273 "tests": tests_json
274 });
275
276 drop(lua);
278
279 (spec_entry, passed, failed)
280 })
281 .await
282 .map_err(|e| format!("pkg_test: blocking task panicked: {e}"))?;
283
284 let duration_ms = total_start.elapsed().as_millis() as u64;
285
286 let result = json!({
287 "passed": agg_passed,
288 "failed": agg_failed,
289 "pending": 0,
290 "total": agg_passed + agg_failed,
291 "duration_ms": duration_ms,
292 "spec_files": [spec_file_entry]
293 });
294
295 Ok(result.to_string())
296}
297
298async fn run_pkg_specs(
312 spec_files: Vec<PathBuf>,
313 search_paths: Vec<String>,
314) -> Result<String, String> {
315 let total_start = Instant::now();
316
317 let (spec_entries, agg_passed, agg_failed) = tokio::task::spawn_blocking(move || {
318 let mut entries: Vec<Value> = Vec::new();
319 let mut total_passed = 0usize;
320 let mut total_failed = 0usize;
321
322 for spec_path in &spec_files {
323 let path_str = spec_path.to_string_lossy().to_string();
324 let code = match std::fs::read_to_string(spec_path) {
325 Ok(s) => s,
326 Err(e) => {
327 entries.push(json!({
329 "path": path_str,
330 "passed": 0,
331 "failed": 1,
332 "total": 1,
333 "duration_ms": 0,
334 "tests": [{
335 "suite": path_str,
336 "name": "<top-level>",
337 "passed": false,
338 "pending": false,
339 "error": format!("pkg_test: failed to read {path_str}: {e}")
340 }]
341 }));
342 total_failed += 1;
343 continue;
344 }
345 };
346
347 let chunk_name = format!("@{path_str}");
348 let search_refs: Vec<&str> = search_paths.iter().map(|s| s.as_str()).collect();
349
350 let lua = Lua::new();
352 let spec_start = Instant::now();
353
354 let (tests_json, passed, failed) =
355 match framework::run_tests(&code, &chunk_name, &search_refs) {
356 Ok(summary) => {
357 let tests: Vec<Value> = summary
358 .tests
359 .iter()
360 .map(|t| {
361 json!({
362 "suite": t.suite,
363 "name": t.name,
364 "passed": t.passed,
365 "pending": false,
366 "error": t.error
367 })
368 })
369 .collect();
370 (tests, summary.passed, summary.failed)
371 }
372 Err(e) => {
373 let tests = vec![json!({
375 "suite": chunk_name,
376 "name": "<top-level>",
377 "passed": false,
378 "pending": false,
379 "error": e.to_string()
380 })];
381 (tests, 0usize, 1usize)
382 }
383 };
384
385 let spec_duration_ms = spec_start.elapsed().as_millis() as u64;
386 let total_tests = passed + failed;
387
388 entries.push(json!({
389 "path": path_str,
390 "passed": passed,
391 "failed": failed,
392 "total": total_tests,
393 "duration_ms": spec_duration_ms,
394 "tests": tests_json
395 }));
396
397 total_passed += passed;
398 total_failed += failed;
399
400 drop(lua);
402 }
403
404 (entries, total_passed, total_failed)
405 })
406 .await
407 .map_err(|e| format!("pkg_test: blocking task panicked: {e}"))?;
408
409 let duration_ms = total_start.elapsed().as_millis() as u64;
410
411 let result = json!({
412 "passed": agg_passed,
413 "failed": agg_failed,
414 "pending": 0,
415 "total": agg_passed + agg_failed,
416 "duration_ms": duration_ms,
417 "spec_files": spec_entries
418 });
419
420 Ok(result.to_string())
421}
422
423#[cfg(test)]
426mod tests {
427 use super::super::super::test_support::make_app_service_at;
428
429 #[tokio::test]
431 async fn inline_passing_test_returns_passed_one() {
432 let tmp = tempfile::tempdir().unwrap();
433 let svc = make_app_service_at(tmp.path().to_path_buf()).await;
434
435 let lua_code = concat!(
436 "local describe, it, expect = lust.describe, lust.it, lust.expect\n",
437 "describe('suite', function()\n",
438 " it('passes', function() expect(1).to.equal(1) end)\n",
439 "end)\n",
440 )
441 .to_string();
442
443 let result = svc
444 .pkg_test(None, None, Some(lua_code), None, None, None, None)
445 .await
446 .expect("pkg_test should succeed");
447
448 let json: serde_json::Value = serde_json::from_str(&result).unwrap();
449 assert_eq!(json["passed"], 1, "expected 1 passed: {result}");
450 assert_eq!(json["failed"], 0, "expected 0 failed: {result}");
451 assert_eq!(json["pending"], 0, "expected 0 pending: {result}");
452 }
453
454 #[tokio::test]
457 async fn inline_failing_test_absorbed_returns_ok() {
458 let tmp = tempfile::tempdir().unwrap();
459 let svc = make_app_service_at(tmp.path().to_path_buf()).await;
460
461 let lua_code = concat!(
462 "local describe, it, expect = lust.describe, lust.it, lust.expect\n",
463 "describe('suite', function()\n",
464 " it('fails', function() expect(1).to.equal(2) end)\n",
465 "end)\n",
466 )
467 .to_string();
468
469 let result = svc
470 .pkg_test(None, None, Some(lua_code), None, None, None, None)
471 .await
472 .expect("pkg_test returns Ok even for failing tests");
473
474 let json: serde_json::Value = serde_json::from_str(&result).unwrap();
475 assert_eq!(json["failed"], 1, "expected 1 failed: {result}");
476 assert_eq!(json["passed"], 0, "expected 0 passed: {result}");
477 }
478
479 #[tokio::test]
481 async fn zero_inputs_returns_exclusivity_error() {
482 let tmp = tempfile::tempdir().unwrap();
483 let svc = make_app_service_at(tmp.path().to_path_buf()).await;
484
485 let err = svc
486 .pkg_test(None, None, None, None, None, None, None)
487 .await
488 .expect_err("should return Err for zero inputs");
489
490 assert_eq!(err, "pkg_test: provide exactly one of pkg, code_file, code");
491 }
492
493 #[tokio::test]
495 async fn multiple_inputs_returns_exclusivity_error() {
496 let tmp = tempfile::tempdir().unwrap();
497 let svc = make_app_service_at(tmp.path().to_path_buf()).await;
498
499 let err = svc
500 .pkg_test(
501 Some("mypkg".into()),
502 None,
503 Some("return 1".into()),
504 None,
505 None,
506 None,
507 None,
508 )
509 .await
510 .expect_err("should return Err for multiple inputs");
511
512 assert_eq!(err, "pkg_test: provide exactly one of pkg, code_file, code");
513 }
514
515 #[tokio::test]
518 async fn pkg_not_found_returns_typed_error() {
519 let tmp = tempfile::tempdir().unwrap();
520 let svc = make_app_service_at(tmp.path().to_path_buf()).await;
521
522 let err = svc
523 .pkg_test(
524 Some("nonexistent_pkg_xyz".into()),
525 None,
526 None,
527 None,
528 None,
529 None,
530 None,
531 )
532 .await
533 .expect_err("should return Err for missing pkg");
534
535 assert!(
536 err.contains("nonexistent_pkg_xyz"),
537 "error must mention pkg name: {err}"
538 );
539 assert!(err.contains("not found"), "error must say not found: {err}");
540 }
541
542 #[tokio::test]
544 async fn code_file_not_found_returns_typed_error() {
545 let tmp = tempfile::tempdir().unwrap();
546 let svc = make_app_service_at(tmp.path().to_path_buf()).await;
547
548 let err = svc
549 .pkg_test(
550 None,
551 Some("/nonexistent/path/missing_spec.lua".into()),
552 None,
553 None,
554 None,
555 None,
556 None,
557 )
558 .await
559 .expect_err("should return Err for missing code_file");
560
561 assert!(
562 err.contains("failed to read"),
563 "error must describe I/O failure: {err}"
564 );
565 }
566}