1use chrono::{DateTime, FixedOffset};
47#[cfg(feature = "serde")]
48use serde::{Deserialize, Serialize};
49use std::collections::{HashMap, VecDeque};
50use std::fs::File;
51use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
52use std::path::{Path, PathBuf};
53
54pub const DEFAULT_LOG_PATH: &str = "/var/log/pacman.log";
55const CHUNK_SIZE: usize = 64 * 1024;
56
57pub type Error = std::io::Error;
58pub type Result<T> = std::io::Result<T>;
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
61#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
62pub enum Action {
63 Installed,
64 Removed,
65 Upgraded,
66 Downgraded,
67 Reinstalled,
68}
69
70impl std::fmt::Display for Action {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 match self {
73 Action::Installed => write!(f, "installed"),
74 Action::Removed => write!(f, "removed"),
75 Action::Upgraded => write!(f, "upgraded"),
76 Action::Downgraded => write!(f, "downgraded"),
77 Action::Reinstalled => write!(f, "reinstalled"),
78 }
79 }
80}
81
82impl std::str::FromStr for Action {
83 type Err = ();
84
85 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
86 match s.to_lowercase().as_str() {
87 "installed" => Ok(Action::Installed),
88 "removed" => Ok(Action::Removed),
89 "upgraded" => Ok(Action::Upgraded),
90 "downgraded" => Ok(Action::Downgraded),
91 "reinstalled" => Ok(Action::Reinstalled),
92 _ => Err(()),
93 }
94 }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
98#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
99pub struct LogEntry {
100 pub timestamp: DateTime<FixedOffset>,
101 pub action: Action,
102 pub package: String,
103 pub old_version: Option<String>,
104 pub new_version: Option<String>,
105}
106
107pub fn parse_line(line: &str) -> Option<LogEntry> {
111 LogEntry::parse(line)
112}
113
114impl LogEntry {
115 fn parse(line: &str) -> Option<Self> {
116 let (timestamp_str, caller, msg) = split_leader(line)?;
117 if caller != "ALPM" {
118 return None;
119 }
120 let (action, package, old_version, new_version) = parse_action(msg.trim())?;
121 let timestamp = parse_timestamp(timestamp_str)?;
122 Some(LogEntry {
123 timestamp,
124 action,
125 package,
126 old_version,
127 new_version,
128 })
129 }
130}
131
132fn split_leader(line: &str) -> Option<(&str, &str, &str)> {
136 let line = line.strip_prefix('[')?;
137 let ts_end = line.find(']')?;
138 let timestamp = &line[..ts_end];
139
140 let rest = line.get(ts_end + 2..)?;
141 let rest = rest.strip_prefix('[')?;
142 let caller_end = rest.find(']')?;
143 let caller = &rest[..caller_end];
144
145 let msg = rest.get(caller_end + 2..)?;
146 Some((timestamp, caller, msg))
147}
148
149fn parse_timestamp(s: &str) -> Option<DateTime<FixedOffset>> {
150 DateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%z").ok()
151}
152
153fn parse_action(s: &str) -> Option<(Action, String, Option<String>, Option<String>)> {
154 let parts: Vec<&str> = s.splitn(2, ' ').collect();
155 if parts.len() < 2 {
156 return None;
157 }
158
159 let action: Action = parts[0].parse().ok()?;
160 let rest = parts[1];
161
162 let paren_start = rest.find('(')?;
163 let paren_end = rest.rfind(')')?;
164 let package = rest[..paren_start].trim().to_string();
165 let version_part = &rest[paren_start + 1..paren_end];
166
167 match action {
168 Action::Upgraded | Action::Downgraded => {
169 if let Some(arrow_pos) = version_part.find(" -> ") {
170 let old_version = version_part[..arrow_pos].trim().to_string();
171 let new_version = version_part[arrow_pos + 4..].trim().to_string();
172 Some((action, package, Some(old_version), Some(new_version)))
173 } else {
174 Some((action, package, None, None))
175 }
176 }
177 Action::Installed => {
178 let version = version_part.trim().to_string();
179 Some((action, package, None, Some(version)))
180 }
181 Action::Removed => {
182 let version = version_part.trim().to_string();
183 Some((action, package, Some(version), None))
184 }
185 Action::Reinstalled => {
186 let version = version_part.trim().to_string();
187 Some((action, package, Some(version.clone()), Some(version)))
188 }
189 }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
194#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
195pub enum Caller {
196 Pacman,
197 Alpm,
198 AlpmScriptlet,
199 Unknown,
200}
201
202impl Caller {
203 fn from_field(s: &str) -> Self {
204 match s {
205 "PACMAN" => Caller::Pacman,
206 "ALPM" => Caller::Alpm,
207 "ALPM-SCRIPTLET" => Caller::AlpmScriptlet,
208 _ => Caller::Unknown,
209 }
210 }
211}
212
213#[derive(Debug, Clone, PartialEq, Eq)]
215#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
216pub struct Entry {
217 pub timestamp: DateTime<FixedOffset>,
218 pub caller: Caller,
219 pub event: Event,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq)]
224#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
225pub enum Event {
226 Operation(LogEntry),
228 Command(String),
230 TransactionStart,
231 TransactionComplete,
232 Other(String),
234}
235
236pub fn parse_entry(line: &str) -> Option<Entry> {
240 let (timestamp_str, caller_field, msg) = split_leader(line)?;
241 let timestamp = parse_timestamp(timestamp_str)?;
242 let caller = Caller::from_field(caller_field);
243 let msg = msg.trim();
244
245 let event = if msg == "transaction started" {
246 Event::TransactionStart
247 } else if msg == "transaction completed" {
248 Event::TransactionComplete
249 } else if let Some(cmd) = (caller == Caller::Pacman)
250 .then(|| {
251 msg.strip_prefix("Running '")
252 .and_then(|s| s.strip_suffix('\''))
253 })
254 .flatten()
255 {
256 Event::Command(cmd.to_string())
257 } else if let Some(op) = (caller == Caller::Alpm)
258 .then(|| parse_action(msg))
259 .flatten()
260 {
261 let (action, package, old_version, new_version) = op;
262 Event::Operation(LogEntry {
263 timestamp,
264 action,
265 package,
266 old_version,
267 new_version,
268 })
269 } else {
270 Event::Other(msg.to_string())
271 };
272
273 Some(Entry {
274 timestamp,
275 caller,
276 event,
277 })
278}
279
280#[derive(Debug, Clone)]
281pub(crate) struct Filter {
282 action: Option<Action>,
283 package: Option<String>,
284 package_exact: Option<String>,
285 since: Option<DateTime<FixedOffset>>,
286 until: Option<DateTime<FixedOffset>>,
287}
288
289impl Filter {
290 pub fn new() -> Self {
291 Filter {
292 action: None,
293 package: None,
294 package_exact: None,
295 since: None,
296 until: None,
297 }
298 }
299
300 fn matches(&self, entry: &LogEntry) -> bool {
301 if self.action.is_some_and(|a| a != entry.action) {
302 return false;
303 }
304
305 if self
306 .package
307 .as_ref()
308 .is_some_and(|p| !entry.package.contains(p.as_str()))
309 {
310 return false;
311 }
312
313 if self
314 .package_exact
315 .as_ref()
316 .is_some_and(|p| &entry.package != p)
317 {
318 return false;
319 }
320
321 if self.since.is_some_and(|s| entry.timestamp < s) {
322 return false;
323 }
324
325 if self.until.is_some_and(|u| entry.timestamp > u) {
326 return false;
327 }
328
329 true
330 }
331}
332
333pub struct LogReader {
334 path: PathBuf,
335 reverse: bool,
336 filter: Filter,
337}
338
339impl LogReader {
340 pub fn open<P: AsRef<Path>>(path: P) -> Self {
345 LogReader {
346 path: path.as_ref().to_path_buf(),
347 reverse: false,
348 filter: Filter::new(),
349 }
350 }
351
352 pub fn system() -> Self {
354 Self::open(DEFAULT_LOG_PATH)
355 }
356
357 #[must_use]
359 pub fn reverse(mut self) -> Self {
360 self.reverse = true;
361 self
362 }
363
364 #[must_use]
366 pub fn filter_action(mut self, action: Action) -> Self {
367 self.filter.action = Some(action);
368 self
369 }
370
371 #[must_use]
373 pub fn filter_package(mut self, package: &str) -> Self {
374 self.filter.package = Some(package.to_string());
375 self
376 }
377
378 #[must_use]
380 pub fn filter_package_exact(mut self, package: &str) -> Self {
381 self.filter.package_exact = Some(package.to_string());
382 self
383 }
384
385 #[must_use]
387 pub fn since(mut self, timestamp: DateTime<FixedOffset>) -> Self {
388 self.filter.since = Some(timestamp);
389 self
390 }
391
392 #[must_use]
394 pub fn until(mut self, timestamp: DateTime<FixedOffset>) -> Self {
395 self.filter.until = Some(timestamp);
396 self
397 }
398
399 pub fn entries(self) -> impl Iterator<Item = Result<Entry>> {
404 EntryIterator::new(self.path)
405 }
406
407 pub fn transactions(self) -> impl Iterator<Item = Result<Transaction>> {
414 TransactionIterator::new(EntryIterator::new(self.path))
415 }
416}
417
418#[derive(Debug, Clone, PartialEq, Eq)]
421#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
422pub struct Transaction {
423 pub command: Option<String>,
424 pub started: DateTime<FixedOffset>,
425 pub completed: Option<DateTime<FixedOffset>>,
426 pub operations: Vec<LogEntry>,
427}
428
429pub fn command_counts(entries: impl Iterator<Item = Entry>) -> HashMap<String, usize> {
433 let mut counts = HashMap::new();
434 for entry in entries {
435 if let Event::Command(cmd) = entry.event {
436 *counts.entry(cmd).or_insert(0) += 1;
437 }
438 }
439 counts
440}
441
442impl IntoIterator for LogReader {
443 type Item = Result<LogEntry>;
444 type IntoIter = Box<dyn Iterator<Item = Result<LogEntry>>>;
445
446 fn into_iter(self) -> Self::IntoIter {
447 if self.reverse {
448 Box::new(ReverseIterator::new(self.path, self.filter))
449 } else {
450 Box::new(ForwardIterator::new(self.path, self.filter))
451 }
452 }
453}
454
455struct ForwardIterator {
456 reader: Option<BufReader<File>>,
457 filter: Filter,
458 error: Option<Error>,
459}
460
461impl ForwardIterator {
462 fn new(path: PathBuf, filter: Filter) -> Self {
463 match File::open(&path) {
464 Ok(file) => ForwardIterator {
465 reader: Some(BufReader::new(file)),
466 filter,
467 error: None,
468 },
469 Err(e) => ForwardIterator {
470 reader: None,
471 filter,
472 error: Some(e),
473 },
474 }
475 }
476}
477
478impl Iterator for ForwardIterator {
479 type Item = Result<LogEntry>;
480
481 fn next(&mut self) -> Option<Self::Item> {
482 if let Some(error) = self.error.take() {
483 return Some(Err(error));
484 }
485
486 let reader = self.reader.as_mut()?;
487
488 loop {
489 let mut line = String::new();
490 match reader.read_line(&mut line) {
491 Ok(0) => return None,
492 Ok(_) => {
493 if let Some(entry) = LogEntry::parse(&line).filter(|e| self.filter.matches(e)) {
494 return Some(Ok(entry));
495 }
496 }
497 Err(e) => {
498 return Some(Err(e));
499 }
500 }
501 }
502 }
503}
504
505struct EntryIterator {
506 reader: Option<BufReader<File>>,
507 error: Option<Error>,
508}
509
510impl EntryIterator {
511 fn new(path: PathBuf) -> Self {
512 match File::open(&path) {
513 Ok(file) => EntryIterator {
514 reader: Some(BufReader::new(file)),
515 error: None,
516 },
517 Err(e) => EntryIterator {
518 reader: None,
519 error: Some(e),
520 },
521 }
522 }
523}
524
525impl Iterator for EntryIterator {
526 type Item = Result<Entry>;
527
528 fn next(&mut self) -> Option<Self::Item> {
529 if let Some(error) = self.error.take() {
530 return Some(Err(error));
531 }
532
533 let reader = self.reader.as_mut()?;
534
535 loop {
536 let mut line = String::new();
537 match reader.read_line(&mut line) {
538 Ok(0) => return None,
539 Ok(_) => {
540 if let Some(entry) = parse_entry(&line) {
541 return Some(Ok(entry));
542 }
543 }
544 Err(e) => return Some(Err(e)),
545 }
546 }
547 }
548}
549
550struct TransactionIterator<I> {
551 entries: I,
552 last_command: Option<String>,
553}
554
555impl<I> TransactionIterator<I> {
556 fn new(entries: I) -> Self {
557 TransactionIterator {
558 entries,
559 last_command: None,
560 }
561 }
562}
563
564impl<I: Iterator<Item = Result<Entry>>> Iterator for TransactionIterator<I> {
565 type Item = Result<Transaction>;
566
567 fn next(&mut self) -> Option<Self::Item> {
568 let mut current: Option<Transaction> = None;
569
570 for entry in self.entries.by_ref() {
571 let entry = match entry {
572 Ok(e) => e,
573 Err(e) => return Some(Err(e)),
574 };
575
576 match entry.event {
577 Event::Command(cmd) => self.last_command = Some(cmd),
578 Event::TransactionStart => {
579 current = Some(Transaction {
580 command: self.last_command.take(),
581 started: entry.timestamp,
582 completed: None,
583 operations: Vec::new(),
584 });
585 }
586 Event::TransactionComplete => {
587 if let Some(mut txn) = current.take() {
588 txn.completed = Some(entry.timestamp);
589 return Some(Ok(txn));
590 }
591 }
592 Event::Operation(op) => {
593 if let Some(txn) = current.as_mut() {
594 txn.operations.push(op);
595 }
596 }
597 Event::Other(_) => {}
598 }
599 }
600
601 current.map(Ok)
602 }
603}
604
605struct ReverseIterator {
606 file: Option<File>,
607 pos: u64,
608 leftover: String,
609 incomplete_utf8: Vec<u8>,
610 buffer: VecDeque<LogEntry>,
611 filter: Filter,
612 error: Option<Error>,
613 done: bool,
614}
615
616impl ReverseIterator {
617 fn new(path: PathBuf, filter: Filter) -> Self {
618 match File::open(&path) {
619 Ok(file) => {
620 let pos = file.metadata().map(|m| m.len()).unwrap_or(0);
621 ReverseIterator {
622 file: Some(file),
623 pos,
624 leftover: String::new(),
625 incomplete_utf8: Vec::new(),
626 buffer: VecDeque::new(),
627 filter,
628 error: None,
629 done: false,
630 }
631 }
632 Err(e) => ReverseIterator {
633 file: None,
634 pos: 0,
635 leftover: String::new(),
636 incomplete_utf8: Vec::new(),
637 buffer: VecDeque::new(),
638 filter,
639 error: Some(e),
640 done: true,
641 },
642 }
643 }
644
645 fn fill_buffer(&mut self) -> Option<()> {
646 let file = self.file.as_mut()?;
647
648 while self.buffer.is_empty() && self.pos > 0 {
649 let chunk_size = std::cmp::min(self.pos, CHUNK_SIZE as u64);
650 self.pos -= chunk_size;
651
652 if file.seek(SeekFrom::Start(self.pos)).is_err() {
653 return None;
654 }
655
656 let mut buf = vec![0u8; chunk_size as usize];
657 if file.read_exact(&mut buf).is_err() {
658 return None;
659 }
660
661 if !self.incomplete_utf8.is_empty() {
662 buf.extend(std::mem::take(&mut self.incomplete_utf8));
663 }
664
665 let (valid_str, incomplete_start) = decode_utf8_safe(&buf);
666 if incomplete_start > 0 {
667 self.incomplete_utf8 = buf[..incomplete_start].to_vec();
668 }
669
670 let combined = format!("{}{}", valid_str, self.leftover);
671 let mut lines: Vec<&str> = combined.lines().collect();
672
673 if self.pos > 0 && !lines.is_empty() {
674 self.leftover = lines.remove(0).to_string();
675 } else {
676 self.leftover.clear();
677 }
678
679 for line in lines.into_iter().rev() {
680 if let Some(entry) = LogEntry::parse(line).filter(|e| self.filter.matches(e)) {
681 self.buffer.push_back(entry);
682 }
683 }
684 }
685
686 if self.pos == 0 && !self.leftover.is_empty() && self.buffer.is_empty() {
687 if let Some(entry) = LogEntry::parse(&self.leftover).filter(|e| self.filter.matches(e))
688 {
689 self.buffer.push_back(entry);
690 }
691 self.leftover.clear();
692 }
693
694 Some(())
695 }
696}
697
698fn find_utf8_start_boundary(buf: &[u8]) -> usize {
699 for (i, &byte) in buf.iter().enumerate() {
700 if byte & 0xC0 != 0x80 {
701 return i;
702 }
703 }
704 buf.len()
705}
706
707fn decode_utf8_safe(buf: &[u8]) -> (&str, usize) {
708 let start = find_utf8_start_boundary(buf);
709 if start >= buf.len() {
710 return ("", buf.len());
711 }
712
713 let valid_portion = &buf[start..];
714 match std::str::from_utf8(valid_portion) {
715 Ok(s) => (s, start),
716 Err(e) => {
717 let valid_up_to = e.valid_up_to();
718 if valid_up_to == 0 {
719 ("", buf.len())
720 } else {
721 let s = std::str::from_utf8(&valid_portion[..valid_up_to])
722 .expect("valid_up_to guarantees valid UTF-8");
723 (s, start)
724 }
725 }
726 }
727}
728
729impl Iterator for ReverseIterator {
730 type Item = Result<LogEntry>;
731
732 fn next(&mut self) -> Option<Self::Item> {
733 if let Some(error) = self.error.take() {
734 return Some(Err(error));
735 }
736
737 if self.done {
738 return None;
739 }
740
741 if self.buffer.is_empty() {
742 self.fill_buffer();
743 }
744
745 if let Some(entry) = self.buffer.pop_front() {
746 return Some(Ok(entry));
747 }
748
749 self.done = true;
750 None
751 }
752}
753
754#[cfg(test)]
755mod tests {
756 use super::*;
757 use std::io::Write;
758 use tempfile::NamedTempFile;
759
760 fn create_test_log() -> NamedTempFile {
761 let mut file = NamedTempFile::new().unwrap();
762 writeln!(
763 file,
764 "[2024-01-15T10:30:45+0100] [ALPM] installed neovim (0.9.4-1)"
765 )
766 .unwrap();
767 writeln!(
768 file,
769 "[2024-01-15T10:30:46+0100] [ALPM] upgraded linux (6.6.8.arch1-1 -> 6.6.9.arch1-1)"
770 )
771 .unwrap();
772 writeln!(
773 file,
774 "[2024-01-15T10:30:47+0100] [ALPM] removed vim (9.0.2000-1)"
775 )
776 .unwrap();
777 writeln!(
778 file,
779 "[2024-01-15T10:30:48+0100] [PACMAN] starting full system upgrade"
780 )
781 .unwrap();
782 writeln!(
783 file,
784 "[2024-01-15T10:30:49+0100] [ALPM] downgraded mesa (24.0.0-1 -> 23.3.0-1)"
785 )
786 .unwrap();
787 writeln!(
788 file,
789 "[2024-01-15T10:30:50+0100] [ALPM] reinstalled firefox (122.0-1)"
790 )
791 .unwrap();
792 file
793 }
794
795 #[test]
796 fn test_parse_installed() {
797 let line = "[2024-01-15T10:30:45+0100] [ALPM] installed neovim (0.9.4-1)";
798 let entry = LogEntry::parse(line).unwrap();
799 assert_eq!(entry.action, Action::Installed);
800 assert_eq!(entry.package, "neovim");
801 assert_eq!(entry.old_version, None);
802 assert_eq!(entry.new_version, Some("0.9.4-1".to_string()));
803 }
804
805 #[test]
806 fn test_parse_upgraded() {
807 let line =
808 "[2024-01-15T10:30:46+0100] [ALPM] upgraded linux (6.6.8.arch1-1 -> 6.6.9.arch1-1)";
809 let entry = LogEntry::parse(line).unwrap();
810 assert_eq!(entry.action, Action::Upgraded);
811 assert_eq!(entry.package, "linux");
812 assert_eq!(entry.old_version, Some("6.6.8.arch1-1".to_string()));
813 assert_eq!(entry.new_version, Some("6.6.9.arch1-1".to_string()));
814 }
815
816 #[test]
817 fn test_parse_removed() {
818 let line = "[2024-01-15T10:30:47+0100] [ALPM] removed vim (9.0.2000-1)";
819 let entry = LogEntry::parse(line).unwrap();
820 assert_eq!(entry.action, Action::Removed);
821 assert_eq!(entry.package, "vim");
822 assert_eq!(entry.old_version, Some("9.0.2000-1".to_string()));
823 assert_eq!(entry.new_version, None);
824 }
825
826 #[test]
827 fn test_parse_downgraded() {
828 let line = "[2024-01-15T10:30:49+0100] [ALPM] downgraded mesa (24.0.0-1 -> 23.3.0-1)";
829 let entry = LogEntry::parse(line).unwrap();
830 assert_eq!(entry.action, Action::Downgraded);
831 assert_eq!(entry.package, "mesa");
832 assert_eq!(entry.old_version, Some("24.0.0-1".to_string()));
833 assert_eq!(entry.new_version, Some("23.3.0-1".to_string()));
834 }
835
836 #[test]
837 fn test_parse_reinstalled() {
838 let line = "[2024-01-15T10:30:50+0100] [ALPM] reinstalled firefox (122.0-1)";
839 let entry = LogEntry::parse(line).unwrap();
840 assert_eq!(entry.action, Action::Reinstalled);
841 assert_eq!(entry.package, "firefox");
842 assert_eq!(entry.old_version, Some("122.0-1".to_string()));
843 assert_eq!(entry.new_version, Some("122.0-1".to_string()));
844 }
845
846 #[test]
847 fn test_pacman_line_ignored() {
848 let line = "[2024-01-15T10:30:48+0100] [PACMAN] starting full system upgrade";
849 assert!(LogEntry::parse(line).is_none());
850 }
851
852 #[test]
853 fn test_forward_iteration() {
854 let file = create_test_log();
855 let entries: Vec<_> = LogReader::open(file.path())
856 .into_iter()
857 .collect::<Result<Vec<_>>>()
858 .unwrap();
859
860 assert_eq!(entries.len(), 5);
861 assert_eq!(entries[0].action, Action::Installed);
862 assert_eq!(entries[4].action, Action::Reinstalled);
863 }
864
865 #[test]
866 fn test_reverse_iteration() {
867 let file = create_test_log();
868 let entries: Vec<_> = LogReader::open(file.path())
869 .reverse()
870 .into_iter()
871 .collect::<Result<Vec<_>>>()
872 .unwrap();
873
874 assert_eq!(entries.len(), 5);
875 assert_eq!(entries[0].action, Action::Reinstalled);
876 assert_eq!(entries[4].action, Action::Installed);
877 }
878
879 #[test]
880 fn test_filter_action() {
881 let file = create_test_log();
882 let entries: Vec<_> = LogReader::open(file.path())
883 .filter_action(Action::Upgraded)
884 .into_iter()
885 .collect::<Result<Vec<_>>>()
886 .unwrap();
887
888 assert_eq!(entries.len(), 1);
889 assert_eq!(entries[0].package, "linux");
890 }
891
892 #[test]
893 fn test_filter_package() {
894 let file = create_test_log();
895 let entries: Vec<_> = LogReader::open(file.path())
896 .filter_package("vim")
897 .into_iter()
898 .collect::<Result<Vec<_>>>()
899 .unwrap();
900
901 assert_eq!(entries.len(), 2);
902 }
903
904 #[test]
905 fn test_take_with_reverse() {
906 let file = create_test_log();
907 let entries: Vec<_> = LogReader::open(file.path())
908 .reverse()
909 .into_iter()
910 .take(2)
911 .collect::<Result<Vec<_>>>()
912 .unwrap();
913
914 assert_eq!(entries.len(), 2);
915 assert_eq!(entries[0].action, Action::Reinstalled);
916 assert_eq!(entries[1].action, Action::Downgraded);
917 }
918
919 #[test]
920 fn test_filter_package_exact() {
921 let file = create_test_log();
922 let entries: Vec<_> = LogReader::open(file.path())
923 .filter_package_exact("vim")
924 .into_iter()
925 .collect::<Result<Vec<_>>>()
926 .unwrap();
927
928 assert_eq!(entries.len(), 1);
929 assert_eq!(entries[0].package, "vim");
930 }
931
932 #[test]
933 fn test_parse_line_function() {
934 let entry = parse_line("[2024-01-15T10:30:45+0100] [ALPM] installed neovim (0.9.4-1)");
935 assert!(entry.is_some());
936 let entry = entry.unwrap();
937 assert_eq!(entry.action, Action::Installed);
938 assert_eq!(entry.package, "neovim");
939 }
940
941 #[test]
942 fn test_action_from_str() {
943 assert_eq!("installed".parse::<Action>().unwrap(), Action::Installed);
944 assert_eq!("removed".parse::<Action>().unwrap(), Action::Removed);
945 assert_eq!("upgraded".parse::<Action>().unwrap(), Action::Upgraded);
946 assert_eq!("downgraded".parse::<Action>().unwrap(), Action::Downgraded);
947 assert_eq!(
948 "reinstalled".parse::<Action>().unwrap(),
949 Action::Reinstalled
950 );
951 assert!("invalid".parse::<Action>().is_err());
952 }
953
954 #[test]
955 fn test_action_display() {
956 assert_eq!(format!("{}", Action::Installed), "installed");
957 assert_eq!(format!("{}", Action::Removed), "removed");
958 assert_eq!(format!("{}", Action::Upgraded), "upgraded");
959 }
960
961 #[test]
962 fn test_filter_since() {
963 let file = create_test_log();
964 let since =
965 DateTime::parse_from_str("2024-01-15T10:30:47+0100", "%Y-%m-%dT%H:%M:%S%z").unwrap();
966 let entries: Vec<_> = LogReader::open(file.path())
967 .since(since)
968 .into_iter()
969 .collect::<Result<Vec<_>>>()
970 .unwrap();
971
972 assert_eq!(entries.len(), 3);
973 assert_eq!(entries[0].package, "vim");
974 assert_eq!(entries[1].package, "mesa");
975 assert_eq!(entries[2].package, "firefox");
976 }
977
978 #[test]
979 fn test_filter_until() {
980 let file = create_test_log();
981 let until =
982 DateTime::parse_from_str("2024-01-15T10:30:46+0100", "%Y-%m-%dT%H:%M:%S%z").unwrap();
983 let entries: Vec<_> = LogReader::open(file.path())
984 .until(until)
985 .into_iter()
986 .collect::<Result<Vec<_>>>()
987 .unwrap();
988
989 assert_eq!(entries.len(), 2);
990 assert_eq!(entries[0].package, "neovim");
991 assert_eq!(entries[1].package, "linux");
992 }
993
994 #[test]
995 fn test_filter_since_until_combined() {
996 let file = create_test_log();
997 let since =
998 DateTime::parse_from_str("2024-01-15T10:30:46+0100", "%Y-%m-%dT%H:%M:%S%z").unwrap();
999 let until =
1000 DateTime::parse_from_str("2024-01-15T10:30:49+0100", "%Y-%m-%dT%H:%M:%S%z").unwrap();
1001 let entries: Vec<_> = LogReader::open(file.path())
1002 .since(since)
1003 .until(until)
1004 .into_iter()
1005 .collect::<Result<Vec<_>>>()
1006 .unwrap();
1007
1008 assert_eq!(entries.len(), 3);
1009 assert_eq!(entries[0].package, "linux");
1010 assert_eq!(entries[1].package, "vim");
1011 assert_eq!(entries[2].package, "mesa");
1012 }
1013
1014 #[cfg(feature = "serde")]
1015 #[test]
1016 fn test_serde_serialization() {
1017 let entry =
1018 parse_line("[2024-01-15T10:30:45+0100] [ALPM] installed neovim (0.9.4-1)").unwrap();
1019 let json = serde_json::to_string(&entry).unwrap();
1020 assert!(json.contains("neovim"));
1021 assert!(json.contains("Installed"));
1022 }
1023
1024 #[test]
1025 fn test_parse_entry_line_types() {
1026 type Check = fn(&Event) -> bool;
1027 let cases: &[(&str, Caller, Check)] = &[
1028 (
1029 "[2024-01-15T10:30:45+0100] [ALPM] installed neovim (0.9.4-1)",
1030 Caller::Alpm,
1031 |e| matches!(e, Event::Operation(o) if o.package == "neovim"),
1032 ),
1033 (
1034 "[2024-01-15T10:30:48+0100] [PACMAN] Running 'pacman -Syu'",
1035 Caller::Pacman,
1036 |e| matches!(e, Event::Command(c) if c == "pacman -Syu"),
1037 ),
1038 (
1039 "[2024-01-15T10:30:48+0100] [PACMAN] starting full system upgrade",
1040 Caller::Pacman,
1041 |e| matches!(e, Event::Other(m) if m == "starting full system upgrade"),
1042 ),
1043 (
1044 "[2024-01-15T10:30:58+0100] [ALPM] transaction started",
1045 Caller::Alpm,
1046 |e| matches!(e, Event::TransactionStart),
1047 ),
1048 (
1049 "[2024-01-15T10:30:59+0100] [ALPM] transaction completed",
1050 Caller::Alpm,
1051 |e| matches!(e, Event::TransactionComplete),
1052 ),
1053 (
1054 "[2024-01-15T10:30:59+0100] [ALPM] running 'gtk-update-icon-cache.hook'...",
1055 Caller::Alpm,
1056 |e| matches!(e, Event::Other(m) if m.contains("hook")),
1057 ),
1058 (
1059 "[2024-01-15T10:31:00+0100] [ALPM-SCRIPTLET] ==> WARNING: check config",
1060 Caller::AlpmScriptlet,
1061 |e| matches!(e, Event::Other(m) if m.contains("WARNING")),
1062 ),
1063 (
1064 "[2024-01-15T10:31:01+0100] [UNKNOWN] something happened",
1065 Caller::Unknown,
1066 |e| matches!(e, Event::Other(_)),
1067 ),
1068 ];
1069
1070 for (line, caller, check) in cases {
1071 let entry = parse_entry(line).unwrap_or_else(|| panic!("failed to parse: {line}"));
1072 assert_eq!(entry.caller, *caller, "caller mismatch for: {line}");
1073 assert!(check(&entry.event), "event mismatch for: {line}");
1074 }
1075 }
1076
1077 #[test]
1078 fn test_parse_entry_rejects_malformed() {
1079 assert!(parse_entry("not a log line").is_none());
1080 assert!(parse_entry("[bad-timestamp] [ALPM] installed x (1-1)").is_none());
1081 }
1082
1083 fn create_transaction_log() -> NamedTempFile {
1084 let mut file = NamedTempFile::new().unwrap();
1085 write!(
1086 file,
1087 "[2024-01-15T10:30:48+0100] [PACMAN] Running 'pacman -Syu'\n\
1088 [2024-01-15T10:30:48+0100] [PACMAN] starting full system upgrade\n\
1089 [2024-01-15T10:30:58+0100] [ALPM] transaction started\n\
1090 [2024-01-15T10:30:59+0100] [ALPM] upgraded linux (6.6.8-1 -> 6.6.9-1)\n\
1091 [2024-01-15T10:31:00+0100] [ALPM] installed neovim (0.9.4-1)\n\
1092 [2024-01-15T10:31:01+0100] [ALPM] transaction completed\n"
1093 )
1094 .unwrap();
1095 file
1096 }
1097
1098 #[test]
1099 fn test_transactions_basic() {
1100 let file = create_transaction_log();
1101 let txns: Vec<_> = LogReader::open(file.path())
1102 .transactions()
1103 .collect::<Result<Vec<_>>>()
1104 .unwrap();
1105
1106 assert_eq!(txns.len(), 1);
1107 let txn = &txns[0];
1108 assert_eq!(txn.command.as_deref(), Some("pacman -Syu"));
1109 assert!(txn.completed.is_some());
1110 assert_eq!(txn.operations.len(), 2);
1111 assert_eq!(txn.operations[0].package, "linux");
1112 assert_eq!(txn.operations[1].package, "neovim");
1113 }
1114
1115 #[test]
1116 fn test_transactions_truncated_log_yields_open() {
1117 let mut file = NamedTempFile::new().unwrap();
1118 write!(
1119 file,
1120 "[2024-01-15T10:30:58+0100] [ALPM] transaction started\n\
1121 [2024-01-15T10:31:00+0100] [ALPM] installed neovim (0.9.4-1)\n"
1122 )
1123 .unwrap();
1124
1125 let txns: Vec<_> = LogReader::open(file.path())
1126 .transactions()
1127 .collect::<Result<Vec<_>>>()
1128 .unwrap();
1129
1130 assert_eq!(txns.len(), 1);
1131 assert_eq!(txns[0].completed, None);
1132 assert_eq!(txns[0].command, None);
1133 assert_eq!(txns[0].operations.len(), 1);
1134 }
1135
1136 #[test]
1137 fn test_command_counts() {
1138 let mut file = NamedTempFile::new().unwrap();
1139 write!(
1140 file,
1141 "[2024-01-15T10:30:48+0100] [PACMAN] Running 'pacman -Syu'\n\
1142 [2024-01-15T11:00:00+0100] [PACMAN] Running 'pacman -Syu'\n\
1143 [2024-01-15T12:00:00+0100] [PACMAN] Running 'pacman -S neovim'\n"
1144 )
1145 .unwrap();
1146
1147 let counts = command_counts(
1148 LogReader::open(file.path())
1149 .entries()
1150 .filter_map(Result::ok),
1151 );
1152
1153 assert_eq!(counts.get("pacman -Syu"), Some(&2));
1154 assert_eq!(counts.get("pacman -S neovim"), Some(&1));
1155 }
1156
1157 #[test]
1158 fn test_entries_yields_all_line_types() {
1159 let file = create_transaction_log();
1160 let entries: Vec<_> = LogReader::open(file.path())
1161 .entries()
1162 .collect::<Result<Vec<_>>>()
1163 .unwrap();
1164
1165 assert_eq!(entries.len(), 6);
1166 assert!(matches!(entries[0].event, Event::Command(_)));
1167 assert!(matches!(entries[2].event, Event::TransactionStart));
1168 }
1169}