use std::path::{Path, PathBuf};
use anyhow::{bail, Result};
use chrono::Utc;
use clap::{Parser, Subcommand};
use serde_json::Value;
use crate::cli::format::TableOrJson;
use crate::daemon::client::DaemonClient;
use crate::daemon::protocol::{DaemonEnvelope, DaemonReply};
use crate::daemon::server;
const SERVICE: &str = "worktrees";
#[derive(Parser)]
pub struct WorktreesCommand {
#[command(subcommand)]
pub command: WorktreesSubcommands,
}
#[derive(Subcommand)]
pub enum WorktreesSubcommands {
List(ListCommand),
}
impl WorktreesCommand {
pub async fn execute(self) -> Result<()> {
match self.command {
WorktreesSubcommands::List(cmd) => cmd.execute().await,
}
}
}
#[derive(Parser)]
pub struct ListCommand {
#[arg(long, value_name = "PATH")]
pub socket: Option<PathBuf>,
#[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
pub output: TableOrJson,
#[arg(long, hide = true)]
pub json: bool,
}
impl ListCommand {
pub async fn execute(mut self) -> Result<()> {
if self.json {
eprintln!("warning: --json is deprecated; use -o/--output json instead");
self.output = TableOrJson::Json;
}
let socket = server::resolve_socket(self.socket)?;
let result = call(&socket, "list", Value::Null).await?;
match self.output {
TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
TableOrJson::Table => println!("{}", render_windows(&result)),
}
Ok(())
}
}
async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
let reply = DaemonClient::new(socket)
.request(DaemonEnvelope::service(SERVICE, op, payload))
.await?;
reply_payload(reply)
}
fn reply_payload(reply: DaemonReply) -> Result<Value> {
if reply.ok {
Ok(reply.payload)
} else {
bail!(
"daemon returned an error: {}",
reply.error.as_deref().unwrap_or("unknown error")
)
}
}
fn render_windows(result: &Value) -> String {
let windows = result
.get("windows")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
if windows.is_empty() {
return "No open windows.".to_string();
}
let mut out = format!(
"{:<22} {:<24} {:<9} {:<40} {:>5}",
"REPO", "BRANCH", "SYNC", "FOLDER", "AGE"
);
for window in windows {
let repo = sanitize(window.get("repo").and_then(Value::as_str).unwrap_or("-"));
let branch = sanitize(window.get("branch").and_then(Value::as_str).unwrap_or("-"));
let sync = sync_summary(window);
let folder_disp = folder_summary(window);
let age = age_secs(window.get("last_seen").and_then(Value::as_str));
out.push_str(&format!(
"\n{repo:<22} {branch:<24} {sync:<9} {folder_disp:<40} {age:>4}s"
));
}
out
}
fn sync_summary(window: &Value) -> String {
let ahead = window.get("ahead").and_then(Value::as_u64);
let behind = window.get("behind").and_then(Value::as_u64);
match (ahead, behind) {
(Some(ahead), Some(behind)) => format!("+{ahead} -{behind}"),
_ => "-".to_string(),
}
}
fn folder_summary(window: &Value) -> String {
let folders = window
.get("folders")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
let first = sanitize(folders.first().and_then(Value::as_str).unwrap_or(""));
let extra = folders.len().saturating_sub(1);
if extra > 0 {
format!("{first} (+{extra})")
} else {
first
}
}
fn sanitize(s: &str) -> String {
s.chars().filter(|c| !c.is_control()).collect()
}
fn age_secs(ts: Option<&str>) -> i64 {
ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map_or(0, |t| {
(Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use serde_json::json;
#[derive(Parser)]
struct Wrapper {
#[command(subcommand)]
cmd: WorktreesSubcommands,
}
fn parse(args: &[&str]) -> WorktreesSubcommands {
let mut full = vec!["omni-dev"];
full.extend_from_slice(args);
Wrapper::try_parse_from(full).unwrap().cmd
}
#[test]
fn list_parses_flags_and_defaults() {
let WorktreesSubcommands::List(cmd) = parse(&["list"]);
assert_eq!(cmd.output, TableOrJson::Table);
assert!(!cmd.json);
assert!(cmd.socket.is_none());
let WorktreesSubcommands::List(cmd) =
parse(&["list", "-o", "json", "--socket", "/tmp/d.sock"]);
assert_eq!(cmd.output, TableOrJson::Json);
assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
}
#[test]
fn list_deprecated_json_flag_still_parses() {
let WorktreesSubcommands::List(cmd) = parse(&["list", "--json"]);
assert!(cmd.json);
assert_eq!(cmd.output, TableOrJson::Table);
}
#[test]
fn render_windows_handles_empty_replies() {
assert_eq!(
render_windows(&json!({ "windows": [] })),
"No open windows."
);
assert_eq!(render_windows(&json!({})), "No open windows.");
}
#[test]
fn render_windows_renders_rows() {
let result = json!({ "windows": [{
"key": "w1",
"repo": "omni-dev",
"branch": "issue-1011",
"ahead": 2,
"behind": 1,
"folders": ["/home/me/omni-dev", "/home/me/docs"],
"last_seen": "2000-01-01T00:00:00Z",
}]});
let table = render_windows(&result);
assert!(table.contains("omni-dev"), "{table}");
assert!(table.contains("issue-1011"), "{table}");
assert!(table.contains("+2 -1"), "{table}");
assert!(table.contains("/home/me/omni-dev (+1)"), "{table}");
assert_eq!(table.lines().count(), 2, "{table}");
}
#[test]
fn render_windows_strips_control_bytes() {
let result = json!({ "windows": [{
"key": "w1",
"repo": "evil\x1b[31mrepo",
"branch": "br\ranch\x07\u{9b}2J",
"folders": ["/tmp/a\x1b]0;owned\x07\u{7f}", "/tmp/b"],
"last_seen": "2000-01-01T00:00:00Z",
}]});
let table = render_windows(&result);
assert!(
!table.contains(|c: char| c.is_control() && c != '\n'),
"{table:?}"
);
assert!(table.contains("evil[31mrepo"), "{table:?}");
assert!(table.contains("branch2J"), "{table:?}");
assert!(table.contains("/tmp/a]0;owned (+1)"), "{table:?}");
assert_eq!(table.lines().count(), 2, "{table:?}");
}
#[test]
fn sync_summary_formats_or_dashes() {
assert_eq!(sync_summary(&json!({ "ahead": 2, "behind": 1 })), "+2 -1");
assert_eq!(sync_summary(&json!({ "ahead": 0, "behind": 0 })), "+0 -0");
assert_eq!(sync_summary(&json!({ "branch": "main" })), "-");
assert_eq!(sync_summary(&json!({})), "-");
}
#[test]
fn folder_summary_strips_control_bytes() {
assert_eq!(
folder_summary(&json!({ "folders": ["/a\x1b[2J/b"] })),
"/a[2J/b"
);
}
#[test]
fn folder_summary_counts_extra_folders() {
assert_eq!(folder_summary(&json!({ "folders": [] })), "");
assert_eq!(folder_summary(&json!({ "folders": ["/a"] })), "/a");
assert_eq!(
folder_summary(&json!({ "folders": ["/a", "/b", "/c"] })),
"/a (+2)"
);
}
#[test]
fn age_secs_handles_absent_and_unparseable_and_past() {
assert_eq!(age_secs(None), 0);
assert_eq!(age_secs(Some("not-a-timestamp")), 0);
assert!(age_secs(Some("2000-01-01T00:00:00Z")) > 0);
}
#[test]
fn reply_payload_unwraps_ok_and_maps_errors() {
assert_eq!(
reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
json!({ "a": 1 })
);
let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
assert!(err.to_string().contains("boom"), "{err}");
let err = reply_payload(DaemonReply {
ok: false,
payload: Value::Null,
error: None,
})
.unwrap_err();
assert!(err.to_string().contains("unknown error"), "{err}");
}
}