Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 1 addition & 144 deletions cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ use std::process::ExitCode;

use clap::{CommandFactory, Parser, Subcommand};
use txcript::harness::{amp, chatgpt, claude_chat, simple};
use txcript::{Codec, Common, HarnessId, Store, TextCodec, Transcript, local};
use txcript::{Codec, Common, HarnessId, Store, TextCodec, Transcript, local, paths::under_dir};

pub mod cache;
mod draft;
Expand Down Expand Up @@ -434,109 +434,6 @@ pub fn run_session(command: SessionCommand, options: &Options) -> Result<ExitCod
}
}

/// Strip the Windows verbatim-device prefix (`\\?\`, including `\\?\UNC\`)
/// that `canonicalize` adds, so vanished paths still match canonicalized
/// parents. Wide-char math keeps non-UTF8 paths intact.
#[cfg(windows)]
const VERBATIM_PREFIX: &[u16] = &[0x5C, 0x5C, 0x3F, 0x5C]; // `\\?\`
#[cfg(windows)]
const UNC_PREFIX: &[u16] = &[0x5C, 0x5C, 0x3F, 0x5C, 0x55, 0x4E, 0x43, 0x5C]; // `\\?\UNC\`

#[cfg(windows)]
fn strip_verbatim_prefix(path: &std::path::Path) -> std::path::PathBuf {
use std::os::windows::ffi::{OsStrExt, OsStringExt};
let wide: Vec<u16> = path.as_os_str().encode_wide().collect();
if let Some(rest) = wide.strip_prefix(UNC_PREFIX) {
// `\\?\UNC\server\share` ⟺ `\\server\share`.
let mut full = vec![0x5C, 0x5C];
full.extend_from_slice(rest);
std::ffi::OsString::from_wide(&full).into()
} else if let Some(rest) = wide.strip_prefix(VERBATIM_PREFIX) {
std::ffi::OsString::from_wide(rest).into()
} else {
path.to_path_buf()
}
}

#[cfg(not(windows))]
fn strip_verbatim_prefix(path: &std::path::Path) -> std::path::PathBuf {
path.to_path_buf()
}

/// Component-wise `starts_with` that folds case on Windows, where `C:\Users`
/// and `c:\users` are the same directory. Non-Windows keeps the exact
/// `Path::starts_with`.
fn path_starts_with(child: &std::path::Path, parent: &std::path::Path) -> bool {
#[cfg(windows)]
{
let mut child_comps = child.components();
let mut parent_comps = parent.components();
loop {
match (parent_comps.next(), child_comps.next()) {
(None, _) => return true,
(Some(_), None) => return false,
(Some(p), Some(c)) => {
// Unicode lowercase approximates the filesystem's own
// case folding; ASCII-only would miss e.g. `é` vs `É`.
if p.as_os_str().to_string_lossy().to_lowercase()
!= c.as_os_str().to_string_lossy().to_lowercase()
{
return false;
}
}
}
}
}
#[cfg(not(windows))]
{
child.starts_with(parent)
}
}

/// Canonicalize the longest existing prefix of `path`, re-appending the
/// vanished tail verbatim, so the parent still resolves symlinks, junctions,
/// or 8.3 short names the raw spelling would mismatch.
fn canonicalize_lenient(path: &std::path::Path) -> std::path::PathBuf {
use std::path::Component;
let mut tail: Vec<std::ffi::OsString> = Vec::new();
let mut current = path;
loop {
if let Ok(base) = current.canonicalize() {
let mut out = strip_verbatim_prefix(&base);
for component in tail.iter().rev() {
out.push(component);
}
return out;
}
let mut components = current.components();
match components.next_back() {
// Empty path, or only a prefix/root remains: nothing resolvable.
None | Some(Component::Prefix(_) | Component::RootDir) => {
return strip_verbatim_prefix(path);
}
Some(last) => {
tail.push(last.as_os_str().to_os_string());
current = components.as_path();
}
}
}
}

