Skip to main content

free_launch/model/
invocation.rs

1use chrono::{DateTime, Local};
2use color_eyre::Result;
3use csv::WriterBuilder;
4use serde::{Deserialize, Serialize};
5use std::{
6    cmp::Reverse,
7    fs::{self, DirEntry, File, OpenOptions},
8    path::{Path, PathBuf},
9    sync::mpsc::Sender,
10    time::SystemTime,
11};
12use tracing::{info, warn};
13
14use crate::{
15    free_launch::free_launch::PROJECT_DIRS, launch_entries::launch_entry::LaunchEntry,
16    signaling::item_update::ItemUpdate, signaling::request::Request,
17};
18
19/// The minimum number of invocations to load for frecency calculations
20const MIN_INVOCATION_NUM: usize = 1000;
21/// The number of days/files that will be retained for invocation logs
22const MAX_INVOCATION_RETENTION: usize = 60;
23// TODO need to implement invocation log cleaning
24
25/// The prefix to use for invocation log files
26const INVOCATION_FILE_PREFIX: &str = "free-launch-invocations-";
27/// The suffix to use for invocation log files
28const INVOCATION_FILE_SUFFIX: &str = ".csv";
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct Invocation {
32    pub timestamp: u64,
33    pub search_query: String,
34    pub action: String,
35    pub item_name: String,
36    pub item_id: String,
37    pub item_path: String,
38}
39
40impl Invocation {
41    pub fn new(search_query: &str, action: String, launch_entry: &LaunchEntry) -> Self {
42        // Setup timestamp
43        // TODO make this more concise, this was originally generated by Claude and seems overly verbose
44        let now: SystemTime = SystemTime::now();
45        let timestamp = now
46            .duration_since(SystemTime::UNIX_EPOCH)
47            .unwrap_or_default()
48            .as_secs();
49
50        Self {
51            timestamp,
52            search_query: search_query.to_owned(),
53            action,
54            item_name: launch_entry.name().to_owned(),
55            item_id: launch_entry.id().map(|i| i.to_owned()).unwrap_or_default(),
56            item_path: launch_entry
57                .file_path()
58                .map(|i| i.to_string_lossy().to_string())
59                .unwrap_or_default(),
60        }
61    }
62
63    pub(crate) fn id(&self) -> &str {
64        &self.item_id
65    }
66
67    pub(crate) fn name(&self) -> &str {
68        &self.item_name
69    }
70
71    fn cache_file(date: DateTime<Local>) -> PathBuf {
72        // Format date as YYYY-MM-DD
73        let date_str = date.format("%Y-%m-%d").to_string();
74        let log_filename = format!(
75            "{INVOCATION_FILE_PREFIX}{}{INVOCATION_FILE_SUFFIX}",
76            date_str
77        );
78        PROJECT_DIRS.cache_dir().join(log_filename)
79        // TODO need to only return valid files
80    }
81
82    pub(crate) fn log_entry(
83        search_query: &str,
84        action: String,
85        launch_entry: &LaunchEntry,
86    ) -> Result<()> {
87        Invocation::new(search_query, action, launch_entry)
88            .append_to_csv(&Self::cache_file(Local::now()))
89    }
90
91    fn append_to_csv(&self, file_path: &Path) -> Result<()> {
92        // Check if the file already exists so we don't write headers more than once
93        let write_headers = !file_path.exists();
94
95        // Open the file in append mode.
96        // If the file does not exist, create it.
97        let file = OpenOptions::new()
98            .append(true)
99            .create(true)
100            .open(file_path)?;
101
102        // Create a CSV writer that writes to the file.
103        // Note: When appending, you might want to avoid writing headers if they already exist.
104        let mut wtr = WriterBuilder::new()
105            .has_headers(write_headers)
106            .from_writer(file);
107
108        // Write each record in append mode
109        wtr.serialize(self)?;
110
111        // Make sure all data is flushed to the file
112        wtr.flush()?;
113        Ok(())
114    }
115
116    fn cache_files() -> Result<impl Iterator<Item = DirEntry>> {
117        let cache_files = PROJECT_DIRS.cache_dir();
118        Ok(fs::read_dir(cache_files)?
119            .filter_map(Result::ok)
120            .filter(|entry| match entry.metadata() {
121                Ok(metadata) => {
122                    metadata.is_file()
123                        && entry
124                            .file_name()
125                            .to_string_lossy()
126                            .starts_with(INVOCATION_FILE_PREFIX)
127                }
128                Err(_) => false,
129            }))
130    }
131
132    pub(crate) fn load_from_cache(request_sender: Sender<Box<Request>>) -> Result<usize> {
133        // return all files in an iterator
134        let mut cache_files = Self::cache_files()?.collect::<Vec<DirEntry>>();
135
136        // sort the files
137        cache_files.sort_by_cached_key(|i| Reverse(i.file_name()));
138        let mut cache_file_iter = cache_files.into_iter();
139
140        // take the retention num
141        let cache_files_to_load = cache_file_iter.by_ref().take(MAX_INVOCATION_RETENTION);
142
143        let mut invocation_count = 0;
144
145        for cache_file in cache_files_to_load {
146            // Create a CSV reader builder
147            let mut rdr = csv::Reader::from_reader(File::open(cache_file.path())?);
148
149            // Deserialize each record into our struct
150            for result in rdr.deserialize() {
151                // TODO might want a custom implementation of deserialize here
152                let record: Invocation = result?;
153
154                // Ensure that invocations are valid before actually loading them
155                // Only load invocations with IDs
156                // TODO replace with an `is_valid` method or ensure valid invocations on parse
157                if !record.item_id.is_empty() {
158                    match request_sender.send(Box::new(Request::UpdateIndexItem {
159                        item: ItemUpdate::Invocation(record),
160                    })) {
161                        Ok(_) => invocation_count += 1,
162                        Err(e) => warn!("could not send invocation: {}", e),
163                    }
164                }
165            }
166
167            // exit if we have enough invocations or have read all of the cache files
168            // TODO need to bail if we have checked more than MAX_INVOCATION_RETENTION files
169            if invocation_count >= MIN_INVOCATION_NUM {
170                break;
171            }
172        }
173
174        // remove extra cache files
175        for file_to_delete in cache_file_iter {
176            if let Err(e) = fs::remove_file(file_to_delete.path()) {
177                warn!(
178                    "Could not remove cache file {}: {}",
179                    file_to_delete.path().to_string_lossy(),
180                    e
181                )
182            }
183        }
184
185        drop(request_sender);
186
187        Ok(invocation_count)
188    }
189}
190
191pub(crate) fn load_invocations(request_sender: Sender<Box<Request>>) {
192    // Load invocations from cache
193    info!("Loading invocations from cache...");
194    match Invocation::load_from_cache(request_sender) {
195        Ok(invocation_count) => info!("{} invocations loaded from cache", invocation_count),
196        Err(e) => warn!("invocation loading failed with the following error: {}", e),
197    }
198}