Skip to main content

roowho2_lib/proto/finger_protocol/
parser.rs

1use std::path::PathBuf;
2
3use anyhow::Context;
4use chrono::{
5    DateTime, Datelike, Duration, NaiveDate, NaiveTime, TimeZone, Timelike, Utc, Weekday,
6};
7use itertools::Itertools;
8
9use crate::proto::finger_protocol::{
10    FingerResponseStructuredUserEntry, FingerResponseUserSession, MailStatus, RawFingerResponse,
11};
12
13/// Parse the time serialization format commonly used by bsd-finger
14fn parse_bsd_finger_time(time: &str) -> anyhow::Result<DateTime<Utc>> {
15    let time_parts: Vec<_> = time.split_ascii_whitespace().collect();
16
17    let time_ = &time_parts[..time_parts.len() - 1].join(" ");
18    let timezone = time_parts[time_parts.len() - 1]
19        .trim_start_matches('(')
20        .trim_end_matches(')');
21
22    let tz: chrono_tz::Tz = timezone
23        .parse()
24        .context(format!("Failed to parse timezone in login time: {}", time))?;
25
26    let mut parts = time_.split_whitespace();
27
28    let weekday = match parts.next() {
29        Some("Mon") => Weekday::Mon,
30        Some("Tue") => Weekday::Tue,
31        Some("Wed") => Weekday::Wed,
32        Some("Thu") => Weekday::Thu,
33        Some("Fri") => Weekday::Fri,
34        Some("Sat") => Weekday::Sat,
35        Some("Sun") => Weekday::Sun,
36        _ => anyhow::bail!("Invalid weekday in login time: {}", time_),
37    };
38
39    let month = match parts.next() {
40        Some("Jan") => 1,
41        Some("Feb") => 2,
42        Some("Mar") => 3,
43        Some("Apr") => 4,
44        Some("May") => 5,
45        Some("Jun") => 6,
46        Some("Jul") => 7,
47        Some("Aug") => 8,
48        Some("Sep") => 9,
49        Some("Oct") => 10,
50        Some("Nov") => 11,
51        Some("Dec") => 12,
52        _ => anyhow::bail!("Invalid month in login time: {}", time_),
53    };
54
55    let day: u32 = parts
56        .next()
57        .and_then(|d| d.parse().ok())
58        .ok_or_else(|| anyhow::anyhow!("Invalid day in login time: {}", time_))?;
59
60    let time_part = parts
61        .next()
62        .ok_or_else(|| anyhow::anyhow!("Missing time in login time: {}", time_))?;
63
64    let clock = NaiveTime::parse_from_str(time_part, "%H:%M").map_err(|e| {
65        anyhow::anyhow!(
66            "Failed to parse time component in login time: {}: {}",
67            time_,
68            e
69        )
70    })?;
71
72    let now = Utc::now();
73    const MAX_YEARS_BACK: i32 = 10;
74    let mut year = None;
75    for offset in 0..=MAX_YEARS_BACK {
76        let year_ = now.year() - offset;
77
78        let date = match NaiveDate::from_ymd_opt(year_, month, day) {
79            Some(d) => d,
80            None => continue,
81        };
82
83        if date.weekday() != weekday {
84            continue;
85        }
86
87        year = Some(year_);
88    }
89
90    if year.is_none() {
91        return Err(anyhow::anyhow!(
92            "Could not find a valid year for login time {} within {} years",
93            time_,
94            MAX_YEARS_BACK
95        ));
96    }
97
98    tz.with_ymd_and_hms(year.unwrap(), month, day, clock.hour(), clock.minute(), 0)
99        .single()
100        .ok_or_else(|| {
101            anyhow::anyhow!(
102                "Failed to convert login time to timezone-aware datetime: {}",
103                time_
104            )
105        })
106        .map(|dt| dt.with_timezone(&Utc))
107}
108
109pub fn try_parse_structured_user_entry_from_raw_finger_response(
110    response: &RawFingerResponse,
111    username: String,
112) -> anyhow::Result<FingerResponseStructuredUserEntry> {
113    let content = response.get_inner();
114    let lines: Vec<&str> = content.lines().collect();
115
116    if lines.len() < 2 {
117        return Err(anyhow::anyhow!(
118            "Unexpected finger response format for user {}",
119            username
120        ));
121    }
122
123    let first_line = lines[0];
124    let second_line = lines[1];
125
126    let full_name = parse_full_name(first_line, &username)?;
127    let home_dir = parse_home_dir(second_line, &username)?;
128    let shell = parse_shell(second_line, &username)?;
129
130    let mut current_index = 2;
131
132    let (office, office_phone, home_phone) = parse_gecos_fields(&lines, &mut current_index)?;
133
134    let never_logged_in = lines[current_index].trim() == "Never logged in.";
135
136    let user_sessions = if never_logged_in {
137        current_index += 1;
138        vec![]
139    } else {
140        parse_user_sessions(&lines, &mut current_index)
141    };
142
143    let forward_status = parse_forward_status(&lines, &mut current_index);
144    let mail_status = parse_mail_status(&lines, &mut current_index)?;
145    let pgp_key = parse_pgp_key(&lines, &mut current_index);
146    let project = parse_project(&lines, &mut current_index);
147    let plan = parse_plan(&lines, &mut current_index);
148
149    debug_assert!(
150        current_index == lines.len(),
151        "Not all lines in finger response were parsed for user {}. Unparsed lines: {:?}",
152        username,
153        &lines[current_index..]
154    );
155
156    Ok(FingerResponseStructuredUserEntry::new(
157        username,
158        full_name,
159        home_dir,
160        shell,
161        office,
162        office_phone,
163        home_phone,
164        never_logged_in,
165        user_sessions,
166        forward_status,
167        mail_status,
168        pgp_key,
169        project,
170        plan,
171    ))
172}
173
174fn parse_full_name(first_line: &str, username: &str) -> anyhow::Result<String> {
175    Ok(first_line
176        .split("Name:")
177        .nth(1)
178        .ok_or_else(|| {
179            anyhow::anyhow!(
180                "Failed to parse full name from finger response for user {}",
181                username
182            )
183        })?
184        .trim()
185        .to_string())
186}
187
188fn parse_home_dir(second_line: &str, username: &str) -> anyhow::Result<PathBuf> {
189    second_line
190        .split("Directory:")
191        .nth(1)
192        .and_then(|s| s.split("Shell:").next())
193        .map(|s| s.trim())
194        .map(PathBuf::from)
195        .ok_or_else(|| {
196            anyhow::anyhow!(
197                "Failed to parse home directory from finger response for user {}",
198                username
199            )
200        })
201}
202
203fn parse_shell(second_line: &str, username: &str) -> anyhow::Result<PathBuf> {
204    second_line
205        .split("Shell:")
206        .nth(1)
207        .map(|s| s.trim())
208        .map(PathBuf::from)
209        .ok_or_else(|| {
210            anyhow::anyhow!(
211                "Failed to parse shell from finger response for user {}",
212                username
213            )
214        })
215}
216
217fn parse_gecos_fields(
218    lines: &[&str],
219    current_index: &mut usize,
220) -> anyhow::Result<(Option<String>, Option<String>, Option<String>)> {
221    let mut office: Option<String> = None;
222    let mut office_phone: Option<String> = None;
223    let mut home_phone: Option<String> = None;
224
225    // TODO: handle case where office details contains comma, use last comma as separator
226    if let Some(line) = lines.get(*current_index)
227        && line.trim().starts_with("Office:")
228    {
229        let office_line = line.trim().trim_start_matches("Office:").trim();
230        if let Some((office_loc, phone)) = office_line.split_once(',') {
231            office = Some(office_loc.trim().to_string());
232            office_phone = Some(phone.trim().to_string());
233        } else {
234            office = Some(office_line.to_string());
235        }
236        *current_index += 1;
237    }
238    if let Some(line) = lines.get(*current_index)
239        && line.trim().starts_with("Office Phone:")
240    {
241        let phone = line.trim().trim_start_matches("Office Phone:").trim();
242        office_phone = Some(phone.to_string());
243        *current_index += 1;
244    }
245    if let Some(line) = lines.get(*current_index)
246        && line.trim().starts_with("Home Phone:")
247    {
248        let phone = line.trim().trim_start_matches("Home Phone:").trim();
249        home_phone = Some(phone.to_string());
250        *current_index += 1;
251    }
252
253    Ok((office, office_phone, home_phone))
254}
255
256fn parse_user_sessions(
257    lines: &[&str],
258    current_index: &mut usize,
259) -> Vec<FingerResponseUserSession> {
260    let mut sessions = Vec::new();
261
262    while let Some(line) = lines.get(*current_index)
263        && line.starts_with("On since")
264    {
265        let line_to_parse = if line.contains("from")
266            && let Some(next_line) = lines.get(*current_index + 1)
267            && next_line
268                .trim_suffix(" (messages off)")
269                .strip_suffix("idle")
270                .is_some()
271        {
272            *current_index += 2;
273            line.to_string() + "\n" + next_line
274        } else {
275            *current_index += 1;
276            line.to_string()
277        };
278
279        match parse_user_session(&line_to_parse) {
280            Ok(session) => {
281                sessions.push(session);
282            }
283            Err(err) => {
284                tracing::warn!("Failed to parse user session from line: {}\n{}", line, err);
285            }
286        }
287    }
288
289    sessions
290}
291
292/// Try parsing a [FingerResponseUserSession] from the text format used by bsd-finger.
293pub fn parse_user_session(line: &str) -> anyhow::Result<FingerResponseUserSession> {
294    let parts: Vec<&str> = line.split_whitespace().collect();
295
296    debug_assert!(parts[0] == "On");
297    debug_assert!(parts[1] == "since");
298
299    let login_time_str = parts
300        .iter()
301        .take_while(|&&s| s != "on")
302        .skip(2)
303        .cloned()
304        .join(" ");
305
306    let login_time = parse_bsd_finger_time(&login_time_str)?;
307
308    let (tty_loc, tty_str) = parts
309        .iter()
310        .enumerate()
311        .skip(2)
312        .skip_while(|&(_, &s)| s != "on")
313        .nth(1)
314        .ok_or_else(|| anyhow::anyhow!("Failed to find tty in finger session line: {line}"))?;
315    let tty = tty_str.trim_end_matches(',').to_string();
316
317    let (host_loc, host) = match parts
318        .iter()
319        .enumerate()
320        .skip(tty_loc)
321        .skip_while(|&(_, &s)| s != "from")
322        .nth(1)
323    {
324        Some((host_loc, host)) => (host_loc, Some(host.to_string())),
325        None => (tty_loc, None),
326    };
327
328    let idle_str = parts
329        .iter()
330        .skip(host_loc + 1)
331        .take_while(|&&x| x != "idle")
332        .join(" ")
333        .trim_end_matches("(messages off)")
334        .to_string();
335
336    let idle_time = if idle_str.is_empty() {
337        None
338    } else {
339        Some(parse_user_session_idle_time(&idle_str)?)
340    };
341
342    let messages_on = !line.ends_with("(messages off)");
343
344    Ok(FingerResponseUserSession::new(
345        tty,
346        login_time,
347        host,
348        idle_time,
349        messages_on,
350    ))
351}
352
353/// Parse the idle time from the text string generated by bsd-finger
354fn parse_user_session_idle_time(str: &str) -> anyhow::Result<Duration> {
355    let mut total_duration = Duration::zero();
356
357    let parts: Vec<&str> = str.split_whitespace().collect();
358    let mut i = 0;
359    while i < parts.len() {
360        let value_str = parts[i];
361        let unit_str = parts
362            .get(i + 1)
363            .ok_or_else(|| anyhow::anyhow!("Missing time unit in idle time string: {}", str))?;
364
365        let value: i64 = value_str
366            .parse()
367            .map_err(|e| anyhow::anyhow!("Failed to parse value from idle time {}: {}", str, e))?;
368
369        match *unit_str {
370            "day" | "days" => total_duration += Duration::days(value),
371            "hour" | "hours" => total_duration += Duration::hours(value),
372            "minute" | "minutes" => total_duration += Duration::minutes(value),
373            "second" | "seconds" => total_duration += Duration::seconds(value),
374            _ => {
375                return Err(anyhow::anyhow!(
376                    "Unknown time unit '{}' in idle time string: {}",
377                    unit_str,
378                    str
379                ));
380            }
381        }
382
383        i += 2;
384    }
385
386    Ok(total_duration)
387}
388
389fn parse_forward_status(lines: &[&str], current_index: &mut usize) -> Option<String> {
390    let next_line = lines.get(*current_index);
391
392    // TODO: handle multi-line case
393    if let Some(line) = next_line
394        && line.trim().starts_with("Mail forwarded to ")
395    {
396        *current_index += 1;
397        Some(line.trim().trim_prefix("Mail forwarded to ").to_string())
398    } else {
399        None
400    }
401}
402
403fn parse_mail_status(
404    lines: &[&str],
405    current_index: &mut usize,
406) -> anyhow::Result<Option<MailStatus>> {
407    let next_line = lines.get(*current_index);
408
409    if let Some(line) = next_line
410        && line.trim().starts_with("New mail received")
411    {
412        let received_time_line =
413            parse_bsd_finger_time(line.trim().trim_start_matches("New mail received "))?;
414
415        let unread_since_line = parse_bsd_finger_time(
416            lines
417                .get(*current_index + 1)
418                .ok_or_else(|| anyhow::anyhow!("Missing unread since line in mail status"))?
419                .trim()
420                .trim_start_matches("Unread since "),
421        )?;
422
423        *current_index += 2;
424
425        Ok(Some(MailStatus::NewMailReceived {
426            received_time: received_time_line,
427            unread_since: unread_since_line,
428        }))
429    } else if let Some(line) = next_line
430        && (line.trim().starts_with("Mail last read"))
431    {
432        *current_index += 1;
433        let datetime = parse_bsd_finger_time(line.trim().trim_prefix("Mail last read "))?;
434        Ok(Some(MailStatus::MailLastRead(datetime)))
435    } else if let Some(line) = next_line
436        && line.trim() == "No mail."
437    {
438        *current_index += 1;
439        Ok(Some(MailStatus::NoMail))
440    } else {
441        tracing::warn!("Failed to parse mail status from line: {:?}", next_line);
442        Ok(None)
443    }
444}
445
446fn parse_pgp_key(lines: &[&str], current_index: &mut usize) -> Option<String> {
447    let next_line = lines.get(*current_index);
448
449    if let Some(line) = next_line
450        && line.trim().starts_with("PGP key:")
451    {
452        *current_index += 1;
453        let mut pgp_lines = Vec::new();
454        while let Some(line) = lines.get(*current_index) {
455            let trimmed = line.trim();
456            if trimmed.starts_with("Project:") || trimmed.starts_with("Plan:") {
457                break;
458            }
459            pgp_lines.push(trimmed);
460            *current_index += 1;
461        }
462        Some(pgp_lines.join("\n"))
463    } else {
464        None
465    }
466}
467
468fn parse_project(lines: &[&str], current_index: &mut usize) -> Option<String> {
469    let next_line = lines.get(*current_index);
470
471    if let Some(line) = next_line
472        && line.trim().starts_with("Project:")
473    {
474        if line.trim().trim_start_matches("Project:").trim().is_empty() {
475            *current_index += 1;
476
477            let mut project_lines = Vec::new();
478            while let Some(line) = lines.get(*current_index) {
479                let trimmed = line.trim();
480                if trimmed.starts_with("Plan:") {
481                    break;
482                }
483                project_lines.push(trimmed);
484                *current_index += 1;
485            }
486            Some(project_lines.join("\n"))
487        } else {
488            *current_index += 1;
489            Some(
490                line.trim()
491                    .trim_start_matches("Project:")
492                    .trim()
493                    .to_string(),
494            )
495        }
496    } else {
497        None
498    }
499}
500
501fn parse_plan(lines: &[&str], current_index: &mut usize) -> Option<String> {
502    let next_line = lines.get(*current_index);
503
504    if let Some(line) = next_line
505        && line.trim().starts_with("Plan:")
506    {
507        if line.trim().trim_start_matches("Plan:").trim().is_empty() {
508            *current_index += 1;
509            let mut plan_lines = Vec::new();
510            while let Some(line) = lines.get(*current_index) {
511                plan_lines.push(line.trim());
512                *current_index += 1;
513            }
514            Some(plan_lines.join("\n"))
515        } else {
516            *current_index += 1;
517            Some(line.trim().trim_start_matches("Plan:").trim().to_string())
518        }
519    } else if let Some(line) = next_line
520        && line.trim() == "No Plan."
521    {
522        *current_index += 1;
523        None
524    } else {
525        None
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use chrono::{TimeZone, Timelike};
532
533    use super::*;
534
535    #[test]
536    fn test_parse_bsd_finger_time() {
537        let cases = vec![
538            "Mon Mar  1 10:00 (UTC)",
539            "Tue Feb 28 23:59 (UTC)",
540            "Wed Dec 31 00:00 (UTC)",
541            "Wed Dec 31 00:00 (GMT)",
542            "Wed Dec 31 00:00 (Asia/Tokyo)",
543        ];
544
545        for input in cases {
546            let datetime = parse_bsd_finger_time(input);
547            assert!(
548                datetime.is_ok(),
549                "Failed to parse datetime for input: {}",
550                input
551            );
552        }
553    }
554
555    #[test]
556    fn test_parse_user_session_idle_time() {
557        let cases = vec![
558            ("1 second", 1),
559            ("3 minutes 2 seconds", 3 * 60 + 2),
560            (
561                "1 day 5 hours 30 minutes",
562                1 * 24 * 60 * 60 + 5 * 60 * 60 + 30 * 60,
563            ),
564            ("1 day 1 second", 1 * 24 * 60 * 60 + 1),
565        ];
566
567        for (input, expected_seconds) in cases {
568            let duration = parse_user_session_idle_time(input).unwrap();
569            assert_eq!(
570                duration.num_seconds(),
571                expected_seconds,
572                "Failed on input: {}",
573                input
574            );
575        }
576    }
577
578    #[test]
579    fn test_finger_user_session_parsing() {
580        let line = "On since Mon Mar  1 10:00 (UTC) on pts/0 from host.example.com";
581        let session = parse_user_session(line).unwrap();
582        assert_eq!(session.tty, "pts/0");
583        assert_eq!(session.host, Some("host.example.com".into()));
584        assert_eq!(session.login_time.weekday(), Weekday::Mon);
585        assert_eq!(session.login_time.hour(), 10);
586        assert_eq!(session.idle_time, None);
587        assert!(session.messages_on);
588
589        let line_off =
590            "On since Mon Mar  1 10:00 (UTC) on pts/1 from another.host.com (messages off)";
591        let session_off = parse_user_session(line_off).unwrap();
592        assert_eq!(session_off.tty, "pts/1");
593        assert_eq!(session_off.host, Some("another.host.com".into()));
594        assert_eq!(session_off.login_time.weekday(), Weekday::Mon);
595        assert_eq!(session_off.login_time.hour(), 10);
596        assert_eq!(session_off.idle_time, None);
597        assert!(!session_off.messages_on);
598
599        let line_idle = "On since Mon Mar  1 10:00 (UTC) on pts/2 1 day 5 hours 30 minutes idle";
600        let session_idle = parse_user_session(line_idle).unwrap();
601        assert_eq!(session_idle.tty, "pts/2");
602        assert_eq!(session_idle.host, None);
603        assert_eq!(session_idle.login_time.weekday(), Weekday::Mon);
604        assert_eq!(session_idle.login_time.hour(), 10);
605        assert_eq!(
606            session_idle.idle_time.unwrap().num_minutes(),
607            1 * 24 * 60 + 5 * 60 + 30
608        );
609        assert!(session_idle.messages_on);
610
611        let line_idle_off = "On since Mon Mar  1 10:00 (UTC) on pts/3 from host.example.com 47 minutes 1 second idle (messages off)";
612        let session_idle_off = parse_user_session(line_idle_off).unwrap();
613        assert_eq!(session_idle_off.tty, "pts/3");
614        assert_eq!(session_idle_off.host, Some("host.example.com".into()));
615        assert_eq!(session_idle_off.login_time.weekday(), Weekday::Mon);
616        assert_eq!(session_idle_off.login_time.hour(), 10);
617        assert_eq!(session_idle_off.idle_time.unwrap().num_minutes(), 47);
618        assert!(!session_idle_off.messages_on);
619
620        let line_host_and_idle = indoc::indoc! {"
621          On since Mon Mar  1 10:00 (UTC) on pts/4 from host.example.com
622             2 hours idle
623        "}
624        .trim();
625        let session_host_and_idle = parse_user_session(line_host_and_idle).unwrap();
626        assert_eq!(session_host_and_idle.tty, "pts/4");
627        assert_eq!(session_host_and_idle.host, Some("host.example.com".into()));
628        assert_eq!(session_host_and_idle.login_time.weekday(), Weekday::Mon);
629        assert_eq!(session_host_and_idle.login_time.hour(), 10);
630        assert_eq!(
631            session_host_and_idle.idle_time.unwrap().num_minutes(),
632            60 * 2
633        );
634        assert!(session_host_and_idle.messages_on);
635
636        // FIXME: "CEST" timezone parsing is currently broken
637        // let line_cest_timezone = indoc::indoc! {"
638        //   On since Thu Apr 30 05:45 (CEST) on pts/4 from 10.0.0.192
639        //      6 hours 34 minutes idle
640        // "}
641        // .trim();
642        // let session_cest = parse_user_session(line_cest_timezone).unwrap();
643        // assert_eq!(session_cest.tty, "pts/4");
644        // assert_eq!(session_cest.host, Some("10.0.0.192".to_string()));
645        // assert_eq!(session_cest.login_time.weekday(), Weekday::Thu);
646        // assert_eq!(session_cest.login_time.hour(), 5);
647        // assert_eq!(session_cest.idle_time.unwrap().num_minutes(), 6 * 60 + 34);
648        // assert!(session_cest.messages_on);
649    }
650
651    #[test]
652    fn test_finger_user_entry_parsing_basic() {
653        let response_content = indoc::indoc! {"
654          Login: alice           			Name: Alice Wonderland
655          Directory: /home/alice             	Shell: /bin/bash
656          On since Mon Mar  1 10:00 (UTC) on pts/0 from host.example.com
657          No mail.
658          No Plan.
659        "}
660        .trim();
661
662        let response = RawFingerResponse::from(response_content.to_string());
663        let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
664            &response,
665            "alice".to_string(),
666        )
667        .unwrap();
668        assert_eq!(user_entry.username, "alice");
669        assert_eq!(user_entry.full_name, "Alice Wonderland");
670        assert_eq!(user_entry.home_dir, PathBuf::from("/home/alice"));
671        assert_eq!(user_entry.shell, PathBuf::from("/bin/bash"));
672        assert_eq!(user_entry.sessions.len(), 1);
673        assert_eq!(user_entry.sessions[0].tty, "pts/0");
674        assert_eq!(user_entry.sessions[0].host, Some("host.example.com".into()));
675    }
676
677    #[test]
678    #[ignore = "CEST timezone parsing is currently broken"]
679    fn test_finger_user_entry_parsing_idle_sessions() {
680        let response_content = indoc::indoc! {"
681        Login: alice           			Name: Alice Wonderland
682        Directory: /home/alice             	Shell: /bin/bash
683        On since Thu Apr 30 05:45 (CEST) on pts/4 from 10.0.0.192
684           6 hours 34 minutes idle
685        On since Thu Apr 30 05:46 (CEST) on pts/5 from 10.0.0.192
686           6 hours 33 minutes idle
687        No mail.
688        No Plan.
689      "}
690        .trim();
691
692        let response = RawFingerResponse::from(response_content.to_string());
693        let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
694            &response,
695            "alice".to_string(),
696        )
697        .unwrap();
698
699        assert_eq!(user_entry.sessions.len(), 2);
700
701        assert_eq!(user_entry.sessions[0].tty, "pts/4");
702        assert_eq!(user_entry.sessions[0].host, Some("10.0.0.192".into()));
703        assert_eq!(
704            user_entry.sessions[0].idle_time.unwrap().num_minutes(),
705            6 * 60 + 34
706        );
707
708        assert_eq!(user_entry.sessions[1].tty, "pts/5");
709        assert_eq!(user_entry.sessions[1].host, Some("10.0.0.192".into()));
710        assert_eq!(
711            user_entry.sessions[1].idle_time.unwrap().num_minutes(),
712            6 * 60 + 33
713        );
714    }
715
716    #[test]
717    fn test_finger_user_entry_parsing_single_line_office_phone() {
718        let response_content = indoc::indoc! {"
719          Login: alice           			Name: Alice Wonderland
720          Directory: /home/alice             	Shell: /bin/bash
721          Office: 123 Main St, 012-345-6789
722          Home Phone: +0-123-456-7890
723          On since Mon Mar  1 10:00 (UTC) on pts/0, idle 5:00, from host.example.com
724          No mail.
725          No Plan.
726        "}
727        .trim();
728
729        let response = RawFingerResponse::from(response_content.to_string());
730        let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
731            &response,
732            "alice".to_string(),
733        )
734        .unwrap();
735
736        assert_eq!(user_entry.office, Some("123 Main St".to_string()));
737        assert_eq!(user_entry.office_phone, Some("012-345-6789".to_string()));
738        assert_eq!(user_entry.home_phone, Some("+0-123-456-7890".to_string()));
739    }
740
741    #[test]
742    fn test_finger_user_entry_parsing_multiline_office_phone() {
743        let response_content = indoc::indoc! {"
744          Login: alice           			Name: Alice Wonderland
745          Directory: /home/alice             	Shell: /bin/bash
746          Office: 123 Main St
747          Office Phone: 012-345-6789
748          Home Phone: +0-123-456-7890
749          On since Mon Mar  1 10:00 (UTC) on pts/0, idle 5:00, from host.example.com
750          No mail.
751          No Plan.
752        "}
753        .trim();
754
755        let response = RawFingerResponse::from(response_content.to_string());
756        let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
757            &response,
758            "alice".to_string(),
759        )
760        .unwrap();
761
762        assert_eq!(user_entry.office, Some("123 Main St".to_string()));
763        assert_eq!(user_entry.office_phone, Some("012-345-6789".to_string()));
764        assert_eq!(user_entry.home_phone, Some("+0-123-456-7890".to_string()));
765    }
766
767    #[test]
768    fn test_finger_user_entry_parsing_never_logged_in() {
769        let response_content = indoc::indoc! {"
770          Login: bob           			Name: Bob Builder
771          Directory: /home/bob             	Shell: /bin/zsh
772          Never logged in.
773          No mail.
774          No Plan.
775        "}
776        .trim();
777
778        let response = RawFingerResponse::from(response_content.to_string());
779        let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
780            &response,
781            "bob".to_string(),
782        )
783        .unwrap();
784
785        assert!(user_entry.never_logged_in);
786        assert!(user_entry.sessions.is_empty());
787    }
788
789    #[test]
790    fn test_finger_user_entry_parsing_no_mail() {
791        let response_content = indoc::indoc! {"
792        Login: bob           			Name: Bob Builder
793        Directory: /home/bob             	Shell: /bin/zsh
794        Never logged in.
795        No mail.
796        No Plan.
797      "}
798        .trim();
799
800        let response = RawFingerResponse::from(response_content.to_string());
801        let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
802            &response,
803            "bob".to_string(),
804        )
805        .unwrap();
806        assert_eq!(user_entry.mail_status, Some(MailStatus::NoMail));
807    }
808
809    #[test]
810    fn test_finger_user_entry_parsing_new_mail_received() {
811        let response_content = indoc::indoc! {"
812        Login: bob           			Name: Bob Builder
813        Directory: /home/bob             	Shell: /bin/zsh
814        Never logged in.
815        New mail received Mon Mar  1 10:00 (UTC)
816             Unread since Mon Mar  1 09:00 (UTC)
817        No Plan.
818      "}
819        .trim();
820
821        let response = RawFingerResponse::from(response_content.to_string());
822        let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
823            &response,
824            "bob".to_string(),
825        )
826        .unwrap();
827        assert_eq!(
828            user_entry.mail_status,
829            Some(MailStatus::NewMailReceived {
830                received_time: Utc.with_ymd_and_hms(2021, 3, 1, 10, 0, 0).unwrap(),
831                unread_since: Utc.with_ymd_and_hms(2021, 3, 1, 9, 0, 0).unwrap(),
832            })
833        );
834    }
835
836    #[test]
837    fn test_finger_user_entry_parsing_mail_last_read() {
838        let response_content = indoc::indoc! {"
839        Login: bob           			Name: Bob Builder
840        Directory: /home/bob             	Shell: /bin/zsh
841        Never logged in.
842        Mail last read Mon Mar  1 10:00 (UTC)
843        No Plan.
844      "}
845        .trim();
846
847        let response = RawFingerResponse::from(response_content.to_string());
848        let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
849            &response,
850            "bob".to_string(),
851        )
852        .unwrap();
853        assert_eq!(
854            user_entry.mail_status,
855            Some(MailStatus::MailLastRead(
856                Utc.with_ymd_and_hms(2021, 3, 1, 10, 0, 0).unwrap()
857            ))
858        );
859    }
860
861    #[test]
862    fn test_finger_user_entry_parsing_single_line_plan_project() {
863        let response_content = indoc::indoc! {"
864        Login: bob           			Name: Bob Builder
865        Directory: /home/bob             	Shell: /bin/zsh
866        Never logged in.
867        Mail last read Mon Mar  1 10:00 (UTC)
868        Project: Build a new house.
869        Plan: Build a new house.
870      "}
871        .trim();
872
873        let response = RawFingerResponse::from(response_content.to_string());
874        let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
875            &response,
876            "bob".to_string(),
877        )
878        .unwrap();
879
880        assert_eq!(user_entry.project, Some("Build a new house.".to_string()));
881        assert_eq!(user_entry.plan, Some("Build a new house.".to_string()));
882    }
883
884    #[test]
885    fn test_finger_user_entry_parsing_multiline_pgp_plan_project() {
886        let response_content = indoc::indoc! {"
887        Login: bob           			Name: Bob Builder
888        Directory: /home/bob             	Shell: /bin/zsh
889        Never logged in.
890        Mail last read Mon Mar  1 10:00 (UTC)
891        PGP key:
892        -----BEGIN PGP KEY-----
893        Version: GnuPG v1
894        ABCDEFGHIJKLMNOPQRSTUVWXYZ
895        -----END PGP KEY-----
896        Project:
897        Build a new house.
898
899        Need to buy materials.
900        Plan:
901        Build a new house.
902
903        Need to buy materials.
904      "}
905        .trim();
906
907        let response = RawFingerResponse::from(response_content.to_string());
908        let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
909            &response,
910            "bob".to_string(),
911        )
912        .unwrap();
913        assert_eq!(
914        user_entry.pgp_key,
915        Some("-----BEGIN PGP KEY-----\nVersion: GnuPG v1\nABCDEFGHIJKLMNOPQRSTUVWXYZ\n-----END PGP KEY-----".to_string()),
916    );
917
918        assert_eq!(
919            user_entry.project,
920            Some("Build a new house.\n\nNeed to buy materials.".to_string())
921        );
922        assert_eq!(
923            user_entry.plan,
924            Some("Build a new house.\n\nNeed to buy materials.".to_string())
925        );
926    }
927
928    #[test]
929    fn test_finger_user_entry_parsing_plan_keyword_in_plan() {
930        let response_content = indoc::indoc! {"
931        Login: bob           			Name: Bob Builder
932        Directory: /home/bob             	Shell: /bin/zsh
933        Never logged in.
934        Mail last read Mon Mar  1 10:00 (UTC)
935        Project:
936        I put an extra Plan: keyword here for kaos.
937
938        :3:3:3
939        Plan:
940        Build a new house.
941
942        Plan:
943        Need to buy materials.
944
945        The plan is to build a new house.
946      "}
947        .trim();
948
949        let response = RawFingerResponse::from(response_content.to_string());
950        let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
951            &response,
952            "bob".to_string(),
953        )
954        .unwrap();
955
956        assert_eq!(
957            user_entry.project,
958            Some("I put an extra Plan: keyword here for kaos.\n\n:3:3:3".to_string())
959        );
960        assert_eq!(
961        user_entry.plan,
962        Some("Build a new house.\n\nPlan:\nNeed to buy materials.\n\nThe plan is to build a new house.".to_string())
963    );
964    }
965}