use std::collections::HashSet;
use std::path::{Path, PathBuf};
use super::super::alc_toml::{self, PackageDep};
use super::super::lockfile::load_lockfile;
use super::super::manifest::{load_manifest, ManifestEntry};
use super::super::resolve::packages_dir;
use super::super::source::PackageSource;
use super::super::AppService;
use super::install::InstallSource;
enum RepairOutcome {
Repaired { source: String },
Skipped,
Unrepairable {
kind: &'static str,
reason: String,
suggestion: String,
},
Failed { reason: String },
}
#[derive(Default)]
struct Buckets {
repaired: Vec<serde_json::Value>,
skipped: Vec<serde_json::Value>,
unrepairable: Vec<serde_json::Value>,
failed: Vec<serde_json::Value>,
}
impl Buckets {
fn any_matched(&self) -> bool {
!self.repaired.is_empty()
|| !self.skipped.is_empty()
|| !self.unrepairable.is_empty()
|| !self.failed.is_empty()
}
fn into_json(self) -> String {
serde_json::json!({
"repaired": self.repaired,
"skipped": self.skipped,
"unrepairable": self.unrepairable,
"failed": self.failed,
})
.to_string()
}
}
pub(super) fn symlink_dangling_suggestion(name: &str) -> String {
format!("alc_pkg_unlink({name:?}) then alc_pkg_link with the new path")
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum AliveBucket {
Unregistered,
}
fn push_installed_outcome(name: &str, outcome: RepairOutcome, buckets: &mut Buckets) {
match outcome {
RepairOutcome::Repaired { source } => buckets.repaired.push(serde_json::json!({
"name": name,
"kind": "installed_missing",
"action": "reinstall",
"source": source,
})),
RepairOutcome::Skipped => buckets.skipped.push(serde_json::json!({
"name": name,
"reason": "healthy",
})),
RepairOutcome::Unrepairable {
kind,
reason,
suggestion,
} => buckets.unrepairable.push(serde_json::json!({
"name": name,
"kind": kind,
"reason": reason,
"suggestion": suggestion,
})),
RepairOutcome::Failed { reason } => buckets.failed.push(serde_json::json!({
"name": name,
"kind": "installed_missing",
"reason": reason,
})),
}
}
impl AppService {
pub async fn pkg_repair(
&self,
name: Option<String>,
project_root: Option<String>,
) -> Result<String, String> {
let app_dir = self.log_config.app_dir();
let manifest = load_manifest(&app_dir)?;
let pkg_dir = packages_dir(&app_dir);
let resolved_root = self.resolve_root(project_root.as_deref());
let mut buckets = Buckets::default();
let target_filter = name.as_deref();
for (pkg_name, entry) in &manifest.packages {
if let Some(target) = target_filter {
if target != pkg_name.as_str() {
continue;
}
}
let outcome = self.repair_installed(pkg_name, entry, &pkg_dir).await;
push_installed_outcome(pkg_name, outcome, &mut buckets);
}
collect_unattached_dangling_symlinks(
&pkg_dir,
target_filter,
&manifest.packages,
&mut buckets.unrepairable,
);
if let Some(root) = resolved_root.as_ref() {
collect_path_missing(
root,
target_filter,
"project",
&mut buckets.unrepairable,
ProjectPathSource::Toml,
);
collect_path_missing(
root,
target_filter,
"variant",
&mut buckets.unrepairable,
ProjectPathSource::Local,
);
}
if let Some(target) = target_filter {
if !buckets.any_matched() {
return Err(format!(
"Package '{target}' not found in installed.json, ~/.algocline/packages/, alc.toml, or alc.local.toml"
));
}
}
Ok(buckets.into_json())
}
async fn repair_installed(
&self,
name: &str,
entry: &ManifestEntry,
pkg_dir: &Path,
) -> RepairOutcome {
let dest = pkg_dir.join(name);
let is_symlink = dest
.symlink_metadata()
.map(|m| m.file_type().is_symlink())
.unwrap_or(false);
if is_symlink {
let target_alive = dest.try_exists().unwrap_or(false);
if target_alive {
return RepairOutcome::Skipped;
}
let link_target = dest
.read_link()
.map(|t| t.display().to_string())
.unwrap_or_else(|_| "<unknown>".to_string());
return RepairOutcome::Unrepairable {
kind: "symlink_dangling",
reason: format!("symlink target missing: {link_target}"),
suggestion: symlink_dangling_suggestion(name),
};
}
if dest.exists() {
return RepairOutcome::Skipped;
}
let install_source = match &entry.source {
PackageSource::Path { path } => InstallSource::LocalPath(PathBuf::from(path)),
PackageSource::Git { url, .. } => InstallSource::GitUrl(normalize_git_url(url)),
PackageSource::Bundled { .. } => {
return RepairOutcome::Unrepairable {
kind: "installed_missing",
reason: "bundled package — restore via `alc_init` or reinstall algocline"
.to_string(),
suggestion: "alc_init (reinstalls bundled packages from the algocline binary)"
.to_string(),
};
}
PackageSource::Installed => {
return RepairOutcome::Unrepairable {
kind: "installed_missing",
reason: "legacy 'installed' marker carries no source path".to_string(),
suggestion: "alc_pkg_install <path-or-url> to re-record source, \
then alc_pkg_repair"
.to_string(),
};
}
PackageSource::Unknown => {
return RepairOutcome::Unrepairable {
kind: "installed_missing",
reason: "source unknown (legacy entry; run alc_hub_reindex)".to_string(),
suggestion: "alc_hub_reindex to rebuild the index, or \
alc_pkg_install <path-or-url> to re-record source"
.to_string(),
};
}
};
if let InstallSource::LocalPath(ref p) = install_source {
if !p.exists() {
return RepairOutcome::Unrepairable {
kind: "installed_missing",
reason: format!("source directory missing: {}", p.display()),
suggestion: format!(
"alc_pkg_install from a valid source, or remove the '{name}' entry from ~/.algocline/installed.json"
),
};
}
if !p.join(name).join("init.lua").exists() {
return RepairOutcome::Unrepairable {
kind: "installed_missing",
reason: format!(
"source directory has no init.lua at root: {}",
p.display()
),
suggestion: format!(
"alc_pkg_install from a valid source, or remove the '{name}' entry from ~/.algocline/installed.json"
),
};
}
}
match self.pkg_install_typed(install_source, None, None).await {
Ok(_) => RepairOutcome::Repaired {
source: entry.source.display_string(),
},
Err(e) => RepairOutcome::Failed { reason: e },
}
}
}
fn normalize_git_url(url: &str) -> String {
super::install::prefix_git_scheme_if_missing(url)
}
pub(super) fn collect_unattached_dangling_symlinks(
pkg_dir: &Path,
target_filter: Option<&str>,
manifest_names: &std::collections::BTreeMap<String, ManifestEntry>,
unrepairable: &mut Vec<serde_json::Value>,
) {
let read = match std::fs::read_dir(pkg_dir) {
Ok(r) => r,
Err(e) => {
tracing::warn!(
"pkg: failed to read packages_dir at {}: {e}",
pkg_dir.display()
);
return;
}
};
for dir_entry_result in read {
let dir_entry = match dir_entry_result {
Ok(e) => e,
Err(e) => {
tracing::warn!(
"pkg: skipping unreadable entry in {}: {e}",
pkg_dir.display()
);
continue;
}
};
let path = dir_entry.path();
let pkg_name = dir_entry.file_name().to_string_lossy().to_string();
if let Some(target) = target_filter {
if target != pkg_name.as_str() {
continue;
}
}
if manifest_names.contains_key(&pkg_name) {
continue;
}
let is_symlink = path
.symlink_metadata()
.map(|m| m.file_type().is_symlink())
.unwrap_or(false);
if !is_symlink {
continue;
}
let target_exists = path.try_exists().unwrap_or(false);
if target_exists {
continue;
}
let link_target = path
.read_link()
.map(|t| t.display().to_string())
.unwrap_or_else(|_| "<unknown>".to_string());
unrepairable.push(serde_json::json!({
"name": pkg_name,
"kind": "symlink_dangling",
"reason": format!("symlink target missing: {link_target}"),
"suggestion": symlink_dangling_suggestion(&pkg_name),
}));
}
}
#[derive(Debug, Clone, Copy)]
pub(super) enum ProjectPathSource {
Toml,
Local,
}
pub(super) fn collect_path_missing(
root: &Path,
target_filter: Option<&str>,
scope: &'static str,
unrepairable: &mut Vec<serde_json::Value>,
src: ProjectPathSource,
) {
let loaded = match src {
ProjectPathSource::Toml => alc_toml::load_alc_toml(root),
ProjectPathSource::Local => alc_toml::load_alc_local_toml(root),
};
let Ok(Some(toml_data)) = loaded else {
return;
};
let lock_lookup = if matches!(src, ProjectPathSource::Toml) {
load_lockfile(root).ok().flatten().map(|l| {
l.packages
.into_iter()
.filter_map(|p| match p.source {
PackageSource::Path { path } => Some((p.name, path)),
_ => None,
})
.collect::<std::collections::HashMap<String, String>>()
})
} else {
None
};
for (name, dep) in &toml_data.packages {
if let Some(t) = target_filter {
if t != name.as_str() {
continue;
}
}
let raw = match dep {
PackageDep::Path { path, .. } => path,
_ => continue,
};
let resolved_raw = lock_lookup
.as_ref()
.and_then(|m| m.get(name).cloned())
.unwrap_or_else(|| raw.clone());
let p = Path::new(&resolved_raw);
let abs = if p.is_absolute() {
p.to_path_buf()
} else {
root.join(p)
};
if abs.exists() {
continue;
}
let suggestion = match src {
ProjectPathSource::Toml => {
format!("update or remove [packages.{name}] in alc.toml")
}
ProjectPathSource::Local => {
format!("alc_pkg_unlink({name:?}) or update [packages.{name}] in alc.local.toml")
}
};
unrepairable.push(serde_json::json!({
"name": name,
"kind": "path_missing",
"scope": scope,
"reason": format!("declared path does not exist: {}", abs.display()),
"suggestion": suggestion,
}));
}
}
pub(super) fn collect_unregistered_pkg_dirs(
pkg_dir: &Path,
registered: &HashSet<String>,
registered_paths: &[PathBuf],
target_filter: Option<&str>,
) -> Result<Vec<serde_json::Value>, String> {
let read = match std::fs::read_dir(pkg_dir) {
Ok(r) => r,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(vec![]);
}
Err(e) => {
return Err(format!(
"pkg: failed to read packages_dir at {}: {e}",
pkg_dir.display()
));
}
};
let mut entries = Vec::new();
for dir_entry_result in read {
let dir_entry = match dir_entry_result {
Ok(e) => e,
Err(e) => {
tracing::warn!(
"pkg: skipping unreadable entry in {}: {e}",
pkg_dir.display()
);
continue;
}
};
let path = dir_entry.path();
let pkg_name = dir_entry.file_name().to_string_lossy().to_string();
if let Some(target) = target_filter {
if target != pkg_name.as_str() {
continue;
}
}
if registered.contains(&pkg_name) {
continue;
}
let meta = match path.symlink_metadata() {
Ok(m) => m,
Err(e) => {
tracing::warn!("pkg: cannot stat {}: {e}", path.display());
continue;
}
};
if !meta.is_dir() {
continue;
}
if !path.join("init.lua").exists() {
continue;
}
let canonical_pkg_path = match path.canonicalize() {
Ok(c) => c,
Err(e) => {
return Err(format!(
"pkg: failed to canonicalize existing dir {}: {e}",
path.display()
));
}
};
if registered_paths.contains(&canonical_pkg_path) {
continue;
}
let abs_path = path.display().to_string();
let suggestion = serde_json::json!([
format!(
"If this pkg was scaffolded outside `alc_pkg_scaffold` and you want it installed: \
`alc_pkg_install --force {abs_path}` (re-copy + register in installed.json)"
),
format!(
"If you are actively iterating on this pkg in-tree: \
`alc_pkg_link {abs_path}` (symlink-based, no copy)"
),
format!("If this dir is stale/abandoned: `rm -rf {abs_path}` to clean it up"),
"Note: source is unknown — git URL cannot be inferred from the bare directory. \
Re-record via one of the above."
.to_string(),
]);
entries.push(serde_json::json!({
"name": pkg_name,
"kind": "unregistered_pkg",
"source": "unknown",
"reason": format!(
"physical dir with init.lua exists but is not registered in \
installed.json, alc.toml, or alc.local.toml: {}",
path.display()
),
"suggestion": suggestion,
}));
}
Ok(entries)
}
impl AppService {
pub(super) async fn collect_alive_unregistered_symlinks(
&self,
pkg_dir: &Path,
registered: &HashSet<String>,
registered_paths: &[PathBuf],
target_filter: Option<&str>,
) -> Result<Vec<(String, AliveBucket)>, String> {
let read = match std::fs::read_dir(pkg_dir) {
Ok(r) => r,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(vec![]);
}
Err(e) => {
return Err(format!(
"pkg: failed to read packages_dir at {}: {e}",
pkg_dir.display()
));
}
};
let mut entries = Vec::new();
for dir_entry_result in read {
let dir_entry = match dir_entry_result {
Ok(e) => e,
Err(e) => {
tracing::warn!(
"pkg: skipping unreadable entry in {}: {e}",
pkg_dir.display()
);
continue;
}
};
let path = dir_entry.path();
let pkg_name = dir_entry.file_name().to_string_lossy().to_string();
if let Some(target) = target_filter {
if target != pkg_name.as_str() {
continue;
}
}
if registered.contains(&pkg_name) {
continue;
}
let meta = match path.symlink_metadata() {
Ok(m) => m,
Err(e) => {
tracing::warn!("pkg: cannot stat {}: {e}", path.display());
continue;
}
};
if !meta.file_type().is_symlink() {
continue;
}
let target_exists = path.try_exists().unwrap_or(false);
if !target_exists {
continue;
}
if !path.join("init.lua").exists() {
continue;
}
let canonical_pkg_path = match path.canonicalize() {
Ok(c) => c,
Err(e) => {
return Err(format!(
"pkg: failed to canonicalize symlink target {}: {e}",
path.display()
));
}
};
if registered_paths.contains(&canonical_pkg_path) {
continue;
}
entries.push((pkg_name, AliveBucket::Unregistered));
}
Ok(entries)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
mod alive_symlink_tests {
use super::super::super::super::test_support::make_app_service_at;
use super::*;
use std::os::unix::fs::symlink as unix_symlink;
fn write_auto_library_init_lua(pkg_dir: &std::path::Path, pkg_name: &str) {
let pkg = pkg_dir.join(pkg_name);
std::fs::create_dir_all(&pkg).expect("create pkg dir");
std::fs::write(
pkg.join("init.lua"),
format!("local M = {{}}\nM.meta = {{ name = \"{pkg_name}\" }}\nreturn M\n"),
)
.expect("write init.lua");
}
fn write_explicit_type_init_lua(pkg_dir: &std::path::Path, pkg_name: &str) {
let pkg = pkg_dir.join(pkg_name);
std::fs::create_dir_all(&pkg).expect("create pkg dir");
std::fs::write(
pkg.join("init.lua"),
format!(
"local M = {{}}\nM.meta = {{ name = \"{pkg_name}\", type = \"library\" }}\nreturn M\n"
),
)
.expect("write init.lua");
}
#[tokio::test]
async fn dangling_symlink_excluded() {
let tmp = tempfile::tempdir().expect("create tempdir");
let pkg_dir = tmp.path().join("packages");
std::fs::create_dir_all(&pkg_dir).expect("create packages dir");
let link = pkg_dir.join("ghost_pkg");
unix_symlink(tmp.path().join("does_not_exist"), &link)
.expect("create dangling symlink");
let svc = make_app_service_at(tmp.path().to_path_buf()).await;
let registered = HashSet::new();
let registered_paths: Vec<PathBuf> = vec![];
let result = svc
.collect_alive_unregistered_symlinks(&pkg_dir, ®istered, ®istered_paths, None)
.await
.expect("helper should not error");
assert!(
result.is_empty(),
"dangling symlink must not appear in result"
);
}
#[tokio::test]
async fn alive_unregistered_is_always_unregistered() {
let tmp = tempfile::tempdir().expect("create tempdir");
let real_pkgs = tmp.path().join("real");
let pkg_dir = tmp.path().join("packages");
std::fs::create_dir_all(&pkg_dir).expect("create packages dir");
write_auto_library_init_lua(&real_pkgs, "my_lib");
unix_symlink(real_pkgs.join("my_lib"), pkg_dir.join("my_lib"))
.expect("create alive symlink");
let svc = make_app_service_at(tmp.path().to_path_buf()).await;
let registered = HashSet::new();
let registered_paths: Vec<PathBuf> = vec![];
let result = svc
.collect_alive_unregistered_symlinks(&pkg_dir, ®istered, ®istered_paths, None)
.await
.expect("helper should not error");
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, "my_lib");
assert_eq!(result[0].1, AliveBucket::Unregistered);
}
#[tokio::test]
async fn alive_unregistered_explicit_type_routes_to_unregistered() {
let tmp = tempfile::tempdir().expect("create tempdir");
let real_pkgs = tmp.path().join("real");
let pkg_dir = tmp.path().join("packages");
std::fs::create_dir_all(&pkg_dir).expect("create packages dir");
write_explicit_type_init_lua(&real_pkgs, "explicit_lib");
unix_symlink(real_pkgs.join("explicit_lib"), pkg_dir.join("explicit_lib"))
.expect("create alive symlink");
let svc = make_app_service_at(tmp.path().to_path_buf()).await;
let registered = HashSet::new();
let registered_paths: Vec<PathBuf> = vec![];
let result = svc
.collect_alive_unregistered_symlinks(&pkg_dir, ®istered, ®istered_paths, None)
.await
.expect("helper should not error");
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, "explicit_lib");
assert_eq!(result[0].1, AliveBucket::Unregistered);
}
#[tokio::test]
async fn alive_registered_pkg_excluded() {
let tmp = tempfile::tempdir().expect("create tempdir");
let real_pkgs = tmp.path().join("real");
let pkg_dir = tmp.path().join("packages");
std::fs::create_dir_all(&pkg_dir).expect("create packages dir");
write_auto_library_init_lua(&real_pkgs, "known_pkg");
unix_symlink(real_pkgs.join("known_pkg"), pkg_dir.join("known_pkg"))
.expect("create alive symlink");
let svc = make_app_service_at(tmp.path().to_path_buf()).await;
let mut registered = HashSet::new();
registered.insert("known_pkg".to_string());
let registered_paths: Vec<PathBuf> = vec![];
let result = svc
.collect_alive_unregistered_symlinks(&pkg_dir, ®istered, ®istered_paths, None)
.await
.expect("helper should not error");
assert!(
result.is_empty(),
"registered pkg must not appear in result"
);
}
#[tokio::test]
async fn target_filter_restricts_output() {
let tmp = tempfile::tempdir().expect("create tempdir");
let real_pkgs = tmp.path().join("real");
let pkg_dir = tmp.path().join("packages");
std::fs::create_dir_all(&pkg_dir).expect("create packages dir");
write_auto_library_init_lua(&real_pkgs, "lib_a");
write_auto_library_init_lua(&real_pkgs, "lib_b");
unix_symlink(real_pkgs.join("lib_a"), pkg_dir.join("lib_a"))
.expect("create symlink lib_a");
unix_symlink(real_pkgs.join("lib_b"), pkg_dir.join("lib_b"))
.expect("create symlink lib_b");
let svc = make_app_service_at(tmp.path().to_path_buf()).await;
let registered = HashSet::new();
let registered_paths: Vec<PathBuf> = vec![];
let result = svc
.collect_alive_unregistered_symlinks(
&pkg_dir,
®istered,
®istered_paths,
Some("lib_a"),
)
.await
.expect("helper should not error");
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, "lib_a");
}
#[tokio::test]
async fn registered_path_dep_excluded() {
let tmp = tempfile::tempdir().expect("create tempdir");
let real_pkgs = tmp.path().join("real");
let pkg_dir = tmp.path().join("packages");
std::fs::create_dir_all(&pkg_dir).expect("create packages dir");
write_auto_library_init_lua(&real_pkgs, "path_dep_lib");
let real_dir = real_pkgs.join("path_dep_lib");
unix_symlink(&real_dir, pkg_dir.join("path_dep_lib")).expect("create alive symlink");
let canonical = real_dir.canonicalize().expect("canonicalize real dir");
let svc = make_app_service_at(tmp.path().to_path_buf()).await;
let registered = HashSet::new();
let registered_paths = vec![canonical];
let result = svc
.collect_alive_unregistered_symlinks(&pkg_dir, ®istered, ®istered_paths, None)
.await
.expect("helper should not error");
assert!(
result.is_empty(),
"path-dep registered entry must not appear in result"
);
}
}
}