omni_dev/cli/
worktrees.rs1use std::path::{Path, PathBuf};
11
12use anyhow::{bail, Result};
13use chrono::Utc;
14use clap::{Parser, Subcommand};
15use serde_json::Value;
16
17use crate::daemon::client::DaemonClient;
18use crate::daemon::protocol::{DaemonEnvelope, DaemonReply};
19use crate::daemon::server;
20
21const SERVICE: &str = "worktrees";
23
24#[derive(Parser)]
27pub struct WorktreesCommand {
28 #[command(subcommand)]
30 pub command: WorktreesSubcommands,
31}
32
33#[derive(Subcommand)]
35pub enum WorktreesSubcommands {
36 List(ListCommand),
38}
39
40impl WorktreesCommand {
41 pub async fn execute(self) -> Result<()> {
43 match self.command {
44 WorktreesSubcommands::List(cmd) => cmd.execute().await,
45 }
46 }
47}
48
49#[derive(Parser)]
51pub struct ListCommand {
52 #[arg(long, value_name = "PATH")]
54 pub socket: Option<PathBuf>,
55 #[arg(long)]
57 pub json: bool,
58}
59
60impl ListCommand {
61 pub async fn execute(self) -> Result<()> {
63 let socket = server::resolve_socket(self.socket)?;
64 let result = call(&socket, "list", Value::Null).await?;
65 if self.json {
66 println!("{}", serde_json::to_string_pretty(&result)?);
67 return Ok(());
68 }
69 println!("{}", render_windows(&result));
70 Ok(())
71 }
72}
73
74async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
77 let reply = DaemonClient::new(socket)
78 .request(DaemonEnvelope::service(SERVICE, op, payload))
79 .await?;
80 reply_payload(reply)
81}
82
83fn reply_payload(reply: DaemonReply) -> Result<Value> {
86 if reply.ok {
87 Ok(reply.payload)
88 } else {
89 bail!(
90 "daemon returned an error: {}",
91 reply.error.as_deref().unwrap_or("unknown error")
92 )
93 }
94}
95
96fn render_windows(result: &Value) -> String {
100 let windows = result
101 .get("windows")
102 .and_then(Value::as_array)
103 .map(Vec::as_slice)
104 .unwrap_or_default();
105 if windows.is_empty() {
106 return "No open windows.".to_string();
107 }
108 let mut out = format!(
109 "{:<24} {:<32} {:<40} {:>5}",
110 "REPO", "TITLE", "FOLDER", "AGE"
111 );
112 for window in windows {
113 let repo = window.get("repo").and_then(Value::as_str).unwrap_or("-");
114 let title = window.get("title").and_then(Value::as_str).unwrap_or("");
115 let folder_disp = folder_summary(window);
116 let age = age_secs(window.get("last_seen").and_then(Value::as_str));
117 out.push_str(&format!(
118 "\n{repo:<24} {title:<32} {folder_disp:<40} {age:>4}s"
119 ));
120 }
121 out
122}
123
124fn folder_summary(window: &Value) -> String {
127 let folders = window
128 .get("folders")
129 .and_then(Value::as_array)
130 .map(Vec::as_slice)
131 .unwrap_or_default();
132 let first = folders.first().and_then(Value::as_str).unwrap_or("");
133 let extra = folders.len().saturating_sub(1);
134 if extra > 0 {
135 format!("{first} (+{extra})")
136 } else {
137 first.to_string()
138 }
139}
140
141fn age_secs(ts: Option<&str>) -> i64 {
143 ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
144 .map_or(0, |t| {
145 (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
146 })
147}
148
149#[cfg(test)]
150#[allow(clippy::unwrap_used, clippy::expect_used)]
151mod tests {
152 use super::*;
153 use serde_json::json;
154
155 #[derive(Parser)]
157 struct Wrapper {
158 #[command(subcommand)]
159 cmd: WorktreesSubcommands,
160 }
161
162 fn parse(args: &[&str]) -> WorktreesSubcommands {
163 let mut full = vec!["omni-dev"];
164 full.extend_from_slice(args);
165 Wrapper::try_parse_from(full).unwrap().cmd
166 }
167
168 #[test]
169 fn list_parses_flags_and_defaults() {
170 let WorktreesSubcommands::List(cmd) = parse(&["list"]);
171 assert!(!cmd.json);
172 assert!(cmd.socket.is_none());
173
174 let WorktreesSubcommands::List(cmd) = parse(&["list", "--json", "--socket", "/tmp/d.sock"]);
175 assert!(cmd.json);
176 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
177 }
178
179 #[test]
180 fn render_windows_handles_empty_replies() {
181 assert_eq!(
182 render_windows(&json!({ "windows": [] })),
183 "No open windows."
184 );
185 assert_eq!(render_windows(&json!({})), "No open windows.");
186 }
187
188 #[test]
189 fn render_windows_renders_rows() {
190 let result = json!({ "windows": [{
191 "key": "w1",
192 "repo": "omni-dev",
193 "title": "issue-1011 — worktrees",
194 "folders": ["/home/me/omni-dev", "/home/me/docs"],
195 "last_seen": "2000-01-01T00:00:00Z",
196 }]});
197 let table = render_windows(&result);
198 assert!(table.contains("omni-dev"), "{table}");
199 assert!(table.contains("issue-1011"), "{table}");
200 assert!(table.contains("/home/me/omni-dev (+1)"), "{table}");
202 assert_eq!(table.lines().count(), 2, "{table}");
204 }
205
206 #[test]
207 fn folder_summary_counts_extra_folders() {
208 assert_eq!(folder_summary(&json!({ "folders": [] })), "");
209 assert_eq!(folder_summary(&json!({ "folders": ["/a"] })), "/a");
210 assert_eq!(
211 folder_summary(&json!({ "folders": ["/a", "/b", "/c"] })),
212 "/a (+2)"
213 );
214 }
215
216 #[test]
217 fn age_secs_handles_absent_and_unparseable_and_past() {
218 assert_eq!(age_secs(None), 0);
219 assert_eq!(age_secs(Some("not-a-timestamp")), 0);
220 assert!(age_secs(Some("2000-01-01T00:00:00Z")) > 0);
221 }
222
223 #[test]
224 fn reply_payload_unwraps_ok_and_maps_errors() {
225 assert_eq!(
227 reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
228 json!({ "a": 1 })
229 );
230 let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
232 assert!(err.to_string().contains("boom"), "{err}");
233 let err = reply_payload(DaemonReply {
235 ok: false,
236 payload: Value::Null,
237 error: None,
238 })
239 .unwrap_err();
240 assert!(err.to_string().contains("unknown error"), "{err}");
241 }
242}