acro/
lib.rs

1use clap::{Arg, ArgAction, Command};
2use colored::Colorize;
3use std::{
4    env,
5    error::Error,
6    fs::File,
7    io::{self, BufRead, BufReader},
8};
9
10#[derive(Debug)]
11pub struct Config {
12    acronym: String,
13    file: String,
14    acro_column: usize,
15    definition_column: usize,
16    color: bool,
17    header: bool,
18    delimiter: char,
19}
20
21pub fn get_args() -> Result<Config, Box<dyn Error>> {
22    let matches = Command::new("Acro")
23        .author("Nil Ventosa")
24        .version("0.2.0")
25        .about("Helps query csv files of acronyms")
26        .arg(
27            Arg::new("acronym")
28                .help("the acronym to query")
29                .required(true),
30        )
31        .arg(
32            Arg::new("file")
33                .short('f')
34                .long("file")
35                .help("the csv file with the acronyms and definitions, can be set with env variable ACRO_FILE"),
36        )
37        .arg(
38            Arg::new("acro_column")
39                .short('a')
40                .long("acro")
41                .help("the column with the acronyms, can be set with env variable ACRO_COLUMN, defaults to 1")
42                .value_parser(clap::value_parser!(usize)),
43        )
44        .arg(
45            Arg::new("definition_column")
46                .short('d')
47                .long("definition")
48                .help("the column with the definitions, can be set with env variable DEFINITION_COLUMN, defaults to 2")
49                .value_parser(clap::value_parser!(usize)),
50        )
51        .arg(
52            Arg::new("delimiter")
53                .short('D')
54                .long("delimiter")
55                .help("delimiter character between columns. Defaults to ','")
56                .value_parser(clap::value_parser!(char)),
57        )
58        .arg(
59            Arg::new("header")
60                .short('H')
61                .long("header")
62                .action(ArgAction::SetTrue)
63                .help("flag if there is a header line"),
64            )
65        .arg(
66            Arg::new("color")
67                .short('c')
68                .long("color")
69                .action(ArgAction::SetTrue)
70                .help("disables color output"),
71            )
72        .get_matches();
73
74    let mut file: String = "".to_string();
75    if let Some(f) = matches.get_one::<String>("file") {
76        file = f.to_string();
77    } else if let Ok(f) = env::var("ACRO_FILE") {
78        file = f;
79    } else {
80        eprintln!("File should be specified in argument -f or in env variable ACRO_FILE");
81    }
82
83    let mut acro_column: usize = 0;
84    if let Some(a) = matches.get_one::<usize>("acro_column") {
85        acro_column = a.to_owned() - 1;
86    } else if let Ok(a) = env::var("ACRO_COLUMN") {
87        if let Ok(a) = a.parse::<usize>() {
88            acro_column = a - 1;
89        }
90    }
91
92    let mut definition_column: usize = 1;
93    if let Some(d) = matches.get_one::<usize>("definition_column") {
94        definition_column = d.to_owned() - 1;
95    } else if let Ok(d) = env::var("DEFINITION_COLUMN") {
96        if let Ok(d) = d.parse::<usize>() {
97            definition_column = d - 1;
98        }
99    }
100
101    let mut delimiter: char = ',';
102    if let Some(d) = matches.get_one::<char>("delimiter") {
103        delimiter = d.to_owned();
104    } else if let Ok(d) = env::var("ACRO_DELIMITER") {
105        if let Ok(d) = d.parse::<char>() {
106            delimiter = d;
107        }
108    }
109
110    let header: bool = if matches.get_flag("header") {
111        true
112    } else {
113        env::var("ACRO_HEADER").is_ok()
114    };
115
116    let color: bool = if matches.get_flag("color") {
117        false
118    } else {
119        !env::var("ACRO_COLOR").is_ok()
120    };
121
122    Ok(Config {
123        acronym: matches.get_one::<String>("acronym").unwrap().to_string(),
124        file,
125        acro_column,
126        definition_column,
127        color,
128        header,
129        delimiter,
130    })
131}
132
133pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
134    let all_entries = get_entries_from_file(&config);
135    let matching_entries = find_matching_entries(&all_entries, &config.acronym);
136
137    for entry in matching_entries {
138        entry.print(&config.color);
139    }
140
141    Ok(())
142}
143
144#[derive(Debug)]
145struct Entry {
146    acronym: String,
147    definition: String,
148}
149
150impl Entry {
151    fn print(&self, color: &bool) {
152        if *color {
153            println!(" {}: {}", self.acronym.bold().blue(), self.definition);
154        } else {
155            println!(" {}: {}", self.acronym, self.definition);
156        }
157    }
158}
159
160fn find_matching_entries(entries: &Vec<Entry>, acro: &str) -> Vec<Entry> {
161    let matching = find_exact_match(entries, acro);
162
163    if matching.is_empty() {
164        return find_partial_match(entries, acro);
165    }
166    matching
167}
168
169fn find_exact_match(entries: &Vec<Entry>, acro: &str) -> Vec<Entry> {
170    let mut matching = Vec::new();
171
172    for entry in entries {
173        if entry.acronym.to_uppercase() == acro.to_uppercase() {
174            matching.push(Entry {
175                acronym: entry.acronym.clone(),
176                definition: entry.definition.clone(),
177            });
178        }
179    }
180
181    matching
182}
183
184fn find_partial_match(entries: &Vec<Entry>, acro: &str) -> Vec<Entry> {
185    let mut matching = Vec::new();
186
187    for entry in entries {
188        if entry.acronym.to_uppercase().contains(&acro.to_uppercase()) {
189            matching.push(Entry {
190                acronym: entry.acronym.clone(),
191                definition: entry.definition.clone(),
192            });
193        }
194    }
195    matching
196}
197
198fn get_entries_from_file(config: &Config) -> Vec<Entry> {
199    let mut entries = Vec::new();
200
201    match open(&config.file) {
202        Err(err) => eprintln!("Failed to open {}: {}", config.file, err),
203        Ok(file) => {
204            let mut reader = csv::ReaderBuilder::new()
205                .has_headers(config.header)
206                .delimiter(config.delimiter as u8)
207                .from_reader(file);
208
209            for record in reader.records().flatten() {
210                if let (Some(acronym), Some(definition)) = (
211                    record.get(config.acro_column),
212                    record.get(config.definition_column),
213                ) {
214                    entries.push(Entry {
215                        acronym: acronym.to_string(),
216                        definition: definition.to_string(),
217                    });
218                }
219            }
220        }
221    }
222    entries
223}
224
225fn open(filename: &str) -> Result<Box<dyn BufRead>, Box<dyn Error>> {
226    match filename {
227        "-" => Ok(Box::new(BufReader::new(io::stdin()))),
228        _ => Ok(Box::new(BufReader::new(File::open(filename)?))),
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    fn get_test_entry_vec() -> Vec<Entry> {
237        Vec::from([
238            Entry {
239                acronym: String::from("NATO"),
240                definition: String::from("N A T O"),
241            },
242            Entry {
243                acronym: String::from("USA"),
244                definition: String::from("U S A"),
245            },
246        ])
247    }
248
249    #[test]
250    fn test_find_exact_match_found() {
251        let result = find_exact_match(&get_test_entry_vec(), "NATO");
252        assert_eq!(result.len(), 1);
253    }
254
255    #[test]
256    fn test_find_exact_match_nothing() {
257        let result = find_exact_match(&get_test_entry_vec(), "NAT");
258        assert_eq!(result.len(), 0);
259    }
260
261    #[test]
262    fn test_find_partial_match_nothing() {
263        let result = find_partial_match(&get_test_entry_vec(), "NATA");
264        assert_eq!(result.len(), 0);
265    }
266
267    #[test]
268    fn test_find_partial_match_two_results() {
269        let result = find_partial_match(&get_test_entry_vec(), "A");
270        assert_eq!(result.len(), 2);
271    }
272
273    #[test]
274    fn test_find_matching_entries_two_results() {
275        let result = find_matching_entries(&get_test_entry_vec(), "A");
276        assert_eq!(result.len(), 2);
277    }
278
279    #[test]
280    fn test_find_matching_entries_one_result() {
281        let result = find_matching_entries(&get_test_entry_vec(), "NATO");
282        assert_eq!(result.len(), 1);
283    }
284
285    #[test]
286    fn test_find_matching_entries_no_results() {
287        let result = find_matching_entries(&get_test_entry_vec(), "NATA");
288        assert_eq!(result.len(), 0);
289    }
290}