watch mode

This commit is contained in:
Robin Appelman 2025-10-13 18:21:10 +02:00
commit 7500d6ccf4
6 changed files with 452 additions and 151 deletions

View file

@ -1,14 +1,13 @@
use std::fs::read_to_string;
use std::path::{Path, PathBuf};
use crate::rule::Rule;
use home::home_dir;
use regex::Regex;
use serde::Deserialize;
use std::fs::read_to_string;
use std::path::{Path, PathBuf};
use thiserror::Error;
use crate::rule::Rule;
#[derive(Debug, Deserialize)]
pub struct GaltonConfig {
pub global: GlobalConfig,
#[serde(default)]
pub rule: Vec<Rule>,
}
@ -18,29 +17,24 @@ impl GaltonConfig {
let path = path.as_ref();
let raw = read_to_string(path).map_err(|error| ConfigError::Read {
path: path.into(),
error
error,
})?;
toml::from_str(&raw).map_err(|error| ConfigError::Parse {
path: path.into(),
error
error,
})
}
}
#[derive(Debug, Deserialize)]
pub struct GlobalConfig {
directory: String,
pub last: bool,
}
impl GlobalConfig {
pub fn directory<'a>(&'a self) -> PathBuf {
if self.directory.starts_with("~/") {
let home = home_dir().unwrap_or_default();
home.join(&self.directory[2..])
} else {
self.directory.clone().into()
}
fn normalize_path(path: String) -> String {
if let Some(suffix) = path.strip_prefix("~/") {
let home = home_dir().unwrap_or_default();
home.join(suffix)
.into_os_string()
.into_string()
.expect("non utf8 home directory")
} else {
path
}
}
@ -62,7 +56,7 @@ impl TryFrom<RuleConfig> for Rule {
return Err(RuleError::NoMatches);
}
if value.rename.is_none() && value.target.is_none() {
return Err(RuleError::NoMatches);
return Err(RuleError::NoAction);
}
fn parse_rule(val: Option<String>) -> Result<Option<Regex>, RuleError> {
@ -70,17 +64,16 @@ impl TryFrom<RuleConfig> for Rule {
return Ok(None);
};
Ok(Some(Regex::new(&val).map_err(|error| RuleError::Regex {
input: val,
error,
})?))
Ok(Some(
Regex::new(&val).map_err(|error| RuleError::Regex { input: val, error })?,
))
}
Ok(Rule {
name: parse_rule(value.name)?,
referrer: parse_rule(value.referrer)?,
url: parse_rule(value.url)?,
target: value.target,
target: value.target.map(normalize_path),
rename: value.rename,
})
}
@ -98,8 +91,6 @@ pub enum ConfigError {
path: PathBuf,
error: toml::de::Error,
},
#[error("Failed to parse rule: {0}")]
InvalidRule(RuleError),
}
#[derive(Debug, Error)]
@ -109,8 +100,5 @@ pub enum RuleError {
#[error("at least one action rule needs to be defined")]
NoAction,
#[error("invalid regex {input}: {error:#}")]
Regex {
input: String,
error: regex::Error,
}
}
Regex { input: String, error: regex::Error },
}

View file

@ -6,6 +6,7 @@ pub struct FileInfo {
pub path: String,
pub url: Option<String>,
pub referrer: Option<String>,
#[allow(dead_code)]
pub mtime: u64,
pub mtime_str: String,
}
@ -16,18 +17,21 @@ impl FileInfo {
let stat = path.metadata().map_err(|error| FileError::Stat {
path: path.into(),
error
error,
})?;
let path = path.to_str().ok_or_else(|| FileError::InvalidPath {
path: path.into(),
})?;
let path = path
.to_str()
.ok_or_else(|| FileError::InvalidPath { path: path.into() })?;
let mtime = stat.modified().map_err(|error| FileError::Stat {
path: path.into(),
error
error,
})?;
let mtime = mtime.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
let mtime = mtime
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut file = FileInfo {
path: path.into(),
@ -56,6 +60,20 @@ impl FileInfo {
Ok(file)
}
pub fn name(&self) -> &str {
self.path
.rsplit_once('/')
.map(|(_, name)| name)
.unwrap_or(self.path.as_str())
}
pub fn parent(&self) -> &str {
self.path
.rsplit_once('/')
.map(|(parent, _)| parent)
.unwrap_or("")
}
}
#[derive(Debug, Error)]
@ -66,5 +84,5 @@ pub enum FileError {
Stat {
path: PathBuf,
error: std::io::Error,
}
},
}

View file

