Skip to main content

pacman_log/
lib.rs

1//! A Rust library for parsing and querying pacman logs.
2//!
3//! [`LogReader`] iterates package operations ([`LogEntry`]); [`LogReader::entries`]
4//! yields every line type ([`Entry`]) including pacman commands and transaction
5//! markers, and [`LogReader::transactions`] groups operations into commits.
6//!
7//! # Package operations
8//!
9//! ```no_run
10//! use pacman_log::{LogReader, Action};
11//!
12//! // Read recent upgrades
13//! let upgrades: Vec<_> = LogReader::system()
14//!     .filter_action(Action::Upgraded)
15//!     .into_iter()
16//!     .take(10)
17//!     .collect();
18//!
19//! // Read from end of file (most recent first)
20//! let recent: Vec<_> = LogReader::system()
21//!     .reverse()
22//!     .into_iter()
23//!     .take(100)
24//!     .collect();
25//!
26//! // Parse a single line
27//! let entry = pacman_log::parse_line(
28//!     "[2024-01-15T10:30:45+0100] [ALPM] installed neovim (0.9.4-1)"
29//! );
30//! ```
31//!
32//! # Transactions and commands
33//!
34//! ```no_run
35//! use pacman_log::{LogReader, command_counts};
36//!
37//! // Each commit, tagged with the pacman command that triggered it
38//! for txn in LogReader::system().transactions().filter_map(Result::ok) {
39//!     println!("{:?}: {} operations", txn.command, txn.operations.len());
40//! }
41//!
42//! // Most-run commands across the whole log
43//! let counts = command_counts(LogReader::system().entries().filter_map(Result::ok));
44//! ```
45
46use 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
107/// Parse a single pacman log line into a LogEntry.
108///
109/// Returns None if the line is not a valid ALPM package operation.
110pub 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
132/// Split a raw log line into its `(timestamp, caller, message)` parts.
133///
134/// libalpm writes every line as `[timestamp] [caller] message`.
135fn 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/// The component that emitted a log line, taken from the second bracketed field.
193#[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/// A parsed log line of any kind, not just package operations.
214#[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/// What a log line represents.
223#[derive(Debug, Clone, PartialEq, Eq)]
224#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
225pub enum Event {
226    /// A package operation. Carries the same `timestamp` as the enclosing [`Entry`].
227    Operation(LogEntry),
228    /// A pacman invocation, e.g. the command behind `Running 'pacman -Syu'`.
229    Command(String),
230    TransactionStart,
231    TransactionComplete,
232    /// Any other line: hooks, scriptlet output, sync notices, warnings, errors.
233    Other(String),
234}
235
236/// Parse any pacman log line into an [`Entry`], not just package operations.
237///
238/// Returns None if the line is not a well-formed `[timestamp] [caller] message`.
239pub 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    /// Create a log reader for the file at the specified path.
341    ///
342    /// The file is opened when iteration begins. If the file does not exist
343    /// or is not readable, the first iteration will return an error.
344    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    /// Create a log reader for the system pacman log at /var/log/pacman.log.
353    pub fn system() -> Self {
354        Self::open(DEFAULT_LOG_PATH)
355    }
356
357    /// Read entries in reverse order (most recent first).
358    #[must_use]
359    pub fn reverse(mut self) -> Self {
360        self.reverse = true;
361        self
362    }
363
364    /// Filter entries by action type.
365    #[must_use]
366    pub fn filter_action(mut self, action: Action) -> Self {
367        self.filter.action = Some(action);
368        self
369    }
370
371    /// Filter entries by package name (substring match).
372    #[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    /// Filter entries by exact package name.
379    #[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    /// Filter entries since the given timestamp (inclusive).
386    #[must_use]
387    pub fn since(mut self, timestamp: DateTime<FixedOffset>) -> Self {
388        self.filter.since = Some(timestamp);
389        self
390    }
391
392    /// Filter entries until the given timestamp (inclusive).
393    #[must_use]
394    pub fn until(mut self, timestamp: DateTime<FixedOffset>) -> Self {
395        self.filter.until = Some(timestamp);
396        self
397    }
398
399    /// Iterate over every log line as an [`Entry`], in forward order.
400    ///
401    /// Unlike the [`IntoIterator`] impl, this yields all line types (commands,
402    /// transaction markers, hook and scriptlet output) and ignores the filters.
403    pub fn entries(self) -> impl Iterator<Item = Result<Entry>> {
404        EntryIterator::new(self.path)
405    }
406
407    /// Group package operations into transactions, in forward order.
408    ///
409    /// Each [`Transaction`] is delimited by the `transaction started` and
410    /// `transaction completed` markers and tagged with the most recent pacman
411    /// command logged before it. A transaction left open by a truncated log is
412    /// still yielded, with `completed` set to `None`.
413    pub fn transactions(self) -> impl Iterator<Item = Result<Transaction>> {
414        TransactionIterator::new(EntryIterator::new(self.path))
415    }
416}
417
418/// A single pacman commit: the operations applied between a `transaction
419/// started` and `transaction completed` marker.
420#[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
429/// Tally how often each pacman command was run across a stream of entries.
430///
431/// Drop errors first, e.g. `command_counts(reader.entries().filter_map(Result::ok))`.
432pub 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}