Skip to main content

lean_ctx/core/patterns/
argocd.rs

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