diff --git a/.editorconfig b/.editorconfig index 941e639..85f3f11 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,4 +6,8 @@ indent_size = 4 end_of_line = lf charset = utf-8 trim_trailing_whitespace = false -insert_final_newline = false \ No newline at end of file +insert_final_newline = false + +[*.yml] +indent_style = space +indent_size = 2 \ No newline at end of file diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..3143e95 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +jobs: + typos: + name: "Typos" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check typos + uses: crate-ci/typos@master + with: + config: ./typos.toml + + clippy: + name: "Clippy" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: "Install Rust" + run: | + sudo apt update + DEBIAN_FRONTEND=noninteractive \ + sudo apt install --yes rustup + + - name: Run clippy + working-directory: ./index + run: cargo clippy --all-targets --all-features + + buildandtest: + name: "Build and test" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: "Install Rust" + run: | + sudo apt update + DEBIAN_FRONTEND=noninteractive \ + sudo apt install --yes rustup + + - name: Build + working-directory: ./index + run: cargo build --release + + - name: Test + working-directory: ./index + run: cargo test --release diff --git a/Cargo.lock b/Cargo.lock index 14a3995..2cf178d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,18 +11,166 @@ dependencies = [ "memchr", ] +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +dependencies = [ + "anstyle", + "once_cell", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + +[[package]] +name = "bitflags" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clap" +version = "4.5.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "getrandom" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi", +] + [[package]] name = "hashbrown" version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "indexmap" version = "2.9.0" @@ -34,23 +182,96 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.172" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + [[package]] name = "memchr" version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "pick" version = "0.1.0" dependencies = [ + "anstyle", + "anyhow", + "clap", "indexmap", "regex", "serde", + "tempfile", "toml", + "tracing", + "tracing-subscriber", "walkdir", ] +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + [[package]] name = "proc-macro2" version = "1.0.95" @@ -69,6 +290,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + [[package]] name = "regex" version = "1.11.1" @@ -77,8 +304,17 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", ] [[package]] @@ -89,15 +325,34 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.5", ] +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "rustix" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "same-file" version = "1.0.6" @@ -136,6 +391,27 @@ dependencies = [ "serde", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "smallvec" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.101" @@ -147,6 +423,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + [[package]] name = "toml" version = "0.8.22" @@ -189,12 +488,85 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfb942dfe1d8e29a7ee7fcbde5bd2b9a25fb89aa70caea2eba3bee836ff41076" +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + [[package]] name = "unicode-ident" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "walkdir" version = "2.5.0" @@ -205,6 +577,31 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.9" @@ -214,6 +611,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-sys" version = "0.59.0" @@ -295,3 +698,12 @@ checksum = "d9fb597c990f03753e08d3c29efbfcf2019a003b4bf4ba19225c158e1549f0f3" dependencies = [ "memchr", ] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] diff --git a/Cargo.toml b/Cargo.toml index 02b9bb9..4c8e664 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,8 +58,14 @@ unwrap_used = "deny" # [dependencies] +anstyle = "1.0.10" +clap = { version = "4.5.37", features = ["derive"] } indexmap = { version = "2.9.0", features = ["serde"] } regex = "1.11.1" serde = { version = "1.0.219", features = ["derive"] } toml = { version = "0.8.22", features = ["preserve_order"] } +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } walkdir = "2.5.0" +tempfile = "3.10.1" +anyhow = "1.0.98" diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..d18e526 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,170 @@ +use clap::{Parser, ValueEnum}; +use serde::Deserialize; +use std::{fmt::Display, str::FromStr}; +use tracing_subscriber::EnvFilter; + +#[derive(Debug)] +pub enum LogLevel { + Trace, + Debug, + Info, + Warn, + Error, +} + +impl Default for LogLevel { + fn default() -> Self { + Self::Info + } +} + +impl Display for LogLevel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Trace => write!(f, "trace"), + Self::Debug => write!(f, "debug"), + Self::Info => write!(f, "info"), + Self::Warn => write!(f, "warn"), + Self::Error => write!(f, "error"), + } + } +} + +#[derive(Debug, Deserialize, Clone, Copy, ValueEnum, PartialEq, Eq)] +pub enum LoggingPreset { + Error, + Warn, + Info, + Debug, + TraceMain, + TraceTools, +} + +pub struct LoggingConfig { + other: LogLevel, + + pick: LogLevel, + tool: LogLevel, +} + +impl From for EnvFilter { + fn from(conf: LoggingConfig) -> Self { + // Should never fail + #[expect(clippy::unwrap_used)] + EnvFilter::from_str( + &[ + format!("pick={}", conf.pick), + format!("pick::tool={}", conf.tool), + conf.other.to_string(), + ] + .join(","), + ) + .unwrap() + } +} + +impl Default for LoggingPreset { + fn default() -> Self { + return Self::Info; + } +} + +impl LoggingPreset { + /// Returns a logging preset with the given level. + /// Negative numbers are more quiet, positive are more verbose. + /// Zero is the default. + pub fn from_number(level: i16) -> Self { + if level <= -2 { + return Self::Error; + } else if level == -1 { + return Self::Warn; + } else if level == 0 { + return Self::Info; + } else if level == 1 { + return Self::Debug; + } else if level == 2 { + return Self::TraceMain; + } else if level >= 3 { + return Self::TraceTools; + } else { + unreachable!() + } + } + + pub fn get_config(&self) -> LoggingConfig { + match self { + Self::Error => LoggingConfig { + other: LogLevel::Error, + pick: LogLevel::Error, + tool: LogLevel::Error, + }, + + Self::Warn => LoggingConfig { + other: LogLevel::Error, + pick: LogLevel::Warn, + tool: LogLevel::Warn, + }, + + Self::Info => LoggingConfig { + other: LogLevel::Warn, + pick: LogLevel::Info, + tool: LogLevel::Info, + }, + + Self::Debug => LoggingConfig { + other: LogLevel::Warn, + pick: LogLevel::Debug, + tool: LogLevel::Debug, + }, + + Self::TraceMain => LoggingConfig { + other: LogLevel::Trace, + pick: LogLevel::Trace, + tool: LogLevel::Debug, + }, + + Self::TraceTools => LoggingConfig { + other: LogLevel::Trace, + pick: LogLevel::Trace, + tool: LogLevel::Trace, + }, + } + } +} + +/// A pre-baked set of loglevel cli arguments. +/// +/// # Usage +/// ```ignore +/// #[derive(Parser, Debug)] +/// struct Cli { +/// #[command(flatten)] +/// log: LogCli, +/// } +/// ``` +#[derive(Parser, Debug)] +pub struct LogCli { + /// Increase verbosity (can be repeated) + #[arg(short, action = clap::ArgAction::Count,global = true)] + pub v: u8, + + /// Decrease verbosity (can be repeated) + #[arg(short, action = clap::ArgAction::Count, global = true)] + pub q: u8, +} + +impl LogCli { + pub fn to_preset(&self) -> LoggingPreset { + LoggingPreset::from_number(self.v as i16 - self.q as i16) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn loglevel_0_is_default() { + assert_eq!(LoggingPreset::default(), LoggingPreset::from_number(0)); + } +} diff --git a/src/main.rs b/src/main.rs index c028473..7343cb7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,33 +1,215 @@ +use anyhow::{Context, Result}; +use clap::Parser; +use logging::LogCli; +use manifest::Manifest; +use std::{path::PathBuf, process::ExitCode}; +use tool::{PickTool, TaskContext}; +use tracing::{debug, error, trace}; use walkdir::WalkDir; +pub mod logging; pub mod manifest; +pub mod tool; +pub mod util; -fn main() { - let file = std::fs::read_to_string("./test.toml").unwrap(); - let x: manifest::Manifest = toml::from_str(&file).unwrap(); +// enumerate files with a spinner (count size) +// warn if links and follow +// trim everything +// parallelism +// input from stdin? +// * ** greed +// fix and document "**.flac", "**/*.flac" +// tests +// show progress +// bash before and after +// capture/print stdout/stderr +// workdir vs root? +// +// Tools: +// - *** bash +// - * list +// - *** rename +// - ** typst +// - *** retag +// - gitea pkg (POST) +// - s3 +// - rsync +// +// chain tools +// print output? - let rules = x +#[derive(Parser, Debug)] +#[command(version, about, long_about = None, styles=util::get_styles())] +struct Cli { + #[command(flatten)] + log: LogCli, + + manifest: PathBuf, +} + +fn main() -> ExitCode { + if let Err(e) = main_inner() { + for e in e.chain() { + error!(e); + } + + return ExitCode::FAILURE; + } + + return ExitCode::SUCCESS; +} + +fn main_inner() -> Result { + // + // MARK: setup + // + + let cli = Cli::parse(); + + tracing_subscriber::fmt() + .with_env_filter(cli.log.to_preset().get_config()) + .without_time() + .with_ansi(true) + .with_writer(std::io::stderr) + .init(); + + let manifest_path_str = cli + .manifest + .to_str() + .context("while converting path to string")?; + + if !cli.manifest.is_file() { + error!("Manifest {manifest_path_str} isn't a file"); + return Ok(ExitCode::FAILURE); + } + + let manifest_string = match std::fs::read_to_string(&cli.manifest) { + Ok(x) => x, + Err(error) => { + error!("Error while reading {manifest_path_str}: {error}"); + return Ok(ExitCode::FAILURE); + } + }; + + let manifest = match toml::from_str::(&manifest_string) { + Ok(manifest) => { + // Validate manifest + if manifest.config.follow_links && manifest.config.links { + error!("Error: `follow_links` and `links` are mutually exclusive"); + return Ok(ExitCode::FAILURE); + } + + manifest + } + Err(error) => { + error!("Error while parsing {manifest_path_str}"); + error!("{}", error.to_string()); + return Ok(ExitCode::FAILURE); + } + }; + + let manifest_path = std::path::absolute(cli.manifest)?; + let work_dir = manifest.config.work_dir(&manifest_path)?; + debug!("Working directory is {work_dir:?}"); + + // + // MARK: rules + // + + let rules = manifest .rules .iter() - .map(|rule| (rule.regex(), rule.action)) + .map(|rule| (rule.regex(), rule.tasks)) .collect::>(); - let walker = WalkDir::new("./target").into_iter(); + let source_path = std::path::absolute(&work_dir)?; + let walker = WalkDir::new(&source_path).follow_links(manifest.config.follow_links); + + let bash = manifest.tool.bash.as_ref().unwrap(); + bash.before(&manifest_path, &manifest.config)?; for entry in walker { - let e = entry.unwrap(); - let p = e.path(); - let s = p.to_str().unwrap(); + let entry = entry?; + let path_abs = std::path::absolute(entry.path())?; - if !p.is_file() { + // This path is a child of source_path, so this cannot fail + #[expect(clippy::unwrap_used)] + let path_rel = entry.path().strip_prefix(&source_path).unwrap(); + let path_rel = if path_rel.parent().is_none() { + // Make sure we never have empty string paths + // (makes logs clearer) + PathBuf::from(".").join(path_rel) + } else { + path_rel.to_path_buf() + }; + + let path_abs_str = path_abs + .to_str() + .context("could not convert path to string")? + .to_owned(); + let path_rel_str = path_rel + .to_str() + .context("could not convert path to string")? + .to_owned(); + + if path_abs.is_symlink() && !manifest.config.links { + trace!("Skipping {}, is a symlink", path_rel_str); continue; } - let m = rules - .iter() - .find(|(r, _)| r.is_match(s)) - .map(|x| x.1.clone()); + if path_abs.is_dir() && !manifest.config.dirs { + trace!("Skipping {}, is a directory", path_rel_str); + continue; + } - println!(" {m:?} {s}") + if path_abs.is_file() && !manifest.config.files { + trace!("Skipping {}, is a file", path_rel_str); + continue; + } + + let task = rules.iter().find(|(r, _)| r.is_match(&path_rel_str)); + + let tasks = match task { + None => { + trace!("Skipping {}, no match", path_rel_str); + continue; + } + Some(x) => { + let tasks: Vec = + x.1.iter() + .map(|x| x.trim()) + .filter(|x| !x.is_empty()) + .map(|x| x.to_owned()) + .collect(); + + if tasks.is_empty() { + trace!("Skipping {}", path_rel_str); + continue; + } + + tasks + } + }; + + let base_ctx = TaskContext { + task: "".into(), + path_abs, + path_abs_str, + path_rel, + path_rel_str, + }; + + for task in tasks { + trace!("Running `{task}` on {}", base_ctx.path_rel_str); + + let mut ctx = base_ctx.clone(); + ctx.task = task; + + bash.run(&manifest_path, &manifest.config, ctx)?; + } } + + bash.after(&manifest_path, &manifest.config)?; + + return Ok(ExitCode::SUCCESS); } diff --git a/src/manifest.rs b/src/manifest.rs index 3df4c33..7202c00 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -1,18 +1,63 @@ +use anyhow::Result; use indexmap::IndexMap; use regex::Regex; use serde::Deserialize; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; -#[derive(Debug, Clone, Deserialize)] +use crate::tool::ToolConfig; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Manifest { pub config: PickConfig, + pub tool: ToolConfig, pub rules: PickRules, } #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct PickConfig { - pub source: PathBuf, - pub target: PathBuf, + #[serde(default)] + pub work_dir: Option, + + #[serde(default = "default_false")] + pub follow_links: bool, + + #[serde(default = "default_true")] + pub files: bool, + + #[serde(default = "default_false")] + pub dirs: bool, + + #[serde(default = "default_false")] + pub links: bool, +} + +impl PickConfig { + pub fn work_dir(&self, manifest_path: &Path) -> Result { + // Parent directory should always exist since manifest is a file. + #[expect(clippy::unwrap_used)] + let p = manifest_path.parent().unwrap().to_path_buf(); + + match &self.work_dir { + None => Ok(p), + Some(path) => { + if path.is_absolute() { + Ok(path.to_owned()) + } else { + Ok(std::path::absolute(p.join(path))?) + } + } + } + } +} + +fn default_true() -> bool { + true +} + +fn default_false() -> bool { + false } // @@ -21,12 +66,12 @@ pub struct PickConfig { #[derive(Debug, Clone, Deserialize)] #[serde(untagged)] -pub enum OptVec { +pub enum OptVec { Single(T), Vec(Vec), } -impl OptVec { +impl OptVec { pub fn len(&self) -> usize { match self { Self::Single(_) => 1, @@ -34,6 +79,13 @@ impl OptVec { } } + pub fn is_empty(&self) -> bool { + match self { + Self::Single(_) => false, + Self::Vec(v) => v.is_empty(), + } + } + pub fn get(&self, idx: usize) -> Option<&T> { match self { Self::Single(t) => (idx == 0).then_some(t), @@ -41,11 +93,20 @@ impl OptVec { } } } +impl From> for Vec { + fn from(val: OptVec) -> Self { + match val { + OptVec::Single(t) => vec![t], + OptVec::Vec(v) => v, + } + } +} #[derive(Debug, Clone, Deserialize)] #[serde(untagged)] +#[serde(deny_unknown_fields)] pub enum PickRule { - Plain(String), + Plain(OptVec), Nested(PickRules), } @@ -54,7 +115,7 @@ pub enum PickRule { pub struct PickRules(OptVec>); impl PickRules { - pub fn iter<'a>(&'a self) -> PickRuleIterator<'a> { + pub fn iter(&self) -> PickRuleIterator<'_> { PickRuleIterator { stack: vec![PickRuleIterState { rules: self, @@ -82,11 +143,13 @@ impl<'a> IntoIterator for &'a PickRules { #[derive(Debug, Clone)] pub struct FlatPickRule { pub patterns: Vec, - pub action: String, + pub tasks: Vec, } impl FlatPickRule { pub fn regex(&self) -> Regex { + // This regex should always be valid + #[expect(clippy::unwrap_used)] Regex::new( &self .patterns @@ -122,6 +185,7 @@ impl Iterator for PickRuleIterator<'_> { return None; } + #[expect(clippy::unwrap_used)] let current = self.stack.last_mut().unwrap(); if current.map_index >= current.rules.0.len() { @@ -129,6 +193,7 @@ impl Iterator for PickRuleIterator<'_> { return self.next(); } + #[expect(clippy::unwrap_used)] let current_map = ¤t.rules.0.get(current.map_index).unwrap(); if current.entry_index >= current_map.len() { @@ -137,18 +202,19 @@ impl Iterator for PickRuleIterator<'_> { return self.next(); } + #[expect(clippy::unwrap_used)] let (key, value) = current_map.get_index(current.entry_index).unwrap(); current.entry_index += 1; match value { - PickRule::Plain(action) => { + PickRule::Plain(task) => { let mut patterns = current.prefix.clone(); patterns.push(key.to_string()); Some(FlatPickRule { patterns, - action: action.clone(), + tasks: task.clone().into(), }) } PickRule::Nested(nested_rules) => { @@ -175,121 +241,82 @@ impl Iterator for PickRuleIterator<'_> { #[cfg(test)] mod tests { use super::*; - use std::path::Path; - #[test] - fn parse_simple_manifest() { - let toml_str = r#" - [config] - source = "./src" - target = "./tgt" - - [[rules]] - "*.rs" = "copy" - "*.md" = "ignore" - "#; - - let manifest: Manifest = toml::from_str(toml_str).unwrap(); - - assert_eq!(manifest.config.source, Path::new("./src")); - assert_eq!(manifest.config.target, Path::new("./tgt")); - - let rules: Vec = manifest.rules.iter().collect(); - assert_eq!(rules.len(), 2); - - assert_eq!(rules[0].patterns, vec!["*.rs"]); - assert_eq!(rules[0].action, "copy"); - - assert_eq!(rules[1].patterns, vec!["*.md"]); - assert_eq!(rules[1].action, "ignore"); + #[derive(Debug, Clone, Deserialize)] + struct TestManifest { + rules: PickRules, } #[test] fn rule_ordering_preserved() { let toml_str = r#" - [config] - source = "./src" - target = "./tgt" - [[rules]] "third" = "c" "first" = "a" "second" = "b" "#; - let manifest: Manifest = toml::from_str(toml_str).unwrap(); - let rules: Vec = manifest.rules.iter().collect(); + let test_manifest: TestManifest = toml::from_str(toml_str).unwrap(); + let rules: Vec = test_manifest.rules.iter().collect(); assert_eq!(rules.len(), 3); assert_eq!(rules[0].patterns, vec!["third"]); - assert_eq!(rules[0].action, "c"); + assert_eq!(rules[0].tasks, vec!["c"]); assert_eq!(rules[1].patterns, vec!["first"]); - assert_eq!(rules[1].action, "a"); + assert_eq!(rules[1].tasks, vec!["a"]); assert_eq!(rules[2].patterns, vec!["second"]); - assert_eq!(rules[2].action, "b"); + assert_eq!(rules[2].tasks, vec!["b"]); } #[test] fn nested_rules_order() { let toml_str = r#" - [config] - source = "./src" - target = "./tgt" - [[rules]] - "a" = "action_a" - "b" = "action_b" + "a" = "task_a" + "b" = "task_b" [[rules."nested"]] - "c" = "action_c" - "d" = "action_d" + "c" = "task_c" + "d" = "task_d" [[rules]] - "e" = "action_e" + "e" = "task_e" "#; - let manifest: Manifest = toml::from_str(toml_str).unwrap(); - let rules: Vec = manifest.rules.iter().collect(); + let test_manifest: TestManifest = toml::from_str(toml_str).unwrap(); + let rules: Vec = test_manifest.rules.iter().collect(); assert_eq!(rules.len(), 5); assert_eq!(rules[0].patterns, vec!["a"]); - assert_eq!(rules[0].action, "action_a"); + assert_eq!(rules[0].tasks, vec!["task_a"]); assert_eq!(rules[1].patterns, vec!["b"]); - assert_eq!(rules[1].action, "action_b"); + assert_eq!(rules[1].tasks, vec!["task_b"]); assert_eq!(rules[2].patterns, vec!["nested", "c"]); - assert_eq!(rules[2].action, "action_c"); + assert_eq!(rules[2].tasks, vec!["task_c"]); assert_eq!(rules[3].patterns, vec!["nested", "d"]); - assert_eq!(rules[3].action, "action_d"); + assert_eq!(rules[3].tasks, vec!["task_d"]); assert_eq!(rules[4].patterns, vec!["e"]); - assert_eq!(rules[4].action, "action_e"); + assert_eq!(rules[4].tasks, vec!["task_e"]); } #[test] fn deeply_nested_rules() { let toml_str = r#" - [config] - source = "./src" - target = "./tgt" - [[rules."a"."b"."c"]] - "d" = "action_d" + "d" = "task_d" "#; - let manifest: Manifest = toml::from_str(toml_str).unwrap(); - let rules: Vec = manifest.rules.iter().collect(); + let test_manifest: TestManifest = toml::from_str(toml_str).unwrap(); + let rules: Vec = test_manifest.rules.iter().collect(); assert_eq!(rules.len(), 1); assert_eq!(rules[0].patterns, vec!["a", "b", "c", "d"]); - assert_eq!(rules[0].action, "action_d"); + assert_eq!(rules[0].tasks, vec!["task_d"]); } #[test] fn multiple_maps_same_level() { let toml_str = r#" - [config] - source = "./src" - target = "./tgt" - [[rules]] "a1" = "copy" "a2" = "ignore" @@ -299,91 +326,41 @@ mod tests { "b2" = "ignore" "#; - let manifest: Manifest = toml::from_str(toml_str).unwrap(); - let rules: Vec = manifest.rules.iter().collect(); + let test_manifest: TestManifest = toml::from_str(toml_str).unwrap(); + let rules: Vec = test_manifest.rules.iter().collect(); - // Test that all rules exist and are in the correct order assert_eq!(rules.len(), 4); assert_eq!(rules[0].patterns, vec!["a1"]); - assert_eq!(rules[0].action, "copy"); + assert_eq!(rules[0].tasks, vec!["copy"]); assert_eq!(rules[1].patterns, vec!["a2"]); - assert_eq!(rules[1].action, "ignore"); + assert_eq!(rules[1].tasks, vec!["ignore"]); assert_eq!(rules[2].patterns, vec!["b1"]); - assert_eq!(rules[2].action, "copy"); + assert_eq!(rules[2].tasks, vec!["copy"]); assert_eq!(rules[3].patterns, vec!["b2"]); - assert_eq!(rules[3].action, "ignore"); + assert_eq!(rules[3].tasks, vec!["ignore"]); } #[test] fn empty_rules_list() { let toml_str = r#" - [config] - source = "./src" - target = "./tgt" - [[rules]] "#; - let manifest: Manifest = toml::from_str(toml_str).unwrap(); - let rules: Vec = manifest.rules.iter().collect(); + let test_manifest: TestManifest = toml::from_str(toml_str).unwrap(); + let rules: Vec = test_manifest.rules.iter().collect(); assert_eq!(rules.len(), 0); } - #[test] - #[should_panic(expected = "missing field `config`")] - fn missing_config() { - let toml_str = r#" - [[rules]] - "a" = "copy" - "#; - - let _: Manifest = toml::from_str(toml_str).unwrap(); - } - - #[test] - #[should_panic(expected = "missing field `source`")] - fn incomplete_config() { - let toml_str = r#" - [config] - target = "./tgt" - - [[rules]] - "a" = "copy" - "#; - - let _: Manifest = toml::from_str(toml_str).unwrap(); - } - - #[test] - #[should_panic] - fn invalid_toml_syntax() { - let toml_str = r#" - [config] - source = "./src" - target = "./tgt" - - [[rules]] - "invalid" = { this is not valid TOML } - "#; - - let _: Manifest = toml::from_str(toml_str).unwrap(); - } - #[test] fn mixed_rule_types() { let toml_str = r#" - [config] - source = "./src" - target = "./tgt" - [[rules]] "plain" = "copy" "nested" = { invalid_as_string = true } "#; - // This should fail because a table is not a valid PickRule - let result = toml::from_str::(toml_str); + let result = toml::from_str::(toml_str); assert!(result.is_err()); } } diff --git a/src/tool/bash.rs b/src/tool/bash.rs new file mode 100644 index 0000000..a1f76ca --- /dev/null +++ b/src/tool/bash.rs @@ -0,0 +1,174 @@ +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::io::Write; +use std::{collections::HashMap, path::Path}; +use tracing::{error, trace, warn}; + +use crate::manifest::PickConfig; + +use super::{PickTool, TaskContext}; + +#[derive(Debug, Deserialize)] +pub struct ToolBash { + #[serde(default)] + pub before: Option, + + #[serde(default)] + pub after: Option, + + #[serde(default)] + pub env: HashMap, + + #[serde(default)] + pub script: HashMap, +} + +impl PickTool for ToolBash { + fn before(&self, manifest_path: &Path, cfg: &PickConfig) -> Result<()> { + let script = match &self.before { + None => { + return Ok(()); + } + + Some(script) => { + trace!("Running `before` script"); + let mut temp_file = + tempfile::NamedTempFile::new().context("while creating temporary script")?; + writeln!(temp_file, "{}", script).context("while creating temporary script")?; + temp_file + } + }; + + let mut cmd = std::process::Command::new("bash"); + cmd.arg(script.path()); + cmd.current_dir(&cfg.work_dir(manifest_path)?); + + for (key, value) in &self.env { + cmd.env(key, value); + } + + let output = match cmd.output() { + Ok(output) => output, + Err(error) => { + error!("Failed to execute `before` script: {error}"); + return Ok(()); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + error!( + "`before` script failed with status {}: {stderr}", + output.status.code().unwrap_or(-1) + ); + } else { + let stdout = String::from_utf8_lossy(&output.stdout); + #[expect(clippy::print_stdout)] + if !stdout.is_empty() { + println!("{}", stdout.trim()); + } + } + + return Ok(()); + } + + fn after(&self, manifest_path: &Path, cfg: &PickConfig) -> Result<()> { + let script = match &self.after { + None => { + return Ok(()); + } + + Some(script) => { + trace!("Running `after` script"); + let mut temp_file = + tempfile::NamedTempFile::new().context("while creating temporary script")?; + writeln!(temp_file, "{}", script).context("while creating temporary script")?; + temp_file + } + }; + + let mut cmd = std::process::Command::new("bash"); + cmd.arg(script.path()); + cmd.current_dir(&cfg.work_dir(manifest_path)?); + + for (key, value) in &self.env { + cmd.env(key, value); + } + + let output = match cmd.output() { + Ok(output) => output, + Err(error) => { + error!("Failed to execute `after` script: {error}"); + return Ok(()); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + error!( + "`after` script failed with status {}: {stderr}", + output.status.code().unwrap_or(-1) + ); + } else { + let stdout = String::from_utf8_lossy(&output.stdout); + #[expect(clippy::print_stdout)] + if !stdout.is_empty() { + println!("{}", stdout.trim()); + } + } + + return Ok(()); + } + + fn run(&self, manifest_path: &Path, cfg: &PickConfig, ctx: TaskContext) -> Result<()> { + let script = match self.script.get(&ctx.task) { + None => { + warn!("No script named \"{}\"", ctx.task); + return Ok(()); + } + + Some(script) => { + trace!("Running script for {}: {}", ctx.path_rel_str, ctx.task); + let mut temp_file = + tempfile::NamedTempFile::new().context("while creating temporary script")?; + writeln!(temp_file, "{}", script).context("while creating temporary script")?; + temp_file + } + }; + + let mut cmd = std::process::Command::new("bash"); + cmd.arg(script.path()); + cmd.current_dir(&cfg.work_dir(manifest_path)?); + + cmd.env("PICK_FILE", &ctx.path_abs_str); + cmd.env("PICK_RELATIVE", &ctx.path_rel_str); + for (key, value) in &self.env { + cmd.env(key, value); + } + + let output = match cmd.output() { + Ok(output) => output, + Err(error) => { + error!("Failed to execute script for {}: {error}", ctx.path_rel_str); + return Ok(()); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + error!( + "Script for {} failed with status {}: {stderr}", + ctx.path_rel_str, + output.status.code().unwrap_or(-1) + ); + } else { + let stdout = String::from_utf8_lossy(&output.stdout); + #[expect(clippy::print_stdout)] + if !stdout.is_empty() { + println!("{}", stdout.trim()); + } + } + + return Ok(()); + } +} diff --git a/src/tool/mod.rs b/src/tool/mod.rs new file mode 100644 index 0000000..690d879 --- /dev/null +++ b/src/tool/mod.rs @@ -0,0 +1,48 @@ +use anyhow::Result; +use serde::{Deserialize, de::DeserializeOwned}; +use std::{ + fmt::Debug, + path::{Path, PathBuf}, +}; + +mod bash; +pub use bash::*; + +use crate::manifest::PickConfig; + +pub trait PickTool: Debug + DeserializeOwned { + /// Runs once, before all tasks + fn before(&self, manifest_path: &Path, cfg: &PickConfig) -> Result<()>; + + /// Runs once, after all tasks + fn after(&self, manifest_path: &Path, cfg: &PickConfig) -> Result<()>; + + /// Runs once per task + fn run(&self, manifest_path: &Path, cfg: &PickConfig, ctx: TaskContext) -> Result<()>; +} + +#[derive(Debug, Clone)] +/// All the information available when running a task +pub struct TaskContext { + /// The task to run + pub task: String, + + /// The absolute path of the file we're processing + pub path_abs: PathBuf, + + /// `self.path_abs` as a string + pub path_abs_str: String, + + /// The path of the file we're processing, + /// relative to our working directory. + pub path_rel: PathBuf, + + /// `self.path_rel`` as a string + pub path_rel_str: String, +} + +#[derive(Debug, Deserialize)] +pub struct ToolConfig { + #[serde(default)] + pub bash: Option, +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..a29080b --- /dev/null +++ b/src/util.rs @@ -0,0 +1,37 @@ +use anstyle::{AnsiColor, Color, Style}; + +pub fn get_styles() -> clap::builder::Styles { + clap::builder::Styles::styled() + .usage( + Style::new() + .bold() + .fg_color(Some(Color::Ansi(AnsiColor::Blue))), + ) + .header( + Style::new() + .bold() + .fg_color(Some(Color::Ansi(AnsiColor::Blue))), + ) + .literal( + Style::new() + .bold() + .fg_color(Some(Color::Ansi(AnsiColor::BrightBlack))), + ) + .invalid( + Style::new() + .bold() + .fg_color(Some(Color::Ansi(AnsiColor::Red))), + ) + .error( + Style::new() + .bold() + .fg_color(Some(Color::Ansi(AnsiColor::Red))), + ) + .valid( + Style::new() + .bold() + .underline() + .fg_color(Some(Color::Ansi(AnsiColor::Green))), + ) + .placeholder(Style::new().fg_color(Some(Color::Ansi(AnsiColor::White)))) +} diff --git a/test.toml b/test.toml new file mode 100644 index 0000000..3c41598 --- /dev/null +++ b/test.toml @@ -0,0 +1,59 @@ +# All paths are relative to workdir. +# Workdir is this file's parent by default. +# If workdir is relative, it is relative to this file's parent. +[config] +work_dir = "./music" +# follow_links: if true, follow symlinks (default false) +# dirs: if true, act on directories (default false) +# files: if true, act on regular files (default true) +# links: if true, act on symlinks (default false. throw an error if this is provided with follow_links) + +[tool.bash] +script.test = """ +mkdir -p "$(dirname "../out/${PICK_RELATIVE}")" + +filename="${PICK_RELATIVE%.*}" + +ffmpeg \ + -i "${PICK_FILE}" \ + -map_metadata 0 \ + -id3v2_version 3 \ + -b:a 192k \ + -loglevel error \ + -hide_banner -n \ + "../out/${filename}.mp3" +""" + +script.ogg = """ +mkdir -p "$(dirname "../out/${PICK_RELATIVE}")" + +filename="${PICK_RELATIVE%.*}" + +ffmpeg \ + -i "${PICK_FILE}" \ + -c:v libtheora \ + -q:v 10 \ + -c:a libopus \ + -b:a 192k \ + -loglevel error \ + -hide_banner -n \ + "../out/${filename}.ogg" +""" + + +# The first rule to match a path is run. +# Paths are checked relative to source. +# "/source/path/to/file.gz" becomes "path/to/file.gz" +# +# a "path segment" is a single file or directory. +# +# * matches exactly one path segment. In regex, this is [^/]+ +# ** matches zero or more path segments. In regex, this is ([^/]+)* +# +# All rules are matched against the FULL PATH of files. +# Directories are ignored. +[[rules]] +"**" = "test" + +[[rules]] +"**" = "" diff --git a/typos.toml b/typos.toml new file mode 100644 index 0000000..305dcb9 --- /dev/null +++ b/typos.toml @@ -0,0 +1,10 @@ +[default] +extend-words."keynode" = "keynode" + +extend-ignore-re = [ + # spell:disable-line + "(?Rm)^.*(%|#|//|;)\\s*spell:disable-line$", + + # spell: + "(?s)(%|#|//|;)\\s*spell:off.*?\\n\\s*(%|#|//)\\s*spell:on", +]