diff --git a/Cargo.lock b/Cargo.lock index d992b36..7ed98e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,6 +84,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "itoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" + [[package]] name = "lazy_static" version = "1.4.0" @@ -92,9 +98,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.151" +version = "0.2.153" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "302d7ab3130588088d277783b1e2d2e10c9e9e4a16dd9050e6ec93fb3e7048f4" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" [[package]] name = "libnotify" @@ -124,42 +130,103 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + [[package]] name = "pkg-config" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d3587f8a9e599cc7ec2c00e331f71c4e69a5f9a4b8a6efd5b07466b9736f9a" +checksum = "2900ede94e305130c13ddd391e0ab7cbaeb783945ae07a279c268cb05109c6cb" [[package]] -name = "regex" -version = "1.10.2" +name = "proc-macro2" +version = "1.0.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" dependencies = [ - "regex-automata", - "regex-syntax", + "unicode-ident", ] [[package]] -name = "regex-automata" -version = "0.4.3" +name = "quote" +version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" dependencies = [ - "regex-syntax", + "proc-macro2", ] [[package]] -name = "regex-syntax" -version = "0.8.2" +name = "ryu" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" +checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" [[package]] name = "scun" -version = "0.2.1" +version = "0.3.0" dependencies = [ - "lazy_static", "libnotify", - "regex", + "once_cell", + "serde", + "serde_json", + "xdg", +] + +[[package]] +name = "serde" +version = "1.0.196" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.196" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] + +[[package]] +name = "serde_json" +version = "1.0.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69801b70b1c3dac963ecb03a364ba0ceda9cf60c71cfe475e99864759c8b8a79" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "syn" +version = "2.0.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "xdg" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546" diff --git a/Cargo.toml b/Cargo.toml index d85a355..42ac82c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,17 +1,19 @@ [package] name = "scun" -version = "0.2.1" +version = "0.3.0" authors = ["Tim Biermann "] edition = "2021" [dependencies] -lazy_static = "1.4.0" +once_cell = "1.19.0" libnotify = "1.0.3" -regex = { version = "1.10.2", default-features = false } +serde = { version = "1.0.196", features = ["derive"] } +serde_json = "1.0.113" +xdg = "2.5.2" [profile.release] -lto = true -incremental = true +lto = "fat" +incremental = false codegen-units = 1 strip = true panic = "abort" diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..2aea546 --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,101 @@ +use crate::*; +use core::fmt; +use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; +use std::io; +use std::io::Write; + +#[derive(Serialize, Deserialize)] +pub struct CacheData { + pub data: Vec, + timestamp: Option, + db_mod_time: Option, +} + +#[derive(Debug)] +pub enum CacheError { + Io(io::Error), + Serde(serde_json::Error), + SystemTime(std::time::SystemTimeError), +} + +impl fmt::Display for CacheError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + CacheError::Io(e) => write!(f, "IO error: {}", e), + CacheError::Serde(e) => write!(f, "Serialization error: {}", e), + CacheError::SystemTime(e) => write!(f, "System time error: {}", e), + } + } +} + +impl Error for CacheError {} + +impl From for CacheError { + fn from(error: io::Error) -> Self { + CacheError::Io(error) + } +} + +impl From for CacheError { + fn from(error: serde_json::Error) -> Self { + CacheError::Serde(error) + } +} + +impl From for CacheError { + fn from(error: std::time::SystemTimeError) -> Self { + CacheError::SystemTime(error) + } +} + +pub static CACHE_FILE_PATH: Lazy = Lazy::new(|| { + xdg::BaseDirectories::new() + .expect("Failed to create BaseDirectories") + .place_cache_file("scun.json") + .expect("Failed to create cache file path") +}); + +pub fn save_cache_to_file( + cache_path: &Path, + data: &[PackageInfo], + db_mod_time: u64, +) -> Result<(), CacheError> { + let cache_data = CacheData { + data: data.to_owned(), + timestamp: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .ok(), + db_mod_time: Some(db_mod_time), + }; + + let json = serde_json::to_string(&cache_data)?; + let mut file = File::create(cache_path)?; + file.write_all(json.as_bytes())?; + Ok(()) +} + +pub fn is_cache_valid(cache_data: &CacheData) -> bool { + if let Ok(metadata) = std::fs::metadata("/var/lib/pkg/db") { + if let Ok(mod_time) = metadata.modified() { + if let Ok(mod_time_secs) = mod_time.duration_since(UNIX_EPOCH) { + // Check if the stored modification time is older than the current modification time + return cache_data + .db_mod_time + .map_or(false, |db_mod_time| db_mod_time >= mod_time_secs.as_secs()); + } + } + } + false +} + +pub fn read_cache_from_file(cache_path: &Path) -> Result> { + if let Ok(file) = File::open(cache_path) { + let reader = BufReader::new(file); + if let Ok(json) = serde_json::from_reader(reader) { + return Ok(json); + } + } + Err("Cache file not found or invalid".into()) +} diff --git a/src/main.rs b/src/main.rs index 6b6fa12..6f88c76 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,198 +1,89 @@ -use lazy_static::lazy_static; -use regex::Regex; +mod cache; +mod ports; + +use cache::*; +use ports::*; + use std::env; use std::error::Error; use std::fs::File; -use std::io::{BufRead, BufReader}; -use std::path::Path; +use std::io::BufReader; +use std::path::{Path, PathBuf}; use std::process; - -type PackageInfo = (String, Option); - -fn find_ports_in_repositories(package_name: &str) -> Result> { - let file = File::open("/etc/prt-get.conf")?; - let reader = BufReader::new(file); - - let mut prtdirs: Vec = Vec::new(); - - for line in reader.lines().flatten() { - if line.starts_with("prtdir ") { - let prtdir = line.trim(); - // `prtdir ` is 7 characters long - prtdirs.push(prtdir[7..].to_string()); - } - } - - let repository = "N/A".to_string(); - - for repo_name in prtdirs { - let repo_path = format!("/{repo_name}/{package_name}"); - if Path::new(&repo_path).exists() { - // `/usr/ports/` is 11 character long - return Ok(repo_name[11..].to_string()); - } - } - - Ok(repository) -} - -lazy_static! { - static ref VERSION_REGEX: Regex = Regex::new(r#"version=(.+)"#).unwrap(); - static ref RELEASE_REGEX: Regex = Regex::new(r#"release=(.+)"#).unwrap(); -} - -fn extract_pkgfile_version(port_dir: &str) -> Option { - let pkgfile_path = format!("{port_dir}/Pkgfile"); - if !Path::new(&pkgfile_path).exists() { - return None; - } - - let pkgfile_content = std::fs::read_to_string(pkgfile_path).ok()?; - - let version = match VERSION_REGEX.captures(&pkgfile_content) { - Some(captures) => captures.get(1).map(|m| m.as_str()).unwrap_or(""), - None => { - eprintln!("Failed to extract version from Pkgfile for: {port_dir}"); - "" - } - }; - let release = RELEASE_REGEX - .captures(&pkgfile_content)? - .get(1) - .map(|m| m.as_str()) - .unwrap_or(""); - - Some(format!("{version}-{release}")) -} - -fn list_installed_packages(filename: &str) -> Result, Box> { - let file = File::open(filename)?; - let reader = BufReader::with_capacity(1024 * 1024, file); - - let mut packages = Vec::new(); - let mut current_package: Option = None; - - for line in reader.lines() { - let line = line?; - if line.is_empty() { - if let Some(mut package) = current_package.take() { - if let Some(available_version) = - extract_pkgfile_version(&format!("/usr/ports/{}", package.0)) - { - package.1 = Some(available_version); - } - packages.push(package); - } - } else if current_package.is_none() { - current_package = Some((line, None)); - } else { - let package = current_package - .as_mut() - .expect("Error: no package was evaluated"); - if package.1.is_none() { - package.1 = Some(line); - } - } - } - - if let Some(mut package) = current_package { - if let Some(available_version) = - extract_pkgfile_version(&format!("/usr/ports/{}", package.0)) - { - package.1 = Some(available_version); - } - packages.push(package); - } - - Ok(packages) -} +use std::time::{SystemTime, UNIX_EPOCH}; fn notify_mode(output: Vec) -> Result<(), Box> { - if let Err(e) = libnotify::init("scun") { + libnotify::init("scun").map_err(|e| { eprintln!("Failed to initialize libnotify: {}", e); - return Err(e.into()); - } - let notification_body: String = output.join("\n"); - let n = libnotify::Notification::new("Port Updates", ¬ification_body as &str, None); - n.set_timeout(5000); - if let Err(e) = n.show() { + e + })?; + + let notification_body = output.join("\n"); + let notification = libnotify::Notification::new("Port Updates", &*notification_body, None); + notification.set_timeout(5000); + notification.show().map_err(|e| { eprintln!("Failed to show notification: {}", e); - return Err(e.into()); - } - libnotify::uninit(); + e + })?; + libnotify::uninit(); Ok(()) } fn print_mode(output: Vec, submode: Option) -> Result<(), Box> { - if let Some(sub) = submode { - if sub == "-i" || sub == "--icon" { - println!("󰚰 {}", output.len() - 1); - } else if sub == "-l" || sub == "--long" { - for line in output { - println!("{line}"); - } - } else { - println!("{}", output.len() - 1); - } - } else { - println!("{}", output.len() - 1); + match submode.as_deref() { + Some("-i") | Some("--icon") => println!("󰚰 {}", output.len() - 1), + Some("-l") | Some("--long") => output.iter().for_each(|line| println!("{line}")), + Some(_) | None => println!("{}", output.len() - 1), } Ok(()) } fn main() -> Result<(), Box> { - let packages = list_installed_packages("/var/lib/pkg/db")?; let mut output = Vec::new(); - output.push(format!( "{:<20} {:<15} {:<15}", "Port", "Version", "Available" )); - for (name, version) in &packages { - let repository = find_ports_in_repositories(name)?; - let available_version = extract_pkgfile_version(&format!("/usr/ports/{repository}/{name}")) - .unwrap_or("N/A".to_string()); - - if version.as_ref().map_or("unknown", |v| v) != available_version { - output.push(format!( - "{:<20} {:<15} {:<15}", - name, - version.as_ref().map_or("unknown", |v| v), - available_version - )); + match INSTALLED_PACKAGES.lock() { + Ok(packages) => { + packages.iter().for_each(|(name, version)| { + let repository = + find_ports_in_repositories(name).unwrap_or_else(|_| "N/A".to_string()); + let available_version = + extract_pkgfile_version(&format!("/usr/ports/{}/{}", repository, name)) + .unwrap_or_else(|| "N/A".to_string()); + + if version.as_deref().unwrap_or("unknown") != available_version { + output.push(format!( + "{:<20} {:<15} {:<15}", + name, + version.as_deref().unwrap_or("unknown"), + available_version + )); + } + }); + } + Err(e) => { + eprintln!("Could not acquire the lock: {}", e); + return Err(Box::new(e)); } } let args: Vec = env::args().collect(); - if args.len() < 2 { - let message = r#"Usage: scun [notify|print] -notify: send a list via libnotify -print: prints the number of available updates - --icon|-i: adds an icon - --long|-l: prints the whole list"#; - eprintln!("{message}"); + eprintln!("Usage: scun [notify|print]"); process::exit(1); } - let mode = &args[1]; - let submode = match args.len() { - 0..=2 => Option::None, - 3 => Some(args[2].to_string()), - _ => Option::None, - }; - - if mode == "notify" || mode == "n" { - notify_mode(output)?; - } else if mode == "print" || mode == "p" { - print_mode(output, submode)?; - } else { - eprintln!("Invalid mode: {mode}. Use 'notify' or 'print'."); - process::exit(1); + match args[1].as_str() { + "notify" | "n" => notify_mode(output), + "print" | "p" => print_mode(output, args.get(2).cloned()), + _ => { + eprintln!("Invalid mode: {}. Use 'notify' or 'print'.", args[1]); + process::exit(1); + } } - - Ok(()) } diff --git a/src/ports.rs b/src/ports.rs new file mode 100644 index 0000000..bd50b01 --- /dev/null +++ b/src/ports.rs @@ -0,0 +1,110 @@ +use crate::*; +use once_cell::sync::Lazy; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::sync::Mutex; +use std::time::UNIX_EPOCH; + +pub type PackageInfo = (String, Option); + +pub static INSTALLED_PACKAGES: Lazy>> = Lazy::new(|| { + Mutex::new({ + let db_mod_time = fs::metadata("/var/lib/pkg/db") + .map_err(|e| Box::new(e) as Box) + .and_then(|metadata| { + metadata + .modified() + .map_err(|e| Box::new(e) as Box) + }) + .and_then(|mod_time| { + mod_time + .duration_since(UNIX_EPOCH) + .map_err(|e| Box::new(e) as Box) + }) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + + match read_cache_from_file(&CACHE_FILE_PATH) { + Ok(contents) if is_cache_valid(&contents) => contents.data, + _ => fetch_installed_packages(db_mod_time).unwrap_or_else(|e| { + eprintln!("Error fetching installed packages: {}", e); + Vec::new() + }), + } + }) +}); + +fn fetch_installed_packages(db_mod_time: u64) -> Result, Box> { + let packages = list_installed_packages("/var/lib/pkg/db")?; + save_cache_to_file(&CACHE_FILE_PATH, &packages, db_mod_time)?; + Ok(packages) +} + +pub fn find_ports_in_repositories( + package_name: &str, +) -> Result> { + let file = File::open("/etc/prt-get.conf")?; + let reader = BufReader::new(file); + + reader + .lines() + .map_while(Result::ok) + .filter_map(|line| { + line.strip_prefix("prtdir ") + .map(|stripped| stripped.trim().to_string()) + }) + .find_map(|repo_name| { + let repo_path = format!("/{repo_name}/{package_name}"); + if Path::new(&repo_path).exists() { + Some(repo_name[11..].to_string()) + } else { + None + } + }) + .ok_or_else(|| "N/A".to_string().into()) +} + +pub fn extract_pkgfile_version(port_dir: &str) -> Option { + let pkgfile_path = format!("{}/Pkgfile", port_dir); + let pkgfile_content = std::fs::read_to_string(pkgfile_path).ok()?; + + let mut version = None; + let mut release = None; + + for line in pkgfile_content.lines() { + if line.starts_with("version=") { + version = line.split('=').nth(1)?.trim().to_string().into(); + } else if line.starts_with("release=") { + release = line.split('=').nth(1)?.trim().to_string().into(); + } + } + + version.and_then(|v| release.map(|r| format!("{}-{}", v, r))) +} + +fn list_installed_packages(filename: &str) -> Result, CacheError> { + let file = File::open(filename)?; + let reader = BufReader::new(file); + + let mut packages = Vec::new(); + let mut lines_iter = reader.lines().peekable(); + + while let Some(Ok(name)) = lines_iter.next() { + if name.trim().is_empty() { + continue; + } + + if let Some(Ok(version)) = lines_iter.next() { + packages.push((name, Some(version))); + + while lines_iter + .peek() + .map_or(false, |line| !line.as_ref().unwrap().trim().is_empty()) + { + lines_iter.next(); // ignore footprint + } + } + } + + Ok(packages) +}