use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum AppsDirEntry {
Staged(PathBuf),
Source(PathBuf),
Shadowed { path: PathBuf, reason: String },
}
pub(crate) fn link_staged_copy(
apps_dir: &Path,
name: &str,
staged: &[PathBuf],
source: &Path,
) -> Result<AppsDirEntry> {
let first = staged.first().context("no staged copy to link")?;
let target = first
.canonicalize()
.with_context(|| format!("resolve {}", first.display()))?;
if let Some(other) = other_dir_declaring(apps_dir, name) {
return Ok(AppsDirEntry::Shadowed {
path: other,
reason: format!("it also declares '{name}'"),
});
}
let link = apps_dir.join(name);
match std::fs::symlink_metadata(&link) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error).with_context(|| format!("stat {}", link.display())),
Ok(meta) if !meta.file_type().is_symlink() => {
return Ok(AppsDirEntry::Shadowed {
path: link,
reason: "a directory the host loads instead".into(),
});
}
Ok(_) => {
let current = std::fs::read_link(&link)
.with_context(|| format!("read link {}", link.display()))?;
if let Ok(resolved) = link.canonicalize() {
if staged.iter().any(|copy| copy.canonicalize().ok() == Some(resolved.clone())) {
return Ok(AppsDirEntry::Staged(link));
}
if source.canonicalize().ok() == Some(resolved) {
return Ok(AppsDirEntry::Source(link));
}
if !is_staged_copy(¤t, name) {
return Ok(AppsDirEntry::Shadowed {
path: link,
reason: format!("it links to {}", current.display()),
});
}
}
std::fs::remove_file(&link).with_context(|| format!("remove {}", link.display()))?;
}
}
std::os::unix::fs::symlink(&target, &link)
.with_context(|| format!("link {} -> {}", link.display(), target.display()))?;
Ok(AppsDirEntry::Staged(link))
}
pub(crate) fn host_booted_after(link: &Path, socket: &Path) -> Option<bool> {
let linked = std::fs::symlink_metadata(link).ok()?.modified().ok()?;
let booted = std::fs::metadata(socket).ok()?.modified().ok()?;
Some(booted >= linked)
}
pub(crate) fn describe(
name: &str,
staged: &Path,
entry: Option<&Result<AppsDirEntry>>,
socket: &Path,
) -> String {
let staged = staged.display();
let (link, via) = match entry {
None => {
return format!(
"âš UI-only '{name}' staged in {staged}; this host reads apps only from its apps \
directory (APT_APPS_DIR), which node-app dev does not know here: link \
<APT_APPS_DIR>/{name} to the staged copy and restart the host"
)
}
Some(Err(error)) => {
return format!(
"âš UI-only '{name}' staged in {staged}, but not linked where the host reads apps: {error:#}"
)
}
Some(Ok(AppsDirEntry::Shadowed { path, reason })) => {
return format!(
"âš UI-only '{name}' staged in {staged}, but the host reads {} ({reason}), not this copy",
path.display()
)
}
Some(Ok(AppsDirEntry::Staged(link))) => (link, format!("from {}", link.display())),
Some(Ok(AppsDirEntry::Source(link))) => {
(link, format!("from the source directory through {}", link.display()))
}
};
match host_booted_after(link, socket) {
Some(true) => format!("✓ UI-only '{name}' staged; the host serves it {via}, nothing to start — reload the page"),
Some(false) => format!(
"✓ UI-only '{name}' staged and linked at {}; the host started before the link, so restart the lane to list and serve it",
link.display()
),
None => format!("✓ UI-only '{name}' staged; the host lists and serves it {via} once it boots"),
}
}
fn is_staged_copy(target: &Path, name: &str) -> bool {
target.file_name().is_some_and(|file| file == name)
&& target
.parent()
.and_then(Path::file_name)
.is_some_and(|dir| dir == "dev-apps")
}
fn other_dir_declaring(apps_dir: &Path, name: &str) -> Option<PathBuf> {
std::fs::read_dir(apps_dir)
.ok()?
.flatten()
.map(|entry| entry.path())
.filter(|path| path.file_name().is_some_and(|file| file != name))
.find(|path| declared_name(path).as_deref() == Some(name))
}
fn declared_name(dir: &Path) -> Option<String> {
let raw = std::fs::read_to_string(dir.join("manifest.json")).ok()?;
let manifest: serde_json::Value = serde_json::from_str(&raw).ok()?;
manifest.get("name")?.as_str().map(str::to_owned)
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::symlink;
use std::time::{Duration, SystemTime};
fn app_dir(path: &Path, name: &str) -> PathBuf {
std::fs::create_dir_all(path).unwrap();
std::fs::write(path.join("manifest.json"), format!(r#"{{"name":"{name}"}}"#)).unwrap();
path.to_path_buf()
}
struct Layout {
_tmp: tempfile::TempDir,
apps: PathBuf,
alice: PathBuf,
bob: PathBuf,
source: PathBuf,
}
fn layout() -> Layout {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let apps = root.join("node/modules");
std::fs::create_dir_all(&apps).unwrap();
Layout {
alice: app_dir(&root.join("alice/dev-apps/agent-chat"), "agent-chat"),
bob: app_dir(&root.join("bob/dev-apps/agent-chat"), "agent-chat"),
source: app_dir(&root.join("node-app-agent/chat-widget"), "agent-chat"),
apps,
_tmp: tmp,
}
}
#[test]
fn links_the_first_staged_copy_and_keeps_a_link_to_any_of_them() {
let l = layout();
let staged = [l.alice.clone(), l.bob.clone()];
let link = l.apps.join("agent-chat");
assert_eq!(
link_staged_copy(&l.apps, "agent-chat", &staged, &l.source).unwrap(),
AppsDirEntry::Staged(link.clone())
);
assert_eq!(std::fs::read_link(&link).unwrap(), l.alice);
let bob_first = [l.bob.clone(), l.alice.clone()];
link_staged_copy(&l.apps, "agent-chat", &bob_first, &l.source).unwrap();
assert_eq!(std::fs::read_link(&link).unwrap(), l.alice);
}
#[test]
fn replaces_a_dangling_link_or_another_lanes_staged_copy() {
let l = layout();
let link = l.apps.join("agent-chat");
let other_lane = app_dir(&l.apps.join("../../other-lane/dev-apps/agent-chat"), "agent-chat");
symlink(&other_lane, &link).unwrap();
link_staged_copy(&l.apps, "agent-chat", std::slice::from_ref(&l.alice), &l.source).unwrap();
assert_eq!(std::fs::read_link(&link).unwrap(), l.alice);
std::fs::remove_file(&link).unwrap();
symlink(l.apps.join("gone"), &link).unwrap();
link_staged_copy(&l.apps, "agent-chat", std::slice::from_ref(&l.alice), &l.source).unwrap();
assert_eq!(std::fs::read_link(&link).unwrap(), l.alice);
}
#[test]
fn leaves_what_it_did_not_make_and_says_so() {
let l = layout();
let link = l.apps.join("agent-chat");
symlink(&l.source, &link).unwrap();
assert_eq!(
link_staged_copy(&l.apps, "agent-chat", std::slice::from_ref(&l.alice), &l.source).unwrap(),
AppsDirEntry::Source(link.clone())
);
std::fs::remove_file(&link).unwrap();
let checkout = app_dir(&l.apps.join("../elsewhere"), "agent-chat");
symlink(&checkout, &link).unwrap();
assert!(matches!(
link_staged_copy(&l.apps, "agent-chat", std::slice::from_ref(&l.alice), &l.source).unwrap(),
AppsDirEntry::Shadowed { .. }
));
assert_eq!(std::fs::read_link(&link).unwrap(), checkout);
std::fs::remove_file(&link).unwrap();
app_dir(&link, "agent-chat");
assert!(matches!(
link_staged_copy(&l.apps, "agent-chat", std::slice::from_ref(&l.alice), &l.source).unwrap(),
AppsDirEntry::Shadowed { .. }
));
assert!(!std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
}
#[test]
fn refuses_to_add_a_second_copy_of_a_name_the_host_already_loads() {
let l = layout();
let clone = app_dir(&l.apps.join("agent-chat-ui"), "agent-chat");
let entry = link_staged_copy(&l.apps, "agent-chat", std::slice::from_ref(&l.alice), &l.source).unwrap();
assert_eq!(
entry,
AppsDirEntry::Shadowed { path: clone, reason: "it also declares 'agent-chat'".into() }
);
assert!(!l.apps.join("agent-chat").exists());
}
#[test]
fn describe_tells_a_host_that_booted_before_the_link_to_restart() {
let l = layout();
let socket = l.apps.join("../control.sock");
std::fs::write(&socket, "").unwrap();
let entry = link_staged_copy(&l.apps, "agent-chat", std::slice::from_ref(&l.alice), &l.source);
let set_socket_mtime = |at: SystemTime| {
std::fs::File::options().write(true).open(&socket).unwrap().set_modified(at).unwrap();
};
set_socket_mtime(SystemTime::now() - Duration::from_secs(60));
let line = describe("agent-chat", &l.alice, Some(&entry), &socket);
assert!(line.contains("restart the lane"), "{line}");
set_socket_mtime(SystemTime::now() + Duration::from_secs(60));
let line = describe("agent-chat", &l.alice, Some(&entry), &socket);
assert!(line.contains("the host serves it from"), "{line}");
let line = describe("agent-chat", &l.alice, Some(&entry), &l.apps.join("absent.sock"));
assert!(line.contains("once it boots"), "{line}");
let line = describe("agent-chat", &l.alice, None, &socket);
assert!(line.contains("APT_APPS_DIR"), "{line}");
}
}