markdown_tables/
lib.rs

1//! A library for formatting data as markdown tables.
2//!
3//! This crate provides a simple way to format structured data as markdown tables
4//! with automatic column width calculation and special character escaping.
5//!
6//! # Example
7//!
8//! ```
9//! use markdown_tables::{MarkdownTableRow, as_table};
10//!
11//! #[derive(Debug)]
12//! struct Person {
13//!     name: String,
14//!     age: u32,
15//!     city: String,
16//! }
17//!
18//! impl MarkdownTableRow for Person {
19//!     fn column_names() -> Vec<&'static str> {
20//!         vec!["Name", "Age", "City"]
21//!     }
22//!
23//!     fn column_values(&self) -> Vec<String> {
24//!         vec![self.name.clone(), self.age.to_string(), self.city.clone()]
25//!     }
26//! }
27//!
28//! let people = vec![
29//!     Person {
30//!         name: "Alice".to_string(),
31//!         age: 30,
32//!         city: "New York".to_string(),
33//!     },
34//!     Person {
35//!         name: "Bob".to_string(),
36//!         age: 25,
37//!         city: "Los Angeles".to_string(),
38//!     },
39//! ];
40//!
41//! let table = as_table(&people);
42//! assert_eq!(table, "| Name  | Age | City        |\n|-------|-----|-------------|\n| Alice | 30  | New York    |\n| Bob   | 25  | Los Angeles |\n");
43//! ```
44//!
45//! # Features
46//!
47//! - Automatic column width calculation based on content
48//! - Pipe character (`|`) escaping for data containing pipes
49//! - Support for Unicode characters (though alignment may not be perfect for wide characters)
50//! - Empty table handling (returns empty string)
51
52/// A trait for types that can be formatted as a row in a markdown table.
53///
54/// Implement this trait to enable formatting your custom types as markdown table rows.
55///
56/// # Example
57///
58/// ```
59/// use markdown_tables::MarkdownTableRow;
60///
61/// struct Product {
62///     id: u32,
63///     name: String,
64///     price: f64,
65/// }
66///
67/// impl MarkdownTableRow for Product {
68///     fn column_names() -> Vec<&'static str> {
69///         vec!["ID", "Product", "Price"]
70///     }
71///
72///     fn column_values(&self) -> Vec<String> {
73///         vec![
74///             self.id.to_string(),
75///             self.name.clone(),
76///             format!("${:.2}", self.price),
77///         ]
78///     }
79/// }
80/// ```
81pub trait MarkdownTableRow {
82    /// The names of the columns in the table (used for the header row)
83    fn column_names() -> Vec<&'static str>;
84    /// The values of the columns in the table
85    fn column_values(&self) -> Vec<String>;
86}
87
88/// Formats the input as a markdown-style table.
89///
90/// Takes a slice of items that implement [`MarkdownTableRow`] and returns a formatted
91/// markdown table as a string. If the input slice is empty, returns an empty string.
92///
93/// # Column Width
94///
95/// Column widths are automatically calculated based on the maximum width of content
96/// in each column, including the header.
97///
98/// # Special Characters
99///
100/// Pipe characters (`|`) in data are automatically escaped with a backslash (`\|`)
101/// to prevent breaking the table format.
102///
103/// # Example
104///
105/// ```
106/// use markdown_tables::{MarkdownTableRow, as_table};
107///
108/// struct Task {
109///     id: u32,
110///     description: String,
111///     completed: bool,
112/// }
113///
114/// impl MarkdownTableRow for Task {
115///     fn column_names() -> Vec<&'static str> {
116///         vec!["ID", "Description", "Status"]
117///     }
118///
119///     fn column_values(&self) -> Vec<String> {
120///         vec![
121///             self.id.to_string(),
122///             self.description.clone(),
123///             if self.completed { "✓" } else { "✗" }.to_string(),
124///         ]
125///     }
126/// }
127///
128/// let tasks = vec![
129///     Task { id: 1, description: "Write documentation".to_string(), completed: true },
130///     Task { id: 2, description: "Add tests".to_string(), completed: true },
131///     Task { id: 3, description: "Review PR | Deploy".to_string(), completed: false },
132/// ];
133///
134/// let table = as_table(&tasks);
135/// println!("{}", table);
136/// // Output:
137/// // | ID | Description         | Status |
138/// // |----|---------------------|--------|
139/// // | 1  | Write documentation | ✓      |
140/// // | 2  | Add tests           | ✓      |
141/// // | 3  | Review PR \| Deploy | ✗      |
142/// ```
143pub fn as_table<T: MarkdownTableRow>(table: &[T]) -> String {
144    if table.is_empty() {
145        return String::new();
146    }
147
148    let column_names = T::column_names();
149    let mut max_widths: Vec<usize> = column_names.iter().map(|name| name.len()).collect();
150
151    for row in table {
152        let values = row
153            .column_values()
154            .into_iter()
155            .map(|v| v.replace('|', "\\|"))
156            .collect::<Vec<String>>();
157        for (i, value) in values.iter().enumerate() {
158            let width = value.chars().count(); // TODO: would be better if this was unicode-aware (otherwise emojis mess it up)
159            max_widths[i] = max_widths[i].max(width);
160        }
161    }
162
163    let mut result = String::new();
164
165    // Header row
166    result.push('|');
167    for (i, name) in column_names.iter().enumerate() {
168        result.push_str(&format!(" {:<width$} |", name, width = max_widths[i]));
169    }
170    result.push('\n');
171
172    // Separator row
173    result.push('|');
174    for width in &max_widths {
175        result.push_str(&format!("{:-<width$}|", "", width = width + 2));
176    }
177    result.push('\n');
178
179    // Data rows
180    for row in table {
181        result.push('|');
182        let values = row.column_values();
183        for (i, value) in values.iter().enumerate() {
184            result.push_str(&format!(" {:<width$} |", value, width = max_widths[i]));
185        }
186        result.push('\n');
187    }
188
189    result
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[derive(Debug)]
197    struct Person {
198        name: String,
199        age: u32,
200        city: String,
201    }
202
203    impl MarkdownTableRow for Person {
204        fn column_names() -> Vec<&'static str> {
205            vec!["Name", "Age", "City"]
206        }
207
208        fn column_values(&self) -> Vec<String> {
209            vec![self.name.clone(), self.age.to_string(), self.city.clone()]
210        }
211    }
212
213    #[derive(Debug)]
214    struct Product {
215        id: u32,
216        name: String,
217        price: f64,
218        in_stock: bool,
219    }
220
221    impl MarkdownTableRow for Product {
222        fn column_names() -> Vec<&'static str> {
223            vec!["ID", "Product Name", "Price", "In Stock"]
224        }
225
226        fn column_values(&self) -> Vec<String> {
227            vec![
228                self.id.to_string(),
229                self.name.clone(),
230                format!("${:.2}", self.price),
231                if self.in_stock {
232                    "Yes".to_string()
233                } else {
234                    "No".to_string()
235                },
236            ]
237        }
238    }
239
240    #[test]
241    fn test_empty_table() {
242        let people: Vec<Person> = vec![];
243        let result = as_table(&people);
244        assert_eq!(result, "");
245    }
246
247    #[test]
248    fn test_single_row() {
249        let people = vec![Person {
250            name: "Alice".to_string(),
251            age: 30,
252            city: "New York".to_string(),
253        }];
254        let result = as_table(&people);
255        let expected =
256            "| Name  | Age | City     |\n|-------|-----|----------|\n| Alice | 30  | New York |\n";
257        assert_eq!(result, expected);
258    }
259
260    #[test]
261    fn test_multiple_rows() {
262        let people = vec![
263            Person {
264                name: "Alice".to_string(),
265                age: 30,
266                city: "New York".to_string(),
267            },
268            Person {
269                name: "Bob".to_string(),
270                age: 25,
271                city: "Los Angeles".to_string(),
272            },
273            Person {
274                name: "Charlie".to_string(),
275                age: 35,
276                city: "Chicago".to_string(),
277            },
278        ];
279        let result = as_table(&people);
280        let expected = "| Name    | Age | City        |\n|---------|-----|-------------|\n| Alice   | 30  | New York    |\n| Bob     | 25  | Los Angeles |\n| Charlie | 35  | Chicago     |\n";
281        assert_eq!(result, expected);
282    }
283
284    #[test]
285    fn test_varying_column_widths() {
286        let products = vec![
287            Product {
288                id: 1,
289                name: "Laptop".to_string(),
290                price: 999.99,
291                in_stock: true,
292            },
293            Product {
294                id: 2,
295                name: "Wireless Mouse".to_string(),
296                price: 29.99,
297                in_stock: false,
298            },
299            Product {
300                id: 100,
301                name: "USB-C Hub".to_string(),
302                price: 49.99,
303                in_stock: true,
304            },
305        ];
306        let result = as_table(&products);
307        let expected = "| ID  | Product Name   | Price   | In Stock |\n|-----|----------------|---------|----------|\n| 1   | Laptop         | $999.99 | Yes      |\n| 2   | Wireless Mouse | $29.99  | No       |\n| 100 | USB-C Hub      | $49.99  | Yes      |\n";
308        assert_eq!(result, expected);
309    }
310
311    #[test]
312    fn test_pipe_character_escaping() {
313        let people = vec![
314            Person {
315                name: "Alice | Bob".to_string(),
316                age: 30,
317                city: "New York | NY".to_string(),
318            },
319            Person {
320                name: "Charlie".to_string(),
321                age: 35,
322                city: "Chicago".to_string(),
323            },
324        ];
325        let result = as_table(&people);
326        let expected = r#"| Name         | Age | City           |
327|--------------|-----|----------------|
328| Alice | Bob  | 30  | New York | NY  |
329| Charlie      | 35  | Chicago        |
330"#;
331        assert_eq!(result, expected);
332    }
333
334    #[test]
335    fn test_unicode_characters() {
336        let people = vec![
337            Person {
338                name: "José".to_string(),
339                age: 28,
340                city: "São Paulo".to_string(),
341            },
342            Person {
343                name: "李明".to_string(),
344                age: 32,
345                city: "北京".to_string(),
346            },
347            Person {
348                name: "Müller".to_string(),
349                age: 45,
350                city: "München".to_string(),
351            },
352        ];
353        let result = as_table(&people);
354        // Note: The current implementation counts chars, not display width
355        // So unicode characters might not align perfectly
356        assert!(result.contains("José"));
357        assert!(result.contains("São Paulo"));
358        assert!(result.contains("李明"));
359        assert!(result.contains("北京"));
360        assert!(result.contains("Müller"));
361        assert!(result.contains("München"));
362    }
363
364    #[test]
365    fn test_empty_values() {
366        let people = vec![
367            Person {
368                name: "".to_string(),
369                age: 30,
370                city: "New York".to_string(),
371            },
372            Person {
373                name: "Bob".to_string(),
374                age: 25,
375                city: "".to_string(),
376            },
377        ];
378        let result = as_table(&people);
379        let expected = "| Name | Age | City     |\n|------|-----|----------|\n|      | 30  | New York |\n| Bob  | 25  |          |\n";
380        assert_eq!(result, expected);
381    }
382
383    #[test]
384    fn test_single_column_table() {
385        struct SingleColumn {
386            value: String,
387        }
388
389        impl MarkdownTableRow for SingleColumn {
390            fn column_names() -> Vec<&'static str> {
391                vec!["Value"]
392            }
393
394            fn column_values(&self) -> Vec<String> {
395                vec![self.value.clone()]
396            }
397        }
398
399        let items = vec![
400            SingleColumn {
401                value: "First".to_string(),
402            },
403            SingleColumn {
404                value: "Second".to_string(),
405            },
406            SingleColumn {
407                value: "Third".to_string(),
408            },
409        ];
410        let result = as_table(&items);
411        let expected = "| Value  |\n|--------|\n| First  |\n| Second |\n| Third  |\n";
412        assert_eq!(result, expected);
413    }
414
415    #[test]
416    fn test_many_columns() {
417        struct ManyColumns {
418            a: String,
419            b: String,
420            c: String,
421            d: String,
422            e: String,
423        }
424
425        impl MarkdownTableRow for ManyColumns {
426            fn column_names() -> Vec<&'static str> {
427                vec!["A", "B", "C", "D", "E"]
428            }
429
430            fn column_values(&self) -> Vec<String> {
431                vec![
432                    self.a.clone(),
433                    self.b.clone(),
434                    self.c.clone(),
435                    self.d.clone(),
436                    self.e.clone(),
437                ]
438            }
439        }
440
441        let items = vec![ManyColumns {
442            a: "1".to_string(),
443            b: "2".to_string(),
444            c: "3".to_string(),
445            d: "4".to_string(),
446            e: "5".to_string(),
447        }];
448        let result = as_table(&items);
449        let expected = "| A | B | C | D | E |\n|---|---|---|---|---|\n| 1 | 2 | 3 | 4 | 5 |\n";
450        assert_eq!(result, expected);
451    }
452
453    #[test]
454    fn test_special_characters() {
455        let people = vec![
456            Person {
457                name: "Alice\tBob".to_string(),
458                age: 30,
459                city: "New\nYork".to_string(),
460            },
461            Person {
462                name: "Charlie\\Dave".to_string(),
463                age: 35,
464                city: "Chicago\"IL\"".to_string(),
465            },
466        ];
467        let result = as_table(&people);
468        // Tabs and newlines are preserved in the output
469        assert!(result.contains("Alice\tBob"));
470        assert!(result.contains("New\nYork"));
471        assert!(result.contains("Charlie\\Dave"));
472        assert!(result.contains("Chicago\"IL\""));
473    }
474}