use futures::{
channel::mpsc::{Receiver, Sender},
SinkExt, StreamExt,
};
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use path_slash::PathExt as _;
use walkdir::WalkDir;
use notify_debouncer_mini::DebounceEventResult;
use time::OffsetDateTime;
use tokio::time::Duration;
use tokio_util::sync::CancellationToken;
use log::debug;
use crate::chunker;
use crate::connection::{get_connection, ConnectionPool};
use crate::errors::SyncError;
use crate::models::*;
use crate::registry;
use crate::{SyncStatus, SyncStatusListener};
type DBFiles = HashMap<String, FileRecord>;
type DiskFiles = HashMap<String, CreateForm>;
const CHECK_INTERVAL_WAIT_SEC: Duration = Duration::from_secs(61);
pub async fn run(
token: CancellationToken,
listener: Option<Arc<dyn SyncStatusListener>>,
pool: &ConnectionPool,
storage_path: &Path,
namespace_id: i32,
mut local_file_update_rx: Receiver<DebounceEventResult>,
mut updated_tx: Sender<IndexerUpdateEvent>,
) -> Result<(), SyncError> {
loop {
if token.is_cancelled() {
debug!("Indexer received shutdown signal");
break;
}
if let Some(ref cb) = listener {
cb.on_status_changed(SyncStatus::Indexing);
}
if check_index_once(pool, storage_path, namespace_id)? {
updated_tx.send(IndexerUpdateEvent::Updated).await?;
}
if let Some(ref cb) = listener {
cb.on_status_changed(SyncStatus::Idle);
}
tokio::select! {
_ = token.cancelled() => {
debug!("Indexer shutting down");
break;
}
_ = tokio::time::sleep(CHECK_INTERVAL_WAIT_SEC) => {},
Some(_) = local_file_update_rx.next() => {},
};
}
Ok(())
}
pub fn check_index_once(
pool: &ConnectionPool,
storage_path: &Path,
namespace_id: i32,
) -> Result<bool, SyncError> {
debug!("interval scan");
let from_db = get_file_records_from_registry(pool, namespace_id)?;
let from_fs = get_file_records_from_disk(storage_path, namespace_id)?;
let (to_remove, to_add) = compare_records(from_db, from_fs, namespace_id);
if !to_remove.is_empty() || !to_add.is_empty() {
let conn = &mut get_connection(pool)?;
if !to_remove.is_empty() {
registry::delete(conn, &to_remove)?;
}
if !to_add.is_empty() {
registry::create(conn, &to_add)?;
}
Ok(true)
} else {
Ok(false)
}
}
fn filter_eligible(p: &Path) -> bool {
if p.is_symlink() {
return false;
}
chunker::is_text(p) || chunker::is_binary(p)
}
fn is_dot_dir(e: &walkdir::DirEntry) -> bool {
e.depth() > 0
&& e.file_name().to_str().is_some_and(|s| s.starts_with('.'))
}
fn get_file_records_from_disk(base_path: &Path, namespace_id: i32) -> Result<DiskFiles, SyncError> {
let mut cache = HashMap::new();
let iter = WalkDir::new(base_path)
.into_iter()
.filter_entry(|e| !(e.file_type().is_dir() && is_dot_dir(e)))
.filter_map(|e| e.ok())
.map(|p| p.into_path())
.filter(|p| filter_eligible(p));
for p in iter {
let record = build_file_record(&p, base_path, namespace_id)?;
cache.insert(record.path.clone(), record);
}
Ok(cache)
}
fn get_file_records_from_registry(
pool: &ConnectionPool,
namespace_id: i32,
) -> Result<DBFiles, SyncError> {
let mut cache = HashMap::new();
let conn = &mut get_connection(pool)?;
for record in registry::non_deleted(conn, namespace_id)? {
cache.insert(record.path.clone(), record);
}
Ok(cache)
}
fn compare_records(
from_db: DBFiles,
from_fs: DiskFiles,
namespace_id: i32,
) -> (Vec<DeleteForm>, Vec<CreateForm>) {
let mut to_remove: Vec<DeleteForm> = Vec::new();
let mut to_add: Vec<CreateForm> = Vec::new();
for (p, db_file) in &from_db {
match from_fs.get(p) {
Some(disk_file) => {
if db_file != disk_file {
to_add.push(disk_file.clone());
}
}
None => {
to_remove.push(build_delete_form(db_file, namespace_id));
}
}
}
for (p, disk_file) in &from_fs {
if !from_db.contains_key(p) {
to_add.push(disk_file.clone());
}
}
(to_remove, to_add)
}
fn build_file_record(path: &Path, base: &Path, namespace_id: i32) -> Result<CreateForm, SyncError> {
let metadata = path
.metadata()
.map_err(|e| SyncError::from_io_error(path, e))?;
let path = path.strip_prefix(base)?.to_slash_lossy().into_owned();
let size: i64 = metadata.len().try_into()?;
let time = metadata
.modified()
.map_err(|e| SyncError::from_io_error(path.clone(), e))?;
let modified_at = truncate_to_seconds(OffsetDateTime::from(time));
let f = CreateForm {
jid: None,
path,
deleted: false,
size,
modified_at,
namespace_id,
};
Ok(f)
}
pub(crate) fn truncate_to_seconds(t: OffsetDateTime) -> OffsetDateTime {
t.replace_nanosecond(0).unwrap_or(t)
}
fn build_delete_form(record: &FileRecord, namespace_id: i32) -> DeleteForm {
DeleteForm {
path: record.path.to_string(),
jid: None,
deleted: true,
size: record.size,
modified_at: record.modified_at,
namespace_id,
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use std::fs::{self, File};
fn dt(nanos: u32) -> OffsetDateTime {
OffsetDateTime::UNIX_EPOCH
.replace_nanosecond(nanos)
.unwrap()
}
#[test]
fn truncate_to_seconds_zeroes_subsecond_component() {
let truncated = truncate_to_seconds(dt(123_456_789));
assert_eq!(truncated.nanosecond(), 0);
assert_eq!(truncated, OffsetDateTime::UNIX_EPOCH);
}
#[test]
fn truncate_to_seconds_is_idempotent_on_whole_seconds() {
let t = OffsetDateTime::UNIX_EPOCH;
assert_eq!(truncate_to_seconds(t), t);
}
#[test]
fn truncate_to_seconds_makes_roundtrip_equality_stable() {
let disk = dt(123_456_789);
let stored_roundtrip = dt(123_000_000);
assert_ne!(disk, stored_roundtrip);
assert_eq!(truncate_to_seconds(disk), truncate_to_seconds(stored_roundtrip));
}
#[test]
fn build_file_record_normalises_path_separators_to_forward_slash() {
let tmp = TempDir::new().expect("create tempdir");
let base = tmp.path();
let nested_dir = base.join("plats");
fs::create_dir_all(&nested_dir).expect("create nested dir");
let file_path = nested_dir.join("pates-carbo.cook");
File::create(&file_path).expect("create file");
let record = build_file_record(&file_path, base, 1).expect("build_file_record");
assert!(
!record.path.contains('\\'),
"path must not contain backslash, got {:?}",
record.path
);
assert_eq!(record.path, "plats/pates-carbo.cook");
}
#[test]
fn get_file_records_from_disk_skips_files_inside_dot_directory() {
let tmp = TempDir::new().expect("create tempdir");
let base = tmp.path();
fs::create_dir_all(base.join("recipes")).expect("mkdir recipes");
File::create(base.join("recipes/dinner.cook")).expect("create cook");
fs::create_dir_all(base.join(".git")).expect("mkdir .git");
File::create(base.join(".git/config.yaml")).expect("create config");
let records = get_file_records_from_disk(base, 1).expect("walk");
assert_eq!(records.len(), 1, "expected exactly one record; got {:?}", records.keys().collect::<Vec<_>>());
assert!(records.contains_key("recipes/dinner.cook"), "normal recipe must be indexed; got {:?}", records.keys().collect::<Vec<_>>());
assert!(!records.contains_key(".git/config.yaml"), "dot-dir file must be excluded; got {:?}", records.keys().collect::<Vec<_>>());
}
#[test]
fn get_file_records_from_disk_skips_nested_dot_directory() {
let tmp = TempDir::new().expect("create tempdir");
let base = tmp.path();
fs::create_dir_all(base.join("recipes/.cache")).expect("mkdir nested");
File::create(base.join("recipes/.cache/x.cook")).expect("create nested file");
let records = get_file_records_from_disk(base, 1).expect("walk");
assert!(
records.is_empty(),
"nested dot-dir contents must be skipped; got {:?}",
records.keys().collect::<Vec<_>>()
);
}
#[test]
fn get_file_records_from_disk_keeps_dotfile_in_root() {
let tmp = TempDir::new().expect("create tempdir");
let base = tmp.path();
File::create(base.join(".shopping-list")).expect("create dotfile");
let records = get_file_records_from_disk(base, 1).expect("walk");
assert_eq!(records.len(), 1, "expected exactly one record; got {:?}", records.keys().collect::<Vec<_>>());
assert!(records.contains_key(".shopping-list"), "whitelisted root dotfile must be indexed; got {:?}", records.keys().collect::<Vec<_>>());
}
#[test]
fn get_file_records_from_disk_keeps_files_when_storage_root_is_hidden() {
let tmp = TempDir::new().expect("create tempdir");
let hidden_root = tmp.path().join(".cooklang");
fs::create_dir_all(&hidden_root).expect("mkdir hidden root");
File::create(hidden_root.join("r.cook")).expect("create cook in hidden root");
let records = get_file_records_from_disk(&hidden_root, 1).expect("walk");
assert_eq!(records.len(), 1, "expected exactly one record; got {:?}", records.keys().collect::<Vec<_>>());
assert!(records.contains_key("r.cook"), "file inside hidden storage root must be indexed; got {:?}", records.keys().collect::<Vec<_>>());
}
}