heddle_cli_render/cli/
render.rs1use 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
24pub 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
39pub 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
54pub 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
87pub 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
152pub 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
181pub 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#[derive(Clone, Debug, Default)]
202pub struct RenderOpts {
203 pub short: bool,
205 pub no_color: bool,
209 pub limit: Option<usize>,
211}
212
213pub trait RenderOutput: Serialize {
218 fn render_text<W: std::io::Write>(&self, w: &mut W, opts: RenderOpts) -> std::io::Result<()>;
219}
220
221pub 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
237pub 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
255pub 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
266pub 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}