Skip to main content

algocline_app/service/pkg/
test_run.rs

1//! `pkg_test` — run mlua-lspec tests for a package, a file, or inline code.
2//!
3//! # Input routing
4//!
5//! Exactly one of `pkg`, `code_file`, or `code` must be provided:
6//! - **`pkg`**: discovers `*_spec.lua` files under `<pkg_root>/<spec_dir>/`
7//!   (default `"spec"`), runs each in its own mlua VM sequentially inside a
8//!   single `spawn_blocking` task.
9//! - **`code_file`**: reads the given absolute path and runs it as a single
10//!   test file.
11//! - **`code`**: runs inline Lua source as a single test (chunk name
12//!   `"@inline.lua"`).
13//!
14//! # Two-tier error model
15//!
16//! - **Per-spec-file crashes** (mlua-lspec `Err`): absorbed — `failed += 1`,
17//!   a synthetic error entry is appended to `tests`, and execution continues.
18//! - **Setup failures** (VM init, pkg not found, zero spec files, I/O errors,
19//!   `spawn_blocking` panic): propagated as typed `Err(String)` to MCP wire.
20
21use 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    /// Run mlua-lspec tests for a package, a single file, or inline code.
33    ///
34    /// Exactly one of `pkg`, `code_file`, `code` must be provided.
35    /// Zero or more than one returns a typed `Err`.
36    ///
37    /// # Arguments
38    ///
39    /// * `pkg` — installed package name. Spec files are discovered under
40    ///   `<pkg_root>/<spec_dir>/*_spec.lua` (default `spec_dir = "spec"`).
41    /// * `code_file` — absolute path to a single `.lua` test file.
42    /// * `code` — inline Lua source code containing lspec tests.
43    /// * `spec_dir` — subdirectory inside the pkg root for spec files
44    ///   (default `"spec"`). Only used when `pkg` is provided.
45    /// * `filter` — substring filter on spec file stems (only for `pkg`).
46    /// * `search_paths` — additional dirs prepended to `package.path` inside
47    ///   the Lua VM.
48    /// * `project_root` — optional project root for variant-scope resolution
49    ///   (`alc.local.toml`). Falls back to ancestor walk from cwd.
50    ///
51    /// # Returns
52    ///
53    /// On success: JSON string with shape
54    /// `{passed, failed, pending, total, duration_ms, spec_files: [{path,
55    /// passed, failed, total, duration_ms, tests: [{suite, name, passed,
56    /// pending, error}]}]}`.
57    ///
58    /// # Errors
59    ///
60    /// Returns `Err(String)` for setup failures (VM init, pkg not found, zero
61    /// spec files, I/O errors, `spawn_blocking` panic). Per-spec Lua crashes
62    /// are absorbed, not propagated.
63    // 8 parameters are justified by the MCP wire shape: 3 mutually exclusive
64    // input sources (pkg / code_file / code) plus filtering/path options.
65    #[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        // ── Crux constraint 1: exactly-one input exclusivity ──────────────────
77        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            // `code` path: single VM, inline source.
86            run_inline(inline_code, extra_search_paths).await
87        } else if let Some(file_path) = code_file {
88            // `code_file` path: read file then run.
89            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            // `pkg` path: spec_dir scan.
102            // input_count == 1 and neither `code` nor `code_file` is Some,
103            // so `pkg` must be Some here by the exclusivity check above.
104            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            // Collect *_spec.lua files deterministically.
124            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
135// ─── Helpers ──────────────────────────────────────────────────────────────────
136
137/// Collect `*_spec.lua` entries from `spec_dir_path`, sorted deterministically.
138///
139/// Returns `Err` if the directory does not exist or zero files remain after
140/// applying `filter`.
141///
142/// # Arguments
143///
144/// * `spec_dir_path` — absolute path to the spec directory.
145/// * `filter` — optional substring matched against the file stem (e.g.
146///   `"shape"` matches `shape_spec.lua`).
147///
148/// # Errors
149///
150/// Returns `Err(String)` when the directory is absent or no spec files match.
151fn 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
191/// Run inline Lua code as a single lspec test.
192///
193/// `framework::run_tests` pre-loads the `lust` global — no separate
194/// `framework::register` call is needed (and calling it would risk double
195/// registration).
196///
197/// # Errors
198///
199/// Returns `Err` if the blocking task panics.
200async 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
204/// Execute one spec source string inside a fresh mlua VM on a blocking thread.
205///
206/// Per-spec Lua crashes are represented as a failing test entry (crux
207/// constraint 2 — per-spec absorption) rather than propagating the error.
208///
209/// # Arguments
210///
211/// * `code` — Lua source to execute.
212/// * `chunk_name` — Lua chunk name convention: `"@inline.lua"` or
213///   `"@<abs_path>"`.
214/// * `search_paths` — directories prepended to `package.path` inside the VM.
215///
216/// # Errors
217///
218/// Returns `Err` when the task panics.
219async 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        // mlua::Lua is !Send — construct inside the blocking task.
228        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                    // Per-spec crash: absorbed, contributes 1 failing entry.
253                    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        // Keep lua alive until here to avoid premature Drop.
277        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
298/// Run multiple spec files sequentially inside a single `spawn_blocking` task.
299///
300/// Each spec file gets its own fresh mlua VM. Per-spec crashes are absorbed
301/// (crux constraint 2). Aggregate counts and per-file entries are returned.
302///
303/// # Arguments
304///
305/// * `spec_files` — sorted list of absolute paths to `*_spec.lua` files.
306/// * `search_paths` — directories prepended to `package.path` inside each VM.
307///
308/// # Errors
309///
310/// Returns `Err` if the task panics.
311async 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                    // I/O failure for this spec file: absorbed as failing entry.
328                    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            // mlua::Lua is !Send — construct fresh per spec file.
351            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                        // Per-spec crash: absorbed per crux constraint 2.
374                        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            // Keep lua alive until end of this iteration, then drop.
401            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// ─── Unit tests ───────────────────────────────────────────────────────────────
424
425#[cfg(test)]
426mod tests {
427    use super::super::super::test_support::make_app_service_at;
428
429    // T1: happy path — inline code with a passing test.
430    #[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    // T2: edge case — inline code with a failing assertion returns Ok with
455    // failed=1 (per-spec crash absorption).
456    #[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    // T3: error path — zero inputs triggers typed Err (crux constraint 1).
480    #[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    // T3: error path — multiple inputs triggers typed Err (crux constraint 1).
494    #[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    // T3: error path — pkg not found returns typed Err (crux constraint 2
516    // propagated tier).
517    #[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    // T2: edge — code_file with non-existent path returns typed Err.
543    #[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}