#[cfg(unix)]
use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf};
use super::alc_toml::{self, add_package_entry, validate_package_name, PackageDep};
#[cfg(unix)]
use super::resolve::packages_dir;
use super::AppService;
impl AppService {
pub async fn pkg_link(
&self,
path: String,
name: Option<String>,
force: Option<bool>,
scope: Option<String>,
project_root: Option<String>,
) -> Result<String, String> {
let scope_str = scope.as_deref().unwrap_or("global");
match scope_str {
"global" => self.pkg_link_global(path, name, force).await,
"variant" => {
if force == Some(true) {
return Err(
"force is not supported with scope='variant' (variant scope writes \
alc.local.toml; there is no filesystem destination to overwrite)"
.to_string(),
);
}
self.pkg_link_variant(path, name, project_root).await
}
other => Err(format!(
"invalid scope: '{other}' (expected 'global' or 'variant')"
)),
}
}
async fn pkg_link_global(
&self,
path: String,
name: Option<String>,
force: Option<bool>,
) -> Result<String, String> {
#[cfg(not(unix))]
{
let _ = (path, name, force);
return Err(
"pkg_link scope='global' is not supported on non-Unix platforms".to_string(),
);
}
#[cfg(unix)]
{
if name.is_some() {
return Err(
"The 'name' parameter is no longer supported. Single-mode link was removed \
in v0.36.0; package names are derived from subdirectory names in collection \
layout (<dir>/<name>/init.lua)."
.to_string(),
);
}
let force = force.unwrap_or(false);
let raw = Path::new(&path);
let source: PathBuf = if raw.is_absolute() {
raw.to_path_buf()
} else {
std::env::current_dir()
.map_err(|e| format!("Cannot determine cwd: {e}"))?
.join(raw)
};
if !source.is_dir() {
return Err(format!("Path is not a directory: {}", source.display()));
}
let pkgs = packages_dir(&self.log_config.app_dir());
std::fs::create_dir_all(&pkgs)
.map_err(|e| format!("Cannot create packages dir {}: {e}", pkgs.display()))?;
let mut linked_names: Vec<String> = Vec::new();
let mut targets: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
let entries = std::fs::read_dir(&source)
.map_err(|e| format!("Failed to read directory {}: {e}", source.display()))?;
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read directory entry: {e}"))?;
let pkg_path = entry.path();
if !pkg_path.is_dir() || !pkg_path.join("init.lua").exists() {
continue;
}
let pkg_name = entry.file_name().to_string_lossy().to_string();
validate_package_name(&pkg_name)?;
let dest = pkgs.join(&pkg_name);
create_symlink(&pkg_path, &dest, force)?;
targets.insert(
pkg_name.clone(),
serde_json::Value::String(pkg_path.display().to_string()),
);
linked_names.push(pkg_name);
}
if linked_names.is_empty() {
return Err(format!(
"No init.lua found in any subdirectory of: {} \
(expected */init.lua collection layout)",
source.display()
));
}
linked_names.sort();
Ok(serde_json::json!({
"linked": linked_names,
"mode": "collection",
"targets": targets,
"scope": "global",
})
.to_string())
}
}
async fn pkg_link_variant(
&self,
path: String,
name: Option<String>,
project_root: Option<String>,
) -> Result<String, String> {
if name.is_some() {
return Err(
"The 'name' parameter is no longer supported. Single-mode link was removed \
in v0.36.0; package names are derived from subdirectory names in collection \
layout (<dir>/<name>/init.lua)."
.to_string(),
);
}
let raw = Path::new(&path);
let source: PathBuf = if raw.is_absolute() {
raw.to_path_buf()
} else {
std::env::current_dir()
.map_err(|e| format!("Cannot determine cwd: {e}"))?
.join(raw)
};
if !source.is_dir() {
return Err(format!("Path is not a directory: {}", source.display()));
}
let root = self.resolve_root(project_root.as_deref()).ok_or_else(|| {
"No project root found. Pass project_root or activate via alc_session_new, set ALC_PROJECT_ROOT, or run from within a project containing alc.toml.".to_string()
})?;
let mut doc = match alc_toml::load_alc_local_toml_document(&root)? {
Some(d) => d,
None => "[packages]\n"
.parse::<toml_edit::DocumentMut>()
.map_err(|e| format!("Failed to create empty alc.local.toml document: {e}"))?,
};
let mut linked_names: Vec<String> = Vec::new();
let mut targets: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
let entries = std::fs::read_dir(&source)
.map_err(|e| format!("Failed to read directory {}: {e}", source.display()))?;
let mut candidates: Vec<(String, String)> = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read directory entry: {e}"))?;
let pkg_path = entry.path();
if !pkg_path.is_dir() || !pkg_path.join("init.lua").exists() {
continue;
}
let pkg_name = entry.file_name().to_string_lossy().to_string();
validate_package_name(&pkg_name)?;
candidates.push((pkg_name, pkg_path.display().to_string()));
}
if candidates.is_empty() {
return Err(format!(
"No init.lua found in any subdirectory of: {} \
(expected */init.lua collection layout)",
source.display()
));
}
candidates.sort();
for (pkg_name, abs) in candidates {
let added = add_package_entry(
&mut doc,
&pkg_name,
&PackageDep::Path {
path: abs.clone(),
version: None,
},
);
targets.insert(pkg_name.clone(), serde_json::Value::String(abs));
if added {
linked_names.push(pkg_name);
}
}
alc_toml::save_alc_local_toml(&root, &doc)?;
let alc_local_path = alc_toml::local_alc_toml_path(&root);
Ok(serde_json::json!({
"linked": linked_names,
"mode": "collection",
"targets": targets,
"scope": "variant",
"alc_local_toml": alc_local_path.display().to_string(),
})
.to_string())
}
}
#[cfg(unix)]
fn create_symlink(source: &Path, dest: &Path, force: bool) -> Result<(), String> {
let meta = dest.symlink_metadata();
if let Ok(m) = meta {
if m.file_type().is_symlink() {
std::fs::remove_file(dest).map_err(|e| {
format!("Failed to remove existing symlink {}: {e}", dest.display())
})?;
} else if m.is_dir() {
if !force {
return Err(format!(
"Destination '{}' is a real directory. Use force=true to overwrite.",
dest.display()
));
}
std::fs::remove_dir_all(dest)
.map_err(|e| format!("Failed to remove directory {}: {e}", dest.display()))?;
} else {
std::fs::remove_file(dest)
.map_err(|e| format!("Failed to remove {}: {e}", dest.display()))?;
}
}
symlink(source, dest).map_err(|e| {
format!(
"Failed to create symlink {} -> {}: {e}",
dest.display(),
source.display()
)
})
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use crate::service::test_support::make_app_service_at;
#[tokio::test]
async fn pkg_link_creates_symlink_in_collection_mode() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let coll = home.join("my_coll");
let pkg_dir = coll.join("my_pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
let svc = make_app_service_at(home.to_path_buf()).await;
let result = svc
.pkg_link(coll.to_string_lossy().to_string(), None, None, None, None)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(json["mode"], "collection");
assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
assert_eq!(
json["targets"]["my_pkg"],
pkg_dir.to_string_lossy().as_ref()
);
let dest = home.join("packages").join("my_pkg");
assert!(dest.symlink_metadata().unwrap().file_type().is_symlink());
assert_eq!(std::fs::read_link(&dest).unwrap(), pkg_dir);
}
#[tokio::test]
async fn pkg_link_collection_creates_symlinks() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let coll = home.join("collection");
std::fs::create_dir_all(coll.join("pkg_a")).unwrap();
std::fs::create_dir_all(coll.join("pkg_b")).unwrap();
std::fs::write(coll.join("pkg_a").join("init.lua"), "return {}").unwrap();
std::fs::write(coll.join("pkg_b").join("init.lua"), "return {}").unwrap();
let svc = make_app_service_at(home.to_path_buf()).await;
let result = svc
.pkg_link(coll.to_string_lossy().to_string(), None, None, None, None)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(json["mode"], "collection");
let linked = json["linked"].as_array().unwrap();
let mut names: Vec<&str> = linked.iter().map(|v| v.as_str().unwrap()).collect();
names.sort();
assert_eq!(names, ["pkg_a", "pkg_b"]);
let pkgs = home.join("packages");
assert!(pkgs
.join("pkg_a")
.symlink_metadata()
.unwrap()
.file_type()
.is_symlink());
assert!(pkgs
.join("pkg_b")
.symlink_metadata()
.unwrap()
.file_type()
.is_symlink());
}
#[tokio::test]
async fn pkg_link_overwrites_existing_symlink() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let coll = home.join("my_coll");
let pkg_dir = coll.join("my_pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
let pkgs = home.join("packages");
std::fs::create_dir_all(&pkgs).unwrap();
let dest = pkgs.join("my_pkg");
symlink(&pkg_dir, &dest).unwrap();
let svc = make_app_service_at(home.to_path_buf()).await;
let result = svc
.pkg_link(coll.to_string_lossy().to_string(), None, None, None, None)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
assert!(dest.symlink_metadata().unwrap().file_type().is_symlink());
}
#[tokio::test]
async fn pkg_link_real_dir_requires_force() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let coll = home.join("my_coll");
let pkg_dir = coll.join("my_pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
let pkgs = home.join("packages");
let dest = pkgs.join("my_pkg");
std::fs::create_dir_all(&dest).unwrap();
let svc = make_app_service_at(home.to_path_buf()).await;
let err = svc
.pkg_link(coll.to_string_lossy().to_string(), None, None, None, None)
.await
.unwrap_err();
assert!(
err.contains("real directory"),
"expected real directory error, got: {err}"
);
let result = svc
.pkg_link(
coll.to_string_lossy().to_string(),
None,
Some(true),
None,
None,
)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
assert!(dest.symlink_metadata().unwrap().file_type().is_symlink());
}
#[tokio::test]
async fn pkg_link_dangling_symlink_overwritten() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let coll = home.join("my_coll");
let pkg_dir = coll.join("my_pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
let pkgs = home.join("packages");
std::fs::create_dir_all(&pkgs).unwrap();
let dest = pkgs.join("my_pkg");
symlink(home.join("nonexistent"), &dest).unwrap();
assert!(!dest.exists());
let svc = make_app_service_at(home.to_path_buf()).await;
let result = svc
.pkg_link(coll.to_string_lossy().to_string(), None, None, None, None)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
assert!(dest.symlink_metadata().unwrap().file_type().is_symlink());
assert!(dest.exists()); }
#[tokio::test]
async fn pkg_link_path_not_found_returns_error() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let nonexistent = home.join("does_not_exist");
let svc = make_app_service_at(home.to_path_buf()).await;
let err = svc
.pkg_link(
nonexistent.to_string_lossy().to_string(),
None,
None,
None,
None,
)
.await
.unwrap_err();
assert!(err.contains("not a directory"), "got: {err}");
}
#[tokio::test]
async fn pkg_link_scope_variant_appends_to_alc_local_toml() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let root = home.join("proj");
std::fs::create_dir_all(&root).unwrap();
let coll = home.join("my_coll");
let pkg_dir = coll.join("my_pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
let svc = make_app_service_at(home.to_path_buf()).await;
let result = svc
.pkg_link(
coll.to_string_lossy().to_string(),
None,
None,
Some("variant".to_string()),
Some(root.to_string_lossy().to_string()),
)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(json["scope"], "variant");
assert_eq!(json["mode"], "collection");
assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
let local = root.join("alc.local.toml");
assert!(local.exists());
let content = std::fs::read_to_string(&local).unwrap();
assert!(content.contains("my_pkg"));
assert!(content.contains(pkg_dir.to_string_lossy().as_ref()));
}
#[tokio::test]
async fn pkg_link_scope_variant_no_symlink_created() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let root = home.join("proj");
std::fs::create_dir_all(&root).unwrap();
let coll = home.join("my_coll");
let pkg_dir = coll.join("my_pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
let svc = make_app_service_at(home.to_path_buf()).await;
svc.pkg_link(
coll.to_string_lossy().to_string(),
None,
None,
Some("variant".to_string()),
Some(root.to_string_lossy().to_string()),
)
.await
.unwrap();
let cache_link = home.join("packages").join("my_pkg");
assert!(
cache_link.symlink_metadata().is_err(),
"variant scope must not create a symlink in ~/.algocline/packages/"
);
}
#[tokio::test]
async fn pkg_link_scope_variant_second_call_is_noop_for_existing_entry() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let root = home.join("proj");
std::fs::create_dir_all(&root).unwrap();
let coll = home.join("my_coll");
let pkg_dir = coll.join("my_pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
let svc = make_app_service_at(home.to_path_buf()).await;
svc.pkg_link(
coll.to_string_lossy().to_string(),
None,
None,
Some("variant".to_string()),
Some(root.to_string_lossy().to_string()),
)
.await
.unwrap();
let result = svc
.pkg_link(
coll.to_string_lossy().to_string(),
None,
None,
Some("variant".to_string()),
Some(root.to_string_lossy().to_string()),
)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(json["linked"], serde_json::json!([]));
assert_eq!(
json["targets"]["my_pkg"],
pkg_dir.to_string_lossy().as_ref()
);
let local = root.join("alc.local.toml");
let content = std::fs::read_to_string(&local).unwrap();
let doc: toml_edit::DocumentMut = content.parse().unwrap();
let pkgs = doc["packages"].as_table().unwrap();
let key_count = pkgs.iter().filter(|(k, _)| *k == "my_pkg").count();
assert_eq!(key_count, 1, "duplicate entry written: {content}");
}
#[tokio::test]
async fn pkg_link_scope_variant_requires_project_root() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let coll = home.join("my_coll");
let pkg_dir = coll.join("my_pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
let svc = make_app_service_at(home.to_path_buf()).await;
let nonexistent = home.join("no_such_project_root_zzz");
let err = svc
.pkg_link(
coll.to_string_lossy().to_string(),
None,
None,
Some("variant".to_string()),
Some(nonexistent.to_string_lossy().to_string()),
)
.await;
if let Err(e) = err {
assert!(e.contains("No project root found"), "unexpected err: {e}");
}
}
#[tokio::test]
async fn pkg_link_invalid_scope_returns_error() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let coll = home.join("my_coll");
let pkg_dir = coll.join("my_pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
let svc = make_app_service_at(home.to_path_buf()).await;
let err = svc
.pkg_link(
coll.to_string_lossy().to_string(),
None,
None,
Some("unknown".to_string()),
None,
)
.await
.unwrap_err();
assert!(err.contains("invalid scope"), "got: {err}");
}
#[tokio::test]
async fn pkg_link_scope_global_default_matches_existing_behavior() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let coll = home.join("my_coll");
let pkg_dir = coll.join("my_pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
let svc = make_app_service_at(home.to_path_buf()).await;
let result = svc
.pkg_link(
coll.to_string_lossy().to_string(),
None,
None,
Some("global".to_string()),
None,
)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(json["scope"], "global");
assert_eq!(json["mode"], "collection");
assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
let dest = home.join("packages").join("my_pkg");
assert!(dest.symlink_metadata().unwrap().file_type().is_symlink());
}
#[tokio::test]
async fn pkg_link_scope_variant_collection_appends_all() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let root = home.join("proj");
std::fs::create_dir_all(&root).unwrap();
let coll = home.join("collection");
std::fs::create_dir_all(coll.join("pkg_a")).unwrap();
std::fs::create_dir_all(coll.join("pkg_b")).unwrap();
std::fs::write(coll.join("pkg_a").join("init.lua"), "return {}").unwrap();
std::fs::write(coll.join("pkg_b").join("init.lua"), "return {}").unwrap();
let svc = make_app_service_at(home.to_path_buf()).await;
let result = svc
.pkg_link(
coll.to_string_lossy().to_string(),
None,
None,
Some("variant".to_string()),
Some(root.to_string_lossy().to_string()),
)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(json["scope"], "variant");
assert_eq!(json["mode"], "collection");
let linked = json["linked"].as_array().unwrap();
let names: Vec<&str> = linked.iter().map(|v| v.as_str().unwrap()).collect();
assert_eq!(names, ["pkg_a", "pkg_b"]);
let local = root.join("alc.local.toml");
let content = std::fs::read_to_string(&local).unwrap();
assert!(content.contains("pkg_a"));
assert!(content.contains("pkg_b"));
}
#[tokio::test]
async fn pkg_link_scope_variant_rejects_force() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let root = home.join("proj");
std::fs::create_dir_all(&root).unwrap();
let coll = home.join("my_coll");
let pkg_dir = coll.join("my_pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
let svc = make_app_service_at(home.to_path_buf()).await;
let err = svc
.pkg_link(
coll.to_string_lossy().to_string(),
None,
Some(true),
Some("variant".to_string()),
Some(root.to_string_lossy().to_string()),
)
.await
.unwrap_err();
assert!(
err.contains("force is not supported with scope='variant'"),
"got: {err}"
);
assert!(
!root.join("alc.local.toml").exists(),
"alc.local.toml must not be written when the call is rejected"
);
}
#[tokio::test]
async fn pkg_link_scope_variant_accepts_force_false() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let root = home.join("proj");
std::fs::create_dir_all(&root).unwrap();
let coll = home.join("my_coll");
let pkg_dir = coll.join("my_pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
let svc = make_app_service_at(home.to_path_buf()).await;
let result = svc
.pkg_link(
coll.to_string_lossy().to_string(),
None,
Some(false),
Some("variant".to_string()),
Some(root.to_string_lossy().to_string()),
)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(json["scope"], "variant");
assert_eq!(json["mode"], "collection");
assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
}
}