git-ar 1.1.12

Git all remotes. Git cli tool that targets both Github and Gitlab. Brings common development operations such as opening a pull request down to the shell. This is an alternative to both Github https://github.com/cli/cli and Gitlab https://gitlab.com/gitlab-org/cli cli tools.
Documentation
use crate::remote::GetRemoteCliArgs;
use crate::Result;
use std::{collections::HashMap, io::Write};

#[derive(Clone, Debug, Default)]
pub enum Format {
    CSV,
    JSON,
    #[default]
    PIPE,
    TOML,
}

impl From<Format> for u8 {
    fn from(f: Format) -> Self {
        match f {
            Format::CSV => b',',
            Format::PIPE => b'|',
            Format::JSON => 0,
            Format::TOML => 0,
        }
    }
}

pub struct DisplayBody {
    pub columns: Vec<Column>,
}

impl DisplayBody {
    pub fn new(columns: Vec<Column>) -> Self {
        Self { columns }
    }
}

#[derive(Builder)]
pub struct Column {
    pub name: String,
    pub value: String,
    #[builder(default)]
    pub optional: bool,
}

impl Column {
    pub fn builder() -> ColumnBuilder {
        ColumnBuilder::default()
    }
    pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            value: value.into(),
            optional: false,
        }
    }
}

