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