use chrono::{DateTime, FixedOffset};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
use std::fs::File;
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
pub const DEFAULT_LOG_PATH: &str = "/var/log/pacman.log";
const CHUNK_SIZE: usize = 64 * 1024;
pub type Error = std::io::Error;
pub type Result<T> = std::io::Result<T>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum Action {
Installed,
Removed,
Upgraded,
Downgraded,
Reinstalled,
}
impl std::fmt::Display for Action {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Action::Installed => write!(f, "installed"),
Action::Removed => write!(f, "removed"),
Action::Upgraded => write!(f, "upgraded"),
Action::Downgraded => write!(f, "downgraded"),
Action::Reinstalled => write!(f, "reinstalled"),
}
}
}
impl std::str::FromStr for Action {
type Err = ();
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"installed" => Ok(Action::Installed),
"removed" => Ok(Action::Removed),
"upgraded" => Ok(Action::Upgraded),
"downgraded" => Ok(Action::Downgraded),
"reinstalled" => Ok(Action::Reinstalled),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct LogEntry {
pub timestamp: DateTime<FixedOffset>,
pub action: Action,
pub package: String,
pub old_version: Option<String>,
pub new_version: Option<String>,
}
pub fn parse_line(line: &str) -> Option<LogEntry> {
LogEntry::parse(line)
}
impl LogEntry {
fn parse(line: &str) -> Option<Self> {
let (timestamp_str, caller, msg) = split_leader(line)?;
if caller != "ALPM" {
return None;
}
let (action, package, old_version, new_version) = parse_action(msg.trim())?;
let timestamp = parse_timestamp(timestamp_str)?;
Some(LogEntry {
timestamp,
action,
package,
old_version,
new_version,
})
}
}
fn split_leader(line: &str) -> Option<(&str, &str, &str)> {
let line = line.strip_prefix('[')?;
let ts_end = line.find(']')?;
let timestamp = &line[..ts_end];
let rest = line.get(ts_end + 2..)?;
let rest = rest.strip_prefix('[')?;
let caller_end = rest.find(']')?;
let caller = &rest[..caller_end];
let msg = rest.get(caller_end + 2..)?;
Some((timestamp, caller, msg))
}
fn parse_timestamp(s: &str) -> Option<DateTime<FixedOffset>> {
DateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%z").ok()
}
fn parse_action(s: &str) -> Option<(Action, String, Option<String>, Option<String>)> {
let parts: Vec<&str> = s.splitn(2, ' ').collect();
if parts.len() < 2 {
return None;
}
let action: Action = parts[0].parse().ok()?;
let rest = parts[1];
let paren_start = rest.find('(')?;
let paren_end = rest.rfind(')')?;
let package = rest[..paren_start].trim().to_string();
let version_part = &rest[paren_start + 1..paren_end];
match action {
Action::Upgraded | Action::Downgraded => {
if let Some(arrow_pos) = version_part.find(" -> ") {
let old_version = version_part[..arrow_pos].trim().to_string();
let new_version = version_part[arrow_pos + 4..].trim().to_string();
Some((action, package, Some(old_version), Some(new_version)))
} else {
Some((action, package, None, None))
}
}
Action::Installed => {
let version = version_part.trim().to_string();
Some((action, package, None, Some(version)))
}
Action::Removed => {
let version = version_part.trim().to_string();
Some((action, package, Some(version), None))
}
Action::Reinstalled => {
let version = version_part.trim().to_string();
Some((action, package, Some(version.clone()), Some(version)))
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum Caller {
Pacman,
Alpm,
AlpmScriptlet,
Unknown,
}
impl Caller {
fn from_field(s: &str) -> Self {
match s {
"PACMAN" => Caller::Pacman,
"ALPM" => Caller::Alpm,
"ALPM-SCRIPTLET" => Caller::AlpmScriptlet,
_ => Caller::Unknown,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Entry {
pub timestamp: DateTime<FixedOffset>,
pub caller: Caller,
pub event: Event,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum Event {
Operation(LogEntry),
Command(String),
TransactionStart,
TransactionComplete,
Other(String),
}
pub fn parse_entry(line: &str) -> Option<Entry> {
let (timestamp_str, caller_field, msg) = split_leader(line)?;
let timestamp = parse_timestamp(timestamp_str)?;
let caller = Caller::from_field(caller_field);
let msg = msg.trim();
let event = if msg == "transaction started" {
Event::TransactionStart
} else if msg == "transaction completed" {
Event::TransactionComplete
} else if let Some(cmd) = (caller == Caller::Pacman)
.then(|| {
msg.strip_prefix("Running '")
.and_then(|s| s.strip_suffix('\''))
})
.flatten()
{
Event::Command(cmd.to_string())
} else if let Some(op) = (caller == Caller::Alpm)
.then(|| parse_action(msg))
.flatten()
{
let (action, package, old_version, new_version) = op;
Event::Operation(LogEntry {
timestamp,
action,
package,
old_version,
new_version,
})
} else {
Event::Other(msg.to_string())
};
Some(Entry {
timestamp,
caller,
event,
})
}
#[derive(Debug, Clone)]
pub(crate) struct Filter {
action: Option<Action>,
package: Option<String>,
package_exact: Option<String>,
since: Option<DateTime<FixedOffset>>,
until: Option<DateTime<FixedOffset>>,
}
impl Filter {
pub fn new() -> Self {
Filter {
action: None,
package: None,
package_exact: None,
since: None,
until: None,
}
}
fn matches(&self, entry: &LogEntry) -> bool {
if self.action.is_some_and(|a| a != entry.action) {
return false;
}
if self
.package
.as_ref()
.is_some_and(|p| !entry.package.contains(p.as_str()))
{
return false;
}
if self
.package_exact
.as_ref()
.is_some_and(|p| &entry.package != p)
{
return false;
}
if self.since.is_some_and(|s| entry.timestamp < s) {
return false;
}
if self.until.is_some_and(|u| entry.timestamp > u) {
return false;
}
true
}
}
pub struct LogReader {
path: PathBuf,
reverse: bool,
filter: Filter,
}
impl LogReader {
pub fn open<P: AsRef<Path>>(path: P) -> Self {
LogReader {
path: path.as_ref().to_path_buf(),
reverse: false,
filter: Filter::new(),
}
}
pub fn system() -> Self {
Self::open(DEFAULT_LOG_PATH)
}
#[must_use]
pub fn reverse(mut self) -> Self {
self.reverse = true;
self
}
#[must_use]
pub fn filter_action(mut self, action: Action) -> Self {
self.filter.action = Some(action);
self
}
#[must_use]
pub fn filter_package(mut self, package: &str) -> Self {
self.filter.package = Some(package.to_string());
self
}
#[must_use]
pub fn filter_package_exact(mut self, package: &str) -> Self {
self.filter.package_exact = Some(package.to_string());
self
}
#[must_use]
pub fn since(mut self, timestamp: DateTime<FixedOffset>) -> Self {
self.filter.since = Some(timestamp);
self
}
#[must_use]
pub fn until(mut self, timestamp: DateTime<FixedOffset>) -> Self {
self.filter.until = Some(timestamp);
self
}
pub fn entries(self) -> impl Iterator<Item = Result<Entry>> {
EntryIterator::new(self.path)
}
pub fn transactions(self) -> impl Iterator<Item = Result<Transaction>> {
TransactionIterator::new(EntryIterator::new(self.path))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Transaction {
pub command: Option<String>,
pub started: DateTime<FixedOffset>,
pub completed: Option<DateTime<FixedOffset>>,
pub operations: Vec<LogEntry>,
}
pub fn command_counts(entries: impl Iterator<Item = Entry>) -> HashMap<String, usize> {
let mut counts = HashMap::new();
for entry in entries {
if let Event::Command(cmd) = entry.event {
*counts.entry(cmd).or_insert(0) += 1;
}
}
counts
}
impl IntoIterator for LogReader {
type Item = Result<LogEntry>;
type IntoIter = Box<dyn Iterator<Item = Result<LogEntry>>>;
fn into_iter(self) -> Self::IntoIter {
if self.reverse {
Box::new(ReverseIterator::new(self.path, self.filter))
} else {
Box::new(ForwardIterator::new(self.path, self.filter))
}
}
}
struct ForwardIterator {
reader: Option<BufReader<File>>,
filter: Filter,
error: Option<Error>,
}
impl ForwardIterator {
fn new(path: PathBuf, filter: Filter) -> Self {
match File::open(&path) {
Ok(file) => ForwardIterator {
reader: Some(BufReader::new(file)),
filter,
error: None,
},
Err(e) => ForwardIterator {
reader: None,
filter,
error: Some(e),
},
}
}
}
impl Iterator for ForwardIterator {
type Item = Result<LogEntry>;
fn next(&mut self) -> Option<Self::Item> {
if let Some(error) = self.error.take() {
return Some(Err(error));
}
let reader = self.reader.as_mut()?;
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => return None,
Ok(_) => {
if let Some(entry) = LogEntry::parse(&line).filter(|e| self.filter.matches(e)) {
return Some(Ok(entry));
}
}
Err(e) => {
return Some(Err(e));
}
}
}
}
}
struct EntryIterator {
reader: Option<BufReader<File>>,
error: Option<Error>,
}
impl EntryIterator {
fn new(path: PathBuf) -> Self {
match File::open(&path) {
Ok(file) => EntryIterator {
reader: Some(BufReader::new(file)),
error: None,
},
Err(e) => EntryIterator {
reader: None,
error: Some(e),
},
}
}
}
impl Iterator for EntryIterator {
type Item = Result<Entry>;
fn next(&mut self) -> Option<Self::Item> {
if let Some(error) = self.error.take() {
return Some(Err(error));
}
let reader = self.reader.as_mut()?;
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => return None,
Ok(_) => {
if let Some(entry) = parse_entry(&line) {
return Some(Ok(entry));
}
}
Err(e) => return Some(Err(e)),
}
}
}
}
struct TransactionIterator<I> {
entries: I,
last_command: Option<String>,
}
impl<I> TransactionIterator<I> {
fn new(entries: I) -> Self {
TransactionIterator {
entries,
last_command: None,
}
}
}
impl<I: Iterator<Item = Result<Entry>>> Iterator for TransactionIterator<I> {
type Item = Result<Transaction>;
fn next(&mut self) -> Option<Self::Item> {
let mut current: Option<Transaction> = None;
for entry in self.entries.by_ref() {
let entry = match entry {
Ok(e) => e,
Err(e) => return Some(Err(e)),
};
match entry.event {
Event::Command(cmd) => self.last_command = Some(cmd),
Event::TransactionStart => {
current = Some(Transaction {
command: self.last_command.take(),
started: entry.timestamp,
completed: None,
operations: Vec::new(),
});
}
Event::TransactionComplete => {
if let Some(mut txn) = current.take() {
txn.completed = Some(entry.timestamp);
return Some(Ok(txn));
}
}
Event::Operation(op) => {
if let Some(txn) = current.as_mut() {
txn.operations.push(op);
}
}
Event::Other(_) => {}
}
}
current.map(Ok)
}
}
struct ReverseIterator {
file: Option<File>,
pos: u64,
leftover: String,
incomplete_utf8: Vec<u8>,
buffer: VecDeque<LogEntry>,
filter: Filter,
error: Option<Error>,
done: bool,
}
impl ReverseIterator {
fn new(path: PathBuf, filter: Filter) -> Self {
match File::open(&path) {
Ok(file) => {
let pos = file.metadata().map(|m| m.len()).unwrap_or(0);
ReverseIterator {
file: Some(file),
pos,
leftover: String::new(),
incomplete_utf8: Vec::new(),
buffer: VecDeque::new(),
filter,
error: None,
done: false,
}
}
Err(e) => ReverseIterator {
file: None,
pos: 0,
leftover: String::new(),
incomplete_utf8: Vec::new(),
buffer: VecDeque::new(),
filter,
error: Some(e),
done: true,
},
}
}
fn fill_buffer(&mut self) -> Option<()> {
let file = self.file.as_mut()?;
while self.buffer.is_empty() && self.pos > 0 {
let chunk_size = std::cmp::min(self.pos, CHUNK_SIZE as u64);
self.pos -= chunk_size;
if file.seek(SeekFrom::Start(self.pos)).is_err() {
return None;
}
let mut buf = vec![0u8; chunk_size as usize];
if file.read_exact(&mut buf).is_err() {
return None;
}
if !self.incomplete_utf8.is_empty() {
buf.extend(std::mem::take(&mut self.incomplete_utf8));
}
let (valid_str, incomplete_start) = decode_utf8_safe(&buf);
if incomplete_start > 0 {
self.incomplete_utf8 = buf[..incomplete_start].to_vec();
}
let combined = format!("{}{}", valid_str, self.leftover);
let mut lines: Vec<&str> = combined.lines().collect();
if self.pos > 0 && !lines.is_empty() {
self.leftover = lines.remove(0).to_string();
} else {
self.leftover.clear();
}
for line in lines.into_iter().rev() {
if let Some(entry) = LogEntry::parse(line).filter(|e| self.filter.matches(e)) {
self.buffer.push_back(entry);
}
}
}
if self.pos == 0 && !self.leftover.is_empty() && self.buffer.is_empty() {
if let Some(entry) = LogEntry::parse(&self.leftover).filter(|e| self.filter.matches(e))
{
self.buffer.push_back(entry);
}
self.leftover.clear();
}
Some(())
}
}
fn find_utf8_start_boundary(buf: &[u8]) -> usize {
for (i, &byte) in buf.iter().enumerate() {
if byte & 0xC0 != 0x80 {
return i;
}
}
buf.len()
}
fn decode_utf8_safe(buf: &[u8]) -> (&str, usize) {
let start = find_utf8_start_boundary(buf);
if start >= buf.len() {
return ("", buf.len());
}
let valid_portion = &buf[start..];
match std::str::from_utf8(valid_portion) {
Ok(s) => (s, start),
Err(e) => {
let valid_up_to = e.valid_up_to();
if valid_up_to == 0 {
("", buf.len())
} else {
let s = std::str::from_utf8(&valid_portion[..valid_up_to])
.expect("valid_up_to guarantees valid UTF-8");
(s, start)
}
}
}
}
impl Iterator for ReverseIterator {
type Item = Result<LogEntry>;
fn next(&mut self) -> Option<Self::Item> {
if let Some(error) = self.error.take() {
return Some(Err(error));
}
if self.done {
return None;
}
if self.buffer.is_empty() {
self.fill_buffer();
}
if let Some(entry) = self.buffer.pop_front() {
return Some(Ok(entry));
}
self.done = true;
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn create_test_log() -> NamedTempFile {
let mut file = NamedTempFile::new().unwrap();
writeln!(
file,
"[2024-01-15T10:30:45+0100] [ALPM] installed neovim (0.9.4-1)"
)
.unwrap();
writeln!(
file,
"[2024-01-15T10:30:46+0100] [ALPM] upgraded linux (6.6.8.arch1-1 -> 6.6.9.arch1-1)"
)
.unwrap();
writeln!(
file,
"[2024-01-15T10:30:47+0100] [ALPM] removed vim (9.0.2000-1)"
)
.unwrap();
writeln!(
file,
"[2024-01-15T10:30:48+0100] [PACMAN] starting full system upgrade"
)
.unwrap();
writeln!(
file,
"[2024-01-15T10:30:49+0100] [ALPM] downgraded mesa (24.0.0-1 -> 23.3.0-1)"
)
.unwrap();
writeln!(
file,
"[2024-01-15T10:30:50+0100] [ALPM] reinstalled firefox (122.0-1)"
)
.unwrap();
file
}
#[test]
fn test_parse_installed() {
let line = "[2024-01-15T10:30:45+0100] [ALPM] installed neovim (0.9.4-1)";
let entry = LogEntry::parse(line).unwrap();
assert_eq!(entry.action, Action::Installed);
assert_eq!(entry.package, "neovim");
assert_eq!(entry.old_version, None);
assert_eq!(entry.new_version, Some("0.9.4-1".to_string()));
}
#[test]
fn test_parse_upgraded() {
let line =
"[2024-01-15T10:30:46+0100] [ALPM] upgraded linux (6.6.8.arch1-1 -> 6.6.9.arch1-1)";
let entry = LogEntry::parse(line).unwrap();
assert_eq!(entry.action, Action::Upgraded);
assert_eq!(entry.package, "linux");
assert_eq!(entry.old_version, Some("6.6.8.arch1-1".to_string()));
assert_eq!(entry.new_version, Some("6.6.9.arch1-1".to_string()));
}
#[test]
fn test_parse_removed() {
let line = "[2024-01-15T10:30:47+0100] [ALPM] removed vim (9.0.2000-1)";
let entry = LogEntry::parse(line).unwrap();
assert_eq!(entry.action, Action::Removed);
assert_eq!(entry.package, "vim");
assert_eq!(entry.old_version, Some("9.0.2000-1".to_string()));
assert_eq!(entry.new_version, None);
}
#[test]
fn test_parse_downgraded() {
let line = "[2024-01-15T10:30:49+0100] [ALPM] downgraded mesa (24.0.0-1 -> 23.3.0-1)";
let entry = LogEntry::parse(line).unwrap();
assert_eq!(entry.action, Action::Downgraded);
assert_eq!(entry.package, "mesa");
assert_eq!(entry.old_version, Some("24.0.0-1".to_string()));
assert_eq!(entry.new_version, Some("23.3.0-1".to_string()));
}
#[test]
fn test_parse_reinstalled() {
let line = "[2024-01-15T10:30:50+0100] [ALPM] reinstalled firefox (122.0-1)";
let entry = LogEntry::parse(line).unwrap();
assert_eq!(entry.action, Action::Reinstalled);
assert_eq!(entry.package, "firefox");
assert_eq!(entry.old_version, Some("122.0-1".to_string()));
assert_eq!(entry.new_version, Some("122.0-1".to_string()));
}
#[test]
fn test_pacman_line_ignored() {
let line = "[2024-01-15T10:30:48+0100] [PACMAN] starting full system upgrade";
assert!(LogEntry::parse(line).is_none());
}
#[test]
fn test_forward_iteration() {
let file = create_test_log();
let entries: Vec<_> = LogReader::open(file.path())
.into_iter()
.collect::<Result<Vec<_>>>()
.unwrap();
assert_eq!(entries.len(), 5);
assert_eq!(entries[0].action, Action::Installed);
assert_eq!(entries[4].action, Action::Reinstalled);
}
#[test]
fn test_reverse_iteration() {
let file = create_test_log();
let entries: Vec<_> = LogReader::open(file.path())
.reverse()
.into_iter()
.collect::<Result<Vec<_>>>()
.unwrap();
assert_eq!(entries.len(), 5);
assert_eq!(entries[0].action, Action::Reinstalled);
assert_eq!(entries[4].action, Action::Installed);
}
#[test]
fn test_filter_action() {
let file = create_test_log();
let entries: Vec<_> = LogReader::open(file.path())
.filter_action(Action::Upgraded)
.into_iter()
.collect::<Result<Vec<_>>>()
.unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].package, "linux");
}
#[test]
fn test_filter_package() {
let file = create_test_log();
let entries: Vec<_> = LogReader::open(file.path())
.filter_package("vim")
.into_iter()
.collect::<Result<Vec<_>>>()
.unwrap();
assert_eq!(entries.len(), 2);
}
#[test]
fn test_take_with_reverse() {
let file = create_test_log();
let entries: Vec<_> = LogReader::open(file.path())
.reverse()
.into_iter()
.take(2)
.collect::<Result<Vec<_>>>()
.unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].action, Action::Reinstalled);
assert_eq!(entries[1].action, Action::Downgraded);
}
#[test]
fn test_filter_package_exact() {
let file = create_test_log();
let entries: Vec<_> = LogReader::open(file.path())
.filter_package_exact("vim")
.into_iter()
.collect::<Result<Vec<_>>>()
.unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].package, "vim");
}
#[test]
fn test_parse_line_function() {
let entry = parse_line("[2024-01-15T10:30:45+0100] [ALPM] installed neovim (0.9.4-1)");
assert!(entry.is_some());
let entry = entry.unwrap();
assert_eq!(entry.action, Action::Installed);
assert_eq!(entry.package, "neovim");
}
#[test]
fn test_action_from_str() {
assert_eq!("installed".parse::<Action>().unwrap(), Action::Installed);
assert_eq!("removed".parse::<Action>().unwrap(), Action::Removed);
assert_eq!("upgraded".parse::<Action>().unwrap(), Action::Upgraded);
assert_eq!("downgraded".parse::<Action>().unwrap(), Action::Downgraded);
assert_eq!(
"reinstalled".parse::<Action>().unwrap(),
Action::Reinstalled
);
assert!("invalid".parse::<Action>().is_err());
}
#[test]
fn test_action_display() {
assert_eq!(format!("{}", Action::Installed), "installed");
assert_eq!(format!("{}", Action::Removed), "removed");
assert_eq!(format!("{}", Action::Upgraded), "upgraded");
}
#[test]
fn test_filter_since() {
let file = create_test_log();
let since =
DateTime::parse_from_str("2024-01-15T10:30:47+0100", "%Y-%m-%dT%H:%M:%S%z").unwrap();
let entries: Vec<_> = LogReader::open(file.path())
.since(since)
.into_iter()
.collect::<Result<Vec<_>>>()
.unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].package, "vim");
assert_eq!(entries[1].package, "mesa");
assert_eq!(entries[2].package, "firefox");
}
#[test]
fn test_filter_until() {
let file = create_test_log();
let until =
DateTime::parse_from_str("2024-01-15T10:30:46+0100", "%Y-%m-%dT%H:%M:%S%z").unwrap();
let entries: Vec<_> = LogReader::open(file.path())
.until(until)
.into_iter()
.collect::<Result<Vec<_>>>()
.unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].package, "neovim");
assert_eq!(entries[1].package, "linux");
}
#[test]
fn test_filter_since_until_combined() {
let file = create_test_log();
let since =
DateTime::parse_from_str("2024-01-15T10:30:46+0100", "%Y-%m-%dT%H:%M:%S%z").unwrap();
let until =
DateTime::parse_from_str("2024-01-15T10:30:49+0100", "%Y-%m-%dT%H:%M:%S%z").unwrap();
let entries: Vec<_> = LogReader::open(file.path())
.since(since)
.until(until)
.into_iter()
.collect::<Result<Vec<_>>>()
.unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].package, "linux");
assert_eq!(entries[1].package, "vim");
assert_eq!(entries[2].package, "mesa");
}
#[cfg(feature = "serde")]
#[test]
fn test_serde_serialization() {
let entry =
parse_line("[2024-01-15T10:30:45+0100] [ALPM] installed neovim (0.9.4-1)").unwrap();
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("neovim"));
assert!(json.contains("Installed"));
}
#[test]
fn test_parse_entry_line_types() {
type Check = fn(&Event) -> bool;
let cases: &[(&str, Caller, Check)] = &[
(
"[2024-01-15T10:30:45+0100] [ALPM] installed neovim (0.9.4-1)",
Caller::Alpm,
|e| matches!(e, Event::Operation(o) if o.package == "neovim"),
),
(
"[2024-01-15T10:30:48+0100] [PACMAN] Running 'pacman -Syu'",
Caller::Pacman,
|e| matches!(e, Event::Command(c) if c == "pacman -Syu"),
),
(
"[2024-01-15T10:30:48+0100] [PACMAN] starting full system upgrade",
Caller::Pacman,
|e| matches!(e, Event::Other(m) if m == "starting full system upgrade"),
),
(
"[2024-01-15T10:30:58+0100] [ALPM] transaction started",
Caller::Alpm,
|e| matches!(e, Event::TransactionStart),
),
(
"[2024-01-15T10:30:59+0100] [ALPM] transaction completed",
Caller::Alpm,
|e| matches!(e, Event::TransactionComplete),
),
(
"[2024-01-15T10:30:59+0100] [ALPM] running 'gtk-update-icon-cache.hook'...",
Caller::Alpm,
|e| matches!(e, Event::Other(m) if m.contains("hook")),
),
(
"[2024-01-15T10:31:00+0100] [ALPM-SCRIPTLET] ==> WARNING: check config",
Caller::AlpmScriptlet,
|e| matches!(e, Event::Other(m) if m.contains("WARNING")),
),
(
"[2024-01-15T10:31:01+0100] [UNKNOWN] something happened",
Caller::Unknown,
|e| matches!(e, Event::Other(_)),
),
];
for (line, caller, check) in cases {
let entry = parse_entry(line).unwrap_or_else(|| panic!("failed to parse: {line}"));
assert_eq!(entry.caller, *caller, "caller mismatch for: {line}");
assert!(check(&entry.event), "event mismatch for: {line}");
}
}
#[test]
fn test_parse_entry_rejects_malformed() {
assert!(parse_entry("not a log line").is_none());
assert!(parse_entry("[bad-timestamp] [ALPM] installed x (1-1)").is_none());
}
fn create_transaction_log() -> NamedTempFile {
let mut file = NamedTempFile::new().unwrap();
write!(
file,
"[2024-01-15T10:30:48+0100] [PACMAN] Running 'pacman -Syu'\n\
[2024-01-15T10:30:48+0100] [PACMAN] starting full system upgrade\n\
[2024-01-15T10:30:58+0100] [ALPM] transaction started\n\
[2024-01-15T10:30:59+0100] [ALPM] upgraded linux (6.6.8-1 -> 6.6.9-1)\n\
[2024-01-15T10:31:00+0100] [ALPM] installed neovim (0.9.4-1)\n\
[2024-01-15T10:31:01+0100] [ALPM] transaction completed\n"
)
.unwrap();
file
}
#[test]
fn test_transactions_basic() {
let file = create_transaction_log();
let txns: Vec<_> = LogReader::open(file.path())
.transactions()
.collect::<Result<Vec<_>>>()
.unwrap();
assert_eq!(txns.len(), 1);
let txn = &txns[0];
assert_eq!(txn.command.as_deref(), Some("pacman -Syu"));
assert!(txn.completed.is_some());
assert_eq!(txn.operations.len(), 2);
assert_eq!(txn.operations[0].package, "linux");
assert_eq!(txn.operations[1].package, "neovim");
}
#[test]
fn test_transactions_truncated_log_yields_open() {
let mut file = NamedTempFile::new().unwrap();
write!(
file,
"[2024-01-15T10:30:58+0100] [ALPM] transaction started\n\
[2024-01-15T10:31:00+0100] [ALPM] installed neovim (0.9.4-1)\n"
)
.unwrap();
let txns: Vec<_> = LogReader::open(file.path())
.transactions()
.collect::<Result<Vec<_>>>()
.unwrap();
assert_eq!(txns.len(), 1);
assert_eq!(txns[0].completed, None);
assert_eq!(txns[0].command, None);
assert_eq!(txns[0].operations.len(), 1);
}
#[test]
fn test_command_counts() {
let mut file = NamedTempFile::new().unwrap();
write!(
file,
"[2024-01-15T10:30:48+0100] [PACMAN] Running 'pacman -Syu'\n\
[2024-01-15T11:00:00+0100] [PACMAN] Running 'pacman -Syu'\n\
[2024-01-15T12:00:00+0100] [PACMAN] Running 'pacman -S neovim'\n"
)
.unwrap();
let counts = command_counts(
LogReader::open(file.path())
.entries()
.filter_map(Result::ok),
);
assert_eq!(counts.get("pacman -Syu"), Some(&2));
assert_eq!(counts.get("pacman -S neovim"), Some(&1));
}
#[test]
fn test_entries_yields_all_line_types() {
let file = create_transaction_log();
let entries: Vec<_> = LogReader::open(file.path())
.entries()
.collect::<Result<Vec<_>>>()
.unwrap();
assert_eq!(entries.len(), 6);
assert!(matches!(entries[0].event, Event::Command(_)));
assert!(matches!(entries[2].event, Event::TransactionStart));
}
}