libraries: accept newline as an excluded_dirs separator

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) <noreply@anthropic.com>
This commit is contained in:
Cameron Cordes
2026-05-13 09:23:51 -04:00
parent 439532377d
commit 7ec156fc05

View File

@@ -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<String> {
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 {