heddle_cli_render/cli/
render.rs1use 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
25pub 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
40pub 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
55pub 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
88pub 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
153pub 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
182pub 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#[derive(Clone, Debug, Default)]
203pub struct RenderOpts {
204 pub short: bool,
206 pub no_color: bool,
210 pub limit: Option<usize>,
212}
213
214pub trait RenderOutput: Serialize {
219 fn render_text<W: std::io::Write>(&self, w: &mut W, opts: RenderOpts) -> std::io::Result<()>;
220}
221
222pub 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
238pub 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
256pub 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
267pub 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}