Skip to content
Open
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "just test-e2e",
"ran_at": 1790605446,
"exit_code": 0,
"commit": "ab668f9f958ed79fe0598f6cc494f1d0cc049316",
"branch": "claude/1184-win-rel-paths"
}
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 34 additions & 0 deletions packages/rust/migrations.d/2026-09-28-windows-slash-paths.md
Original file line number Diff line number Diff line change
@@ -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
```
2 changes: 1 addition & 1 deletion packages/rust/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<(String, String)>> {
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 \
Expand Down
13 changes: 3 additions & 10 deletions packages/rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1836,10 +1832,7 @@ fn run_channel_loop(db: DirSQL, tx: UnboundedSender<RowEvent>) {
}

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.
Expand Down
8 changes: 4 additions & 4 deletions packages/rust/src/parsed_vtab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -268,15 +268,15 @@ 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)
)
}

/// Warning for a file whose parser output was not a JSON array of rows.
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)
)
}

Expand Down
36 changes: 36 additions & 0 deletions packages/rust/src/scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,19 @@ 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(crate) fn to_slash(path: &Path) -> String {
with_slashes(&path.to_string_lossy(), std::path::MAIN_SEPARATOR)
}

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.
/// Ignored paths and directories are skipped. Only files (not directories) are returned.
///
Expand Down Expand Up @@ -354,6 +367,29 @@ 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());
Expand Down
18 changes: 14 additions & 4 deletions packages/rust/src/vtab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -111,10 +111,15 @@ fn parse_module_args(args: &[&[u8]]) -> Result<ScanSpec> {
/// 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.
Expand Down Expand Up @@ -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));
Expand Down
10 changes: 7 additions & 3 deletions packages/rust/tests/watch_scan_correctness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand All @@ -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)),
Expand Down
7 changes: 7 additions & 0 deletions packages/ts/e2e-attestations/claude-1184-win-rel-paths.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "pnpm test:e2e",
"ran_at": 1790605513,
"exit_code": 0,
"commit": "1bd09b2f31221125606dc5383fb8122038d1e14f",
"branch": "claude/1184-win-rel-paths"
}
Loading