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}