1use lash_core::ToolResult;
2use std::io::{BufRead, BufReader};
3use std::path::{Component, Path, PathBuf};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6mod static_provider;
7pub use static_provider::{StaticToolExecute, StaticToolProvider};
8
9pub fn resolve_under(base: &Path, path: &Path) -> PathBuf {
26 let joined = if path.is_absolute() {
27 path.to_path_buf()
28 } else {
29 base.join(path)
30 };
31 normalize_lexical(&joined)
32}
33
34pub fn normalize_lexical(path: &Path) -> PathBuf {
38 let mut normalized = PathBuf::new();
39 for component in path.components() {
40 match component {
41 Component::CurDir => {}
42 Component::ParentDir => {
43 if !normalized.pop() {
44 normalized.push(component.as_os_str());
45 }
46 }
47 Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
48 normalized.push(component.as_os_str());
49 }
50 }
51 }
52 normalized
53}
54
55pub fn canonicalize_under(base: &Path, path: &Path) -> std::io::Result<PathBuf> {
61 std::fs::canonicalize(resolve_under(base, path))
62}
63
64pub fn display_relative(base: &Path, path: &Path) -> String {
68 let display = path
69 .strip_prefix(base)
70 .unwrap_or(path)
71 .display()
72 .to_string();
73 let display = if display.is_empty() {
74 path.file_name()
75 .and_then(|name| name.to_str())
76 .unwrap_or(".")
77 .to_string()
78 } else {
79 display
80 };
81 display.replace('\\', "/")
82}
83
84pub const FS_DEFAULTS_PREAMBLE: &str =
88 "By default this includes hidden files and respects `.gitignore` only inside Git repos.";
89
90#[derive(Clone, Debug, serde::Serialize)]
91pub struct PathEntry {
92 pub path: String,
93 pub kind: String,
94 pub size_bytes: u64,
95 pub lines: Option<u64>,
96 pub modified_at: String,
97}
98
99#[derive(Clone, Debug, serde::Serialize)]
100pub struct TruncationMeta {
101 pub shown: usize,
102 pub total: usize,
103 pub omitted: usize,
104}
105
106pub fn require_str<'a>(args: &'a serde_json::Value, key: &str) -> Result<&'a str, ToolResult> {
108 args.get(key)
109 .and_then(|v| v.as_str())
110 .filter(|s| !s.is_empty())
111 .ok_or_else(|| ToolResult::err_fmt(format_args!("Missing required parameter: {key}")))
112}
113
114pub fn parse_optional_bool(
116 args: &serde_json::Value,
117 key: &str,
118 default: bool,
119) -> Result<bool, ToolResult> {
120 match args.get(key) {
121 None => Ok(default),
122 Some(v) if v.is_null() => Ok(default),
123 Some(v) => match v.as_bool() {
124 Some(b) => Ok(b),
125 None => Err(ToolResult::err_fmt(format_args!(
126 "Invalid {key}: expected bool"
127 ))),
128 },
129 }
130}
131
132pub fn parse_optional_usize_arg(
135 args: &serde_json::Value,
136 key: &str,
137 default: Option<usize>,
138 allow_none: bool,
139 min: usize,
140) -> Result<Option<usize>, ToolResult> {
141 match args.get(key) {
142 None => Ok(default),
143 Some(v) if v.is_null() => {
144 if allow_none {
145 Ok(None)
146 } else {
147 Err(ToolResult::err_fmt(format_args!(
148 "Invalid {key}: expected int >= {min}"
149 )))
150 }
151 }
152 Some(v) => {
153 if let Some(s) = v.as_str() {
154 if allow_none && s.eq_ignore_ascii_case("none") {
155 return Ok(None);
156 }
157 return Err(ToolResult::err_fmt(format_args!(
158 "Invalid {key}: expected int{}",
159 if allow_none {
160 ", null, or \"none\""
161 } else {
162 ""
163 }
164 )));
165 }
166 let n = v.as_u64().ok_or_else(|| {
167 ToolResult::err_fmt(format_args!(
168 "Invalid {key}: expected int{}",
169 if allow_none {
170 ", null, or \"none\""
171 } else {
172 ""
173 }
174 ))
175 })? as usize;
176 if n < min {
177 return Err(ToolResult::err_fmt(format_args!(
178 "Invalid {key}: must be >= {min}{}",
179 if allow_none {
180 ", or use null/\"none\" for no cap"
181 } else {
182 ""
183 }
184 )));
185 }
186 Ok(Some(n))
187 }
188 }
189}
190
191pub fn object_schema(properties: serde_json::Value, required: &[&str]) -> serde_json::Value {
192 serde_json::json!({
193 "type": "object",
194 "properties": properties,
195 "required": required,
196 "additionalProperties": false,
197 })
198}
199
200pub fn path_entry_output_schema() -> serde_json::Value {
201 serde_json::json!({
202 "type": "object",
203 "properties": {
204 "path": { "type": "string" },
205 "kind": { "type": "string", "enum": ["file", "dir", "symlink", "other"] },
206 "size_bytes": { "type": "integer", "minimum": 0 },
207 "lines": {
208 "anyOf": [
209 { "type": "integer", "minimum": 0 },
210 { "type": "null" }
211 ]
212 },
213 "modified_at": {
214 "type": "string",
215 "description": "Modification timestamp formatted as RFC3339 UTC."
216 }
217 },
218 "required": ["path", "kind", "size_bytes", "lines", "modified_at"],
219 "additionalProperties": false,
220 })
221}
222
223pub fn filesystem_entries_output_schema() -> serde_json::Value {
224 serde_json::json!({
225 "type": "object",
226 "properties": {
227 "items": {
228 "type": "array",
229 "items": path_entry_output_schema()
230 },
231 "truncated": {
232 "anyOf": [
233 {
234 "type": "object",
235 "properties": {
236 "shown": { "type": "integer", "minimum": 0 },
237 "total": { "type": "integer", "minimum": 0 },
238 "omitted": { "type": "integer", "minimum": 0 }
239 },
240 "required": ["shown", "total", "omitted"],
241 "additionalProperties": false
242 },
243 { "type": "null" }
244 ]
245 }
246 },
247 "required": ["items", "truncated"],
248 "additionalProperties": false,
249 })
250}
251
252pub fn agent_surface(
253 module_path: impl IntoIterator<Item = impl Into<String>>,
254 operation: impl Into<String>,
255 aliases: &[&str],
256) -> lash_core::ToolAgentSurface {
257 lash_core::ToolAgentSurface::new(module_path, operation).with_aliases(aliases.iter().copied())
258}
259
260pub async fn run_blocking<F>(f: F) -> ToolResult
262where
263 F: FnOnce() -> ToolResult + Send + 'static,
264{
265 match tokio::task::spawn_blocking(f).await {
266 Ok(result) => result,
267 Err(e) => ToolResult::err_fmt(format_args!("blocking task failed: {e}")),
268 }
269}
270
271pub async fn run_blocking_value<F, T>(f: F) -> Result<T, String>
273where
274 F: FnOnce() -> T + Send + 'static,
275 T: Send + 'static,
276{
277 tokio::task::spawn_blocking(f)
278 .await
279 .map_err(|err| format!("blocking task failed: {err}"))
280}
281
282pub fn build_path_entry(path: &Path, with_lines: bool) -> (PathEntry, SystemTime) {
285 let fallback_mtime = UNIX_EPOCH;
286 let path_str = path.to_string_lossy().to_string();
287
288 let metadata = match std::fs::symlink_metadata(path) {
289 Ok(m) => m,
290 Err(_) => {
291 let entry = PathEntry {
292 path: path_str,
293 kind: "other".to_string(),
294 size_bytes: 0,
295 lines: None,
296 modified_at: format_time_rfc3339(fallback_mtime),
297 };
298 return (entry, fallback_mtime);
299 }
300 };
301
302 let file_type = metadata.file_type();
303 let kind = if file_type.is_symlink() {
304 "symlink"
305 } else if file_type.is_dir() {
306 "dir"
307 } else if file_type.is_file() {
308 "file"
309 } else {
310 "other"
311 };
312
313 let mtime = metadata.modified().unwrap_or(fallback_mtime);
314 let lines = if with_lines && kind == "file" {
315 count_text_lines(path)
316 } else {
317 None
318 };
319
320 let entry = PathEntry {
321 path: path_str,
322 kind: kind.to_string(),
323 size_bytes: metadata.len(),
324 lines,
325 modified_at: format_time_rfc3339(mtime),
326 };
327 (entry, mtime)
328}
329
330pub fn rg_file_list(
331 base: &Path,
332 include_hidden: bool,
333 respect_gitignore: bool,
334 max_depth: Option<usize>,
335 globs: &[String],
336) -> Result<Vec<PathBuf>, ToolResult> {
337 let mut builder = ignore::WalkBuilder::new(base);
338 builder.hidden(!include_hidden).max_depth(max_depth);
339
340 if respect_gitignore {
341 builder.git_ignore(true).git_exclude(true).git_global(true);
342 builder.require_git(true);
343 } else {
344 builder
345 .git_ignore(false)
346 .git_exclude(false)
347 .git_global(false)
348 .ignore(false)
349 .parents(false)
350 .require_git(false);
351 }
352
353 if !globs.is_empty() {
354 let mut override_builder = ignore::overrides::OverrideBuilder::new(base);
355 for glob in globs {
356 override_builder.add(glob).map_err(|err| {
357 ToolResult::err_fmt(format_args!(
358 "invalid ignore glob for {}: {err}",
359 base.display()
360 ))
361 })?;
362 }
363
364 let overrides = override_builder.build().map_err(|err| {
365 ToolResult::err_fmt(format_args!(
366 "failed to build ignore globs for {}: {err}",
367 base.display()
368 ))
369 })?;
370 builder.overrides(overrides);
371 }
372
373 let files = builder
374 .build()
375 .filter_map(Result::ok)
376 .filter(|entry| entry.path() != base)
377 .map(ignore::DirEntry::into_path)
378 .collect();
379 Ok(files)
380}
381
382pub fn filesystem_entries_result(items: Vec<PathEntry>, total_count: usize) -> serde_json::Value {
384 let shown = items.len();
385 let truncated = if total_count > shown {
386 Some(TruncationMeta {
387 shown,
388 total: total_count,
389 omitted: total_count - shown,
390 })
391 } else {
392 None
393 };
394 serde_json::json!({
395 "items": items,
396 "truncated": truncated,
397 })
398}
399
400fn count_text_lines(path: &Path) -> Option<u64> {
401 let file = std::fs::File::open(path).ok()?;
402 let reader = BufReader::new(file);
403 let mut count = 0_u64;
404 for line in reader.lines() {
405 if line.is_err() {
406 return None;
407 }
408 count += 1;
409 }
410 Some(count)
411}
412
413fn format_time_rfc3339(ts: SystemTime) -> String {
414 chrono::DateTime::<chrono::Utc>::from(ts).to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
415}
416
417pub fn compact_diff(old: &str, new: &str, path: &str, max_lines: usize) -> String {
420 let diff = similar::TextDiff::from_lines(old, new);
421 let unified = diff
422 .unified_diff()
423 .header(&format!("a/{path}"), &format!("b/{path}"))
424 .to_string();
425 if unified.is_empty() {
426 return String::new();
427 }
428 let lines: Vec<&str> = unified.lines().collect();
429 if lines.len() <= max_lines {
430 unified
431 } else {
432 let mut truncated: String = lines[..max_lines].join("\n");
433 truncated.push_str(&format!("\n... ({} more lines)", lines.len() - max_lines));
434 truncated
435 }
436}