From 7ec156fc056d7dd5a199e16b5adf2a253464e4b2 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Wed, 13 May 2026 09:23:51 -0400 Subject: [PATCH] libraries: accept newline as an excluded_dirs separator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits parse_excluded_dirs_column on `,`, `\n`, AND `\r` so a textarea submit with one entry per line works the same as comma-separated. Mixed input (`a, b\nc`) parses cleanly too — the frontend can paste from any source without preprocessing. Motivated by the "forgot the comma" footgun: typing `.thumbnails .thumbnails2` in a single-line input today stores a never-matching component pattern. With newlines as a first-class separator and the frontend switching to a textarea, the natural one-per-line UX makes that mistake impossible. The DB store form stays comma-joined (normalize_excluded_dirs_input hasn't changed) so existing rows are unaffected and no migration is needed. Newline support matters mostly for the inbound write path; mirroring it on the read side keeps the parser round-trip safe in case anything writes a newline form directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/libraries.rs | 51 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/src/libraries.rs b/src/libraries.rs index 02d02ee..59b614a 100644 --- a/src/libraries.rs +++ b/src/libraries.rs @@ -80,16 +80,21 @@ impl Library { } } -/// Parse a comma-separated excluded_dirs column into a Vec, dropping -/// empty entries (mirrors `AppState::parse_excluded_dirs` for the env -/// var). NULL → empty Vec. Duplicates are preserved — `PathExcluder` -/// accepts repeats, and the storage-side normaliser is where dedup -/// happens. +/// Parse an excluded_dirs string into a Vec, dropping empty entries. +/// NULL → empty Vec. Duplicates are preserved — `PathExcluder` accepts +/// repeats, and the storage-side normaliser is where dedup happens. +/// +/// Accepts both `,` and newline (`\n` / `\r\n`) as separators so the +/// UI's textarea can submit one-entry-per-line input without forcing +/// the operator to remember commas. The DB stores the canonical +/// comma-joined form (see `normalize_excluded_dirs_input`); the +/// newline path matters mostly for the frontend submit, but mirroring +/// it here keeps the parse direction round-trip safe. pub fn parse_excluded_dirs_column(raw: Option<&str>) -> Vec { match raw { None => Vec::new(), Some(s) => s - .split(',') + .split(|c: char| matches!(c, ',' | '\n' | '\r')) .map(str::trim) .filter(|s| !s.is_empty()) .map(String::from) @@ -739,6 +744,40 @@ mod tests { ); } + #[test] + fn parse_excluded_dirs_column_splits_on_newlines_too() { + // Newline-separated input from a textarea submit. One-per-line + // is the recommended UX because "I forgot the comma" was a + // recurring footgun (.thumbnails .thumbnails2 silently + // becomes a single never-matching pattern). + assert_eq!( + parse_excluded_dirs_column(Some("@eaDir\n.thumbnails\n/private")), + vec![ + "@eaDir".to_string(), + ".thumbnails".to_string(), + "/private".to_string() + ] + ); + // Windows line endings (CRLF) — the carriage return is its own + // separator so the trailing empty token between \r and \n gets + // trimmed + dropped. + assert_eq!( + parse_excluded_dirs_column(Some("a\r\nb\r\nc")), + vec!["a".to_string(), "b".to_string(), "c".to_string()] + ); + // Mixed comma + newline — the user pastes from one source, + // adds a few entries inline. Both work, in any combination. + assert_eq!( + parse_excluded_dirs_column(Some("a, b\nc,d")), + vec![ + "a".to_string(), + "b".to_string(), + "c".to_string(), + "d".to_string() + ] + ); + } + #[test] fn effective_excluded_dirs_unions_global_and_per_library() { let lib_no_extras = Library {