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
13fn 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 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
292pub 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
353fn 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 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 }
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}