runtime-cli 0.1.0

Command-line client for managing git projects on Runtime
Documentation
//! Output formatting helpers. Three modes:
//! - `Human` — width-padded text columns / labeled rows.
//! - `Tsv` — tab-separated, ready for `awk`/`cut`.
//! - `Json` — raw GraphQL data slice.

use std::fmt::Write as _;

use clap::ValueEnum;
use serde_json::Value;

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
pub enum Format {
    #[default]
    Human,
    Tsv,
    Json,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FormatChoice {
    pub json: bool,
    pub tsv: bool,
}

impl FormatChoice {
    pub fn resolve(self) -> Format {
        if self.json {
            Format::Json
        } else if self.tsv {
            Format::Tsv
        } else {
            Format::Human
        }
    }
}

/// Render a table of rows. `headers.len()` must equal each row's
/// length. Empty input is a no-op.
pub fn render_table(headers: &[&str], rows: &[Vec<String>], fmt: Format) -> String {
    if rows.is_empty() && matches!(fmt, Format::Human) {
        return String::from("(no rows)\n");
    }
    match fmt {
        Format::Tsv => {
            rows.iter()
                .map(|r| r.join("\t"))
                .collect::<Vec<_>>()
                .join("\n")
                .trim_end()
                .to_string()
                + "\n"
        }
        Format::Json => {
            let arr: Vec<Value> = rows
                .iter()
                .map(|r| {
                    let mut m = serde_json::Map::new();
                    for (i, h) in headers.iter().enumerate() {
                        m.insert((*h).to_string(), Value::String(r[i].clone()));
                    }
                    Value::Object(m)
                })
                .collect();
            serde_json::to_string_pretty(&arr).unwrap_or_default() + "\n"
        }
        Format::Human => {
            let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
            for row in rows {
                for (i, cell) in row.iter().enumerate() {
                    if cell.len() > widths[i] {
                        widths[i] = cell.len();
                    }
                }
            }
            let mut out = String::new();
            for (i, h) in headers.iter().enumerate() {
                let _ = write!(out, "{:<width$}", h, width = widths[i]);
                if i + 1 < headers.len() {
                    out.push_str("  ");
                }
            }
            out.push('\n');
            for row in rows {
                for (i, cell) in row.iter().enumerate() {
                    let _ = write!(out, "{:<width$}", cell, width = widths[i]);
                    if i + 1 < row.len() {
                        out.push_str("  ");
                    }
                }
                out.push('\n');
            }
            out
        }
    }
}

/// Render a single record as labeled rows.
pub fn render_record(pairs: &[(&str, String)], fmt: Format) -> String {
    match fmt {
        Format::Json => {
            let mut m = serde_json::Map::new();
            for (k, v) in pairs {
                m.insert((*k).to_string(), Value::String(v.clone()));
            }
            serde_json::to_string_pretty(&Value::Object(m)).unwrap_or_default() + "\n"
        }
        Format::Tsv => {
            pairs
                .iter()
                .map(|(_, v)| v.as_str())
                .collect::<Vec<_>>()
                .join("\t")
                + "\n"
        }
        Format::Human => {
            let key_w = pairs.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
            let mut out = String::new();
            for (k, v) in pairs {
                let _ = writeln!(out, "{:<key_w$}  {}", k, v, key_w = key_w);
            }
            out
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn human_table_pads_columns() {
        let s = render_table(
            &["a", "bbb"],
            &[
                vec!["1".into(), "two".into()],
                vec!["12".into(), "x".into()],
            ],
            Format::Human,
        );
        let lines: Vec<&str> = s.lines().collect();
        assert_eq!(lines[0], "a   bbb");
        assert_eq!(lines[1], "1   two");
        assert_eq!(lines[2], "12  x  ");
    }

    #[test]
    fn tsv_table_uses_tabs() {
        let s = render_table(&["a", "b"], &[vec!["1".into(), "2".into()]], Format::Tsv);
        assert_eq!(s, "1\t2\n");
    }

    #[test]
    fn json_table_is_array_of_objects() {
        let s = render_table(&["a", "b"], &[vec!["1".into(), "2".into()]], Format::Json);
        let v: Value = serde_json::from_str(s.trim()).unwrap();
        assert_eq!(v[0]["a"], "1");
        assert_eq!(v[0]["b"], "2");
    }

    #[test]
    fn empty_human_table_is_marker() {
        let s = render_table(&["a"], &[], Format::Human);
        assert_eq!(s, "(no rows)\n");
    }

    #[test]
    fn format_choice_priority() {
        assert_eq!(
            FormatChoice {
                json: true,
                tsv: true
            }
            .resolve(),
            Format::Json
        );
        assert_eq!(
            FormatChoice {
                json: false,
                tsv: true
            }
            .resolve(),
            Format::Tsv
        );
        assert_eq!(FormatChoice::default().resolve(), Format::Human);
    }
}