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
//! §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
//! [`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 catalog;
mod filter;
mod flags;
mod history;
mod journal;
pub(crate) mod legacy;
mod list;
mod readop;
mod record;
mod show;
mod style;
pub(crate) use catalog::{Catalog, Entry};
pub(crate) use flags::parse;
pub(crate) use history::resolve_dead;
pub(crate) use record::{json_line, task_json};
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 [`super::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>,
/// `--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 {
Verb::Show => show::dispatch(store, &cat, flags, &style, &fold(flags.target.as_deref())),
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() };
Ok(list::render_list(&cat, &dead, flags, &style) + &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;