free_launch/model/
invocation.rs1use 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
19const MIN_INVOCATION_NUM: usize = 1000;
21const MAX_INVOCATION_RETENTION: usize = 60;
23const INVOCATION_FILE_PREFIX: &str = "free-launch-invocations-";
27const 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 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 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 }
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 let write_headers = !file_path.exists();
94
95 let file = OpenOptions::new()
98 .append(true)
99 .create(true)
100 .open(file_path)?;
101
102 let mut wtr = WriterBuilder::new()
105 .has_headers(write_headers)
106 .from_writer(file);
107
108 wtr.serialize(self)?;
110
111 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 let mut cache_files = Self::cache_files()?.collect::<Vec<DirEntry>>();
135
136 cache_files.sort_by_cached_key(|i| Reverse(i.file_name()));
138 let mut cache_file_iter = cache_files.into_iter();
139
140 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 let mut rdr = csv::Reader::from_reader(File::open(cache_file.path())?);
148
149 for result in rdr.deserialize() {
151 let record: Invocation = result?;
153
154 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 if invocation_count >= MIN_INVOCATION_NUM {
170 break;
171 }
172 }
173
174 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 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}