mirror of
https://codeberg.org/icewind/galton.git
synced 2026-06-03 18:34:08 +02:00
watch mode
This commit is contained in:
parent
5083cd1591
commit
7500d6ccf4
6 changed files with 452 additions and 151 deletions
|
|
@ -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 },
|
||||
}
|
||||
|
|
|
|||
32
src/file.rs
32
src/file.rs
|
|
@ -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,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
|||
146
src/main.rs
146
src/main.rs
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
src/rule.rs
15
src/rule.rs
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue