use chrono::{DateTime, Local};
use color_eyre::Result;
use csv::WriterBuilder;
use serde::{Deserialize, Serialize};
use std::{
cmp::Reverse,
fs::{self, DirEntry, File, OpenOptions},
path::{Path, PathBuf},
sync::mpsc::Sender,
time::SystemTime,
};
use tracing::{info, warn};
use crate::{
free_launch::free_launch::PROJECT_DIRS, launch_entries::launch_entry::LaunchEntry,
signaling::item_update::ItemUpdate, signaling::request::Request,
};
const MIN_INVOCATION_NUM: usize = 1000;
const MAX_INVOCATION_RETENTION: usize = 60;
const INVOCATION_FILE_PREFIX: &str = "free-launch-invocations-";
const INVOCATION_FILE_SUFFIX: &str = ".csv";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Invocation {
pub timestamp: u64,
pub search_query: String,
pub action: String,
pub item_name: String,
pub item_id: String,
pub item_path: String,
}
impl Invocation {
pub fn new(search_query: &str, action: String, launch_entry: &LaunchEntry) -> Self {
let now: SystemTime = SystemTime::now();
let timestamp = now
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Self {
timestamp,
search_query: search_query.to_owned(),
action,
item_name: launch_entry.name().to_owned(),
item_id: launch_entry.id().map(|i| i.to_owned()).unwrap_or_default(),
item_path: launch_entry
.file_path()
.map(|i| i.to_string_lossy().to_string())
.unwrap_or_default(),
}
}
pub(crate) fn id(&self) -> &str {
&self.item_id
}
pub(crate) fn name(&self) -> &str {
&self.item_name
}
fn cache_file(date: DateTime<Local>) -> PathBuf {
let date_str = date.format("%Y-%m-%d").to_string();
let log_filename = format!(
"{INVOCATION_FILE_PREFIX}{}{INVOCATION_FILE_SUFFIX}",
date_str
);
PROJECT_DIRS.cache_dir().join(log_filename)
}
pub(crate) fn log_entry(
search_query: &str,
action: String,
launch_entry: &LaunchEntry,
) -> Result<()> {
Invocation::new(search_query, action, launch_entry)
.append_to_csv(&Self::cache_file(Local::now()))
}
fn append_to_csv(&self, file_path: &Path) -> Result<()> {
let write_headers = !file_path.exists();
let file = OpenOptions::new()
.append(true)
.create(true)
.open(file_path)?;
let mut wtr = WriterBuilder::new()
.has_headers(write_headers)
.from_writer(file);
wtr.serialize(self)?;
wtr.flush()?;
Ok(())
}
fn cache_files() -> Result<impl Iterator<Item = DirEntry>> {
let cache_files = PROJECT_DIRS.cache_dir();
Ok(fs::read_dir(cache_files)?
.filter_map(Result::ok)
.filter(|entry| match entry.metadata() {
Ok(metadata) => {
metadata.is_file()
&& entry
.file_name()
.to_string_lossy()
.starts_with(INVOCATION_FILE_PREFIX)
}
Err(_) => false,
}))
}
pub(crate) fn load_from_cache(request_sender: Sender<Box<Request>>) -> Result<usize> {
let mut cache_files = Self::cache_files()?.collect::<Vec<DirEntry>>();
cache_files.sort_by_cached_key(|i| Reverse(i.file_name()));
let mut cache_file_iter = cache_files.into_iter();
let cache_files_to_load = cache_file_iter.by_ref().take(MAX_INVOCATION_RETENTION);
let mut invocation_count = 0;
for cache_file in cache_files_to_load {
let mut rdr = csv::Reader::from_reader(File::open(cache_file.path())?);
for result in rdr.deserialize() {
let record: Invocation = result?;
if !record.item_id.is_empty() {
match request_sender.send(Box::new(Request::UpdateIndexItem {
item: ItemUpdate::Invocation(record),
})) {
Ok(_) => invocation_count += 1,
Err(e) => warn!("could not send invocation: {}", e),
}
}
}
if invocation_count >= MIN_INVOCATION_NUM {
break;
}
}
for file_to_delete in cache_file_iter {
if let Err(e) = fs::remove_file(file_to_delete.path()) {
warn!(
"Could not remove cache file {}: {}",
file_to_delete.path().to_string_lossy(),
e
)
}
}
drop(request_sender);
Ok(invocation_count)
}
}
pub(crate) fn load_invocations(request_sender: Sender<Box<Request>>) {
info!("Loading invocations from cache...");
match Invocation::load_from_cache(request_sender) {
Ok(invocation_count) => info!("{} invocations loaded from cache", invocation_count),
Err(e) => warn!("invocation loading failed with the following error: {}", e),
}
}