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