From 3f9d78568bad8aa167884b1b994580be2acf9d98 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:53:41 -0400 Subject: [PATCH 1/5] fix(rust): resolve Windows absolute path-table names Drive-letter, UNC and ~\ names now resolve on Windows, and absolute path-tables report a /-separated prefix. Path parsing goes through typed-path so the Windows rules are unit-tested on any host. --- Cargo.lock | 7 + docs/reference/path-tables.md | 6 + packages/rust/Cargo.toml | 1 + ...2026-09-28-windows-absolute-path-tables.md | 1 + packages/rust/src/path_table.rs | 346 ++++++++++++++++-- packages/rust/tests/path_table_cli_e2e.rs | 10 +- packages/rust/tests/path_table_globs.rs | 16 +- 7 files changed, 345 insertions(+), 42 deletions(-) create mode 100644 packages/rust/changelog.d/2026-09-28-windows-absolute-path-tables.md diff --git a/Cargo.lock b/Cargo.lock index 8e7c3b44c..e2e8b9af1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -584,6 +584,7 @@ dependencies = [ "tokio", "tokio-stream", "toml", + "typed-path", "walkdir", ] @@ -2915,6 +2916,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "unicase" version = "2.9.0" diff --git a/docs/reference/path-tables.md b/docs/reference/path-tables.md index 1f155fb22..a282b8109 100644 --- a/docs/reference/path-tables.md +++ b/docs/reference/path-tables.md @@ -96,6 +96,12 @@ SELECT path FROM '/var/log/*.log'; -- /var/log/syslog ``` +On Windows, the absolute forms also include a drive letter +(`'C:\logs\*.log'` or `'C:/logs/*.log'`), a UNC share +(`'\\server\share\*.md'`) and `'~\notes'`. `\` and `/` both separate there, +and the `path` these report uses `/` throughout (`C:/logs/app.log`), matching +the `/`-separated relative paths. + On a system with no home directory, a `~/` path-table reports that it cannot resolve rather than guessing. diff --git a/packages/rust/Cargo.toml b/packages/rust/Cargo.toml index 47422d37e..f27e6e4d7 100644 --- a/packages/rust/Cargo.toml +++ b/packages/rust/Cargo.toml @@ -85,6 +85,7 @@ thiserror.workspace = true tokio.workspace = true tokio-stream = { version = "0.1", features = ["sync"], optional = true } toml = "0.8" +typed-path = "0.12" walkdir = "2" [lints.rust] diff --git a/packages/rust/changelog.d/2026-09-28-windows-absolute-path-tables.md b/packages/rust/changelog.d/2026-09-28-windows-absolute-path-tables.md new file mode 100644 index 000000000..b43bb6e5e --- /dev/null +++ b/packages/rust/changelog.d/2026-09-28-windows-absolute-path-tables.md @@ -0,0 +1 @@ +**Fixed** On Windows, a path-table named by a drive letter (`C:\logs\*.log`, `C:/logs/*.log`), a UNC share, or `~\` now resolves instead of failing with `no such table`. Absolute path-tables report `path` with `/` separators on every platform. diff --git a/packages/rust/src/path_table.rs b/packages/rust/src/path_table.rs index 8b5fa0d64..ba6537a0c 100644 --- a/packages/rust/src/path_table.rs +++ b/packages/rust/src/path_table.rs @@ -4,9 +4,15 @@ //! //! Every decision here is a pure function of the written string plus one //! filesystem question (*is this a directory?*), which is injected so the -//! rules can be tested without a filesystem. +//! rules can be tested without a filesystem. The path syntax is a type +//! parameter, so the Windows rules are testable on any host. -use std::path::{Component, Path, PathBuf}; +use std::path::{Path, PathBuf}; + +use typed_path::{ + Utf8Component, Utf8Encoding, Utf8NativeEncoding, Utf8Path, Utf8PathBuf, Utf8UnixEncoding, + Utf8WindowsComponent, Utf8WindowsEncoding, Utf8WindowsPath, +}; /// Directories a path-table scan skips, at any depth. They are skipped only /// *beneath* the literal part of the path you write, so naming one explicitly @@ -43,6 +49,50 @@ pub enum Resolution { NotAPath, } +/// The per-platform rules for which written names are absolute and how a +/// resolved scan root is reported. +trait Syntax: Utf8Encoding + Sized { + const HOME_PREFIXES: &'static [&'static str]; + + fn is_rooted(name: &str) -> bool; + + fn reported(path: &Utf8Path) -> String; +} + +impl Syntax for Utf8UnixEncoding { + const HOME_PREFIXES: &'static [&'static str] = &["~/"]; + + fn is_rooted(name: &str) -> bool { + name.starts_with('/') + } + + fn reported(path: &Utf8Path) -> String { + path.as_str().to_string() + } +} + +impl Syntax for Utf8WindowsEncoding { + const HOME_PREFIXES: &'static [&'static str] = &["~/", "~\\"]; + + fn is_rooted(name: &str) -> bool { + Utf8WindowsPath::new(name).has_root() + } + + /// Reported with `/`, matching the index-root-relative paths. A verbatim + /// (`\\?\`) path is the exception: there `/` is not a separator. + fn reported(path: &Utf8Path) -> String { + let verbatim = matches!( + path.components().next(), + Some(Utf8WindowsComponent::Prefix(prefix)) if prefix.kind().is_verbatim() + ); + if verbatim { + path.as_str().to_string() + } else { + path.as_str().replace('\\', "/") + } + } +} + /// Whether `name` contains a character that makes it a glob rather than a /// literal path. fn has_glob_metacharacter(name: &str) -> bool { @@ -58,6 +108,15 @@ pub fn resolve( index_root: &Path, home: Option<&Path>, is_dir: &dyn Fn(&Path) -> bool, +) -> Resolution { + resolve_as::(name, index_root, home, is_dir) +} + +fn resolve_as( + name: &str, + index_root: &Path, + home: Option<&Path>, + is_dir: &dyn Fn(&Path) -> bool, ) -> Resolution { if let Some(rest) = name.strip_prefix("./") { return Resolution::Table(PathTable { @@ -67,7 +126,7 @@ pub fn resolve( }); } - match absolute_target(name, index_root, home) { + match absolute_target::(name, index_root, home) { Some(Some(target)) => Resolution::Table(split_absolute(&target, is_dir)), Some(None) => Resolution::NoHome, None if has_glob_metacharacter(name) => Resolution::Hint, @@ -91,19 +150,27 @@ fn relative_glob(rest: &str, is_dir: &dyn Fn(&Path) -> bool) -> String { trimmed.to_string() } +fn typed(path: &Path) -> Utf8PathBuf { + Utf8PathBuf::from(path.to_string_lossy().into_owned()) +} + /// The absolute path a non-`./` path-table names, with `.` and `..` folded out. /// /// `None` means the name is not a path at all. `Some(None)` means it is a `~/` /// path but no home directory could be found. -fn absolute_target(name: &str, index_root: &Path, home: Option<&Path>) -> Option> { - if let Some(rest) = name.strip_prefix("~/") { - return Some(home.map(|h| normalize(&h.join(rest)))); +fn absolute_target( + name: &str, + index_root: &Path, + home: Option<&Path>, +) -> Option>> { + if let Some(rest) = S::HOME_PREFIXES.iter().find_map(|p| name.strip_prefix(p)) { + return Some(home.map(|h| normalize(&typed::(h).join(rest)))); } if name.starts_with("../") { - return Some(Some(normalize(&index_root.join(name)))); + return Some(Some(normalize(&typed::(index_root).join(name)))); } - if name.starts_with('/') { - return Some(Some(normalize(Path::new(name)))); + if S::is_rooted(name) { + return Some(Some(normalize(Utf8Path::new(name)))); } None } @@ -111,21 +178,37 @@ fn absolute_target(name: &str, index_root: &Path, home: Option<&Path>) -> Option /// Fold `.` and `..` out of `path` lexically. Purely textual: a `..` is not /// resolved through a symlink, which keeps the answer a function of the string /// the user wrote. -fn normalize(path: &Path) -> PathBuf { - let mut out = PathBuf::new(); +fn normalize(path: &Utf8Path) -> Utf8PathBuf { + let mut kept: Vec<&str> = Vec::new(); + let mut normal = 0; + let mut rooted = false; for component in path.components() { - match component { - Component::CurDir => {} + if component.is_current() { + continue; + } + if component.is_parent() { // A `..` that cannot pop is kept on a relative path (it still // means something) but dropped at the filesystem root, which has // no parent to climb to. - Component::ParentDir => { - if !out.pop() && !out.has_root() { - out.push(component); - } + if normal > 0 { + kept.pop(); + normal -= 1; + } else if !rooted { + kept.push(component.as_str()); } - other => out.push(other), + continue; + } + if component.is_normal() { + normal += 1; + } else if component.is_root() { + rooted = true; } + kept.push(component.as_str()); + } + + let mut out = Utf8PathBuf::new(); + for component in kept { + out.push(component); } out } @@ -133,43 +216,44 @@ fn normalize(path: &Path) -> PathBuf { /// Split an absolute path-table target into the directory to walk and the glob /// to match beneath it. A wholly literal target is a directory (scan it /// recursively) or a single file (match exactly that name). -fn split_absolute(target: &Path, is_dir: &dyn Fn(&Path) -> bool) -> PathTable { +fn split_absolute(target: &Utf8Path, is_dir: &dyn Fn(&Path) -> bool) -> PathTable { let (literal, rest) = split_at_first_glob(target); if !rest.is_empty() { - return table_at(literal, rest); + return table_at(&literal, rest); } - if is_dir(&literal) { - return table_at(literal, RECURSIVE_GLOB.to_string()); + if is_dir(Path::new(literal.as_str())) { + return table_at(&literal, RECURSIVE_GLOB.to_string()); } let name = literal .file_name() - .map(|n| n.to_string_lossy().into_owned()) + .map(str::to_string) .unwrap_or_else(|| RECURSIVE_GLOB.to_string()); - let parent = literal.parent().unwrap_or(&literal).to_path_buf(); + let parent = literal.parent().unwrap_or(&literal); table_at(parent, name) } /// A path-table rooted at `root`, reporting absolute paths. -fn table_at(root: PathBuf, glob: String) -> PathTable { +fn table_at(root: &Utf8Path, glob: String) -> PathTable { PathTable { - path_prefix: root.to_string_lossy().into_owned(), - root, + path_prefix: S::reported(root), + root: PathBuf::from(root.as_str()), glob, } } /// Split `target` at its first glob-bearing component: the literal directory /// chain ahead of it, and the rest as a `/`-joined glob. -fn split_at_first_glob(target: &Path) -> (PathBuf, String) { - let mut literal = PathBuf::new(); - let mut rest: Vec = Vec::new(); +fn split_at_first_glob(target: &Utf8Path) -> (Utf8PathBuf, String) { + let mut literal = Utf8PathBuf::new(); + let mut rest: Vec<&str> = Vec::new(); for component in target.components() { - let text = component.as_os_str().to_string_lossy().into_owned(); - if rest.is_empty() && !has_glob_metacharacter(&text) { - literal.push(component); + let text = component.as_str(); + let is_glob = component.is_normal() && has_glob_metacharacter(text); + if rest.is_empty() && !is_glob { + literal.push(text); } else { rest.push(text); } @@ -388,12 +472,23 @@ mod tests { #[test] fn normalize_drops_current_directory_components() { - assert_eq!(normalize(Path::new("/a/./b")), PathBuf::from("/a/b")); + assert_eq!(unix_normalize("/a/./b"), "/a/b"); } #[test] fn normalize_keeps_a_leading_parent_it_cannot_pop() { - assert_eq!(normalize(Path::new("../a")), PathBuf::from("../a")); + assert_eq!(unix_normalize("../a"), "../a"); + } + + #[test] + fn normalize_does_not_pop_a_parent_it_kept() { + assert_eq!(unix_normalize("../../a"), "../../a"); + } + + fn unix_normalize(path: &str) -> String { + normalize(Utf8Path::::new(path)) + .as_str() + .to_string() } #[test] @@ -413,4 +508,185 @@ mod tests { fn ignore_base_is_the_whole_of_a_literal_glob() { assert_eq!(ignore_base("docs/a.md"), PathBuf::from("docs/a.md")); } + + mod windows { + use super::*; + + const ROOT: &str = r"C:\index"; + + fn resolve_with(name: &str, is_dir: &dyn Fn(&Path) -> bool) -> Resolution { + resolve_as::( + name, + Path::new(ROOT), + Some(Path::new(r"C:\Users\u")), + is_dir, + ) + } + + fn table(name: &str, is_dir: &dyn Fn(&Path) -> bool) -> PathTable { + match resolve_with(name, is_dir) { + Resolution::Table(t) => t, + other => panic!("expected a path-table, got {other:?}"), + } + } + + #[test] + fn a_drive_letter_glob_roots_at_its_literal_prefix() { + let t = table(r"C:\var\log\*.log", ¬hing_is_a_dir); + assert_eq!(t.root, Path::new(r"C:\var\log")); + assert_eq!(t.glob, "*.log"); + assert_eq!(t.path_prefix, "C:/var/log"); + } + + #[test] + fn a_drive_letter_path_may_use_forward_slashes() { + let t = table("C:/var/log/*.log", ¬hing_is_a_dir); + assert_eq!(t.root, Path::new(r"C:\var\log")); + assert_eq!(t.path_prefix, "C:/var/log"); + } + + #[test] + fn a_mixed_separator_path_splits_on_both() { + let t = table(r"C:\Users\u\Temp/docs/*.md", ¬hing_is_a_dir); + assert_eq!(t.root, Path::new(r"C:\Users\u\Temp\docs")); + assert_eq!(t.glob, "*.md"); + assert_eq!(t.path_prefix, "C:/Users/u/Temp/docs"); + } + + #[test] + fn a_drive_letter_directory_expands_recursively() { + let t = table(r"C:\var\log", &everything_is_a_dir); + assert_eq!(t.root, Path::new(r"C:\var\log")); + assert_eq!(t.glob, "**/*"); + } + + #[test] + fn a_drive_letter_single_file_roots_at_its_parent() { + let t = table(r"C:\var\log\syslog", ¬hing_is_a_dir); + assert_eq!(t.root, Path::new(r"C:\var\log")); + assert_eq!(t.glob, "syslog"); + } + + #[test] + fn a_drive_root_scans_everything() { + let t = table(r"C:\", &everything_is_a_dir); + assert_eq!(t.root, Path::new(r"C:\")); + assert_eq!(t.glob, "**/*"); + assert_eq!(t.path_prefix, "C:/"); + } + + #[test] + fn a_drive_relative_name_is_not_a_path() { + assert_eq!( + resolve_with("C:notes", ¬hing_is_a_dir), + Resolution::NotAPath + ); + } + + #[test] + fn a_rooted_path_without_a_drive_is_still_absolute() { + let t = table("/var/log/*.log", ¬hing_is_a_dir); + assert_eq!(t.root, Path::new(r"\var\log")); + assert_eq!(t.path_prefix, "/var/log"); + } + + #[test] + fn a_unc_path_roots_at_its_share() { + let t = table(r"\\server\share\docs\*.md", ¬hing_is_a_dir); + assert_eq!(t.root, Path::new(r"\\server\share\docs")); + assert_eq!(t.glob, "*.md"); + assert_eq!(t.path_prefix, "//server/share/docs"); + } + + #[test] + fn a_verbatim_path_keeps_its_backslashes() { + let t = table(r"\\?\C:\var\log\*.log", ¬hing_is_a_dir); + assert_eq!(t.path_prefix, r"\\?\C:\var\log"); + } + + #[test] + fn a_parent_relative_path_resolves_against_the_index_root() { + let t = resolve_as::( + "../../a/*.md", + Path::new(r"C:\x\y\z"), + None, + ¬hing_is_a_dir, + ); + assert_eq!( + t, + Resolution::Table(PathTable { + root: PathBuf::from(r"C:\x\a"), + glob: "*.md".to_string(), + path_prefix: "C:/x/a".to_string(), + }) + ); + } + + #[test] + fn a_parent_relative_path_past_the_drive_root_stops_there() { + let t = resolve_as::( + "../../*.md", + Path::new(r"C:\x"), + None, + ¬hing_is_a_dir, + ); + assert_eq!( + t, + Resolution::Table(PathTable { + root: PathBuf::from(r"C:\"), + glob: "*.md".to_string(), + path_prefix: "C:/".to_string(), + }) + ); + } + + #[test] + fn a_home_relative_path_resolves_against_the_home_directory() { + let t = table("~/notes/*.md", ¬hing_is_a_dir); + assert_eq!(t.root, Path::new(r"C:\Users\u\notes")); + assert_eq!(t.glob, "*.md"); + assert_eq!(t.path_prefix, "C:/Users/u/notes"); + } + + #[test] + fn a_backslash_home_relative_path_resolves_against_the_home_directory() { + let t = table(r"~\notes\*.md", ¬hing_is_a_dir); + assert_eq!(t.root, Path::new(r"C:\Users\u\notes")); + assert_eq!(t.glob, "*.md"); + } + + #[test] + fn a_home_relative_path_without_a_home_directory_is_unresolvable() { + assert_eq!( + resolve_as::( + r"~\notes", + Path::new(ROOT), + None, + &everything_is_a_dir + ), + Resolution::NoHome + ); + } + + #[test] + fn a_bare_glob_asks_for_the_dot_slash_form() { + assert_eq!(resolve_with("**/*.md", ¬hing_is_a_dir), Resolution::Hint); + } + + #[test] + fn a_plain_identifier_is_not_a_path() { + assert_eq!( + resolve_with("users", ¬hing_is_a_dir), + Resolution::NotAPath + ); + } + } + + #[test] + fn a_backslash_after_a_tilde_is_not_a_unix_path() { + assert_eq!( + resolve_with(r"~\notes", ¬hing_is_a_dir), + Resolution::NotAPath + ); + } } diff --git a/packages/rust/tests/path_table_cli_e2e.rs b/packages/rust/tests/path_table_cli_e2e.rs index 117245062..bdf47f5d9 100644 --- a/packages/rust/tests/path_table_cli_e2e.rs +++ b/packages/rust/tests/path_table_cli_e2e.rs @@ -4,6 +4,7 @@ #![cfg(feature = "cli")] use std::fs; +use std::path::Path; use std::process::Output; use assert_cmd::prelude::*; @@ -19,6 +20,11 @@ fn fixture() -> TempDir { root } +/// How an absolute path-table reports `path`: always `/`-separated. +fn reported(path: &Path) -> String { + path.display().to_string().replace('\\', "/") +} + fn run(dir: &TempDir, sql: &str) -> Output { std::process::Command::cargo_bin("dirsql") .expect("binary must exist") @@ -181,7 +187,7 @@ fn a_home_relative_path_table_resolves_against_the_home_directory() { let path = rows(&out)[0]["path"].as_str().unwrap().to_string(); assert_eq!( path, - home.path().join("notes/n.md").display().to_string(), + format!("{}/notes/n.md", reported(home.path())), "a '~/' path-table reports absolute paths" ); } @@ -201,7 +207,7 @@ fn an_absolute_path_table_resolves_outside_the_index_root() { .map(|r| r["path"].as_str().unwrap().to_string()) .collect(); - assert_eq!(found, vec![other.path().join("o.md").display().to_string()]); + assert_eq!(found, vec![format!("{}/o.md", reported(other.path()))]); } #[test] diff --git a/packages/rust/tests/path_table_globs.rs b/packages/rust/tests/path_table_globs.rs index 84f3c8986..588bc1e75 100644 --- a/packages/rust/tests/path_table_globs.rs +++ b/packages/rust/tests/path_table_globs.rs @@ -3,10 +3,16 @@ //! real SQLite, SDK public API. use std::fs; +use std::path::Path; use dirsql::{DirSQL, Row, Value}; use tempfile::TempDir; +/// How an absolute path-table reports `path`: always `/`-separated. +fn reported(path: &Path) -> String { + path.display().to_string().replace('\\', "/") +} + /// A tree with a nested doc directory, a top-level file, a dotfile, and the /// two directories a zero-config scan must not drown in. fn fixture() -> TempDir { @@ -226,7 +232,7 @@ fn an_absolute_path_table_resolves_and_reports_absolute_paths() { let root = fixture(); let db = open(&root); - let dir = root.path().display().to_string(); + let dir = reported(root.path()); let found = paths(&db, &format!("SELECT path FROM '{dir}/docs/*.md'")); assert_eq!( @@ -241,7 +247,7 @@ fn an_absolute_directory_path_scans_it_recursively() { let root = fixture(); let db = open(&root); - let dir = root.path().display().to_string(); + let dir = reported(root.path()); let found = paths(&db, &format!("SELECT path FROM '{dir}/docs'")); assert_eq!( @@ -259,7 +265,7 @@ fn an_absolute_single_file_path_is_exactly_one_row() { let root = fixture(); let db = open(&root); - let dir = root.path().display().to_string(); + let dir = reported(root.path()); let rows = db .query(&format!("SELECT path FROM '{dir}/docs/a.md'")) .unwrap(); @@ -277,7 +283,7 @@ fn a_parent_relative_path_table_resolves_against_the_index_root() { let inner = root.path().join("docs/nested"); let db = DirSQL::new(&inner, vec![]).unwrap(); - let dir = root.path().display().to_string(); + let dir = reported(root.path()); let found = paths(&db, "SELECT path FROM '../*.md'"); assert_eq!( @@ -292,7 +298,7 @@ fn an_absolute_path_table_reads_content_from_the_right_file() { let root = fixture(); let db = open(&root); - let dir = root.path().display().to_string(); + let dir = reported(root.path()); let rows = db .query(&format!( "SELECT path FROM '{dir}/docs/*.md' WHERE content = 'alpha'" From 5fba62f9a17758b7f6aa7ab761e1649fa1e4f931 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:11:54 -0400 Subject: [PATCH 2/5] e2e attestation for claude/1185-win-abs-table-names --- .../e2e-attestations/claude-1185-win-abs-table-names.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/python/e2e-attestations/claude-1185-win-abs-table-names.json diff --git a/packages/python/e2e-attestations/claude-1185-win-abs-table-names.json b/packages/python/e2e-attestations/claude-1185-win-abs-table-names.json new file mode 100644 index 000000000..48b464350 --- /dev/null +++ b/packages/python/e2e-attestations/claude-1185-win-abs-table-names.json @@ -0,0 +1,7 @@ +{ + "command": "just test-e2e", + "ran_at": 1790604714, + "exit_code": 0, + "commit": "f719e49b96e8610e5692f76ebfb564ae3188d04e", + "branch": "claude/1185-win-abs-table-names" +} From 35fbef28b6ad71fbebbbab702fc3783b39e6a0f2 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:13:17 -0400 Subject: [PATCH 3/5] e2e attestation for claude/1185-win-abs-table-names --- .../e2e-attestations/claude-1185-win-abs-table-names.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/ts/e2e-attestations/claude-1185-win-abs-table-names.json diff --git a/packages/ts/e2e-attestations/claude-1185-win-abs-table-names.json b/packages/ts/e2e-attestations/claude-1185-win-abs-table-names.json new file mode 100644 index 000000000..d57294a64 --- /dev/null +++ b/packages/ts/e2e-attestations/claude-1185-win-abs-table-names.json @@ -0,0 +1,7 @@ +{ + "command": "pnpm test:e2e", + "ran_at": 1790604797, + "exit_code": 0, + "commit": "ddc34c98416eb819a5d2adf7eba04540bba04e43", + "branch": "claude/1185-win-abs-table-names" +} From dfdfbbc867966d58dc41465a42e9ff35870dcbde Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:25:20 -0400 Subject: [PATCH 4/5] docs(rust): migration fragment for Windows absolute path-tables --- ...2026-09-28-windows-absolute-path-tables.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 packages/rust/migrations.d/2026-09-28-windows-absolute-path-tables.md diff --git a/packages/rust/migrations.d/2026-09-28-windows-absolute-path-tables.md b/packages/rust/migrations.d/2026-09-28-windows-absolute-path-tables.md new file mode 100644 index 000000000..ee0ad76c3 --- /dev/null +++ b/packages/rust/migrations.d/2026-09-28-windows-absolute-path-tables.md @@ -0,0 +1,33 @@ +### Windows absolute path-tables resolve and report `/` paths + +**Summary** + +On Windows, a path-table named by a drive letter (`C:\logs\*.log`), a UNC share +or `~\` now resolves; it previously failed with `no such table`. Absolute +path-tables (`/`, `../`, `~/` and the Windows forms) report `path` with `/` +separators on Windows. No signature changed, and Unix output is unchanged. + +**Required changes** + +_None._ + +**Deprecations removed** + +_None._ + +**Behavior changes without code changes** + +| Surface (Windows) | Before | After | +|---|---|---| +| `SELECT path FROM 'C:\logs\*.log'` | `no such table` | `C:/logs/app.log` | +| `SELECT path FROM '~/notes/*.md'` | `C:\Users\u\notes\n.md` | `C:/Users/u/notes/n.md` | + +A Windows consumer that matched on `\` in an absolute path-table's `path` must +match on `/`. + +**Verification** + +```bash +dirsql "SELECT path FROM 'C:/Windows/*.ini' LIMIT 1" +# [{"path":"C:/Windows/system.ini"}] +``` From 148951ac8e292e976307b23c13468d6920fbc4ac Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:26:00 -0400 Subject: [PATCH 5/5] test(rust): pin the ~\ rejection test to Unix syntax --- packages/rust/src/path_table.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/rust/src/path_table.rs b/packages/rust/src/path_table.rs index ba6537a0c..46a20863e 100644 --- a/packages/rust/src/path_table.rs +++ b/packages/rust/src/path_table.rs @@ -685,7 +685,12 @@ mod tests { #[test] fn a_backslash_after_a_tilde_is_not_a_unix_path() { assert_eq!( - resolve_with(r"~\notes", ¬hing_is_a_dir), + resolve_as::( + r"~\notes", + Path::new(ROOT), + Some(Path::new("/home/u")), + ¬hing_is_a_dir + ), Resolution::NotAPath ); }