initial rule matching

This commit is contained in:
Robin Appelman 2025-10-13 00:34:20 +02:00
commit 6d8b8d363e
7 changed files with 1067 additions and 2 deletions

116
src/config.rs Normal file
View file

@ -0,0 +1,116 @@
use std::fs::read_to_string;
use std::path::{Path, PathBuf};
use home::home_dir;
use regex::Regex;
use serde::Deserialize;
use thiserror::Error;
use crate::rule::Rule;
#[derive(Debug, Deserialize)]
pub struct GaltonConfig {
pub global: GlobalConfig,
#[serde(default)]
pub rule: Vec<Rule>,
}
impl GaltonConfig {
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
let path = path.as_ref();
let raw = read_to_string(path).map_err(|error| ConfigError::Read {
path: path.into(),
error
})?;
toml::from_str(&raw).map_err(|error| ConfigError::Parse {
path: path.into(),
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()
}
}
}
#[derive(Debug, Deserialize)]
pub(crate) struct RuleConfig {
name: Option<String>,
referrer: Option<String>,
url: Option<String>,
#[serde(rename = "move")]
target: Option<String>,
rename: Option<String>,
}
impl TryFrom<RuleConfig> for Rule {
type Error = RuleError;
fn try_from(value: RuleConfig) -> Result<Self, Self::Error> {
if value.name.is_none() && value.referrer.is_none() && value.url.is_none() {
return Err(RuleError::NoMatches);
}
if value.rename.is_none() && value.target.is_none() {
return Err(RuleError::NoMatches);
}
fn parse_rule(val: Option<String>) -> Result<Option<Regex>, RuleError> {
let Some(val) = val else {
return Ok(None);
};
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,
rename: value.rename,
})
}
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Failed to read config file {}: {error:#}", path.display())]
Read {
path: PathBuf,
error: std::io::Error,
},
#[error("Failed to parse config file {}: {error:#}", path.display())]
Parse {
path: PathBuf,
error: toml::de::Error,
},
#[error("Failed to parse rule: {0}")]
InvalidRule(RuleError),
}
#[derive(Debug, Error)]
pub enum RuleError {
#[error("at least one match rule needs to be defined")]
NoMatches,
#[error("at least one action rule needs to be defined")]
NoAction,
#[error("invalid regex {input}: {error:#}")]
Regex {
input: String,
error: regex::Error,
}
}

61
src/file.rs Normal file
View file

@ -0,0 +1,61 @@
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use thiserror::Error;
pub struct FileInfo {
pub path: String,
pub url: Option<String>,
pub referrer: Option<String>,
pub mtime: u64,
pub mtime_str: String,
}
impl FileInfo {
pub fn load<P: AsRef<Path>>(path: P) -> Result<FileInfo, FileError> {
let path = path.as_ref();
let stat = path.metadata();
let path = path.to_str().ok_or_else(|| FileError::InvalidPath {
path: path.into(),
})?;
let mtime = match stat {
Ok(stat) => stat.modified().unwrap_or_else(|_| SystemTime::now()),
Err(_) => SystemTime::now()
}.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
let mut file = FileInfo {
path: path.into(),
url: None,
referrer: None,
mtime,
mtime_str: mtime.to_string(),
};
let attributes = xattr::list(path).unwrap_or_default();
for attr in attributes {
if let Some(attr) = attr.to_str() {
if let Some(val) = xattr::get(path, attr)
.ok()
.flatten()
.and_then(|val| String::from_utf8(val).ok())
{
match attr {
"user.xdg.origin.url" => file.url = Some(val),
"user.xdg.referrer.url" => file.referrer = Some(val),
_ => {}
}
}
}
}
Ok(file)
}
}
#[derive(Debug, Error)]
pub enum FileError {
#[error("Invalid path: {}", path.display())]
InvalidPath { path: PathBuf },
}

View file

@ -1,3 +1,65 @@
fn main() {
println!("Hello, world!");
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;
mod config;
mod file;
mod rule;
fn styles() -> Styles {
Styles::styled()
.header(AnsiColor::Yellow.on_default() | Effects::BOLD)
.usage(AnsiColor::Yellow.on_default() | Effects::BOLD)
.literal(AnsiColor::Blue.on_default() | Effects::BOLD)
.placeholder(AnsiColor::Green.on_default())
}
#[derive(Debug, Parser)]
#[command(styles = styles())]
struct Args {
/// Config file
#[arg(long)]
config: PathBuf,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Apply rules on a single file
File {
/// Path to apply rules on
path: PathBuf,
},
/// Watch for new files and apply rules on them
Watch
}
#[tokio::main]
async fn main() -> MainResult {
let args: Args = Args::parse();
let config = GaltonConfig::load(&args.config)?;
match args.command {
Commands::File {path} => {
let file = FileInfo::load(path)?;
for rule in &config.rule {
if let Some(target) = rule.matches(&file) {
dbg!(target);
todo!()
}
}
}
Commands::Watch => {
todo!()
}
}
Ok(())
}

122
src/rule.rs Normal file
View file

@ -0,0 +1,122 @@
use crate::file::FileInfo;
use regex::Regex;
use serde::Deserialize;
use std::borrow::Cow;
use std::collections::HashMap;
#[derive(Debug, Deserialize)]
#[serde(try_from = "crate::config::RuleConfig")]
pub struct Rule {
pub name: Option<Regex>,
pub referrer: Option<Regex>,
pub url: Option<Regex>,
pub target: Option<String>,
pub rename: Option<String>,
}
#[derive(Debug)]
pub struct RuleMatch {
pub target: Option<String>,
pub rename: Option<String>,
}
#[derive(Hash, PartialEq, Eq, Debug)]
enum CaptureName<'a> {
Named(&'a str),
Unnamed(usize),
}
impl<'a> CaptureName<'a> {
pub fn to_str(&self) -> Cow<'a, str> {
match self {
CaptureName::Named(s) => Cow::Borrowed(s),
CaptureName::Unnamed(1) => Cow::Borrowed("1"),
CaptureName::Unnamed(2) => Cow::Borrowed("2"),
CaptureName::Unnamed(3) => Cow::Borrowed("3"),
CaptureName::Unnamed(4) => Cow::Borrowed("4"),
CaptureName::Unnamed(5) => Cow::Borrowed("5"),
CaptureName::Unnamed(6) => Cow::Borrowed("6"),
CaptureName::Unnamed(7) => Cow::Borrowed("7"),
CaptureName::Unnamed(8) => Cow::Borrowed("8"),
CaptureName::Unnamed(9) => Cow::Borrowed("9"),
CaptureName::Unnamed(10) => Cow::Borrowed("10"),
CaptureName::Unnamed(i) => Cow::Owned(i.to_string()),
}
}
}
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) {
return None;
}
}
if let Some(referrer) = &self.referrer {
if !extract_matches(
referrer,
&file.referrer.as_deref().unwrap_or_default(),
&mut captures,
) {
return None;
}
}
if let Some(url) = &self.url {
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),
})
}
}
fn apply_captures(input: &str, captures: &HashMap<CaptureName, &str>) -> String {
let mut output = input.to_string();
for (name, value) in captures {
let name = name.to_str();
if output.contains(name.as_ref()) && output.contains('$') {
output = output.replace(&format!("${name}"), value);
}
}
output
}
fn extract_matches<'a, 'b>(
regex: &'a Regex,
string: &'b str,
output: &mut HashMap<CaptureName<'a>, &'b str>,
) -> bool {
match regex.captures(string) {
Some(caps) => {
for (i, (m, name)) in caps.iter().zip(regex.capture_names()).enumerate().skip(1) {
if let Some(m) = m {
let cap_name = match name {
Some(name) => CaptureName::Named(name),
None => CaptureName::Unnamed(i),
};
output.insert(cap_name, m.as_str());
}
}
}
None => {
return false;
}
}
true
}