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
//! §9 read verbs — `show`, `list`. Diffless ops (§8 "skip
//! steps 1/3/5"): they author no ball-file diff and seal nothing; the printed
//! output IS the whole contribution. Every read's human render may also FOLD
//! IN a §6 read-op plugin dispatch ([`readop`] — `show`'s delivery worktree
//! line, §11); `--json` never dispatches. Each walks `tasks/` on the STORE checkout
//! (§12 — reads run with cwd = the store, never the landing), parses every
//! [`crate::task::Task`], and renders the §3 derived status ladder plus the §10
//! ready/closeable predicates two ways: a human view (status glyphs, ANSI
//! colour) and `--json` (the supported machine contract).
//!
//! The HUMAN view is the sole place storage's i64 unix-time becomes a date — it
//! renders through [`crate::civil::iso8601`] (§9). `--json` does NOT: it emits
//! the literal stored i64, the lossless export the i64 storage was chosen for
//! (§3). Colour is suppressed for stable ASCII whenever `--plain` is passed,
//! `NO_COLOR` is set, or stdout is not a tty (the last two resolved at the edge
//! — [`Edge::color`]).
//!
//! The silent-empty case (a store with no `tasks/` yet) renders as an empty set,
//! not an error — surfacing an un-primed checkout is the tracker's job (§13),
//! not a read verb's.
use std::io;
use std::path::Path;
use crate::config::EffectiveConfig;
use crate::edge::Edge;
use crate::log::{self, Level, Log};
use crate::task::Status;
use crate::verb::Verb;
mod attribution;
mod catalog;
mod claim_age;
mod filter;
mod flags;
mod history;
mod journal;
pub(crate) mod legacy;
mod list;
mod readop;
mod record;
pub(crate) mod scope;
mod show;
mod style;
mod target;
mod tree;
// The typed read surface a LINKED consumer imports (yog, DESIGN §16.7): the
// catalog + its row, and the bedrock `--json` projection — the in-process
// mirror of `bl list --json` / `bl show --json`, carrying no derived status
// (see [`Catalog`] / [`task_json`]). Everything else here stays crate-private.
pub use catalog::{Catalog, Entry};
pub use record::task_json;
pub(crate) use flags::parse;
pub(crate) use history::resolve_dead;
pub(crate) use record::json_line;
pub(crate) use style::Style;
#[cfg(test)]
pub(crate) mod test_support;
/// Parsed read-verb flags: the two output toggles shared by every read, plus
/// `list`'s optional §3 status filter and the compose-AND history filters (§9).
#[derive(Default)]
pub(crate) struct Flags {
pub json: bool,
pub plain: bool,
/// `bl list --status|-s ready|blocked|claimed` — the derived ladder (§3) as
/// a predicate. `None` ⇒ no live filter (every live ball). Only `list`
/// accepts it; it filters the LIVE rung alone (dead balls left no ladder
/// behind). The fourth rung, `closed`, carries no live predicate — it steers
/// `reach` to `Dead` instead (see [`self::flags`]).
pub status: Option<Status>,
/// How far into history a listing reaches (§9). `Live` (default) is the
/// current `tasks/`; `Dead` (`--status closed`) / `All` (`--all`)
/// reconstruct dead balls most-recent-down. `list`-only.
pub reach: Reach,
/// `bl list --tag T` (repeatable): every named tag must be present (AND).
pub tags: Vec<String>,
/// `bl list --since DATE`: lower date bound (a day's `00:00:00Z`, §9).
pub since: Option<i64>,
/// `bl list --until DATE`: upper date bound, inclusive of the whole day.
pub until: Option<i64>,
/// `bl list --claimant NAME`: exact-match predicate over the stored
/// `claimant` field (§3) — the last schema axis. `list`-only; applied
/// uniformly to live and reconstructed-dead rows, so `-s closed --claimant
/// X` answers "what did X deliver".
pub claimant: Option<String>,
/// `bl list --everywhere`: LIFT the default root scope (bl-0161 Q2). The
/// default set is the claim-admitted set — this checkout's project plus
/// rootless balls ([`scope`]); `--everywhere` omits that predicate to show
/// the whole fleet, foreign rows carrying a human project label. `list`-only;
/// composes unchanged with every other filter. `show` is always global.
pub everywhere: bool,
/// `--legacy[=REF]` (§16): point this read at the PRE-greenfield JSON store
/// instead of `tasks/` — the bounded migration shim, projected into the
/// greenfield wire shape by [`legacy`]. `Some` holds the `<ref>:<dir>` spec
/// (default `balls/tasks:.balls/tasks`). Severable: delete the flag and the
/// [`legacy`] module and nothing in core changes.
pub legacy: Option<String>,
/// The lone positional: a ball id for `show`, the text-search needle for
/// `list` (substring over title+body, §9).
pub target: Option<String>,
}
/// How far a `bl list` reaches into the `balls/tasks` history (§9). Dead
/// (closed/dropped) balls are not gone, they are older content (§2) — recovered
/// most-recent-down by the recency walk ([`history`]).
#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
pub(crate) enum Reach {
/// Only the live/open set — the current `tasks/*.md` (the default).
#[default]
Live,
/// Only the dead set, reconstructed from history (`--status closed`).
Dead,
/// Live and dead together (`--all`).
All,
}
impl Reach {
/// Does this reach include the live set?
pub(crate) fn live(self) -> bool {
matches!(self, Reach::Live | Reach::All)
}
/// Does this reach include the dead (history-served) set?
pub(crate) fn dead(self) -> bool {
matches!(self, Reach::Dead | Reach::All)
}
}
/// The §8 diffless dispatch for the read verbs: resolve the §4 `log_level`
/// stack (CLI ▸ XDG ▸ landing ▸ default), narrate the read at `debug` (§4 —
/// core narration is `debug` on every op, so the default threshold keeps
/// routine chatter out of the log), then render. `edge.color` is
/// the resolved host signal (tty AND no `NO_COLOR`); `--plain` overrides it
/// off. Reads never touch the landing's git or a remote (§13) — the landing
/// CONFIG is read (the threshold, the `[hooks]` schedule), like every op.
pub fn run(edge: &Edge, verb: Verb, args: &[String]) -> io::Result<()> {
let flags = parse(verb, args)?;
let clone = edge.xdg.clone_dir(&edge.invocation_path);
let cfg = EffectiveConfig::resolve(&clone.landing(), &edge.xdg.user_config())?;
let level = Level::parse(edge.log_level.as_deref().unwrap_or(&cfg.log_level))?;
let log = Log::new(clone.op_log(), level, verb, log::wall);
log.record(Level::Debug, "core", None, "begin");
match render(edge, verb, &flags, &clone.store(), &cfg, &log) {
Ok(out) => {
print!("{out}");
log.record(Level::Debug, "core", None, "done");
Ok(())
}
// The whole read lifecycle narrates at `debug` (§4) — abort included: a
// read mutates nothing, and the error itself reaches the invoker anyway.
Err(e) => {
log.record(Level::Debug, "core", None, &format!("abort {e}"));
Err(e)
}
}
}
/// Render one read verb: load the store catalog, fold in the §6 read-op
/// dispatch, render. Reads are not special-cased (§6): EVERY read verb's bare
/// `<op>` hook key dispatches through [`readop::fold`] — `show` ships wired by
/// default, `list` is plugin-free only because nothing is listed for it by
/// default. `--json` never dispatches — it stays the lossless mirror of stored
/// frontmatter; only `show` names a ball on the wire (`metadata.bl-id`), the
/// target-free reads carry no id.
fn render(edge: &Edge, verb: Verb, flags: &Flags, store: &Path, cfg: &EffectiveConfig, log: &Log) -> io::Result<String> {
// `--legacy` swaps the SOURCE only (§16): the catalog comes from the legacy
// ref projected into greenfield shape, and everything downstream — status
// ladder, filters, both renders — is the ordinary read path.
let cat = match &flags.legacy {
Some(spec) => Catalog::from_pairs(legacy::balls(&edge.invocation_path, spec)?),
None => Catalog::load(store)?,
};
let style = Style { plain: flags.plain || !edge.color };
let fold =
|id: Option<&str>| if flags.json { String::new() } else { readop::fold(edge, store, verb, id, cfg, log) };
match verb {
// `now` is the render clock the derived claim-age is measured against
// (bl-46ef); like the journal walk, it is paid only on the human path.
Verb::Show => {
let out = show::dispatch(store, &cat, flags, &style, &fold(flags.target.as_deref()), log::wall())?;
// The content just went to this invoker's stdout: mint the bl-9f1d
// seen-token (eager is safe — a token only ever SKIPS a refusal).
// `--legacy` reads a different world, so it acknowledges nothing.
if flags.legacy.is_none() {
crate::seen::mint(&edge.invocation_path, store, flags.target.as_deref().expect("parser guarantees show has a target"));
}
Ok(out)
}
Verb::List => {
// The dead set is reconstructed from history only when the reach
// calls for it — the live-only default never touches git (§9).
let dead = if flags.reach.dead() { history::dead_balls(store, &cat)? } else { Vec::new() };
// The invocation path + XDG layout feed the root-aware scope and the
// fleet-view labels (bl-0161); both git reads stay lazy inside `list`.
let ctx = list::Ctx { store, now: log::wall(), invocation: &edge.invocation_path, xdg: &edge.xdg };
Ok(list::render_list(&cat, &dead, flags, &style, &ctx)? + &fold(None))
}
other => Err(io::Error::other(format!("{}: not a read verb", other.token()))),
}
}
#[cfg(test)]
#[path = "reads_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "reads/run_tests.rs"]
mod run_tests;