free-launch 0.2.9

A simple fuzzy launcher written in Rust.
Documentation
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,
};

/// The minimum number of invocations to load for frecency calculations
const MIN_INVOCATION_NUM: usize = 1000;
/// The number of days/files that will be retained for invocation logs
const MAX_INVOCATION_RETENTION: usize = 60;
// TODO need to implement invocation log cleaning

/// The prefix to use for invocation log files
const INVOCATION_FILE_PREFIX: &str = "free-launch-invocations-";
/// The suffix to use for invocation log files
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 {
        // Setup timestamp
        // TODO make this more concise, this was originally generated by Claude and seems overly verbose
        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 {
        // Format date as YYYY-MM-DD
        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)
        // TODO need to only return valid files
    }

    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<()> {
        // Check if the file already exists so we don't write headers more than once
        let write_headers = !file_path.exists();

        // Open the file in append mode.
        // If the file does not exist, create it.
        let file = OpenOptions::new()
            .append(true)
            .create(true)
            .open(file_path)?;

        // Create a CSV writer that writes to the file.
        // Note: When appending, you might want to avoid writing headers if they already exist.
        let mut wtr = WriterBuilder::new()
            .has_headers(write_headers)
            .from_writer(file);

        // Write each record in append mode
        wtr.serialize(self)?;

        // Make sure all data is flushed to the file
        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> {
        // return all files in an iterator
        let mut cache_files = Self::cache_files()?.collect::<Vec<DirEntry>>();

        // sort the files
        cache_files.sort_by_cached_key(|i| Reverse(i.file_name()));
        let mut cache_file_iter = cache_files.into_iter();

        // take the retention num
        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 {
            // Create a CSV reader builder
            let mut rdr = csv::Reader::from_reader(File::open(cache_file.path())?);

            // Deserialize each record into our struct
            for result in rdr.deserialize() {
                // TODO might want a custom implementation of deserialize here
                let record: Invocation = result?;

                // Ensure that invocations are valid before actually loading them
                // Only load invocations with IDs
                // TODO replace with an `is_valid` method or ensure valid invocations on parse
                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),
                    }
                }
            }

            // exit if we have enough invocations or have read all of the cache files
            // TODO need to bail if we have checked more than MAX_INVOCATION_RETENTION files
            if invocation_count >= MIN_INVOCATION_NUM {
                break;
            }
        }

        // remove extra cache files
        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>>) {
    // Load invocations from cache
    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),
    }
}