use std::path::Path;
use algocline_core::PkgEntity;
use tracing::warn;
use super::super::manifest::{load_manifest, Manifest, ManifestEntry};
use super::super::resolve::packages_dir;
use super::super::source::PackageSource;
use super::super::AppService;
use super::repair::{
collect_path_missing, collect_unattached_dangling_symlinks, symlink_dangling_suggestion,
ProjectPathSource,
};
const DOCTOR_CACHE_TTL_SECS: u64 = 3600;
#[derive(Debug)]
enum DoctorOutcome {
Healthy,
SymlinkDangling { reason: String, suggestion: String },
InstalledMissing { reason: String, suggestion: String },
IncompletePkg {
missing_subs: Vec<String>,
suggestion: String,
},
MissingMeta { reason: String, suggestion: String },
SpecMissing { reason: String, suggestion: String },
}
#[derive(Default)]
struct DoctorBuckets {
healthy: Vec<serde_json::Value>,
installed_missing: Vec<serde_json::Value>,
symlink_dangling: Vec<serde_json::Value>,
path_missing: Vec<serde_json::Value>,
incomplete_pkg: Vec<serde_json::Value>,
missing_meta: Vec<serde_json::Value>,
missing_hub_index: Vec<serde_json::Value>,
spec_missing: Vec<serde_json::Value>,
stale_cache: Vec<serde_json::Value>,
}
impl DoctorBuckets {
fn any_matched(&self) -> bool {
!self.healthy.is_empty()
|| !self.installed_missing.is_empty()
|| !self.symlink_dangling.is_empty()
|| !self.path_missing.is_empty()
|| !self.incomplete_pkg.is_empty()
|| !self.missing_meta.is_empty()
|| !self.missing_hub_index.is_empty()
|| !self.spec_missing.is_empty()
|| !self.stale_cache.is_empty()
}
fn into_json(self) -> String {
serde_json::json!({
"healthy": self.healthy,
"incomplete_pkg": self.incomplete_pkg,
"installed_missing": self.installed_missing,
"missing_hub_index": self.missing_hub_index,
"missing_meta": self.missing_meta,
"path_missing": self.path_missing,
"spec_missing": self.spec_missing,
"stale_cache": self.stale_cache,
"symlink_dangling": self.symlink_dangling,
})
.to_string()
}
}
fn extract_required_subs(lua_src: &str, pkg_name: &str) -> Vec<String> {
let mut subs = Vec::new();
let prefix = format!("{pkg_name}.");
let mut remaining = lua_src;
while let Some(pos) = remaining.find("require") {
remaining = &remaining[pos + "require".len()..];
let trimmed = remaining.trim_start_matches([' ', '\t']);
if !trimmed.starts_with('(') {
continue;
}
let after_paren = &trimmed[1..];
let after_paren = after_paren.trim_start_matches([' ', '\t']);
let quote = match after_paren.chars().next() {
Some(q @ '"') | Some(q @ '\'') => q,
_ => continue,
};
let content = &after_paren[1..];
let end = match content.find(quote) {
Some(i) => i,
None => continue,
};
let module = &content[..end];
if let Some(sub) = module.strip_prefix(&prefix) {
if !sub.is_empty() && !sub.contains('.') {
subs.push(sub.to_string());
}
}
}
subs.sort();
subs.dedup();
subs
}
fn incomplete_pkg_suggestion(name: &str, is_symlink: bool) -> String {
if is_symlink {
format!("Re-run alc_pkg_link <path> to re-link {name:?} with the complete source directory")
} else {
format!(
"Run alc_pkg_install --force {name:?} to reinstall {name:?} with all submodule files"
)
}
}
fn installed_missing_suggestion(name: &str, entry_source: &PackageSource) -> String {
match entry_source {
PackageSource::Bundled { .. } => {
"alc_init (reinstalls bundled packages from the algocline binary)".to_string()
}
PackageSource::Path { path } => {
format!("alc_pkg_install({path:?}) to reinstall {name:?} from local path")
}
PackageSource::Git { url, .. } => {
format!("alc_pkg_install({url:?}) to reinstall {name:?} from Git")
}
PackageSource::Installed => {
format!(
"alc_pkg_install <path-or-url> to re-record source for {name:?} \
(legacy 'installed' marker carries no path)"
)
}
PackageSource::Unknown => {
format!(
"alc_hub_reindex then alc_pkg_install <path-or-url> for {name:?} \
(source unknown — legacy entry)"
)
}
}
}
fn push_doctor_outcome(name: &str, outcome: DoctorOutcome, buckets: &mut DoctorBuckets) {
match outcome {
DoctorOutcome::Healthy => buckets.healthy.push(serde_json::json!({
"name": name,
})),
DoctorOutcome::SymlinkDangling { reason, suggestion } => {
buckets.symlink_dangling.push(serde_json::json!({
"name": name,
"kind": "symlink_dangling",
"reason": reason,
"suggestion": suggestion,
}))
}
DoctorOutcome::InstalledMissing { reason, suggestion } => {
buckets.installed_missing.push(serde_json::json!({
"name": name,
"kind": "installed_missing",
"reason": reason,
"suggestion": suggestion,
}))
}
DoctorOutcome::IncompletePkg {
missing_subs,
suggestion,
} => buckets.incomplete_pkg.push(serde_json::json!({
"name": name,
"kind": "incomplete_pkg",
"missing_subs": missing_subs,
"suggestion": suggestion,
})),
DoctorOutcome::MissingMeta { reason, suggestion } => {
buckets.missing_meta.push(serde_json::json!({
"name": name,
"kind": "missing_meta",
"reason": reason,
"suggestion": suggestion,
}))
}
DoctorOutcome::SpecMissing { reason, suggestion } => {
buckets.spec_missing.push(serde_json::json!({
"name": name,
"kind": "spec_missing",
"reason": reason,
"suggestion": suggestion,
}))
}
}
}
fn check_incomplete(name: &str, dest: &Path, is_symlink: bool) -> Option<DoctorOutcome> {
let init_lua = dest.join("init.lua");
let src = match std::fs::read_to_string(&init_lua) {
Ok(s) => s,
Err(e) => {
warn!(
error = %e,
path = %init_lua.display(),
"could not read init.lua for incomplete check; skipping"
);
return None;
}
};
let required_subs = extract_required_subs(&src, name);
if required_subs.is_empty() {
return None;
}
let missing: Vec<String> = required_subs
.into_iter()
.filter(|sub| {
let as_file = dest.join(format!("{sub}.lua"));
let as_dir = dest.join(sub).join("init.lua");
!as_file.exists() && !as_dir.exists()
})
.collect();
if missing.is_empty() {
return None;
}
Some(DoctorOutcome::IncompletePkg {
missing_subs: missing,
suggestion: incomplete_pkg_suggestion(name, is_symlink),
})
}
fn check_missing_meta(name: &str, dest: &Path) -> Option<DoctorOutcome> {
let init_lua = dest.join("init.lua");
if PkgEntity::parse_from_init_lua(&init_lua).is_some() {
return None;
}
Some(DoctorOutcome::MissingMeta {
reason: format!("init.lua at {} lacks M.meta.name", init_lua.display()),
suggestion: format!(
"Package directory at {} lacks M.meta.name in init.lua — \
run alc_pkg_install --force {name:?} or fix init.lua to declare \
M.meta = {{ name = ..., version = ... }}",
dest.display()
),
})
}
fn check_spec_missing(name: &str, dest: &Path) -> Result<Option<DoctorOutcome>, String> {
let spec_dir = dest.join("spec");
if !spec_dir.is_dir() {
return Ok(None);
}
let entries = std::fs::read_dir(&spec_dir).map_err(|e| {
format!(
"spec_missing: failed to read_dir {}: {e}",
spec_dir.display()
)
})?;
let mut found_spec = false;
for entry in entries {
let entry = entry.map_err(|e| format!("spec_missing: failed to read dir entry: {e}"))?;
let ft = entry.file_type().map_err(|e| {
format!(
"spec_missing: failed to read file_type for {}: {e}",
entry.path().display()
)
})?;
if !ft.is_file() {
continue;
}
let fname = entry.file_name();
if fname.to_string_lossy().ends_with("_spec.lua") {
found_spec = true;
break;
}
}
if found_spec {
return Ok(None);
}
Ok(Some(DoctorOutcome::SpecMissing {
reason: format!(
"spec directory at {} exists but contains zero *_spec.lua files",
spec_dir.display()
),
suggestion: format!(
"Package {name:?} declared test intent by creating spec/ at {} — \
add at least one <name>_spec.lua file (mlua-lspec convention) or remove \
the spec/ directory to opt out of spec discipline",
spec_dir.display()
),
}))
}
fn classify_installed(
name: &str,
entry: &ManifestEntry,
pkg_dir: &Path,
) -> Result<DoctorOutcome, String> {
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 = match dest.try_exists() {
Ok(v) => v,
Err(e) => {
warn!(error = %e, path = %dest.display(), "try_exists failed; treating symlink target as dead");
false
}
};
if target_alive {
if let Some(incomplete) = check_incomplete(name, &dest, true) {
return Ok(incomplete);
}
if let Some(mm) = check_missing_meta(name, &dest) {
return Ok(mm);
}
if let Some(sm) = check_spec_missing(name, &dest)? {
return Ok(sm);
}
return Ok(DoctorOutcome::Healthy);
}
let link_target = match dest.read_link() {
Ok(t) => t.display().to_string(),
Err(e) => {
warn!(error = %e, path = %dest.display(), "read_link failed; using placeholder for dangling target");
"<unknown>".to_string()
}
};
return Ok(DoctorOutcome::SymlinkDangling {
reason: format!("symlink target missing: {link_target}"),
suggestion: symlink_dangling_suggestion(name),
});
}
if dest.exists() {
if let Some(incomplete) = check_incomplete(name, &dest, false) {
return Ok(incomplete);
}
if let Some(mm) = check_missing_meta(name, &dest) {
return Ok(mm);
}
if let Some(sm) = check_spec_missing(name, &dest)? {
return Ok(sm);
}
return Ok(DoctorOutcome::Healthy);
}
Ok(DoctorOutcome::InstalledMissing {
reason: format!("installed directory missing: {}", dest.display()),
suggestion: installed_missing_suggestion(name, &entry.source),
})
}
fn run_manifest_pass(
manifest: &Manifest,
target_filter: Option<&str>,
pkg_dir: &Path,
buckets: &mut DoctorBuckets,
) -> Result<(), String> {
if let Some(target) = target_filter {
if let Some(entry) = manifest.packages.get(target) {
let outcome = classify_installed(target, entry, pkg_dir)?;
push_doctor_outcome(target, outcome, buckets);
}
return Ok(());
}
for (pkg_name, entry) in &manifest.packages {
let outcome = classify_installed(pkg_name, entry, pkg_dir)?;
push_doctor_outcome(pkg_name, outcome, buckets);
}
Ok(())
}
fn run_unattached_symlink_pass(
pkg_dir: &Path,
target_filter: Option<&str>,
manifest: &Manifest,
buckets: &mut DoctorBuckets,
) {
let mut scratch: Vec<serde_json::Value> = Vec::new();
collect_unattached_dangling_symlinks(pkg_dir, target_filter, &manifest.packages, &mut scratch);
buckets.symlink_dangling.extend(scratch);
}
fn run_path_missing_pass(
resolved_root: Option<&Path>,
target_filter: Option<&str>,
buckets: &mut DoctorBuckets,
) {
let Some(root) = resolved_root else {
return;
};
let mut scratch: Vec<serde_json::Value> = Vec::new();
collect_path_missing(
root,
target_filter,
"project",
&mut scratch,
ProjectPathSource::Toml,
);
collect_path_missing(
root,
target_filter,
"variant",
&mut scratch,
ProjectPathSource::Local,
);
buckets.path_missing.extend(scratch);
}
fn run_hub_index_pass(root: &Path, buckets: &mut DoctorBuckets) -> Result<(), String> {
let mut pkg_count = 0usize;
let entries = std::fs::read_dir(root).map_err(|e| {
format!(
"hub_index_pass: failed to read project_root {}: {e}",
root.display()
)
})?;
for entry in entries {
let entry = entry.map_err(|e| format!("hub_index_pass: failed to read dir entry: {e}"))?;
let ft = entry
.file_type()
.map_err(|e| format!("hub_index_pass: failed to read file_type: {e}"))?;
if !ft.is_dir() {
continue;
}
let init_lua = entry.path().join("init.lua");
let exists = init_lua.try_exists().map_err(|e| {
format!(
"hub_index_pass: try_exists failed for {}: {e}",
init_lua.display()
)
})?;
if exists {
pkg_count += 1;
}
}
if pkg_count < 2 {
return Ok(());
}
let hub_index = root.join("hub_index.json");
let has_index = hub_index.try_exists().map_err(|e| {
format!(
"hub_index_pass: try_exists failed for {}: {e}",
hub_index.display()
)
})?;
if has_index {
return Ok(());
}
buckets.missing_hub_index.push(serde_json::json!({
"kind": "missing_hub_index",
"project_root": root.display().to_string(),
"pkg_count": pkg_count,
"suggestion": format!(
"Collection project root contains {pkg_count} package dirs but \
{}/hub_index.json is missing — run alc_hub_reindex --source_dir {} \
to generate it",
root.display(),
root.display()
),
}));
Ok(())
}
fn run_stale_cache_pass(cache_dir: &Path, buckets: &mut DoctorBuckets) -> Result<(), String> {
let exists = cache_dir.try_exists().map_err(|e| {
format!(
"stale_cache_pass: try_exists failed for {}: {e}",
cache_dir.display()
)
})?;
if !exists {
return Ok(());
}
let entries = std::fs::read_dir(cache_dir).map_err(|e| {
format!(
"stale_cache_pass: failed to read_dir {}: {e}",
cache_dir.display()
)
})?;
for entry in entries {
let entry =
entry.map_err(|e| format!("stale_cache_pass: failed to read dir entry: {e}"))?;
let ft = entry.file_type().map_err(|e| {
format!(
"stale_cache_pass: failed to read file_type for {}: {e}",
entry.path().display()
)
})?;
if !ft.is_file() {
continue;
}
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let metadata = entry.metadata().map_err(|e| {
format!(
"stale_cache_pass: failed to read metadata for {}: {e}",
path.display()
)
})?;
let Some(modified) = metadata.modified().ok() else {
continue;
};
let Some(age) = modified.elapsed().ok() else {
continue;
};
if age.as_secs() <= DOCTOR_CACHE_TTL_SECS {
continue;
}
buckets.stale_cache.push(serde_json::json!({
"kind": "stale_cache",
"path": path.display().to_string(),
"age_secs": age.as_secs(),
"suggestion": format!(
"Run alc_hub_search to refresh stale cache (>{DOCTOR_CACHE_TTL_SECS}s old)"
),
}));
}
Ok(())
}
impl AppService {
pub async fn pkg_doctor(
&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 target_filter = name.as_deref();
let mut buckets = DoctorBuckets::default();
run_manifest_pass(&manifest, target_filter, &pkg_dir, &mut buckets)?;
run_unattached_symlink_pass(&pkg_dir, target_filter, &manifest, &mut buckets);
run_path_missing_pass(resolved_root.as_deref(), target_filter, &mut buckets);
if target_filter.is_none() {
run_stale_cache_pass(&app_dir.hub_cache_dir(), &mut buckets)?;
if let Some(ref root) = resolved_root {
run_hub_index_pass(root, &mut buckets)?;
}
}
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())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn mk_entry(source: &str) -> ManifestEntry {
ManifestEntry {
version: None,
source: PackageSource::Path {
path: source.to_string(),
},
installed_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
}
}
#[test]
fn classify_installed_healthy_dir() {
let tmp = tempfile::tempdir().unwrap();
let pkg_dir = tmp.path();
let dest = pkg_dir.join("p");
std::fs::create_dir(&dest).unwrap();
std::fs::write(
dest.join("init.lua"),
"local M = {} M.meta = { name = \"p\", version = \"0.1.0\" } return M",
)
.unwrap();
let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir).expect("classify ok");
assert!(matches!(outcome, DoctorOutcome::Healthy));
}
#[test]
fn classify_installed_missing_dir() {
let tmp = tempfile::tempdir().unwrap();
let pkg_dir = tmp.path();
let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir).expect("classify ok");
match outcome {
DoctorOutcome::InstalledMissing { reason, suggestion } => {
assert!(
reason.contains("installed directory missing"),
"reason = {reason}"
);
assert!(
suggestion.contains("alc_pkg_install"),
"suggestion = {suggestion}"
);
assert!(
suggestion.contains("/src/p"),
"suggestion carries source: {suggestion}"
);
}
_ => panic!("expected InstalledMissing"),
}
}
#[test]
#[cfg(unix)]
fn classify_installed_symlink_dangling() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let pkg_dir = tmp.path();
let dangling_target = PathBuf::from("/nonexistent/path/for/doctor_test");
symlink(&dangling_target, pkg_dir.join("p")).unwrap();
let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir).expect("classify ok");
match outcome {
DoctorOutcome::SymlinkDangling { reason, suggestion } => {
assert!(reason.contains("symlink target missing"), "{reason}");
assert!(suggestion.contains("alc_pkg_unlink"), "{suggestion}");
}
_ => panic!("expected SymlinkDangling"),
}
}
#[test]
#[cfg(unix)]
fn classify_installed_symlink_alive() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let real_target = tmp.path().join("real_target_dir");
std::fs::create_dir(&real_target).unwrap();
std::fs::write(
real_target.join("init.lua"),
"local M = {} M.meta = { name = \"q\", version = \"0.1.0\" } return M",
)
.unwrap();
let pkg_dir = tmp.path().join("pkgs");
std::fs::create_dir(&pkg_dir).unwrap();
symlink(&real_target, pkg_dir.join("q")).unwrap();
let outcome = classify_installed("q", &mk_entry("/src/q"), &pkg_dir).expect("classify ok");
assert!(matches!(outcome, DoctorOutcome::Healthy));
}
#[test]
fn buckets_into_json_emits_all_nine_keys() {
let mut b = DoctorBuckets::default();
b.healthy.push(serde_json::json!({"name": "h"}));
b.installed_missing
.push(serde_json::json!({"name": "i", "kind": "installed_missing"}));
b.symlink_dangling
.push(serde_json::json!({"name": "s", "kind": "symlink_dangling"}));
b.path_missing
.push(serde_json::json!({"name": "p", "kind": "path_missing"}));
b.incomplete_pkg
.push(serde_json::json!({"name": "c", "kind": "incomplete_pkg"}));
b.missing_meta
.push(serde_json::json!({"name": "m", "kind": "missing_meta"}));
b.missing_hub_index
.push(serde_json::json!({"kind": "missing_hub_index", "project_root": "/r"}));
b.spec_missing
.push(serde_json::json!({"name": "sm", "kind": "spec_missing"}));
b.stale_cache
.push(serde_json::json!({"kind": "stale_cache", "path": "/p", "age_secs": 7200}));
let out = b.into_json();
let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
let obj = parsed.as_object().expect("JSON object");
assert!(obj.contains_key("healthy"));
assert!(obj.contains_key("installed_missing"));
assert!(obj.contains_key("symlink_dangling"));
assert!(obj.contains_key("path_missing"));
assert!(obj.contains_key("incomplete_pkg"));
assert!(obj.contains_key("missing_meta"));
assert!(obj.contains_key("missing_hub_index"));
assert!(obj.contains_key("spec_missing"));
assert!(obj.contains_key("stale_cache"));
assert_eq!(obj.len(), 9, "exactly nine top-level buckets: {out}");
assert_eq!(obj["healthy"][0]["name"], "h");
assert_eq!(obj["installed_missing"][0]["name"], "i");
assert_eq!(obj["symlink_dangling"][0]["name"], "s");
assert_eq!(obj["path_missing"][0]["name"], "p");
assert_eq!(obj["incomplete_pkg"][0]["name"], "c");
assert_eq!(obj["missing_meta"][0]["name"], "m");
assert_eq!(obj["missing_hub_index"][0]["project_root"], "/r");
assert_eq!(obj["spec_missing"][0]["name"], "sm");
assert_eq!(obj["stale_cache"][0]["path"], "/p");
}
#[test]
fn any_matched_tracks_all_buckets() {
let mut b = DoctorBuckets::default();
assert!(!b.any_matched());
b.healthy.push(serde_json::json!({"name": "h"}));
assert!(b.any_matched());
let mut b = DoctorBuckets::default();
b.installed_missing.push(serde_json::json!({}));
assert!(b.any_matched());
let mut b = DoctorBuckets::default();
b.symlink_dangling.push(serde_json::json!({}));
assert!(b.any_matched());
let mut b = DoctorBuckets::default();
b.path_missing.push(serde_json::json!({}));
assert!(b.any_matched());
let mut b = DoctorBuckets::default();
b.incomplete_pkg.push(serde_json::json!({}));
assert!(b.any_matched());
let mut b = DoctorBuckets::default();
b.missing_meta.push(serde_json::json!({}));
assert!(b.any_matched());
let mut b = DoctorBuckets::default();
b.missing_hub_index.push(serde_json::json!({}));
assert!(b.any_matched());
let mut b = DoctorBuckets::default();
b.spec_missing.push(serde_json::json!({}));
assert!(b.any_matched());
let mut b = DoctorBuckets::default();
b.stale_cache.push(serde_json::json!({}));
assert!(b.any_matched());
}
#[test]
fn check_spec_missing_returns_none_when_spec_file_present() {
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("mypkg");
std::fs::create_dir_all(dest.join("spec")).unwrap();
std::fs::write(dest.join("spec/foo_spec.lua"), "return {}").unwrap();
let out = check_spec_missing("mypkg", &dest).expect("must not error");
assert!(out.is_none(), "expected None, got: {out:?}");
}
#[test]
fn check_spec_missing_detects_empty_spec_dir() {
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("mypkg");
std::fs::create_dir_all(dest.join("spec")).unwrap();
let out = check_spec_missing("mypkg", &dest)
.expect("must not error")
.expect("expected SpecMissing");
match out {
DoctorOutcome::SpecMissing { reason, suggestion } => {
assert!(reason.contains("spec"), "reason: {reason}");
assert!(suggestion.contains("_spec.lua"), "suggestion: {suggestion}");
}
_ => panic!("expected SpecMissing, got {out:?}"),
}
}
#[test]
fn check_spec_missing_detects_spec_dir_with_only_non_spec_files() {
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("mypkg");
std::fs::create_dir_all(dest.join("spec")).unwrap();
std::fs::write(dest.join("spec/helper.lua"), "return {}").unwrap();
std::fs::write(dest.join("spec/README.md"), "docs").unwrap();
let out = check_spec_missing("mypkg", &dest)
.expect("must not error")
.expect("expected SpecMissing");
assert!(matches!(out, DoctorOutcome::SpecMissing { .. }));
}
#[test]
fn check_spec_missing_silently_skips_when_spec_dir_absent() {
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("mypkg");
std::fs::create_dir_all(&dest).unwrap();
let out = check_spec_missing("mypkg", &dest).expect("must not error");
assert!(
out.is_none(),
"expected None for absent spec/, got: {out:?}"
);
}
#[test]
fn run_stale_cache_pass_emits_when_file_older_than_ttl() {
let tmp = tempfile::tempdir().unwrap();
let cache_dir = tmp.path();
let stale_file = cache_dir.join("abc123.json");
std::fs::write(&stale_file, "{}").unwrap();
let past = std::time::SystemTime::now() - std::time::Duration::from_secs(7200);
let times = std::fs::FileTimes::new().set_modified(past);
let f = std::fs::OpenOptions::new()
.write(true)
.open(&stale_file)
.unwrap();
f.set_times(times).unwrap();
let mut buckets = DoctorBuckets::default();
run_stale_cache_pass(cache_dir, &mut buckets).expect("must not error");
assert_eq!(
buckets.stale_cache.len(),
1,
"expected 1 stale entry: {:?}",
buckets.stale_cache
);
let entry = &buckets.stale_cache[0];
assert_eq!(entry["kind"], "stale_cache");
assert!(entry["path"]
.as_str()
.unwrap_or("")
.ends_with("abc123.json"));
assert!(entry["age_secs"].as_u64().unwrap_or(0) >= 7200);
}
#[test]
fn run_stale_cache_pass_no_emit_for_fresh_file() {
let tmp = tempfile::tempdir().unwrap();
let cache_dir = tmp.path();
let fresh_file = cache_dir.join("xyz789.json");
std::fs::write(&fresh_file, "{}").unwrap();
let mut buckets = DoctorBuckets::default();
run_stale_cache_pass(cache_dir, &mut buckets).expect("must not error");
assert!(
buckets.stale_cache.is_empty(),
"expected no stale entries for fresh file"
);
}
#[test]
fn run_stale_cache_pass_skips_when_cache_dir_absent() {
let tmp = tempfile::tempdir().unwrap();
let missing_dir = tmp.path().join("nonexistent_cache");
let mut buckets = DoctorBuckets::default();
run_stale_cache_pass(&missing_dir, &mut buckets).expect("absent dir must skip with Ok");
assert!(buckets.stale_cache.is_empty());
}
#[test]
fn run_stale_cache_pass_ignores_non_json_files() {
let tmp = tempfile::tempdir().unwrap();
let cache_dir = tmp.path();
let garbage = cache_dir.join(".DS_Store");
std::fs::write(&garbage, "garbage").unwrap();
let past = std::time::SystemTime::now() - std::time::Duration::from_secs(7200);
let times = std::fs::FileTimes::new().set_modified(past);
let f = std::fs::OpenOptions::new()
.write(true)
.open(&garbage)
.unwrap();
f.set_times(times).unwrap();
let mut buckets = DoctorBuckets::default();
run_stale_cache_pass(cache_dir, &mut buckets).expect("must not error");
assert!(
buckets.stale_cache.is_empty(),
"non-json files must be ignored"
);
}
#[test]
fn classify_installed_missing_meta_when_init_lua_lacks_meta() {
let tmp = tempfile::tempdir().unwrap();
let pkg_dir = tmp.path();
let dest = pkg_dir.join("mypkg");
std::fs::create_dir(&dest).unwrap();
std::fs::write(dest.join("init.lua"), "local M = {} return M").unwrap();
let outcome =
classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
match outcome {
DoctorOutcome::MissingMeta { reason, suggestion } => {
assert!(reason.contains("lacks M.meta.name"), "reason: {reason}");
assert!(
suggestion.contains("alc_pkg_install"),
"suggestion: {suggestion}"
);
assert!(
suggestion.contains("mypkg"),
"suggestion carries name: {suggestion}"
);
}
_ => panic!("expected MissingMeta, got {outcome:?}"),
}
}
#[test]
fn classify_installed_missing_meta_when_init_lua_has_empty_meta_name() {
let tmp = tempfile::tempdir().unwrap();
let pkg_dir = tmp.path();
let dest = pkg_dir.join("mypkg");
std::fs::create_dir(&dest).unwrap();
std::fs::write(
dest.join("init.lua"),
"local M = {} M.meta = { name = \"\", version = \"0.1.0\" } return M",
)
.unwrap();
let outcome =
classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
assert!(
matches!(outcome, DoctorOutcome::MissingMeta { .. }),
"expected MissingMeta for empty name, got {outcome:?}"
);
}
#[test]
fn classify_installed_no_missing_meta_when_init_lua_complete() {
let tmp = tempfile::tempdir().unwrap();
let pkg_dir = tmp.path();
let dest = pkg_dir.join("mypkg");
std::fs::create_dir(&dest).unwrap();
std::fs::write(
dest.join("init.lua"),
"local M = {} M.meta = { name = \"mypkg\", version = \"0.1.0\" } return M",
)
.unwrap();
let outcome =
classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
assert!(
matches!(outcome, DoctorOutcome::Healthy),
"expected Healthy for complete init.lua, got {outcome:?}"
);
}
#[test]
fn run_hub_index_pass_emits_when_2_plus_pkgs_and_index_absent() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for name in &["pkg_a", "pkg_b"] {
let dir = root.join(name);
std::fs::create_dir(&dir).unwrap();
std::fs::write(dir.join("init.lua"), "return {}").unwrap();
}
let mut buckets = DoctorBuckets::default();
run_hub_index_pass(root, &mut buckets).expect("run_hub_index_pass must not error");
assert_eq!(
buckets.missing_hub_index.len(),
1,
"expected 1 missing_hub_index entry: {:?}",
buckets.missing_hub_index
);
let entry = &buckets.missing_hub_index[0];
assert_eq!(entry["kind"], "missing_hub_index");
assert_eq!(entry["pkg_count"], 2);
assert!(
entry["suggestion"]
.as_str()
.unwrap_or("")
.contains("alc_hub_reindex"),
"suggestion: {entry}"
);
}
#[test]
fn run_hub_index_pass_skips_when_only_1_pkg_dir() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let dir = root.join("pkg_a");
std::fs::create_dir(&dir).unwrap();
std::fs::write(dir.join("init.lua"), "return {}").unwrap();
let mut buckets = DoctorBuckets::default();
run_hub_index_pass(root, &mut buckets).expect("run_hub_index_pass must not error");
assert!(
buckets.missing_hub_index.is_empty(),
"must not emit with only 1 pkg dir: {:?}",
buckets.missing_hub_index
);
}
#[test]
fn run_hub_index_pass_skips_when_hub_index_exists() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for name in &["pkg_a", "pkg_b"] {
let dir = root.join(name);
std::fs::create_dir(&dir).unwrap();
std::fs::write(dir.join("init.lua"), "return {}").unwrap();
}
std::fs::write(root.join("hub_index.json"), "{}").unwrap();
let mut buckets = DoctorBuckets::default();
run_hub_index_pass(root, &mut buckets).expect("run_hub_index_pass must not error");
assert!(
buckets.missing_hub_index.is_empty(),
"must not emit when hub_index.json exists: {:?}",
buckets.missing_hub_index
);
}
#[test]
fn installed_missing_suggestion_shape() {
let git = PackageSource::Git {
url: "github.com/foo/bar".to_string(),
rev: None,
};
let s = installed_missing_suggestion("ucb", &git);
assert!(s.contains("alc_pkg_install"), "{s}");
assert!(s.contains("\"ucb\""), "{s}");
assert!(s.contains("github.com/foo/bar"), "{s}");
}
#[test]
fn installed_missing_suggestion_routes_bundled_to_alc_init() {
let bundled = PackageSource::Bundled { collection: None };
let s = installed_missing_suggestion("ucb", &bundled);
assert!(s.contains("alc_init"), "bundled must suggest alc_init: {s}");
assert!(
!s.contains("alc_pkg_install"),
"bundled must NOT suggest alc_pkg_install: {s}"
);
}
#[test]
fn installed_missing_suggestion_routes_absolute_path_to_pkg_install() {
let local = PackageSource::Path {
path: "/abs/path/to/src".to_string(),
};
let s = installed_missing_suggestion("local_pkg", &local);
assert!(s.contains("alc_pkg_install"), "{s}");
assert!(s.contains("/abs/path/to/src"), "{s}");
}
#[test]
fn installed_missing_suggestion_routes_unknown_to_reindex() {
let s = installed_missing_suggestion("legacy_pkg", &PackageSource::Unknown);
assert!(
s.contains("alc_hub_reindex"),
"Unknown must suggest alc_hub_reindex: {s}"
);
}
#[test]
fn extract_subs_double_quote() {
let src = r#"
local M = {}
local check = require("mypkg.check")
local t = require("mypkg.t")
return M
"#;
let subs = extract_required_subs(src, "mypkg");
assert_eq!(subs, vec!["check", "t"]);
}
#[test]
fn extract_subs_single_quote() {
let src = "local x = require('mypkg.sub')";
let subs = extract_required_subs(src, "mypkg");
assert_eq!(subs, vec!["sub"]);
}
#[test]
fn extract_subs_ignores_other_packages() {
let src = r#"
local x = require("other.sub")
local y = require("mypkg.mine")
"#;
let subs = extract_required_subs(src, "mypkg");
assert_eq!(subs, vec!["mine"]);
}
#[test]
fn extract_subs_deduplicates() {
let src = r#"
local a = require("mypkg.check")
local b = require("mypkg.check")
"#;
let subs = extract_required_subs(src, "mypkg");
assert_eq!(subs, vec!["check"]);
}
#[test]
fn extract_subs_ignores_dynamic_require() {
let src = r#"local x = require(mod_name)"#;
let subs = extract_required_subs(src, "mypkg");
assert!(subs.is_empty(), "dynamic require must be ignored: {subs:?}");
}
#[test]
fn extract_subs_ignores_nested_dots() {
let src = r#"local x = require("mypkg.sub.deeper")"#;
let subs = extract_required_subs(src, "mypkg");
assert!(
subs.is_empty(),
"nested dotted require must be ignored: {subs:?}"
);
}
#[test]
fn extract_subs_empty_for_no_require() {
let src = r#"local M = {} return M"#;
let subs = extract_required_subs(src, "mypkg");
assert!(subs.is_empty());
}
#[test]
fn check_incomplete_returns_none_when_all_subs_present_as_lua() {
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("mypkg");
std::fs::create_dir(&dest).unwrap();
std::fs::write(
dest.join("init.lua"),
r#"local c = require("mypkg.check") return {}"#,
)
.unwrap();
std::fs::write(dest.join("check.lua"), "return {}").unwrap();
assert!(check_incomplete("mypkg", &dest, false).is_none());
}
#[test]
fn check_incomplete_returns_none_when_sub_is_dir_init() {
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("mypkg");
std::fs::create_dir(&dest).unwrap();
std::fs::write(
dest.join("init.lua"),
r#"local c = require("mypkg.sub") return {}"#,
)
.unwrap();
std::fs::create_dir(dest.join("sub")).unwrap();
std::fs::write(dest.join("sub").join("init.lua"), "return {}").unwrap();
assert!(check_incomplete("mypkg", &dest, false).is_none());
}
#[test]
fn check_incomplete_detects_missing_sub() {
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("mypkg");
std::fs::create_dir(&dest).unwrap();
std::fs::write(
dest.join("init.lua"),
r#"
local check = require("mypkg.check")
local t = require("mypkg.t")
return {}
"#,
)
.unwrap();
std::fs::write(dest.join("check.lua"), "return {}").unwrap();
let outcome = check_incomplete("mypkg", &dest, false).expect("should detect incomplete");
match outcome {
DoctorOutcome::IncompletePkg {
missing_subs,
suggestion,
} => {
assert_eq!(missing_subs, vec!["t"], "missing_subs: {missing_subs:?}");
assert!(
suggestion.contains("alc_pkg_install"),
"non-symlink suggestion: {suggestion}"
);
}
_ => panic!("expected IncompletePkg"),
}
}
#[test]
fn check_incomplete_suggestion_uses_link_for_symlink() {
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("mypkg");
std::fs::create_dir(&dest).unwrap();
std::fs::write(
dest.join("init.lua"),
r#"local x = require("mypkg.missing") return {}"#,
)
.unwrap();
let outcome = check_incomplete("mypkg", &dest, true).expect("should detect incomplete");
match outcome {
DoctorOutcome::IncompletePkg { suggestion, .. } => {
assert!(
suggestion.contains("alc_pkg_link"),
"symlink suggestion: {suggestion}"
);
}
_ => panic!("expected IncompletePkg"),
}
}
#[test]
fn check_incomplete_returns_none_when_no_init_lua() {
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("mypkg");
std::fs::create_dir(&dest).unwrap();
assert!(check_incomplete("mypkg", &dest, false).is_none());
}
#[test]
fn classify_installed_incomplete_pkg() {
let tmp = tempfile::tempdir().unwrap();
let pkg_dir = tmp.path();
let dest = pkg_dir.join("mypkg");
std::fs::create_dir(&dest).unwrap();
std::fs::write(
dest.join("init.lua"),
r#"local x = require("mypkg.sub") return {}"#,
)
.unwrap();
let outcome =
classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
match outcome {
DoctorOutcome::IncompletePkg {
missing_subs,
suggestion,
} => {
assert_eq!(missing_subs, vec!["sub"]);
assert!(suggestion.contains("alc_pkg_install"), "{suggestion}");
}
_ => panic!("expected IncompletePkg, got {outcome:?}"),
}
}
#[test]
fn classify_installed_healthy_when_all_subs_present() {
let tmp = tempfile::tempdir().unwrap();
let pkg_dir = tmp.path();
let dest = pkg_dir.join("mypkg");
std::fs::create_dir(&dest).unwrap();
std::fs::write(
dest.join("init.lua"),
"local M = {}\n\
M.meta = { name = \"mypkg\", version = \"0.1.0\" }\n\
local x = require(\"mypkg.sub\")\n\
return M",
)
.unwrap();
std::fs::write(dest.join("sub.lua"), "return {}").unwrap();
let outcome =
classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
assert!(
matches!(outcome, DoctorOutcome::Healthy),
"expected Healthy, got {outcome:?}"
);
}
}