Skip to main content

clickup_cli/
output.rs

1use chrono::DateTime;
2use comfy_table::{ContentArrangement, Table};
3
4pub struct OutputConfig {
5    pub mode: String,
6    pub fields: Option<Vec<String>>,
7    pub no_header: bool,
8    pub quiet: bool,
9}
10
11impl OutputConfig {
12    pub fn from_cli(mode: &str, fields: &Option<String>, no_header: bool, quiet: bool) -> Self {
13        Self {
14            mode: mode.to_string(),
15            fields: fields.as_ref().map(|f| f.split(',').map(|s| s.trim().to_string()).collect()),
16            no_header,
17            quiet,
18        }
19    }
20
21    pub fn print_items(&self, items: &[serde_json::Value], default_fields: &[&str], id_field: &str) {
22        if self.quiet {
23            for item in items {
24                if let Some(id) = item.get(id_field).and_then(|v| v.as_str()) {
25                    println!("{}", id);
26                }
27            }
28            return;
29        }
30
31        let fields: Vec<&str> = match &self.fields {
32            Some(f) => f.iter().map(|s| s.as_str()).collect(),
33            None => default_fields.to_vec(),
34        };
35
36        match self.mode.as_str() {
37            "json" => {
38                println!("{}", serde_json::to_string_pretty(items).unwrap());
39            }
40            "json-compact" => {
41                let filtered = compact_items(items, &fields);
42                println!("{}", serde_json::to_string_pretty(&filtered).unwrap());
43            }
44            "csv" => {
45                if !self.no_header {
46                    println!("{}", fields.join(","));
47                }
48                for item in items {
49                    let row: Vec<String> = fields
50                        .iter()
51                        .map(|&f| flatten_value(item.get(f)))
52                        .collect();
53                    println!("{}", row.join(","));
54                }
55            }
56            _ => {
57                // table (default)
58                let mut table = Table::new();
59                table.set_content_arrangement(ContentArrangement::Dynamic);
60                if !self.no_header {
61                    table.set_header(fields.iter().map(|f| f.to_string()).collect::<Vec<_>>());
62                }
63                for item in items {
64                    let row: Vec<String> = fields
65                        .iter()
66                        .map(|&f| flatten_value(item.get(f)))
67                        .collect();
68                    table.add_row(row);
69                }
70                println!("{}", table);
71            }
72        }
73    }
74
75    pub fn print_single(&self, item: &serde_json::Value, default_fields: &[&str], id_field: &str) {
76        self.print_items(&[item.clone()], default_fields, id_field);
77    }
78
79    pub fn print_message(&self, message: &str) {
80        if self.mode == "json" {
81            println!("{}", serde_json::json!({ "message": message }));
82        } else {
83            println!("{}", message);
84        }
85    }
86}
87
88/// Flatten a list of items to only include the specified fields with flattened values.
89/// Returns a JSON array. Used by MCP server for token-efficient responses.
90pub fn compact_items(items: &[serde_json::Value], fields: &[&str]) -> serde_json::Value {
91    let compacted: Vec<serde_json::Value> = items
92        .iter()
93        .map(|item| {
94            let mut obj = serde_json::Map::new();
95            for &field in fields {
96                let val = flatten_value(item.get(field));
97                obj.insert(field.to_string(), serde_json::Value::String(val));
98            }
99            serde_json::Value::Object(obj)
100        })
101        .collect();
102    serde_json::Value::Array(compacted)
103}
104
105pub fn flatten_value(value: Option<&serde_json::Value>) -> String {
106    match value {
107        None | Some(serde_json::Value::Null) => "-".to_string(),
108        Some(serde_json::Value::String(s)) => {
109            // Try to parse as Unix millisecond timestamp
110            if let Ok(ms) = s.parse::<i64>() {
111                if ms > 1_000_000_000_000 && ms < 10_000_000_000_000 {
112                    if let Some(dt) = DateTime::from_timestamp_millis(ms) {
113                        return dt.format("%Y-%m-%d").to_string();
114                    }
115                }
116            }
117            s.clone()
118        }
119        Some(serde_json::Value::Number(n)) => n.to_string(),
120        Some(serde_json::Value::Bool(b)) => b.to_string(),
121        Some(serde_json::Value::Array(arr)) => {
122            // For arrays of objects with "username" field (assignees)
123            let items: Vec<String> = arr
124                .iter()
125                .map(|v| {
126                    if let Some(username) = v.get("username").and_then(|u| u.as_str()) {
127                        username.to_string()
128                    } else if let Some(s) = v.as_str() {
129                        s.to_string()
130                    } else {
131                        v.to_string()
132                    }
133                })
134                .collect();
135            if items.is_empty() { "-".to_string() } else { items.join(", ") }
136        }
137        Some(serde_json::Value::Object(obj)) => {
138            // Flatten nested objects: status.status, priority.priority
139            if let Some(inner) = obj.get("status").and_then(|v| v.as_str()) {
140                inner.to_string()
141            } else if let Some(inner) = obj.get("priority").and_then(|v| v.as_str()) {
142                inner.to_string()
143            } else if let Some(name) = obj.get("name").and_then(|v| v.as_str()) {
144                name.to_string()
145            } else if let Some(username) = obj.get("username").and_then(|v| v.as_str()) {
146                username.to_string()
147            } else {
148                serde_json::to_string(&serde_json::Value::Object(obj.clone())).unwrap()
149            }
150        }
151    }
152}