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