Skip to main content

omni_dev/cli/
worktrees.rs

1//! `omni-dev worktrees` — a thin client for the daemon's cross-window worktree
2//! registry.
3//!
4//! Lifecycle stays on `omni-dev daemon` (`start`/`stop`/`status`/`restart`);
5//! this command only sends the `worktrees` service's read op (`list`) over the
6//! daemon's Unix control socket. The companion VS Code extension is what *feeds*
7//! the registry (`register`/`heartbeat`/`unregister`), talking to the same
8//! socket directly from each window.
9
10use 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
21/// The `worktrees` service routing key on the daemon control socket.
22const SERVICE: &str = "worktrees";
23
24/// Worktrees: see the repos/worktrees open across every VS Code window, kept
25/// live by the daemon.
26#[derive(Parser)]
27pub struct WorktreesCommand {
28    /// The worktrees subcommand to execute.
29    #[command(subcommand)]
30    pub command: WorktreesSubcommands,
31}
32
33/// Worktrees subcommands.
34#[derive(Subcommand)]
35pub enum WorktreesSubcommands {
36    /// List the repos/worktrees currently open across all windows.
37    List(ListCommand),
38}
39
40impl WorktreesCommand {
41    /// Executes the worktrees command.
42    pub async fn execute(self) -> Result<()> {
43        match self.command {
44            WorktreesSubcommands::List(cmd) => cmd.execute().await,
45        }
46    }
47}
48
49/// Lists the live cross-window set of open worktrees/repos.
50#[derive(Parser)]
51pub struct ListCommand {
52    /// Control-socket path. Defaults to the per-user runtime location.
53    #[arg(long, value_name = "PATH")]
54    pub socket: Option<PathBuf>,
55    /// Emit machine-readable JSON instead of a table.
56    #[arg(long)]
57    pub json: bool,
58}
59
60impl ListCommand {
61    /// Executes the list command.
62    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
74/// Sends one `worktrees` service op over the control socket, returning its
75/// payload or turning an `ok: false` reply into an error.
76async 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
83/// Unwraps a daemon reply into its payload, turning an `ok: false` reply into an
84/// error. Pure (no socket), so both mappings are unit-testable.
85fn 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
96/// Renders a `list` reply as a human-readable table: a header and one row per
97/// open window (repo, title, primary folder, and how long ago it was last
98/// seen). Returns a placeholder line when nothing is open.
99fn 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
124/// The primary folder of a window, with a `(+N)` suffix when it has more than
125/// one workspace folder.
126fn 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
141/// Seconds elapsed since an RFC 3339 timestamp (0 if absent/unparseable).
142fn 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    /// Mirrors the `omni-dev worktrees` argv surface for parse tests.
156    #[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        // Primary folder plus a (+1) for the second workspace folder.
201        assert!(table.contains("/home/me/omni-dev (+1)"), "{table}");
202        // A header line plus exactly one data row.
203        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        // ok → payload.
226        assert_eq!(
227            reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
228            json!({ "a": 1 })
229        );
230        // ok: false with a message → that message.
231        let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
232        assert!(err.to_string().contains("boom"), "{err}");
233        // ok: false with no message → the "unknown error" fallback.
234        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}