1use std::path::{Path, PathBuf};
11
12use anyhow::{bail, Result};
13use chrono::Utc;
14use clap::{Parser, Subcommand};
15use serde_json::Value;
16
17use crate::cli::format::TableOrJson;
18use crate::daemon::client::DaemonClient;
19use crate::daemon::protocol::{DaemonEnvelope, DaemonReply};
20use crate::daemon::server;
21
22const SERVICE: &str = "worktrees";
24
25#[derive(Parser)]
28pub struct WorktreesCommand {
29 #[command(subcommand)]
31 pub command: WorktreesSubcommands,
32}
33
34#[derive(Subcommand)]
36pub enum WorktreesSubcommands {
37 List(ListCommand),
39}
40
41impl WorktreesCommand {
42 pub async fn execute(self) -> Result<()> {
44 match self.command {
45 WorktreesSubcommands::List(cmd) => cmd.execute().await,
46 }
47 }
48}
49
50#[derive(Parser)]
52pub struct ListCommand {
53 #[arg(long, value_name = "PATH")]
55 pub socket: Option<PathBuf>,
56 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
58 pub output: TableOrJson,
59 #[arg(long, hide = true)]
61 pub json: bool,
62}
63
64impl ListCommand {
65 pub async fn execute(mut self) -> Result<()> {
67 if self.json {
68 eprintln!("warning: --json is deprecated; use -o/--output json instead");
69 self.output = TableOrJson::Json;
70 }
71 let socket = server::resolve_socket(self.socket)?;
72 let result = call(&socket, "list", Value::Null).await?;
73 match self.output {
74 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
75 TableOrJson::Table => println!("{}", render_windows(&result)),
76 }
77 Ok(())
78 }
79}
80
81async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
84 let reply = DaemonClient::new(socket)
85 .request(DaemonEnvelope::service(SERVICE, op, payload))
86 .await?;
87 reply_payload(reply)
88}
89
90fn reply_payload(reply: DaemonReply) -> Result<Value> {
93 if reply.ok {
94 Ok(reply.payload)
95 } else {
96 bail!(
97 "daemon returned an error: {}",
98 reply.error.as_deref().unwrap_or("unknown error")
99 )
100 }
101}
102
103fn render_windows(result: &Value) -> String {
108 let windows = result
109 .get("windows")
110 .and_then(Value::as_array)
111 .map(Vec::as_slice)
112 .unwrap_or_default();
113 if windows.is_empty() {
114 return "No open windows.".to_string();
115 }
116 let mut out = format!(
117 "{:<22} {:<24} {:<9} {:<40} {:>5}",
118 "REPO", "BRANCH", "SYNC", "FOLDER", "AGE"
119 );
120 for window in windows {
121 let repo = sanitize(window.get("repo").and_then(Value::as_str).unwrap_or("-"));
122 let branch = sanitize(window.get("branch").and_then(Value::as_str).unwrap_or("-"));
123 let sync = sync_summary(window);
124 let folder_disp = folder_summary(window);
125 let age = age_secs(window.get("last_seen").and_then(Value::as_str));
126 out.push_str(&format!(
127 "\n{repo:<22} {branch:<24} {sync:<9} {folder_disp:<40} {age:>4}s"
128 ));
129 }
130 out
131}
132
133fn sync_summary(window: &Value) -> String {
137 let ahead = window.get("ahead").and_then(Value::as_u64);
138 let behind = window.get("behind").and_then(Value::as_u64);
139 match (ahead, behind) {
140 (Some(ahead), Some(behind)) => format!("+{ahead} -{behind}"),
141 _ => "-".to_string(),
142 }
143}
144
145fn folder_summary(window: &Value) -> String {
148 let folders = window
149 .get("folders")
150 .and_then(Value::as_array)
151 .map(Vec::as_slice)
152 .unwrap_or_default();
153 let first = sanitize(folders.first().and_then(Value::as_str).unwrap_or(""));
154 let extra = folders.len().saturating_sub(1);
155 if extra > 0 {
156 format!("{first} (+{extra})")
157 } else {
158 first
159 }
160}
161
162fn sanitize(s: &str) -> String {
166 s.chars().filter(|c| !c.is_control()).collect()
167}
168
169fn age_secs(ts: Option<&str>) -> i64 {
171 ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
172 .map_or(0, |t| {
173 (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
174 })
175}
176
177#[cfg(test)]
178#[allow(clippy::unwrap_used, clippy::expect_used)]
179mod tests {
180 use super::*;
181 use serde_json::json;
182
183 #[derive(Parser)]
185 struct Wrapper {
186 #[command(subcommand)]
187 cmd: WorktreesSubcommands,
188 }
189
190 fn parse(args: &[&str]) -> WorktreesSubcommands {
191 let mut full = vec!["omni-dev"];
192 full.extend_from_slice(args);
193 Wrapper::try_parse_from(full).unwrap().cmd
194 }
195
196 #[test]
197 fn list_parses_flags_and_defaults() {
198 let WorktreesSubcommands::List(cmd) = parse(&["list"]);
199 assert_eq!(cmd.output, TableOrJson::Table);
200 assert!(!cmd.json);
201 assert!(cmd.socket.is_none());
202
203 let WorktreesSubcommands::List(cmd) =
204 parse(&["list", "-o", "json", "--socket", "/tmp/d.sock"]);
205 assert_eq!(cmd.output, TableOrJson::Json);
206 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
207 }
208
209 #[test]
210 fn list_deprecated_json_flag_still_parses() {
211 let WorktreesSubcommands::List(cmd) = parse(&["list", "--json"]);
213 assert!(cmd.json);
214 assert_eq!(cmd.output, TableOrJson::Table);
215 }
216
217 #[test]
218 fn render_windows_handles_empty_replies() {
219 assert_eq!(
220 render_windows(&json!({ "windows": [] })),
221 "No open windows."
222 );
223 assert_eq!(render_windows(&json!({})), "No open windows.");
224 }
225
226 #[test]
227 fn render_windows_renders_rows() {
228 let result = json!({ "windows": [{
229 "key": "w1",
230 "repo": "omni-dev",
231 "branch": "issue-1011",
232 "ahead": 2,
233 "behind": 1,
234 "folders": ["/home/me/omni-dev", "/home/me/docs"],
235 "last_seen": "2000-01-01T00:00:00Z",
236 }]});
237 let table = render_windows(&result);
238 assert!(table.contains("omni-dev"), "{table}");
239 assert!(table.contains("issue-1011"), "{table}");
241 assert!(table.contains("+2 -1"), "{table}");
242 assert!(table.contains("/home/me/omni-dev (+1)"), "{table}");
244 assert_eq!(table.lines().count(), 2, "{table}");
246 }
247
248 #[test]
249 fn render_windows_strips_control_bytes() {
250 let result = json!({ "windows": [{
253 "key": "w1",
254 "repo": "evil\x1b[31mrepo",
255 "branch": "br\ranch\x07\u{9b}2J",
256 "folders": ["/tmp/a\x1b]0;owned\x07\u{7f}", "/tmp/b"],
257 "last_seen": "2000-01-01T00:00:00Z",
258 }]});
259 let table = render_windows(&result);
260 assert!(
261 !table.contains(|c: char| c.is_control() && c != '\n'),
262 "{table:?}"
263 );
264 assert!(table.contains("evil[31mrepo"), "{table:?}");
266 assert!(table.contains("branch2J"), "{table:?}");
267 assert!(table.contains("/tmp/a]0;owned (+1)"), "{table:?}");
268 assert_eq!(table.lines().count(), 2, "{table:?}");
270 }
271
272 #[test]
273 fn sync_summary_formats_or_dashes() {
274 assert_eq!(sync_summary(&json!({ "ahead": 2, "behind": 1 })), "+2 -1");
275 assert_eq!(sync_summary(&json!({ "ahead": 0, "behind": 0 })), "+0 -0");
276 assert_eq!(sync_summary(&json!({ "branch": "main" })), "-");
278 assert_eq!(sync_summary(&json!({})), "-");
279 }
280
281 #[test]
282 fn folder_summary_strips_control_bytes() {
283 assert_eq!(
284 folder_summary(&json!({ "folders": ["/a\x1b[2J/b"] })),
285 "/a[2J/b"
286 );
287 }
288
289 #[test]
290 fn folder_summary_counts_extra_folders() {
291 assert_eq!(folder_summary(&json!({ "folders": [] })), "");
292 assert_eq!(folder_summary(&json!({ "folders": ["/a"] })), "/a");
293 assert_eq!(
294 folder_summary(&json!({ "folders": ["/a", "/b", "/c"] })),
295 "/a (+2)"
296 );
297 }
298
299 #[test]
300 fn age_secs_handles_absent_and_unparseable_and_past() {
301 assert_eq!(age_secs(None), 0);
302 assert_eq!(age_secs(Some("not-a-timestamp")), 0);
303 assert!(age_secs(Some("2000-01-01T00:00:00Z")) > 0);
304 }
305
306 #[test]
307 fn reply_payload_unwraps_ok_and_maps_errors() {
308 assert_eq!(
310 reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
311 json!({ "a": 1 })
312 );
313 let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
315 assert!(err.to_string().contains("boom"), "{err}");
316 let err = reply_payload(DaemonReply {
318 ok: false,
319 payload: Value::Null,
320 error: None,
321 })
322 .unwrap_err();
323 assert!(err.to_string().contains("unknown error"), "{err}");
324 }
325}