leviath_cli/commands/
ps.rs1use anyhow::bail;
8use leviath_runtime::components::AgentStatus;
9use leviath_runtime::control_socket::{ControlClient, ControlResponse};
10
11#[derive(clap::Args, Debug, Clone, Default)]
13pub struct PsArgs {}
14
15fn status_label(status: &AgentStatus) -> &'static str {
17 match status {
18 AgentStatus::Idle => "idle",
19 AgentStatus::Active => "active",
20 AgentStatus::Waiting => "waiting",
21 AgentStatus::Complete => "complete",
22 AgentStatus::Error { .. } => "error",
23 AgentStatus::Cancelled => "cancelled",
24 }
25}
26
27pub fn format_runs(runs: &[(String, AgentStatus)]) -> String {
30 if runs.is_empty() {
31 return "no agents running".to_string();
32 }
33 let width = runs.iter().map(|(id, _)| id.len()).max().unwrap_or(0);
34 runs.iter()
35 .map(|(id, status)| format!("{id:<width$} {}", status_label(status)))
36 .collect::<Vec<_>>()
37 .join("\n")
38}
39
40pub async fn send_list(client: &ControlClient) -> anyhow::Result<()> {
42 match client.list().await {
43 Ok(ControlResponse::List { runs }) => {
44 println!("{}", format_runs(&runs));
45 Ok(())
46 }
47 Ok(other) => bail!("unexpected daemon response: {other:?}"),
48 Err(e) => bail!("the leviath daemon is not reachable ({e}); start it with `lev daemon`"),
49 }
50}
51
52#[cfg(test)]
53mod tests {
54 use super::*;
55 use leviath_runtime::control_socket::{ControlId, bind_control_listener, control_id};
56 use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
57 use tokio::task::JoinHandle;
58
59 #[test]
60 fn status_labels_cover_every_variant() {
61 assert_eq!(status_label(&AgentStatus::Idle), "idle");
62 assert_eq!(status_label(&AgentStatus::Active), "active");
63 assert_eq!(status_label(&AgentStatus::Waiting), "waiting");
64 assert_eq!(status_label(&AgentStatus::Complete), "complete");
65 assert_eq!(
66 status_label(&AgentStatus::Error {
67 message: "x".to_string()
68 }),
69 "error"
70 );
71 assert_eq!(status_label(&AgentStatus::Cancelled), "cancelled");
72 }
73
74 #[test]
75 fn format_runs_aligns_and_handles_empty() {
76 assert_eq!(format_runs(&[]), "no agents running");
77 let runs = vec![
78 ("run-a".to_string(), AgentStatus::Active),
79 ("longer-run".to_string(), AgentStatus::Complete),
80 ];
81 let out = format_runs(&runs);
82 assert!(out.contains("run-a"));
83 assert!(out.contains("active"));
84 assert!(out.contains("longer-run complete"));
85 }
86
87 fn fake_daemon(
90 dir: &std::path::Path,
91 response_line: &'static str,
92 ) -> (ControlId, JoinHandle<()>) {
93 let id = control_id(dir);
94 let mut listener = bind_control_listener(&id).unwrap();
95 let handle = tokio::spawn(async move {
96 let stream = listener
97 .accept()
98 .await
99 .expect("accept succeeds")
100 .expect("our own connection is admitted");
101 let (read_half, mut write_half) = tokio::io::split(stream);
102 let mut lines = BufReader::new(read_half).lines();
103 let _request = lines.next_line().await.unwrap();
104 write_half
105 .write_all(response_line.as_bytes())
106 .await
107 .unwrap();
108 write_half.write_all(b"\n").await.unwrap();
109 });
110 (id, handle)
111 }
112
113 async fn list(response_line: &'static str) -> anyhow::Result<()> {
114 let dir = tempfile::tempdir().unwrap();
115 let (id, server) = fake_daemon(dir.path(), response_line);
116 let result = send_list(&ControlClient::new(id)).await;
117 server.await.unwrap();
118 result
119 }
120
121 #[tokio::test]
122 async fn send_list_prints_runs() {
123 assert!(
124 list(r#"{"result":"list","runs":[["run-a","Active"]]}"#)
125 .await
126 .is_ok()
127 );
128 }
129
130 #[tokio::test]
131 async fn send_list_rejects_unexpected_response() {
132 let err = list(r#"{"result":"ok","ok":true}"#).await.unwrap_err();
133 assert!(err.to_string().contains("unexpected"));
134 }
135
136 #[tokio::test]
137 async fn send_list_errors_when_daemon_absent() {
138 let dir = tempfile::tempdir().unwrap();
139 let err = send_list(&ControlClient::new(control_id(
140 &dir.path().join("no-daemon"),
141 )))
142 .await
143 .unwrap_err();
144 assert!(err.to_string().contains("not reachable"));
145 }
146}