use std::path::{Path, PathBuf};
use anyhow::{bail, Result};
use chrono::Utc;
use clap::{Parser, Subcommand};
use serde_json::Value;
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(long)]
pub json: bool,
}
impl ListCommand {
pub async fn execute(self) -> Result<()> {
let socket = server::resolve_socket(self.socket)?;
let result = call(&socket, "list", Value::Null).await?;
if self.json {
println!("{}", serde_json::to_string_pretty(&result)?);
return Ok(());
}
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!(
"{:<24} {:<32} {:<40} {:>5}",
"REPO", "TITLE", "FOLDER", "AGE"
);
for window in windows {
let repo = window.get("repo").and_then(Value::as_str).unwrap_or("-");
let title = window.get("title").and_then(Value::as_str).unwrap_or("");
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:<24} {title:<32} {folder_disp:<40} {age:>4}s"
));
}
out
}
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 = folders.first().and_then(Value::as_str).unwrap_or("");
let extra = folders.len().saturating_sub(1);
if extra > 0 {
format!("{first} (+{extra})")
} else {
first.to_string()
}
}
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!(!cmd.json);
assert!(cmd.socket.is_none());
let WorktreesSubcommands::List(cmd) = parse(&["list", "--json", "--socket", "/tmp/d.sock"]);
assert!(cmd.json);
assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
}
#[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",
"title": "issue-1011 — worktrees",
"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("/home/me/omni-dev (+1)"), "{table}");
assert_eq!(table.lines().count(), 2, "{table}");
}
#[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}");
}
}