mirror of
https://codeberg.org/icewind/galton.git
synced 2026-06-03 18:34:08 +02:00
initial rule matching
This commit is contained in:
parent
67f2fca6f5
commit
6d8b8d363e
7 changed files with 1067 additions and 2 deletions
116
src/config.rs
Normal file
116
src/config.rs
Normal 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
61
src/file.rs
Normal 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 },
|
||||
}
|
||||
66
src/main.rs
66
src/main.rs
|
|
@ -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
122
src/rule.rs
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue