1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
#[macro_use]
extern crate prettytable;
extern crate textwrap;
//extern crate unicode_width;
use chrono::prelude::*;
use grep::cli::DecompressionReader;
use grep::searcher::sinks::UTF8;
use grep::searcher::Searcher;
use prettytable::{format, Cell, Row, Table};
use regex::Regex;
use std::process;
// grep_pcre2 for look-around regex
use grep_pcre2::RegexMatcher;
use std::collections::BTreeMap;
use std::error::Error;
use std::io::Read;

pub struct Config {
    pub query: String,
    pub filename: String,
}
impl Config {
    pub fn new(query: &str, filename: &str) -> Result<Config, &'static str> {
        let query = String::from(query);
        let filename = String::from(filename);
        Ok(Config { query, filename })
    }
}

pub fn create_query(filter: &str, user_filter: &str, _verbose: bool) -> String {
    // pour des mots avec position inconnue : look-around regex
    // https://stackoverflow.com/questions/4389644/regex-to-match-string-containing-two-names-in-any-order
    // exemple: ^(?=.*\bjack\b)(?=.*\bjames\b).*$
    // TODO attention ça marche pas bien :
    // `-F backupDataViaRsync` est OK, mais pas `-F backup`
    let mut query = String::from("^(?=.*\\bCRON\\b)");
    if user_filter.is_empty() {
        let user_filter = format!("(?=.*\\b{}\\b)", user_filter);
        query.push_str(&user_filter);
    }
    if filter.is_empty() {
        let filter = format!("(?=.*\\b{}\\b)", filter);
        query.push_str(&filter);
    }
    query.push_str(".*$");
    query
}

struct Cronjob {
    pid: i32,
    status: JobStatus,
    user: Option<String>,
    hostname: Option<String>,
    start_date: Option<chrono::NaiveDateTime>,
    end_date: Option<chrono::NaiveDateTime>,
    duration: Option<i64>,
    start_line: Option<String>,
    // si les 2 sont None, ça veut dire que ya ptet pas la conf (-L 15) : faudra afficher un warn
    message: Option<String>,
    end_line: Option<String>,
}
impl Cronjob {
    fn get_user(&self) -> &str {
        match &self.user {
            Some(user) => user.as_str(),
            _ => "",
        }
    }
    fn get_dates(&self) -> String {
        // faut gérer les cas où il manque une des 2 dates
        let mut dates = String::from("");
        match (self.start_date, self.end_date) {
            (Some(start), Some(end)) => dates = format!("{}\n{}", start, end),
            (Some(start), None) => dates = format!("{}", start),
            (None, Some(end)) => dates = format!("{}", end),
            _ => (),
        }
        dates
    }
    fn set_duration(&mut self) {
        if let (Some(start), Some(end)) = (self.start_date, self.end_date) {
            let duration = end - start;
            // TODO virer num_seconds et gérer un affichage sympa ?
            self.duration = Some(duration.num_seconds());
        }
    }
    fn get_duration(&self) -> String {
        match &self.duration {
            Some(duration) => duration.to_string(),
            _ => String::from("unknow"),
        }
    }
    fn get_command(&self) -> String {
        match &self.start_line {
            Some(command) => command.to_owned(),
            _ => String::from(""),
        }
    }
}

enum JobStatus {
    Ok,
    Failed,
    Unknow,
}

fn parse_date(date: String, year: i32) -> Option<chrono::NaiveDateTime> {
    // Mar 23 14:45:01
    let date_with_year = format!("{} {}", year, date);
    let parsed = NaiveDateTime::parse_from_str(date_with_year.as_str(), "%Y %b %d %T");
    match parsed {
        Ok(parsed) => Some(parsed),
        Err(e) => {
            println!("err {}", e);
            None
        }
    }
}
fn status_filter(ko_filter: bool, ok_filter: bool, verbose: bool) {
    if verbose {
        println!("DEBUG: ko:{:?} ok:{:?}", ko_filter, ok_filter);
    }
}

// TODO retourner la ref mutable du job pour le modifier directement ?
fn create_job_if_needed(cronjobs: &mut BTreeMap<i32, Cronjob>, pid: i32) {
    // si ce pid est déjà en mémoire, on ajoute juste des champs, sinon on le crée
    match cronjobs.get(&pid) {
        Some(_) => (),
        _ => {
            let job = Cronjob {
                pid,
                user: None,
                hostname: None,
                start_date: None,
                end_date: None,
                start_line: None,
                message: None,
                end_line: None,
                status: JobStatus::Unknow,
                duration: None,
            };
            cronjobs.insert(pid, job);
        }
    }
}

