Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions tui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ mod results_pane;
/// Six methods, six error variants, the 21-route table, and no subprocess anywhere (ADR-02).
/// (#321)
mod server;
/// The semantic colour layer (#556): six roles, an ANSI-16 palette, and `NO_COLOR`. **The only
/// module permitted to name a `Color`** — every other module says `theme.error`, and
/// `tests/no_colour_literal_outside_theme.rs` makes that a failing test rather than a convention.
/// Colour is decoration only: every state this crate shows is already textual, which is NFR-3 of
/// #321 and the reason `NO_COLOR` costs nothing to honour. (#556)
mod theme;
/// The wire vocabulary six later units share. Declared here so every consumer imports the
/// types from one place rather than redeclaring the server's shapes locally. (#321)
mod types;
Expand Down Expand Up @@ -255,6 +261,12 @@ fn run_app() -> Result<(), TuiError> {
let mut shell = renderer::Renderer::new(server.as_ref(), &host, cols, rows)
.with_concurrent_pickers(Arc::clone(&server));

// `NO_COLOR` is read exactly ONCE, here, and the resolved palette is threaded down (#556).
// Re-reading it per frame would let a mid-session change produce a half-coloured screen, and
// reading it deeper in the call tree would make every unit test's output depend on the ambient
// environment. This is the only `from_env` call in the crate.
shell.set_theme(theme::Theme::from_env());

// A `Fatal` here exits non-zero with one styled line — never a traceback (SR-1). Mapped into
// `TuiError` because this function's signature is the boundary contract, and `Fatal`'s own
// `Display` already carries the whole operator-facing sentence.
Expand All @@ -268,6 +280,17 @@ fn run_app() -> Result<(), TuiError> {
if !interactive {
let frame = shell.render();
let mut out = io::stdout().lock();
// `{line}` on a `Line` writes its spans' content and **no SGR codes** — checked in
// ratatui-core 0.1.2, `Span`'s `Display` is a plain `write!` of `content`. That is what
// keeps a pipe free of escapes now that these are styled values (SR-1); it is relied upon
// here rather than merely true, so it is written down. Guarded by
// `renderer::tests::a_styled_line_displays_without_escape_codes` — nothing asserted this
// from a real piped process, and a dependency on an upstream `Display` impl with no test
// behind it is what silently breaks on a minor-version bump.
//
// Still header+footer only, deliberately: this is the pipe frame from #321, and widening it
// to `plain_lines()` would change what `cao-tui | ...` prints under cover of a colour
// change. (#556)
for line in frame.header.iter().chain(&frame.footer) {
writeln!(out, "{line}")?;
}
Expand Down
2,021 changes: 1,935 additions & 86 deletions tui/src/renderer.rs

Large diffs are not rendered by default.

548 changes: 535 additions & 13 deletions tui/src/results_pane.rs

Large diffs are not rendered by default.

608 changes: 608 additions & 0 deletions tui/src/theme.rs

Large diffs are not rendered by default.

88 changes: 81 additions & 7 deletions tui/tests/hermeticity_tripwire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,16 @@ const SOURCES: &[(&str, &str)] = &[
// additionally where FR-1.4's forbidden CLI fallback would be written ("the picker failed, so
// shell out to `cao profile list`") — the `cao`-spawn needles catch that. (#321)
("src/renderer.rs", include_str!("../src/renderer.rs")),
// Added by the semantic colour layer (#556). This entry was **not** forced by a failing test:
// when `mod theme;` landed, `no_backend_attach_call.rs` went red immediately and this tripwire
// stayed green, because its coverage check is a hand-maintained count and a count cannot
// notice an omission. The `catalog.rs` comment above named that hole exactly, and it stayed
// open — so `theme.rs` sat unscanned by the HTTP-ownership guard until somebody thought to
// look. It is closed now: [`the_scan_set_covers_every_source_in_the_crate`] cross-checks
// `src/main.rs`'s `mod` declarations, so the next module is forced in rather than remembered
// in. Nothing in a palette wants HTTP, which is the point — the file judged least interesting
// is the one an omission hides in. (#556)
("src/theme.rs", include_str!("../src/theme.rs")),
// Test targets and shared test infrastructure.
(
"tests/binary_exits_zero.rs",
Expand All @@ -143,6 +153,16 @@ const SOURCES: &[(&str, &str)] = &[
"tests/hermeticity_tripwire.rs",
include_str!("hermeticity_tripwire.rs"),
),
// Added by the semantic colour layer (#556). A source-text guard needs no I/O at all, which is
// exactly why it is listed: the plausible regression is somebody deciding the scan would be
// tidier reading files from disk than embedding them with `include_str!`, and `fs` is one step
// from `minreq`. The `mod` cross-check above cannot force this entry — it derives from
// `src/main.rs`, and a test target is not a module — so a new file under `tests/` still has to
// be remembered. That gap is named in [`what_this_tripwire_cannot_detect`]. (#556)
(
"tests/no_colour_literal_outside_theme.rs",
include_str!("no_colour_literal_outside_theme.rs"),
),
];

/// The exemption set. **Exactly one member** (BR-9, INV-3, SR-5).
Expand Down Expand Up @@ -847,28 +867,82 @@ fn every_needle_is_actually_findable_in_stripped_code() {
///
/// The number is a literal for the usual reason — `SOURCES.len()` compared against itself proves
/// nothing.
///
/// # The count was never enough, and #556 demonstrated it
///
/// A count reddens when a file is **added** to this list and stays green when one is **omitted**,
/// which is the wrong direction: the unlisted file is the unscanned one. That asymmetry was
/// documented on the `catalog.rs` entry in [`SOURCES`] and left open here, while
/// `no_backend_attach_call.rs` closed it by cross-checking `src/main.rs`'s `mod` declarations. The
/// consequence arrived on schedule: `mod theme;` landed, that tripwire went red and this one did
/// not, so `src/theme.rs` was outside the HTTP-ownership scan while this test reported full
/// coverage. The cross-check below is that repair — a `mod` declaration with no [`SOURCES`] entry
/// is now a failing test, so the next module cannot be forgotten the same way. (#556)
#[test]
fn the_scan_set_covers_every_source_in_the_crate() {
assert_eq!(
SOURCES.len(),
16,
"expected 16 Rust sources: 10 under src/ (main, error, handoff, types, env_guard, catalog, \
results_pane, server, guided_flow, renderer) and 6 under tests/ (binary_exits_zero, \
endpoint_contract, no_backend_attach_call, pty, pty_harness/mod, hermeticity_tripwire). A \
new file must be added to SOURCES or the tripwire silently stops covering it"
18,
"expected 18 Rust sources: 11 under src/ (main, error, handoff, types, env_guard, catalog, \
results_pane, server, guided_flow, renderer, theme) and 7 under tests/ \
(binary_exits_zero, endpoint_contract, no_backend_attach_call, pty, pty_harness/mod, \
hermeticity_tripwire, no_colour_literal_outside_theme). A new file must be added to \
SOURCES or the tripwire silently stops covering it"
);

let production = SOURCES
.iter()
.filter(|(path, _)| path.starts_with("src/"))
.count();
assert_eq!(production, 10, "10 production sources");
assert_eq!(production, 11, "11 production sources");

let test_sources = SOURCES
.iter()
.filter(|(path, _)| path.starts_with("tests/"))
.count();
assert_eq!(test_sources, 6, "6 test sources");
assert_eq!(test_sources, 7, "7 test sources");

// Every `mod` declared by the crate root must be listed. This is the half the count cannot
// do — see the note above on why `theme.rs` went unscanned. Derived from the declarations
// rather than from a second literal, so it closes the omission direction instead of
// restating the addition one.
let (_, crate_root) = SOURCES
.iter()
.find(|(path, _)| *path == "src/main.rs")
.expect("src/main.rs must be listed in SOURCES for the mod cross-check to run");

let mut declared = 0;
for line in crate_root.lines() {
let trimmed = line.trim();
// `pub mod`/`pub(crate) mod` are handled so this does not quietly stop matching if a
// module's visibility changes. An inline `mod x { .. }` is not a separate file and is
// skipped; today every declaration in the root is a file.
let Some(rest) = trimmed
.strip_prefix("mod ")
.or_else(|| trimmed.strip_prefix("pub mod "))
.or_else(|| trimmed.strip_prefix("pub(crate) mod "))
else {
continue;
};
let Some(module) = rest.strip_suffix(';') else {
continue;
};

declared += 1;
let expected = format!("src/{module}.rs");
assert!(
SOURCES.iter().any(|(path, _)| *path == expected),
"`src/main.rs` declares `mod {module};` but {expected} is not in SOURCES, so this \
tripwire does not scan it. An unlisted module is a silent hole: the count assertion \
above only fires when a file is ADDED to the list, never when one is omitted"
);
}

assert_eq!(
declared, 10,
"expected 10 `mod` declarations in src/main.rs (every production source except main.rs \
itself). If this is 0 the loop above matched nothing and its assertion is vacuous"
);

// No duplicate paths: a duplicated entry would inflate the count above and let a real file
// go unlisted while the assertion still passed.
Expand Down
16 changes: 12 additions & 4 deletions tui/tests/no_backend_attach_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ const SOURCES: &[(&str, &str)] = &[
// verb: `renderer` receives the refusal argv as an opaque `Option<String>` from `Refused` and
// hands it straight to `ResultsPane::refuse`, so it never names, builds, or spawns it. (#321)
("src/renderer.rs", include_str!("../src/renderer.rs")),
// Added by the semantic colour layer (#556). Listed for coverage, not because a palette is a
// plausible place to spawn tmux — it is the least likely module in the crate. That is the
// reason to list it rather than an argument against: the `mod`-declaration cross-check below
// makes every `src/` file mandatory precisely so nobody has to be right about which files are
// "interesting", and the one judged uninteresting is where an unscanned hole would sit. Adding
// this entry was in fact FORCED by that cross-check, which reddened the moment `main.rs`
// declared `mod theme;` — the asymmetry the `env_guard` comment above describes, working. (#556)
("src/theme.rs", include_str!("../src/theme.rs")),
];

/// Strips `//`-comments so the needles named in prose are not counted as code.
Expand Down Expand Up @@ -340,10 +348,10 @@ fn no_rust_source_calls_either_backend_attach_session() {
// (#321)
assert_eq!(
SOURCES.len(),
10,
"expected exactly 10 Rust sources under src/ (main, error, handoff, types, env_guard, \
catalog, results_pane, server, guided_flow, renderer); a new module must be added to \
SOURCES or this tripwire silently stops covering it"
11,
"expected exactly 11 Rust sources under src/ (main, error, handoff, types, env_guard, \
catalog, results_pane, server, guided_flow, renderer, theme); a new module must be added \
to SOURCES or this tripwire silently stops covering it"
);

let crate_root = SOURCES
Expand Down
Loading
Loading