node-app-build 6.3.0

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! `node-app deps` — inspect and manage the node-app dep sources that
//! `node-app dev` (platform mode) resolves and pins.
//!
//! Resolution is sticky by design: `.node-app/sources.lock` pins whichever
//! source won a dep's FIRST resolution (lock → sibling → cache → clone), and
//! a cache-pinned dep never auto-updates. These commands are the supported
//! way to see and change that state:
//!
//!   node-app deps                     status: where each platform dep resolves from
//!   node-app deps update <name>|--all fetch + reset cached clones to upstream HEAD
//!   node-app deps use <name> <src>    pin a dep to an explicit source
//!   node-app deps rm <name>|--all     drop cached clone(s) + lock pins → re-resolve
//!
//! Dep names are as they appear in `infra/debian/platform-depends`, without
//! the `node-app-` prefix (e.g. `lcd`, `ldk-node`).

use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

use anyhow::{bail, Context, Result};

use super::dev::platform::parse_platform_depends;
use super::dev::sources_resolver;

// ── status ───────────────────────────────────────────────────────────────────

/// `node-app deps` — one row per entry in `platform-depends`: resolved
/// source, git rev, and working-tree state. With `fetch`, also contacts each
/// repo's remote and reports how far behind upstream the local copy is.
pub fn status(project_path: &Path, fetch: bool) -> Result<()> {
    // Canonicalize so sibling detection (parent-of-project) works when
    // invoked with the default relative path ".".
    let project_path = &project_path
        .canonicalize()
        .unwrap_or_else(|_| project_path.to_path_buf());
    let depends_path = project_path.join("infra/debian/platform-depends");
    if !depends_path.is_file() {
        bail!(
            "{} not found — run from the platform repo root (or pass --path)",
            depends_path.display()
        );
    }
    let names = parse_platform_depends(&depends_path)?;
    let lock = sources_resolver::lock_entries(project_path);

    println!("{:<20} {:<44} {:<9} STATE", "DEP", "SOURCE", "REV");
    for name in &names {
        let (source, dir) = classify(project_path, name, &lock);
        let (rev, state) = match &dir {
            Some(d) => {
                let rev = git_short_rev(d).unwrap_or_else(|| "-".into());
                let mut state = match dirty_file_count(d) {
                    Some(0) => "clean".to_string(),
                    Some(n) => format!("DIRTY ({n} files)"),
                    None => "-".to_string(),
                };
                if fetch {
                    match commits_behind(d) {
                        Some(0) => state.push_str(", up to date"),
                        Some(n) => state.push_str(&format!(", BEHIND {n}")),
                        None => state.push_str(", behind: ?"),
                    }
                }
                (rev, state)
            }
            None => ("-".into(), "-".into()),
        };
        println!("{name:<20} {source:<44} {rev:<9} {state}");
    }
    Ok(())
}

/// Where would/does `name` resolve from? Returns a human label and the
/// source dir when one exists on disk. Mirrors `sources_resolver::resolve_one`
/// order without side effects.
fn classify(
    project_root: &Path,
    name: &str,
    lock: &[(String, PathBuf)],
) -> (String, Option<PathBuf>) {
    if let Some((_, p)) = lock.iter().find(|(n, _)| n == name) {
        if sources_resolver::validate_dep_dir(p, name) {
            let label = match source_kind(project_root, name, p) {
                SourceKind::Cache => "cache".to_string(),
                SourceKind::Sibling => format!("sibling {}", p.display()),
                SourceKind::Other => format!("custom {}", p.display()),
            };
            return (label, Some(p.clone()));
        }
        // Stale pin — fall through to what the next dev run would pick.
    }
    if let Some(sib) = sources_resolver::find_sibling(project_root, name) {
        return (format!("(will use sibling {})", sib.display()), Some(sib));
    }
    if let Ok(cache) = sources_resolver::cache_dep_dir(name) {
        if sources_resolver::validate_dep_dir(&cache, name) {
            return ("(will use cache)".to_string(), Some(cache));
        }
    }
    ("(will clone)".to_string(), None)
}

enum SourceKind {
    Cache,
    Sibling,
    Other,
}

fn source_kind(project_root: &Path, name: &str, path: &Path) -> SourceKind {
    if let Ok(cache) = sources_resolver::cache_dep_dir(name) {
        if same_path(path, &cache) {
            return SourceKind::Cache;
        }
    }
    if let Some(parent) = project_root.parent() {
        if same_path(path, &parent.join(format!("node-app-{name}"))) {
            return SourceKind::Sibling;
        }
    }
    SourceKind::Other
}

fn same_path(a: &Path, b: &Path) -> bool {
    let ca = a.canonicalize().unwrap_or_else(|_| a.to_path_buf());
    let cb = b.canonicalize().unwrap_or_else(|_| b.to_path_buf());
    ca == cb
}

// ── update ───────────────────────────────────────────────────────────────────

/// `node-app deps update` — fetch + hard-reset cached clones to upstream
/// HEAD. Only ever touches the global cache; sibling checkouts and custom
/// pins are never reset. Dirty cache clones are skipped unless `force`.
pub fn update(project_path: &Path, names: &[String], all: bool, force: bool) -> Result<()> {
    let deps_root = sources_resolver::cache_root()?.join("deps");
    if !all && names.is_empty() {
        bail!("pass one or more dep names or --all");
    }
    let targets: Vec<String> = if all {
        cached_dep_names(&deps_root)
    } else {
        names.to_vec()
    };
    if targets.is_empty() {
        println!("dep cache is empty ({})", deps_root.display());
        return Ok(());
    }

    let lock = sources_resolver::lock_entries(project_path);
    let mut failed = 0usize;
    for name in &targets {
        let path = sources_resolver::cache_dep_dir(name)?;
        if !path.join(".git").is_dir() {
            println!("{name}: not cached ({}) — skipped", path.display());
            continue;
        }
        if let Some(n) = dirty_file_count(&path) {
            if n > 0 && !force {
                println!(
                    "{name}: cache clone has {n} uncommitted change(s) — skipped (--force to reset anyway)"
                );
                continue;
            }
        }
        let old = git_short_rev(&path).unwrap_or_else(|| "?".into());
        if let Err(e) = git_in(&path, &["fetch", "--quiet"]) {
            println!("{name}: git fetch failed: {e:#}");
            failed += 1;
            continue;
        }
        if let Err(e) = git_in(&path, &["reset", "--hard", "--quiet", "origin/HEAD"]) {
            println!("{name}: git reset failed: {e:#}");
            failed += 1;
            continue;
        }
        let new = git_short_rev(&path).unwrap_or_else(|| "?".into());
        if old == new {
            println!("{name}: already up to date ({new})");
        } else {
            let count = git_count(&path, &format!("{old}..{new}"))
                .map(|n| format!(" (+{n} commits)"))
                .unwrap_or_default();
            println!("{name}: {old}{new}{count}");
        }
        // Keep the lockfile's recorded rev in sync when this cache copy is
        // the pinned source (no-op otherwise).
        sources_resolver::refresh_lock_rev(project_path, name, &path)?;
        // A pin pointing elsewhere means this update won't affect dev runs.
        if let Some((_, pinned)) = lock.iter().find(|(n, _)| n == name) {
            if !same_path(pinned, &path) {
                println!(
                    "  note: '{name}' is pinned to {} — dev runs are unaffected",
                    pinned.display()
                );
            }
        }
    }
    if failed > 0 {
        bail!("{failed} dep(s) failed to update");
    }
    Ok(())
}

// ── use ──────────────────────────────────────────────────────────────────────

/// `node-app deps use` — pin a dep to an explicit source in `sources.lock`.
pub fn use_source(
    project_path: &Path,
    name: &str,
    source: Option<&Path>,
    sibling: bool,
    cache: bool,
) -> Result<()> {
    let target: PathBuf = match (source, sibling, cache) {
        (Some(p), false, false) => p.to_path_buf(),
        (None, true, false) => {
            let parent = project_path
                .canonicalize()
                .unwrap_or_else(|_| project_path.to_path_buf())
                .parent()
                .map(|p| p.to_path_buf())
                .ok_or_else(|| anyhow::anyhow!("project root has no parent directory"))?;
            parent.join(format!("node-app-{name}"))
        }
        (None, false, true) => sources_resolver::cache_dep_dir(name)?,
        _ => bail!("pass exactly one of: a source PATH, --sibling, or --cache"),
    };
    if cache && !target.is_dir() {
        bail!(
            "no cached clone at {} — the next `node-app dev` clones it, or `node-app deps rm {name}` first to force a fresh one",
            target.display()
        );
    }
    sources_resolver::pin_lock_entry(project_path, name, &target)?;
    let rev = git_short_rev(&target).unwrap_or_else(|| "-".into());
    println!("✓ '{name}' now resolves from {} (rev {rev})", target.display());
    println!("  takes effect on the next `node-app dev` run; for a live session: node-app reload {name}");
    Ok(())
}

// ── rm ───────────────────────────────────────────────────────────────────────

