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