linesmith_core/lib.rs
1//! linesmith-core: render engine + data context for the linesmith
2//! status line. Hosts the segment system, theming, layout, plugin
3//! host, config schema, and runtime predicates that both the CLI
4//! driver and the doctor consume.
5//!
6//! `run` reads a JSON payload from a `Read`, renders a status line,
7//! and writes the result to a `Write`. The `linesmith-cli` binary
8//! crate wires this to stdin/stdout. See `docs/specs/` for the
9//! segment, theme, config, and plugin contracts.
10//!
11//! Workspace-internal in v0.1 per ADR-0018; the public surface is
12//! free to refactor without SemVer cost until a publish decision
13//! lands (post-v1.0 at the earliest).
14
15pub mod config;
16pub mod data_context;
17pub mod input;
18pub mod layout;
19pub mod logging;
20pub mod plugins;
21pub mod presets;
22pub mod runtime;
23pub mod segments;
24pub mod theme;
25
26pub use segments::builder::{build_default_segments, build_lines, build_segments};
27
28use crate::segments::LineItem;
29use std::io::{self, Read, Write};
30
31/// Read a JSON payload from `reader`, render a status line, and write it
32/// to `writer`. Parse failures render a `?` marker to `writer` and log
33/// detail to stderr; only I/O failures surface as errors.
34///
35/// # Errors
36///
37/// Returns an `io::Error` if reading from `reader` or writing to `writer`
38/// fails. Parse errors are handled internally.
39pub fn run(reader: impl Read, writer: impl Write) -> io::Result<()> {
40 run_with_width(reader, writer, detect_terminal_width())
41}
42
43/// Same as [`run`] but with an explicit terminal width. Exposed so
44/// callers with their own width source (tests, a TUI wrapper) can
45/// bypass `detect_terminal_width`.
46///
47/// # Errors
48///
49/// See [`run_with_segments_and_width`].
50pub fn run_with_width(
51 reader: impl Read,
52 writer: impl Write,
53 terminal_width: u16,
54) -> io::Result<()> {
55 let items = build_default_segments();
56 run_with_segments_and_width(reader, writer, &items, terminal_width)
57}
58
59/// Full-control entry: pre-built [`LineItem`] list plus explicit width.
60/// Parse failures render a `?` marker and log to the real process
61/// stderr; output is unstyled. For themed output or injected-stderr
62/// testability (used by `cli_main`), call [`run_with_context`] instead.
63///
64/// # Errors
65///
66/// Returns an `io::Error` if reading from `reader` or writing to
67/// `writer` fails.
68pub fn run_with_segments_and_width(
69 reader: impl Read,
70 writer: impl Write,
71 items: &[LineItem],
72 terminal_width: u16,
73) -> io::Result<()> {
74 // `cwd: None` โ callers that want gix discovery go through
75 // `run_with_context` with a populated RunContext.
76 let ctx = RunContext::new(
77 theme::default_theme(),
78 theme::Capability::None,
79 terminal_width,
80 None,
81 false,
82 );
83 run_with_context(reader, writer, &mut io::stderr().lock(), items, &ctx)
84}
85
86/// CLI run-state bundle: theme + capability + terminal width + cwd.
87/// Passed to [`run_with_context`]; the CLI driver builds one from
88/// config (theme name), the color-policy precedence chain (CLI flags /
89/// env / config), `CliEnv.terminal_width` minus any padding, and the
90/// process cwd. Distinct from
91/// [`segments::RenderContext`](crate::segments::RenderContext), which
92/// is the per-segment-render layout state.
93///
94/// `cwd` seeds gix repo discovery. `None` skips discovery entirely;
95/// `Some(path)` runs `gix::discover(path)` on the first `ctx.git()`
96/// read.
97#[derive(Debug, Clone)]
98#[non_exhaustive]
99pub struct RunContext<'a> {
100 pub theme: &'a theme::Theme,
101 pub capability: theme::Capability,
102 pub terminal_width: u16,
103 pub cwd: Option<std::path::PathBuf>,
104 /// Whether the terminal advertises OSC 8 hyperlink support. Drives
105 /// emission of `Style.hyperlink` URLs in [`layout::runs_to_ansi`];
106 /// orthogonal to color `capability` since hyperlinks and color
107 /// support are independent terminal features.
108 pub hyperlinks: bool,
109}
110
111impl<'a> RunContext<'a> {
112 /// Build a `RunContext`. The struct is `#[non_exhaustive]` so
113 /// struct-literal construction is blocked downstream; benches,
114 /// out-of-tree embedders, and in-crate call sites all go through
115 /// this constructor so future field additions touch one site.
116 ///
117 /// `terminal_width` is forwarded as-is. The layout engine treats
118 /// `0` as "no budget" and drops every droppable segment; callers
119 /// that want a default should use `detect_terminal_width` or
120 /// `DEFAULT_TERMINAL_WIDTH` instead of relying on the sentinel.
121 #[must_use]
122 pub fn new(
123 theme: &'a theme::Theme,
124 capability: theme::Capability,
125 terminal_width: u16,
126 cwd: Option<std::path::PathBuf>,
127 hyperlinks: bool,
128 ) -> Self {
129 Self {
130 theme,
131 capability,
132 terminal_width,
133 cwd,
134 hyperlinks,
135 }
136 }
137}
138
139/// Full-control entry with injected stderr and explicit run context.
140/// Parse failures render a `?` marker to `writer`; only stdin/stdout
141/// I/O failures surface as errors.
142///
143/// # Errors
144///
145/// Returns an `io::Error` if reading from `reader` or writing to
146/// `writer` fails. Stderr write failures are swallowed (a broken
147/// stderr pipe must not abort a valid stdout render).
148pub fn run_with_context(
149 reader: impl Read,
150 writer: impl Write,
151 stderr: &mut dyn Write,
152 items: &[LineItem],
153 ctx: &RunContext<'_>,
154) -> io::Result<()> {
155 // The function predates multi-line and is part of the public API
156 // surface (`pub use` in lib.rs), so removing it would be a SemVer
157 // break. Delegate to the multi-line path with one line so single-
158 // line callers don't need to allocate a `Vec<Vec<...>>` shim.
159 run_lines_with_context(reader, writer, stderr, std::slice::from_ref(&items), ctx)
160}
161
162/// Multi-line render entry. Each inner slice is one rendered line;
163/// the layout algorithm runs independently per line with the full
164/// terminal width budget. Stdin is parsed once into a shared
165/// [`DataContext`](data_context::DataContext) so every line sees the
166/// same data snapshot. Empty inner slices still emit a `writeln!()`
167/// โ the user explicitly defined the line slot, so it stays in the
168/// output even if no segments rendered.
169///
170/// Parse failures emit a single `?` marker on the first line and
171/// stop, matching the single-line failure mode (the marker tells
172/// Claude Code "linesmith ran but couldn't parse stdin"; emitting a
173/// per-line marker would be visually noisy without conveying more
174/// information).
175///
176/// # Errors
177///
178/// Returns the first `io::Error` from a `writeln!` to `writer`.
179/// Stderr write failures are swallowed.
180pub fn run_lines_with_context(
181 mut reader: impl Read,
182 mut writer: impl Write,
183 stderr: &mut dyn Write,
184 lines: &[&[LineItem]],
185 ctx: &RunContext<'_>,
186) -> io::Result<()> {
187 let mut buf = Vec::new();
188 reader.read_to_end(&mut buf)?;
189
190 let status_ctx = match input::parse(&buf) {
191 Ok(c) => c,
192 Err(err) => {
193 let _ = writeln!(stderr, "linesmith: parse: {err}");
194 return writeln!(writer, "?");
195 }
196 };
197 let data_ctx = data_context::DataContext::with_cwd(status_ctx, ctx.cwd.clone());
198
199 for items in lines {
200 let mut warn = |msg: &str| {
201 let _ = writeln!(stderr, "linesmith: {msg}");
202 };
203 let mut observers = layout::LayoutObservers::new(&mut warn);
204 let line = layout::render_with_observers(
205 items,
206 &data_ctx,
207 ctx.terminal_width,
208 &mut observers,
209 ctx.theme,
210 ctx.capability,
211 ctx.hyperlinks,
212 );
213 writeln!(writer, "{line}")?;
214 }
215 Ok(())
216}
217
218/// Width fallback when `terminal_size()` and `COLUMNS` both fail.
219/// Matches `docs/specs/segment-system.md` edge-case table.
220const DEFAULT_TERMINAL_WIDTH: u16 = 200;
221
222/// Resolve the terminal width in cells. Prefers the OS-reported size, then
223/// the `COLUMNS` env var, then `DEFAULT_TERMINAL_WIDTH`. A set-but-invalid
224/// `COLUMNS` value routes through [`lsm_warn!`] so the user can correct
225/// their config; an unset `COLUMNS` falls through silently (the common
226/// case when stdout is piped to Claude Code).
227#[must_use]
228pub fn detect_terminal_width() -> u16 {
229 let os_width = terminal_size::terminal_size().map(|(terminal_size::Width(w), _)| w);
230 let columns = std::env::var("COLUMNS").ok();
231 resolve_terminal_width(os_width, columns.as_deref(), |msg| {
232 crate::lsm_warn!("{msg}")
233 })
234}
235
236/// Shared core of `detect_terminal_width`. Pure: takes the two inputs
237/// (OS size, `COLUMNS` value) and a stderr sink, returns the chosen
238/// width. Split out so tests don't have to mutate process env.
239fn resolve_terminal_width(
240 os_width: Option<u16>,
241 columns: Option<&str>,
242 mut warn: impl FnMut(&str),
243) -> u16 {
244 if let Some(w) = os_width {
245 return w;
246 }
247 let Some(raw) = columns else {
248 return DEFAULT_TERMINAL_WIDTH;
249 };
250 match raw.parse::<u16>() {
251 Ok(parsed) if parsed > 0 => parsed,
252 Ok(_) => {
253 warn(&format!(
254 "COLUMNS='{raw}' is zero; using {DEFAULT_TERMINAL_WIDTH} cells"
255 ));
256 DEFAULT_TERMINAL_WIDTH
257 }
258 Err(err) => {
259 warn(&format!(
260 "COLUMNS='{raw}' unparseable ({err}); using {DEFAULT_TERMINAL_WIDTH} cells"
261 ));
262 DEFAULT_TERMINAL_WIDTH
263 }
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use std::io::Cursor;
271
272 #[test]
273 fn malformed_json_renders_marker_and_succeeds() {
274 let mut out = Vec::new();
275 run(Cursor::new(b"{not json"), &mut out).expect("IO should not fail");
276 assert_eq!(String::from_utf8(out).expect("utf8"), "?\n");
277 }
278
279 #[test]
280 fn minimal_payload_renders_model_then_workspace() {
281 let json = br#"{
282 "model": { "display_name": "Claude Test" },
283 "workspace": { "project_dir": "/home/dev/linesmith" }
284 }"#;
285 let mut out = Vec::new();
286 run(Cursor::new(json), &mut out).expect("run ok");
287 assert_eq!(
288 String::from_utf8(out).expect("utf8"),
289 "Claude Test linesmith\n"
290 );
291 }
292
293 // --- resolve_terminal_width ---
294
295 fn resolve(os_width: Option<u16>, columns: Option<&str>) -> (u16, Vec<String>) {
296 let mut warnings = Vec::new();
297 let w = resolve_terminal_width(os_width, columns, |m| warnings.push(m.to_string()));
298 (w, warnings)
299 }
300
301 #[test]
302 fn os_width_wins_over_columns_env() {
303 let (w, warns) = resolve(Some(120), Some("80"));
304 assert_eq!(w, 120);
305 assert!(warns.is_empty());
306 }
307
308 #[test]
309 fn columns_env_used_when_os_width_missing() {
310 let (w, warns) = resolve(None, Some("80"));
311 assert_eq!(w, 80);
312 assert!(warns.is_empty());
313 }
314
315 #[test]
316 fn missing_columns_falls_back_silently() {
317 let (w, warns) = resolve(None, None);
318 assert_eq!(w, DEFAULT_TERMINAL_WIDTH);
319 assert!(warns.is_empty());
320 }
321
322 #[test]
323 fn zero_columns_falls_back_and_warns() {
324 let (w, warns) = resolve(None, Some("0"));
325 assert_eq!(w, DEFAULT_TERMINAL_WIDTH);
326 assert_eq!(warns.len(), 1);
327 assert!(warns[0].contains("COLUMNS='0'"));
328 }
329
330 #[test]
331 fn unparseable_columns_falls_back_and_warns() {
332 let (w, warns) = resolve(None, Some("wide"));
333 assert_eq!(w, DEFAULT_TERMINAL_WIDTH);
334 assert_eq!(warns.len(), 1);
335 assert!(warns[0].contains("unparseable"));
336 }
337
338 #[test]
339 fn columns_beyond_u16_range_warns() {
340 // "99999" is > u16::MAX (65535), so parse::<u16>() fails.
341 let (w, warns) = resolve(None, Some("99999"));
342 assert_eq!(w, DEFAULT_TERMINAL_WIDTH);
343 assert_eq!(warns.len(), 1);
344 }
345 #[test]
346 fn full_payload_renders_model_context_workspace() {
347 let json = br#"{
348 "model": { "display_name": "Claude Sonnet 4.6" },
349 "workspace": {
350 "project_dir": "/home/dev/linesmith"
351 },
352 "context_window": {
353 "used_percentage": 42.5,
354 "context_window_size": 200000,
355 "total_input_tokens": 12345,
356 "total_output_tokens": 6789
357 }
358 }"#;
359 let mut out = Vec::new();
360 run(Cursor::new(json), &mut out).expect("run ok");
361 assert_eq!(
362 String::from_utf8(out).expect("utf8"),
363 "Claude Sonnet 4.6 42% ยท 200k linesmith\n"
364 );
365 }
366}