use crate::service::list_opts::ListOpts;
use crate::service::lockfile::{load_lockfile, LockFile, LockPackage};
use crate::service::source::PackageSource;
use crate::service::test_support::{
make_app_service, make_app_service_at, make_app_service_at_with_search_paths,
make_app_service_with_search_paths,
};
fn opts() -> ListOpts {
ListOpts {
limit: None,
sort: None,
filter: None,
fields: None,
verbose: None,
}
}
fn filter_map(v: serde_json::Value) -> std::collections::HashMap<String, serde_json::Value> {
v.as_object()
.expect("filter must be a JSON object")
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
fn make_lock_with_pkg(name: &str) -> LockFile {
LockFile {
version: 1,
packages: vec![LockPackage {
name: name.to_string(),
version: None,
source: PackageSource::Installed,
}],
}
}
async fn pkg_list_summary(
svc: &crate::service::AppService,
project_root: Option<String>,
) -> String {
svc.pkg_list(project_root, opts()).await.unwrap()
}
async fn pkg_list_full(svc: &crate::service::AppService, project_root: Option<String>) -> String {
svc.pkg_list(
project_root,
ListOpts {
verbose: Some("full".to_string()),
..opts()
},
)
.await
.unwrap()
}
#[tokio::test]
async fn pkg_list_with_project() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
std::fs::write(
project_root.join("alc.toml"),
"[packages]\nmy_local_pkg = \"*\"\n",
)
.unwrap();
let pkg_dir = project_root.join("my_local_pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
let lock = LockFile {
version: 1,
packages: vec![LockPackage {
name: "my_local_pkg".to_string(),
version: None,
source: PackageSource::Path {
path: "my_local_pkg".to_string(),
},
}],
};
crate::service::lockfile::save_lockfile(project_root, &lock).unwrap();
let svc = make_app_service().await;
let result = svc
.pkg_list(
Some(project_root.to_string_lossy().to_string()),
ListOpts {
verbose: Some("full".to_string()),
..opts()
},
)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let packages = json["packages"].as_array().unwrap();
let project_pkg = packages
.iter()
.find(|p| p["name"] == "my_local_pkg")
.expect("my_local_pkg not found in pkg_list output");
assert_eq!(project_pkg["scope"], "project");
assert_eq!(project_pkg["source_type"], "path");
assert_eq!(project_pkg["active"], true);
assert!(json["project_root"].is_string());
assert!(json["lockfile_path"].is_string());
}
#[tokio::test]
async fn pkg_list_no_project_root() {
let svc = make_app_service().await;
let result = pkg_list_summary(&svc, None).await;
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert!(json["packages"].is_array());
}
async fn build_project_with_pkgs(
project_root: &std::path::Path,
names: &[&str],
) -> crate::service::AppService {
let mut alc_toml = String::from("[packages]\n");
let mut lock_pkgs = Vec::new();
for name in names {
alc_toml.push_str(&format!("{name} = {{ path = \"{name}\" }}\n"));
let pkg_dir = project_root.join(name);
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
lock_pkgs.push(LockPackage {
name: (*name).to_string(),
version: None,
source: PackageSource::Path {
path: (*name).to_string(),
},
});
}
std::fs::write(project_root.join("alc.toml"), alc_toml).unwrap();
let lock = LockFile {
version: 1,
packages: lock_pkgs,
};
crate::service::lockfile::save_lockfile(project_root, &lock).unwrap();
make_app_service().await
}
#[tokio::test]
async fn pkg_list_summary_excludes_install_source() {
let tmp = tempfile::tempdir().unwrap();
let svc = build_project_with_pkgs(tmp.path(), &["alpha"]).await;
let result = pkg_list_summary(&svc, Some(tmp.path().to_string_lossy().to_string())).await;
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let packages = json["packages"].as_array().unwrap();
let pkg = packages.iter().find(|p| p["name"] == "alpha").unwrap();
let map = pkg.as_object().unwrap();
assert!(
!map.contains_key("install_source"),
"install_source must be absent from summary preset, got: {map:?}"
);
}
#[tokio::test]
async fn pkg_list_summary_includes_resolved_source_path() {
let tmp = tempfile::tempdir().unwrap();
let svc = build_project_with_pkgs(tmp.path(), &["alpha"]).await;
let result = pkg_list_summary(&svc, Some(tmp.path().to_string_lossy().to_string())).await;
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let packages = json["packages"].as_array().unwrap();
let pkg = packages.iter().find(|p| p["name"] == "alpha").unwrap();
assert!(
pkg["resolved_source_path"].is_string(),
"resolved_source_path must appear under summary preset"
);
}
#[tokio::test]
async fn pkg_list_verbose_full_includes_install_source() {
let tmp = tempfile::tempdir().unwrap();
let svc = build_project_with_pkgs(tmp.path(), &["alpha"]).await;
let result = pkg_list_full(&svc, Some(tmp.path().to_string_lossy().to_string())).await;
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let packages = json["packages"].as_array().unwrap();
let pkg = packages.iter().find(|p| p["name"] == "alpha").unwrap();
let map = pkg.as_object().unwrap();
assert!(
map.contains_key("path"),
"path must reappear under verbose=full, got: {map:?}"
);
assert!(
map.contains_key("source_type"),
"source_type must reappear under verbose=full, got: {map:?}"
);
}
#[tokio::test]
async fn pkg_list_fields_beats_verbose() {
let tmp = tempfile::tempdir().unwrap();
let svc = build_project_with_pkgs(tmp.path(), &["alpha"]).await;
let result = svc
.pkg_list(
Some(tmp.path().to_string_lossy().to_string()),
ListOpts {
fields: Some(vec!["name".to_string()]),
verbose: Some("full".to_string()),
..opts()
},
)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let packages = json["packages"].as_array().unwrap();
let pkg = packages.iter().find(|p| p["name"] == "alpha").unwrap();
let map = pkg.as_object().unwrap();
assert_eq!(map.len(), 1, "exact projection: only 'name' should survive");
assert!(map.contains_key("name"));
}
#[tokio::test]
async fn pkg_list_sort_active_desc_installed_at() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
let svc = build_project_with_pkgs(project_root, &["alpha", "beta"]).await;
let variant_dir = tmp.path().join("variant_src").join("alpha");
std::fs::create_dir_all(&variant_dir).unwrap();
std::fs::write(variant_dir.join("init.lua"), "return {}").unwrap();
std::fs::write(
project_root.join("alc.local.toml"),
format!(
"[packages]\nalpha = {{ path = \"{}\" }}\n",
variant_dir.display()
),
)
.unwrap();
let result = pkg_list_summary(&svc, Some(project_root.to_string_lossy().to_string())).await;
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let packages = json["packages"].as_array().unwrap();
let mut seen_inactive = false;
for pkg in packages {
let active = pkg["active"].as_bool().unwrap_or(false);
if !active {
seen_inactive = true;
} else if seen_inactive {
panic!(
"default sort should put active=true before active=false; \
saw active=true after active=false in {packages:?}"
);
}
}
}
#[tokio::test]
async fn pkg_list_filter_by_scope() {
let tmp = tempfile::tempdir().unwrap();
let svc = build_project_with_pkgs(tmp.path(), &["alpha", "beta"]).await;
let filter = serde_json::json!({"scope": "global"});
let result = svc
.pkg_list(
Some(tmp.path().to_string_lossy().to_string()),
ListOpts {
filter: Some(filter_map(filter)),
..opts()
},
)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let packages = json["packages"].as_array().unwrap();
for pkg in packages {
assert_ne!(
pkg["scope"], "project",
"filter scope=global must exclude project entries, got: {pkg:?}"
);
}
}
#[tokio::test]
async fn pkg_list_filter_by_active_true() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
let svc = build_project_with_pkgs(project_root, &["alpha"]).await;
let variant_dir = tmp.path().join("variant_src").join("alpha");
std::fs::create_dir_all(&variant_dir).unwrap();
std::fs::write(variant_dir.join("init.lua"), "return {}").unwrap();
std::fs::write(
project_root.join("alc.local.toml"),
format!(
"[packages]\nalpha = {{ path = \"{}\" }}\n",
variant_dir.display()
),
)
.unwrap();
let filter = serde_json::json!({"active": true});
let result = svc
.pkg_list(
Some(project_root.to_string_lossy().to_string()),
ListOpts {
filter: Some(filter_map(filter)),
..opts()
},
)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let packages = json["packages"].as_array().unwrap();
for pkg in packages {
assert_eq!(
pkg["active"], true,
"filter active=true must exclude inactive entries, got: {pkg:?}"
);
}
}
#[tokio::test]
async fn pkg_list_limit_truncates() {
let tmp = tempfile::tempdir().unwrap();
let svc = build_project_with_pkgs(
tmp.path(),
&[
"pkg_a", "pkg_b", "pkg_c", "pkg_d", "pkg_e", "pkg_f", "pkg_g",
],
)
.await;
let result = svc
.pkg_list(
Some(tmp.path().to_string_lossy().to_string()),
ListOpts {
limit: Some(5),
..opts()
},
)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let packages = json["packages"].as_array().unwrap();
assert!(
packages.len() <= 5,
"limit=5 must truncate the array, got {} entries",
packages.len()
);
}
#[tokio::test]
async fn pkg_list_limit_preserves_top_level_shape() {
let tmp = tempfile::tempdir().unwrap();
let svc = build_project_with_pkgs(tmp.path(), &["pkg_a", "pkg_b", "pkg_c"]).await;
let result = svc
.pkg_list(
Some(tmp.path().to_string_lossy().to_string()),
ListOpts {
limit: Some(1),
..opts()
},
)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert!(
json["search_paths"].is_array(),
"search_paths must remain after limit truncation"
);
assert!(
json["project_root"].is_string(),
"project_root must remain after limit truncation"
);
assert!(
json["lockfile_path"].is_string(),
"lockfile_path must remain after limit truncation"
);
let packages = json["packages"].as_array().unwrap();
assert!(packages.len() <= 1);
}
#[tokio::test]
async fn pkg_list_unknown_field_silently_skipped() {
let tmp = tempfile::tempdir().unwrap();
let svc = build_project_with_pkgs(tmp.path(), &["alpha"]).await;
let result = svc
.pkg_list(
Some(tmp.path().to_string_lossy().to_string()),
ListOpts {
fields: Some(vec!["name".to_string(), "bogus_field".to_string()]),
..opts()
},
)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let packages = json["packages"].as_array().unwrap();
let pkg = packages.iter().find(|p| p["name"] == "alpha").unwrap();
let map = pkg.as_object().unwrap();
assert!(map.contains_key("name"));
assert!(
!map.contains_key("bogus_field"),
"unknown field must be silently skipped, got: {map:?}"
);
assert_eq!(
map.len(),
1,
"only known fields should appear, got: {map:?}"
);
}
#[tokio::test]
async fn pkg_list_invalid_sort_returns_error() {
let tmp = tempfile::tempdir().unwrap();
let svc = build_project_with_pkgs(tmp.path(), &["alpha"]).await;
let result = svc
.pkg_list(
Some(tmp.path().to_string_lossy().to_string()),
ListOpts {
sort: Some("-".to_string()),
..opts()
},
)
.await;
assert!(
result.is_err(),
"bare '-' sort string must be rejected, got: {result:?}"
);
}
#[tokio::test]
async fn pkg_list_invalid_verbose_returns_error() {
let tmp = tempfile::tempdir().unwrap();
let svc = build_project_with_pkgs(tmp.path(), &["alpha"]).await;
let result = svc
.pkg_list(
Some(tmp.path().to_string_lossy().to_string()),
ListOpts {
verbose: Some("fat".to_string()),
..opts()
},
)
.await;
assert!(
result.is_err(),
"verbose='fat' must be rejected, got: {result:?}"
);
}
#[tokio::test]
async fn pkg_remove_project_scope() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
std::fs::write(
project_root.join("alc.toml"),
"[packages]\nmy_local_pkg = \"*\"\n",
)
.unwrap();
let pkg_dir = project_root.join("my_local_pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
let lock = LockFile {
version: 1,
packages: vec![LockPackage {
name: "my_local_pkg".to_string(),
version: None,
source: PackageSource::Path {
path: "my_local_pkg".to_string(),
},
}],
};
crate::service::lockfile::save_lockfile(project_root, &lock).unwrap();
let svc = make_app_service().await;
let result = svc
.pkg_remove(
"my_local_pkg",
Some(project_root.to_string_lossy().to_string()),
None, None, )
.await
.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(json["removed"], "my_local_pkg");
assert!(json["alc_toml"].is_string());
assert!(json["alc_lock"].is_string());
assert!(pkg_dir.exists(), "physical directory was deleted");
let lock_after = load_lockfile(project_root).unwrap().unwrap();
assert!(
lock_after.packages.is_empty(),
"alc.lock still contains the entry"
);
}
#[tokio::test]
async fn pkg_remove_project_scope_not_found_returns_error() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
std::fs::write(
project_root.join("alc.toml"),
"[packages]\nother_pkg = \"*\"\n",
)
.unwrap();
let lock = make_lock_with_pkg("other_pkg");
crate::service::lockfile::save_lockfile(project_root, &lock).unwrap();
let svc = make_app_service().await;
let result = svc
.pkg_remove(
"nonexistent_pkg",
Some(project_root.to_string_lossy().to_string()),
None, None, )
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found in alc.lock"));
}
#[tokio::test]
async fn pkg_remove_global_scope_removes_manifest_entry() {
use crate::service::manifest::{load_manifest, record_install};
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let app_dir = crate::service::test_support::test_app_dir(home);
record_install(
&app_dir,
"ghost_pkg",
Some("0.1.0"),
crate::service::source::PackageSource::Path {
path: "/tmp/ghost_source".to_string(),
},
)
.unwrap();
assert!(load_manifest(&app_dir)
.unwrap()
.packages
.contains_key("ghost_pkg"));
let svc = make_app_service_at(home.to_path_buf()).await;
let result = svc
.pkg_remove(
"ghost_pkg",
None, None, Some("global".to_string()),
)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(json["removed"], "ghost_pkg");
assert_eq!(json["scope"], "global");
assert!(json["installed_json"].is_string());
assert!(
!load_manifest(&app_dir)
.unwrap()
.packages
.contains_key("ghost_pkg"),
"global manifest still contains the entry"
);
}
#[tokio::test]
async fn pkg_remove_global_scope_not_found_returns_error() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let svc = make_app_service_at(home.to_path_buf()).await;
let result = svc
.pkg_remove("never_installed", None, None, Some("global".to_string()))
.await;
let err = result.expect_err("expected Err");
assert!(
err.contains("not found in global manifest"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn pkg_remove_global_scope_preserves_physical_dir() {
use crate::service::manifest::record_install;
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let pkg_dir = home.join("packages").join("kept");
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
let app_dir = crate::service::test_support::test_app_dir(home);
record_install(
&app_dir,
"kept",
Some("0.1.0"),
crate::service::source::PackageSource::Path {
path: "/tmp/kept_source".to_string(),
},
)
.unwrap();
let svc = make_app_service_at(home.to_path_buf()).await;
svc.pkg_remove("kept", None, None, Some("global".to_string()))
.await
.unwrap();
assert!(
pkg_dir.exists(),
"global scope must not delete ~/.algocline/packages/{{name}}/"
);
assert!(
pkg_dir.join("init.lua").exists(),
"init.lua should still be present"
);
}
#[tokio::test]
async fn pkg_remove_all_scope_is_lenient_when_only_global_has_entry() {
use crate::service::manifest::{load_manifest, record_install};
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let app_dir = crate::service::test_support::test_app_dir(home);
record_install(
&app_dir,
"orphan",
None,
crate::service::source::PackageSource::Path {
path: "/tmp/orphan_source".to_string(),
},
)
.unwrap();
let svc = make_app_service_at(home.to_path_buf()).await;
let result = svc
.pkg_remove("orphan", None, None, Some("all".to_string()))
.await
.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(json["removed"], "orphan");
assert_eq!(json["scope"], "all");
assert_eq!(json["global_removed"], true);
assert_eq!(json["project_removed"], false);
assert!(
!load_manifest(&app_dir)
.unwrap()
.packages
.contains_key("orphan"),
"global manifest still contains the entry"
);
}
#[tokio::test]
async fn pkg_remove_all_scope_errors_when_neither_scope_has_entry() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let svc = make_app_service_at(home.to_path_buf()).await;
let err = svc
.pkg_remove("never_anywhere", None, None, Some("all".to_string()))
.await
.expect_err("expected Err");
assert!(err.contains("not found in any scope"), "unexpected: {err}");
assert!(err.contains("project:"), "missing project context: {err}");
assert!(err.contains("global:"), "missing global context: {err}");
}
#[tokio::test]
async fn pkg_remove_invalid_scope_errors() {
let svc = make_app_service().await;
let err = svc
.pkg_remove("x", None, None, Some("packages".to_string()))
.await
.expect_err("expected Err");
assert!(
err.contains("invalid scope") && err.contains("packages"),
"unexpected: {err}"
);
}
#[tokio::test]
async fn pkg_list_project_path_entry_has_resolved_source() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
let pkg_dir = project_root.join("my_vendor_pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
std::fs::write(
project_root.join("alc.toml"),
"[packages]\nmy_vendor_pkg = { path = \"my_vendor_pkg\" }\n",
)
.unwrap();
let lock = LockFile {
version: 1,
packages: vec![LockPackage {
name: "my_vendor_pkg".to_string(),
version: None,
source: PackageSource::Path {
path: "my_vendor_pkg".to_string(),
},
}],
};
crate::service::lockfile::save_lockfile(project_root, &lock).unwrap();
let svc = make_app_service().await;
let result = pkg_list_summary(&svc, Some(project_root.to_string_lossy().to_string())).await;
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let packages = json["packages"].as_array().unwrap();
let pkg = packages
.iter()
.find(|p| p["name"] == "my_vendor_pkg")
.expect("my_vendor_pkg not found");
let expected_canonical = std::fs::canonicalize(&pkg_dir)
.unwrap()
.display()
.to_string();
assert_eq!(
pkg["resolved_source_path"].as_str().unwrap(),
expected_canonical,
"resolved_source_path should be canonicalized path"
);
assert_eq!(pkg["resolved_source_kind"], "local_path");
}
#[tokio::test]
async fn pkg_list_project_path_with_symlink_vendor_follows_target() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
let real_pkg = tmp.path().join("real_pkg_dir");
std::fs::create_dir_all(&real_pkg).unwrap();
std::fs::write(real_pkg.join("init.lua"), "return {}").unwrap();
let symlink_in_project = project_root.join("sym_vendor_pkg");
std::os::unix::fs::symlink(&real_pkg, &symlink_in_project).unwrap();
std::fs::write(
project_root.join("alc.toml"),
"[packages]\nsym_vendor_pkg = { path = \"sym_vendor_pkg\" }\n",
)
.unwrap();
let lock = LockFile {
version: 1,
packages: vec![LockPackage {
name: "sym_vendor_pkg".to_string(),
version: None,
source: PackageSource::Path {
path: "sym_vendor_pkg".to_string(),
},
}],
};
crate::service::lockfile::save_lockfile(project_root, &lock).unwrap();
let svc = make_app_service().await;
let result = pkg_list_summary(&svc, Some(project_root.to_string_lossy().to_string())).await;
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let packages = json["packages"].as_array().unwrap();
let pkg = packages
.iter()
.find(|p| p["name"] == "sym_vendor_pkg")
.expect("sym_vendor_pkg not found");
let expected_canonical = std::fs::canonicalize(&real_pkg)
.unwrap()
.display()
.to_string();
assert_eq!(
pkg["resolved_source_path"].as_str().unwrap(),
expected_canonical,
"resolved_source_path should resolve through symlink to real target"
);
assert_eq!(pkg["resolved_source_kind"], "local_path");
}
#[tokio::test]
async fn pkg_list_project_installed_entry_has_resolved_source() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let packages_dir = home.join("packages");
let pkg_dir = packages_dir.join("installed_pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
std::fs::write(
project_root.join("alc.toml"),
"[packages]\ninstalled_pkg = \"*\"\n",
)
.unwrap();
let lock = LockFile {
version: 1,
packages: vec![LockPackage {
name: "installed_pkg".to_string(),
version: None,
source: PackageSource::Installed,
}],
};
crate::service::lockfile::save_lockfile(project_root, &lock).unwrap();
let svc = make_app_service_at(home.to_path_buf()).await;
let result = pkg_list_summary(&svc, Some(project_root.to_string_lossy().to_string())).await;
let expected_canonical = std::fs::canonicalize(&pkg_dir)
.unwrap()
.display()
.to_string();
drop(tmp);
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let packages = json["packages"].as_array().unwrap();
let pkg = packages
.iter()
.find(|p| p["name"] == "installed_pkg")
.expect("installed_pkg not found");
assert_eq!(
pkg["resolved_source_path"].as_str().unwrap(),
expected_canonical,
"resolved_source_path should be packages_dir/<name> canonicalized"
);
assert_eq!(pkg["resolved_source_kind"], "installed");
}
#[tokio::test]
async fn pkg_list_project_installed_resolves_through_linked_pkg() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let packages_dir = home.join("packages");
std::fs::create_dir_all(&packages_dir).unwrap();
let real_dev_dir = home.join("dev").join("linked_pkg_real");
std::fs::create_dir_all(&real_dev_dir).unwrap();
std::fs::write(real_dev_dir.join("init.lua"), "return {}").unwrap();
let symlink_path = packages_dir.join("linked_pkg");
std::os::unix::fs::symlink(&real_dev_dir, &symlink_path).unwrap();
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
std::fs::write(
project_root.join("alc.toml"),
"[packages]\nlinked_pkg = \"*\"\n",
)
.unwrap();
let lock = LockFile {
version: 1,
packages: vec![LockPackage {
name: "linked_pkg".to_string(),
version: None,
source: PackageSource::Installed,
}],
};
crate::service::lockfile::save_lockfile(project_root, &lock).unwrap();
let svc = make_app_service_at(home.to_path_buf()).await;
let result = pkg_list_summary(&svc, Some(project_root.to_string_lossy().to_string())).await;
let expected_canonical = std::fs::canonicalize(&real_dev_dir)
.unwrap()
.display()
.to_string();
drop(tmp);
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let packages = json["packages"].as_array().unwrap();
let pkg = packages
.iter()
.find(|p| p["name"] == "linked_pkg")
.expect("linked_pkg not found");
assert_eq!(
pkg["resolved_source_path"].as_str().unwrap(),
expected_canonical,
"resolved_source_path should follow symlink in packages_dir to real target"
);
assert_eq!(pkg["resolved_source_kind"], "installed");
}
#[tokio::test]
async fn pkg_list_global_regular_pkg_has_resolved_source() {
let tmp = tempfile::tempdir().unwrap();
let search_dir = tmp.path().join("pkgs");
std::fs::create_dir_all(&search_dir).unwrap();
let pkg_dir = search_dir.join("regular_pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
let search_path = crate::service::resolve::SearchPath {
path: search_dir.clone(),
source: crate::service::resolve::SearchPathSource::Env,
};
let svc = make_app_service_with_search_paths(vec![search_path]).await;
let result = pkg_list_summary(&svc, None).await;
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let packages = json["packages"].as_array().unwrap();
let pkg = packages
.iter()
.find(|p| p["name"] == "regular_pkg")
.expect("regular_pkg not found");
let expected_canonical = std::fs::canonicalize(&pkg_dir)
.unwrap()
.display()
.to_string();
assert_eq!(
pkg["resolved_source_path"].as_str().unwrap(),
expected_canonical
);
assert_eq!(pkg["resolved_source_kind"], "installed");
}
#[tokio::test]
async fn pkg_list_global_linked_pkg_resolves_to_link_target() {
let tmp = tempfile::tempdir().unwrap();
let search_dir = tmp.path().join("pkgs");
std::fs::create_dir_all(&search_dir).unwrap();
let real_dir = tmp.path().join("my_dev_pkg");
std::fs::create_dir_all(&real_dir).unwrap();
std::fs::write(real_dir.join("init.lua"), "return {}").unwrap();
let link_path = search_dir.join("linked_global_pkg");
std::os::unix::fs::symlink(&real_dir, &link_path).unwrap();
let search_path = crate::service::resolve::SearchPath {
path: search_dir,
source: crate::service::resolve::SearchPathSource::Env,
};
let svc = make_app_service_with_search_paths(vec![search_path]).await;
let result = pkg_list_full(&svc, None).await;
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let packages = json["packages"].as_array().unwrap();
let pkg = packages
.iter()
.find(|p| p["name"] == "linked_global_pkg")
.expect("linked_global_pkg not found");
let expected_canonical = std::fs::canonicalize(&real_dir)
.unwrap()
.display()
.to_string();
assert_eq!(
pkg["resolved_source_path"].as_str().unwrap(),
expected_canonical,
"resolved_source_path should point to real target"
);
assert_eq!(pkg["resolved_source_kind"], "linked");
assert_eq!(pkg["linked"], true);
}
#[tokio::test]
async fn pkg_list_global_linked_broken_omits_resolved_source() {
let tmp = tempfile::tempdir().unwrap();
let search_dir = tmp.path().join("pkgs");
std::fs::create_dir_all(&search_dir).unwrap();
let nonexistent_target = tmp.path().join("this_does_not_exist");
let link_path = search_dir.join("broken_pkg");
std::os::unix::fs::symlink(&nonexistent_target, &link_path).unwrap();
let search_path = crate::service::resolve::SearchPath {
path: search_dir,
source: crate::service::resolve::SearchPathSource::Env,
};
let svc = make_app_service_with_search_paths(vec![search_path]).await;
let result = pkg_list_full(&svc, None).await;
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let packages = json["packages"].as_array().unwrap();
let pkg = packages
.iter()
.find(|p| p["name"] == "broken_pkg")
.expect("broken_pkg not found");
assert!(
pkg.get("resolved_source_path").is_none() || pkg["resolved_source_path"].is_null(),
"resolved_source_path must be absent for broken symlink"
);
assert_eq!(pkg["resolved_source_kind"], "linked");
assert_eq!(pkg["broken"], true);
}
#[tokio::test]
async fn pkg_list_override_paths_global_shadow() {
let tmp = tempfile::tempdir().unwrap();
let search_dir1 = tmp.path().join("pkgs1");
let search_dir2 = tmp.path().join("pkgs2");
std::fs::create_dir_all(&search_dir1).unwrap();
std::fs::create_dir_all(&search_dir2).unwrap();
for dir in [&search_dir1, &search_dir2] {
let pkg_dir = dir.join("dup_pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
}
let svc = make_app_service_with_search_paths(vec![
crate::service::resolve::SearchPath {
path: search_dir1.clone(),
source: crate::service::resolve::SearchPathSource::Env,
},
crate::service::resolve::SearchPath {
path: search_dir2.clone(),
source: crate::service::resolve::SearchPathSource::Env,
},
])
.await;
let result = pkg_list_full(&svc, None).await;
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let packages = json["packages"].as_array().unwrap();
let active_pkg = packages
.iter()
.find(|p| p["name"] == "dup_pkg" && p["active"] == true)
.expect("active dup_pkg not found");
let override_paths = active_pkg["override_paths"]
.as_array()
.expect("override_paths should be an array on active entry");
assert_eq!(
override_paths.len(),
1,
"should have exactly one shadowed entry"
);
let expected_shadow = std::fs::canonicalize(search_dir2.join("dup_pkg"))
.unwrap()
.display()
.to_string();
assert_eq!(
override_paths[0].as_str().unwrap(),
expected_shadow,
"override_paths[0] should be the canonicalized path in search_dir2"
);
}
#[tokio::test]
async fn pkg_list_override_paths_project_shadows_global() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
let search_dir = tmp.path().join("global_pkgs");
std::fs::create_dir_all(&search_dir).unwrap();
let global_pkg_dir = search_dir.join("shared_pkg");
std::fs::create_dir_all(&global_pkg_dir).unwrap();
std::fs::write(global_pkg_dir.join("init.lua"), "return {}").unwrap();
let local_pkg_dir = project_root.join("shared_pkg");
std::fs::create_dir_all(&local_pkg_dir).unwrap();
std::fs::write(local_pkg_dir.join("init.lua"), "return {}").unwrap();
std::fs::write(
project_root.join("alc.toml"),
"[packages]\nshared_pkg = { path = \"shared_pkg\" }\n",
)
.unwrap();
let lock = LockFile {
version: 1,
packages: vec![LockPackage {
name: "shared_pkg".to_string(),
version: None,
source: PackageSource::Path {
path: "shared_pkg".to_string(),
},
}],
};
crate::service::lockfile::save_lockfile(project_root, &lock).unwrap();
let svc = make_app_service_with_search_paths(vec![crate::service::resolve::SearchPath {
path: search_dir.clone(),
source: crate::service::resolve::SearchPathSource::Env,
}])
.await;
let result = pkg_list_full(&svc, Some(project_root.to_string_lossy().to_string())).await;
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let packages = json["packages"].as_array().unwrap();
let project_entry = packages
.iter()
.find(|p| p["name"] == "shared_pkg" && p["scope"] == "project")
.expect("project shared_pkg not found");
let override_paths = project_entry["override_paths"]
.as_array()
.expect("project entry should have override_paths listing shadowed global");
let expected_global_canonical = std::fs::canonicalize(&global_pkg_dir)
.unwrap()
.display()
.to_string();
assert!(
override_paths
.iter()
.any(|p| p.as_str().unwrap() == expected_global_canonical),
"project override_paths should include the global pkg canonical path"
);
let global_entry = packages
.iter()
.find(|p| p["name"] == "shared_pkg" && p["scope"] == "global")
.expect("global shared_pkg not found");
assert_eq!(
global_entry["active"], false,
"global entry should be inactive"
);
let global_map = global_entry
.as_object()
.expect("global entry must be object");
assert!(
!global_map.contains_key("override_paths"),
"inactive global entry must not have override_paths, got: {:?}",
global_map.get("override_paths")
);
}
#[tokio::test]
async fn pkg_list_project_installed_does_not_self_shadow() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let packages_dir = home.join("packages");
let pkg_dir = packages_dir.join("self_shadow_pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
std::fs::write(
project_root.join("alc.toml"),
"[packages]\nself_shadow_pkg = \"*\"\n",
)
.unwrap();
let lock = LockFile {
version: 1,
packages: vec![LockPackage {
name: "self_shadow_pkg".to_string(),
version: None,
source: PackageSource::Installed,
}],
};
crate::service::lockfile::save_lockfile(project_root, &lock).unwrap();
let svc = make_app_service_at_with_search_paths(
home.to_path_buf(),
vec![crate::service::resolve::SearchPath {
path: packages_dir.clone(),
source: crate::service::resolve::SearchPathSource::Default,
}],
)
.await;
let result = pkg_list_full(&svc, Some(project_root.to_string_lossy().to_string())).await;
drop(tmp);
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let packages = json["packages"].as_array().unwrap();
let project_entry = packages
.iter()
.find(|p| p["name"] == "self_shadow_pkg" && p["scope"] == "project")
.expect("project self_shadow_pkg not found");
let entry_map = project_entry
.as_object()
.expect("project entry must be object");
assert!(
!entry_map.contains_key("override_paths"),
"project `installed` entry must not list its own backing dir as override_paths, got: {:?}",
entry_map.get("override_paths")
);
}
#[tokio::test]
async fn pkg_list_global_unregistered_has_no_source_type() {
let tmp = tempfile::tempdir().unwrap();
let search_dir = tmp.path().join("pkgs");
std::fs::create_dir_all(&search_dir).unwrap();
let pkg_dir = search_dir.join("hand_copied_pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(
pkg_dir.join("init.lua"),
"return { meta = { name = 'hand_copied_pkg' } }",
)
.unwrap();
let search_path = crate::service::resolve::SearchPath {
path: search_dir,
source: crate::service::resolve::SearchPathSource::Env,
};
let svc = make_app_service_with_search_paths(vec![search_path]).await;
let result = pkg_list_full(&svc, None).await;
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let packages = json["packages"].as_array().unwrap();
let pkg = packages
.iter()
.find(|p| p["name"] == "hand_copied_pkg")
.expect("hand_copied_pkg not found in pkg_list output");
let pkg_map = pkg
.as_object()
.expect("package entry must be a JSON object");
assert!(
!pkg_map.contains_key("source_type"),
"source_type should be absent for unregistered package, got: {:?}",
pkg_map.get("source_type")
);
assert_eq!(pkg["scope"], "global");
assert_eq!(pkg["active"], true);
let expected_canonical = std::fs::canonicalize(&pkg_dir)
.unwrap()
.display()
.to_string();
assert_eq!(
pkg["resolved_source_path"].as_str().unwrap(),
expected_canonical,
"resolved_source_path should be populated regardless of manifest state"
);
assert_eq!(
pkg["resolved_source_kind"], "installed",
"unregistered global package should default to installed kind"
);
}
#[tokio::test]
async fn pkg_list_variant_pkg_appears_with_variant_scope() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
let pkg_dir = tmp.path().join("variant_src").join("my_variant_pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
std::fs::write(
project_root.join("alc.local.toml"),
format!(
"[packages]\nmy_variant_pkg = {{ path = \"{}\" }}\n",
pkg_dir.display()
),
)
.unwrap();
let svc = make_app_service().await;
let result = pkg_list_full(&svc, Some(project_root.to_string_lossy().to_string())).await;
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let packages = json["packages"].as_array().unwrap();
let entry = packages
.iter()
.find(|p| p["name"] == "my_variant_pkg")
.expect("my_variant_pkg not found in pkg_list output");
assert_eq!(entry["scope"], "variant");
assert_eq!(entry["active"], true);
assert_eq!(entry["source_type"], "path");
assert_eq!(entry["resolved_source_kind"], "variant");
let expected_canonical = std::fs::canonicalize(&pkg_dir)
.unwrap()
.display()
.to_string();
assert_eq!(
entry["resolved_source_path"].as_str().unwrap(),
expected_canonical,
"resolved_source_path should canonicalize to the variant pkg dir"
);
assert_eq!(
entry["path"].as_str().unwrap(),
pkg_dir.display().to_string(),
"path should be the absolute pkg_dir as declared in alc.local.toml"
);
}
#[tokio::test]
async fn pkg_list_variant_shadows_global() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
let global_dir = tmp.path().join("global_pkgs");
std::fs::create_dir_all(&global_dir).unwrap();
let global_pkg = global_dir.join("shared");
std::fs::create_dir_all(&global_pkg).unwrap();
std::fs::write(global_pkg.join("init.lua"), "return {}").unwrap();
let variant_pkg = tmp.path().join("variant_src").join("shared");
std::fs::create_dir_all(&variant_pkg).unwrap();
std::fs::write(variant_pkg.join("init.lua"), "return {}").unwrap();
std::fs::write(
project_root.join("alc.local.toml"),
format!(
"[packages]\nshared = {{ path = \"{}\" }}\n",
variant_pkg.display()
),
)
.unwrap();
let svc = make_app_service_with_search_paths(vec![crate::service::resolve::SearchPath {
path: global_dir.clone(),
source: crate::service::resolve::SearchPathSource::Env,
}])
.await;
let result = pkg_list_summary(&svc, Some(project_root.to_string_lossy().to_string())).await;
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let packages = json["packages"].as_array().unwrap();
let variant_entry = packages
.iter()
.find(|p| p["name"] == "shared" && p["scope"] == "variant")
.expect("variant 'shared' entry not found");
assert_eq!(variant_entry["active"], true);
let global_entry = packages
.iter()
.find(|p| p["name"] == "shared" && p["scope"] == "global")
.expect("global 'shared' entry not found");
assert_eq!(
global_entry["active"], false,
"global entry must be demoted when shadowed by variant"
);
}
#[tokio::test]
async fn pkg_list_variant_shadows_project() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
let project_pkg = project_root.join("shared");
std::fs::create_dir_all(&project_pkg).unwrap();
std::fs::write(project_pkg.join("init.lua"), "return {}").unwrap();
std::fs::write(
project_root.join("alc.toml"),
"[packages]\nshared = { path = \"shared\" }\n",
)
.unwrap();
let lock = LockFile {
version: 1,
packages: vec![LockPackage {
name: "shared".to_string(),
version: None,
source: PackageSource::Path {
path: "shared".to_string(),
},
}],
};
crate::service::lockfile::save_lockfile(project_root, &lock).unwrap();
let variant_pkg = tmp.path().join("variant_src").join("shared");
std::fs::create_dir_all(&variant_pkg).unwrap();
std::fs::write(variant_pkg.join("init.lua"), "return {}").unwrap();
std::fs::write(
project_root.join("alc.local.toml"),
format!(
"[packages]\nshared = {{ path = \"{}\" }}\n",
variant_pkg.display()
),
)
.unwrap();
let svc = make_app_service().await;
let result = pkg_list_summary(&svc, Some(project_root.to_string_lossy().to_string())).await;
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let packages = json["packages"].as_array().unwrap();
let variant_entry = packages
.iter()
.find(|p| p["name"] == "shared" && p["scope"] == "variant")
.expect("variant 'shared' entry not found");
assert_eq!(variant_entry["active"], true);
let project_entry = packages
.iter()
.find(|p| p["name"] == "shared" && p["scope"] == "project")
.expect("project 'shared' entry not found");
assert_eq!(
project_entry["active"], false,
"project entry must be demoted when shadowed by variant"
);
}
#[tokio::test]
async fn pkg_repair_reinstalls_missing_installed_dir() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let source = home.join("src_repo").join("repair_pkg");
std::fs::create_dir_all(&source).unwrap();
std::fs::write(
source.join("init.lua"),
"return { meta = { version = '0.1.0' } }",
)
.unwrap();
let svc = make_app_service_at(home.to_path_buf()).await;
svc.pkg_install(source.display().to_string(), None)
.await
.expect("initial install");
let dest = home.join("packages").join("repair_pkg");
assert!(dest.exists(), "dest must exist after install");
std::fs::remove_dir_all(&dest).unwrap();
assert!(!dest.exists());
let result = svc.pkg_repair(None, None).await.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let repaired = json["repaired"].as_array().expect("repaired array");
assert_eq!(repaired.len(), 1, "exactly one repair, got: {json}");
assert_eq!(repaired[0]["name"], "repair_pkg");
assert_eq!(repaired[0]["kind"], "installed_missing");
assert_eq!(repaired[0]["action"], "reinstall");
assert!(dest.exists(), "dest must be restored after repair");
}
#[tokio::test]
async fn pkg_repair_skips_healthy_pkg() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let source = home.join("src_repo").join("healthy_pkg");
std::fs::create_dir_all(&source).unwrap();
std::fs::write(source.join("init.lua"), "return {}").unwrap();
let svc = make_app_service_at(home.to_path_buf()).await;
svc.pkg_install(source.display().to_string(), None)
.await
.unwrap();
let result = svc.pkg_repair(None, None).await.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert!(
json["repaired"].as_array().unwrap().is_empty(),
"no repair expected"
);
let skipped = json["skipped"].as_array().unwrap();
assert!(
skipped.iter().any(|e| e["name"] == "healthy_pkg"),
"healthy_pkg must be in skipped, got: {json}"
);
}
#[tokio::test]
async fn pkg_repair_reports_dangling_symlink_as_unrepairable() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let pkg_dir = home.join("packages");
std::fs::create_dir_all(&pkg_dir).unwrap();
let target = home.join("does_not_exist");
let link = pkg_dir.join("dangling_pkg");
std::os::unix::fs::symlink(&target, &link).unwrap();
let svc = make_app_service_at(home.to_path_buf()).await;
let result = svc.pkg_repair(None, None).await.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let unrepairable = json["unrepairable"].as_array().expect("unrepairable array");
let entry = unrepairable
.iter()
.find(|e| e["name"] == "dangling_pkg")
.expect("dangling_pkg must surface as unrepairable");
assert_eq!(entry["kind"], "symlink_dangling");
assert!(
entry["suggestion"]
.as_str()
.unwrap()
.contains("alc_pkg_unlink"),
"suggestion should mention alc_pkg_unlink"
);
}
#[tokio::test]
async fn pkg_repair_reports_project_path_missing_as_unrepairable() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let project_root = home.join("proj");
std::fs::create_dir_all(&project_root).unwrap();
std::fs::write(
project_root.join("alc.toml"),
"[packages]\nghost = { path = \"missing_dir\" }\n",
)
.unwrap();
let svc = make_app_service_at(home.to_path_buf()).await;
let result = svc
.pkg_repair(None, Some(project_root.to_string_lossy().to_string()))
.await
.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let unrepairable = json["unrepairable"].as_array().unwrap();
let entry = unrepairable
.iter()
.find(|e| e["name"] == "ghost" && e["scope"] == "project")
.unwrap_or_else(|| panic!("ghost must surface as project path_missing, got: {json}"));
assert_eq!(entry["kind"], "path_missing");
assert!(entry["suggestion"].as_str().unwrap().contains("alc.toml"));
}
#[tokio::test]
async fn pkg_repair_reports_variant_path_missing_as_unrepairable() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let project_root = home.join("proj");
std::fs::create_dir_all(&project_root).unwrap();
let absent = project_root.join("nope_pkg");
std::fs::write(
project_root.join("alc.local.toml"),
format!(
"[packages]\nnope_pkg = {{ path = \"{}\" }}\n",
absent.display()
),
)
.unwrap();
let svc = make_app_service_at(home.to_path_buf()).await;
let result = svc
.pkg_repair(None, Some(project_root.to_string_lossy().to_string()))
.await
.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
let unrepairable = json["unrepairable"].as_array().unwrap();
let entry = unrepairable
.iter()
.find(|e| e["name"] == "nope_pkg" && e["scope"] == "variant")
.expect("nope_pkg must surface as variant path_missing");
assert_eq!(entry["kind"], "path_missing");
assert!(entry["suggestion"]
.as_str()
.unwrap()
.contains("alc_pkg_unlink"));
}
#[tokio::test]
async fn pkg_repair_unknown_name_returns_error() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let svc = make_app_service_at(home.to_path_buf()).await;
let err = svc
.pkg_repair(Some("nonexistent_pkg".to_string()), None)
.await
.unwrap_err();
assert!(
err.contains("nonexistent_pkg"),
"error should mention the missing name, got: {err}"
);
}
#[tokio::test]
async fn pkg_repair_reports_localpath_source_missing_as_unrepairable() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let source = home.join("gone").join("ghost_pkg");
std::fs::create_dir_all(&source).unwrap();
std::fs::write(source.join("init.lua"), "return {}").unwrap();
let svc = make_app_service_at(home.to_path_buf()).await;
svc.pkg_install(source.display().to_string(), None)
.await
.expect("initial install");
let dest = home.join("packages").join("ghost_pkg");
std::fs::remove_dir_all(&dest).unwrap();
std::fs::remove_dir_all(&source).unwrap();
let result = svc.pkg_repair(None, None).await.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert!(
json["failed"].as_array().unwrap().is_empty(),
"missing-source must not leak into `failed`; got: {json}"
);
let unrepairable = json["unrepairable"].as_array().expect("unrepairable");
let entry = unrepairable
.iter()
.find(|e| e["name"] == "ghost_pkg")
.unwrap_or_else(|| panic!("ghost_pkg must appear in unrepairable, got: {json}"));
assert_eq!(entry["kind"], "installed_missing");
let reason = entry["reason"].as_str().unwrap();
assert!(
reason.contains("source directory missing"),
"reason should mention missing source, got: {reason}"
);
assert!(
reason.contains("ghost_pkg"),
"reason should name the path, got: {reason}"
);
}
#[tokio::test]
async fn pkg_repair_reports_localpath_without_init_lua_as_unrepairable() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let source = home.join("shell").join("shell_pkg");
std::fs::create_dir_all(&source).unwrap();
std::fs::write(source.join("init.lua"), "return {}").unwrap();
let svc = make_app_service_at(home.to_path_buf()).await;
svc.pkg_install(source.display().to_string(), None)
.await
.expect("initial install");
let dest = home.join("packages").join("shell_pkg");
std::fs::remove_dir_all(&dest).unwrap();
std::fs::remove_file(source.join("init.lua")).unwrap();
let result = svc.pkg_repair(None, None).await.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert!(
json["failed"].as_array().unwrap().is_empty(),
"init.lua-missing must not leak into `failed`; got: {json}"
);
let entry = json["unrepairable"]
.as_array()
.unwrap()
.iter()
.find(|e| e["name"] == "shell_pkg")
.unwrap_or_else(|| panic!("shell_pkg must appear in unrepairable, got: {json}"))
.clone();
assert_eq!(entry["kind"], "installed_missing");
let reason = entry["reason"].as_str().unwrap();
assert!(
reason.contains("no init.lua at root"),
"reason should cite missing init.lua, got: {reason}"
);
}
#[tokio::test]
async fn pkg_install_rejects_missing_local_source_with_clear_error() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let svc = make_app_service_at(home.to_path_buf()).await;
let missing = "/tmp/alc-nonexistent-source-for-test-2e8f3a";
let err = svc
.pkg_install(missing.to_string(), Some("anything".to_string()))
.await
.unwrap_err();
assert!(
err.contains("Source directory does not exist"),
"expected explicit source-missing error, got: {err}"
);
assert!(
!err.contains("'name' parameter"),
"must not regress to collection-mode misleading error, got: {err}"
);
}
fn fnv1a_hex(url: &str) -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in url.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
format!("{h:016x}")
}
#[tokio::test]
async fn pkg_list_default_summary_is_compact() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let tmp = tempfile::tempdir().unwrap();
let search_dir = tmp.path().join("pkgs");
std::fs::create_dir_all(&search_dir).unwrap();
for i in 0..60 {
let pkg_dir = search_dir.join(format!("size_regression_pkg_{i:02}"));
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
}
let search_path = crate::service::resolve::SearchPath {
path: search_dir,
source: crate::service::resolve::SearchPathSource::Env,
};
let svc = make_app_service_at_with_search_paths(home.to_path_buf(), vec![search_path]).await;
let out = pkg_list_summary(&svc, None).await;
assert!(
out.len() < 15_000,
"pkg_list default summary should stay compact (got {} chars)",
out.len()
);
let json: serde_json::Value = serde_json::from_str(&out).unwrap();
assert!(
!json["packages"].as_array().unwrap().is_empty(),
"size test is meaningless without populated packages"
);
}
#[tokio::test]
async fn hub_search_default_summary_is_compact() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let cache_dir = home.join("hub_cache");
std::fs::create_dir_all(&cache_dir).unwrap();
let empty_index = serde_json::json!({
"schema_version": "hub_index/v0",
"updated_at": "",
"packages": [],
})
.to_string();
for repo in [
"https://github.com/ynishi/algocline-bundled-packages",
"https://github.com/ynishi/evalframe",
] {
let owner_repo = repo.trim_start_matches("https://github.com/");
let index_url =
format!("https://raw.githubusercontent.com/{owner_repo}/main/hub_index.json");
let cache_path = cache_dir.join(format!("{}.json", fnv1a_hex(&index_url)));
std::fs::write(&cache_path, &empty_index).unwrap();
}
let svc = make_app_service_at(home.to_path_buf()).await;
let out = svc.hub_search(None, None, None, opts()).unwrap();
assert!(
out.len() < 10_000,
"hub_search default summary should stay compact (got {} chars)",
out.len()
);
let _: serde_json::Value = serde_json::from_str(&out).unwrap();
}
#[tokio::test]
async fn pkg_list_limit_zero_returns_all() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let tmp = tempfile::tempdir().unwrap();
let search_dir = tmp.path().join("pkgs");
std::fs::create_dir_all(&search_dir).unwrap();
for i in 0..60 {
let pkg_dir = search_dir.join(format!("limit_zero_pkg_{i:02}"));
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
}
let search_path = crate::service::resolve::SearchPath {
path: search_dir,
source: crate::service::resolve::SearchPathSource::Env,
};
let svc = make_app_service_at_with_search_paths(home.to_path_buf(), vec![search_path]).await;
let out = svc
.pkg_list(
None,
ListOpts {
limit: Some(0),
..opts()
},
)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_str(&out).unwrap();
let packages = json["packages"].as_array().expect("packages array");
assert_eq!(
packages.len(),
60,
"limit=0 must return all 60 entries (got {})",
packages.len()
);
}
#[tokio::test]
async fn hub_search_limit_zero_returns_all() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let cache_dir = home.join("hub_cache");
std::fs::create_dir_all(&cache_dir).unwrap();
let empty_index = serde_json::json!({
"schema_version": "hub_index/v0",
"updated_at": "",
"packages": [],
})
.to_string();
for repo in [
"https://github.com/ynishi/algocline-bundled-packages",
"https://github.com/ynishi/evalframe",
] {
let owner_repo = repo.trim_start_matches("https://github.com/");
let index_url =
format!("https://raw.githubusercontent.com/{owner_repo}/main/hub_index.json");
let cache_path = cache_dir.join(format!("{}.json", fnv1a_hex(&index_url)));
std::fs::write(&cache_path, &empty_index).unwrap();
}
let svc = make_app_service_at(home.to_path_buf()).await;
let out = svc
.hub_search(
None,
None,
None,
ListOpts {
limit: Some(0),
..opts()
},
)
.unwrap();
let json: serde_json::Value = serde_json::from_str(&out).unwrap();
let results = json["results"].as_array().expect("results array");
let total = json["total"].as_u64().expect("total number");
assert_eq!(
results.len() as u64,
total,
"limit=0 must not truncate: results.len={} vs total={}",
results.len(),
total
);
}