Skip to main content

cli/cli/
render.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Renderer split formalization (A8).
3//!
4//! The CLI is already structure-first: every verb builds a
5//! `#[derive(Serialize)]` output struct, then routes through
6//! `should_output_json` to either `serde_json::to_writer` or a
7//! hand-written text renderer. This module codifies that pattern as a
8//! trait, plus an `emit` helper, so future verbs can't drift back to
9//! `println!` at call sites.
10//!
11//! Adding a new verb: define `struct FooOutput { ... }` deriving
12//! `Serialize`, `impl RenderOutput for FooOutput { fn render_text(...) }`,
13//! then call `emit(&cli, repo.config(), &output)` from the handler.
14
15use anyhow::Result;
16use serde::Serialize;
17
18use crate::cli::{cli_args::Cli, should_output_json};
19
20/// Treat the harness "unknown" placeholder and empty/whitespace strings
21/// as absent so renderers don't surface them as literal text. Mirrors
22/// the discipline in `snapshot::clean_attribution_value` — the harness
23/// writes "unknown" when it can't identify provider/model from
24/// argv/env, and rendering that literally as `anthropic/unknown` is
25/// worse than just showing the meaningful side.
26pub fn real_or_none(value: &str) -> Option<&str> {
27    let trimmed = value.trim();
28    if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("unknown") {
29        None
30    } else {
31        Some(value)
32    }
33}
34
35/// Format an `actor` payload (`provider`, `model`) into a one-line
36/// display. Suppresses the literal "unknown" placeholder. Returns
37/// `None` when neither side carries a real value — callers should
38/// suppress the `Actor:` line entirely in that case.
39pub fn actor_display(provider: Option<&str>, model: Option<&str>) -> Option<String> {
40    let provider = provider.and_then(real_or_none);
41    let model = model.and_then(real_or_none);
42    match (provider, model) {
43        (Some(p), Some(m)) => Some(format!("{p}/{m}")),
44        (Some(p), None) => Some(p.to_string()),
45        (None, Some(m)) => Some(m.to_string()),
46        (None, None) => None,
47    }
48}
49
50/// Format a truncated one-line preview of an ordered string list for
51/// inclusion in a status / advice / blocker message. Used by every
52/// verb that would otherwise dump a 50+ item csv onto a single line:
53/// branch lists in `status`/`log`/`show`/`diagnose`, heavy-impact path
54/// lists in `status`/`snapshot`/`thread`/`merge`, and the
55/// `Heavy-impact change:` blocker built in `repo::thread_advice`.
56///
57/// Keeps the first three names and tags the rest as `… +N more`. The
58/// full list still lives in every JSON form (`--output json` plus the
59/// verb-specific structured surfaces).
60pub fn preview_list(items: &[String], total: usize) -> String {
61    const PREVIEW: usize = 3;
62    let visible: Vec<&str> = items.iter().take(PREVIEW).map(String::as_str).collect();
63    let suffix = if total > visible.len() {
64        format!(", … +{} more", total - visible.len())
65    } else {
66        String::new()
67    };
68    format!("{}{suffix}", visible.join(", "))
69}
70
71/// POSIX-shell-quote a path for inclusion in a copy-pasteable command.
72///
73/// Returns the bare path when it's a safe identifier; otherwise wraps it
74/// in single quotes (escaping any embedded single quote via the standard
75/// `'\''` trick). Keeps the common case (`cd /tmp/scratch`) clean while
76/// staying correct for spaces, parens, `$`, etc.
77pub fn shell_quote(path: &str) -> String {
78    let safe = !path.is_empty()
79        && path
80            .bytes()
81            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'.' | b'_' | b'-' | b'+'));
82    if safe {
83        path.to_string()
84    } else {
85        format!("'{}'", path.replace('\'', "'\\''"))
86    }
87}
88
89/// Optional knobs the text renderer respects. New options append at the
90/// tail; defaults stay backwards-compatible.
91#[derive(Clone, Debug, Default)]
92pub struct RenderOpts {
93    /// Caller hint to render a compact one-line view (e.g. `log --oneline`).
94    pub short: bool,
95    /// Suppress ANSI colour. Resolved by `cli::style` from the global
96    /// CLI flag and env, but text renderers may want to consult it
97    /// directly when emitting low-level escapes.
98    pub no_color: bool,
99    /// Optional row cap. `None` means "render everything".
100    pub limit: Option<usize>,
101}
102
103/// Contract every CLI output type implements. The `Serialize` super-trait
104/// is what powers `--json`; `render_text` is the human view. The same
105/// underlying value powers both — there is no separate "text-mode" code
106/// path that could drift from JSON.
107pub trait RenderOutput: Serialize {
108    fn render_text<W: std::io::Write>(&self, w: &mut W, opts: RenderOpts) -> std::io::Result<()>;
109}
110
111/// Resolve the format decision (JSON vs text) and emit accordingly.
112///
113/// Centralises the `should_output_json → branch → write` idiom from the
114/// existing structure-first verbs. Handlers should construct a typed
115/// output value and call this; never `println!` directly.
116pub fn emit<T: RenderOutput>(cli: &Cli, cfg: Option<&repo::RepoConfig>, out: &T) -> Result<()> {
117    let stdout = std::io::stdout();
118    let mut handle = stdout.lock();
119    if should_output_json(cli, cfg) {
120        serde_json::to_writer(&mut handle, out)?;
121        // Trailing newline so terminal renderers don't visually run into
122        // the next prompt. JSON consumers strip whitespace anyway.
123        use std::io::Write;
124        let _ = handle.write_all(b"\n");
125    } else {
126        out.render_text(&mut handle, RenderOpts::default())?;
127    }
128    Ok(())
129}
130
131/// Same as [`emit`] but lets the caller pass non-default render options
132/// (e.g. `RenderOpts { short: true, .. }` for `log --oneline`).
133pub fn emit_with_opts<T: RenderOutput>(
134    cli: &Cli,
135    cfg: Option<&repo::RepoConfig>,
136    out: &T,
137    opts: RenderOpts,
138) -> Result<()> {
139    let stdout = std::io::stdout();
140    let mut handle = stdout.lock();
141    if should_output_json(cli, cfg) {
142        serde_json::to_writer(&mut handle, out)?;
143        use std::io::Write;
144        let _ = handle.write_all(b"\n");
145    } else {
146        out.render_text(&mut handle, opts)?;
147    }
148    Ok(())
149}
150
151#[cfg(test)]
152mod tests {
153    use super::shell_quote;
154
155    #[test]
156    fn safe_paths_are_returned_unquoted() {
157        assert_eq!(shell_quote("/tmp/scratch"), "/tmp/scratch");
158        assert_eq!(
159            shell_quote("/home/user/.heddle-threads/my-thread/root"),
160            "/home/user/.heddle-threads/my-thread/root"
161        );
162        assert_eq!(
163            shell_quote("relative/path-1.2_3+x"),
164            "relative/path-1.2_3+x"
165        );
166    }
167
168    #[test]
169    fn paths_with_spaces_are_single_quoted() {
170        assert_eq!(shell_quote("/tmp/scratch dir"), "'/tmp/scratch dir'");
171        assert_eq!(
172            shell_quote("/Users/luke/My Repo/.thread"),
173            "'/Users/luke/My Repo/.thread'"
174        );
175    }
176
177    #[test]
178    fn metacharacters_are_single_quoted() {
179        assert_eq!(shell_quote("/tmp/$HOME"), "'/tmp/$HOME'");
180        assert_eq!(shell_quote("/tmp/(paren)"), "'/tmp/(paren)'");
181        assert_eq!(shell_quote("/tmp/a;b"), "'/tmp/a;b'");
182        assert_eq!(shell_quote("/tmp/a&b"), "'/tmp/a&b'");
183        assert_eq!(shell_quote("/tmp/a*b"), "'/tmp/a*b'");
184    }
185
186    #[test]
187    fn embedded_single_quote_is_escaped() {
188        assert_eq!(shell_quote("/tmp/o'brien"), "'/tmp/o'\\''brien'");
189    }
190
191    #[test]
192    fn empty_path_is_quoted() {
193        assert_eq!(shell_quote(""), "''");
194    }
195}