use anyhow::Result;
use repo::{Repository, Thread, ThreadManager};
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 repository_mode_label(capability: &str, storage_model: &str) -> String {
if capability == "git-overlay" || storage_model == "git+heddle-sidecar" {
"Git + Heddle".to_string()
} else if capability == "plain-git" || storage_model == "git-only" {
"Git repo (setup needed)".to_string()
} else if capability == "native"
|| capability == "native-heddle"
|| storage_model == "heddle-native"
{
"Heddle native".to_string()
} else {
capability.to_string()
}
}
#[derive(Clone, Debug, Serialize)]
pub struct RepositoryContextInfo {
pub kind: String,
pub parent_repository: Option<String>,
pub target_thread: Option<String>,
pub parent_thread: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct RepositoryPresentation {
pub label: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<RepositoryContextInfo>,
}
pub fn repository_presentation(
repo: &Repository,
target_thread: Option<&str>,
parent_thread: Option<&str>,
) -> RepositoryPresentation {
if let Some(parent_root) = managed_git_overlay_parent_root(repo) {
let thread = current_child_thread(repo);
let target_thread = target_thread.map(ToString::to_string).or_else(|| {
thread
.as_ref()
.and_then(|thread| thread.target_thread.clone())
});
let parent_thread = parent_thread.map(ToString::to_string).or_else(|| {
thread
.as_ref()
.and_then(|thread| thread.parent_thread.clone())
});
return RepositoryPresentation {
label: "Git + Heddle isolated checkout".to_string(),
context: Some(RepositoryContextInfo {
kind: "git-overlay-isolated-checkout".to_string(),
parent_repository: Some(parent_root.display().to_string()),
target_thread,
parent_thread,
}),
};
}
RepositoryPresentation {
label: repository_mode_label(repo.capability_label(), repo.storage_model_label()),
context: None,
}
}
fn managed_git_overlay_parent_root(repo: &Repository) -> Option<std::path::PathBuf> {
let parent_root = repo.heddle_dir().parent()?;
if paths_equal(parent_root, repo.root()) {
return None;
}
parent_root
.join(".git")
.exists()
.then(|| parent_root.to_path_buf())
}
fn current_child_thread(repo: &Repository) -> Option<Thread> {
let manager = ThreadManager::new(repo.heddle_dir());
if let Ok(Some(thread)) = manager.find_by_execution_root(repo.root()) {
return Some(thread);
}
let lane = repo.current_lane().ok().flatten()?;
manager.find_by_thread(&lane).ok().flatten()
}
fn paths_equal(left: &std::path::Path, right: &std::path::Path) -> bool {
let left = left.canonicalize().unwrap_or_else(|_| left.to_path_buf());
let right = right.canonicalize().unwrap_or_else(|_| right.to_path_buf());
left == right
}
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 git_only_branch_summary(branches: &[String], total: usize) -> String {
let noun = if total == 1 { "branch" } else { "branches" };
format!(
"Optional Git-only {noun} available: {}",
preview_list(branches, total)
)
}
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<()> {
if should_output_json(cli, cfg) {
write_json_stdout(out)?;
} else {
let stdout = std::io::stdout();
let mut handle = stdout.lock();
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<()> {
if should_output_json(cli, cfg) {
write_json_stdout(out)?;
} else {
let stdout = std::io::stdout();
let mut handle = stdout.lock();
out.render_text(&mut handle, opts)?;
}
Ok(())
}
pub fn write_json_stdout<T: Serialize>(out: &T) -> Result<()> {
let mut text = serde_json::to_string(out)?;
text.push('\n');
write_stdout(&text)
}
pub fn write_stdout(text: &str) -> Result<()> {
use std::io::Write;
let stdout = std::io::stdout();
let mut handle = stdout.lock();
match handle.write_all(text.as_bytes()) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
Err(err) => Err(err.into()),
}
}
#[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(""), "''");
}
}