Skip to main content

lean_ctx_patterns/
argocd.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Argo CD (`argocd app get`/`sync`/`list`) output compression.
4//!
5//! `argocd app get`/`sync` print a key/value metadata header followed by a
6//! resource table where most rows are `Synced`/`Healthy` (noise). We keep the
7//! important status keys (sync/health/phase/message/url) and only the resource
8//! rows that are *not* both Synced and Healthy, plus a kept/total tally.
9
10use crate::ansi::strip_ansi;
11
12const KEYS: &[&str] = &[
13    "Name:",
14    "URL:",
15    "Project:",
16    "Sync Status:",
17    "Health Status:",
18    "Operation:",
19    "Phase:",
20    "Message:",
21];
22
23pub fn compress(command: &str, output: &str) -> Option<String> {
24    let sub = command
25        .trim()
26        .strip_prefix("argocd")
27        .map_or("", str::trim_start);
28    if sub.starts_with("app get") || sub.starts_with("app sync") || sub.starts_with("app wait") {
29        return Some(compress_app(output));
30    }
31    if sub.starts_with("app list") {
32        return Some(compress_table(output, "argocd app list"));
33    }
34    None
35}
36
37fn compress_app(output: &str) -> String {
38    let mut header: Vec<String> = Vec::new();
39    let mut table: Vec<String> = Vec::new();
40    let mut in_table = false;
41
42    for raw in output.lines() {
43        let line = strip_ansi(raw);
44        let t = line.trim();
45        if t.is_empty() {
46            continue;
47        }
48        if is_table_header(t) {
49            in_table = true;
50            table.push(t.to_string());
51            continue;
52        }
53        if in_table {
54            table.push(t.to_string());
55            continue;
56        }
57        if KEYS.iter().any(|k| t.starts_with(k)) || t.to_ascii_lowercase().contains("error") {
58            header.push(t.to_string());
59        }
60    }
61
62    let mut parts = header;
63    if table.len() > 1 {
64        parts.push(filter_rows(&table));
65    }
66    if parts.is_empty() {
67        return "argocd: ok".to_string();
68    }
69    parts.join("\n")
70}
71
72fn compress_table(output: &str, label: &str) -> String {
73    let rows: Vec<&str> = output.lines().map(str::trim_end).collect();
74    let table: Vec<String> = rows
75        .iter()
76        .map(|l| strip_ansi(l).trim().to_string())
77        .filter(|l| !l.is_empty())
78        .collect();
79    if table.is_empty() {
80        return format!("{label}: ok");
81    }
82    filter_rows(&table)
83}
84
85/// Keep the header row + rows that are not both Synced and Healthy.
86fn filter_rows(table: &[String]) -> String {
87    let header = &table[0];
88    let mut kept: Vec<String> = vec![header.clone()];
89    let mut healthy = 0usize;
90    for row in &table[1..] {
91        if row.contains("Synced") && row.contains("Healthy") {
92            healthy += 1;
93        } else {
94            kept.push(row.clone());
95        }
96    }
97    let total = table.len() - 1;
98    let mut s = kept.join("\n");
99    if healthy > 0 {
100        s.push_str(&format!(
101            "\n({healthy}/{total} resources Synced+Healthy, hidden)"
102        ));
103    }
104    s
105}
106
107fn is_table_header(t: &str) -> bool {
108    let u = t.to_ascii_uppercase();
109    (u.starts_with("GROUP") || u.starts_with("NAME") || u.starts_with("TIMESTAMP"))
110        && u.contains("STATUS")
111        && u.contains("HEALTH")
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    const GET: &str = "Name:               argocd/myapp\nProject:            default\nURL:                https://argocd.example.com/applications/myapp\nSync Status:        Synced to HEAD (abc1234)\nHealth Status:      Healthy\n\nGROUP  KIND        NAMESPACE  NAME      STATUS     HEALTH       HOOK  MESSAGE\n       Service     myns       mysvc     Synced     Healthy            service/mysvc created\napps   Deployment  myns      mydeploy   OutOfSync  Progressing        waiting for rollout\n";
119
120    #[test]
121    fn keeps_status_and_unhealthy_rows() {
122        let r = compress("argocd app get myapp", GET).unwrap();
123        assert!(r.contains("Sync Status:"), "{r}");
124        assert!(r.contains("Health Status:      Healthy"), "{r}");
125        assert!(r.contains("mydeploy"), "keeps unhealthy row: {r}");
126        assert!(r.contains("OutOfSync"), "{r}");
127        assert!(!r.contains("mysvc"), "drops synced+healthy row: {r}");
128        assert!(r.contains("1/2 resources"), "tally: {r}");
129    }
130
131    #[test]
132    fn app_list_keeps_header_and_unhealthy() {
133        let list = "NAME    CLUSTER  NAMESPACE  PROJECT  STATUS     HEALTH       SYNCPOLICY\napp-a   in-cl    ns-a       default  Synced     Healthy      Automated\napp-b   in-cl    ns-b       default  OutOfSync  Degraded     Automated";
134        let r = compress("argocd app list", list).unwrap();
135        assert!(r.contains("app-b"), "keeps unhealthy: {r}");
136        assert!(!r.contains("app-a"), "drops healthy: {r}");
137    }
138
139    #[test]
140    fn non_app_subcommand_none() {
141        assert!(compress("argocd version", "v2.9.0").is_none());
142    }
143}