// TODO: Change args to borrow GetRemoteCliArgs
pub fn print<W: Write, D: Into<DisplayBody> + Clone>(
    w: &mut W,
    data: Vec<D>,
    args: GetRemoteCliArgs,
) -> Result<()> {
    if data.is_empty() {
        return Ok(());
    }
    match args.format {
        Format::JSON => {
            for d in data {
                let d = d.into();
                let kvs: HashMap<String, String> = d
                    .columns
                    .into_iter()
                    .filter(|c| !c.optional || args.display_optional)
                    .map(|item| (item.name.to_lowercase(), item.value))
                    .collect();
                writeln!(w, "{}", serde_json::to_string(&kvs)?)?;
            }
        }
        Format::TOML => {
            writeln!(w, "[")?;
            let data_len = data.len();
            for (index, d) in data.into_iter().enumerate() {
                let d = d.into();
                write!(w, "    {{")?;
                let mut first = true;
                for column in d.columns {
                    if !column.optional || args.display_optional {
                        if !first {
                            write!(w, ",")?;
                        }
                        write!(w, " {} = {:?}", column.name.to_lowercase(), column.value)?;
                        first = false;
                    }
                }
                write!(w, " }}")?;
                if index < data_len - 1 {
                    writeln!(w, ",")?;
                } else {
                    writeln!(w)?;
                }
            }
            writeln!(w, "]")?;
        }
        _ => {
            let mut wtr = csv::WriterBuilder::new()
                .delimiter(args.format.into())
                .from_writer(w);
            if !args.no_headers {
                // Get the headers from the first row of columns
                let headers = data[0]
                    .clone()
                    .into()
                    .columns
                    .iter()
                    .filter(|c| !c.optional || args.display_optional)
                    .map(|c| c.name.clone())
                    .collect::<Vec<_>>();
                wtr.write_record(&headers)?;
            }
            for d in data {
                let d = d.into();
                let row = d
                    .columns
                    .into_iter()
                    .filter(|c| !c.optional || args.display_optional)
                    .map(|c| c.value)
                    .collect::<Vec<_>>();
                wtr.write_record(&row)?;
            }
            wtr.flush()?;
        }
    }
    Ok(())
}

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

    #[derive(Clone)]
    struct Book {
        pub title: String,
        pub author: String,
    }

    impl Book {
        pub fn new(title: impl Into<String>, author: impl Into<String>) -> Self {
            Self {
                title: title.into(),
                author: author.into(),
            }
        }
    }

    impl From<Book> for DisplayBody {
        fn from(b: Book) -> Self {
            DisplayBody::new(vec![
                Column::new("title", b.title),
                Column::new("author", b.author),
            ])
        }
    }

    #[test]
    fn test_json() {
        let mut w = Vec::new();
        let books = vec![
            Book::new("The Catcher in the Rye", "J.D. Salinger"),
            Book::new("The Adventures of Huckleberry Finn", "Mark Twain"),
        ];
        let args = GetRemoteCliArgs::builder()
            .no_headers(true)
            .format(Format::JSON)
            .build()
            .unwrap();
        print(&mut w, books, args).unwrap();
        let s = String::from_utf8(w).unwrap();
        assert_eq!(2, s.lines().count());
        for line in s.lines() {
            let v: serde_json::Value = serde_json::from_str(line).unwrap();
            assert!(v.is_object());
            let obj = v.as_object().unwrap();
            assert_eq!(obj.len(), 2);
            assert!(obj.contains_key("title"));
            assert!(obj.contains_key("author"));
        }
    }

    #[test]
    fn test_csv_multiple_commas_one_field() {
        let mut w = Vec::new();
        let books = vec![
            Book::new("Faust, Part One", "Goethe"),
            Book::new("The Adventures of Huckleberry Finn", "Mark Twain"),
        ];
        let args = GetRemoteCliArgs::builder()
            .no_headers(true)
            .format(Format::CSV)
            .build()
            .unwrap();
        print(&mut w, books, args).unwrap();
        let mut reader = csv::ReaderBuilder::new()
            .has_headers(false)
            .from_reader(w.as_slice());
        assert_eq!(
            "Faust, Part One",
            &reader.records().next().unwrap().unwrap()[0]
        );
    }

    #[derive(Clone)]
    struct BookOptionalColumns {
        pub title: String,
        pub author: String,
        pub isbn: String,
    }

    impl BookOptionalColumns {
        pub fn new(
            title: impl Into<String>,
            author: impl Into<String>,
            isbn: impl Into<String>,
        ) -> Self {
            Self {
                title: title.into(),
                author: author.into(),
                isbn: isbn.into(),
            }
        }
    }

    impl From<BookOptionalColumns> for DisplayBody {
        fn from(b: BookOptionalColumns) -> Self {
            DisplayBody::new(vec![
                Column::new("title", b.title),
                Column::new("author", b.author),
                Column::builder()
                    .name("isbn".to_string())
                    .value(b.isbn)
                    .optional(true)
                    .build()
                    .unwrap(),
            ])
        }
    }

    #[test]
    fn test_csv_optional_columns() {
        let mut w = Vec::new();
        let books = vec![
            BookOptionalColumns::new("The Catcher in the Rye", "J.D. Salinger", "0316769487"),
            BookOptionalColumns::new(
                "The Adventures of Huckleberry Finn",
                "Mark Twain",
                "9780199536559",
            ),
        ];
        let args = GetRemoteCliArgs::builder()
            .format(Format::CSV)
            .build()
            .unwrap();
        print(&mut w, books, args).unwrap();
        assert_eq!(
            "title,author\nThe Catcher in the Rye,J.D. Salinger\nThe Adventures of Huckleberry Finn,Mark Twain\n",
            String::from_utf8(w).unwrap()
        );
    }

    #[test]
    fn test_csv_display_optional_columns_on_args() {
        let mut w = Vec::new();
        let books = vec![
            BookOptionalColumns::new("The Catcher in the Rye", "J.D. Salinger", "0316769487"),
            BookOptionalColumns::new(
                "The Adventures of Huckleberry Finn",
                "Mark Twain",
                "9780199536559",
            ),
        ];
        let args = GetRemoteCliArgs::builder()
            .format(Format::CSV)
            .display_optional(true)
            .build()
            .unwrap();
        print(&mut w, books, args).unwrap();
        assert_eq!(
            "title,author,isbn\nThe Catcher in the Rye,J.D. Salinger,0316769487\nThe Adventures of Huckleberry Finn,Mark Twain,9780199536559\n",
            String::from_utf8(w).unwrap()
        );
    }

    #[test]
    fn test_toml_single_row() {
        let mut w = Vec::new();
        let books = vec![Book::new("The Catcher in the Rye", "J.D. Salinger")];
        let args = GetRemoteCliArgs::builder()
            .format(Format::TOML)
            .build()
            .unwrap();
        print(&mut w, books, args).unwrap();
        let s = String::from_utf8(w).unwrap();
        assert_eq!(
            s,
            "[\n    { title = \"The Catcher in the Rye\", author = \"J.D. Salinger\" }\n]\n"
        );
    }

    #[test]
    fn test_toml_multiple_rows() {
        let mut w = Vec::new();
        let books = vec![
            Book::new("The Catcher in the Rye", "J.D. Salinger"),
            Book::new("The Adventures of Huckleberry Finn", "Mark Twain"),
        ];
        let args = GetRemoteCliArgs::builder()
            .format(Format::TOML)
            .build()
            .unwrap();
        print(&mut w, books, args).unwrap();
        let s = String::from_utf8(w).unwrap();
        assert_eq!(s, "[\n    { title = \"The Catcher in the Rye\", author = \"J.D. Salinger\" },\n    { title = \"The Adventures of Huckleberry Finn\", author = \"Mark Twain\" }\n]\n");
    }

    #[test]
    fn test_toml_optional_columns() {
        let mut w = Vec::new();
        let books = vec![
            BookOptionalColumns::new("The Catcher in the Rye", "J.D. Salinger", "0316769487"),
            BookOptionalColumns::new(
                "The Adventures of Huckleberry Finn",
                "Mark Twain",
                "9780199536559",
            ),
        ];
        let args = GetRemoteCliArgs::builder()
            .format(Format::TOML)
            .build()
            .unwrap();
        print(&mut w, books, args).unwrap();
        let s = String::from_utf8(w).unwrap();
        assert_eq!(s, "[\n    { title = \"The Catcher in the Rye\", author = \"J.D. Salinger\" },\n    { title = \"The Adventures of Huckleberry Finn\", author = \"Mark Twain\" }\n]\n");
    }
}