/// True when a session's recorded `cwd` is `dir` or anywhere under it, so a
/// monorepo session started in `repo/packages/foo` shows up when listing
/// `repo`. The check is component-wise (`/foo/barbaz` is not under
/// `/foo/bar`). Both sides are canonicalized so different spellings of one
/// directory still match (`/tmp` vs `/private/tmp`, `$PWD` through a
/// symlink); a path that no longer exists keeps its raw spelling, so
/// vanished directories compare as plain components.
#[must_use]
pub fn under_dir(session_cwd: &str, dir: &std::path::Path) -> bool {
path_starts_with(
&canonicalize_lenient(std::path::Path::new(session_cwd)),
&canonicalize_lenient(dir),
)
}

/// The `--from`/`--cwd` session filters shared by `list` and `query`.
/// A `--cwd` filter excludes sessions with no recorded cwd — they don't
/// pertain to any folder.
Expand Down Expand Up @@ -811,46 +708,6 @@ mod filter_tests {
Some(repo)
));
}

#[test]
fn strip_verbatim_prefix_leaves_plain_paths_alone() {
let p = std::path::Path::new("some/relative/dir");
assert_eq!(super::strip_verbatim_prefix(p), p.to_path_buf());
}

#[cfg(windows)]
#[test]
fn strip_verbatim_prefix_strips_device_and_unc_forms() {
assert_eq!(
super::strip_verbatim_prefix(std::path::Path::new(r"\\?\C:\some\repo")),
std::path::PathBuf::from(r"C:\some\repo")
);
assert_eq!(
super::strip_verbatim_prefix(std::path::Path::new(r"\\?\UNC\server\share")),
std::path::PathBuf::from(r"\\server\share")
);
}

// Drive-letter paths only parse where `\` separates components.
// The tempdir case is the reported scenario: live `dir`, vanished child.
#[cfg(windows)]
#[test]
fn cwd_filter_matches_vanished_child_of_live_dir() {
let dir = tempfile::tempdir().unwrap();
let gone = dir.path().join("packages").join("foo");
assert!(super::under_dir(gone.to_str().unwrap(), dir.path()));
}

#[cfg(windows)]
#[test]
fn cwd_filter_handles_windows_verbatim_prefixes_and_casing() {
let dir = std::path::Path::new(r"C:\some\repo");
assert!(super::under_dir(r"C:\some\repo\packages\foo", dir));
assert!(super::under_dir(r"c:\Some\Repo\packages\foo", dir));
assert!(super::under_dir(r"C:/some/repo/packages/foo", dir));
assert!(!super::under_dir(r"C:\some\repo2", dir));
assert!(!super::under_dir(r"C:\other\repo", dir));
}
}

#[cfg(test)]
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub mod error;
pub mod harness;
#[cfg(not(target_arch = "wasm32"))]
pub mod local;
pub mod paths;
#[cfg(feature = "search")]
pub mod search;
pub mod text;
Expand Down
161 changes: 161 additions & 0 deletions src/paths.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
//! Directory-membership checks shared by every `--cwd` filter (`list`,
//! `query`): component-wise, canonicalized, and Windows-aware, so one
//! spelling of a directory matches all the others.

/// Strip the Windows verbatim-device prefix (`\\?\`, including `\\?\UNC\`)
/// that `canonicalize` adds, so vanished paths still match canonicalized
/// parents. Wide-char math keeps non-UTF8 paths intact.
#[cfg(windows)]
const VERBATIM_PREFIX: &[u16] = &[0x5C, 0x5C, 0x3F, 0x5C]; // `\\?\`
#[cfg(windows)]
const UNC_PREFIX: &[u16] = &[0x5C, 0x5C, 0x3F, 0x5C, 0x55, 0x4E, 0x43, 0x5C]; // `\\?\UNC\`

