1use 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#[derive(Debug, Clone, Serialize)]
38#[serde(rename_all = "snake_case")]
39pub(crate) enum AutoSearchPathSource {
40 Installed,
42 #[serde(rename = "alc.toml")]
44 AlcToml,
45 #[serde(rename = "alc.local.toml")]
47 AlcLocalToml,
48}
49
50#[derive(Debug, Clone, Serialize)]
52pub(crate) struct ResolvedSearchPath {
53 pub name: String,
55 pub search_dir: String,
57 pub source: AutoSearchPathSource,
59}
60
61impl AppService {
62 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 let mut seen_dirs: HashSet<PathBuf> = HashSet::new();
93
94 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 let is_dir = path.metadata().map(|m| m.is_dir()).unwrap_or(false);
103 if !is_dir {
104 continue;
105 }
106 if !path.join("init.lua").exists() {
108 continue;
109 }
110 let pkg_name = entry.file_name().to_string_lossy().into_owned();
111 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 let resolved_root = self.resolve_root(project_root);
134 if let Some(ref root) = resolved_root {
135 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 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 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 (results, warnings)
233 }
234
235 #[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 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 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 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 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 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 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 let spec_files = collect_spec_files(&spec_dir_path, filter.as_deref())?;
375
376 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
392fn 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 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
444fn 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
500async 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
513async 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 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 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 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
607async 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 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 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 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 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#[cfg(test)]
735mod tests {
736 use std::fs;
737
738 use super::super::super::test_support::make_app_service_at;
739
740 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 assert!(
923 json.get("resolved_search_paths").is_some(),
924 "resolved_search_paths key must be present: {result}"
925 );
926 }
927
928 #[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 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 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 #[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 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 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 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 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 #[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 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 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 #[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 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 let (mapping, warnings) = svc.collect_auto_search_paths(None);
1137 assert!(warnings.is_empty(), "no warnings expected: {warnings:?}");
1138
1139 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 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}