use std::collections::HashMap;
use std::fs;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
use serde::{Deserialize, Serialize};
use super::types::{ScriptFilterIcon, ScriptFilterItem};
const KEPT: usize = 50;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(super) struct RecentPick {
pub source: String,
pub key: String,
pub last_used: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub item: Option<StoredItem>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(super) struct StoredItem {
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subtitle: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub arg: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub variables: HashMap<String, String>,
}
impl StoredItem {
pub fn of(item: &ScriptFilterItem, default_icon: Option<&str>) -> Self {
Self {
title: item.title.clone(),
subtitle: item.subtitle.clone(),
arg: item.arg.clone(),
icon: item
.icon
.as_ref()
.and_then(|icon| icon.path.clone())
.or_else(|| default_icon.map(str::to_string)),
variables: item.variables.clone(),
}
}
pub fn to_item(&self) -> ScriptFilterItem {
ScriptFilterItem {
title: self.title.clone(),
valid: true,
subtitle: self.subtitle.clone(),
arg: self.arg.clone(),
indent: 0,
icon: self
.icon
.clone()
.map(|path| ScriptFilterIcon { path: Some(path) }),
preview: None,
variables: self.variables.clone(),
quicklookurl: None,
text: None,
}
}
}
impl RecentPick {
pub fn new(source: String, key: String) -> Self {
Self {
source,
key,
last_used: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
url: None,
session: None,
item: None,
}
}
}
pub(super) fn parse(content: &str) -> Vec<RecentPick> {
let mut picks: Vec<RecentPick> = content
.lines()
.filter_map(|line| serde_json::from_str(line).ok())
.collect();
picks.sort_by_key(|pick| std::cmp::Reverse(pick.last_used));
let mut kept: Vec<RecentPick> = Vec::new();
for pick in picks {
if kept.len() == KEPT {
break;
}
if !kept
.iter()
.any(|other| other.source == pick.source && other.key == pick.key)
{
kept.push(pick);
}
}
kept
}
pub(super) fn load(path: Option<&Path>) -> Vec<RecentPick> {
path.and_then(|path| fs::read_to_string(path).ok())
.map(|content| parse(&content))
.unwrap_or_default()
}
pub(super) fn record(picks: &mut Vec<RecentPick>, pick: RecentPick) {
picks.retain(|other| other.source != pick.source || other.key != pick.key);
picks.insert(0, pick);
picks.truncate(KEPT);
}
pub(super) fn save(path: Option<&Path>, picks: &[RecentPick]) {
let Some(path) = path else {
return;
};
let content: String = picks
.iter()
.filter_map(|pick| serde_json::to_string(pick).ok())
.map(|line| line + "\n")
.collect();
let temp = path.with_extension(format!("cache.{}.tmp", std::process::id()));
let _ = fs::remove_file(&temp);
let written = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&temp)
.and_then(|mut file| file.write_all(content.as_bytes()))
.and_then(|()| fs::rename(&temp, path));
if let Err(error) = written {
let _ = fs::remove_file(&temp);
eprintln!("Warning: Failed to save recent picks to {path:?}: {error}");
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pick(source: &str, key: &str, last_used: u64) -> RecentPick {
RecentPick {
last_used,
..RecentPick::new(source.into(), key.into())
}
}
#[test]
fn parse_skips_bad_lines_dedupes_and_sorts_newest_first() {
let lines = [
serde_json::to_string(&pick("windows", "a", 10)).unwrap(),
"not json".to_string(),
serde_json::to_string(&pick("pass", "b", 30)).unwrap(),
serde_json::to_string(&pick("windows", "a", 20)).unwrap(),
]
.join("\n");
let picks = parse(&lines);
assert_eq!(
picks
.iter()
.map(|pick| (pick.key.as_str(), pick.last_used))
.collect::<Vec<_>>(),
[("b", 30), ("a", 20)]
);
}
#[test]
fn record_moves_a_pick_first_and_caps_the_list() {
let mut picks: Vec<_> = (0..KEPT)
.map(|n| pick("windows", &n.to_string(), 0))
.collect();
record(&mut picks, pick("windows", "5", 1));
assert_eq!(picks.len(), KEPT);
assert_eq!(picks[0].key, "5");
assert_eq!(picks.iter().filter(|pick| pick.key == "5").count(), 1);
record(&mut picks, pick("pass", "new", 2));
assert_eq!(picks.len(), KEPT);
assert_eq!(picks[0].key, "new");
assert_eq!(picks.last().unwrap().key, (KEPT - 2).to_string());
}
#[test]
fn save_writes_a_private_file_that_loads_back() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("raffi-recent-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let path = dir.join("recent.cache");
let mut tab = pick("browser_tabs", "1:2", 5);
tab.url = Some("https://example.com".into());
let picks = vec![tab, pick("pass", "entry:mail", 4)];
save(Some(&path), &picks);
assert_eq!(load(Some(&path)), picks);
let mode = fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
let _ = fs::remove_dir_all(&dir);
}
}