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;
pub fn status(project_path: &Path, fetch: bool) -> Result<()> {
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 dependencies = parse_platform_depends(&depends_path)?;
let lock = sources_resolver::lock_entries(project_path);
println!("{:<20} {:<44} {:<9} STATE", "DEP", "SOURCE", "REV");
for dependency in &dependencies {
let name = &dependency.key;
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()),
};
let runtime_name = dir
.as_deref()
.and_then(|path| sources_resolver::read_manifest_name(path).ok())
.unwrap_or_else(|| name.to_string());
let identity = display_identity(name, &runtime_name);
println!("{identity:<20} {source:<44} {rev:<9} {state}");
}
Ok(())
}
fn display_identity(key: &str, runtime_name: &str) -> String {
if key == runtime_name {
key.to_string()
} else {
format!("{key} → {runtime_name}")
}
}
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::is_node_app_dir(p) {
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()));
}
}
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::is_node_app_dir(&cache) {
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
}
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}");
}
sources_resolver::refresh_lock_rev(project_path, name, &path)?;
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(())
}
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(())
}
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() {
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());
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(())
}
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"])
}
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())
}
fn commits_behind(dir: &Path) -> Option<usize> {
git_in(dir, &["fetch", "--quiet"]).ok()?;
git_count(dir, "HEAD..origin/HEAD")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_identity_shows_package_and_runtime_names_when_distinct() {
assert_eq!(display_identity("stage-home", "home"), "stage-home → home");
assert_eq!(display_identity("discovery", "discovery"), "discovery");
}
#[test]
fn use_source_rejects_conflicting_flags() {
let tmp = tempfile::TempDir::new().unwrap();
let err = use_source(tmp.path(), "foo", None, true, true).unwrap_err();
assert!(err.to_string().contains("exactly one of"));
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_invalid_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#"{"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 use_source_pins_package_key_with_distinct_runtime_name() {
let tmp = tempfile::TempDir::new().unwrap();
let src = tmp.path().join("node-app-stage-home");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
src.join("manifest.json"),
r#"{"name":"home","version":"1","app_type":"bun"}"#,
)
.unwrap();
use_source(tmp.path(), "stage-home", Some(&src), false, false).unwrap();
let entries = sources_resolver::lock_entries(tmp.path());
assert_eq!(entries, vec![("stage-home".to_string(), src.canonicalize().unwrap())]);
}
#[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"));
}
}