1use 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#[derive(Debug, Clone, Serialize)]
39#[serde(rename_all = "snake_case")]
40pub(crate) enum AutoSearchPathSource {
41 Installed,
43 #[serde(rename = "alc.toml")]
45 AlcToml,
46 #[serde(rename = "alc.local.toml")]
48 AlcLocalToml,
49}
50
51#[derive(Debug, Clone, Serialize)]
53pub(crate) struct ResolvedSearchPath {
54 pub name: String,
56 pub search_dir: String,
58 pub source: AutoSearchPathSource,
60}
61
62impl AppService {
63 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 let mut seen_dirs: HashSet<PathBuf> = HashSet::new();
94
95 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 let is_dir = path.metadata().map(|m| m.is_dir()).unwrap_or(false);
104 if !is_dir {
105 continue;
106 }
107 if !path.join("init.lua").exists() {
109 continue;
110 }
111 let pkg_name = entry.file_name().to_string_lossy().into_owned();
112 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 let resolved_root = self.resolve_root(project_root);
135 if let Some(ref root) = resolved_root {
136 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 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 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 (results, warnings)
234 }
235
236 #[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 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 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 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 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 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 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 let spec_files = collect_spec_files(&spec_dir_path, filter.as_deref())?;
376
377 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
393fn 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 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
445fn 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
501async 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
514fn 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 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(¤t);
573 package
574 .set("path", prefix)
575 .map_err(|e| format!("Failed to set package.path: {e}"))?;
576 }
577
578 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
598async 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 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 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
675async 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 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 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 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#[cfg(test)]
799mod tests {
800 use std::fs;
801
802 use super::super::super::test_support::make_app_service_at;
803
804 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 assert!(
987 json.get("resolved_search_paths").is_some(),
988 "resolved_search_paths key must be present: {result}"
989 );
990 }
991
992 #[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 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 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 #[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 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 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 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 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 #[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 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 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 #[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 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 let (mapping, warnings) = svc.collect_auto_search_paths(None);
1201 assert!(warnings.is_empty(), "no warnings expected: {warnings:?}");
1202
1203 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 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}