dsh-ops 0.0.0-stage → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +202 -0
- package/LICENSE +30 -0
- package/NOTICE +106 -0
- package/PROVENANCE.md +435 -0
- package/README.en.md +126 -0
- package/README.md +115 -2
- package/README.zh.md +116 -0
- package/bin/dsh-ops.mjs +1216 -0
- package/cordis.patch.yml +160 -0
- package/docs/manual-validation.md +53 -0
- package/docs/release-0.2.1.md +72 -0
- package/docs/schema-baseline.json +64 -0
- package/docs/schema-current.json +84 -0
- package/docs/schema-measurement.md +17 -0
- package/dsh-plugin.json +88 -0
- package/icon.svg +12 -0
- package/lib/binary.js +409 -0
- package/lib/config.js +198 -0
- package/lib/handshake.js +252 -0
- package/lib/index.js +108 -0
- package/lib/jobs.js +42 -0
- package/lib/policy.js +64 -0
- package/lib/presentation.js +63 -0
- package/lib/profile-install.js +61 -0
- package/lib/rust.js +194 -0
- package/lib/session-shells.js +78 -0
- package/lib/shells.js +998 -0
- package/lib/tools.js +657 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +114 -4
- package/vendor/fastctx/Cargo.lock +3210 -0
- package/vendor/fastctx/Cargo.toml +94 -0
- package/vendor/fastctx/FORK.md +119 -0
- package/vendor/fastctx/LICENSE-APACHE +201 -0
- package/vendor/fastctx/NOTICE +40 -0
- package/vendor/fastctx/README.md +439 -0
- package/vendor/fastctx/THIRD_PARTY_LICENSES.md +17 -0
- package/vendor/fastctx/THIRD_PARTY_LICENSES_RUST.md +7914 -0
- package/vendor/fastctx/UPSTREAM.md +49 -0
- package/vendor/fastctx/build.rs +413 -0
- package/vendor/fastctx/src/background_status.rs +403 -0
- package/vendor/fastctx/src/binary.rs +75 -0
- package/vendor/fastctx/src/bounded_sort.rs +500 -0
- package/vendor/fastctx/src/budget.rs +781 -0
- package/vendor/fastctx/src/cli/mod.rs +110 -0
- package/vendor/fastctx/src/context_guard.rs +289 -0
- package/vendor/fastctx/src/control/mod.rs +6 -0
- package/vendor/fastctx/src/control/paths.rs +49 -0
- package/vendor/fastctx/src/control/settings.rs +753 -0
- package/vendor/fastctx/src/control/transaction.rs +531 -0
- package/vendor/fastctx/src/edit/document.rs +535 -0
- package/vendor/fastctx/src/edit/locks.rs +371 -0
- package/vendor/fastctx/src/edit/mod.rs +213 -0
- package/vendor/fastctx/src/edit/private_storage/unix.rs +315 -0
- package/vendor/fastctx/src/edit/private_storage/windows.rs +793 -0
- package/vendor/fastctx/src/edit/private_storage.rs +234 -0
- package/vendor/fastctx/src/edit/replace.rs +1030 -0
- package/vendor/fastctx/src/edit_server.rs +53 -0
- package/vendor/fastctx/src/encoding/reference_v011.rs +587 -0
- package/vendor/fastctx/src/encoding/snapshot_pipeline.rs +1678 -0
- package/vendor/fastctx/src/encoding.rs +1118 -0
- package/vendor/fastctx/src/file_executor.rs +1151 -0
- package/vendor/fastctx/src/file_snapshot.rs +1491 -0
- package/vendor/fastctx/src/glob_filter.rs +98 -0
- package/vendor/fastctx/src/glob_tool.rs +653 -0
- package/vendor/fastctx/src/grep_sink.rs +1162 -0
- package/vendor/fastctx/src/grep_tool.rs +2449 -0
- package/vendor/fastctx/src/lib.rs +45 -0
- package/vendor/fastctx/src/main.rs +15 -0
- package/vendor/fastctx/src/model.rs +51 -0
- package/vendor/fastctx/src/model_guidance.rs +62 -0
- package/vendor/fastctx/src/operation.rs +356 -0
- package/vendor/fastctx/src/ordered_window.rs +1235 -0
- package/vendor/fastctx/src/os_environment.rs +414 -0
- package/vendor/fastctx/src/path_codec.rs +850 -0
- package/vendor/fastctx/src/paths.rs +244 -0
- package/vendor/fastctx/src/process_identity.rs +763 -0
- package/vendor/fastctx/src/process_policy.rs +74 -0
- package/vendor/fastctx/src/read_tool/batch.rs +496 -0
- package/vendor/fastctx/src/read_tool/hex_file.rs +141 -0
- package/vendor/fastctx/src/read_tool/image_file.rs +88 -0
- package/vendor/fastctx/src/read_tool/mod.rs +245 -0
- package/vendor/fastctx/src/read_tool/pdf.rs +470 -0
- package/vendor/fastctx/src/read_tool/pdf_disabled.rs +47 -0
- package/vendor/fastctx/src/read_tool/pdf_engine.rs +664 -0
- package/vendor/fastctx/src/read_tool/text_file.rs +351 -0
- package/vendor/fastctx/src/render_plan.rs +468 -0
- package/vendor/fastctx/src/runtime/activity.rs +159 -0
- package/vendor/fastctx/src/runtime/hosts.rs +99 -0
- package/vendor/fastctx/src/runtime/journal.rs +556 -0
- package/vendor/fastctx/src/runtime/local_ipc.rs +186 -0
- package/vendor/fastctx/src/runtime/mod.rs +746 -0
- package/vendor/fastctx/src/runtime/protocol.rs +296 -0
- package/vendor/fastctx/src/runtime/session.rs +536 -0
- package/vendor/fastctx/src/runtime/windows_process.rs +66 -0
- package/vendor/fastctx/src/search_parallelism.rs +106 -0
- package/vendor/fastctx/src/search_text.rs +227 -0
- package/vendor/fastctx/src/server.rs +359 -0
- package/vendor/fastctx/src/server_manifest.rs +468 -0
- package/vendor/fastctx/src/server_support.rs +826 -0
- package/vendor/fastctx/src/session.rs +629 -0
- package/vendor/fastctx/src/shell/apply_patch_hint.rs +41 -0
- package/vendor/fastctx/src/shell/bash.rs +263 -0
- package/vendor/fastctx/src/shell/buffer.rs +108 -0
- package/vendor/fastctx/src/shell/encoding.rs +403 -0
- package/vendor/fastctx/src/shell/foreground.rs +115 -0
- package/vendor/fastctx/src/shell/jobs/admission.rs +91 -0
- package/vendor/fastctx/src/shell/jobs/background.rs +146 -0
- package/vendor/fastctx/src/shell/jobs/host.rs +830 -0
- package/vendor/fastctx/src/shell/jobs/identity.rs +29 -0
- package/vendor/fastctx/src/shell/jobs/mod.rs +1513 -0
- package/vendor/fastctx/src/shell/jobs/model.rs +244 -0
- package/vendor/fastctx/src/shell/jobs/output_log.rs +1148 -0
- package/vendor/fastctx/src/shell/jobs/store.rs +1300 -0
- package/vendor/fastctx/src/shell/mod.rs +345 -0
- package/vendor/fastctx/src/shell/normalize.rs +389 -0
- package/vendor/fastctx/src/shell/output.rs +406 -0
- package/vendor/fastctx/src/shell/process.rs +493 -0
- package/vendor/fastctx/src/shell_server.rs +156 -0
- package/vendor/fastctx/src/skip_report.rs +83 -0
- package/vendor/fastctx/src/stdio_transport.rs +177 -0
- package/vendor/fastctx/src/tool_schema.rs +204 -0
- package/vendor/fastctx/src/traversal.rs +846 -0
- package/vendor/fastctx/third-party/pdfium-7763/LICENSE +9 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/abseil.txt +202 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/agg23.txt +14 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/fast_float.txt +27 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/freetype.txt +169 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/icu.txt +542 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/lcms.txt +27 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.ijg +260 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.md +135 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libopenjpeg.txt +32 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libpng.txt +134 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libtiff.txt +21 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/llvm-libc.txt +278 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/pdfium.txt +230 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/simdutf.txt +18 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/zlib.txt +29 -0
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
//! Explicit ignore policy, deterministic ordering, metadata rendering, and resumable paging.
|
|
2
|
+
|
|
3
|
+
use crate::bounded_sort::sort_cancelable;
|
|
4
|
+
use crate::budget::{
|
|
5
|
+
ErrorBudgetAdapter, ErrorClass, GLOB_TOKEN_BUDGET_ENV, error_budget_hint, tool_token_budget,
|
|
6
|
+
};
|
|
7
|
+
use crate::file_executor::GrepGlobExecutor;
|
|
8
|
+
use crate::glob_filter::{GlobPatterns, PathGlobFilter};
|
|
9
|
+
use crate::model::ToolResponse;
|
|
10
|
+
use crate::operation::{OpError, OperationCtx, RequestWorkGuard};
|
|
11
|
+
use crate::path_codec::{PathRecord, ResolvedRoot, RootRequirement, resolve_search_root};
|
|
12
|
+
use crate::render_plan::{LineRenderGraph, RenderPlanError};
|
|
13
|
+
use crate::skip_report::{SkipTally, detail_line, terminal_with_skips};
|
|
14
|
+
use crate::traversal::{
|
|
15
|
+
SkippedPaths, TraversalCollection, TraversalFailure, TraversalLimit, collect_walk_batched,
|
|
16
|
+
};
|
|
17
|
+
use ignore::WalkBuilder;
|
|
18
|
+
use schemars::JsonSchema;
|
|
19
|
+
use serde::{Deserialize, Serialize};
|
|
20
|
+
use std::fs;
|
|
21
|
+
use std::io;
|
|
22
|
+
use std::sync::Arc;
|
|
23
|
+
use std::time::SystemTime;
|
|
24
|
+
use time::{OffsetDateTime, macros::format_description};
|
|
25
|
+
use tokio_util::sync::CancellationToken;
|
|
26
|
+
|
|
27
|
+
const DEFAULT_LIMIT: usize = 100;
|
|
28
|
+
const MAX_LIMIT: usize = 1_000;
|
|
29
|
+
const MAX_RESULTS: usize = 100_000;
|
|
30
|
+
const TOO_MANY_MATCHES_ERROR: &str =
|
|
31
|
+
"Too many matches: over 100000 files matched. Narrow the pattern or path.";
|
|
32
|
+
|
|
33
|
+
/// Ignore-file policy used by glob traversal.
|
|
34
|
+
#[derive(Clone, Copy, Debug, Default, JsonSchema, Eq, PartialEq)]
|
|
35
|
+
#[serde(rename_all = "snake_case")]
|
|
36
|
+
pub enum FilterMode {
|
|
37
|
+
/// Respect plain `.ignore` files while keeping every other file visible.
|
|
38
|
+
#[default]
|
|
39
|
+
Ignore,
|
|
40
|
+
/// Disable plain `.ignore` filtering.
|
|
41
|
+
All,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
impl<'de> Deserialize<'de> for FilterMode {
|
|
45
|
+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
46
|
+
where
|
|
47
|
+
D: serde::Deserializer<'de>,
|
|
48
|
+
{
|
|
49
|
+
let value = String::deserialize(deserializer)?;
|
|
50
|
+
match value.as_str() {
|
|
51
|
+
"ignore" | "project" => Ok(Self::Ignore),
|
|
52
|
+
"all" => Ok(Self::All),
|
|
53
|
+
_ => Err(serde::de::Error::unknown_variant(
|
|
54
|
+
&value,
|
|
55
|
+
&["ignore", "all"],
|
|
56
|
+
)),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Deterministic ordering for glob results.
|
|
62
|
+
#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Eq, PartialEq)]
|
|
63
|
+
#[serde(rename_all = "snake_case")]
|
|
64
|
+
pub enum SortMode {
|
|
65
|
+
/// Sort by absolute-path bytes in ascending order.
|
|
66
|
+
#[default]
|
|
67
|
+
Path,
|
|
68
|
+
/// Sort by modification time descending, then by path bytes ascending.
|
|
69
|
+
Modified,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// Presentation used for each matched file.
|
|
73
|
+
#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Eq, PartialEq)]
|
|
74
|
+
#[serde(rename_all = "snake_case")]
|
|
75
|
+
pub enum GlobOutputMode {
|
|
76
|
+
/// Return only the canonical model-facing path.
|
|
77
|
+
#[default]
|
|
78
|
+
Paths,
|
|
79
|
+
/// Return one compact JSON object with path, byte size, and UTC modification time.
|
|
80
|
+
Details,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// Parameters for the glob tool.
|
|
84
|
+
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
|
85
|
+
pub struct GlobRequest {
|
|
86
|
+
/// Globs to match files, e.g. ["**/*.rs", "!tests/**"]. A leading `!` excludes and always wins; negative-only patterns list every other file.
|
|
87
|
+
// Published as a plain string array rather than the string-or-array union the type
|
|
88
|
+
// accepts. A union is the one construct no provider subset takes: Gemini rejects a
|
|
89
|
+
// node that carries `anyOf` beside a `description`, and every parameter here has
|
|
90
|
+
// one. Deserialization is unchanged — a bare string still parses. (2026-08-17)
|
|
91
|
+
#[schemars(with = "Vec<String>")]
|
|
92
|
+
pub pattern: GlobPatterns,
|
|
93
|
+
/// Directory to search; omit for the session working directory.
|
|
94
|
+
#[schemars(description = crate::model_guidance::local_path_description(
|
|
95
|
+
"Directory to search. Omit for the session working directory; when provided, it must name an existing directory."
|
|
96
|
+
))]
|
|
97
|
+
pub path: Option<String>,
|
|
98
|
+
/// "ignore" respects only plain .ignore files; "all" disables that filtering. Both include hidden files and .git, and neither reads any Git ignore source. The legacy value "project" is accepted as "ignore" but is not published.
|
|
99
|
+
pub filter_mode: Option<FilterMode>,
|
|
100
|
+
/// "path" = byte-order path sort. "modified" = most recently modified first.
|
|
101
|
+
pub sort: Option<SortMode>,
|
|
102
|
+
/// "paths" (default) returns one path per line. "details" returns one compact JSON object per line as {"path":"...","bytes":123,"modified":"YYYY-MM-DDTHH:MM:SS.NNNNNNNNNZ"}.
|
|
103
|
+
pub output_mode: Option<GlobOutputMode>,
|
|
104
|
+
/// Skip the first N results — for paging.
|
|
105
|
+
pub offset: Option<usize>,
|
|
106
|
+
/// Max results per page (1-1000).
|
|
107
|
+
#[schemars(range(min = 1, max = 1_000))]
|
|
108
|
+
pub limit: Option<usize>,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#[derive(Debug, Eq, PartialEq)]
|
|
112
|
+
struct MatchEntry {
|
|
113
|
+
path: PathRecord,
|
|
114
|
+
rendered: Arc<str>,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/// Finds files within a caller-owned cancellation scope.
|
|
118
|
+
///
|
|
119
|
+
/// Cancellation is checked throughout traversal, collection, sorting,
|
|
120
|
+
/// rendering, and token verification. A cancelled operation returns an error
|
|
121
|
+
/// response and never exposes a partial success body.
|
|
122
|
+
pub fn glob_files(request: GlobRequest, cancellation: CancellationToken) -> ToolResponse {
|
|
123
|
+
let (mut guard, operation) = RequestWorkGuard::new(
|
|
124
|
+
rmcp::model::RequestId::String(Arc::from("direct-glob")),
|
|
125
|
+
cancellation,
|
|
126
|
+
);
|
|
127
|
+
let response = glob_files_with_execution(request, operation, GrepGlobExecutor::shared());
|
|
128
|
+
guard.disarm();
|
|
129
|
+
response
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
fn glob_files_with_execution(
|
|
133
|
+
request: GlobRequest,
|
|
134
|
+
operation: OperationCtx,
|
|
135
|
+
executor: Arc<GrepGlobExecutor>,
|
|
136
|
+
) -> ToolResponse {
|
|
137
|
+
let adapter = ErrorBudgetAdapter::new(
|
|
138
|
+
error_budget_hint(GLOB_TOKEN_BUDGET_ENV),
|
|
139
|
+
GLOB_TOKEN_BUDGET_ENV,
|
|
140
|
+
);
|
|
141
|
+
let budget = match tool_token_budget(GLOB_TOKEN_BUDGET_ENV) {
|
|
142
|
+
Ok(budget) => budget,
|
|
143
|
+
Err(message) => return adapter.error(ErrorClass::Budget, message),
|
|
144
|
+
};
|
|
145
|
+
adapter.adapt(glob_files_with_execution_unadapted(
|
|
146
|
+
request,
|
|
147
|
+
budget.value,
|
|
148
|
+
budget.variable,
|
|
149
|
+
&operation,
|
|
150
|
+
&executor,
|
|
151
|
+
))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
fn glob_files_with_execution_unadapted(
|
|
155
|
+
request: GlobRequest,
|
|
156
|
+
budget: usize,
|
|
157
|
+
budget_variable: &str,
|
|
158
|
+
operation: &OperationCtx,
|
|
159
|
+
executor: &Arc<GrepGlobExecutor>,
|
|
160
|
+
) -> ToolResponse {
|
|
161
|
+
if operation.check().is_err() {
|
|
162
|
+
return ToolResponse::error("Request cancelled.");
|
|
163
|
+
}
|
|
164
|
+
let root = match resolve_search_root(request.path.as_deref(), RootRequirement::Directory) {
|
|
165
|
+
Ok(root) => root,
|
|
166
|
+
Err(message) => return ToolResponse::error(message),
|
|
167
|
+
};
|
|
168
|
+
let matcher = match build_matcher(&request.pattern) {
|
|
169
|
+
Ok(matcher) => matcher,
|
|
170
|
+
Err(message) => return ToolResponse::error(message),
|
|
171
|
+
};
|
|
172
|
+
let limit = request.limit.unwrap_or(DEFAULT_LIMIT);
|
|
173
|
+
if !(1..=MAX_LIMIT).contains(&limit) {
|
|
174
|
+
return ToolResponse::error(format!(
|
|
175
|
+
"Invalid limit value: {limit}. Expected an integer from 1 to 1000."
|
|
176
|
+
));
|
|
177
|
+
}
|
|
178
|
+
let sort = request.sort.unwrap_or_default();
|
|
179
|
+
let output_mode = request.output_mode.unwrap_or_default();
|
|
180
|
+
let collected = match collect_matches(
|
|
181
|
+
&root,
|
|
182
|
+
&matcher,
|
|
183
|
+
request.filter_mode.unwrap_or_default(),
|
|
184
|
+
sort,
|
|
185
|
+
output_mode,
|
|
186
|
+
operation,
|
|
187
|
+
executor,
|
|
188
|
+
) {
|
|
189
|
+
Ok(collected) => collected,
|
|
190
|
+
Err(message) => return ToolResponse::error(message),
|
|
191
|
+
};
|
|
192
|
+
let report = SkipReport::new(&collected.skipped);
|
|
193
|
+
let matches = match sort_cancelable(
|
|
194
|
+
collected.items,
|
|
195
|
+
move |left, right| compare_match_entries(sort, left, right),
|
|
196
|
+
Some(operation),
|
|
197
|
+
Some(executor),
|
|
198
|
+
) {
|
|
199
|
+
Ok(sorted) => sorted.items,
|
|
200
|
+
Err(error) => return ToolResponse::error(error.to_string()),
|
|
201
|
+
};
|
|
202
|
+
format_matches(
|
|
203
|
+
&matches,
|
|
204
|
+
&report,
|
|
205
|
+
request.offset.unwrap_or(0),
|
|
206
|
+
limit,
|
|
207
|
+
budget,
|
|
208
|
+
budget_variable,
|
|
209
|
+
Some(operation),
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/// Runs glob on the server's request cancellation scope and shared executor.
|
|
214
|
+
pub(crate) fn glob_files_cancellable(
|
|
215
|
+
operation: OperationCtx,
|
|
216
|
+
executor: Arc<GrepGlobExecutor>,
|
|
217
|
+
request: GlobRequest,
|
|
218
|
+
) -> Result<ToolResponse, OpError> {
|
|
219
|
+
let work = operation.inline_work();
|
|
220
|
+
work.check_inline()?;
|
|
221
|
+
let response = glob_files_with_execution(request, operation.clone(), executor);
|
|
222
|
+
work.check_inline()?;
|
|
223
|
+
Ok(response)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
fn build_matcher(patterns: &GlobPatterns) -> Result<PathGlobFilter, String> {
|
|
227
|
+
PathGlobFilter::compile(patterns, true).map_err(|error| glob_error(&error))
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
fn glob_error(error: &impl std::fmt::Display) -> String {
|
|
231
|
+
format!("Invalid glob pattern: {error}. Use forms like \"**/*.rs\" or \"src/**/*.ts\".")
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
fn collect_matches(
|
|
235
|
+
root: &ResolvedRoot,
|
|
236
|
+
matcher: &PathGlobFilter,
|
|
237
|
+
filter_mode: FilterMode,
|
|
238
|
+
sort: SortMode,
|
|
239
|
+
output_mode: GlobOutputMode,
|
|
240
|
+
operation: &OperationCtx,
|
|
241
|
+
executor: &Arc<GrepGlobExecutor>,
|
|
242
|
+
) -> Result<TraversalCollection<MatchEntry>, String> {
|
|
243
|
+
if operation.check().is_err() {
|
|
244
|
+
return Err("Request cancelled.".to_string());
|
|
245
|
+
}
|
|
246
|
+
let mut builder = WalkBuilder::new(&root.native);
|
|
247
|
+
match filter_mode {
|
|
248
|
+
FilterMode::Ignore => {
|
|
249
|
+
builder
|
|
250
|
+
.standard_filters(false)
|
|
251
|
+
.parents(true)
|
|
252
|
+
.hidden(false)
|
|
253
|
+
.ignore(true)
|
|
254
|
+
.git_ignore(false)
|
|
255
|
+
.git_global(false)
|
|
256
|
+
.git_exclude(false)
|
|
257
|
+
.follow_links(false);
|
|
258
|
+
}
|
|
259
|
+
FilterMode::All => {
|
|
260
|
+
builder
|
|
261
|
+
.standard_filters(false)
|
|
262
|
+
.hidden(false)
|
|
263
|
+
.follow_links(false);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
collect_walk_batched(
|
|
267
|
+
builder,
|
|
268
|
+
&root.native,
|
|
269
|
+
Some(operation),
|
|
270
|
+
Some(executor),
|
|
271
|
+
Some(TraversalLimit {
|
|
272
|
+
maximum: MAX_RESULTS,
|
|
273
|
+
message: TOO_MANY_MATCHES_ERROR,
|
|
274
|
+
}),
|
|
275
|
+
|entry| {
|
|
276
|
+
if !entry
|
|
277
|
+
.file_type()
|
|
278
|
+
.is_some_and(|file_type| file_type.is_file() || file_type.is_symlink())
|
|
279
|
+
{
|
|
280
|
+
return Ok(None);
|
|
281
|
+
}
|
|
282
|
+
evaluate_match(root, entry, matcher, sort, output_mode)
|
|
283
|
+
},
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
fn compare_match_entries(
|
|
288
|
+
sort: SortMode,
|
|
289
|
+
left: &MatchEntry,
|
|
290
|
+
right: &MatchEntry,
|
|
291
|
+
) -> std::cmp::Ordering {
|
|
292
|
+
match sort {
|
|
293
|
+
SortMode::Path => left
|
|
294
|
+
.path
|
|
295
|
+
.display
|
|
296
|
+
.as_bytes()
|
|
297
|
+
.cmp(right.path.display.as_bytes())
|
|
298
|
+
.then_with(|| left.path.native_key.cmp(&right.path.native_key)),
|
|
299
|
+
SortMode::Modified => right.path.modified.cmp(&left.path.modified).then_with(|| {
|
|
300
|
+
left.path
|
|
301
|
+
.display
|
|
302
|
+
.as_bytes()
|
|
303
|
+
.cmp(right.path.display.as_bytes())
|
|
304
|
+
.then_with(|| left.path.native_key.cmp(&right.path.native_key))
|
|
305
|
+
}),
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
fn evaluate_match(
|
|
310
|
+
root: &ResolvedRoot,
|
|
311
|
+
entry: &ignore::DirEntry,
|
|
312
|
+
matcher: &PathGlobFilter,
|
|
313
|
+
sort: SortMode,
|
|
314
|
+
output_mode: GlobOutputMode,
|
|
315
|
+
) -> Result<Option<MatchEntry>, TraversalFailure> {
|
|
316
|
+
let path = entry.path();
|
|
317
|
+
let preliminary = PathRecord::without_metadata(path, &root.native);
|
|
318
|
+
if !matcher.is_match(preliminary.relative_match.as_ref()) {
|
|
319
|
+
return Ok(None);
|
|
320
|
+
}
|
|
321
|
+
if sort == SortMode::Path
|
|
322
|
+
&& output_mode == GlobOutputMode::Paths
|
|
323
|
+
&& entry
|
|
324
|
+
.file_type()
|
|
325
|
+
.is_some_and(|file_type| file_type.is_file())
|
|
326
|
+
{
|
|
327
|
+
let rendered = Arc::clone(&preliminary.display);
|
|
328
|
+
return Ok(Some(MatchEntry {
|
|
329
|
+
path: preliminary,
|
|
330
|
+
rendered,
|
|
331
|
+
}));
|
|
332
|
+
}
|
|
333
|
+
let metadata = if entry
|
|
334
|
+
.file_type()
|
|
335
|
+
.is_some_and(|file_type| file_type.is_symlink())
|
|
336
|
+
{
|
|
337
|
+
// Symlinks keep the follow-and-check semantics (broken links skip).
|
|
338
|
+
match fs::metadata(path) {
|
|
339
|
+
Ok(metadata) if metadata.is_file() => metadata,
|
|
340
|
+
Ok(_) => return Ok(None),
|
|
341
|
+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
|
342
|
+
Err(error) => return Err(TraversalFailure::from_io(path, &error)),
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
match entry.metadata() {
|
|
346
|
+
Ok(metadata) if metadata.is_file() => metadata,
|
|
347
|
+
Ok(_) => return Ok(None),
|
|
348
|
+
// Fall back to the plain stat so rare metadata failures keep the
|
|
349
|
+
// exact error/skip semantics of the original lookup.
|
|
350
|
+
Err(_) => match fs::metadata(path) {
|
|
351
|
+
Ok(metadata) if metadata.is_file() => metadata,
|
|
352
|
+
Ok(_) => return Ok(None),
|
|
353
|
+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
|
354
|
+
Err(error) => return Err(TraversalFailure::from_io(path, &error)),
|
|
355
|
+
},
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
let include_modified = sort == SortMode::Modified || output_mode == GlobOutputMode::Details;
|
|
359
|
+
let record = PathRecord::from_metadata(path, &root.native, &metadata, include_modified)
|
|
360
|
+
.map_err(|error| TraversalFailure::from_io(path, &error))?;
|
|
361
|
+
let rendered = if output_mode == GlobOutputMode::Details {
|
|
362
|
+
format_match_details(&record).map_err(|error| TraversalFailure::from_io(path, &error))?
|
|
363
|
+
} else {
|
|
364
|
+
Arc::clone(&record.display)
|
|
365
|
+
};
|
|
366
|
+
Ok(Some(MatchEntry {
|
|
367
|
+
path: record,
|
|
368
|
+
rendered,
|
|
369
|
+
}))
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
#[derive(Serialize)]
|
|
373
|
+
struct MatchDetails<'a> {
|
|
374
|
+
path: &'a str,
|
|
375
|
+
bytes: u64,
|
|
376
|
+
modified: &'a str,
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
fn format_match_details(record: &PathRecord) -> io::Result<Arc<str>> {
|
|
380
|
+
let bytes = record
|
|
381
|
+
.traversal_len_hint
|
|
382
|
+
.ok_or_else(|| io::Error::other("glob detail metadata is missing the file size"))?;
|
|
383
|
+
let modified = record
|
|
384
|
+
.modified
|
|
385
|
+
.ok_or_else(|| io::Error::other("glob detail metadata is missing the modification time"))?;
|
|
386
|
+
let modified = format_modified_utc(modified)?;
|
|
387
|
+
serde_json::to_string(&MatchDetails {
|
|
388
|
+
path: &record.display,
|
|
389
|
+
bytes,
|
|
390
|
+
modified: &modified,
|
|
391
|
+
})
|
|
392
|
+
.map(Arc::from)
|
|
393
|
+
.map_err(|error| io::Error::other(format!("cannot serialize glob details: {error}")))
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
fn format_modified_utc(value: SystemTime) -> io::Result<String> {
|
|
397
|
+
let nanoseconds = match value.duration_since(SystemTime::UNIX_EPOCH) {
|
|
398
|
+
Ok(duration) => {
|
|
399
|
+
i128::from(duration.as_secs()) * 1_000_000_000 + i128::from(duration.subsec_nanos())
|
|
400
|
+
}
|
|
401
|
+
Err(error) => {
|
|
402
|
+
let duration = error.duration();
|
|
403
|
+
-(i128::from(duration.as_secs()) * 1_000_000_000 + i128::from(duration.subsec_nanos()))
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
OffsetDateTime::from_unix_timestamp_nanos(nanoseconds)
|
|
407
|
+
.map_err(|error| {
|
|
408
|
+
io::Error::other(format!("file modification time is out of range: {error}"))
|
|
409
|
+
})?
|
|
410
|
+
.format(format_description!(
|
|
411
|
+
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:9]Z"
|
|
412
|
+
))
|
|
413
|
+
.map_err(|error| io::Error::other(format!("cannot format file modification time: {error}")))
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/// A note set that fits the budget, with the token count that proved it.
|
|
417
|
+
type FittedNotes = Option<(Vec<Arc<str>>, usize)>;
|
|
418
|
+
|
|
419
|
+
/// The paths this walk never entered, ready to be rendered alongside results.
|
|
420
|
+
struct SkipReport {
|
|
421
|
+
details: Vec<Arc<str>>,
|
|
422
|
+
tally: SkipTally,
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
impl SkipReport {
|
|
426
|
+
fn new(skipped: &SkippedPaths) -> Self {
|
|
427
|
+
let details = skipped
|
|
428
|
+
.listed()
|
|
429
|
+
.map(|path| Arc::<str>::from(detail_line(path.display, path.reason)))
|
|
430
|
+
.collect::<Vec<_>>();
|
|
431
|
+
Self {
|
|
432
|
+
tally: SkipTally {
|
|
433
|
+
files: 0,
|
|
434
|
+
unreachable: skipped.total(),
|
|
435
|
+
listed: details.len(),
|
|
436
|
+
},
|
|
437
|
+
details,
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
fn notes(&self, shown_details: usize, terminal: &str) -> Option<Vec<Arc<str>>> {
|
|
442
|
+
let terminal = terminal_with_skips(terminal, &self.tally, shown_details)?;
|
|
443
|
+
let mut notes = self.details[..shown_details].to_vec();
|
|
444
|
+
notes.push(Arc::from(terminal));
|
|
445
|
+
Some(notes)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
fn format_matches(
|
|
450
|
+
matches: &[MatchEntry],
|
|
451
|
+
report: &SkipReport,
|
|
452
|
+
offset: usize,
|
|
453
|
+
limit: usize,
|
|
454
|
+
budget: usize,
|
|
455
|
+
budget_variable: &str,
|
|
456
|
+
operation: Option<&OperationCtx>,
|
|
457
|
+
) -> ToolResponse {
|
|
458
|
+
let total = matches.len();
|
|
459
|
+
if total == 0 {
|
|
460
|
+
return status_response(
|
|
461
|
+
"(Complete: no files matched.)".to_string(),
|
|
462
|
+
report,
|
|
463
|
+
budget,
|
|
464
|
+
budget_variable,
|
|
465
|
+
operation,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
if offset >= total {
|
|
469
|
+
let verb = if total == 1 { "exists" } else { "exist" };
|
|
470
|
+
return status_response(
|
|
471
|
+
format!(
|
|
472
|
+
"(Complete: no files at offset={offset}; only {} {verb}.)",
|
|
473
|
+
counted(total, "file", "files")
|
|
474
|
+
),
|
|
475
|
+
report,
|
|
476
|
+
budget,
|
|
477
|
+
budget_variable,
|
|
478
|
+
operation,
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
let maximum = limit.min(total - offset);
|
|
483
|
+
let lines = matches[offset..offset + maximum]
|
|
484
|
+
.iter()
|
|
485
|
+
.map(|entry| Arc::clone(&entry.rendered))
|
|
486
|
+
.collect::<Vec<_>>();
|
|
487
|
+
let mut graph = match LineRenderGraph::new(
|
|
488
|
+
lines,
|
|
489
|
+
operation.map(|operation| operation as &dyn crate::operation::WorkCheckpoint),
|
|
490
|
+
) {
|
|
491
|
+
Ok(graph) => graph,
|
|
492
|
+
Err(error) => return render_plan_failure(error),
|
|
493
|
+
};
|
|
494
|
+
// The body is sized against a bare skip tally so results never lose room to
|
|
495
|
+
// the detail lines describing what is missing; detail fills what is left.
|
|
496
|
+
for shown in (1..=maximum).rev() {
|
|
497
|
+
let terminal = glob_terminal(offset, shown, total);
|
|
498
|
+
let Some(notes) = report.notes(0, &terminal) else {
|
|
499
|
+
return render_plan_failure(RenderPlanError::InvalidTerminal);
|
|
500
|
+
};
|
|
501
|
+
let tokens = match graph.probe_notes(
|
|
502
|
+
shown,
|
|
503
|
+
¬es,
|
|
504
|
+
operation.map(|operation| operation as &dyn crate::operation::WorkCheckpoint),
|
|
505
|
+
) {
|
|
506
|
+
Ok(tokens) => tokens,
|
|
507
|
+
Err(error) => return render_plan_failure(error),
|
|
508
|
+
};
|
|
509
|
+
if tokens <= budget {
|
|
510
|
+
return finish_with_skips(
|
|
511
|
+
&mut graph,
|
|
512
|
+
shown,
|
|
513
|
+
&terminal,
|
|
514
|
+
report,
|
|
515
|
+
budget,
|
|
516
|
+
budget_variable,
|
|
517
|
+
operation,
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
budget_too_small(budget, budget_variable)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/// Renders a fixed body with as many skip details as the remaining budget holds.
|
|
525
|
+
///
|
|
526
|
+
/// The caller has already proven the zero-detail form fits, so the search only
|
|
527
|
+
/// has to find how much detail fits on top of it.
|
|
528
|
+
fn finish_with_skips(
|
|
529
|
+
graph: &mut LineRenderGraph,
|
|
530
|
+
shown: usize,
|
|
531
|
+
terminal: &str,
|
|
532
|
+
report: &SkipReport,
|
|
533
|
+
budget: usize,
|
|
534
|
+
budget_variable: &str,
|
|
535
|
+
operation: Option<&OperationCtx>,
|
|
536
|
+
) -> ToolResponse {
|
|
537
|
+
let work = operation.map(|operation| operation as &dyn crate::operation::WorkCheckpoint);
|
|
538
|
+
let probe =
|
|
539
|
+
|graph: &mut LineRenderGraph, details: usize| -> Result<FittedNotes, RenderPlanError> {
|
|
540
|
+
let notes = report
|
|
541
|
+
.notes(details, terminal)
|
|
542
|
+
.ok_or(RenderPlanError::InvalidTerminal)?;
|
|
543
|
+
let tokens = graph.probe_notes(shown, ¬es, work)?;
|
|
544
|
+
Ok((tokens <= budget).then_some((notes, tokens)))
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
let full = report.details.len();
|
|
548
|
+
let mut best = match probe(graph, full) {
|
|
549
|
+
Ok(Some(hit)) => Some(hit),
|
|
550
|
+
Ok(None) => None,
|
|
551
|
+
Err(error) => return render_plan_failure(error),
|
|
552
|
+
};
|
|
553
|
+
if best.is_none() && full > 0 {
|
|
554
|
+
let (mut low, mut high) = (0_usize, full - 1);
|
|
555
|
+
while low <= high {
|
|
556
|
+
let middle = low + (high - low) / 2;
|
|
557
|
+
match probe(graph, middle) {
|
|
558
|
+
Ok(Some(hit)) => {
|
|
559
|
+
best = Some(hit);
|
|
560
|
+
low = middle + 1;
|
|
561
|
+
}
|
|
562
|
+
Ok(None) => {
|
|
563
|
+
if middle == 0 {
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
high = middle - 1;
|
|
567
|
+
}
|
|
568
|
+
Err(error) => return render_plan_failure(error),
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
let Some((notes, tokens)) = best else {
|
|
573
|
+
return budget_too_small(budget, budget_variable);
|
|
574
|
+
};
|
|
575
|
+
match graph.finish(shown, ¬es, tokens, budget, work) {
|
|
576
|
+
Ok(rendered) => {
|
|
577
|
+
debug_assert!(rendered.tokens <= budget);
|
|
578
|
+
ToolResponse::text(rendered.text)
|
|
579
|
+
}
|
|
580
|
+
Err(error) => render_plan_failure(error),
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
fn glob_terminal(offset: usize, shown: usize, total: usize) -> String {
|
|
585
|
+
let range = entry_range(offset + 1, shown);
|
|
586
|
+
if offset + shown < total {
|
|
587
|
+
format!(
|
|
588
|
+
"(Partial: {range} of {total} shown. Continue with offset={}.)",
|
|
589
|
+
offset + shown
|
|
590
|
+
)
|
|
591
|
+
} else if offset == 0 {
|
|
592
|
+
format!("(Complete: all {} shown.)", counted(total, "file", "files"))
|
|
593
|
+
} else {
|
|
594
|
+
format!("(Complete: {range} of {total} shown; end of results.)")
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
fn entry_range(first: usize, shown: usize) -> String {
|
|
599
|
+
if shown == 1 {
|
|
600
|
+
format!("file {first}")
|
|
601
|
+
} else {
|
|
602
|
+
format!("files {first}-{}", first + shown - 1)
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
fn counted(count: usize, singular: &str, plural: &str) -> String {
|
|
607
|
+
let noun = if count == 1 { singular } else { plural };
|
|
608
|
+
format!("{count} {noun}")
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/// Emits a body-less result. An empty match set is exactly when unreachable
|
|
612
|
+
/// paths matter most, so the skip report travels with it.
|
|
613
|
+
fn status_response(
|
|
614
|
+
status: String,
|
|
615
|
+
report: &SkipReport,
|
|
616
|
+
budget: usize,
|
|
617
|
+
budget_variable: &str,
|
|
618
|
+
operation: Option<&OperationCtx>,
|
|
619
|
+
) -> ToolResponse {
|
|
620
|
+
let mut graph = match LineRenderGraph::new(
|
|
621
|
+
Vec::new(),
|
|
622
|
+
operation.map(|operation| operation as &dyn crate::operation::WorkCheckpoint),
|
|
623
|
+
) {
|
|
624
|
+
Ok(graph) => graph,
|
|
625
|
+
Err(error) => return render_plan_failure(error),
|
|
626
|
+
};
|
|
627
|
+
finish_with_skips(
|
|
628
|
+
&mut graph,
|
|
629
|
+
0,
|
|
630
|
+
&status,
|
|
631
|
+
report,
|
|
632
|
+
budget,
|
|
633
|
+
budget_variable,
|
|
634
|
+
operation,
|
|
635
|
+
)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
fn render_plan_failure(error: RenderPlanError) -> ToolResponse {
|
|
639
|
+
if error.is_cancelled() {
|
|
640
|
+
ToolResponse::error("Request cancelled.")
|
|
641
|
+
} else {
|
|
642
|
+
ToolResponse::error(format!("Internal glob rendering failure: {error}"))
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
fn budget_too_small(budget: usize, budget_variable: &str) -> ToolResponse {
|
|
647
|
+
ErrorBudgetAdapter::new(budget, budget_variable).error(
|
|
648
|
+
ErrorClass::Budget,
|
|
649
|
+
format!(
|
|
650
|
+
"{budget_variable}={budget} is too small to return the required glob truncation note. Increase it and retry."
|
|
651
|
+
),
|
|
652
|
+
)
|
|
653
|
+
}
|