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, HashSet};
22use std::path::{Path, PathBuf};
23use std::time::Instant;
24
25use algocline_engine::bridge as engine_bridge;
26use mlua::{Lua, Value as LuaValue, Variadic};
27use mlua_lspec::{doubles, framework};
28use serde::Serialize;
29use serde_json::{json, Value};
30use tracing::warn;
31
32use super::super::alc_toml::{load_alc_local_toml, load_alc_toml, PackageDep};
33use super::super::AppService;
34
35// ─── Auto search path types ───────────────────────────────────────────────────
36
37/// Source of an auto-resolved search path entry.
38#[derive(Debug, Clone, Serialize)]
39#[serde(rename_all = "snake_case")]
40pub(crate) enum AutoSearchPathSource {
41    /// Path comes from the installed packages directory (`~/.algocline/packages/`).
42    Installed,
43    /// Path comes from a `[packages]` path entry in `alc.toml`.
44    #[serde(rename = "alc.toml")]
45    AlcToml,
46    /// Path comes from a `[packages]` path entry in `alc.local.toml`.
47    #[serde(rename = "alc.local.toml")]
48    AlcLocalToml,
49}
50
51/// A single package-name → parent-dir mapping returned by auto-resolution.
52#[derive(Debug, Clone, Serialize)]
53pub(crate) struct ResolvedSearchPath {
54    /// Package name as declared in the registry.
55    pub name: String,
56    /// Canonicalized parent directory that should be added to `package.path`.
57    pub search_dir: String,
58    /// Which registry source this entry came from.
59    pub source: AutoSearchPathSource,
60}
61
62impl AppService {
63    /// Collect auto-resolved `package.path` directories from all three
64    /// registry sources.
65    ///
66    /// # Arguments
67    ///
68    /// * `project_root` — optional project root string; if `None` (or if root
69    ///   resolution fails), `alc.toml` / `alc.local.toml` sources are skipped
70    ///   and only installed packages are returned.
71    ///
72    /// # Returns
73    ///
74    /// `(mapping, warnings)` where:
75    /// - `mapping` — one row per package, preserving per-package detail even
76    ///   when multiple packages share the same parent directory.
77    /// - `warnings` — non-fatal errors encountered during resolution (parse
78    ///   failures, canonicalize errors, etc.).
79    ///
80    /// # Errors
81    ///
82    /// Never returns `Err`; all errors are surfaced in the `warnings` vector.
83    pub(crate) fn collect_auto_search_paths(
84        &self,
85        project_root: Option<&str>,
86    ) -> (Vec<ResolvedSearchPath>, Vec<String>) {
87        let mut results: Vec<ResolvedSearchPath> = Vec::new();
88        let mut warnings: Vec<String> = Vec::new();
89        // Track parent dirs already added to avoid injecting the same dir
90        // multiple times into `package.path` (dir-level dedupe).
91        // Note: `results` still retains one row per package for diagnostic
92        // purposes — the dedupe only applies to the actual dirs injected.
93        let mut seen_dirs: HashSet<PathBuf> = HashSet::new();
94
95        // ── Source 1: ~/.algocline/packages/ sub-dirs ─────────────────────────
96        let app_dir = self.log_config.app_dir();
97        let pkg_dir = app_dir.packages_dir();
98        match std::fs::read_dir(&pkg_dir) {
99            Ok(entries) => {
100                for entry in entries.flatten() {
101                    let path = entry.path();
102                    // Only directories (including symlinks to dirs).
103                    let is_dir = path.metadata().map(|m| m.is_dir()).unwrap_or(false);
104                    if !is_dir {
105                        continue;
106                    }
107                    // Only include dirs that contain an `init.lua`.
108                    if !path.join("init.lua").exists() {
109                        continue;
110                    }
111                    let pkg_name = entry.file_name().to_string_lossy().into_owned();
112                    // The search dir is the parent of the pkg dir, i.e. pkg_dir
113                    // itself (so `require("pkg_name")` resolves to
114                    // `pkg_dir/pkg_name/init.lua`).
115                    let search_dir = pkg_dir.clone();
116                    results.push(ResolvedSearchPath {
117                        name: pkg_name,
118                        search_dir: search_dir.to_string_lossy().into_owned(),
119                        source: AutoSearchPathSource::Installed,
120                    });
121                    seen_dirs.insert(search_dir);
122                }
123            }
124            Err(e) => {
125                warnings.push(format!(
126                    "failed to read packages dir {}: {e}",
127                    pkg_dir.display()
128                ));
129            }
130        }
131
132        // ── Sources 2 + 3: alc.toml and alc.local.toml ───────────────────────
133        // Only available when project root can be resolved.
134        let resolved_root = self.resolve_root(project_root);
135        if let Some(ref root) = resolved_root {
136            // Source 2: alc.toml [packages] path entries
137            match load_alc_toml(root) {
138                Ok(Some(toml_data)) => {
139                    for (name, dep) in &toml_data.packages {
140                        let PackageDep::Path { path, .. } = dep else {
141                            continue;
142                        };
143                        let raw = std::path::Path::new(path);
144                        let abs = if raw.is_absolute() {
145                            raw.to_path_buf()
146                        } else {
147                            root.join(raw)
148                        };
149                        match abs.canonicalize() {
150                            Ok(canonical_pkg_dir) => {
151                                // The pkg_dir is the package directory itself
152                                // (e.g. `.../packages/swarm_frame`). The search
153                                // dir for `require` is its parent
154                                // (`.../packages/`).
155                                let search_dir = canonical_pkg_dir
156                                    .parent()
157                                    .map(|p| p.to_path_buf())
158                                    .unwrap_or_else(|| canonical_pkg_dir.clone());
159                                results.push(ResolvedSearchPath {
160                                    name: name.clone(),
161                                    search_dir: search_dir.to_string_lossy().into_owned(),
162                                    source: AutoSearchPathSource::AlcToml,
163                                });
164                                seen_dirs.insert(search_dir);
165                            }
166                            Err(e) => {
167                                warnings.push(format!(
168                                    "cannot canonicalize alc.toml path entry for '{}' ({}): {e}",
169                                    name,
170                                    abs.display()
171                                ));
172                            }
173                        }
174                    }
175                }
176                Ok(None) => {}
177                Err(e) => {
178                    warnings.push(format!(
179                        "failed to load alc.toml at {}: {e}",
180                        root.display()
181                    ));
182                }
183            }
184
185            // Source 3: alc.local.toml [packages] path entries
186            match load_alc_local_toml(root) {
187                Ok(Some(local_data)) => {
188                    for (name, dep) in &local_data.packages {
189                        let PackageDep::Path { path, .. } = dep else {
190                            continue;
191                        };
192                        let raw = std::path::Path::new(path);
193                        let abs = if raw.is_absolute() {
194                            raw.to_path_buf()
195                        } else {
196                            root.join(raw)
197                        };
198                        match abs.canonicalize() {
199                            Ok(canonical_pkg_dir) => {
200                                let search_dir = canonical_pkg_dir
201                                    .parent()
202                                    .map(|p| p.to_path_buf())
203                                    .unwrap_or_else(|| canonical_pkg_dir.clone());
204                                results.push(ResolvedSearchPath {
205                                    name: name.clone(),
206                                    search_dir: search_dir.to_string_lossy().into_owned(),
207                                    source: AutoSearchPathSource::AlcLocalToml,
208                                });
209                                seen_dirs.insert(search_dir);
210                            }
211                            Err(e) => {
212                                warnings.push(format!(
213                                    "cannot canonicalize alc.local.toml path entry for '{}' ({}): {e}",
214                                    name,
215                                    abs.display()
216                                ));
217                            }
218                        }
219                    }
220                }
221                Ok(None) => {}
222                Err(e) => {
223                    warnings.push(format!(
224                        "failed to load alc.local.toml at {}: {e}",
225                        root.display()
226                    ));
227                }
228            }
229        }
230        // When resolved_root is None: alc.toml / alc.local.toml are skipped
231        // gracefully (no warning added — Crux constraint 1 note).
232
233        (results, warnings)
234    }
235
236    /// Run mlua-lspec tests for a package, a single file, or inline code.
237    ///
238    /// Exactly one of `pkg`, `code_file`, `code` must be provided.
239    /// Zero or more than one returns a typed `Err`.
240    ///
241    /// # Arguments
242    ///
243    /// * `pkg` — installed package name. Spec files are discovered under
244    ///   `<pkg_root>/<spec_dir>/*_spec.lua` (default `spec_dir = "spec"`).
245    /// * `code_file` — absolute path to a single `.lua` test file.
246    /// * `code` — inline Lua source code containing lspec tests.
247    /// * `spec_dir` — subdirectory inside the pkg root for spec files
248    ///   (default `"spec"`). Only used when `pkg` is provided.
249    /// * `filter` — substring filter on spec file stems (only for `pkg`).
250    /// * `search_paths` — additional dirs prepended to `package.path` inside
251    ///   the Lua VM. These are appended *after* auto-resolved paths.
252    /// * `project_root` — optional project root for variant-scope resolution
253    ///   (`alc.local.toml`). Falls back to ancestor walk from cwd.
254    /// * `auto_search_paths` — when `true` (default) or `None`, auto-prepends
255    ///   parent dirs of all linked/installed packages (installed
256    ///   `~/.algocline/packages/`, `alc.toml` path entries, `alc.local.toml`
257    ///   path entries) to `package.path`. When `false`, no auto-resolve is
258    ///   performed. Resolved mapping is returned in the JSON response
259    ///   `resolved_search_paths` field.
260    ///
261    /// # Returns
262    ///
263    /// On success: JSON string with shape
264    /// `{passed, failed, pending, total, duration_ms, spec_files: [{path,
265    /// passed, failed, total, duration_ms, tests: [{suite, name, passed,
266    /// pending, error}]}], resolved_search_paths: [{name, search_dir,
267    /// source}], search_path_warnings?: [...]}`.
268    ///
269    /// # Errors
270    ///
271    /// Returns `Err(String)` for setup failures (VM init, pkg not found, zero
272    /// spec files, I/O errors, `spawn_blocking` panic). Per-spec Lua crashes
273    /// are absorbed, not propagated.
274    // 9 parameters are justified by the MCP wire shape: 3 mutually exclusive
275    // input sources (pkg / code_file / code) plus filtering/path/auto-resolve
276    // options.
277    #[allow(clippy::too_many_arguments)]
278    pub async fn pkg_test(
279        &self,
280        pkg: Option<String>,
281        code_file: Option<String>,
282        code: Option<String>,
283        spec_dir: Option<String>,
284        filter: Option<String>,
285        search_paths: Option<Vec<String>>,
286        project_root: Option<String>,
287        auto_search_paths: Option<bool>,
288    ) -> Result<String, String> {
289        // ── Crux constraint 1: exactly-one input exclusivity ──────────────────
290        let input_count = pkg.is_some() as u8 + code_file.is_some() as u8 + code.is_some() as u8;
291        if input_count != 1 {
292            return Err("pkg_test: provide exactly one of pkg, code_file, code".to_string());
293        }
294
295        let caller_search_paths: Vec<String> = search_paths.unwrap_or_default();
296
297        // ── Auto-resolve: collect parent dirs from all 3 registry sources ─────
298        // Crux constraint 2: when auto_search_paths == Some(false), skip
299        // entirely (zero I/O, zero injection).
300        let (resolved_mapping, search_path_warnings) = if auto_search_paths == Some(false) {
301            (Vec::new(), Vec::new())
302        } else {
303            self.collect_auto_search_paths(project_root.as_deref())
304        };
305
306        // Deduplicate auto-resolved parent dirs (dir-level, not pkg-level)
307        // to avoid duplicate entries in package.path.
308        let mut seen_auto_dirs: HashSet<&str> = HashSet::new();
309        let auto_dirs: Vec<String> = resolved_mapping
310            .iter()
311            .filter_map(|r| {
312                if seen_auto_dirs.insert(r.search_dir.as_str()) {
313                    Some(r.search_dir.clone())
314                } else {
315                    None
316                }
317            })
318            .collect();
319
320        if let Some(inline_code) = code {
321            // `code` path: single VM, inline source.
322            // Order: [auto..., caller...]
323            let mut search = auto_dirs;
324            search.extend(caller_search_paths);
325            let result_json = run_inline(inline_code, search).await?;
326            Ok(attach_resolved_meta(
327                result_json,
328                &resolved_mapping,
329                &search_path_warnings,
330            ))
331        } else if let Some(file_path) = code_file {
332            // `code_file` path: read file then run.
333            // Order: [file_parent, auto..., caller...]
334            let abs_path = PathBuf::from(&file_path);
335            let src = std::fs::read_to_string(&abs_path)
336                .map_err(|e| format!("pkg_test: failed to read {file_path}: {e}"))?;
337            let parent = abs_path
338                .parent()
339                .map(|p| p.to_string_lossy().into_owned())
340                .unwrap_or_default();
341            let chunk_name = format!("@{file_path}");
342            let mut paths = vec![parent];
343            paths.extend(auto_dirs);
344            paths.extend(caller_search_paths);
345            let result_json = run_single_spec(src, chunk_name, paths).await?;
346            Ok(attach_resolved_meta(
347                result_json,
348                &resolved_mapping,
349                &search_path_warnings,
350            ))
351        } else {
352            // `pkg` path: spec_dir scan.
353            // input_count == 1 and neither `code` nor `code_file` is Some,
354            // so `pkg` must be Some here by the exclusivity check above.
355            let Some(pkg_name) = pkg else {
356                unreachable!("pkg must be Some: input_count==1 and code/code_file are None")
357            };
358            let init_path = self
359                .pkg_resolve_init_path(&pkg_name, project_root.as_deref())
360                .map_err(|e| format!("pkg_test: {e}"))?
361                .ok_or_else(|| {
362                    format!(
363                        "pkg_test: package '{pkg_name}' not found in <project_root>/<name>/, alc.local.toml, or ~/.algocline/packages/"
364                    )
365                })?;
366            let pkg_root = init_path
367                .parent()
368                .map(|p| p.to_path_buf())
369                .unwrap_or_else(|| init_path.clone());
370
371            let spec_subdir = spec_dir.as_deref().unwrap_or("spec");
372            let spec_dir_path = pkg_root.join(spec_subdir);
373
374            // Collect *_spec.lua files deterministically.
375            let spec_files = collect_spec_files(&spec_dir_path, filter.as_deref())?;
376
377            // Order: [pkg_root, auto..., caller...]
378            let pkg_root_str = pkg_root.to_string_lossy().into_owned();
379            let mut search = vec![pkg_root_str];
380            search.extend(auto_dirs);
381            search.extend(caller_search_paths);
382
383            let result_json = run_pkg_specs(spec_files, search).await?;
384            Ok(attach_resolved_meta(
385                result_json,
386                &resolved_mapping,
387                &search_path_warnings,
388            ))
389        }
390    }
391}
392
393/// Attach `resolved_search_paths` (and optionally `search_path_warnings`) to a
394/// JSON result string returned by the internal run helpers.
395///
396/// # Arguments
397///
398/// * `result_json` — the JSON string produced by `run_inline`,
399///   `run_single_spec`, or `run_pkg_specs`.
400/// * `resolved_mapping` — per-package mapping collected by
401///   `collect_auto_search_paths`.
402/// * `warnings` — non-fatal warnings from auto-resolution.
403///
404/// # Returns
405///
406/// A new JSON string with `resolved_search_paths` added. When `warnings` is
407/// non-empty, `search_path_warnings` is also added.
408fn attach_resolved_meta(
409    result_json: String,
410    resolved_mapping: &[ResolvedSearchPath],
411    warnings: &[String],
412) -> String {
413    let mut obj: Value = match serde_json::from_str(&result_json) {
414        Ok(v) => v,
415        Err(e) => {
416            warn!("attach_resolved_meta: failed to parse result JSON: {e}");
417            return result_json;
418        }
419    };
420    if let Some(map) = obj.as_object_mut() {
421        // Crux constraint 3: resolved_search_paths must appear in the JSON
422        // return value as a structured field (not only in logs).
423        let rows: Vec<Value> = resolved_mapping
424            .iter()
425            .map(|r| {
426                json!({
427                    "name": r.name,
428                    "search_dir": r.search_dir,
429                    "source": serde_json::to_value(&r.source)
430                        .unwrap_or(Value::String(String::new()))
431                })
432            })
433            .collect();
434        map.insert("resolved_search_paths".to_string(), Value::Array(rows));
435        if !warnings.is_empty() {
436            map.insert(
437                "search_path_warnings".to_string(),
438                Value::Array(warnings.iter().map(|w| Value::String(w.clone())).collect()),
439            );
440        }
441    }
442    obj.to_string()
443}
444
445// ─── Helpers ──────────────────────────────────────────────────────────────────
446
447/// Collect `*_spec.lua` entries from `spec_dir_path`, sorted deterministically.
448///
449/// Returns `Err` if the directory does not exist or zero files remain after
450/// applying `filter`.
451///
452/// # Arguments
453///
454/// * `spec_dir_path` — absolute path to the spec directory.
455/// * `filter` — optional substring matched against the file stem (e.g.
456///   `"shape"` matches `shape_spec.lua`).
457///
458/// # Errors
459///
460/// Returns `Err(String)` when the directory is absent or no spec files match.
461fn collect_spec_files(spec_dir_path: &Path, filter: Option<&str>) -> Result<Vec<PathBuf>, String> {
462    if !spec_dir_path.exists() {
463        return Err(format!(
464            "pkg_test: no spec files found in {} (looked for *_spec.lua)",
465            spec_dir_path.display()
466        ));
467    }
468
469    let read_result = std::fs::read_dir(spec_dir_path).map_err(|e| {
470        format!(
471            "pkg_test: failed to read spec dir {}: {e}",
472            spec_dir_path.display()
473        )
474    })?;
475
476    let mut set = BTreeSet::new();
477    for entry in read_result.flatten() {
478        let fname = entry.file_name();
479        let name_str = fname.to_string_lossy();
480        if name_str.ends_with("_spec.lua") {
481            if let Some(f) = filter {
482                let stem = name_str.trim_end_matches("_spec.lua");
483                if !stem.contains(f) {
484                    continue;
485                }
486            }
487            set.insert(entry.path());
488        }
489    }
490
491    if set.is_empty() {
492        return Err(format!(
493            "pkg_test: no spec files found in {} (looked for *_spec.lua)",
494            spec_dir_path.display()
495        ));
496    }
497
498    Ok(set.into_iter().collect())
499}
500
501/// Run inline Lua code as a single lspec test.
502///
503/// `framework::run_tests` pre-loads the `lust` global — no separate
504/// `framework::register` call is needed (and calling it would risk double
505/// registration).
506///
507/// # Errors
508///
509/// Returns `Err` if the blocking task panics.
510async fn run_inline(code: String, search_paths: Vec<String>) -> Result<String, String> {
511    run_single_spec(code, "@inline.lua".to_string(), search_paths).await
512}
513
514/// Execute one spec source string inside a fresh mlua VM on a blocking thread.
515///
516/// Per-spec Lua crashes are represented as a failing test entry (crux
517/// constraint 2 — per-spec absorption) rather than propagating the error.
518///
519/// # Arguments
520///
521/// * `code` — Lua source to execute.
522/// * `chunk_name` — Lua chunk name convention: `"@inline.lua"` or
523///   `"@<abs_path>"`.
524/// * `search_paths` — directories prepended to `package.path` inside the VM.
525///
526/// # Errors
527///
528/// Run a single Lua spec inside an `alc_pkg_test` sandbox VM.
529///
530/// Equivalent to [`mlua_lspec::framework::run_tests`], but the VM is
531/// pre-loaded with the full `alc.*` primitive surface via
532/// [`algocline_engine::bridge::install_for_pkg_test`] plus the Pure-Lua
533/// mock layer (`with_alc` / `alc_mock` / `alc.spy`).  This guarantees
534/// `production primitive surface ⊆ test sandbox primitive surface`, so
535/// specs can call `alc.json_encode` etc. without inline workarounds
536/// (fix for issue 7dc77cc7 — alc_run / alc_pkg_test VM狭広逆転).
537///
538/// # Errors
539///
540/// Returns the same `String` error shape as `framework::run_tests` for
541/// per-spec crashes (registration / search-path / load / collect-results).
542fn run_pkg_test_in_sandbox(
543    code: &str,
544    chunk_name: &str,
545    search_paths: &[&str],
546) -> Result<mlua_lspec::TestSummary, String> {
547    let lua = Lua::new();
548
549    engine_bridge::install_for_pkg_test(&lua)
550        .map_err(|e| format!("Failed to install alc.* sandbox: {e}"))?;
551    framework::register(&lua).map_err(|e| format!("Failed to register test framework: {e}"))?;
552    doubles::register(&lua).map_err(|e| format!("Failed to register test doubles: {e}"))?;
553
554    // Prepend search paths (mirrors mlua_lspec::framework::prepend_search_paths,
555    // which is private).
556    if !search_paths.is_empty() {
557        let package: mlua::Table = lua
558            .globals()
559            .get("package")
560            .map_err(|e| format!("Failed to get package table: {e}"))?;
561        let current: String = package
562            .get("path")
563            .map_err(|e| format!("Failed to get package.path: {e}"))?;
564        let mut prefix = String::new();
565        for dir in search_paths {
566            let dir = dir.trim_end_matches('/');
567            prefix.push_str(dir);
568            prefix.push_str("/?.lua;");
569            prefix.push_str(dir);
570            prefix.push_str("/?/init.lua;");
571        }
572        prefix.push_str(&current);
573        package
574            .set("path", prefix)
575            .map_err(|e| format!("Failed to set package.path: {e}"))?;
576    }
577
578    // Suppress lust's print() output so stdio MCP transports stay clean.
579    lua.globals()
580        .set(
581            "print",
582            lua.create_function(|_, _: Variadic<LuaValue>| Ok(()))
583                .map_err(|e| format!("Failed to override print: {e}"))?,
584        )
585        .map_err(|e| format!("Failed to override print: {e}"))?;
586
587    lua.load(code)
588        .set_name(chunk_name)
589        .exec()
590        .map_err(|e| format!("Test execution error: {e}"))?;
591
592    let summary =
593        framework::collect_results(&lua).map_err(|e| format!("Failed to collect results: {e}"))?;
594
595    Ok(summary)
596}
597
598/// Returns `Err` when the task panics.
599async fn run_single_spec(
600    code: String,
601    chunk_name: String,
602    search_paths: Vec<String>,
603) -> Result<String, String> {
604    let total_start = Instant::now();
605
606    let (spec_file_entry, agg_passed, agg_failed) = tokio::task::spawn_blocking(move || {
607        // mlua::Lua is constructed per-spec inside `run_pkg_test_in_sandbox`
608        // (which installs `alc.*` + the mock layer).  No external VM here.
609
610        let search_refs: Vec<&str> = search_paths.iter().map(|s| s.as_str()).collect();
611        let spec_start = Instant::now();
612
613        let (tests_json, passed, failed) =
614            match run_pkg_test_in_sandbox(&code, &chunk_name, &search_refs) {
615                Ok(summary) => {
616                    let tests: Vec<Value> = summary
617                        .tests
618                        .iter()
619                        .map(|t| {
620                            json!({
621                                "suite": t.suite,
622                                "name": t.name,
623                                "passed": t.passed,
624                                "pending": false,
625                                "error": t.error
626                            })
627                        })
628                        .collect();
629                    (tests, summary.passed, summary.failed)
630                }
631                Err(e) => {
632                    // Per-spec crash: absorbed, contributes 1 failing entry.
633                    let tests = vec![json!({
634                        "suite": chunk_name,
635                        "name": "<top-level>",
636                        "passed": false,
637                        "pending": false,
638                        "error": e.to_string()
639                    })];
640                    (tests, 0usize, 1usize)
641                }
642            };
643
644        let spec_duration_ms = spec_start.elapsed().as_millis() as u64;
645        let total_tests = passed + failed;
646
647        let spec_entry = json!({
648            "path": chunk_name,
649            "passed": passed,
650            "failed": failed,
651            "total": total_tests,
652            "duration_ms": spec_duration_ms,
653            "tests": tests_json
654        });
655
656        (spec_entry, passed, failed)
657    })
658    .await
659    .map_err(|e| format!("pkg_test: blocking task panicked: {e}"))?;
660
661    let duration_ms = total_start.elapsed().as_millis() as u64;
662
663    let result = json!({
664        "passed": agg_passed,
665        "failed": agg_failed,
666        "pending": 0,
667        "total": agg_passed + agg_failed,
668        "duration_ms": duration_ms,
669        "spec_files": [spec_file_entry]
670    });
671
672    Ok(result.to_string())
673}
674
675/// Run multiple spec files sequentially inside a single `spawn_blocking` task.
676///
677/// Each spec file gets its own fresh mlua VM. Per-spec crashes are absorbed
678/// (crux constraint 2). Aggregate counts and per-file entries are returned.
679///
680/// # Arguments
681///
682/// * `spec_files` — sorted list of absolute paths to `*_spec.lua` files.
683/// * `search_paths` — directories prepended to `package.path` inside each VM.
684///
685/// # Errors
686///
687/// Returns `Err` if the task panics.
688async fn run_pkg_specs(
689    spec_files: Vec<PathBuf>,
690    search_paths: Vec<String>,
691) -> Result<String, String> {
692    let total_start = Instant::now();
693
694    let (spec_entries, agg_passed, agg_failed) = tokio::task::spawn_blocking(move || {
695        let mut entries: Vec<Value> = Vec::new();
696        let mut total_passed = 0usize;
697        let mut total_failed = 0usize;
698
699        for spec_path in &spec_files {
700            let path_str = spec_path.to_string_lossy().to_string();
701            let code = match std::fs::read_to_string(spec_path) {
702                Ok(s) => s,
703                Err(e) => {
704                    // I/O failure for this spec file: absorbed as failing entry.
705                    entries.push(json!({
706                        "path": path_str,
707                        "passed": 0,
708                        "failed": 1,
709                        "total": 1,
710                        "duration_ms": 0,
711                        "tests": [{
712                            "suite": path_str,
713                            "name": "<top-level>",
714                            "passed": false,
715                            "pending": false,
716                            "error": format!("pkg_test: failed to read {path_str}: {e}")
717                        }]
718                    }));
719                    total_failed += 1;
720                    continue;
721                }
722            };
723
724            let chunk_name = format!("@{path_str}");
725            let search_refs: Vec<&str> = search_paths.iter().map(|s| s.as_str()).collect();
726
727            // mlua::Lua is constructed per-spec inside `run_pkg_test_in_sandbox`.
728            let spec_start = Instant::now();
729
730            let (tests_json, passed, failed) =
731                match run_pkg_test_in_sandbox(&code, &chunk_name, &search_refs) {
732                    Ok(summary) => {
733                        let tests: Vec<Value> = summary
734                            .tests
735                            .iter()
736                            .map(|t| {
737                                json!({
738                                    "suite": t.suite,
739                                    "name": t.name,
740                                    "passed": t.passed,
741                                    "pending": false,
742                                    "error": t.error
743                                })
744                            })
745                            .collect();
746                        (tests, summary.passed, summary.failed)
747                    }
748                    Err(e) => {
749                        // Per-spec crash: absorbed per crux constraint 2.
750                        let tests = vec![json!({
751                            "suite": chunk_name,
752                            "name": "<top-level>",
753                            "passed": false,
754                            "pending": false,
755                            "error": e.to_string()
756                        })];
757                        (tests, 0usize, 1usize)
758                    }
759                };
760
761            let spec_duration_ms = spec_start.elapsed().as_millis() as u64;
762            let total_tests = passed + failed;
763
764            entries.push(json!({
765                "path": path_str,
766                "passed": passed,
767                "failed": failed,
768                "total": total_tests,
769                "duration_ms": spec_duration_ms,
770                "tests": tests_json
771            }));
772
773            total_passed += passed;
774            total_failed += failed;
775        }
776
777        (entries, total_passed, total_failed)
778    })
779    .await
780    .map_err(|e| format!("pkg_test: blocking task panicked: {e}"))?;
781
782    let duration_ms = total_start.elapsed().as_millis() as u64;
783
784    let result = json!({
785        "passed": agg_passed,
786        "failed": agg_failed,
787        "pending": 0,
788        "total": agg_passed + agg_failed,
789        "duration_ms": duration_ms,
790        "spec_files": spec_entries
791    });
792
793    Ok(result.to_string())
794}
795
796// ─── Unit tests ───────────────────────────────────────────────────────────────
797
798#[cfg(test)]
799mod tests {
800    use std::fs;
801
802    use super::super::super::test_support::make_app_service_at;
803
804    // T1: happy path — inline code with a passing test.
805    #[tokio::test]
806    async fn inline_passing_test_returns_passed_one() {
807        let tmp = tempfile::tempdir().unwrap();
808        let svc = make_app_service_at(tmp.path().to_path_buf()).await;
809
810        let lua_code = concat!(
811            "local describe, it, expect = lust.describe, lust.it, lust.expect\n",
812            "describe('suite', function()\n",
813            "    it('passes', function() expect(1).to.equal(1) end)\n",
814            "end)\n",
815        )
816        .to_string();
817
818        let result = svc
819            .pkg_test(None, None, Some(lua_code), None, None, None, None, None)
820            .await
821            .expect("pkg_test should succeed");
822
823        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
824        assert_eq!(json["passed"], 1, "expected 1 passed: {result}");
825        assert_eq!(json["failed"], 0, "expected 0 failed: {result}");
826        assert_eq!(json["pending"], 0, "expected 0 pending: {result}");
827    }
828
829    // T2: edge case — inline code with a failing assertion returns Ok with
830    // failed=1 (per-spec crash absorption).
831    #[tokio::test]
832    async fn inline_failing_test_absorbed_returns_ok() {
833        let tmp = tempfile::tempdir().unwrap();
834        let svc = make_app_service_at(tmp.path().to_path_buf()).await;
835
836        let lua_code = concat!(
837            "local describe, it, expect = lust.describe, lust.it, lust.expect\n",
838            "describe('suite', function()\n",
839            "    it('fails', function() expect(1).to.equal(2) end)\n",
840            "end)\n",
841        )
842        .to_string();
843
844        let result = svc
845            .pkg_test(None, None, Some(lua_code), None, None, None, None, None)
846            .await
847            .expect("pkg_test returns Ok even for failing tests");
848
849        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
850        assert_eq!(json["failed"], 1, "expected 1 failed: {result}");
851        assert_eq!(json["passed"], 0, "expected 0 passed: {result}");
852    }
853
854    // T3: error path — zero inputs triggers typed Err (crux constraint 1).
855    #[tokio::test]
856    async fn zero_inputs_returns_exclusivity_error() {
857        let tmp = tempfile::tempdir().unwrap();
858        let svc = make_app_service_at(tmp.path().to_path_buf()).await;
859
860        let err = svc
861            .pkg_test(None, None, None, None, None, None, None, None)
862            .await
863            .expect_err("should return Err for zero inputs");
864
865        assert_eq!(err, "pkg_test: provide exactly one of pkg, code_file, code");
866    }
867
868    // T3: error path — multiple inputs triggers typed Err (crux constraint 1).
869    #[tokio::test]
870    async fn multiple_inputs_returns_exclusivity_error() {
871        let tmp = tempfile::tempdir().unwrap();
872        let svc = make_app_service_at(tmp.path().to_path_buf()).await;
873
874        let err = svc
875            .pkg_test(
876                Some("mypkg".into()),
877                None,
878                Some("return 1".into()),
879                None,
880                None,
881                None,
882                None,
883                None,
884            )
885            .await
886            .expect_err("should return Err for multiple inputs");
887
888        assert_eq!(err, "pkg_test: provide exactly one of pkg, code_file, code");
889    }
890
891    // T3: error path — pkg not found returns typed Err (crux constraint 2
892    // propagated tier).
893    #[tokio::test]
894    async fn pkg_not_found_returns_typed_error() {
895        let tmp = tempfile::tempdir().unwrap();
896        let svc = make_app_service_at(tmp.path().to_path_buf()).await;
897
898        let err = svc
899            .pkg_test(
900                Some("nonexistent_pkg_xyz".into()),
901                None,
902                None,
903                None,
904                None,
905                None,
906                None,
907                None,
908            )
909            .await
910            .expect_err("should return Err for missing pkg");
911
912        assert!(
913            err.contains("nonexistent_pkg_xyz"),
914            "error must mention pkg name: {err}"
915        );
916        assert!(err.contains("not found"), "error must say not found: {err}");
917    }
918
919    // T2: edge — code_file with non-existent path returns typed Err.
920    #[tokio::test]
921    async fn code_file_not_found_returns_typed_error() {
922        let tmp = tempfile::tempdir().unwrap();
923        let svc = make_app_service_at(tmp.path().to_path_buf()).await;
924
925        let err = svc
926            .pkg_test(
927                None,
928                Some("/nonexistent/path/missing_spec.lua".into()),
929                None,
930                None,
931                None,
932                None,
933                None,
934                None,
935            )
936            .await
937            .expect_err("should return Err for missing code_file");
938
939        assert!(
940            err.contains("failed to read"),
941            "error must describe I/O failure: {err}"
942        );
943    }
944
945    // T1 (new): opt-out — auto_search_paths=false returns resolved_search_paths: []
946    // Crux constraint 2: zero auto-resolved paths when opt-out.
947    #[tokio::test]
948    async fn auto_search_paths_false_returns_empty_resolved_mapping() {
949        let tmp = tempfile::tempdir().unwrap();
950        let svc = make_app_service_at(tmp.path().to_path_buf()).await;
951
952        let lua_code = concat!(
953            "local describe, it, expect = lust.describe, lust.it, lust.expect\n",
954            "describe('s', function()\n",
955            "    it('ok', function() expect(1).to.equal(1) end)\n",
956            "end)\n",
957        )
958        .to_string();
959
960        let result = svc
961            .pkg_test(
962                None,
963                None,
964                Some(lua_code),
965                None,
966                None,
967                None,
968                None,
969                Some(false),
970            )
971            .await
972            .expect("pkg_test should succeed with auto_search_paths=false");
973
974        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
975        // Crux 2: resolved_search_paths must be empty array when opt-out.
976        assert!(
977            json["resolved_search_paths"].is_array(),
978            "resolved_search_paths must be present: {result}"
979        );
980        assert_eq!(
981            json["resolved_search_paths"].as_array().unwrap().len(),
982            0,
983            "resolved_search_paths must be empty when auto_search_paths=false: {result}"
984        );
985        // Crux 3: key must be present even when empty.
986        assert!(
987            json.get("resolved_search_paths").is_some(),
988            "resolved_search_paths key must be present: {result}"
989        );
990    }
991
992    // T1 (new): installed source — a pkg with init.lua in packages/ is auto-resolved
993    // and appears in resolved_search_paths with source="installed".
994    // Crux constraint 1: installed source is included.
995    #[tokio::test]
996    async fn installed_pkg_appears_in_resolved_search_paths() {
997        let tmp = tempfile::tempdir().unwrap();
998        let app_root = tmp.path().to_path_buf();
999
1000        // Create a dummy installed package: {app_root}/packages/mypkg/init.lua
1001        let pkg_dir = app_root.join("packages").join("mypkg");
1002        fs::create_dir_all(&pkg_dir).unwrap();
1003        fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
1004
1005        let svc = make_app_service_at(app_root.clone()).await;
1006
1007        let lua_code = concat!(
1008            "local describe, it, expect = lust.describe, lust.it, lust.expect\n",
1009            "describe('s', function()\n",
1010            "    it('ok', function() expect(1).to.equal(1) end)\n",
1011            "end)\n",
1012        )
1013        .to_string();
1014
1015        let result = svc
1016            .pkg_test(None, None, Some(lua_code), None, None, None, None, None)
1017            .await
1018            .expect("pkg_test should succeed");
1019
1020        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
1021        let rows = json["resolved_search_paths"]
1022            .as_array()
1023            .expect("resolved_search_paths must be array");
1024
1025        let installed_row = rows
1026            .iter()
1027            .find(|r| r["source"] == "installed" && r["name"] == "mypkg");
1028        assert!(
1029            installed_row.is_some(),
1030            "mypkg with source=installed must appear in resolved_search_paths: {result}"
1031        );
1032
1033        // The search_dir should be the packages/ directory (parent of mypkg/).
1034        let expected_dir = app_root.join("packages").to_string_lossy().into_owned();
1035        let actual_dir = installed_row.unwrap()["search_dir"].as_str().unwrap_or("");
1036        assert_eq!(
1037            actual_dir, expected_dir,
1038            "search_dir must be the packages/ parent dir: {result}"
1039        );
1040    }
1041
1042    // T1 (new): alc.toml source — a path entry in alc.toml is resolved and
1043    // appears in resolved_search_paths with source="alc.toml".
1044    // Crux constraint 1: alc.toml source is included.
1045    #[tokio::test]
1046    async fn alc_toml_path_entry_appears_in_resolved_search_paths() {
1047        let tmp = tempfile::tempdir().unwrap();
1048        let project_root = tmp.path().to_path_buf();
1049
1050        // Create an external pkg directory structure:
1051        // {project_root}/ext_pkgs/ext_pkg/init.lua
1052        let ext_pkgs = project_root.join("ext_pkgs");
1053        let ext_pkg_dir = ext_pkgs.join("ext_pkg");
1054        fs::create_dir_all(&ext_pkg_dir).unwrap();
1055        fs::write(ext_pkg_dir.join("init.lua"), "return {}").unwrap();
1056
1057        // Write alc.toml with a path entry pointing to ext_pkgs/ext_pkg.
1058        let alc_toml_content = "[packages.ext_pkg]\npath = \"ext_pkgs/ext_pkg\"\n";
1059        fs::write(project_root.join("alc.toml"), alc_toml_content).unwrap();
1060
1061        // AppService rooted at project_root (so app_dir = project_root, and
1062        // project root resolution = project_root when passed explicitly).
1063        let svc = make_app_service_at(project_root.clone()).await;
1064
1065        let lua_code = concat!(
1066            "local describe, it, expect = lust.describe, lust.it, lust.expect\n",
1067            "describe('s', function()\n",
1068            "    it('ok', function() expect(1).to.equal(1) end)\n",
1069            "end)\n",
1070        )
1071        .to_string();
1072
1073        let project_root_str = project_root.to_string_lossy().into_owned();
1074        let result = svc
1075            .pkg_test(
1076                None,
1077                None,
1078                Some(lua_code),
1079                None,
1080                None,
1081                None,
1082                Some(project_root_str),
1083                None,
1084            )
1085            .await
1086            .expect("pkg_test should succeed");
1087
1088        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
1089        let rows = json["resolved_search_paths"]
1090            .as_array()
1091            .expect("resolved_search_paths must be array");
1092
1093        let toml_row = rows
1094            .iter()
1095            .find(|r| r["source"] == "alc.toml" && r["name"] == "ext_pkg");
1096        assert!(
1097            toml_row.is_some(),
1098            "ext_pkg with source=alc.toml must appear in resolved_search_paths: {result}"
1099        );
1100
1101        // search_dir should be the parent of ext_pkg_dir (i.e. ext_pkgs/).
1102        let expected_parent = ext_pkgs
1103            .canonicalize()
1104            .unwrap()
1105            .to_string_lossy()
1106            .into_owned();
1107        let actual_dir = toml_row.unwrap()["search_dir"].as_str().unwrap_or("");
1108        assert_eq!(
1109            actual_dir, expected_parent,
1110            "search_dir must be the canonicalized parent of the pkg dir: {result}"
1111        );
1112    }
1113
1114    // T1 (new): alc.local.toml source — a path entry in alc.local.toml appears
1115    // in resolved_search_paths with source="alc.local.toml".
1116    // Crux constraint 1: alc.local.toml source is included.
1117    #[tokio::test]
1118    async fn alc_local_toml_path_entry_appears_in_resolved_search_paths() {
1119        let tmp = tempfile::tempdir().unwrap();
1120        let project_root = tmp.path().to_path_buf();
1121
1122        // Create variant pkg: {project_root}/variant_pkgs/variant_pkg/init.lua
1123        let variant_pkgs = project_root.join("variant_pkgs");
1124        let variant_pkg_dir = variant_pkgs.join("variant_pkg");
1125        fs::create_dir_all(&variant_pkg_dir).unwrap();
1126        fs::write(variant_pkg_dir.join("init.lua"), "return {}").unwrap();
1127
1128        // Write alc.local.toml with a path entry.
1129        // Use [packages.name] table syntax (not [[packages.name]] array) for PackageDep::Path.
1130        let alc_local_content = "[packages.variant_pkg]\npath = \"variant_pkgs/variant_pkg\"\n";
1131        fs::write(project_root.join("alc.local.toml"), alc_local_content).unwrap();
1132
1133        let svc = make_app_service_at(project_root.clone()).await;
1134
1135        let lua_code = concat!(
1136            "local describe, it, expect = lust.describe, lust.it, lust.expect\n",
1137            "describe('s', function()\n",
1138            "    it('ok', function() expect(1).to.equal(1) end)\n",
1139            "end)\n",
1140        )
1141        .to_string();
1142
1143        let project_root_str = project_root.to_string_lossy().into_owned();
1144        let result = svc
1145            .pkg_test(
1146                None,
1147                None,
1148                Some(lua_code),
1149                None,
1150                None,
1151                None,
1152                Some(project_root_str),
1153                None,
1154            )
1155            .await
1156            .expect("pkg_test should succeed");
1157
1158        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
1159        let rows = json["resolved_search_paths"]
1160            .as_array()
1161            .expect("resolved_search_paths must be array");
1162
1163        let local_row = rows
1164            .iter()
1165            .find(|r| r["source"] == "alc.local.toml" && r["name"] == "variant_pkg");
1166        assert!(
1167            local_row.is_some(),
1168            "variant_pkg with source=alc.local.toml must appear in resolved_search_paths: {result}"
1169        );
1170
1171        let expected_parent = variant_pkgs
1172            .canonicalize()
1173            .unwrap()
1174            .to_string_lossy()
1175            .into_owned();
1176        let actual_dir = local_row.unwrap()["search_dir"].as_str().unwrap_or("");
1177        assert_eq!(
1178            actual_dir, expected_parent,
1179            "search_dir must be the canonicalized parent: {result}"
1180        );
1181    }
1182
1183    // T2 (new): prepend order — collect_auto_search_paths helper returns
1184    // installed entries before caller-supplied search_paths are appended.
1185    // This tests the order invariant: [auto..., caller...] for the inline path.
1186    // Crux constraint 1: auto is prepended before caller entries.
1187    #[tokio::test]
1188    async fn auto_paths_prepended_before_caller_search_paths() {
1189        let tmp = tempfile::tempdir().unwrap();
1190        let app_root = tmp.path().to_path_buf();
1191
1192        // Create a dummy installed pkg so auto-resolve returns at least one dir.
1193        let pkg_dir = app_root.join("packages").join("autopkg");
1194        std::fs::create_dir_all(&pkg_dir).unwrap();
1195        std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
1196
1197        let svc = make_app_service_at(app_root.clone()).await;
1198
1199        // Call collect_auto_search_paths directly to verify the mapping.
1200        let (mapping, warnings) = svc.collect_auto_search_paths(None);
1201        assert!(warnings.is_empty(), "no warnings expected: {warnings:?}");
1202
1203        // The installed source must be present.
1204        let found = mapping.iter().any(|r| r.name == "autopkg");
1205        assert!(
1206            found,
1207            "autopkg must appear in auto-resolved mapping: {mapping:?}"
1208        );
1209
1210        // The search_dir must be the packages/ parent (not the pkg dir itself).
1211        let expected_parent = app_root.join("packages").to_string_lossy().into_owned();
1212        let row = mapping.iter().find(|r| r.name == "autopkg").unwrap();
1213        assert_eq!(
1214            row.search_dir, expected_parent,
1215            "search_dir must be packages/ parent: {row:?}"
1216        );
1217    }
1218}