use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::time::Instant;
use mlua::Lua;
use mlua_lspec::framework;
use serde_json::{json, Value};
use super::super::AppService;
impl AppService {
#[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>,
) -> 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 extra_search_paths: Vec<String> = search_paths.unwrap_or_default();
if let Some(inline_code) = code {
run_inline(inline_code, extra_search_paths).await
} 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(extra_search_paths);
run_single_spec(src, chunk_name, paths).await
} 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)
.map_err(|e| format!("pkg_test: {e}"))?
.ok_or_else(|| {
format!(
"pkg_test: package '{pkg_name}' not found in alc.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(extra_search_paths);
run_pkg_specs(spec_files, search).await
}
}
}
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 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)
.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)
.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)
.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,
)
.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,
)
.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,
)
.await
.expect_err("should return Err for missing code_file");
assert!(
err.contains("failed to read"),
"error must describe I/O failure: {err}"
);
}
}