Skip to main content

slack_rs/commands/conv/
format.rs

1//! Output formatting functionality for conversations
2
3use crate::api::ApiResponse;
4use std::fmt;
5
6/// Output format for conversation list
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum OutputFormat {
9    Json,
10    Jsonl,
11    Table,
12    Tsv,
13}
14
15impl OutputFormat {
16    pub fn parse(s: &str) -> Result<Self, String> {
17        match s {
18            "json" => Ok(OutputFormat::Json),
19            "jsonl" => Ok(OutputFormat::Jsonl),
20            "table" => Ok(OutputFormat::Table),
21            "tsv" => Ok(OutputFormat::Tsv),
22            _ => Err(format!(
23                "Invalid format '{}'. Valid values: json, jsonl, table, tsv",
24                s
25            )),
26        }
27    }
28}
29
30impl fmt::Display for OutputFormat {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        match self {
33            OutputFormat::Json => write!(f, "json"),
34            OutputFormat::Jsonl => write!(f, "jsonl"),
35            OutputFormat::Table => write!(f, "table"),
36            OutputFormat::Tsv => write!(f, "tsv"),
37        }
38    }
39}
40
41/// Format response for output
42pub fn format_response(response: &ApiResponse, format: OutputFormat) -> Result<String, String> {
43    match format {
44        OutputFormat::Json => serde_json::to_string_pretty(&response)
45            .map_err(|e| format!("Failed to serialize JSON: {}", e)),
46        OutputFormat::Jsonl => {
47            if let Some(channels) = response.data.get("channels") {
48                if let Some(channels_array) = channels.as_array() {
49                    let lines: Vec<String> = channels_array
50                        .iter()
51                        .filter_map(|conv| serde_json::to_string(conv).ok())
52                        .collect();
53                    Ok(lines.join("\n"))
54                } else {
55                    Ok(String::new())
56                }
57            } else {
58                Ok(String::new())
59            }
60        }
61        OutputFormat::Table => format_as_table(response),
62        OutputFormat::Tsv => format_as_tsv(response),
63    }
64}
65
66/// Format response as table
67fn format_as_table(response: &ApiResponse) -> Result<String, String> {
68    let channels = match response.data.get("channels").and_then(|v| v.as_array()) {
69        Some(ch) => ch,
70        None => return Ok(String::new()),
71    };
72
73    if channels.is_empty() {
74        return Ok(String::new());
75    }
76
77    // Calculate column widths
78    let mut max_id = "ID".len();
79    let mut max_name = "NAME".len();
80    let max_private = "PRIVATE".len();
81    let max_member = "MEMBER".len();
82    let mut max_num_members = "NUM_MEMBERS".len();
83
84    for conv in channels {
85        if let Some(id) = conv.get("id").and_then(|v| v.as_str()) {
86            max_id = max_id.max(id.len());
87        }
88        if let Some(name) = conv.get("name").and_then(|v| v.as_str()) {
89            max_name = max_name.max(name.len());
90        }
91        if let Some(num) = conv.get("num_members").and_then(|v| v.as_i64()) {
92            max_num_members = max_num_members.max(num.to_string().len());
93        }
94    }
95
96    // Build header
97    let mut output = String::new();
98    output.push_str(&format!(
99        "{:width_id$}  {:width_name$}  {:width_private$}  {:width_member$}  {:width_num$}\n",
100        "ID",
101        "NAME",
102        "PRIVATE",
103        "MEMBER",
104        "NUM_MEMBERS",
105        width_id = max_id,
106        width_name = max_name,
107        width_private = max_private,
108        width_member = max_member,
109        width_num = max_num_members,
110    ));
111
112    // Build separator
113    output.push_str(&format!(
114        "{}  {}  {}  {}  {}\n",
115        "-".repeat(max_id),
116        "-".repeat(max_name),
117        "-".repeat(max_private),
118        "-".repeat(max_member),
119        "-".repeat(max_num_members),
120    ));
121
122    // Build rows
123    for conv in channels {
124        let id = conv.get("id").and_then(|v| v.as_str()).unwrap_or("");
125        let name = conv.get("name").and_then(|v| v.as_str()).unwrap_or("");
126        let is_private = conv
127            .get("is_private")
128            .and_then(|v| v.as_bool())
129            .unwrap_or(false);
130        let is_member = conv
131            .get("is_member")
132            .and_then(|v| v.as_bool())
133            .unwrap_or(false);
134        let num_members = conv.get("num_members").and_then(|v| v.as_i64());
135
136        let num_members_str = num_members.map(|n| n.to_string()).unwrap_or_default();
137
138        output.push_str(&format!(
139            "{:width_id$}  {:width_name$}  {:width_private$}  {:width_member$}  {:width_num$}\n",
140            id,
141            name,
142            is_private,
143            is_member,
144            num_members_str,
145            width_id = max_id,
146            width_name = max_name,
147            width_private = max_private,
148            width_member = max_member,
149            width_num = max_num_members,
150        ));
151    }
152
153    Ok(output)
154}
155
156/// Format response as TSV
157fn format_as_tsv(response: &ApiResponse) -> Result<String, String> {
158    let channels = match response.data.get("channels").and_then(|v| v.as_array()) {
159        Some(ch) => ch,
160        None => return Ok(String::new()),
161    };
162
163    if channels.is_empty() {
164        return Ok(String::new());
165    }
166
167    let mut output = String::new();
168
169    // Header
170    output.push_str("id\tname\tis_private\tis_member\tnum_members\n");
171
172    // Rows
173    for conv in channels {
174        let id = conv.get("id").and_then(|v| v.as_str()).unwrap_or("");
175        let name = conv.get("name").and_then(|v| v.as_str()).unwrap_or("");
176        let is_private = conv
177            .get("is_private")
178            .and_then(|v| v.as_bool())
179            .unwrap_or(false);
180        let is_member = conv
181            .get("is_member")
182            .and_then(|v| v.as_bool())
183            .unwrap_or(false);
184        let num_members = conv.get("num_members").and_then(|v| v.as_i64());
185
186        let num_members_str = num_members.map(|n| n.to_string()).unwrap_or_default();
187
188        output.push_str(&format!(
189            "{}\t{}\t{}\t{}\t{}\n",
190            id, name, is_private, is_member, num_members_str
191        ));
192    }
193
194    Ok(output)
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use serde_json::json;
201    use std::collections::HashMap;
202
203    #[test]
204    fn test_output_format_parse() {
205        assert_eq!(OutputFormat::parse("json").unwrap(), OutputFormat::Json);
206        assert_eq!(OutputFormat::parse("jsonl").unwrap(), OutputFormat::Jsonl);
207        assert_eq!(OutputFormat::parse("table").unwrap(), OutputFormat::Table);
208        assert_eq!(OutputFormat::parse("tsv").unwrap(), OutputFormat::Tsv);
209        assert!(OutputFormat::parse("invalid").is_err());
210    }
211
212    #[test]
213    fn test_format_response_jsonl() {
214        let response = ApiResponse {
215            ok: true,
216            data: HashMap::from([(
217                "channels".to_string(),
218                json!([
219                    {"id": "C1", "name": "general"},
220                    {"id": "C2", "name": "random"},
221                ]),
222            )]),
223            error: None,
224        };
225
226        let output = format_response(&response, OutputFormat::Jsonl).unwrap();
227        let lines: Vec<&str> = output.lines().collect();
228        assert_eq!(lines.len(), 2);
229        assert!(lines[0].contains("\"id\":\"C1\""));
230        assert!(lines[1].contains("\"id\":\"C2\""));
231    }
232
233    #[test]
234    fn test_format_response_tsv() {
235        let response = ApiResponse {
236            ok: true,
237            data: HashMap::from([(
238                "channels".to_string(),
239                json!([
240                    {"id": "C1", "name": "general", "is_private": false, "is_member": true, "num_members": 42},
241                    {"id": "C2", "name": "private", "is_private": true, "is_member": false},
242                ]),
243            )]),
244            error: None,
245        };
246
247        let output = format_response(&response, OutputFormat::Tsv).unwrap();
248        let lines: Vec<&str> = output.lines().collect();
249        assert_eq!(lines.len(), 3); // header + 2 rows
250        assert_eq!(lines[0], "id\tname\tis_private\tis_member\tnum_members");
251        assert_eq!(lines[1], "C1\tgeneral\tfalse\ttrue\t42");
252        assert_eq!(lines[2], "C2\tprivate\ttrue\tfalse\t"); // num_members missing -> empty
253    }
254
255    #[test]
256    fn test_format_response_table() {
257        let response = ApiResponse {
258            ok: true,
259            data: HashMap::from([(
260                "channels".to_string(),
261                json!([
262                    {"id": "C1", "name": "general", "is_private": false, "is_member": true, "num_members": 42},
263                ]),
264            )]),
265            error: None,
266        };
267
268        let output = format_response(&response, OutputFormat::Table).unwrap();
269        assert!(output.contains("ID"));
270        assert!(output.contains("NAME"));
271        assert!(output.contains("PRIVATE"));
272        assert!(output.contains("MEMBER"));
273        assert!(output.contains("NUM_MEMBERS"));
274        assert!(output.contains("C1"));
275        assert!(output.contains("general"));
276        assert!(output.contains("42"));
277    }
278}