use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::SystemTime;
use anyhow::{anyhow, bail, Context, Result};
use serde::{Deserialize, Serialize};
use super::host::gh;
use super::platform::PlatformDependency;
use crate::tui::{self, LogEntry, LogSource, LogTx, TuiEvent};
const GITHUB_ORG: &str = "econ-v1";
const SOURCES_LOCK_REL: &str = ".node-app/sources.lock";
const LOCK_SCHEMA_VERSION: u32 = 2;
#[derive(Debug, Serialize, Deserialize, Default)]
struct SourcesLock {
schema_version: u32,
#[serde(default)]
dep: Vec<LockEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LockEntry {
name: String,
#[serde(default)]
runtime_name: String,
source_path: String,
#[serde(default)]
git_rev: String,
#[serde(default)]
last_resolved: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedDependency {
pub key: String,
pub runtime_name: String,
pub source_path: PathBuf,
}
pub(crate) fn ensure_unique_runtime_names(dependencies: &[ResolvedDependency]) -> Result<()> {
let mut owners = std::collections::HashMap::new();
for dependency in dependencies {
if let Some(previous) =
owners.insert(dependency.runtime_name.clone(), dependency.key.clone())
{
bail!(
"platform dependencies '{}' and '{}' both resolve to runtime app '{}'",
previous,
dependency.key,
dependency.runtime_name
);
}
}
Ok(())
}
pub fn resolve(
project_root: &Path,
dependencies: &[PlatformDependency],
explicit_dep_paths: &[PathBuf],
log_tx: Option<&LogTx>,
) -> Result<Vec<ResolvedDependency>> {
if dependencies.is_empty() {
return Ok(Vec::new());
}
let lock_path = project_root.join(SOURCES_LOCK_REL);
let mut lock = read_lock(&lock_path).unwrap_or_default();
if lock.schema_version != LOCK_SCHEMA_VERSION {
lock.schema_version = LOCK_SCHEMA_VERSION;
}
let mut resolved = Vec::with_capacity(dependencies.len());
let mut seen = std::collections::HashSet::new();
for dependency in dependencies {
if !seen.insert(dependency.key.clone()) {
tui::sys_log(log_tx, format!(
"⚠ duplicate dep '{}' in platform-depends — skipping",
dependency.key
));
continue;
}
if explicit_dep_paths
.iter()
.any(|path| explicit_path_overrides(path, dependency))
{
tui::sys_log(log_tx, format!(
"✓ dep '{}' from --dep override",
dependency.key
));
continue;
}
let path = resolve_one(&dependency.key, &mut lock, project_root, log_tx)
.with_context(|| format!("resolve dep '{}'", dependency.key))?;
record(&mut lock, &dependency.key, &path);
resolved.push(ResolvedDependency {
key: dependency.key.clone(),
runtime_name: read_manifest_name(&path)?,
source_path: path,
});
}
write_lock_atomic(&lock_path, &lock)
.with_context(|| format!("write {}", lock_path.display()))?;
Ok(resolved)
}
fn explicit_path_overrides(path: &Path, dependency: &PlatformDependency) -> bool {
if path.file_name().and_then(|name| name.to_str()) == Some(dependency.package.as_str()) {
return is_node_app_dir(path);
}
read_manifest_name(path)
.map(|name| name == dependency.key)
.unwrap_or(false)
}
fn resolve_one(
name: &str,
lock: &mut SourcesLock,
project_root: &Path,
log_tx: Option<&LogTx>,
) -> Result<PathBuf> {
if let Some(entry) = lock.dep.iter().find(|d| d.name == name) {
let p = PathBuf::from(&entry.source_path);
if is_node_app_dir(&p) {
tui::sys_log(log_tx, format!("✓ dep '{}' from sources.lock ({})", name, p.display()));
return Ok(p);
}
tui::sys_log(log_tx, format!(
"⚠ sources.lock entry for '{}' is stale ({} missing) — re-resolving",
name, p.display()
));
}
if let Some(sibling) = find_sibling(project_root, name) {
tui::sys_log(log_tx, format!("✓ dep '{}' from sibling: {}", name, sibling.display()));
return Ok(sibling);
}
let cache_path = cache_dep_dir(name)?;
if is_node_app_dir(&cache_path) {
tui::sys_log(log_tx, format!("→ updating cached dep '{}' at {}…", name, cache_path.display()));
let _ = git_pull_best_effort(&cache_path, log_tx);
return Ok(cache_path);
}
tui::sys_log(log_tx, format!("→ cloning {}/node-app-{} → {}", GITHUB_ORG, name, cache_path.display()));
if let Some(parent) = cache_path.parent() {
fs::create_dir_all(parent).ok();
}
let slug = format!("{}/node-app-{}", GITHUB_ORG, name);
clone_repo(&slug, &cache_path, log_tx)
.with_context(|| {
format!(
"could not resolve 'node-app-{name}'; tried sources.lock, sibling \
../node-app-{name}, cache {}, and clone of {slug}.\n\
Pass --dep PATH or remove this entry from infra/debian/platform-depends.",
cache_path.display()
)
})?;
if !is_node_app_dir(&cache_path) {
bail!(
"cloned '{slug}' to {} but the result doesn't look like a node-app \
(no manifest.json with a non-empty name).",
cache_path.display()
);
}
Ok(cache_path)
}
fn record(lock: &mut SourcesLock, name: &str, path: &Path) {
let runtime_name = read_manifest_name(path).unwrap_or_default();
let git_rev = git_rev_at(path).unwrap_or_default();
let last_resolved = iso8601_now();
let source_path = path
.canonicalize()
.unwrap_or_else(|_| path.to_path_buf())
.to_string_lossy()
.into_owned();
if let Some(entry) = lock.dep.iter_mut().find(|d| d.name == name) {
entry.runtime_name = runtime_name;
entry.source_path = source_path;
entry.git_rev = git_rev;
entry.last_resolved = last_resolved;
} else {
lock.dep.push(LockEntry {
name: name.to_string(),
runtime_name,
source_path,
git_rev,
last_resolved,
});
}
}
pub(crate) fn lock_entries(project_root: &Path) -> Vec<(String, PathBuf)> {
read_lock(&project_root.join(SOURCES_LOCK_REL))
.map(|l| {
l.dep
.into_iter()
.map(|e| (e.name, PathBuf::from(e.source_path)))
.collect()
})
.unwrap_or_default()
}
pub(crate) fn prune_lock_entries(
project_root: &Path,
forget_paths: &[PathBuf],
) -> Result<Vec<String>> {
let lock_path = project_root.join(SOURCES_LOCK_REL);
let Some(mut lock) = read_lock(&lock_path) else {
return Ok(Vec::new());
};
let forget: Vec<String> = forget_paths
.iter()
.map(|p| {
p.canonicalize()
.unwrap_or_else(|_| p.to_path_buf())
.to_string_lossy()
.into_owned()
})
.collect();
let before = lock.dep.len();
let mut removed = Vec::new();
lock.dep.retain(|e| {
if forget.contains(&e.source_path) {
removed.push(e.name.clone());
false
} else {
true
}
});
if lock.dep.len() != before {
write_lock_atomic(&lock_path, &lock)
.with_context(|| format!("write {}", lock_path.display()))?;
}
Ok(removed)
}
fn read_lock(path: &Path) -> Option<SourcesLock> {
let text = fs::read_to_string(path).ok()?;
serde_json::from_str(&text).ok()
}
fn write_lock_atomic(path: &Path, lock: &SourcesLock) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("mkdir -p {}", parent.display()))?;
}
let tmp = path.with_extension("lock.tmp");
let text = serde_json::to_string_pretty(lock).context("serialize sources.lock")?;
fs::write(&tmp, text).with_context(|| format!("write {}", tmp.display()))?;
fs::rename(&tmp, path).with_context(|| format!("rename → {}", path.display()))?;
Ok(())
}
pub(crate) fn is_node_app_dir(dir: &Path) -> bool {
read_manifest_name(dir)
.map(|name| !name.trim().is_empty())
.unwrap_or(false)
}
pub(crate) fn read_manifest_name(dir: &Path) -> Result<String> {
let path = dir.join("manifest.json");
let text = fs::read_to_string(&path)
.with_context(|| format!("read {}", path.display()))?;
let v: serde_json::Value = serde_json::from_str(&text)
.with_context(|| format!("parse {}", path.display()))?;
v.get("name")
.and_then(|x| x.as_str())
.map(|s| s.to_string())
.ok_or_else(|| anyhow!("{} has no 'name' field", path.display()))
}
pub(crate) fn pin_lock_entry(project_root: &Path, name: &str, path: &Path) -> Result<()> {
if !is_node_app_dir(path) {
bail!(
"{} is not a node-app source for '{name}' (no manifest.json with a non-empty name)",
path.display()
);
}
let lock_path = project_root.join(SOURCES_LOCK_REL);
let mut lock = read_lock(&lock_path).unwrap_or_default();
if lock.schema_version != LOCK_SCHEMA_VERSION {
lock.schema_version = LOCK_SCHEMA_VERSION;
}
record(&mut lock, name, path);
write_lock_atomic(&lock_path, &lock)
.with_context(|| format!("write {}", lock_path.display()))
}
pub(crate) fn refresh_lock_rev(project_root: &Path, name: &str, path: &Path) -> Result<()> {
let lock_path = project_root.join(SOURCES_LOCK_REL);
let Some(mut lock) = read_lock(&lock_path) else {
return Ok(());
};
let canonical = path
.canonicalize()
.unwrap_or_else(|_| path.to_path_buf())
.to_string_lossy()
.into_owned();
let matches = lock
.dep
.iter()
.any(|e| e.name == name && e.source_path == canonical);
if !matches {
return Ok(());
}
record(&mut lock, name, path);
write_lock_atomic(&lock_path, &lock)
.with_context(|| format!("write {}", lock_path.display()))
}
pub(crate) fn find_sibling(project_root: &Path, name: &str) -> Option<PathBuf> {
let parent = project_root.parent()?;
let candidate = parent.join(format!("node-app-{}", name));
if is_node_app_dir(&candidate) {
return Some(candidate);
}
None
}
pub(crate) fn cache_dep_dir(name: &str) -> Result<PathBuf> {
Ok(cache_root()?.join("deps").join(name))
}
pub(crate) fn cache_root() -> Result<PathBuf> {
if let Ok(c) = std::env::var("XDG_CACHE_HOME") {
if !c.is_empty() {
return Ok(PathBuf::from(c).join("node-app"));
}
}
let home = std::env::var_os("HOME").ok_or_else(|| anyhow!("$HOME not set"))?;
Ok(PathBuf::from(home).join(".cache/node-app"))
}
fn clone_repo(slug: &str, dest: &Path, log_tx: Option<&LogTx>) -> Result<()> {
if dest.exists() {
fs::remove_dir_all(dest)
.with_context(|| format!("remove stale {}", dest.display()))?;
}
if gh::gh_auth_ok() {
let mut cmd = Command::new("gh");
cmd.args(["repo", "clone", slug]).arg(dest);
let label = format!("gh repo clone {slug}");
if run_with_log_sink(cmd, &label, log_tx).is_ok() {
return Ok(());
}
}
let url = format!("https://github.com/{slug}.git");
let mut cmd = Command::new("git");
cmd.args(["clone", "--depth", "1", "--progress", &url]).arg(dest);
let label = format!("git clone {url}");
run_with_log_sink(cmd, &label, log_tx)?;
Ok(())
}
fn git_pull_best_effort(dir: &Path, log_tx: Option<&LogTx>) -> Result<()> {
if !dir.join(".git").exists() {
return Ok(());
}
let mut fetch = Command::new("git");
fetch.args(["fetch", "--progress"]).current_dir(dir);
let _ = run_with_log_sink(fetch, "git fetch", log_tx);
let mut reset = Command::new("git");
reset.args(["reset", "--hard", "origin/HEAD"]).current_dir(dir);
let _ = run_with_log_sink(reset, "git reset --hard origin/HEAD", log_tx);
Ok(())
}
fn run_with_log_sink(mut cmd: Command, label: &str, log_tx: Option<&LogTx>) -> Result<()> {
if let Some(tx) = log_tx {
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd
.spawn()
.with_context(|| format!("spawn `{label}`"))?;
let stdout = child.stdout.take().expect("piped");
let stderr = child.stderr.take().expect("piped");
let tail = std::sync::Arc::new(std::sync::Mutex::new(
std::collections::VecDeque::<String>::new(),
));
let tx1 = tx.clone();
let tail1 = tail.clone();
let t1 = std::thread::spawn(move || {
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
super::push_tail(&tail1, &line);
let _ = tx1.send(TuiEvent::Log(LogEntry {
source: LogSource::Build,
line,
}));
}
});
let tx2 = tx.clone();
let tail2 = tail.clone();
let t2 = std::thread::spawn(move || {
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
super::push_tail(&tail2, &line);
let _ = tx2.send(TuiEvent::Log(LogEntry {
source: LogSource::Build,
line,
}));
}
});
let status = child
.wait()
.with_context(|| format!("wait `{label}`"))?;
t1.join().ok();
t2.join().ok();
if !status.success() {
bail!("`{label}` exited {}{}", status, super::format_tail(&tail));
}
Ok(())
} else {
cmd.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
let status = cmd
.status()
.with_context(|| format!("spawn `{label}`"))?;
if !status.success() {
bail!("`{label}` exited {}", status);
}
Ok(())
}
}
fn git_rev_at(dir: &Path) -> Option<String> {
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(dir)
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8(out.stdout).ok()?;
Some(s.trim().to_string())
}
fn iso8601_now() -> String {
let secs = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let (year, month, day, hour, min, sec) = unix_to_ymdhms(secs);
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
year, month, day, hour, min, sec
)
}
fn unix_to_ymdhms(secs: u64) -> (i64, u32, u32, u32, u32, u32) {
let days = (secs / 86400) as i64;
let rem = (secs % 86400) as u32;
let hour = rem / 3600;
let min = (rem % 3600) / 60;
let sec = rem % 60;
let z = days + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if m <= 2 { y + 1 } else { y };
(year, m as u32, d as u32, hour, min, sec)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn write_app_manifest(dir: &Path, name: &str) {
fs::create_dir_all(dir).unwrap();
let m = serde_json::json!({
"manifest_version": 1,
"name": name,
"version": "0.1.0",
"app_type": "bun"
});
fs::write(dir.join("manifest.json"), m.to_string()).unwrap();
}
fn dependency(key: &str) -> PlatformDependency {
PlatformDependency {
key: key.to_string(),
package: format!("node-app-{key}"),
}
}
#[test]
fn is_node_app_dir_accepts_nonempty_runtime_name() {
let tmp = TempDir::new().unwrap();
write_app_manifest(tmp.path(), "discovery");
assert!(is_node_app_dir(tmp.path()));
}
#[test]
fn is_node_app_dir_rejects_missing_manifest() {
let tmp = TempDir::new().unwrap();
assert!(!is_node_app_dir(tmp.path()));
}
#[test]
fn read_manifest_name_works() {
let tmp = TempDir::new().unwrap();
write_app_manifest(tmp.path(), "esp32-bridge");
assert_eq!(read_manifest_name(tmp.path()).unwrap(), "esp32-bridge");
}
#[test]
fn find_sibling_returns_match() {
let tmp = TempDir::new().unwrap();
let platform = tmp.path().join("platform");
fs::create_dir_all(&platform).unwrap();
let sibling = tmp.path().join("node-app-discovery");
write_app_manifest(&sibling, "discovery");
let found = find_sibling(&platform, "discovery").unwrap();
assert_eq!(found.canonicalize().unwrap(), sibling.canonicalize().unwrap());
}
#[test]
fn find_sibling_allows_distinct_runtime_name() {
let tmp = TempDir::new().unwrap();
let platform = tmp.path().join("platform");
fs::create_dir_all(&platform).unwrap();
let sibling = tmp.path().join("node-app-discovery");
write_app_manifest(&sibling, "different-name");
assert_eq!(find_sibling(&platform, "discovery").unwrap(), sibling);
}
#[test]
fn lockfile_roundtrip() {
let tmp = TempDir::new().unwrap();
let lock_path = tmp.path().join(".node-app/sources.lock");
let mut lock = SourcesLock {
schema_version: 1,
dep: vec![LockEntry {
name: "discovery".into(),
runtime_name: "discovery".into(),
source_path: "/tmp/x".into(),
git_rev: "abc".into(),
last_resolved: "2026-05-16T00:00:00Z".into(),
}],
};
write_lock_atomic(&lock_path, &lock).unwrap();
let parsed = read_lock(&lock_path).unwrap();
assert_eq!(parsed.dep.len(), 1);
assert_eq!(parsed.dep[0].name, "discovery");
lock.dep[0].git_rev = "def".into();
write_lock_atomic(&lock_path, &lock).unwrap();
let parsed = read_lock(&lock_path).unwrap();
assert_eq!(parsed.dep[0].git_rev, "def");
}
#[test]
fn schema_v1_lock_upgrades_with_runtime_name() {
let tmp = TempDir::new().unwrap();
let source = tmp.path().join("node-app-stage-home");
write_app_manifest(&source, "home");
let platform = tmp.path().join("platform");
let lock_path = platform.join(SOURCES_LOCK_REL);
fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
fs::write(
&lock_path,
format!(
r#"{{
"schema_version": 1,
"dep": [{{
"name": "stage-home",
"source_path": "{}",
"git_rev": "",
"last_resolved": ""
}}]
}}"#,
source.display()
),
)
.unwrap();
resolve(&platform, &[dependency("stage-home")], &[], None).unwrap();
let lock = read_lock(&lock_path).unwrap();
assert_eq!(lock.schema_version, 2);
assert_eq!(lock.dep[0].runtime_name, "home");
}
#[test]
fn resolve_uses_explicit_override() {
let tmp = TempDir::new().unwrap();
let platform = tmp.path().join("platform");
fs::create_dir_all(&platform).unwrap();
let override_path = tmp.path().join("fork-of-discovery");
write_app_manifest(&override_path, "discovery");
let sibling = tmp.path().join("node-app-discovery");
write_app_manifest(&sibling, "discovery");
let out = resolve(
&platform,
&[dependency("discovery")],
std::slice::from_ref(&override_path),
None,
)
.unwrap();
assert!(out.is_empty());
}
#[test]
fn resolve_picks_sibling_over_clone() {
let tmp = TempDir::new().unwrap();
let platform = tmp.path().join("platform");
fs::create_dir_all(&platform).unwrap();
let sibling = tmp.path().join("node-app-discovery");
write_app_manifest(&sibling, "discovery");
let out = resolve(&platform, &[dependency("discovery")], &[], None).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(
out[0].source_path.canonicalize().unwrap(),
sibling.canonicalize().unwrap()
);
let parsed = read_lock(&platform.join(SOURCES_LOCK_REL)).unwrap();
assert_eq!(parsed.dep.len(), 1);
assert_eq!(parsed.dep[0].name, "discovery");
}
#[test]
fn resolve_keeps_package_key_separate_from_runtime_name() {
let tmp = TempDir::new().unwrap();
let platform = tmp.path().join("platform");
fs::create_dir_all(&platform).unwrap();
let sibling = tmp.path().join("node-app-stage-home");
write_app_manifest(&sibling, "home");
let out = resolve(&platform, &[dependency("stage-home")], &[], None).unwrap();
assert_eq!(out[0].key, "stage-home");
assert_eq!(out[0].runtime_name, "home");
assert_eq!(
out[0].source_path.canonicalize().unwrap(),
sibling.canonicalize().unwrap()
);
}
#[test]
fn duplicate_runtime_names_report_both_dependency_keys() {
let dependencies = vec![
ResolvedDependency {
key: "stage-home".to_string(),
runtime_name: "home".to_string(),
source_path: PathBuf::from("/tmp/stage-home"),
},
ResolvedDependency {
key: "home-v2".to_string(),
runtime_name: "home".to_string(),
source_path: PathBuf::from("/tmp/home-v2"),
},
];
let error = ensure_unique_runtime_names(&dependencies)
.unwrap_err()
.to_string();
assert!(error.contains("stage-home"));
assert!(error.contains("home-v2"));
assert!(error.contains("runtime app 'home'"));
}
#[test]
fn resolve_stale_lockfile_falls_through_to_sibling() {
let tmp = TempDir::new().unwrap();
let platform = tmp.path().join("platform");
fs::create_dir_all(&platform).unwrap();
let bogus = tmp.path().join("ghost");
let lock = SourcesLock {
schema_version: 1,
dep: vec![LockEntry {
name: "discovery".into(),
runtime_name: "discovery".into(),
source_path: bogus.to_string_lossy().into_owned(),
git_rev: "x".into(),
last_resolved: "2026-05-16T00:00:00Z".into(),
}],
};
write_lock_atomic(&platform.join(SOURCES_LOCK_REL), &lock).unwrap();
let sibling = tmp.path().join("node-app-discovery");
write_app_manifest(&sibling, "discovery");
let out = resolve(&platform, &[dependency("discovery")], &[], None).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(
out[0].source_path.canonicalize().unwrap(),
sibling.canonicalize().unwrap()
);
}
#[test]
fn pin_lock_entry_writes_and_validates() {
let tmp = TempDir::new().unwrap();
let platform = tmp.path().join("platform");
fs::create_dir_all(&platform).unwrap();
let src = tmp.path().join("fork-of-lcd");
write_app_manifest(&src, "lcd");
pin_lock_entry(&platform, "lcd", &src).unwrap();
let lock = read_lock(&platform.join(SOURCES_LOCK_REL)).unwrap();
assert_eq!(lock.dep.len(), 1);
assert_eq!(lock.dep[0].name, "lcd");
assert_eq!(lock.dep[0].runtime_name, "lcd");
assert_eq!(
PathBuf::from(&lock.dep[0].source_path).canonicalize().unwrap(),
src.canonicalize().unwrap()
);
let stage = tmp.path().join("node-app-stage-home");
write_app_manifest(&stage, "home");
pin_lock_entry(&platform, "stage-home", &stage).unwrap();
let lock = read_lock(&platform.join(SOURCES_LOCK_REL)).unwrap();
let stage_entry = lock.dep.iter().find(|entry| entry.name == "stage-home").unwrap();
assert_eq!(stage_entry.runtime_name, "home");
let bad = tmp.path().join("bad");
fs::create_dir_all(&bad).unwrap();
fs::write(bad.join("manifest.json"), r#"{"version":"1"}"#).unwrap();
assert!(pin_lock_entry(&platform, "lcd", &bad).is_err());
let lock = read_lock(&platform.join(SOURCES_LOCK_REL)).unwrap();
assert_eq!(lock.dep[0].source_path, src.canonicalize().unwrap().to_string_lossy());
}
#[test]
fn refresh_lock_rev_only_touches_matching_entry() {
let tmp = TempDir::new().unwrap();
let platform = tmp.path().join("platform");
fs::create_dir_all(&platform).unwrap();
let src = tmp.path().join("src");
write_app_manifest(&src, "lcd");
pin_lock_entry(&platform, "lcd", &src).unwrap();
let elsewhere = tmp.path().join("elsewhere");
fs::create_dir_all(&elsewhere).unwrap();
refresh_lock_rev(&platform, "lcd", &elsewhere).unwrap();
let lock = read_lock(&platform.join(SOURCES_LOCK_REL)).unwrap();
assert_eq!(
lock.dep[0].source_path,
src.canonicalize().unwrap().to_string_lossy()
);
refresh_lock_rev(&platform, "lcd", &src).unwrap();
let lock = read_lock(&platform.join(SOURCES_LOCK_REL)).unwrap();
assert_eq!(lock.dep.len(), 1);
}
#[cfg(unix)]
#[test]
fn prune_matches_entries_through_symlinked_paths() {
let tmp = TempDir::new().unwrap();
let platform = tmp.path().join("platform");
fs::create_dir_all(&platform).unwrap();
let real = tmp.path().join("real-cache-dir");
write_app_manifest(&real, "foo");
let alias = tmp.path().join("alias");
std::os::unix::fs::symlink(&real, &alias).unwrap();
pin_lock_entry(&platform, "foo", &real).unwrap();
let removed = prune_lock_entries(&platform, &[alias]).unwrap();
assert_eq!(removed, vec!["foo".to_string()]);
assert!(lock_entries(&platform).is_empty());
}
#[test]
fn unix_epoch_decomposes_correctly() {
let (y, mo, d, h, mi, s) = unix_to_ymdhms(0);
assert_eq!((y, mo, d, h, mi, s), (1970, 1, 1, 0, 0, 0));
let (y, mo, d, h, mi, s) = unix_to_ymdhms(1_704_067_200);
assert_eq!((y, mo, d, h, mi, s), (2024, 1, 1, 0, 0, 0));
let (y, mo, d, h, mi, s) = unix_to_ymdhms(1_709_210_096);
assert_eq!((y, mo, d, h, mi, s), (2024, 2, 29, 12, 34, 56));
}
}