use crate::service::lockfile::{load_lockfile, LockFile, LockPackage};
use crate::service::source::PackageSource;
use crate::service::test_support::{make_app_service, make_app_service_with_search_paths};
fn make_lock_with_pkg(name: &str) -> LockFile {
LockFile {
version: 1,
packages: vec![LockPackage {
name: name.to_string(),
version: None,
source: PackageSource::Installed,
}],
}
}
#[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()))
.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 = svc.pkg_list(None).await.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert!(json["packages"].is_array());
}
#[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, )
.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, )
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found in alc.lock"));
}
#[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 = svc
.pkg_list(Some(project_root.to_string_lossy().to_string()))
.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"] == "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 = svc
.pkg_list(Some(project_root.to_string_lossy().to_string()))
.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"] == "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() {
use crate::service::test_support::FakeHome;
let fake_home = FakeHome::new();
let packages_dir = fake_home.home.join(".algocline").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().await;
let result = svc
.pkg_list(Some(project_root.to_string_lossy().to_string()))
.await
.unwrap();
let expected_canonical = std::fs::canonicalize(&pkg_dir)
.unwrap()
.display()
.to_string();
drop(fake_home);
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() {
use crate::service::test_support::FakeHome;
let fake_home = FakeHome::new();
let packages_dir = fake_home.home.join(".algocline").join("packages");
std::fs::create_dir_all(&packages_dir).unwrap();
let real_dev_dir = fake_home.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().await;
let result = svc
.pkg_list(Some(project_root.to_string_lossy().to_string()))
.await
.unwrap();
let expected_canonical = std::fs::canonicalize(&real_dev_dir)
.unwrap()
.display()
.to_string();
drop(fake_home);
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 = svc.pkg_list(None).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"] == "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 = svc.pkg_list(None).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"] == "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 = svc.pkg_list(None).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"] == "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 = svc.pkg_list(None).await.unwrap();
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 = svc
.pkg_list(Some(project_root.to_string_lossy().to_string()))
.await
.unwrap();
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() {
use crate::service::test_support::FakeHome;
let fake_home = FakeHome::new();
let packages_dir = fake_home.home.join(".algocline").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_with_search_paths(vec![crate::service::resolve::SearchPath {
path: packages_dir.clone(),
source: crate::service::resolve::SearchPathSource::Default,
}])
.await;
let result = svc
.pkg_list(Some(project_root.to_string_lossy().to_string()))
.await
.unwrap();
drop(fake_home);
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 = svc.pkg_list(None).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"] == "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 = svc
.pkg_list(Some(project_root.to_string_lossy().to_string()))
.await
.unwrap();
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 = svc
.pkg_list(Some(project_root.to_string_lossy().to_string()))
.await
.unwrap();
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 = svc
.pkg_list(Some(project_root.to_string_lossy().to_string()))
.await
.unwrap();
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() {
use crate::service::test_support::FakeHome;
let fake_home = FakeHome::new();
let source = fake_home.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().await;
svc.pkg_install(source.display().to_string(), None)
.await
.expect("initial install");
let dest = fake_home
.home
.join(".algocline")
.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() {
use crate::service::test_support::FakeHome;
let fake_home = FakeHome::new();
let source = fake_home.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().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() {
use crate::service::test_support::FakeHome;
let fake_home = FakeHome::new();
let pkg_dir = fake_home.home.join(".algocline").join("packages");
std::fs::create_dir_all(&pkg_dir).unwrap();
let target = fake_home.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().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() {
use crate::service::test_support::FakeHome;
let fake_home = FakeHome::new();
let project_root = fake_home.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().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() {
use crate::service::test_support::FakeHome;
let fake_home = FakeHome::new();
let project_root = fake_home.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().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() {
use crate::service::test_support::FakeHome;
let _fake_home = FakeHome::new();
let svc = make_app_service().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() {
use crate::service::test_support::FakeHome;
let fake_home = FakeHome::new();
let source = fake_home.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().await;
svc.pkg_install(source.display().to_string(), None)
.await
.expect("initial install");
let dest = fake_home
.home
.join(".algocline")
.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() {
use crate::service::test_support::FakeHome;
let fake_home = FakeHome::new();
let source = fake_home.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().await;
svc.pkg_install(source.display().to_string(), None)
.await
.expect("initial install");
let dest = fake_home
.home
.join(".algocline")
.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() {
use crate::service::test_support::FakeHome;
let _fake_home = FakeHome::new();
let svc = make_app_service().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}"
);
}