#[cfg(windows)]
fn strip_verbatim_prefix(path: &std::path::Path) -> std::path::PathBuf {
use std::os::windows::ffi::{OsStrExt, OsStringExt};
let wide: Vec<u16> = path.as_os_str().encode_wide().collect();
if let Some(rest) = wide.strip_prefix(UNC_PREFIX) {
// `\\?\UNC\server\share` ⟺ `\\server\share`.
let mut full = vec![0x5C, 0x5C];
full.extend_from_slice(rest);
std::ffi::OsString::from_wide(&full).into()
} else if let Some(rest) = wide.strip_prefix(VERBATIM_PREFIX) {
std::ffi::OsString::from_wide(rest).into()
} else {
path.to_path_buf()
}
}

#[cfg(not(windows))]
fn strip_verbatim_prefix(path: &std::path::Path) -> std::path::PathBuf {
path.to_path_buf()
}

/// Component-wise `starts_with` that folds case on Windows, where `C:\Users`
/// and `c:\users` are the same directory. Non-Windows keeps the exact
/// `Path::starts_with`.
fn path_starts_with(child: &std::path::Path, parent: &std::path::Path) -> bool {
#[cfg(windows)]
{
let mut child_comps = child.components();
let mut parent_comps = parent.components();
loop {
match (parent_comps.next(), child_comps.next()) {
(None, _) => return true,
(Some(_), None) => return false,
(Some(p), Some(c)) => {
// Unicode lowercase approximates the filesystem's own
// case folding; ASCII-only would miss e.g. `é` vs `É`.
if p.as_os_str().to_string_lossy().to_lowercase()
!= c.as_os_str().to_string_lossy().to_lowercase()
{
return false;
}
}
}
}
}
#[cfg(not(windows))]
{
child.starts_with(parent)
}
}

/// Canonicalize the longest existing prefix of `path`, re-appending the
/// vanished tail verbatim, so the parent still resolves symlinks, junctions,
/// or 8.3 short names the raw spelling would mismatch.
fn canonicalize_lenient(path: &std::path::Path) -> std::path::PathBuf {
use std::path::Component;
let mut tail: Vec<std::ffi::OsString> = Vec::new();
let mut current = path;
loop {
if let Ok(base) = current.canonicalize() {
let mut out = strip_verbatim_prefix(&base);
for component in tail.iter().rev() {
out.push(component);
}
return out;
}
let mut components = current.components();
match components.next_back() {
// Empty path, or only a prefix/root remains: nothing resolvable.
None | Some(Component::Prefix(_) | Component::RootDir) => {
return strip_verbatim_prefix(path);
}
Some(last) => {
tail.push(last.as_os_str().to_os_string());
current = components.as_path();
}
}
}
}

/// True when a session's recorded `cwd` is `dir` or anywhere under it, so a
/// monorepo session started in `repo/packages/foo` shows up when filtering
/// `repo`. The check is component-wise (`/foo/barbaz` is not under
/// `/foo/bar`). Both sides are canonicalized so different spellings of one
/// directory still match (`/tmp` vs `/private/tmp`, `$PWD` through a
/// symlink); a path that no longer exists keeps its raw spelling, so
/// vanished directories compare as plain components.
#[must_use]
pub fn under_dir(session_cwd: &str, dir: &std::path::Path) -> bool {
path_starts_with(
&canonicalize_lenient(std::path::Path::new(session_cwd)),
&canonicalize_lenient(dir),
)
}

