use crate::logging::LogFailure;
use color_eyre::Result;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
mod store;
pub(crate) use store::{Kind, Store};
pub use store::{StableHasher, stable_hash};
#[derive(Clone, Debug)]
pub struct CacheManager {
pub(crate) cache_dir: PathBuf,
pub(crate) swept:
std::sync::Arc<std::sync::Mutex<std::collections::HashMap<&'static str, u64>>>,
}
impl CacheManager {
pub fn with_dir(cache_dir: PathBuf) -> Self {
Self {
cache_dir,
swept: Default::default(),
}
}
pub fn new(app_name: &str) -> Result<Self> {
#[cfg(test)]
isolate_cache();
if let Some(dir) = std::env::var_os("DATUI_CACHE_DIR") {
return Ok(Self::with_dir(PathBuf::from(dir)));
}
if running_as_a_cargo_test() {
panic!(
"DATUI_CACHE_DIR is not set: a test would write to the real cache. \
Call common::isolate_cache() (or take the runtime from \
common::test_runtime(), which does) before building an App or a \
CacheManager."
);
}
let cache_dir = dirs::cache_dir()
.ok_or_else(|| color_eyre::eyre::eyre!("Could not determine cache directory"))?
.join(app_name);
Ok(Self::with_dir(cache_dir))
}
pub fn cache_dir(&self) -> &Path {
&self.cache_dir
}
pub fn cache_file(&self, filename: &str) -> PathBuf {
self.cache_dir.join(filename)
}
pub fn ensure_cache_dir(&self) -> Result<()> {
if !self.cache_dir.exists() {
fs::create_dir_all(&self.cache_dir)?;
}
Ok(())
}
pub fn clear_all(&self) -> Result<()> {
for dir in [Shapes::DIR, Facts::DIR, CloudListings::DIR] {
match fs::remove_dir_all(self.cache_file(dir)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
log::warn!(target: "datui", "remove the {dir} cache: {e}");
}
_ => {}
}
}
let Ok(entries) = fs::read_dir(&self.cache_dir) else {
return Ok(());
};
for entry in entries.flatten() {
let path = entry.path();
let list = entry.file_name().to_string_lossy().contains(HISTORY_SUFFIX);
if list && path.is_file() {
fs::remove_file(&path).or_log(&format!("remove {}", path.display()));
}
}
Ok(())
}
pub fn load_history_file(&self, history_id: &str) -> Result<Vec<String>> {
let history_file = self.cache_file(&format!("{history_id}{HISTORY_SUFFIX}"));
let bytes = match fs::read(&history_file) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
};
let mut history = Vec::new();
for line in bytes.split(|&b| b == b'\n') {
let line = line.strip_suffix(b"\r").unwrap_or(line);
match std::str::from_utf8(line) {
Ok(line) if !line.trim().is_empty() => history.push(line.to_string()),
Ok(_) => {}
Err(e) => {
log::warn!(target: "datui", "{history_id} history: skipped a line: {e}")
}
}
}
Ok(history)
}
fn load_history_or_log(&self, history_id: &str) -> Vec<String> {
self.load_history_file(history_id)
.inspect_err(|e| log::warn!(target: "datui", "read {history_id} history: {e:#}"))
.unwrap_or_default()
}
pub fn update_history_file<F>(&self, history_id: &str, update: F) -> Result<HistoryUpdate>
where
F: FnOnce(&mut Vec<String>),
{
use fs2::FileExt;
self.ensure_cache_dir()?;
let lock_path = self.cache_file(&format!("{}_history.lock", history_id));
let Some(lock) = lock_file(&lock_path, LOCK_TIMEOUT)? else {
log::info!(target: "datui", "{history_id} history not updated: its lock is busy");
return Ok(HistoryUpdate::SkippedBusy);
};
let mut entries = self.load_history_file(history_id)?;
update(&mut entries);
let result = self.save_history_file(history_id, &entries);
let _ = FileExt::unlock(&lock);
result.map(|()| HistoryUpdate::Written)
}
pub fn save_history_file(&self, history_id: &str, history: &[String]) -> Result<()> {
self.ensure_cache_dir()?;
let history_file = self.cache_file(&format!("{history_id}{HISTORY_SUFFIX}"));
let mut text = String::new();
for entry in history {
text.push_str(entry);
text.push('\n');
}
atomic_write(&history_file, text.as_bytes())?;
Ok(())
}
}
const HISTORY_SUFFIX: &str = "_history.txt";
const TERMINAL_MODES: &str = "terminal_modes";
pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let serial = SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut name = path.file_name().unwrap_or_default().to_owned();
name.push(format!(".{}.{serial}.tmp", std::process::id()));
let temp = path.with_file_name(name);
let written = (|| {
let mut file = fs::File::create(&temp)?;
file.write_all(bytes)?;
file.sync_all()?;
fs::rename(&temp, path)
})();
if written.is_err() {
let _ = fs::remove_file(&temp);
}
written
}
pub(crate) fn lock_file(
path: &Path,
timeout: std::time::Duration,
) -> std::io::Result<Option<fs::File>> {
take_lock(path, timeout, false)
}
pub(crate) fn lock_file_shared(
path: &Path,
timeout: std::time::Duration,
) -> std::io::Result<Option<fs::File>> {
take_lock(path, timeout, true)
}
fn take_lock(
path: &Path,
timeout: std::time::Duration,
shared: bool,
) -> std::io::Result<Option<fs::File>> {
use fs2::FileExt;
let lock = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(path)?;
let deadline = std::time::Instant::now() + timeout;
loop {
let taken = if shared {
FileExt::try_lock_shared(&lock)
} else {
FileExt::try_lock_exclusive(&lock)
};
if taken.is_ok() {
return Ok(Some(lock));
}
if std::time::Instant::now() >= deadline {
return Ok(None);
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
}
const LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
pub const MAX_RECENTS: usize = 50;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HistoryUpdate {
Written,
SkippedBusy,
}
impl CacheManager {
pub fn load_recents(&self) -> Vec<std::path::PathBuf> {
self.load_recents_with_visits().0
}
pub fn load_recents_with_visits(
&self,
) -> (Vec<PathBuf>, std::collections::HashMap<PathBuf, Visits>) {
let mut recents = Vec::new();
let mut visits = std::collections::HashMap::new();
for line in self.load_history_or_log("recents") {
let (path, seen) = parse_recent(&line);
recents.push(PathBuf::from(path));
if seen.count > 0 {
visits.insert(PathBuf::from(path), seen);
}
}
(recents, visits)
}
pub fn load_folds(&self) -> std::collections::HashMap<String, bool> {
self.load_history_or_log("home_folds")
.into_iter()
.filter_map(|line| {
let (title, state) = line.rsplit_once('\t')?;
Some((title.to_string(), state.trim() == "1"))
})
.collect()
}
pub fn save_folds(&self, folds: &std::collections::HashMap<String, bool>) {
let mut lines: Vec<String> = folds
.iter()
.map(|(title, folded)| format!("{title}\t{}", if *folded { 1 } else { 0 }))
.collect();
lines.sort();
self.save_history_file("home_folds", &lines)
.or_log("save home folds");
}
pub fn terminal_mode(&self, terminal: &str) -> Option<crate::config::ThemeMode> {
self.load_history_or_log(TERMINAL_MODES)
.iter()
.find_map(|line| match line.split_once('\t')? {
(key, "dark") if key == terminal => Some(crate::config::ThemeMode::Dark),
(key, "light") if key == terminal => Some(crate::config::ThemeMode::Light),
_ => None,
})
}
pub fn remember_terminal_mode(&self, terminal: &str, mode: crate::config::ThemeMode) {
if self.terminal_mode(terminal) == Some(mode) {
return;
}
let word = match mode {
crate::config::ThemeMode::Light => "light",
_ => "dark",
};
self.update_history_file(TERMINAL_MODES, |lines| {
lines.retain(|line| line.split_once('\t').is_none_or(|(key, _)| key != terminal));
lines.push(format!("{terminal}\t{word}"));
})
.or_log("remember the terminal's background");
}
pub fn forget_recent(&self, path: &std::path::Path) {
let target = path.to_string_lossy().into_owned();
self.update_history_file("recents", |recents| {
recents.retain(|line| parse_recent(line).0 != target);
})
.or_log("forget a recent");
}
pub fn forget_recents(&self, paths: &[std::path::PathBuf]) {
let targets: Vec<String> = paths
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
self.update_history_file("recents", |recents| {
recents.retain(|line| !targets.iter().any(|t| t == parse_recent(line).0));
})
.or_log("forget recents");
}
pub fn clear_recents(&self) {
self.update_history_file("recents", |recents| recents.clear())
.or_log("clear recents");
}
fn recent_is_worth_keeping(path: &str, mounts: &crate::home::locality::Mounts) -> bool {
let path = std::path::Path::new(path);
if crate::home::locality::object_scheme(path).is_some() || mounts.is_network(path) {
return true;
}
match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent.exists(),
_ => true,
}
}
pub fn push_recent(&self, path: &std::path::Path) -> HistoryUpdate {
let looks_like_url = path.to_string_lossy().contains("://");
let stored = if looks_like_url {
path.to_path_buf()
} else {
crate::canonical::canonicalize(path)
.or_else(|e| match crate::formats::members::split(path) {
Some((db, table)) => crate::canonical::canonicalize(&db)
.map(|db| crate::formats::members::place(&db, &table)),
None => Err(e),
})
.unwrap_or_else(|_| path.to_path_buf())
};
let entry = stored.to_string_lossy().into_owned();
let mounts = crate::home::locality::Mounts::current();
let now = unix_now();
self.update_history_file("recents", |recents| {
let mut visits = Visits::default();
if let Some(at) = recents.iter().position(|l| parse_recent(l).0 == entry) {
visits = parse_recent(&recents.remove(at)).1;
}
visits.count = visits.count.saturating_add(1);
visits.last = now;
recents.insert(0, format!("{entry}\t{}\t{}", visits.count, visits.last));
recents.retain(|line| Self::recent_is_worth_keeping(parse_recent(line).0, &mounts));
recents.truncate(MAX_RECENTS);
})
.inspect_err(|e| log::warn!(target: "datui", "record a recent: {e:#}"))
.unwrap_or(HistoryUpdate::SkippedBusy)
}
}
fn parse_recent(line: &str) -> (&str, Visits) {
let mut parts = line.rsplitn(3, '\t');
if let (Some(last), Some(count), Some(path)) = (parts.next(), parts.next(), parts.next())
&& let (Ok(last), Ok(count)) = (last.parse(), count.parse())
{
return (path, Visits { count, last });
}
(line, Visits::default())
}
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default()
}
#[derive(Debug, Clone, Copy, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Visits {
pub count: u32,
pub last: u64,
}
impl Visits {
pub fn frecency(&self, now: u64) -> f64 {
let age = now.saturating_sub(self.last);
let weight = match age {
a if a < 3_600 => 4.0,
a if a < 86_400 => 2.0,
a if a < 604_800 => 0.5,
_ => 0.25,
};
f64::from(self.count) * weight
}
}
pub fn by_frecency(
mut recents: Vec<PathBuf>,
visits: &std::collections::HashMap<PathBuf, Visits>,
) -> Vec<PathBuf> {
let now = unix_now();
let score = |p: &PathBuf| visits.get(p).map_or(0.0, |v| v.frecency(now));
recents.sort_by(|a, b| score(b).total_cmp(&score(a)));
recents
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct DatasetFacts {
pub mtime: u64,
pub size: u64,
pub rows: Option<usize>,
pub cols: Option<usize>,
#[serde(default)]
pub cols_sampled: bool,
#[serde(default)]
pub columns: Vec<String>,
#[serde(default)]
pub kind: Option<crate::home::discover::EntryKind>,
#[serde(default)]
pub classified_by: u32,
#[serde(default)]
pub cost: crate::home::discover::Cost,
#[serde(
default,
skip_serializing_if = "crate::home::discover::Holds::is_empty"
)]
pub holds: crate::home::discover::Holds,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct DatasetShape {
pub fingerprint: String,
pub files: Vec<CachedFooter>,
pub schemas: Vec<Vec<(String, polars::prelude::DataType)>>,
pub taken_at: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct CachedFooter {
pub schema: Option<usize>,
pub row_group_rows: Vec<usize>,
pub row_group_bytes: Vec<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub column_bytes: Vec<usize>,
}
impl DatasetShape {
pub fn fingerprint_of<'a>(
files: impl IntoIterator<Item = (&'a str, u64, u64, Option<&'a str>)>,
) -> String {
let mut hasher = StableHasher::default();
let mut count = 0usize;
let mut bytes = 0u64;
for (key, size, stamp, etag) in files {
hasher.bytes(key.as_bytes()).u64(size).u64(stamp);
match etag {
Some(etag) => hasher.u64(1).bytes(etag.as_bytes()),
None => hasher.u64(0),
};
count += 1;
bytes = bytes.saturating_add(size);
}
format!("{count}-{bytes}-{:016x}", hasher.finish())
}
pub fn intern_schema(
schemas: &mut Vec<Vec<(String, polars::prelude::DataType)>>,
schema: &polars::prelude::Schema,
) -> usize {
let columns: Vec<(String, polars::prelude::DataType)> = schema
.iter()
.map(|(name, dtype)| (name.to_string(), dtype.clone()))
.collect();
schemas
.iter()
.position(|s| *s == columns)
.unwrap_or_else(|| {
schemas.push(columns);
schemas.len() - 1
})
}
pub fn schema_at(
schemas: &[Vec<(String, polars::prelude::DataType)>],
at: usize,
) -> Option<polars::prelude::Schema> {
let columns = schemas.get(at)?;
let mut schema = polars::prelude::Schema::with_capacity(columns.len());
for (name, dtype) in columns {
schema.with_column(name.as_str().into(), dtype.clone());
}
Some(schema)
}
}
#[cfg(test)]
pub(crate) fn isolate_cache() {
static SCRATCH: std::sync::Mutex<Vec<tempfile::TempDir>> = std::sync::Mutex::new(Vec::new());
unsafe extern "C" {
fn atexit(callback: extern "C" fn()) -> std::ffi::c_int;
}
extern "C" fn remove_scratch_dirs() {
if let Ok(mut held) = SCRATCH.lock() {
held.clear();
}
}
let scratch_dir = |prefix: &str| {
tempfile::Builder::new()
.prefix(prefix)
.tempdir()
.expect("a scratch directory for the test process")
};
static ISOLATE: std::sync::Once = std::sync::Once::new();
ISOLATE.call_once(|| {
let dir = scratch_dir("datui-unit-cache-");
let config_dir = scratch_dir("datui-unit-config-");
unsafe { std::env::set_var("DATUI_CACHE_DIR", dir.path()) };
unsafe { std::env::set_var("DATUI_CONFIG_DIR", config_dir.path()) };
let mut held = SCRATCH.lock().unwrap_or_else(|e| e.into_inner());
held.push(dir);
held.push(config_dir);
unsafe { atexit(remove_scratch_dirs) };
});
}
pub(crate) fn running_as_a_cargo_test() -> bool {
std::env::current_exe()
.is_ok_and(|exe| cargo_test_layout(&exe, std::env::var_os("CARGO_TARGET_DIR").as_deref()))
}
fn cargo_test_layout(exe: &Path, target_dir: Option<&std::ffi::OsStr>) -> bool {
let Some(deps) = exe.parent() else {
return false;
};
if deps.file_name().is_none_or(|name| name != "deps") {
return false;
}
if target_dir.is_some_and(|dir| exe.starts_with(dir)) {
return true;
}
deps.ancestors()
.skip(1)
.take(3)
.any(|dir| dir.file_name().is_some_and(|name| name == "target"))
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct CloudListing {
pub fingerprint: String,
pub buckets: Vec<String>,
pub listed_at: u64,
}
pub(crate) struct Shapes;
impl Kind for Shapes {
const DIR: &'static str = "shapes";
const EXT: &'static str = "shape";
const VERSION: u16 = 1;
const BUDGET: u64 = 128 << 20;
type Value = DatasetShape;
fn encode(shape: &DatasetShape) -> Result<Vec<u8>> {
encode_shape(shape)
}
fn decode(payload: &[u8]) -> Option<DatasetShape> {
decode_shape(payload)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FileFooters {
pub schemas: Vec<Vec<(String, polars::prelude::DataType)>>,
pub files: Vec<(u64, CachedFooter)>,
}
pub fn file_identity(key: &str, size: u64, stamp: u64, etag: Option<&str>) -> u64 {
let mut hasher = StableHasher::default();
hasher.bytes(key.as_bytes()).u64(size).u64(stamp);
match etag {
Some(etag) => hasher.u64(1).bytes(etag.as_bytes()),
None => hasher.u64(0),
};
hasher.finish()
}
pub(crate) struct FileFootersKind;
impl Kind for FileFootersKind {
const DIR: &'static str = "file_footers";
const EXT: &'static str = "footers";
const VERSION: u16 = 1;
const BUDGET: u64 = 64 << 20;
type Value = FileFooters;
fn encode(footers: &FileFooters) -> Result<Vec<u8>> {
let shape = DatasetShape {
fingerprint: String::new(),
files: footers.files.iter().map(|(_, f)| f.clone()).collect(),
schemas: footers.schemas.clone(),
taken_at: 0,
};
let mut out = encode_shape(&shape)?;
for (identity, _) in &footers.files {
out.extend_from_slice(&identity.to_le_bytes());
}
Ok(out)
}
fn decode(payload: &[u8]) -> Option<FileFooters> {
let count = payload.len().checked_sub(4)?;
let (len, rest) = payload.split_first_chunk::<4>()?;
let header = usize::try_from(u32::from_le_bytes(*len)).ok()?;
let mut body = rest.get(header..)?;
let files = usize::try_from(take_varint(&mut body)?).ok()?;
let ids = files.checked_mul(8)?;
if ids > count {
return None;
}
let (shape, identities) = payload.split_at(payload.len() - ids);
let shape = decode_shape(shape)?;
(shape.files.len() == files).then(|| FileFooters {
schemas: shape.schemas,
files: identities
.as_chunks::<8>()
.0
.iter()
.map(|id| u64::from_le_bytes(*id))
.zip(shape.files)
.collect(),
})
}
}
pub(crate) struct Facts;
impl Kind for Facts {
const DIR: &'static str = "facts";
const EXT: &'static str = "facts";
const VERSION: u16 = 1;
const BUDGET: u64 = 16 << 20;
type Value = DatasetFacts;
fn encode(facts: &DatasetFacts) -> Result<Vec<u8>> {
Ok(serde_json::to_vec(facts)?)
}
fn decode(payload: &[u8]) -> Option<DatasetFacts> {
serde_json::from_slice(payload).ok()
}
}
pub(crate) struct CloudListings;
impl Kind for CloudListings {
const DIR: &'static str = "cloud_listings";
const EXT: &'static str = "listing";
const VERSION: u16 = 1;
const BUDGET: u64 = 4 << 20;
type Value = CloudListing;
fn encode(listing: &CloudListing) -> Result<Vec<u8>> {
Ok(serde_json::to_vec(listing)?)
}
fn decode(payload: &[u8]) -> Option<CloudListing> {
serde_json::from_slice(payload).ok()
}
}
#[derive(serde::Serialize, serde::Deserialize)]
struct ShapeHeader {
fingerprint: String,
schemas: Vec<Vec<(String, polars::prelude::DataType)>>,
taken_at: u64,
}
fn put_varint(out: &mut Vec<u8>, mut n: u64) {
while n >= 0x80 {
out.push((n as u8) | 0x80);
n >>= 7;
}
out.push(n as u8);
}
fn take_varint(bytes: &mut &[u8]) -> Option<u64> {
let mut n = 0u64;
for shift in (0..64).step_by(7) {
let (&b, rest) = bytes.split_first()?;
*bytes = rest;
n |= u64::from(b & 0x7f) << shift;
if b & 0x80 == 0 {
return Some(n);
}
}
None
}
fn put_list(out: &mut Vec<u8>, values: &[usize]) {
put_varint(out, values.len() as u64);
for &v in values {
put_varint(out, v as u64);
}
}
fn take_list(bytes: &mut &[u8]) -> Option<Vec<usize>> {
let len = usize::try_from(take_varint(bytes)?).ok()?;
if len > bytes.len() {
return None;
}
(0..len)
.map(|_| take_varint(bytes).and_then(|v| usize::try_from(v).ok()))
.collect()
}
fn encode_shape(shape: &DatasetShape) -> Result<Vec<u8>> {
let header = serde_json::to_vec(&ShapeHeader {
fingerprint: shape.fingerprint.clone(),
schemas: shape.schemas.clone(),
taken_at: shape.taken_at,
})?;
let mut out = Vec::with_capacity(8 + header.len() + shape.files.len() * 8);
out.extend_from_slice(&u32::try_from(header.len())?.to_le_bytes());
out.extend_from_slice(&header);
put_varint(&mut out, shape.files.len() as u64);
for file in &shape.files {
put_varint(&mut out, file.schema.map_or(0, |s| s as u64 + 1));
put_list(&mut out, &file.row_group_rows);
put_list(&mut out, &file.row_group_bytes);
put_list(&mut out, &file.column_bytes);
}
Ok(out)
}
fn decode_shape(bytes: &[u8]) -> Option<DatasetShape> {
let (len, rest) = bytes.split_first_chunk::<4>()?;
let len = usize::try_from(u32::from_le_bytes(*len)).ok()?;
let (header, mut body) = (rest.get(..len)?, rest.get(len..)?);
let header: ShapeHeader = serde_json::from_slice(header).ok()?;
let count = usize::try_from(take_varint(&mut body)?).ok()?;
if count > body.len() {
return None;
}
let mut files = Vec::with_capacity(count);
let (mut rows, mut bytes_total) = (0usize, 0usize);
for _ in 0..count {
let schema = match take_varint(&mut body)? {
0 => None,
at => Some(usize::try_from(at - 1).ok()?),
};
let footer = CachedFooter {
schema,
row_group_rows: take_list(&mut body)?,
row_group_bytes: take_list(&mut body)?,
column_bytes: take_list(&mut body)?,
};
if let Some(at) = footer.schema
&& at >= header.schemas.len()
{
return None;
}
for &n in &footer.row_group_rows {
rows = rows.checked_add(n)?;
}
for &n in footer.row_group_bytes.iter().chain(&footer.column_bytes) {
bytes_total = bytes_total.checked_add(n)?;
}
files.push(footer);
}
body.is_empty().then_some(DatasetShape {
fingerprint: header.fingerprint,
files,
schemas: header.schemas,
taken_at: header.taken_at,
})
}
impl CacheManager {
#[cfg(test)]
pub fn dataset_shapes_kept(&self) -> usize {
Store::<Shapes>::new(self).len()
}
pub fn dataset_shape(&self, path: &str, fingerprint: &str) -> Option<DatasetShape> {
Store::<Shapes>::new(self).get(path, fingerprint)
}
pub fn has_dataset_shape(&self, path: &str) -> bool {
Store::<Shapes>::new(self).file(path).exists()
}
pub fn save_dataset_shape(&self, path: &str, shape: DatasetShape) {
Store::<Shapes>::new(self).put(path, &shape.fingerprint, &shape);
}
pub fn file_footers(&self, path: &str) -> Option<FileFooters> {
Store::<FileFootersKind>::new(self).get(path, "")
}
pub fn save_file_footers(&self, path: &str, footers: &FileFooters) {
Store::<FileFootersKind>::new(self).put(path, "", footers);
}
pub fn cloud_listing(&self, id: &str, fingerprint: &str) -> Option<CloudListing> {
Store::<CloudListings>::new(self).get(id, fingerprint)
}
pub fn save_cloud_listing(&self, id: &str, listing: CloudListing) {
Store::<CloudListings>::new(self).put(id, &listing.fingerprint.clone(), &listing);
}
pub fn load_hidden_cloud_sources(&self) -> Vec<String> {
self.load_history_or_log("cloud_hidden")
}
pub fn hide_cloud_source(&self, id: &str) {
let id = id.to_string();
self.update_history_file("cloud_hidden", |hidden| {
if !hidden.contains(&id) {
hidden.push(id.clone());
}
})
.or_log("hide a cloud source");
}
pub fn examples_hidden(&self) -> bool {
!self.load_history_or_log("examples_hidden").is_empty()
}
pub fn hide_examples(&self) {
self.save_history_file("examples_hidden", &["hidden".to_string()])
.or_log("hide the example datasets");
}
pub fn load_remembered_places(&self) -> Vec<PathBuf> {
let file = self.cache_file(&format!("home_remembered{HISTORY_SUFFIX}"));
if !file.exists() {
return Vec::new();
}
self.load_history_or_log("home_remembered")
.into_iter()
.map(PathBuf::from)
.collect()
}
pub fn clear_remembered_places(&self) {
let file = self.cache_file(&format!("home_remembered{HISTORY_SUFFIX}"));
if let Err(e) = std::fs::remove_file(&file)
&& e.kind() != std::io::ErrorKind::NotFound
{
log::warn!(target: "datui", "remove {}: {e}", file.display());
}
}
pub fn save_remembered_places(&self, places: &[PathBuf]) -> Result<()> {
let places: Vec<String> = places
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
self.save_history_file("home_remembered", &places)
}
}
impl CacheManager {
pub fn load_dataset_facts(&self) -> std::collections::HashMap<PathBuf, DatasetFacts> {
Store::<Facts>::new(self)
.scan()
.into_iter()
.map(|(path, facts)| (PathBuf::from(path), facts))
.collect()
}
pub fn dataset_facts(&self, path: &Path) -> Option<DatasetFacts> {
Store::<Facts>::new(self).get(path.to_str()?, "")
}
pub fn touch_dataset_facts<'a>(&self, paths: impl IntoIterator<Item = &'a Path>) {
let store = Store::<Facts>::new(self);
for path in paths.into_iter().filter_map(Path::to_str) {
store.touch(path);
}
}
pub fn record_dataset_facts(&self, facts: &[(PathBuf, DatasetFacts)]) {
Store::<Facts>::new(self).put_all(
facts
.iter()
.filter_map(|(path, facts)| Some((path.to_str()?, "", facts))),
);
}
fn with_cache_lock<F>(&self, name: &str, work: F) -> Result<()>
where
F: FnOnce() -> Result<()>,
{
use fs2::FileExt;
self.ensure_cache_dir()?;
let Some(lock) = lock_file(&self.cache_file(&format!("{name}.lock")), LOCK_TIMEOUT)? else {
log::info!(target: "datui", "{name} cache not updated: its lock is busy");
return Ok(());
};
let result = work();
let _ = FileExt::unlock(&lock);
result
}
}
#[cfg(test)]
mod harness_tests;
#[cfg(test)]
mod recents_pruning_tests;
#[cfg(test)]
mod dataset_shape_tests;
#[cfg(test)]
mod store_harness_tests;
#[cfg(test)]
mod facts_compat_tests;