Skip to content

Commit 7649550

Browse files
refactor(which/lookup/workspace): improve workspace fallback robustness and logging
- Added helper functions to unwrap walkdir entries with error logging and to log when no matches are found during fallback. - Rewrote workspace traversal to be more resilient by filtering unreadable paths and better handling non-UTF-8 paths. - Simplified collection of executable matches, removing state struct in favor of clearer iteration logic. - Enhanced logging to aid diagnosis of fallback misses or unexpected latencies. - Adjusted test code for improved PATH handling and error expectation assertion. These changes improve the maintainability and robustness of the workspace fallback which resolution mechanism, ensuring smoother operation across platforms and edge cases. Co-authored-by: terragon-labs[bot] <terragon-labs[bot]@users.noreply.github.com>
1 parent 41a6671 commit 7649550

File tree

5 files changed

+98
-123
lines changed

5 files changed

+98
-123
lines changed

src/stdlib/which/lookup/tests.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,13 @@ fn relative_path_entries_resolve_against_cwd(workspace: TempWorkspace) -> Result
163163
fs::create_dir_all(bin.as_std_path()).context("mkdir bin")?;
164164
fs::create_dir_all(tools.as_std_path()).context("mkdir tools")?;
165165

166-
let path_value = format!("{}:bin:tools", workspace.root());
167-
let _guard = VarGuard::set("PATH", std::ffi::OsStr::new(&path_value));
166+
let path_value = std::env::join_paths([
167+
workspace.root().as_std_path(),
168+
std::path::Path::new("bin"),
169+
std::path::Path::new("tools"),
170+
])
171+
.context("join PATH entries")?;
172+
let _guard = VarGuard::set("PATH", path_value.as_os_str());
168173

169174
let snapshot = EnvSnapshot::capture(Some(workspace.root()))
170175
.context("capture env with relative PATH entries")?;

src/stdlib/which/lookup/workspace/mod.rs

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,45 @@ fn workspace_fallback_enabled() -> bool {
6161
let normalised = value.to_ascii_lowercase();
6262
!matches!(normalised.as_str(), "0" | "false" | "off")
6363
}
64-
Err(env::VarError::NotPresent | env::VarError::NotUnicode(_)) => true,
64+
Err(env::VarError::NotPresent) => true,
65+
Err(env::VarError::NotUnicode(_)) => {
66+
tracing::warn!(
67+
env = WORKSPACE_FALLBACK_ENV,
68+
"workspace fallback disabled because env var is not valid UTF-8",
69+
);
70+
false
71+
}
72+
}
73+
}
74+
75+
/// Convert a walkdir item to an entry, logging and skipping unreadable paths to
76+
/// keep workspace traversal robust across platforms.
77+
pub(super) fn unwrap_or_log_error(
78+
walk_entry: Result<walkdir::DirEntry, walkdir::Error>,
79+
command: &str,
80+
) -> Option<walkdir::DirEntry> {
81+
match walk_entry {
82+
Ok(entry) => Some(entry),
83+
Err(err) => {
84+
tracing::debug!(
85+
%command,
86+
error = %err,
87+
"skipping unreadable workspace entry during which fallback",
88+
);
89+
None
90+
}
91+
}
92+
}
93+
94+
/// Emit a debug message when fallback traversal yields no matches, helping
95+
/// callers diagnose unexpected latency or misses.
96+
pub(super) fn log_if_no_matches(matches: &[Utf8PathBuf], command: &str) {
97+
if matches.is_empty() {
98+
tracing::debug!(
99+
%command,
100+
max_depth = WORKSPACE_MAX_DEPTH,
101+
skip = ?WORKSPACE_SKIP_DIRS,
102+
"workspace which fallback found no matches",
103+
);
65104
}
66105
}

src/stdlib/which/lookup/workspace/posix.rs

Lines changed: 8 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -5,69 +5,23 @@ use minijinja::{Error, ErrorKind};
55
use walkdir::WalkDir;
66

77
use super::super::is_executable;
8-
use super::{WORKSPACE_MAX_DEPTH, WORKSPACE_SKIP_DIRS, should_visit_entry};
8+
use super::{WORKSPACE_MAX_DEPTH, log_if_no_matches, should_visit_entry, unwrap_or_log_error};
99
use crate::stdlib::which::env::EnvSnapshot;
1010