/// `node-app deps rm <names...>` / `--all` — delete cached clones and prune
/// any `sources.lock` entries that pointed at them so the next `node-app dev`
/// re-resolves (sibling checkout wins again if present).
pub fn rm(project_path: &Path, names: &[String], all: bool) -> Result<()> {
    let deps_root = sources_resolver::cache_root()?.join("deps");

    if !all && names.is_empty() {
        bail!("pass one or more dep names (as in platform-depends, without the node-app- prefix) or --all");
    }
    let targets: Vec<String> = if all {
        cached_dep_names(&deps_root)
    } else {
        names.to_vec()
    };

    if targets.is_empty() {
        println!("dep cache is already empty ({})", deps_root.display());
        return Ok(());
    }

    let mut removed_paths = Vec::new();
    for name in &targets {
        let path = sources_resolver::cache_dep_dir(name)?;
        if path.is_dir() {
            // Canonicalize BEFORE deletion (impossible after) — lock entries
            // store canonicalized paths, so pruning must compare like with
            // like or symlinked cache roots leave stale entries behind.
            let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
            fs::remove_dir_all(&path)
                .with_context(|| format!("remove {}", path.display()))?;
            println!("✓ removed {}", path.display());
            removed_paths.push(canonical);
        } else {
            println!("⚠ not cached: {name} ({})", path.display());
            // Still prune a lock entry pointing at the (already gone) path so
            // `rm` also repairs a half-deleted state.
            removed_paths.push(path);
        }
    }

    let pruned = sources_resolver::prune_lock_entries(project_path, &removed_paths)?;
    if !pruned.is_empty() {
        println!("✓ pruned sources.lock entries: {}", pruned.join(", "));
    }
    println!("next `node-app dev` will re-resolve: sibling ../node-app-<name> wins over a fresh clone.");
    Ok(())
}

// ── git helpers ──────────────────────────────────────────────────────────────

fn cached_dep_names(deps_root: &Path) -> Vec<String> {
    let mut names: Vec<String> = match fs::read_dir(deps_root) {
        Ok(rd) => rd
            .filter_map(|e| e.ok())
            .filter(|e| e.path().is_dir())
            .map(|e| e.file_name().to_string_lossy().into_owned())
            .collect(),
        Err(_) => Vec::new(),
    };
    names.sort();
    names
}

fn git_in(dir: &Path, args: &[&str]) -> Result<()> {
    let out = Command::new("git")
        .args(args)
        .current_dir(dir)
        .output()
        .with_context(|| format!("run git {args:?} in {}", dir.display()))?;
    if !out.status.success() {
        bail!(
            "git {} failed: {}",
            args.first().copied().unwrap_or(""),
            String::from_utf8_lossy(&out.stderr).trim()
        );
    }
    Ok(())
}

fn git_capture(dir: &Path, args: &[&str]) -> Option<String> {
    let out = Command::new("git").args(args).current_dir(dir).output().ok()?;
    if !out.status.success() {
        return None;
    }
    Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
}

fn git_short_rev(dir: &Path) -> Option<String> {
    git_capture(dir, &["rev-parse", "--short", "HEAD"])
}

/// Number of changed/untracked paths, or None when not a git checkout.
fn dirty_file_count(dir: &Path) -> Option<usize> {
    git_capture(dir, &["status", "--porcelain"])
        .map(|s| s.lines().filter(|l| !l.trim().is_empty()).count())
}

fn git_count(dir: &Path, range: &str) -> Option<usize> {
    git_capture(dir, &["rev-list", "--count", range]).and_then(|s| s.parse().ok())
}

/// Commits HEAD is behind origin/HEAD, fetching first. None when the remote
/// is unreachable or the clone has no origin/HEAD ref (e.g. shallow).
fn commits_behind(dir: &Path) -> Option<usize> {
    git_in(dir, &["fetch", "--quiet"]).ok()?;
    git_count(dir, "HEAD..origin/HEAD")
}

// ── Tests ────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn use_source_rejects_conflicting_flags() {
        let tmp = tempfile::TempDir::new().unwrap();
        // Both --sibling and --cache.
        let err = use_source(tmp.path(), "foo", None, true, true).unwrap_err();
        assert!(err.to_string().contains("exactly one of"));
        // Neither a path nor a flag.
        let err = use_source(tmp.path(), "foo", None, false, false).unwrap_err();
        assert!(err.to_string().contains("exactly one of"));
    }

    #[test]
    fn use_source_rejects_path_with_wrong_manifest() {
        let tmp = tempfile::TempDir::new().unwrap();
        let src = tmp.path().join("src");
        std::fs::create_dir_all(&src).unwrap();
        std::fs::write(
            src.join("manifest.json"),
            r#"{"name":"other","version":"1","app_type":"bun"}"#,
        )
        .unwrap();
        let err = use_source(tmp.path(), "foo", Some(&src), false, false).unwrap_err();
        assert!(err.to_string().contains("not a node-app source"));
    }

    #[test]
    fn update_requires_names_or_all() {
        let tmp = tempfile::TempDir::new().unwrap();
        let err = update(tmp.path(), &[], false, false).unwrap_err();
        assert!(err.to_string().contains("--all"));
    }

    #[test]
    fn rm_requires_names_or_all() {
        let tmp = tempfile::TempDir::new().unwrap();
        let err = rm(tmp.path(), &[], false).unwrap_err();
        assert!(err.to_string().contains("--all"));
    }
}