1use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
9pub struct ProjectStatus {
10 pub name: String,
12 pub status: String,
14 pub states: BTreeMap<String, u32>,
16 pub config_files: String,
18}
19
20impl ProjectStatus {
21 pub fn running(&self) -> u32 {
23 self.states.get("running").copied().unwrap_or(0)
24 }
25
26 pub fn is_running(&self) -> bool {
28 self.running() > 0
29 }
30}
31
32pub fn parse_ls(json: &str) -> Result<Vec<ProjectStatus>, serde_json::Error> {
34 #[derive(Deserialize)]
35 struct Row {
36 #[serde(rename = "Name")]
37 name: String,
38 #[serde(rename = "Status", default)]
39 status: String,
40 #[serde(rename = "ConfigFiles", default)]
41 config_files: String,
42 }
43 if json.trim().is_empty() {
44 return Ok(Vec::new());
45 }
46 let rows: Vec<Row> = serde_json::from_str(json)?;
47 Ok(rows
48 .into_iter()
49 .map(|r| ProjectStatus {
50 states: parse_states(&r.status),
51 name: r.name,
52 status: r.status,
53 config_files: r.config_files,
54 })
55 .collect())
56}
57
58fn parse_states(status: &str) -> BTreeMap<String, u32> {
60 status
61 .split(',')
62 .filter_map(|part| {
63 let (state, count) = part.trim().split_once('(')?;
64 let count = count.strip_suffix(')')?.parse().ok()?;
65 Some((state.to_owned(), count))
66 })
67 .collect()
68}
69
70#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
72#[serde(rename_all = "snake_case")]
73pub enum State {
74 Created,
76 Running,
78 Paused,
80 Restarting,
82 Removing,
84 Exited,
86 Dead,
88 Other(String),
90}
91
92impl State {
93 fn parse(s: &str) -> Self {
94 match s {
95 "created" => State::Created,
96 "running" => State::Running,
97 "paused" => State::Paused,
98 "restarting" => State::Restarting,
99 "removing" => State::Removing,
100 "exited" => State::Exited,
101 "dead" => State::Dead,
102 other => State::Other(other.to_owned()),
103 }
104 }
105}
106
107#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
109#[serde(rename_all = "snake_case")]
110pub enum Health {
111 None,
113 Starting,
115 Healthy,
117 Unhealthy,
119}
120
121impl Health {
122 pub fn parse(s: &str) -> Self {
125 match s.trim() {
126 "starting" => Health::Starting,
127 "healthy" => Health::Healthy,
128 "unhealthy" => Health::Unhealthy,
129 _ => Health::None,
130 }
131 }
132}
133
134#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
136pub struct Published {
137 pub address: String,
139 pub host_port: u16,
141 pub container_port: u16,
143 pub protocol: String,
145}
146
147#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
149pub struct ServiceStatus {
150 pub service: String,
152 pub container: String,
154 pub state: State,
156 pub health: Health,
158 pub exit_code: i32,
160 pub status: String,
162 pub published: Vec<Published>,
164}
165
166impl ServiceStatus {
167 pub fn is_ready(&self) -> bool {
169 self.state == State::Running && matches!(self.health, Health::None | Health::Healthy)
170 }
171}
172
173pub fn parse_ps(json: &str) -> Result<Vec<ServiceStatus>, serde_json::Error> {
176 #[derive(Deserialize)]
177 struct Publisher {
178 #[serde(rename = "URL", default)]
179 url: String,
180 #[serde(rename = "TargetPort", default)]
181 target_port: u16,
182 #[serde(rename = "PublishedPort", default)]
183 published_port: u16,
184 #[serde(rename = "Protocol", default)]
185 protocol: String,
186 }
187 #[derive(Deserialize)]
188 struct Row {
189 #[serde(rename = "Service")]
190 service: String,
191 #[serde(rename = "Name", default)]
192 name: String,
193 #[serde(rename = "State", default)]
194 state: String,
195 #[serde(rename = "Health", default)]
196 health: String,
197 #[serde(rename = "ExitCode", default)]
198 exit_code: i32,
199 #[serde(rename = "Status", default)]
200 status: String,
201 #[serde(rename = "Publishers", default)]
202 publishers: Option<Vec<Publisher>>,
203 }
204 let json = json.trim();
205 let rows: Vec<Row> = if json.starts_with('[') {
206 serde_json::from_str(json)?
207 } else {
208 json.lines()
209 .filter(|l| !l.trim().is_empty())
210 .map(serde_json::from_str)
211 .collect::<Result<_, _>>()?
212 };
213 Ok(rows
214 .into_iter()
215 .map(|r| ServiceStatus {
216 service: r.service,
217 container: r.name,
218 state: State::parse(&r.state),
219 health: Health::parse(&r.health),
220 exit_code: r.exit_code,
221 status: r.status,
222 published: r
223 .publishers
224 .unwrap_or_default()
225 .into_iter()
226 .filter(|p| p.published_port != 0)
228 .map(|p| Published {
229 address: p.url,
230 host_port: p.published_port,
231 container_port: p.target_port,
232 protocol: p.protocol,
233 })
234 .collect(),
235 })
236 .collect())
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 #[test]
244 fn states_parse_from_the_status_string() {
245 let states = parse_states("exited(1), running(2)");
246 assert_eq!(states.get("exited"), Some(&1));
247 assert_eq!(states.get("running"), Some(&2));
248 assert!(parse_states("").is_empty());
249 assert!(parse_states("garbage").is_empty());
250 }
251
252 #[test]
253 fn an_empty_ls_is_no_projects() {
254 assert!(parse_ls("").unwrap().is_empty());
255 assert!(parse_ls("[]\n").unwrap().is_empty());
256 }
257
258 #[test]
259 fn ps_accepts_an_array_too() {
260 let rows = parse_ps(
261 r#"[{"Service":"web","Name":"p-web-1","State":"running","Health":"","ExitCode":0,
262 "Status":"Up","Publishers":null}]"#,
263 )
264 .unwrap();
265 assert_eq!(rows.len(), 1);
266 assert!(rows[0].is_ready());
267 assert!(rows[0].published.is_empty());
268 }
269}