Skip to main content

heddle_cli_render/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 repo::{Repository, Thread, ThreadManager};
17use schemars::JsonSchema;
18use serde::Serialize;
19
20use heddle_cli_args::{Cli, should_output_json};
21
22pub mod fsck;
23pub mod query;
24
25/// Treat the harness "unknown" placeholder and empty/whitespace strings
26/// as absent so renderers don't surface them as literal text. Mirrors
27/// the discipline in `snapshot::clean_attribution_value` — the harness
28/// writes "unknown" when it can't identify provider/model from
29/// argv/env, and rendering that literally as `anthropic/unknown` is
30/// worse than just showing the meaningful side.
31pub fn real_or_none(value: &str) -> Option<&str> {
32    let trimmed = value.trim();
33    if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("unknown") {
34        None
35    } else {
36        Some(value)
37    }
38}
39
40/// Format an `actor` payload (`provider`, `model`) into a one-line
41/// display. Suppresses the literal "unknown" placeholder. Returns
42/// `None` when neither side carries a real value — callers should
43/// suppress the `Actor:` line entirely in that case.
44pub fn actor_display(provider: Option<&str>, model: Option<&str>) -> Option<String> {
45    let provider = provider.and_then(real_or_none);
46    let model = model.and_then(real_or_none);
47    match (provider, model) {
48        (Some(p), Some(m)) => Some(format!("{p}/{m}")),
49        (Some(p), None) => Some(p.to_string()),
50        (None, Some(m)) => Some(m.to_string()),
51        (None, None) => None,
52    }
53}
54
55/// Human-facing repository mode label. JSON keeps the exact
56/// `repository_capability` / `storage_model` values; text output uses
57/// product language instead of storage implementation names.
58pub fn repository_mode_label(capability: &str, storage_model: &str) -> String {
59    if capability == "git-overlay" || storage_model == "git+heddle-sidecar" {
60        "Git + Heddle".to_string()
61    } else if capability == "plain-git" || storage_model == "git-only" {
62        "Git repo (setup needed)".to_string()
63    } else if capability == "native"
64        || capability == "native-heddle"
65        || storage_model == "heddle-native"
66    {
67        "Heddle native".to_string()
68    } else {
69        capability.to_string()
70    }
71}
72
73#[derive(Clone, Debug, Serialize, JsonSchema)]
74pub struct RepositoryContextInfo {
75    pub kind: String,
76    pub parent_repository: Option<String>,
77    pub target_thread: Option<String>,
78    pub parent_thread: Option<String>,
79}
80
81#[derive(Clone, Debug, Serialize)]
82pub struct RepositoryPresentation {
83    pub label: String,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub context: Option<RepositoryContextInfo>,
86}
87
88/// Presentation-only repository identity. This deliberately leaves
89/// `Repository::capability_label()` untouched: an isolated checkout that
90/// shares a Git-overlay object store is still technically opened through
91/// the native Heddle storage path, but user-facing status should say what
92/// the checkout is managed by.
93pub fn repository_presentation(
94    repo: &Repository,
95    target_thread: Option<&str>,
96    parent_thread: Option<&str>,
97) -> RepositoryPresentation {
98    if let Some(parent_root) = managed_git_overlay_parent_root(repo) {
99        let thread = current_child_thread(repo);
100        let target_thread = target_thread.map(ToString::to_string).or_else(|| {
101            thread
102                .as_ref()
103                .and_then(|thread| thread.target_thread.clone())
104        });
105        let parent_thread = parent_thread.map(ToString::to_string).or_else(|| {
106            thread
107                .as_ref()
108                .and_then(|thread| thread.parent_thread.clone())
109        });
110        return RepositoryPresentation {
111            label: "Git + Heddle isolated checkout".to_string(),
112            context: Some(RepositoryContextInfo {
113                kind: "git-overlay-isolated-checkout".to_string(),
114                parent_repository: Some(parent_root.display().to_string()),
115                target_thread,
116                parent_thread,
117            }),
118        };
119    }
120
121    RepositoryPresentation {
122        label: repository_mode_label(repo.capability_label(), repo.storage_model_label()),
123        context: None,
124    }
125}
126
127fn managed_git_overlay_parent_root(repo: &Repository) -> Option<std::path::PathBuf> {
128    let parent_root = repo.heddle_dir().parent()?;
129    if paths_equal(parent_root, repo.root()) {
130        return None;
131    }
132    parent_root
133        .join(".git")
134        .exists()
135        .then(|| parent_root.to_path_buf())
136}
137
138fn current_child_thread(repo: &Repository) -> Option<Thread> {
139    let manager = ThreadManager::new(repo.heddle_dir());
140    if let Ok(Some(thread)) = manager.find_by_execution_root(repo.root()) {
141        return Some(thread);
142    }
143    let lane = repo.current_lane().ok().flatten()?;
144    manager.find_by_thread(&lane).ok().flatten()
145}
146
147fn paths_equal(left: &std::path::Path, right: &std::path::Path) -> bool {
148    let left = left.canonicalize().unwrap_or_else(|_| left.to_path_buf());
149    let right = right.canonicalize().unwrap_or_else(|_| right.to_path_buf());
150    left == right
151}
152
153/// Format a truncated one-line preview of an ordered string list for
154/// inclusion in a status / advice / blocker message. Used by every
155/// verb that would otherwise dump a 50+ item csv onto a single line:
156/// branch lists in `status`/`log`/`show`/`doctor`, heavy-impact path
157/// lists in `status`/`snapshot`/`thread`/`merge`, and the
158/// `Heavy-impact change:` blocker built in `repo::thread_advice`.
159///
160/// Keeps the first three names and tags the rest as `… +N more`. The
161/// full list still lives in every JSON form (`--output json` plus the
162/// verb-specific structured surfaces).
163pub fn preview_list(items: &[String], total: usize) -> String {
164    const PREVIEW: usize = 3;
165    let visible: Vec<&str> = items.iter().take(PREVIEW).map(String::as_str).collect();
166    let suffix = if total > visible.len() {
167        format!(", … +{} more", total - visible.len())
168    } else {
169        String::new()
170    };
171    format!("{}{suffix}", visible.join(", "))
172}
173
174pub fn git_only_branch_summary(branches: &[String], total: usize) -> String {
175    let noun = if total == 1 { "branch" } else { "branches" };
176    format!(
177        "Optional Git-only {noun} available: {}",
178        preview_list(branches, total)
179    )
180}
181
182/// POSIX-shell-quote a path for inclusion in a copy-pasteable command.
183///
184/// Returns the bare path when it's a safe identifier; otherwise wraps it
185/// in single quotes (escaping any embedded single quote via the standard
186/// `'\''` trick). Keeps the common case (`cd /tmp/scratch`) clean while
187/// staying correct for spaces, parens, `$`, etc.
188pub fn shell_quote(path: &str) -> String {
189    let safe = !path.is_empty()
190        && path
191            .bytes()
192            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'.' | b'_' | b'-' | b'+'));
193    if safe {
194        path.to_string()
195    } else {
196        format!("'{}'", path.replace('\'', "'\\''"))
197    }
198}
199
200/// Optional knobs the text renderer respects. New options append at the
201/// tail; defaults stay backwards-compatible.
202#[derive(Clone, Debug, Default)]
203pub struct RenderOpts {
204    /// Caller hint to render a compact one-line view (e.g. `log --oneline`).
205    pub short: bool,
206    /// Suppress ANSI colour. Resolved by `cli::style` from the global
207    /// CLI flag and env, but text renderers may want to consult it
208    /// directly when emitting low-level escapes.
209    pub no_color: bool,
210    /// Optional row cap. `None` means "render everything".
211    pub limit: Option<usize>,
212}
213
214/// Contract every CLI output type implements. The `Serialize` super-trait
215/// is what powers `--output json`; `render_text` is the human view. The same
216/// underlying value powers both — there is no separate "text-mode" code
217/// path that could drift from JSON.
218pub trait RenderOutput: Serialize {
219    fn render_text<W: std::io::Write>(&self, w: &mut W, opts: RenderOpts) -> std::io::Result<()>;
220}
221
222/// Resolve the format decision (JSON vs text) and emit accordingly.
223///
224/// Centralises the `should_output_json → branch → write` idiom from the
225/// existing structure-first verbs. Handlers should construct a typed
226/// output value and call this; never `println!` directly.
227pub fn emit<T: RenderOutput>(cli: &Cli, cfg: Option<&repo::RepoConfig>, out: &T) -> Result<()> {
228    if should_output_json(cli, cfg) {
229        write_json_stdout(out)?;
230    } else {
231        let stdout = std::io::stdout();
232        let mut handle = stdout.lock();
233        out.render_text(&mut handle, RenderOpts::default())?;
234    }
235    Ok(())
236}
237
238/// Same as [`emit`] but lets the caller pass non-default render options
239/// (e.g. `RenderOpts { short: true, .. }` for `log --oneline`).
240pub fn emit_with_opts<T: RenderOutput>(
241    cli: &Cli,
242    cfg: Option<&repo::RepoConfig>,
243    out: &T,
244    opts: RenderOpts,
245) -> Result<()> {
246    if should_output_json(cli, cfg) {
247        write_json_stdout(out)?;
248    } else {
249        let stdout = std::io::stdout();
250        let mut handle = stdout.lock();
251        out.render_text(&mut handle, opts)?;
252    }
253    Ok(())
254}
255
256/// Write a single JSON value plus trailing newline to stdout.
257///
258/// Treats a closed downstream pipe as a successful early stop. CLI tools
259/// should be composable with `head`, `true`, and other short readers; a
260/// consumer choosing to close stdout is not a Heddle failure.
261pub fn write_json_stdout<T: Serialize>(out: &T) -> Result<()> {
262    let mut text = serde_json::to_string(out)?;
263    text.push('\n');
264    write_stdout(&text)
265}
266
267/// Write text to stdout, treating `BrokenPipe` as a normal shell outcome.
268pub fn write_stdout(text: &str) -> Result<()> {
269    use std::io::Write;
270
271    let stdout = std::io::stdout();
272    let mut handle = stdout.lock();
273    match handle.write_all(text.as_bytes()) {
274        Ok(()) => Ok(()),
275        Err(err) if err.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
276        Err(err) => Err(err.into()),
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::shell_quote;
283
284    #[test]
285    fn safe_paths_are_returned_unquoted() {
286        assert_eq!(shell_quote("/tmp/scratch"), "/tmp/scratch");
287        assert_eq!(
288            shell_quote("/home/user/.heddle-threads/my-thread/root"),
289            "/home/user/.heddle-threads/my-thread/root"
290        );
291        assert_eq!(
292            shell_quote("relative/path-1.2_3+x"),
293            "relative/path-1.2_3+x"
294        );
295    }
296
297    #[test]
298    fn paths_with_spaces_are_single_quoted() {
299        assert_eq!(shell_quote("/tmp/scratch dir"), "'/tmp/scratch dir'");
300        assert_eq!(
301            shell_quote("/Users/luke/My Repo/.thread"),
302            "'/Users/luke/My Repo/.thread'"
303        );
304    }
305
306    #[test]
307    fn metacharacters_are_single_quoted() {
308        assert_eq!(shell_quote("/tmp/$HOME"), "'/tmp/$HOME'");
309        assert_eq!(shell_quote("/tmp/(paren)"), "'/tmp/(paren)'");
310        assert_eq!(shell_quote("/tmp/a;b"), "'/tmp/a;b'");
311        assert_eq!(shell_quote("/tmp/a&b"), "'/tmp/a&b'");
312        assert_eq!(shell_quote("/tmp/a*b"), "'/tmp/a*b'");
313    }
314
315    #[test]
316    fn embedded_single_quote_is_escaped() {
317        assert_eq!(shell_quote("/tmp/o'brien"), "'/tmp/o'\\''brien'");
318    }
319
320    #[test]
321    fn empty_path_is_quoted() {
322        assert_eq!(shell_quote(""), "''");
323    }
324}