@ -1,10 +1,20 @@
use std::path::PathBuf;
use clap::{Parser, Subcommand};
use clap::builder::Styles;
use clap::builder::styling::{AnsiColor, Effects};
use main_error::MainResult;
use crate::config::GaltonConfig;
use crate::file::FileInfo;
use crate::rule::{Rule, RuleMatch};
use clap::builder::styling::{AnsiColor, Effects};
use clap::builder::Styles;
use clap::{Parser, Subcommand};
use main_error::MainResult;
use notify_debouncer_full::notify::event::{AccessKind, AccessMode};
use notify_debouncer_full::notify::{EventKind, RecursiveMode};
use notify_debouncer_full::{new_debouncer, DebounceEventResult};
use std::fs::{copy, create_dir_all, remove_file, rename};
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::sync::mpsc::channel;
use std::thread::sleep;
use std::time::Duration;
use tracing::{debug, error, info, instrument};
mod config;
mod file;
@ -36,30 +46,128 @@ enum Commands {
path: PathBuf,
},
/// Watch for new files and apply rules on them
Watch
Watch {
/// Directory to watch for new files
path: PathBuf,
#[arg(long)]
recursive: bool,
},
}
#[tokio::main]
async fn main() -> MainResult {
fn main() -> MainResult {
let args: Args = Args::parse();
tracing_subscriber::fmt::init();
let config = GaltonConfig::load(&args.config)?;
match args.command {
Commands::File {path} => {
Commands::File { path } => {
let file = FileInfo::load(path)?;
for rule in &config.rule {
if let Some(target) = rule.matches(&file) {
dbg!(target);
todo!()
}
}
handle_file(&file, &config.rule);
}
Commands::Watch => {
todo!()
Commands::Watch { path, recursive } => {
let rules = config.rule;
let (tx, rx) = channel();
let mut watcher = new_debouncer(Duration::from_secs(1), None, tx)?;
watcher.watch(
&path,
if recursive {
RecursiveMode::Recursive
} else {
RecursiveMode::NonRecursive
},
)?;
let mut watcher = Some(watcher);
ctrlc::set_handler(move || {
if let Some(watcher) = watcher.take() {
watcher.stop();
}
})?;
for res in rx {
handle_watch_events(res, &rules);
}
}
}
Ok(())
}
fn handle_watch_events(result: DebounceEventResult, rules: &[Rule]) {
match result {
Ok(events) => {
for event in events {
if event.kind == EventKind::Access(AccessKind::Close(AccessMode::Write)) {
for path in &event.paths {
debug!("write event for {}", path.display());
// give originfox time to set xattr
sleep(Duration::from_millis(200));
match FileInfo::load(path) {
Ok(file) => {
handle_file(&file, rules);
}
Err(error) => {
error!(%error, "failed to load file info");
}
}
}
}
}
}
Err(errors) => {
for error in errors {
error!(%error, "watch error")
}
}
}
}
fn match_file(file: &FileInfo, rules: &[Rule]) -> Option<RuleMatch> {
for rule in rules {
if let Some(result) = rule.matches(file) {
debug!(?rule, ?result, "found matching rule");
return Some(result);
}
}
None
}
#[instrument(skip_all, fields(file = file.path))]
fn handle_file(file: &FileInfo, rules: &[Rule]) {
let Some(result) = match_file(file, rules) else {
info!("no matches");
return;
};
let parent = result.target.as_deref().unwrap_or_else(|| file.parent());
let name = result.rename.as_deref().unwrap_or_else(|| file.name());
if let Err(error) = create_dir_all(parent) {
error!(%error, "failed to create target directory");
return;
}
let target = format!("{parent}/{name}");
match cross_storage_move(&file.path, &target) {
Ok(()) => {
info!(target, "moved file");
}
Err(error) => {
info!(target, ?error, "failed to moved file");
}
}
}
fn cross_storage_move(source: impl AsRef<Path>, target: impl AsRef<Path>) -> std::io::Result<()> {
let source = source.as_ref();
let target = target.as_ref();
match rename(source, target) {
Ok(()) => Ok(()),
Err(error) if error.kind() == ErrorKind::CrossesDevices => {
copy(source, target)?;
remove_file(source)
}
Err(error) => Err(error),
}
}

View file

@ -49,14 +49,9 @@ impl Rule {
pub fn matches(&self, file: &FileInfo) -> Option<RuleMatch> {
let mut captures: HashMap<CaptureName, &str> = HashMap::new();
captures.insert(CaptureName::Named("mtime"), &file.mtime_str);
if let Some(name) = &self.name {
let file_name = file
.path
.rsplit_once('/')
.map(|(_, name)| name)
.unwrap_or(file.path.as_str());
if !extract_matches(name, file_name, &mut captures) {
if !extract_matches(name, file.name(), &mut captures) {
return None;
}
}
@ -64,7 +59,7 @@ impl Rule {
if let Some(referrer) = &self.referrer {
if !extract_matches(
referrer,
&file.referrer.as_deref().unwrap_or_default(),
file.referrer.as_deref().unwrap_or_default(),
&mut captures,
) {
return None;
@ -72,13 +67,13 @@ impl Rule {
}
if let Some(url) = &self.url {
if !extract_matches(url, &file.url.as_deref().unwrap_or_default(), &mut captures) {
if !extract_matches(url, file.url.as_deref().unwrap_or_default(), &mut captures) {
return None;
}
}
let apply = |input| apply_captures(input, &captures);
Some(RuleMatch {
target: self.target.as_deref().map(apply),
rename: self.rename.as_deref().map(apply),