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
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
//! The binary edge's resolved inputs — env read once, in `main`, then handed in.
//!
//! Everything host-derived a checkout-lifecycle op needs (`bl prime`/`bl sync`,
//! §12/§13) is gathered here at the process boundary and passed as data, so the
//! library does no env reads (the bl-bfa8 rule: parallel tests vary the layout
//! without racing a shared `std::env`). [`crate::run`] takes an `&Edge`; the
//! `bl` binary builds it from `HOME`/`$XDG_*`, the current dir, `$USER`, the
//! recursion depth, and the directory `bl` itself lives in (where the shipped
//! sibling plugins are found).
use crate::layout::Xdg;
use std::path::PathBuf;
/// The host inputs for one `bl` invocation. `invocation_path` is where `bl` was
/// run (the §7 `binding.invocation_path`); `default_actor` is the identity an op
/// uses unless `--as` overrides it; `depth` is `$BALLS_PLUGIN_DEPTH` (`0` at the
/// top level, the §6 recursion guard); `exe_dir` is the directory holding `bl`,
/// where the shipped sibling plugins (`tracker`, `bl-delivery`) live so the seed
/// can bind them (`None` ⇒ exe path unknown — every default plugin prunes and the
/// box runs stealth/plugin-free, §12). `color`
/// is the resolved rich-output signal the read verbs honour (§9): stdout is a
/// tty AND `NO_COLOR` is unset — a tty/env fact, so it is decided here at the
/// edge (the bl-bfa8 rule), never in the render layer. `log_level` is the §4
/// layer-1 CLI override (`--log-level`): unlike the other fields it is argv- not
/// env-derived, so [`crate::run`] strips the flag and stamps it on — `resolve`
/// leaves it `None` (no override; the config threshold stands). `path_dirs`
/// is `$PATH` split into its directories — the §6 install binary-resolution
/// lookup ("PATH or explicit `--bin`") — read once here like every other env
/// fact (the bl-bfa8 rule). `balls_clock` is `$BALLS_CLOCK` parsed to unix
/// seconds — the §8 op-clock TEST seam ([`crate::clock`]), one rung below the
/// `clock_provider` bin: an env fact, so it is read here at the edge (the bl-bfa8
/// rule) and `None`/unparseable falls straight through to the system clock.
/// `held` is `$BALLS_HELD_STORES` split like `$PATH`: the store checkouts every
/// ENCLOSING `bl` in this invocation tree holds open, outermost first (§6/§12,
/// bl-aac7) — core exports it to each plugin spawn with its own store appended,
/// a shelling plugin inherits it untouched, and a nested `bl` reads it here.
/// Unset ⇒ empty ⇒ nothing is held: a top-level op, or a hand-run one, fails
/// OPEN and publishes.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Edge {
pub xdg: Xdg,
pub invocation_path: PathBuf,
pub default_actor: String,
pub depth: u32,
pub held: Vec<PathBuf>,
pub exe_dir: Option<PathBuf>,
pub path_dirs: Vec<PathBuf>,
pub color: bool,
pub log_level: Option<String>,
pub balls_clock: Option<i64>,
}
impl Edge {
/// Assemble an `Edge` from raw boundary values. `main` reads each from the
/// environment (one always-executed line apiece) and hands them here, so the
/// fallback logic — default actor, depth parse, sibling resolution, the
/// colour decision — lives in the library where unit tests reach every branch
/// (the bl-bfa8 rule). `no_color` is `$NO_COLOR` (any value, even empty,
/// disables colour — the de-facto standard); `stdout_tty` is whether stdout
/// is a terminal (a syscall `main` makes at the boundary).
#[must_use]
#[allow(clippy::too_many_arguments)] // a boundary adapter: each arg is one host input
pub fn resolve(
home: PathBuf,
config_home: Option<String>,
state_home: Option<String>,
invocation_path: PathBuf,
user: Option<String>,
depth: Option<String>,
current_exe: Option<PathBuf>,
path: Option<std::ffi::OsString>,
no_color: Option<String>,
stdout_tty: bool,
balls_clock: Option<String>,
held: Option<std::ffi::OsString>,
) -> Self {
Self {
xdg: Xdg::with(&home, config_home.as_deref(), state_home.as_deref()),
invocation_path,
default_actor: user.unwrap_or_else(|| "unknown".into()),
depth: depth.and_then(|d| d.parse().ok()).unwrap_or(0),
held: held.map(|h| std::env::split_paths(&h).collect()).unwrap_or_default(),
exe_dir: current_exe.and_then(|e| e.parent().map(std::path::Path::to_path_buf)),
path_dirs: path.map(|p| std::env::split_paths(&p).collect()).unwrap_or_default(),
color: stdout_tty && no_color.is_none(),
log_level: None,
balls_clock: balls_clock.and_then(|c| c.trim().parse().ok()),
}
}
/// This box's binary-lookup dirs in trust order — beside `bl` first (the
/// seed sibling rule), then `$PATH`. The one "this machine" surface the
/// clock (bl-cfe3) and the machine-layer hook fallback (bl-053a) share.
#[must_use]
pub fn machine_dirs(&self) -> Vec<PathBuf> {
self.exe_dir.iter().chain(self.path_dirs.iter()).cloned().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn resolve(user: Option<&str>, depth: Option<&str>, exe: Option<PathBuf>) -> Edge {
Edge::resolve(
PathBuf::from("/home/x"),
None,
Some("/state".into()),
PathBuf::from("/proj"),
user.map(str::to_string),
depth.map(str::to_string),
exe,
None,
None,
true,
None,
None,
)
}
#[test]
fn machine_dirs_are_exe_dir_then_path_dirs() {
let mut e = resolve(None, None, None);
e.exe_dir = Some(PathBuf::from("/exe"));
e.path_dirs = vec![PathBuf::from("/p1"), PathBuf::from("/p2")];
assert_eq!(e.machine_dirs(), [PathBuf::from("/exe"), PathBuf::from("/p1"), PathBuf::from("/p2")]);
e.exe_dir = None;
assert_eq!(e.machine_dirs(), [PathBuf::from("/p1"), PathBuf::from("/p2")]);
}
/// Build an edge varying only the two colour inputs.
fn color_of(no_color: Option<&str>, stdout_tty: bool) -> bool {
Edge::resolve(
PathBuf::from("/h"),
None,
None,
PathBuf::from("/p"),
None,
None,
None,
None,
no_color.map(str::to_string),
stdout_tty,
None,
None,
)
.color
}
#[test]
fn path_dirs_split_the_path_variable_and_default_empty() {
let e = Edge::resolve(
PathBuf::from("/h"),
None,
None,
PathBuf::from("/p"),
None,
None,
None,
Some("/usr/bin:/opt/bl".into()),
None,
false,
None,
None,
);
assert_eq!(e.path_dirs, [PathBuf::from("/usr/bin"), PathBuf::from("/opt/bl")]);
assert!(resolve(None, None, None).path_dirs.is_empty()); // no $PATH ⇒ no lookup dirs
}
#[test]
fn held_stores_split_like_path_and_default_empty() {
// bl-aac7: the chain of anvils enclosing ops hold open rides one env,
// `$PATH`-shaped; unset ⇒ nothing held (fail open — a top-level op).
let held = |h: Option<&str>| {
Edge::resolve(
PathBuf::from("/h"),
None,
None,
PathBuf::from("/p"),
None,
None,
None,
None,
None,
true,
None,
h.map(Into::into),
)
.held
};
assert_eq!(held(Some("/s/a/tasks:/s/b/tasks")), [PathBuf::from("/s/a/tasks"), PathBuf::from("/s/b/tasks")]);
assert!(held(None).is_empty());
}
#[test]
fn balls_clock_parses_to_unix_seconds_or_falls_through() {
let clocked = |c: Option<&str>| {
Edge::resolve(
PathBuf::from("/h"),
None,
None,
PathBuf::from("/p"),
None,
None,
None,
None,
None,
true,
c.map(str::to_string),
None,
)
.balls_clock
};
assert_eq!(clocked(Some("1700000000")), Some(1_700_000_000)); // pinned
assert_eq!(clocked(Some(" 1700000000 ")), Some(1_700_000_000)); // trimmed
assert_eq!(clocked(Some("nope")), None); // unparseable ⇒ fall through
assert_eq!(clocked(None), None); // unset ⇒ fall through
}
#[test]
fn an_absent_user_falls_back_to_unknown() {
assert_eq!(resolve(Some("me"), None, None).default_actor, "me");
assert_eq!(resolve(None, None, None).default_actor, "unknown");
}
#[test]
fn depth_parses_or_defaults_to_zero() {
assert_eq!(resolve(None, Some("3"), None).depth, 3);
assert_eq!(resolve(None, Some("nope"), None).depth, 0); // unparseable
assert_eq!(resolve(None, None, None).depth, 0); // absent
}
#[test]
fn the_exe_dir_is_the_parent_of_the_running_binary() {
let tmp = TempDir::new().unwrap();
let bl = tmp.path().join("bl");
// exe_dir is where the shipped siblings (tracker/bl-delivery) live.
assert_eq!(resolve(None, None, Some(bl)).exe_dir.as_deref(), Some(tmp.path()));
}
#[test]
fn an_absent_exe_yields_no_exe_dir() {
assert_eq!(resolve(None, None, None).exe_dir, None);
// A root path has no parent — also no exe dir.
assert_eq!(resolve(None, None, Some(PathBuf::from("/"))).exe_dir, None);
}
#[test]
fn colour_needs_both_a_tty_and_an_unset_no_color() {
assert!(color_of(None, true)); // tty, NO_COLOR unset ⇒ colour
assert!(!color_of(None, false)); // not a tty ⇒ plain
assert!(!color_of(Some("1"), true)); // NO_COLOR set ⇒ plain
assert!(!color_of(Some(""), true)); // even empty NO_COLOR disables
}
}