1111
pub(super) fn search_workspace(
1212
env: &EnvSnapshot,
1313
command: &str,
1414
collect_all: bool,
1515
) -> Result<Vec<Utf8PathBuf>, Error> {
16-
let walker = WalkDir::new(&env.cwd)
16+
let mut matches = Vec::new();
17+
18+
for walk_entry in WalkDir::new(&env.cwd)
1719
.follow_links(false)
1820
.max_depth(WORKSPACE_MAX_DEPTH)
1921
.sort_by_file_name()
2022
.into_iter()
21-
.filter_entry(should_visit_entry);
22-
23-
let matches = collect_matching_executables(walker, command, collect_all)?;
24-
25-
log_if_empty(&matches, command);
26-
27-
Ok(matches)
28-
}
29-
30-
/// Convert a `WalkDir` result into an entry, logging and skipping unreadable
31-
/// paths to keep workspace traversal resilient.
32-
fn unwrap_or_log_error(
33-
walk_entry: Result<walkdir::DirEntry, walkdir::Error>,
34-
command: &str,
35-
) -> Option<walkdir::DirEntry> {
36-
match walk_entry {
37-
Ok(entry) => Some(entry),
38-
Err(err) => {
39-
tracing::debug!(
40-
%command,
41-
error = %err,
42-
"skipping unreadable workspace entry during which fallback",
43-
);
44-
None
45-
}
46-
}
47-
}
48-
49-
/// Emit a debug log when the workspace traversal yields no executables.
50-
fn log_if_empty(matches: &[Utf8PathBuf], command: &str) {
51-
if matches.is_empty() {
52-
tracing::debug!(
53-
%command,
54-
max_depth = WORKSPACE_MAX_DEPTH,
55-
skip = ?WORKSPACE_SKIP_DIRS,
56-
"workspace which fallback found no matches",
57-
);
58-
}
59-
}
60-
61-
/// Traverse the workspace iterator, collecting executable matches and stopping
62-
/// early when `collect_all` is `false` and a match is discovered.
63-
fn collect_matching_executables(
64-
entries: impl Iterator<Item = Result<walkdir::DirEntry, walkdir::Error>>,
65-
command: &str,
66-
collect_all: bool,
67-
) -> Result<Vec<Utf8PathBuf>, Error> {
68-
let mut matches = Vec::new();
69-
70-
for walk_entry in entries {
23+
.filter_entry(should_visit_entry)
24+
{
7125
let Some(entry) = unwrap_or_log_error(walk_entry, command) else {
7226
continue;
7327
};
@@ -80,6 +34,8 @@ fn collect_matching_executables(
8034
}
8135
}
8236

37+
log_if_no_matches(&matches, command);
38+
8339
Ok(matches)
8440
}
8541

src/stdlib/which/lookup/workspace/windows.rs

Lines changed: 42 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ use minijinja::{Error, ErrorKind};
77
use walkdir::WalkDir;
88

99
use super::super::is_executable;
10-
use super::{WORKSPACE_MAX_DEPTH, WORKSPACE_SKIP_DIRS, should_visit_entry};
10+
use super::{
11+
WORKSPACE_MAX_DEPTH, WORKSPACE_SKIP_DIRS, log_if_no_matches, should_visit_entry,
12+
unwrap_or_log_error,
13+
};
1114
use crate::stdlib::which::env::{self, EnvSnapshot};
1215

1316
pub(super) fn search_workspace(
@@ -16,7 +19,7 @@ pub(super) fn search_workspace(
1619
collect_all: bool,
1720
) -> Result<Vec<Utf8PathBuf>, Error> {
1821
let match_ctx = WorkspaceMatchContext::new(command, env);
19-
let mut collector = CollectionState::new(collect_all);
22+
let mut matches = Vec::new();
2023

2124
for walk_entry in WalkDir::new(&env.cwd)
2225
.follow_links(false)
@@ -29,14 +32,17 @@ pub(super) fn search_workspace(
2932
continue;
3033
};
3134

32-
if collector.try_add(entry, command, &match_ctx)? {
33-
break;
35+
if let Some(path) = match_workspace_entry(entry, command, &match_ctx)? {
36+
matches.push(path);
37+
if !collect_all {
38+
break;
39+
}
3440
}
3541
}
3642

37-
log_if_no_matches(&collector.matches, command);
43+
log_if_no_matches(&matches, command);
3844

39-
Ok(collector.matches)
45+
Ok(matches)
4046
}
4147

4248
#[derive(Clone)]
@@ -46,38 +52,39 @@ struct WorkspaceMatchContext {
4652
basenames: HashSet<String>,
4753
}
4854

49-
/// Encapsulates the state and logic for collecting matching executables during
50-
/// workspace traversal.
51-
struct CollectionState {
52-
matches: Vec<Utf8PathBuf>,
53-
collect_all: bool,
54-
}
55-
56-
impl CollectionState {
57-
fn new(collect_all: bool) -> Self {
58-
Self {
59-
matches: Vec::new(),
60-
collect_all,
61-
}
55+
fn match_workspace_entry(
56+
entry: walkdir::DirEntry,
57+
command: &str,
58+
ctx: &WorkspaceMatchContext,
59+
) -> Result<Option<Utf8PathBuf>, Error> {
60+
if !entry.file_type().is_file() {
61+
return Ok(None);
6262
}
6363

64-
/// Process an entry and add it to matches if valid. Returns `true` if
65-
/// collection should stop (i.e., a match was found and `collect_all` is
66-
/// `false`).
67-
fn try_add(
68-
&mut self,
69-
entry: walkdir::DirEntry,
70-
command: &str,
71-
ctx: &WorkspaceMatchContext,
72-
) -> Result<bool, Error> {
73-
if let Some(path) = process_workspace_entry(entry, command, ctx)? {
74-
self.matches.push(path);
75-
if !self.collect_all {
76-
return Ok(true);
77-
}
78-
}
79-
Ok(false)
64+
let file_name = entry.file_name().to_string_lossy().to_ascii_lowercase();
65+
let name_matches = if file_name == ctx.command_lower {
66+
true
67+
} else if ctx.command_has_ext {
68+
false
69+
} else {
70+
ctx.basenames.contains(&file_name)
71+
};
72+
if !name_matches {
73+
return Ok(None);
8074
}
75+
76+
let path = entry.into_path();
77+
let utf8 = Utf8PathBuf::from_path_buf(path).map_err(|path_buf| {
78+
let lossy_path = path_buf.to_string_lossy();
79+
Error::new(
80+
ErrorKind::InvalidOperation,
81+
format!(
82+
"workspace path contains non-UTF-8 components while resolving command '{command}': {lossy_path}",
83+
),
84+
)
85+
})?;
86+
87+
Ok(is_executable(&utf8).then_some(utf8))
8188
}
8289

8390
impl WorkspaceMatchContext {
@@ -137,35 +144,3 @@ fn process_workspace_entry(
137144
})?;
138145
Ok(is_executable(&utf8).then_some(utf8))
139146
}
140-
141-
/// Convert a `WalkDir` result into an entry, logging and skipping unreadable
142-
/// paths to keep workspace traversal robust.
143-
fn unwrap_or_log_error(
144-
walk_entry: Result<walkdir::DirEntry, walkdir::Error>,
145-
command: &str,
146-
) -> Option<walkdir::DirEntry> {
147-
match walk_entry {
148-
Ok(entry) => Some(entry),
149-
Err(err) => {
150-
tracing::debug!(
151-
%command,
152-
error = %err,
153-
"skipping unreadable workspace entry during which fallback",
154-
);
155-
None
156-
}
157-
}
158-
}
159-
160-
/// Emit a debug message when fallback traversal yields no matches, helping
161-
/// callers diagnose unexpected latency or misses.
162-
fn log_if_no_matches(matches: &[Utf8PathBuf], command: &str) {
163-
if matches.is_empty() {
164-
tracing::debug!(
165-
%command,
166-
max_depth = WORKSPACE_MAX_DEPTH,
167-
skip = ?WORKSPACE_SKIP_DIRS,
168-
"workspace which fallback found no matches",
169-
);
170-
}
171-
}

tests/std_filter_tests/which_filter_tests.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ fn test_cache_after_removal(
3838
let second_result = fixture.render(second_template);
3939

4040
if expect_second_err {
41-
let err = second_result.expect("expected fresh which lookup to fail after removal");
41+
let err = second_result.expect_err("expected fresh which lookup to fail after removal");
4242
assert!(err.to_string().contains("not_found"));
4343
} else {
4444
let second = second_result?;

0 commit comments

Comments
 (0)