use std::collections::{BTreeSet, HashSet};
use std::path::{Path, PathBuf};
use std::time::Instant;
use mlua::Lua;
use mlua_lspec::framework;
use serde::Serialize;
use serde_json::{json, Value};
use tracing::warn;
use super::super::alc_toml::{load_alc_local_toml, load_alc_toml, PackageDep};
use super::super::AppService;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum AutoSearchPathSource {
Installed,
#[serde(rename = "alc.toml")]
AlcToml,
#[serde(rename = "alc.local.toml")]
AlcLocalToml,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct ResolvedSearchPath {
pub name: String,
pub search_dir: String,
pub source: AutoSearchPathSource,
}
impl AppService {
pub(crate) fn collect_auto_search_paths(
&self,
project_root: Option<&str>,
) -> (Vec<ResolvedSearchPath>, Vec<String>) {
let mut results: Vec<ResolvedSearchPath> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let mut seen_dirs: HashSet<PathBuf> = HashSet::new();
let app_dir = self.log_config.app_dir();
let pkg_dir = app_dir.packages_dir();
match std::fs::read_dir(&pkg_dir) {
Ok(entries) => {
for entry in entries.flatten() {
let path = entry.path();
let is_dir = path.metadata().map(|m| m.is_dir()).unwrap_or(false);
if !is_dir {
continue;
}
if !path.join("init.lua").exists() {
continue;
}
let pkg_name = entry.file_name().to_string_lossy().into_owned();
let search_dir = pkg_dir.clone();
results.push(ResolvedSearchPath {
name: pkg_name,
search_dir: search_dir.to_string_lossy().into_owned(),
source: AutoSearchPathSource::Installed,
});
seen_dirs.insert(search_dir);
}
}
Err(e) => {
warnings.push(format!(
"failed to read packages dir {}: {e}",
pkg_dir.display()
));
}
}
let resolved_root = self.resolve_root(project_root);
if let Some(ref root) = resolved_root {
match load_alc_toml(root) {
Ok(Some(toml_data)) => {
for (name, dep) in &toml_data.packages {
let PackageDep::Path { path, .. } = dep else {
continue;
};
let raw = std::path::Path::new(path);
let abs = if raw.is_absolute() {
raw.to_path_buf()
} else {
root.join(raw)
};
match abs.canonicalize() {
Ok(canonical_pkg_dir) => {
let search_dir = canonical_pkg_dir
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| canonical_pkg_dir.clone());
results.push(ResolvedSearchPath {
name: name.clone(),
search_dir: search_dir.to_string_lossy().into_owned(),
source: AutoSearchPathSource::AlcToml,
});
seen_dirs.insert(search_dir);
}
Err(e) => {
warnings.push(format!(
"cannot canonicalize alc.toml path entry for '{}' ({}): {e}",
name,
abs.display()
));
}
}
}
}
Ok(None) => {}
Err(e) => {
warnings.push(format!(
"failed to load alc.toml at {}: {e}",
root.display()
));
}
}
match load_alc_local_toml(root) {
Ok(Some(local_data)) => {
for (name, dep) in &local_data.packages {
let PackageDep::Path { path, .. } = dep else {
continue;
};
let raw = std::path::Path::new(path);
let abs = if raw.is_absolute() {
raw.to_path_buf()
} else {
root.join(raw)
};
match abs.canonicalize() {
Ok(canonical_pkg_dir) => {
let search_dir = canonical_pkg_dir
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| canonical_pkg_dir.clone());
results.push(ResolvedSearchPath {
name: name.clone(),
search_dir: search_dir.to_string_lossy().into_owned(),
source: AutoSearchPathSource::AlcLocalToml,
});
seen_dirs.insert(search_dir);
}
Err(e) => {
warnings.push(format!(
"cannot canonicalize alc.local.toml path entry for '{}' ({}): {e}",
name,
abs.display()
));
}
}
}
}
Ok(None) => {}
Err(e) => {
warnings.push(format!(
"failed to load alc.local.toml at {}: {e}",
root.display()
));
}
}
}
(results, warnings)
}
#[allow(clippy::too_many_arguments)]
pub async fn pkg_test(
&self,
pkg: Option<String>,
code_file: Option<String>,
code: Option<String>,
spec_dir: Option<String>,
filter: Option<String>,
search_paths: Option<Vec<String>>,
project_root: Option<String>,
auto_search_paths: Option<bool>,
) -> Result<String, String> {
let input_count = pkg.is_some() as u8 + code_file.is_some() as u8 + code.is_some() as u8;
if input_count != 1 {
return Err("pkg_test: provide exactly one of pkg, code_file, code".to_string());
}
let caller_search_paths: Vec<String> = search_paths.unwrap_or_default();
let (resolved_mapping, search_path_warnings) = if auto_search_paths == Some(false) {
(Vec::new(), Vec::new())
} else {
self.collect_auto_search_paths(project_root.as_deref())
};
let mut seen_auto_dirs: HashSet<&str> = HashSet::new();
let auto_dirs: Vec<String> = resolved_mapping
.iter()
.filter_map(|r| {
if seen_auto_dirs.insert(r.search_dir.as_str()) {
Some(r.search_dir.clone())
} else {
None
}
})
.collect();
if let Some(inline_code) = code {
let mut search = auto_dirs;
search.extend(caller_search_paths);
let result_json = run_inline(inline_code, search).await?;
Ok(attach_resolved_meta(
result_json,
&resolved_mapping,
&search_path_warnings,
))
} else if let Some(file_path) = code_file {
let abs_path = PathBuf::from(&file_path);
let src = std::fs::read_to_string(&abs_path)
.map_err(|e| format!("pkg_test: failed to read {file_path}: {e}"))?;
let parent = abs_path
.parent()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let chunk_name = format!("@{file_path}");
let mut paths = vec![parent];
paths.extend(auto_dirs);
paths.extend(caller_search_paths);
let result_json = run_single_spec(src, chunk_name, paths).await?;
Ok(attach_resolved_meta(
result_json,
&resolved_mapping,
&search_path_warnings,
))
} else {
let Some(pkg_name) = pkg else {
unreachable!("pkg must be Some: input_count==1 and code/code_file are None")
};
let init_path = self
.pkg_resolve_init_path(&pkg_name, project_root.as_deref())
.map_err(|e| format!("pkg_test: {e}"))?
.ok_or_else(|| {
format!(
"pkg_test: package '{pkg_name}' not found in <project_root>/<name>/, alc.local.toml, or ~/.algocline/packages/"
)
})?;
let pkg_root = init_path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| init_path.clone());
let spec_subdir = spec_dir.as_deref().unwrap_or("spec");
let spec_dir_path = pkg_root.join(spec_subdir);
let spec_files = collect_spec_files(&spec_dir_path, filter.as_deref())?;
let pkg_root_str = pkg_root.to_string_lossy().into_owned();
let mut search = vec![pkg_root_str];
search.extend(auto_dirs);
search.extend(caller_search_paths);
let result_json = run_pkg_specs(spec_files, search).await?;
Ok(attach_resolved_meta(
result_json,
&resolved_mapping,
&search_path_warnings,
))
}
}
}
fn attach_resolved_meta(
result_json: String,
resolved_mapping: &[ResolvedSearchPath],
warnings: &[String],
) -> String {
let mut obj: Value = match serde_json::from_str(&result_json) {
Ok(v) => v,
Err(e) => {
warn!("attach_resolved_meta: failed to parse result JSON: {e}");
return result_json;
}
};
if let Some(map) = obj.as_object_mut() {
let rows: Vec<Value> = resolved_mapping
.iter()
.map(|r| {
json!({
"name": r.name,
"search_dir": r.search_dir,
"source": serde_json::to_value(&r.source)
.unwrap_or(Value::String(String::new()))
})
})
.collect();
map.insert("resolved_search_paths".to_string(), Value::Array(rows));
if !warnings.is_empty() {
map.insert(
"search_path_warnings".to_string(),
Value::Array(warnings.iter().map(|w| Value::String(w.clone())).collect()),
);
}
}
obj.to_string()
}
fn collect_spec_files(spec_dir_path: &Path, filter: Option<&str>) -> Result<Vec<PathBuf>, String> {
if !spec_dir_path.exists() {
return Err(format!(
"pkg_test: no spec files found in {} (looked for *_spec.lua)",
spec_dir_path.display()
));
}
let read_result = std::fs::read_dir(spec_dir_path).map_err(|e| {
format!(
"pkg_test: failed to read spec dir {}: {e}",
spec_dir_path.display()
)
})?;
let mut set = BTreeSet::new();
for entry in read_result.flatten() {
let fname = entry.file_name();
let name_str = fname.to_string_lossy();
if name_str.ends_with("_spec.lua") {
if let Some(f) = filter {
let stem = name_str.trim_end_matches("_spec.lua");
if !stem.contains(f) {
continue;
}
}
set.insert(entry.path());
}
}
if set.is_empty() {
return Err(format!(
"pkg_test: no spec files found in {} (looked for *_spec.lua)",
spec_dir_path.display()
));
}
Ok(set.into_iter().collect())
}
async fn run_inline(code: String, search_paths: Vec<String>) -> Result<String, String> {
run_single_spec(code, "@inline.lua".to_string(), search_paths).await
}
async fn run_single_spec(
code: String,
chunk_name: String,
search_paths: Vec<String>,
) -> Result<String, String> {
let total_start = Instant::now();
let (spec_file_entry, agg_passed, agg_failed) = tokio::task::spawn_blocking(move || {
let lua = Lua::new();
let search_refs: Vec<&str> = search_paths.iter().map(|s| s.as_str()).collect();
let spec_start = Instant::now();
let (tests_json, passed, failed) =
match framework::run_tests(&code, &chunk_name, &search_refs) {
Ok(summary) => {
let tests: Vec<Value> = summary
.tests
.iter()
.map(|t| {
json!({
"suite": t.suite,
"name": t.name,
"passed": t.passed,
"pending": false,
"error": t.error
})
})
.collect();
(tests, summary.passed, summary.failed)
}
Err(e) => {
let tests = vec![json!({
"suite": chunk_name,
"name": "<top-level>",
"passed": false,
"pending": false,
"error": e.to_string()
})];
(tests, 0usize, 1usize)
}
};
let spec_duration_ms = spec_start.elapsed().as_millis() as u64;
let total_tests = passed + failed;
let spec_entry = json!({
"path": chunk_name,
"passed": passed,
"failed": failed,
"total": total_tests,
"duration_ms": spec_duration_ms,
"tests": tests_json
});
drop(lua);
(spec_entry, passed, failed)
})
.await
.map_err(|e| format!("pkg_test: blocking task panicked: {e}"))?;
let duration_ms = total_start.elapsed().as_millis() as u64;
let result = json!({
"passed": agg_passed,
"failed": agg_failed,
"pending": 0,
"total": agg_passed + agg_failed,
"duration_ms": duration_ms,
"spec_files": [spec_file_entry]
});
Ok(result.to_string())
}
async fn run_pkg_specs(
spec_files: Vec<PathBuf>,
search_paths: Vec<String>,
) -> Result<String, String> {
let total_start = Instant::now();
let (spec_entries, agg_passed, agg_failed) = tokio::task::spawn_blocking(move || {
let mut entries: Vec<Value> = Vec::new();
let mut total_passed = 0usize;
let mut total_failed = 0usize;
for spec_path in &spec_files {
let path_str = spec_path.to_string_lossy().to_string();
let code = match std::fs::read_to_string(spec_path) {
Ok(s) => s,
Err(e) => {
entries.push(json!({
"path": path_str,
"passed": 0,
"failed": 1,
"total": 1,
"duration_ms": 0,
"tests": [{
"suite": path_str,
"name": "<top-level>",
"passed": false,
"pending": false,
"error": format!("pkg_test: failed to read {path_str}: {e}")
}]
}));
total_failed += 1;
continue;
}
};
let chunk_name = format!("@{path_str}");
let search_refs: Vec<&str> = search_paths.iter().map(|s| s.as_str()).collect();
let lua = Lua::new();
let spec_start = Instant::now();
let (tests_json, passed, failed) =
match framework::run_tests(&code, &chunk_name, &search_refs) {
Ok(summary) => {
let tests: Vec<Value> = summary
.tests
.iter()
.map(|t| {
json!({
"suite": t.suite,
"name": t.name,
"passed": t.passed,
"pending": false,
"error": t.error
})
})
.collect();
(tests, summary.passed, summary.failed)
}
Err(e) => {
let tests = vec![json!({
"suite": chunk_name,
"name": "<top-level>",
"passed": false,
"pending": false,
"error": e.to_string()
})];
(tests, 0usize, 1usize)
}
};
let spec_duration_ms = spec_start.elapsed().as_millis() as u64;
let total_tests = passed + failed;
entries.push(json!({
"path": path_str,
"passed": passed,
"failed": failed,
"total": total_tests,
"duration_ms": spec_duration_ms,
"tests": tests_json
}));
total_passed += passed;
total_failed += failed;
drop(lua);
}
(entries, total_passed, total_failed)
})
.await
.map_err(|e| format!("pkg_test: blocking task panicked: {e}"))?;
let duration_ms = total_start.elapsed().as_millis() as u64;
let result = json!({
"passed": agg_passed,
"failed": agg_failed,
"pending": 0,
"total": agg_passed + agg_failed,
"duration_ms": duration_ms,
"spec_files": spec_entries
});
Ok(result.to_string())
}
#[cfg(test)]
mod tests {
use std::fs;
use super::super::super::test_support::make_app_service_at;
#[tokio::test]
async fn inline_passing_test_returns_passed_one() {
let tmp = tempfile::tempdir().unwrap();
let svc = make_app_service_at(tmp.path().to_path_buf()).await;
let lua_code = concat!(
"local describe, it, expect = lust.describe, lust.it, lust.expect\n",
"describe('suite', function()\n",
" it('passes', function() expect(1).to.equal(1) end)\n",
"end)\n",
)
.to_string();
let result = svc
.pkg_test(None, None, Some(lua_code), None, None, None, None, None)
.await
.expect("pkg_test should succeed");
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(json["passed"], 1, "expected 1 passed: {result}");
assert_eq!(json["failed"], 0, "expected 0 failed: {result}");
assert_eq!(json["pending"], 0, "expected 0 pending: {result}");
}
#[tokio::test]
async fn inline_failing_test_absorbed_returns_ok() {
let tmp = tempfile::tempdir().unwrap();
let svc = make_app_service_at(tmp.path().to_path_buf()).await;
let lua_code = concat!(
"local describe, it, expect = lust.describe, lust.it, lust.expect\n",
"describe('suite', function()\n",
" it('fails', function() expect(1).to.equal(2) end)\n",
"end)\n",
)
.to_string();
let result = svc
.pkg_test(None, None, Some(lua_code), None, None, None, None, None)
.await
.expect("pkg_test returns Ok even for failing tests");
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(json["failed"], 1, "expected 1 failed: {result}");
assert_eq!(json["passed"], 0, "expected 0 passed: {result}");
}
#[tokio::test]
async fn zero_inputs_returns_exclusivity_error() {
let tmp = tempfile::tempdir().unwrap();
let svc = make_app_service_at(tmp.path().to_path_buf()).await;
let err = svc
.pkg_test(None, None, None, None, None, None, None, None)
.await
.expect_err("should return Err for zero inputs");
assert_eq!(err, "pkg_test: provide exactly one of pkg, code_file, code");
}
#[tokio::test]
async fn multiple_inputs_returns_exclusivity_error() {
let tmp = tempfile::tempdir().unwrap();
let svc = make_app_service_at(tmp.path().to_path_buf()).await;
let err = svc
.pkg_test(
Some("mypkg".into()),
None,
Some("return 1".into()),
None,
None,
None,
None,
None,
)
.await
.expect_err("should return Err for multiple inputs");
assert_eq!(err, "pkg_test: provide exactly one of pkg, code_file, code");
}
#[tokio::test]
async fn pkg_not_found_returns_typed_error() {
let tmp = tempfile::tempdir().unwrap();
let svc = make_app_service_at(tmp.path().to_path_buf()).await;
let err = svc
.pkg_test(
Some("nonexistent_pkg_xyz".into()),
None,
None,
None,
None,
None,
None,
None,
)
.await
.expect_err("should return Err for missing pkg");
assert!(
err.contains("nonexistent_pkg_xyz"),
"error must mention pkg name: {err}"
);
assert!(err.contains("not found"), "error must say not found: {err}");
}
#[tokio::test]
async fn code_file_not_found_returns_typed_error() {
let tmp = tempfile::tempdir().unwrap();
let svc = make_app_service_at(tmp.path().to_path_buf()).await;
let err = svc
.pkg_test(
None,
Some("/nonexistent/path/missing_spec.lua".into()),
None,
None,
None,
None,
None,
None,
)
.await
.expect_err("should return Err for missing code_file");
assert!(
err.contains("failed to read"),
"error must describe I/O failure: {err}"
);
}
#[tokio::test]
async fn auto_search_paths_false_returns_empty_resolved_mapping() {
let tmp = tempfile::tempdir().unwrap();
let svc = make_app_service_at(tmp.path().to_path_buf()).await;
let lua_code = concat!(
"local describe, it, expect = lust.describe, lust.it, lust.expect\n",
"describe('s', function()\n",
" it('ok', function() expect(1).to.equal(1) end)\n",
"end)\n",
)
.to_string();
let result = svc
.pkg_test(
None,
None,
Some(lua_code),
None,
None,
None,
None,
Some(false),
)
.await
.expect("pkg_test should succeed with auto_search_paths=false");
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert!(
json["resolved_search_paths"].is_array(),
"resolved_search_paths must be present: {result}"
);
assert_eq!(
json["resolved_search_paths"].as_array().unwrap().len(),
0,
"resolved_search_paths must be empty when auto_search_paths=false: {result}"
);
assert!(
json.get("resolved_search_paths").is_some(),
"resolved_search_paths key must be present: {result}"
);
}
#[tokio::test]
async fn installed_pkg_appears_in_resolved_search_paths() {
let tmp = tempfile::tempdir().unwrap();
let app_root = tmp.path().to_path_buf();
let pkg_dir = app_root.join("packages").join("mypkg");
fs::create_dir_all(&pkg_dir).unwrap();
fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
let svc = make_app_service_at(app_root.clone()).await;
let lua_code = concat!(
"local describe, it, expect = lust.describe, lust.it, lust.expect\n",
"describe('s', function()\n",
" it('ok', function() expect(1).to.equal(1) end)\n",
"end)\n",
)
.to_string();
let result = svc
.pkg_test(None, None, Some(lua_code), None, None, None, None, None)
.await
.expect("pkg_test should succeed");
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let rows = json["resolved_search_paths"]
.as_array()
.expect("resolved_search_paths must be array");
let installed_row = rows
.iter()
.find(|r| r["source"] == "installed" && r["name"] == "mypkg");
assert!(
installed_row.is_some(),
"mypkg with source=installed must appear in resolved_search_paths: {result}"
);
let expected_dir = app_root.join("packages").to_string_lossy().into_owned();
let actual_dir = installed_row.unwrap()["search_dir"].as_str().unwrap_or("");
assert_eq!(
actual_dir, expected_dir,
"search_dir must be the packages/ parent dir: {result}"
);
}
#[tokio::test]
async fn alc_toml_path_entry_appears_in_resolved_search_paths() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path().to_path_buf();
let ext_pkgs = project_root.join("ext_pkgs");
let ext_pkg_dir = ext_pkgs.join("ext_pkg");
fs::create_dir_all(&ext_pkg_dir).unwrap();
fs::write(ext_pkg_dir.join("init.lua"), "return {}").unwrap();
let alc_toml_content = "[packages.ext_pkg]\npath = \"ext_pkgs/ext_pkg\"\n";
fs::write(project_root.join("alc.toml"), alc_toml_content).unwrap();
let svc = make_app_service_at(project_root.clone()).await;
let lua_code = concat!(
"local describe, it, expect = lust.describe, lust.it, lust.expect\n",
"describe('s', function()\n",
" it('ok', function() expect(1).to.equal(1) end)\n",
"end)\n",
)
.to_string();
let project_root_str = project_root.to_string_lossy().into_owned();
let result = svc
.pkg_test(
None,
None,
Some(lua_code),
None,
None,
None,
Some(project_root_str),
None,
)
.await
.expect("pkg_test should succeed");
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let rows = json["resolved_search_paths"]
.as_array()
.expect("resolved_search_paths must be array");
let toml_row = rows
.iter()
.find(|r| r["source"] == "alc.toml" && r["name"] == "ext_pkg");
assert!(
toml_row.is_some(),
"ext_pkg with source=alc.toml must appear in resolved_search_paths: {result}"
);
let expected_parent = ext_pkgs
.canonicalize()
.unwrap()
.to_string_lossy()
.into_owned();
let actual_dir = toml_row.unwrap()["search_dir"].as_str().unwrap_or("");
assert_eq!(
actual_dir, expected_parent,
"search_dir must be the canonicalized parent of the pkg dir: {result}"
);
}
#[tokio::test]
async fn alc_local_toml_path_entry_appears_in_resolved_search_paths() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path().to_path_buf();
let variant_pkgs = project_root.join("variant_pkgs");
let variant_pkg_dir = variant_pkgs.join("variant_pkg");
fs::create_dir_all(&variant_pkg_dir).unwrap();
fs::write(variant_pkg_dir.join("init.lua"), "return {}").unwrap();
let alc_local_content = "[packages.variant_pkg]\npath = \"variant_pkgs/variant_pkg\"\n";
fs::write(project_root.join("alc.local.toml"), alc_local_content).unwrap();
let svc = make_app_service_at(project_root.clone()).await;
let lua_code = concat!(
"local describe, it, expect = lust.describe, lust.it, lust.expect\n",
"describe('s', function()\n",
" it('ok', function() expect(1).to.equal(1) end)\n",
"end)\n",
)
.to_string();
let project_root_str = project_root.to_string_lossy().into_owned();
let result = svc
.pkg_test(
None,
None,
Some(lua_code),
None,
None,
None,
Some(project_root_str),
None,
)
.await
.expect("pkg_test should succeed");
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let rows = json["resolved_search_paths"]
.as_array()
.expect("resolved_search_paths must be array");
let local_row = rows
.iter()
.find(|r| r["source"] == "alc.local.toml" && r["name"] == "variant_pkg");
assert!(
local_row.is_some(),
"variant_pkg with source=alc.local.toml must appear in resolved_search_paths: {result}"
);
let expected_parent = variant_pkgs
.canonicalize()
.unwrap()
.to_string_lossy()
.into_owned();
let actual_dir = local_row.unwrap()["search_dir"].as_str().unwrap_or("");
assert_eq!(
actual_dir, expected_parent,
"search_dir must be the canonicalized parent: {result}"
);
}
#[tokio::test]
async fn auto_paths_prepended_before_caller_search_paths() {
let tmp = tempfile::tempdir().unwrap();
let app_root = tmp.path().to_path_buf();
let pkg_dir = app_root.join("packages").join("autopkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
let svc = make_app_service_at(app_root.clone()).await;
let (mapping, warnings) = svc.collect_auto_search_paths(None);
assert!(warnings.is_empty(), "no warnings expected: {warnings:?}");
let found = mapping.iter().any(|r| r.name == "autopkg");
assert!(
found,
"autopkg must appear in auto-resolved mapping: {mapping:?}"
);
let expected_parent = app_root.join("packages").to_string_lossy().into_owned();
let row = mapping.iter().find(|r| r.name == "autopkg").unwrap();
assert_eq!(
row.search_dir, expected_parent,
"search_dir must be packages/ parent: {row:?}"
);
}
}