use chrono::{DateTime, Utc};
use color_eyre::eyre::{Context, Result, eyre};
use log::{debug, warn};
use serde::{Deserialize, Serialize};
use std::{
cmp::Ordering,
collections::HashMap,
fs::{self, File},
path::PathBuf,
sync::atomic::AtomicUsize,
};
use crate::launch::Behavior;
const MAX_HISTORY_ENTRIES: usize = 35;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entry {
pub workspace_name: String,
pub dev_container_name: Option<String>,
pub workspace_path: PathBuf,
pub config_path: Option<PathBuf>,
pub behavior: Behavior,
pub last_opened: DateTime<Utc>, }
impl PartialEq for Entry {
fn eq(&self, other: &Self) -> bool {
self.workspace_path == other.workspace_path
&& self.config_path == other.config_path
&& self.behavior == other.behavior
}
}
impl Eq for Entry {}
impl Ord for Entry {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
if self.eq(other) {
return Ordering::Equal;
}
self.last_opened.cmp(&other.last_opened)
}
}
impl PartialOrd for Entry {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct EntryId(usize);
impl EntryId {
pub fn new() -> Self {
static GLOBAL_ID: AtomicUsize = AtomicUsize::new(0);
Self(GLOBAL_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst))
}
}
#[derive(Default, Debug, Clone)]
pub struct History(HashMap<EntryId, Entry>);
impl History {
pub fn from_entries(entries: Vec<Entry>) -> Self {
Self(
entries
.into_iter()
.map(|entry| (EntryId::new(), entry))
.collect(),
)
}
pub fn insert(&mut self, entry: Entry) -> EntryId {
let id = EntryId::new();
assert_eq!(self.0.insert(id, entry), None);
id
}
pub fn update(&mut self, id: EntryId, entry: Entry) -> Option<Entry> {
if let std::collections::hash_map::Entry::Occupied(mut e) = self.0.entry(id) {
return Some(e.insert(entry));
}
None
}
pub fn delete(&mut self, id: EntryId) -> Option<Entry> {
self.0.remove(&id)
}
pub fn upsert(&mut self, entry: Entry) -> EntryId {
if let Some(id) = self
.0
.iter_mut()
.find_map(|(id, history_entry)| (history_entry == &entry).then_some(*id))
{
assert!(
self.update(id, entry).is_some(),
"Existing history entry to be replaced"
);
id
} else {
self.insert(entry)
}
}
pub fn iter(&self) -> std::collections::hash_map::Iter<'_, EntryId, Entry> {
self.0.iter()
}
pub fn into_entries(self) -> Vec<Entry> {
self.0.into_values().collect()
}
}
pub struct Tracker {
path: PathBuf,
pub history: History,
}
impl Tracker {
pub fn load<P: Into<PathBuf>>(path: P) -> Result<Self> {
fn load_inner(path: PathBuf) -> Result<Tracker> {
if !path.exists() {
return Ok(Tracker {
path,
history: History::default(),
});
}
let file = File::open(&path)?;
match serde_json::from_reader::<_, Vec<Entry>>(file) {
Ok(entries) => {
debug!("Imported {:?} history entries", entries.len());
Ok(Tracker {
path,
history: History::from_entries(entries),
})
}
Err(err) => {
let new_path = (0..10_000) .map(|i| path.with_file_name(format!(".history_{i}.json.bak")))
.find(|path| !path.exists())
.unwrap_or_else(|| path.with_file_name(".history.json.bak"));
fs::rename(&path, &new_path).wrap_err_with(|| {
format!(
"Could not move history file from `{}` to `{}`",
path.display(),
new_path.display()
)
})?;
warn!(
"Could not read history file: {err}\nMoved broken file to `{}`",
new_path.display()
);
Ok(Tracker {
path,
history: History::default(),
})
}
}
}
let path = path.into();
load_inner(path)
}
pub fn store(self) -> Result<()> {
fs::create_dir_all(
self.path
.parent()
.ok_or_else(|| eyre!("Parent directory not found"))?,
)?;
let file = File::create(self.path)?;
let entries: Vec<Entry> = self
.history
.into_entries()
.into_iter()
.take(MAX_HISTORY_ENTRIES)
.collect();
serde_json::to_writer_pretty(file, &entries)?;
Ok(())
}
}