#[cfg(test)]
mod tests {
#[test]
fn strip_verbatim_prefix_leaves_plain_paths_alone() {
let p = std::path::Path::new("some/relative/dir");
assert_eq!(super::strip_verbatim_prefix(p), p.to_path_buf());
}

#[cfg(windows)]
#[test]
fn strip_verbatim_prefix_strips_device_and_unc_forms() {
assert_eq!(
super::strip_verbatim_prefix(std::path::Path::new(r"\\?\C:\some\repo")),
std::path::PathBuf::from(r"C:\some\repo")
);
assert_eq!(
super::strip_verbatim_prefix(std::path::Path::new(r"\\?\UNC\server\share")),
std::path::PathBuf::from(r"\\server\share")
);
}

// Drive-letter paths only parse where `\` separates components.
// The tempdir case is the reported scenario: live `dir`, vanished child.
#[cfg(windows)]
#[test]
fn under_dir_matches_vanished_child_of_live_dir() {
let dir = tempfile::tempdir().unwrap();
let gone = dir.path().join("packages").join("foo");
assert!(super::under_dir(gone.to_str().unwrap(), dir.path()));
}

// Platform-neutral shape of the same rule: a vanished child matches,
// a sibling sharing the string prefix does not.
#[test]
fn under_dir_matches_vanished_child_but_not_sibling() {
let dir = tempfile::tempdir().unwrap();
let gone = dir.path().join("packages").join("foo");
assert!(super::under_dir(gone.to_str().unwrap(), dir.path()));
let packages = dir.path().join("packages");
let sibling = dir.path().join("packages-foo");
assert!(!super::under_dir(sibling.to_str().unwrap(), &packages));
}

#[cfg(windows)]
#[test]
fn under_dir_handles_verbatim_prefixes_and_casing() {
let dir = std::path::Path::new(r"C:\some\repo");
assert!(super::under_dir(r"C:\some\repo\packages\foo", dir));
assert!(super::under_dir(r"c:\Some\Repo\packages\foo", dir));
assert!(super::under_dir(r"C:/some/repo/packages/foo", dir));
assert!(!super::under_dir(r"C:\some\repo2", dir));
assert!(!super::under_dir(r"C:\other\repo", dir));
}
}
10 changes: 1 addition & 9 deletions src/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
use std::collections::HashMap;
use std::fmt;
use std::ops::Range;
use std::path::Path;

use chrono::{DateTime, Utc};
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
Expand Down Expand Up @@ -606,7 +605,7 @@ impl Doc {
let Some(cwd) = self.meta.cwd.as_deref() else {
return false;
};
if !path_under(cwd, dir) {
if !crate::paths::under_dir(cwd, std::path::Path::new(dir)) {
return false;
}
}
Expand Down Expand Up @@ -640,13 +639,6 @@ impl Doc {
}
}

fn path_under(session_cwd: &str, dir: &str) -> bool {
let p_cwd = Path::new(session_cwd);
let p_dir = Path::new(dir);
let canon = |p: &Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
canon(p_cwd).starts_with(canon(p_dir))
}

/// Pass-1 result for one document: its index, best line score, and each
/// matched line with its score.
type Scored = (usize, u32, Vec<(usize, u32)>);
Expand Down
23 changes: 23 additions & 0 deletions tests/integration/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -542,3 +542,26 @@ fn query_filter_cwd_path_prefix() {
q.cwd = None;
assert_eq!(index.query(&q).len(), 2);
}

/// `query --cwd` must apply the same Windows rules as `list --cwd`:
/// verbatim prefixes stripped, case folded.
#[cfg(windows)]
#[test]
fn query_filter_cwd_matches_verbatim_and_case_variants() {
let mut m = meta("w", 0);
m.cwd = Some(r"\\?\C:\work\replay".to_string());
let t = Transcript::new(m, vec![message(Role::User, vec![text("needle content")])]);
let mut index = Index::new();
index.insert(key(HarnessId::ClaudeCode, "w"), &t);

let mut q = Query::substring("needle");
q.cwd = Some(r"c:\WORK\replay".to_string());
let hits = index.query(&q);
assert_eq!(
hits.len(),
1,
"verbatim prefix + case must match, got {}",
hits.len()
);
assert_eq!(hits[0].key.id, "w");
}
Loading