1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
//! §6 local binary binding — `config/plugins/bin/<name>`.
//!
//! The hook SCHEDULE is committed text in `config/plugins.toml` ([`crate::hooks`]);
//! this module owns the other half of the two-level stitch: the LOCAL, gitignored
//! `config/plugins/bin/<name>` — an ABSOLUTE symlink to this machine's binary.
//!
//! ```text
//! config/plugins.toml COMMITTED [hooks] schedule (names, portable)
//! config/plugins/bin/<name> LOCAL (gitignored) absolute symlink → the binary
//! ```
//!
//! The schedule travels with the branch / `bl install` and is machine-neutral;
//! the binding is resolved per machine ([`Registry::bind`], by `bl prime`'s seed
//! and `bl install`). [`Registry::resolve_bin`] reads it back; a missing
//! `bin/<name>` is a clean "referenced but not installed here" (`None`), surfaced
//! by the dispatch when a hooked name has no binary, never a silent skip.
use std::fs;
use std::io;
use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf};
/// One wired plugin: its name (from the committed [`crate::hooks`] schedule) and
/// the resolved machine binary — `None` when `bin/<name>` is absent or dangling.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PluginRef {
pub name: String,
pub bin: Option<PathBuf>,
}
/// The `config/plugins/bin/` binding store of one landing checkout.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Registry {
plugins: PathBuf,
}
impl Registry {
/// Locate the binding store under a landing checkout's `config/plugins/`.
#[must_use]
pub fn at(landing: &Path) -> Self {
Self {
plugins: landing.join("config").join("plugins"),
}
}
fn bin_dir(&self) -> PathBuf {
self.plugins.join("bin")
}
/// Bind `name` to this machine's `target` binary: the LOCAL (gitignored)
/// absolute symlink `bin/<name>` → `target`. Idempotent — re-binding replaces
/// the existing link.
pub fn bind(&self, name: &str, target: &Path) -> io::Result<()> {
let dir = self.bin_dir();
fs::create_dir_all(&dir)?;
replace_symlink(target, &dir.join(name))
}
/// The machine binary `name` resolves to here, or `None` when `bin/<name>` is
/// absent, dangling, or not a regular file (the "referenced but not installed"
/// case, §6 — an empty name joins to `bin/` itself, a directory, bl-bee0).
#[must_use]
pub fn resolve_bin(&self, name: &str) -> Option<PathBuf> {
fs::canonicalize(self.bin_dir().join(name)).ok().filter(|p| p.is_file())
}
}
/// Create `link` → `original`, first removing any path already at `link` so
/// re-linking is idempotent. Removing a symlink never touches its target.
pub(crate) fn replace_symlink(original: &Path, link: &Path) -> io::Result<()> {
if link.symlink_metadata().is_ok() {
fs::remove_file(link)?;
}
symlink(original, link)
}
#[cfg(test)]
#[path = "registry_tests.rs"]
mod tests;