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::project::resolve_project_root;
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")
}
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 = resolve_project_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("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, Some(name.to_string()), 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,
}));
}
}