Skip to main content

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::Segment;
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 segments = build_default_segments();
56    run_with_segments_and_width(reader, writer, &segments, terminal_width)
57}
58
59/// Full-control entry: pre-built segment 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    segments: &[Box<dyn Segment>],
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(), segments, &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    segments: &[Box<dyn Segment>],
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(&segments), 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: &[&[Box<dyn Segment>]],
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 segments in lines {
200        let line = layout::render_with_warn(
201            segments,
202            &data_ctx,
203            ctx.terminal_width,
204            &mut |msg| {
205                let _ = writeln!(stderr, "linesmith: {msg}");
206            },
207            ctx.theme,
208            ctx.capability,
209            ctx.hyperlinks,
210        );
211        writeln!(writer, "{line}")?;
212    }
213    Ok(())
214}
215
216/// Width fallback when `terminal_size()` and `COLUMNS` both fail.
217/// Matches `docs/specs/segment-system.md` edge-case table.
218const DEFAULT_TERMINAL_WIDTH: u16 = 200;
219
220/// Resolve the terminal width in cells. Prefers the OS-reported size, then
221/// the `COLUMNS` env var, then `DEFAULT_TERMINAL_WIDTH`. A set-but-invalid
222/// `COLUMNS` value routes through [`lsm_warn!`] so the user can correct
223/// their config; an unset `COLUMNS` falls through silently (the common
224/// case when stdout is piped to Claude Code).
225#[must_use]
226pub fn detect_terminal_width() -> u16 {
227    let os_width = terminal_size::terminal_size().map(|(terminal_size::Width(w), _)| w);
228    let columns = std::env::var("COLUMNS").ok();
229    resolve_terminal_width(os_width, columns.as_deref(), |msg| {
230        crate::lsm_warn!("{msg}")
231    })
232}
233
234/// Shared core of `detect_terminal_width`. Pure: takes the two inputs
235/// (OS size, `COLUMNS` value) and a stderr sink, returns the chosen
236/// width. Split out so tests don't have to mutate process env.
237fn resolve_terminal_width(
238    os_width: Option<u16>,
239    columns: Option<&str>,
240    mut warn: impl FnMut(&str),
241) -> u16 {
242    if let Some(w) = os_width {
243        return w;
244    }
245    let Some(raw) = columns else {
246        return DEFAULT_TERMINAL_WIDTH;
247    };
248    match raw.parse::<u16>() {
249        Ok(parsed) if parsed > 0 => parsed,
250        Ok(_) => {
251            warn(&format!(
252                "COLUMNS='{raw}' is zero; using {DEFAULT_TERMINAL_WIDTH} cells"
253            ));
254            DEFAULT_TERMINAL_WIDTH
255        }
256        Err(err) => {
257            warn(&format!(
258                "COLUMNS='{raw}' unparseable ({err}); using {DEFAULT_TERMINAL_WIDTH} cells"
259            ));
260            DEFAULT_TERMINAL_WIDTH
261        }
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use std::io::Cursor;
269
270    #[test]
271    fn malformed_json_renders_marker_and_succeeds() {
272        let mut out = Vec::new();
273        run(Cursor::new(b"{not json"), &mut out).expect("IO should not fail");
274        assert_eq!(String::from_utf8(out).expect("utf8"), "?\n");
275    }
276
277    #[test]
278    fn minimal_payload_renders_model_then_workspace() {
279        let json = br#"{
280            "model": { "display_name": "Claude Test" },
281            "workspace": { "project_dir": "/home/dev/linesmith" }
282        }"#;
283        let mut out = Vec::new();
284        run(Cursor::new(json), &mut out).expect("run ok");
285        assert_eq!(
286            String::from_utf8(out).expect("utf8"),
287            "Claude Test linesmith\n"
288        );
289    }
290
291    // --- resolve_terminal_width ---
292
293    fn resolve(os_width: Option<u16>, columns: Option<&str>) -> (u16, Vec<String>) {
294        let mut warnings = Vec::new();
295        let w = resolve_terminal_width(os_width, columns, |m| warnings.push(m.to_string()));
296        (w, warnings)
297    }
298
299    #[test]
300    fn os_width_wins_over_columns_env() {
301        let (w, warns) = resolve(Some(120), Some("80"));
302        assert_eq!(w, 120);
303        assert!(warns.is_empty());
304    }
305
306    #[test]
307    fn columns_env_used_when_os_width_missing() {
308        let (w, warns) = resolve(None, Some("80"));
309        assert_eq!(w, 80);
310        assert!(warns.is_empty());
311    }
312
313    #[test]
314    fn missing_columns_falls_back_silently() {
315        let (w, warns) = resolve(None, None);
316        assert_eq!(w, DEFAULT_TERMINAL_WIDTH);
317        assert!(warns.is_empty());
318    }
319
320    #[test]
321    fn zero_columns_falls_back_and_warns() {
322        let (w, warns) = resolve(None, Some("0"));
323        assert_eq!(w, DEFAULT_TERMINAL_WIDTH);
324        assert_eq!(warns.len(), 1);
325        assert!(warns[0].contains("COLUMNS='0'"));
326    }
327
328    #[test]
329    fn unparseable_columns_falls_back_and_warns() {
330        let (w, warns) = resolve(None, Some("wide"));
331        assert_eq!(w, DEFAULT_TERMINAL_WIDTH);
332        assert_eq!(warns.len(), 1);
333        assert!(warns[0].contains("unparseable"));
334    }
335
336    #[test]
337    fn columns_beyond_u16_range_warns() {
338        // "99999" is > u16::MAX (65535), so parse::<u16>() fails.
339        let (w, warns) = resolve(None, Some("99999"));
340        assert_eq!(w, DEFAULT_TERMINAL_WIDTH);
341        assert_eq!(warns.len(), 1);
342    }
343    #[test]
344    fn full_payload_renders_model_context_workspace() {
345        let json = br#"{
346            "model": { "display_name": "Claude Sonnet 4.6" },
347            "workspace": {
348                "project_dir": "/home/dev/linesmith"
349            },
350            "context_window": {
351                "used_percentage": 42.5,
352                "context_window_size": 200000,
353                "total_input_tokens": 12345,
354                "total_output_tokens": 6789
355            }
356        }"#;
357        let mut out = Vec::new();
358        run(Cursor::new(json), &mut out).expect("run ok");
359        assert_eq!(
360            String::from_utf8(out).expect("utf8"),
361            "Claude Sonnet 4.6 42% ยท 200k linesmith\n"
362        );
363    }
364}