fn create_cronjobs_list(res: &Vec<String>, verbose: bool) -> Option<BTreeMap<i32, Cronjob>> {
    let re_cron_log = match Regex::new(
        r"^(?P<date>.*) (?P<hostname>.*) CRON\[(?P<pid>[0-9]+)\]: \((?P<user>.*)\) (?P<logtype>(CMD|END|error)) (?P<message>.*)",
    ) {
        Ok(re) => re,
        Err(error) => {
            eprintln!("Problem creating regex to parse cron log: {}", error);
            process::exit(1);
        }
    };
    if verbose {
        println!("DEBUG: regex pid: {:?}", re_cron_log);
    }
    let mut cronjobs: BTreeMap<i32, Cronjob> = BTreeMap::new();
    let current_year = Local::now().year();
    for line in res {
        match re_cron_log.captures(line) {
            None => (),
            Some(matched_line) => {
                // parse des différents champs
                match matched_line.name("pid")?.as_str().parse() {
                    Ok(pid) => {
                        let pid = pid;
                        let user = matched_line.name("user")?.as_str().to_string();
                        let hostname = matched_line.name("hostname")?.as_str().to_string();
                        let date = matched_line.name("date")?.as_str().to_string();
                        let logtype = matched_line.name("logtype")?.as_str().to_string();
                        // TODO virer le [pid] si il est dans le (et why il y est pas tout le temps ??)
                        let message = matched_line.name("message")?.as_str().to_string();

                        // selon le type de log, on va définir le start, end, ou fail
                        create_job_if_needed(&mut cronjobs, pid);
                        if let Some(job) = cronjobs.get_mut(&pid) {
                            match logtype.as_str() {
                                "CMD" => {
                                    job.start_line = Some(message);
                                    job.user = Some(user);
                                    job.hostname = Some(hostname);
                                    //job.start_date = Some(date);
                                    job.start_date = parse_date(date, current_year);
                                }
                                "END" => {
                                    job.end_line = Some(message);
                                    job.user = Some(user);
                                    job.end_date = parse_date(date, current_year);
                                    Cronjob::set_duration(job);
                                    match job.status {
                                        JobStatus::Failed => (),
                                        _ => job.status = JobStatus::Ok,
                                    }
                                }
                                "error" => {
                                    job.message = Some(message);
                                    job.end_date = parse_date(date, current_year);
                                    job.status = JobStatus::Failed;
                                }
                                // TODO afficher fichier et numero ligne
                                _ => eprintln!("Some line are not CRON log."),
                            }
                        }
                    }
                    Err(_) => {
                        println!("Warnig, unable to parse following line : {}", line);
                    }
                };
            }
        }
    }
    // TODO trier la map cronjobs par date
    Some(cronjobs)
}

pub fn display_jobs(res: Vec<String>, ko_filter: bool, ok_filter: bool, verbose: bool) {
    // pour avoir la taille du terminal
    // voir https://github.com/phsym/prettytable-rs/issues/47

    // maintenant on affiche vraiment
    match create_cronjobs_list(&res, verbose) {
        None => (),
        Some(cronjobs) => {
            let mut table = Table::new();
            table.set_format(*format::consts::FORMAT_BOX_CHARS);
            table.add_row(row![
                b->"PID", b->"USER", b->"STATUS", b->"DATES", b->"DURATION", b->"COMMAND"
            ]);
            status_filter(ko_filter, ok_filter, verbose);
            for (_pid, job) in cronjobs {
                // un row en 2 parties, avec une couleur qui change au milieu
                let mut start_of_row =
                    vec![Cell::new(&job.pid.to_string()), Cell::new(job.get_user())];
                match job.status {
                    JobStatus::Ok => start_of_row.push(Cell::new("OK").style_spec("Fg")),
                    JobStatus::Failed => start_of_row.push(Cell::new("KO").style_spec("bFr")),
                    JobStatus::Unknow => start_of_row.push(Cell::new("unknow").style_spec("Fb")),
                }
                let mut end_of_row = vec![
                    (Cell::new(job.get_dates().as_str())),
                    (Cell::new(job.get_duration().as_str())),
                    //(Cell::new(job.get_command().as_str())),
                    (Cell::new(textwrap::fill(job.get_command().as_str(), 80).as_str())),
                ];
                start_of_row.append(&mut end_of_row);
                table.add_row(Row::new(start_of_row));
            }
            table.printstd();
        }
    }
}

pub fn grep_file(config: Config, verbose: bool) -> Result<Vec<String>, Box<dyn Error>> {
    // lecture fichiers
    // utilisation de DecompressionReader pour gérer les .gz
    let mut reader = DecompressionReader::new(&config.filename)?;
    let mut contents = vec![];
    reader.read_to_end(&mut contents)?;

    // construction matcher depuis la regexp
    let matcher = RegexMatcher::new(config.query.as_str())?;
    if verbose {
        println!("DEBUG: regex query : {}", config.query);
    }

    // search des matches dans contents
    // on met les lines dans un Vec qu'on retourne
    let mut matches: Vec<String> = vec![];
    Searcher::new().search_slice(
        &matcher,
        &contents,
        UTF8(|_lnum, line| {
            matches.push(line.to_string());
            Ok(true)
        }),
    )?;
    Ok(matches)
}

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

    #[test]
    fn grep_file_result() {
        let filename = String::from("tests/sample_cron.log");
        let query = String::from("59697");
        let config = Config::new(&query, &filename).unwrap();
        let expected_res = Vec::from(["Mar 23 14:35:01 srv4 CRON[59697]: (_tuptime) CMD (   if [ -x /usr/bin/tuptime ]; then /usr/bin/tuptime -x > /dev/null; fi)\n".to_string()]);
        match grep_file(config, false) {
            Ok(res) => assert_eq!(expected_res, res),
            Err(e) => eprintln!("Application error: {}", e),
        };
    }
}