use chrono::{DateTime, FixedOffset};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::fs::File;
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use thiserror::Error;
pub const DEFAULT_LOG_PATH: &str = "/var/log/pacman.log";
const CHUNK_SIZE: usize = 64 * 1024;
#[derive(Error, Debug)]
pub enum Error {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
#[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(()),
}
}
}
impl Action {
fn parse(s: &str) -> Option<Self> {
s.parse().ok()
}
}
#[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> {
if !line.starts_with('[') {
return None;
}
let timestamp_end = line.find(']')?;
let timestamp_str = &line[1..timestamp_end];
let rest = line.get(timestamp_end + 2..)?;
if !rest.starts_with("[ALPM]") {
return None;
}
let source_end = rest.find(']')?;
let action_str = rest.get(source_end + 2..)?.trim();
if action_str.is_empty() {
return None;
}
let (action, package, old_version, new_version) = parse_action(action_str)?;
let timestamp = parse_timestamp(timestamp_str)?;
Some(LogEntry {
timestamp,
action,
package,
old_version,
new_version,
})
}
}
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::parse(parts[0])?;
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)]
enum ReadDirection {
Forward,
Reverse,
}
#[derive(Debug, Clone)]
pub struct Filter {
action: Option<Action>,
package: Option<String>,
package_exact: Option<String>,
since: Option<DateTime<FixedOffset>>,
until: Option<DateTime<FixedOffset>>,
}
impl Default for Filter {
fn default() -> Self {
Self::new()
}
}
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,
direction: ReadDirection,
filter: Filter,
}
impl LogReader {
pub fn open<P: AsRef<Path>>(path: P) -> Self {
LogReader {
path: path.as_ref().to_path_buf(),
direction: ReadDirection::Forward,
filter: Filter::new(),
}
}
pub fn system() -> Self {
Self::open(DEFAULT_LOG_PATH)
}
#[must_use]
pub fn reverse(mut self) -> Self {
self.direction = ReadDirection::Reverse;
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
}
}
impl IntoIterator for LogReader {
type Item = Result<LogEntry>;
type IntoIter = LogIterator;
fn into_iter(self) -> Self::IntoIter {
LogIterator::new(self.path, self.direction, self.filter)
}
}
pub struct LogIterator {
inner: Box<dyn Iterator<Item = Result<LogEntry>>>,
}
impl LogIterator {
fn new(path: PathBuf, direction: ReadDirection, filter: Filter) -> Self {
let inner: Box<dyn Iterator<Item = Result<LogEntry>>> = match direction {
ReadDirection::Forward => Box::new(ForwardIterator::new(path, filter)),
ReadDirection::Reverse => Box::new(ReverseIterator::new(path, filter)),
};
LogIterator { inner }
}
}
impl Iterator for LogIterator {
type Item = Result<LogEntry>;
fn next(&mut self) -> Option<Self::Item> {
self.inner.next()
}
}
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(Error::Io(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(Error::Io(e)));
}
}
}
}
}
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(Error::Io(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"));
}
}