use anyhow::Result;
use serde::Serialize;
use crate::cli::{cli_args::Cli, should_output_json};
pub fn real_or_none(value: &str) -> Option<&str> {
let trimmed = value.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("unknown") {
None
} else {
Some(value)
}
}
pub fn actor_display(provider: Option<&str>, model: Option<&str>) -> Option<String> {
let provider = provider.and_then(real_or_none);
let model = model.and_then(real_or_none);
match (provider, model) {
(Some(p), Some(m)) => Some(format!("{p}/{m}")),
(Some(p), None) => Some(p.to_string()),
(None, Some(m)) => Some(m.to_string()),
(None, None) => None,
}
}
pub fn preview_list(items: &[String], total: usize) -> String {
const PREVIEW: usize = 3;
let visible: Vec<&str> = items.iter().take(PREVIEW).map(String::as_str).collect();
let suffix = if total > visible.len() {
format!(", … +{} more", total - visible.len())
} else {
String::new()
};
format!("{}{suffix}", visible.join(", "))
}
pub fn shell_quote(path: &str) -> String {
let safe = !path.is_empty()
&& path
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'.' | b'_' | b'-' | b'+'));
if safe {
path.to_string()
} else {
format!("'{}'", path.replace('\'', "'\\''"))
}
}
#[derive(Clone, Debug, Default)]
pub struct RenderOpts {
pub short: bool,
pub no_color: bool,
pub limit: Option<usize>,
}
pub trait RenderOutput: Serialize {
fn render_text<W: std::io::Write>(&self, w: &mut W, opts: RenderOpts) -> std::io::Result<()>;
}
pub fn emit<T: RenderOutput>(cli: &Cli, cfg: Option<&repo::RepoConfig>, out: &T) -> Result<()> {
let stdout = std::io::stdout();
let mut handle = stdout.lock();
if should_output_json(cli, cfg) {
serde_json::to_writer(&mut handle, out)?;
use std::io::Write;
let _ = handle.write_all(b"\n");
} else {
out.render_text(&mut handle, RenderOpts::default())?;
}
Ok(())
}
pub fn emit_with_opts<T: RenderOutput>(
cli: &Cli,
cfg: Option<&repo::RepoConfig>,
out: &T,
opts: RenderOpts,
) -> Result<()> {
let stdout = std::io::stdout();
let mut handle = stdout.lock();
if should_output_json(cli, cfg) {
serde_json::to_writer(&mut handle, out)?;
use std::io::Write;
let _ = handle.write_all(b"\n");
} else {
out.render_text(&mut handle, opts)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::shell_quote;
#[test]
fn safe_paths_are_returned_unquoted() {
assert_eq!(shell_quote("/tmp/scratch"), "/tmp/scratch");
assert_eq!(
shell_quote("/home/user/.heddle-threads/my-thread/root"),
"/home/user/.heddle-threads/my-thread/root"
);
assert_eq!(
shell_quote("relative/path-1.2_3+x"),
"relative/path-1.2_3+x"
);
}
#[test]
fn paths_with_spaces_are_single_quoted() {
assert_eq!(shell_quote("/tmp/scratch dir"), "'/tmp/scratch dir'");
assert_eq!(
shell_quote("/Users/luke/My Repo/.thread"),
"'/Users/luke/My Repo/.thread'"
);
}
#[test]
fn metacharacters_are_single_quoted() {
assert_eq!(shell_quote("/tmp/$HOME"), "'/tmp/$HOME'");
assert_eq!(shell_quote("/tmp/(paren)"), "'/tmp/(paren)'");
assert_eq!(shell_quote("/tmp/a;b"), "'/tmp/a;b'");
assert_eq!(shell_quote("/tmp/a&b"), "'/tmp/a&b'");
assert_eq!(shell_quote("/tmp/a*b"), "'/tmp/a*b'");
}
#[test]
fn embedded_single_quote_is_escaped() {
assert_eq!(shell_quote("/tmp/o'brien"), "'/tmp/o'\\''brien'");
}
#[test]
fn empty_path_is_quoted() {
assert_eq!(shell_quote(""), "''");
}
}