use anyhow::{Context, Result};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use std::path::Path;
use crate::cache::hash_store::HashStore;
use crate::dated::date::DateSource;
use crate::dated::scan::ScannedFile;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileRecord {
#[serde(rename = "s")]
pub size: u64,
#[serde(rename = "m")]
pub mtime_ns: i64,
#[serde(rename = "h")]
pub sha256: String,
#[serde(rename = "u")]
pub uploaded_md5: Option<String>,
#[serde(rename = "d")]
pub capture_date: NaiveDate,
#[serde(rename = "ds")]
pub date_source: DateSource,
#[serde(rename = "i", default, skip_serializing_if = "Option::is_none")]
pub image_uri: Option<String>,
#[serde(rename = "a", default, skip_serializing_if = "Option::is_none")]
pub album_key: Option<String>,
#[serde(rename = "f", default, skip_serializing_if = "Option::is_none")]
pub failed: Option<String>,
}
impl FileRecord {
pub fn matches(&self, file: &ScannedFile) -> bool {
self.size == file.size && self.mtime_ns == file.mtime_ns
}
pub fn is_settled(&self) -> bool {
self.image_uri.is_some() || self.failed.is_some()
}
}
#[derive(Clone)]
pub struct FileIndex {
files: sled::Tree,
refs: sled::Tree,
}
fn key(path: &Path) -> Vec<u8> {
path.to_string_lossy().into_owned().into_bytes()
}
impl FileIndex {
pub fn open(store: &HashStore) -> Result<Self> {
Ok(FileIndex {
files: store.open_tree("files")?,
refs: store.open_tree("refs")?,
})
}
pub fn get(&self, path: &Path) -> Result<Option<FileRecord>> {
match self
.files
.get(key(path))
.context("Failed to read file index")?
{
Some(bytes) => Ok(serde_json::from_slice(&bytes).ok()),
None => Ok(None),
}
}
pub fn put(&self, path: &Path, record: &FileRecord) -> Result<()> {
let previous = self.get(path)?;
let old_uri = previous.and_then(|r| r.image_uri);
if old_uri != record.image_uri {
if let Some(uri) = &old_uri {
self.add_ref(uri, -1)?;
}
if let Some(uri) = &record.image_uri {
self.add_ref(uri, 1)?;
}
}
let bytes = serde_json::to_vec(record).context("Failed to serialize file record")?;
self.files
.insert(key(path), bytes)
.context("Failed to write file index")?;
Ok(())
}
pub fn refs(&self, image_uri: &str) -> Result<u32> {
Ok(self
.refs
.get(image_uri.as_bytes())
.context("Failed to read image references")?
.map(|v| u32::from_be_bytes(v.as_ref().try_into().unwrap_or([0; 4])))
.unwrap_or(0))
}
fn add_ref(&self, image_uri: &str, delta: i64) -> Result<()> {
self.refs
.update_and_fetch(image_uri.as_bytes(), |old| {
let count = old
.map(|v| u32::from_be_bytes(v.try_into().unwrap_or([0; 4])))
.unwrap_or(0) as i64
+ delta;
(count > 0).then(|| (count as u32).to_be_bytes().to_vec())
})
.context("Failed to update image references")?;
Ok(())
}
#[cfg(test)]
fn len(&self) -> usize {
self.files.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use tempfile::TempDir;
fn record(uri: Option<&str>) -> FileRecord {
FileRecord {
size: 10,
mtime_ns: 20,
sha256: "abc".into(),
uploaded_md5: Some("def".into()),
capture_date: NaiveDate::from_ymd_opt(2014, 7, 12).unwrap(),
date_source: DateSource::Exif,
image_uri: uri.map(String::from),
album_key: None,
failed: None,
}
}
fn index() -> (TempDir, FileIndex) {
let dir = TempDir::new().unwrap();
let store = HashStore::new(dir.path().to_str().unwrap()).unwrap();
let index = FileIndex::open(&store).unwrap();
(dir, index)
}
#[test]
fn round_trips_records() {
let (_dir, index) = index();
let path = PathBuf::from("/photos/a.jpg");
assert_eq!(index.get(&path).unwrap(), None);
index.put(&path, &record(Some("/img/1"))).unwrap();
assert_eq!(index.get(&path).unwrap(), Some(record(Some("/img/1"))));
assert_eq!(index.len(), 1);
}
#[test]
fn counts_paths_per_image() {
let (_dir, index) = index();
let a = PathBuf::from("/a.jpg");
let b = PathBuf::from("/b.jpg");
index.put(&a, &record(Some("/img/1"))).unwrap();
index.put(&b, &record(Some("/img/1"))).unwrap();
assert_eq!(index.refs("/img/1").unwrap(), 2);
index.put(&a, &record(Some("/img/1"))).unwrap();
assert_eq!(index.refs("/img/1").unwrap(), 2);
index.put(&a, &record(Some("/img/2"))).unwrap();
assert_eq!(index.refs("/img/1").unwrap(), 1);
assert_eq!(index.refs("/img/2").unwrap(), 1);
index.put(&b, &record(None)).unwrap();
assert_eq!(index.refs("/img/1").unwrap(), 0);
}
#[test]
fn matches_on_size_and_mtime() {
let rec = record(Some("/img/1"));
let mut file = ScannedFile {
path: PathBuf::from("/a.jpg"),
size: 10,
mtime_ns: 20,
live_photo_of: None,
};
assert!(rec.matches(&file));
file.mtime_ns = 21;
assert!(!rec.matches(&file));
}
#[test]
fn clearing_the_store_clears_the_index() {
let dir = TempDir::new().unwrap();
let store = HashStore::new(dir.path().to_str().unwrap()).unwrap();
let index = FileIndex::open(&store).unwrap();
index
.put(Path::new("/a.jpg"), &record(Some("/img/1")))
.unwrap();
store.clear().unwrap();
let index = FileIndex::open(&store).unwrap();
assert_eq!(index.get(Path::new("/a.jpg")).unwrap(), None);
assert_eq!(index.refs("/img/1").unwrap(), 0);
}
}