From f7035abc1aac4f7dc6823e0a9cc461e63039a8d4 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:48:43 -0400 Subject: [PATCH 1/9] test(rust): assert root-relative paths are slash-separated --- packages/rust/src/scanner.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/rust/src/scanner.rs b/packages/rust/src/scanner.rs index 02d1fbe7..d15a09d5 100644 --- a/packages/rust/src/scanner.rs +++ b/packages/rust/src/scanner.rs @@ -10,6 +10,16 @@ use walkdir::WalkDir; /// whether persistence is enabled. pub const RESERVED_DIR: &str = ".dirsql"; +/// A root-relative path as dirsql stores and reports it: `/`-separated on +/// every platform, so globs, `file_path` keys and `path` columns agree. +pub fn to_slash(path: &Path) -> String { + with_slashes(&path.to_string_lossy(), std::path::MAIN_SEPARATOR) +} + +fn with_slashes(path: &str, _native: char) -> String { + path.to_owned() +} + /// Walk a directory tree and return all file paths paired with their matching table name. /// Ignored paths and directories are skipped. Only files (not directories) are returned. /// @@ -354,6 +364,26 @@ mod tests { assert!(!is_glob_match(&set, Path::new("a.md"))); } + #[test] + fn with_slashes_rewrites_a_backslash_native_separator() { + assert_eq!(with_slashes(r"moved\one\mid.txt", '\\'), "moved/one/mid.txt"); + } + + #[test] + fn with_slashes_keeps_a_mixed_path_slash_separated() { + assert_eq!(with_slashes(r"a/b\c.txt", '\\'), "a/b/c.txt"); + } + + #[test] + fn with_slashes_leaves_a_backslash_in_a_unix_file_name() { + assert_eq!(with_slashes(r"dir/a\b.txt", '/'), r"dir/a\b.txt"); + } + + #[test] + fn to_slash_keeps_a_native_unix_path() { + assert_eq!(to_slash(Path::new("docs/nested/a.md")), "docs/nested/a.md"); + } + #[test] fn compile_glob_rejects_an_invalid_pattern() { assert!(compile_glob("[").is_err()); From 2e97244b40e34b1ef4d353e29bbdc21f7d5c80dd Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:51:47 -0400 Subject: [PATCH 2/9] fix(rust): slash-separate root-relative paths on every platform --- .../2026-09-28-windows-slash-paths.md | 1 + packages/rust/src/db.rs | 2 +- packages/rust/src/lib.rs | 13 +++---------- packages/rust/src/parsed_vtab.rs | 8 ++++---- packages/rust/src/scanner.rs | 12 +++++++++--- packages/rust/src/vtab.rs | 18 ++++++++++++++---- 6 files changed, 32 insertions(+), 22 deletions(-) create mode 100644 packages/rust/changelog.d/2026-09-28-windows-slash-paths.md diff --git a/packages/rust/changelog.d/2026-09-28-windows-slash-paths.md b/packages/rust/changelog.d/2026-09-28-windows-slash-paths.md new file mode 100644 index 00000000..fe240113 --- /dev/null +++ b/packages/rust/changelog.d/2026-09-28-windows-slash-paths.md @@ -0,0 +1 @@ +**Fixed** Root-relative paths are `/`-separated on Windows too. The scanner, the watcher, `file_path`, the `path` column and path-table results no longer report `docs\a.md`, and a path-table under a prefix no longer joins with a mixed separator. diff --git a/packages/rust/src/db.rs b/packages/rust/src/db.rs index c3ed2d90..d2b7fe17 100644 --- a/packages/rust/src/db.rs +++ b/packages/rust/src/db.rs @@ -655,7 +655,7 @@ impl Db { /// Every distinct `(table_name, file_path)` whose file sits beneath the /// directory `dir`, ordered by table then path. pub fn files_under(&self, dir: &str) -> Result> { - let prefix = format!("{dir}{}", std::path::MAIN_SEPARATOR); + let prefix = format!("{dir}/"); let mut stmt = self.conn.prepare( "SELECT DISTINCT table_name, file_path FROM _dirsql_internal_rows \ WHERE substr(file_path, 1, length(?1)) = ?1 \ diff --git a/packages/rust/src/lib.rs b/packages/rust/src/lib.rs index 2bd2645b..11a755eb 100644 --- a/packages/rust/src/lib.rs +++ b/packages/rust/src/lib.rs @@ -571,7 +571,7 @@ impl DirSQL { // unspecified. An `on_file` failure produces an error event for that // table only; the other matching tables still process the event. let matches = self.inner.matcher.match_all(&rel_path_buf); - let rel_path = rel_path_buf.to_string_lossy().to_string(); + let rel_path = scanner::to_slash(&rel_path_buf); let mut events = Vec::new(); for m in matches { @@ -625,11 +625,7 @@ impl DirSQL { }; let mut events = Vec::new(); for (path, table) in self.inner.fs.scan_subtree(base, dir, &self.inner.matcher) { - let rel_path = path - .strip_prefix(base) - .unwrap_or(&path) - .to_string_lossy() - .to_string(); + let rel_path = scanner::to_slash(path.strip_prefix(base).unwrap_or(&path)); events.extend(self.handle_upsert(&table, &path, &rel_path)); } events @@ -1836,10 +1832,7 @@ fn run_channel_loop(db: DirSQL, tx: UnboundedSender) { } fn relative_path(root: &Path, path: &Path) -> String { - path.strip_prefix(root) - .unwrap_or(path) - .to_string_lossy() - .to_string() + scanner::to_slash(path.strip_prefix(root).unwrap_or(path)) } /// Build [`Table`] objects from a parsed config. diff --git a/packages/rust/src/parsed_vtab.rs b/packages/rust/src/parsed_vtab.rs index 97b3029c..aaedb203 100644 --- a/packages/rust/src/parsed_vtab.rs +++ b/packages/rust/src/parsed_vtab.rs @@ -36,7 +36,7 @@ use crate::matcher::TableMatcher; use crate::parsed_cache::{self, CachedParse, Entry, RowCache, SqliteRowCache}; use crate::path_table; use crate::persist::{FileStat, hash_file, now_ns}; -use crate::scanner::scan_glob; +use crate::scanner::{scan_glob, to_slash}; use crate::vtab_scaffold::{self, TableSource}; /// SQL module name a parsed path-table is created with. @@ -161,7 +161,7 @@ fn collect_rows_cached( let mut seen: Vec<(String, Parsed)> = Vec::with_capacity(rel_paths.len()); for rel_path in rel_paths { - let key = rel_path.to_string_lossy().into_owned(); + let key = to_slash(rel_path); let Some(live) = fs.stat(rel_path) else { // The file vanished between the scan and the stat. Nothing to // parse and nothing to cache; the next run decides afresh. @@ -268,7 +268,7 @@ impl ParsedFs for RootedFs<'_> { fn command_skip_message(rel_path: &Path, error: &str) -> String { format!( "dirsql: skipping `{}`: on-file command failed: {error}", - rel_path.display() + to_slash(rel_path) ) } @@ -276,7 +276,7 @@ fn command_skip_message(rel_path: &Path, error: &str) -> String { fn parse_skip_message(rel_path: &Path, message: &str) -> String { format!( "dirsql: skipping `{}`: on-file output was not a JSON array of rows: {message}", - rel_path.display() + to_slash(rel_path) ) } diff --git a/packages/rust/src/scanner.rs b/packages/rust/src/scanner.rs index d15a09d5..08c0e7fc 100644 --- a/packages/rust/src/scanner.rs +++ b/packages/rust/src/scanner.rs @@ -16,8 +16,11 @@ pub fn to_slash(path: &Path) -> String { with_slashes(&path.to_string_lossy(), std::path::MAIN_SEPARATOR) } -fn with_slashes(path: &str, _native: char) -> String { - path.to_owned() +fn with_slashes(path: &str, native: char) -> String { + if native == '/' { + return path.to_owned(); + } + path.replace(native, "/") } /// Walk a directory tree and return all file paths paired with their matching table name. @@ -366,7 +369,10 @@ mod tests { #[test] fn with_slashes_rewrites_a_backslash_native_separator() { - assert_eq!(with_slashes(r"moved\one\mid.txt", '\\'), "moved/one/mid.txt"); + assert_eq!( + with_slashes(r"moved\one\mid.txt", '\\'), + "moved/one/mid.txt" + ); } #[test] diff --git a/packages/rust/src/vtab.rs b/packages/rust/src/vtab.rs index abbdcbfc..6e3bcb0f 100644 --- a/packages/rust/src/vtab.rs +++ b/packages/rust/src/vtab.rs @@ -9,7 +9,7 @@ use rusqlite::{Connection, Result}; use crate::compute_stat_virtuals; use crate::matcher::TableMatcher; use crate::path_table; -use crate::scanner::scan_glob; +use crate::scanner::{scan_glob, to_slash}; use crate::vtab_scaffold::{self, TableSource}; use crate::{Row, Value}; @@ -111,10 +111,15 @@ fn parse_module_args(args: &[&[u8]]) -> Result { /// The string a matched file is reported under: the relative path as scanned, /// under the table's path prefix when it has one. fn reported_path(path_prefix: &Path, rel_path: &Path) -> String { - if path_prefix.as_os_str().is_empty() { - return rel_path.to_string_lossy().into_owned(); + let rel_path = to_slash(rel_path); + let prefix = path_prefix.to_string_lossy(); + if prefix.is_empty() { + return rel_path; } - path_prefix.join(rel_path).to_string_lossy().into_owned() + if prefix.ends_with(['/', std::path::MAIN_SEPARATOR]) { + return format!("{prefix}{rel_path}"); + } + format!("{prefix}/{rel_path}") } /// Whether `column` addresses the hidden `content` column. @@ -388,6 +393,11 @@ mod tests { ); } + #[test] + fn reported_path_under_the_filesystem_root_has_one_separator() { + assert_eq!(reported_path(Path::new("/"), Path::new("a.log")), "/a.log"); + } + #[test] fn is_content_column_identifies_only_the_hidden_column() { assert!(is_content_column(CONTENT_COLUMN)); From 2f1c87a1c9ad3b694ec5793a6b30a5cda8fcdffe Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:54:07 -0400 Subject: [PATCH 3/9] e2e attestation for claude/1184-win-rel-paths --- .../python/e2e-attestations/claude-1184-win-rel-paths.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/python/e2e-attestations/claude-1184-win-rel-paths.json diff --git a/packages/python/e2e-attestations/claude-1184-win-rel-paths.json b/packages/python/e2e-attestations/claude-1184-win-rel-paths.json new file mode 100644 index 00000000..f2fc24d8 --- /dev/null +++ b/packages/python/e2e-attestations/claude-1184-win-rel-paths.json @@ -0,0 +1,7 @@ +{ + "command": "just test-e2e", + "ran_at": 1790603647, + "exit_code": 0, + "commit": "2e97244b40e34b1ef4d353e29bbdc21f7d5c80dd", + "branch": "claude/1184-win-rel-paths" +} From 122f9411cbbafdbff1c0dcb7af37269cc1f8c061 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:55:46 -0400 Subject: [PATCH 4/9] e2e attestation for claude/1184-win-rel-paths --- .../ts/e2e-attestations/claude-1184-win-rel-paths.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/ts/e2e-attestations/claude-1184-win-rel-paths.json diff --git a/packages/ts/e2e-attestations/claude-1184-win-rel-paths.json b/packages/ts/e2e-attestations/claude-1184-win-rel-paths.json new file mode 100644 index 00000000..3df3cb91 --- /dev/null +++ b/packages/ts/e2e-attestations/claude-1184-win-rel-paths.json @@ -0,0 +1,7 @@ +{ + "command": "pnpm test:e2e", + "ran_at": 1790603746, + "exit_code": 0, + "commit": "2f1c87a1c9ad3b694ec5793a6b30a5cda8fcdffe", + "branch": "claude/1184-win-rel-paths" +} From 96dbc0c045f9d2430ef9e5eb2e610f6719b03d97 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:23:31 -0400 Subject: [PATCH 5/9] test(rust): derive watch fixture paths from either root spelling, slash-separated --- packages/rust/tests/watch_scan_correctness.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/rust/tests/watch_scan_correctness.rs b/packages/rust/tests/watch_scan_correctness.rs index 60faaee3..c5e558df 100644 --- a/packages/rust/tests/watch_scan_correctness.rs +++ b/packages/rust/tests/watch_scan_correctness.rs @@ -13,7 +13,10 @@ use std::time::{Duration, Instant}; /// A table matching *every* path (like the default `files` table's `**/*`), /// so a newly created subdirectory is a matcher candidate on the watch path. fn files_table(root: &std::path::Path) -> Table { - let root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + // The scan reports paths under `root` as given and the watcher under its + // canonical form; on Windows those differ (`\\?\` prefix, long names). + let given = root.to_path_buf(); + let canonical = fs::canonicalize(root).unwrap_or_else(|_| given.clone()); Table::new( "files", "CREATE TABLE files (name TEXT, path TEXT)", @@ -22,10 +25,11 @@ fn files_table(root: &std::path::Path) -> Table { let content = fs::read_to_string(path).unwrap_or_default(); let abs = std::path::Path::new(path); let rel = abs - .strip_prefix(&root) + .strip_prefix(&canonical) + .or_else(|_| abs.strip_prefix(&given)) .unwrap_or(abs) .to_string_lossy() - .into_owned(); + .replace(std::path::MAIN_SEPARATOR, "/"); vec![HashMap::from([ ("name".to_string(), Value::Text(content.trim().to_string())), ("path".to_string(), Value::Text(rel)), From 861ff1bc9905c8bacf4ce1e65c3abf0c78aee7f4 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:23:31 -0400 Subject: [PATCH 6/9] fix(rust): keep to_slash crate-private --- packages/rust/src/scanner.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rust/src/scanner.rs b/packages/rust/src/scanner.rs index 08c0e7fc..e7c91a9e 100644 --- a/packages/rust/src/scanner.rs +++ b/packages/rust/src/scanner.rs @@ -12,7 +12,7 @@ pub const RESERVED_DIR: &str = ".dirsql"; /// A root-relative path as dirsql stores and reports it: `/`-separated on /// every platform, so globs, `file_path` keys and `path` columns agree. -pub fn to_slash(path: &Path) -> String { +pub(crate) fn to_slash(path: &Path) -> String { with_slashes(&path.to_string_lossy(), std::path::MAIN_SEPARATOR) } From ab668f9f958ed79fe0598f6cc494f1d0cc049316 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:23:31 -0400 Subject: [PATCH 7/9] docs(rust): migration fragment for slash-separated paths on Windows --- .../2026-09-28-windows-slash-paths.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 packages/rust/migrations.d/2026-09-28-windows-slash-paths.md diff --git a/packages/rust/migrations.d/2026-09-28-windows-slash-paths.md b/packages/rust/migrations.d/2026-09-28-windows-slash-paths.md new file mode 100644 index 00000000..2b418873 --- /dev/null +++ b/packages/rust/migrations.d/2026-09-28-windows-slash-paths.md @@ -0,0 +1,34 @@ +### Root-relative paths are `/`-separated on Windows + +**Summary** + +On Windows, dirsql reported root-relative paths with `\`: `file_path` in row +events, the `path` column of path-tables, and the rows the watcher wrote. +They now use `/` on every platform, matching Linux and macOS. No signature +changed. Output on Unix is unchanged. + +**Required changes** + +_None._ + +**Deprecations removed** + +_None._ + +**Behavior changes without code changes** + +| Surface (Windows only) | Before | After | +|---|---|---| +| `SELECT path FROM './'` | `docs\a.md` | `docs/a.md` | +| `RowEvent` `file_path` | `nested\a.txt` | `nested/a.txt` | +| Path-table under an absolute prefix | `C:\data\logs\a.log` | `C:\data\logs/a.log` | + +A persisted parsed-path-table cache written on Windows before this change is +keyed by `\` paths, so its first run after the upgrade re-parses every file. + +**Verification** + +```bash +dirsql "SELECT path FROM './' LIMIT 1" +# [{"path":"docs/a.md"}] -- `/` separators on Windows too +``` From 1bd09b2f31221125606dc5383fb8122038d1e14f Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:24:06 -0400 Subject: [PATCH 8/9] e2e attestation for claude/1184-win-rel-paths --- .../python/e2e-attestations/claude-1184-win-rel-paths.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/python/e2e-attestations/claude-1184-win-rel-paths.json b/packages/python/e2e-attestations/claude-1184-win-rel-paths.json index f2fc24d8..512c4785 100644 --- a/packages/python/e2e-attestations/claude-1184-win-rel-paths.json +++ b/packages/python/e2e-attestations/claude-1184-win-rel-paths.json @@ -1,7 +1,7 @@ { "command": "just test-e2e", - "ran_at": 1790603647, + "ran_at": 1790605446, "exit_code": 0, - "commit": "2e97244b40e34b1ef4d353e29bbdc21f7d5c80dd", + "commit": "ab668f9f958ed79fe0598f6cc494f1d0cc049316", "branch": "claude/1184-win-rel-paths" } From 94c09d57b32a4132e9e1566489dfccd27975fec7 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:25:13 -0400 Subject: [PATCH 9/9] e2e attestation for claude/1184-win-rel-paths --- packages/ts/e2e-attestations/claude-1184-win-rel-paths.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ts/e2e-attestations/claude-1184-win-rel-paths.json b/packages/ts/e2e-attestations/claude-1184-win-rel-paths.json index 3df3cb91..6965e1cf 100644 --- a/packages/ts/e2e-attestations/claude-1184-win-rel-paths.json +++ b/packages/ts/e2e-attestations/claude-1184-win-rel-paths.json @@ -1,7 +1,7 @@ { "command": "pnpm test:e2e", - "ran_at": 1790603746, + "ran_at": 1790605513, "exit_code": 0, - "commit": "2f1c87a1c9ad3b694ec5793a6b30a5cda8fcdffe", + "commit": "1bd09b2f31221125606dc5383fb8122038d1e14f", "branch": "claude/1184-win-rel-paths" }