Skip to main content

lash_tool_support/
lib.rs

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
61/// Resolve a possibly-relative `path` against `base`, returning a lexically
62/// normalized [`PathBuf`].
63///
64/// Behavior:
65/// - Absolute `path` passes through unchanged (only normalized).
66/// - Relative `path` is joined onto `base`.
67/// - `.` and `..` components are collapsed *lexically* — purely by string
68///   manipulation, without touching the filesystem and without requiring the
69///   path (or its parents) to exist.
70///
71/// Lexical (rather than `std::fs::canonicalize`) resolution is the deliberate
72/// choice for tool path handling: write/patch tools must resolve targets that
73/// do not yet exist on disk, and canonicalization both fails for missing paths
74/// and silently rewrites symlinks. Tools that genuinely need symlink-real-path
75/// resolution for an existence/scope check should use [`canonicalize_under`]
76/// instead and accept that it requires the path to exist.
77pub 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
86/// Lexically collapse `.` and `..` components in `path` without touching the
87/// filesystem. Leading `..` components (that would escape the root) are
88/// preserved verbatim, matching `Path::join` intuitions for relative roots.
89pub 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
107/// Resolve `path` against `base` (via [`resolve_under`]) and then canonicalize
108/// it on disk, resolving symlinks to their real path. Fails if the path does
109/// not exist. Use this only when a tool needs a real, existence-checked path
110/// (e.g. a security/scope decision or distinguishing a file from a directory);
111/// prefer [`resolve_under`] for write/patch targets that may not exist yet.
112pub fn canonicalize_under(base: &Path, path: &Path) -> std::io::Result<PathBuf> {
113    std::fs::canonicalize(resolve_under(base, path))
114}
115
116/// Render `path` relative to `base` for display, falling back to the file name
117/// (then the full path) when `path` is not under `base`. Backslashes are
118/// normalized to forward slashes so output is stable across platforms.
119pub 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
136/// Shared preamble describing default filesystem-listing behavior.
137/// Used by `ls` and `glob` so both tools document hidden-file and
138/// `.gitignore` handling in identical wording.
139pub 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
158/// Extract a required non-empty string arg, or return ToolResult::err.
159pub 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
166/// Parse optional bool arg with a default.
167pub 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
184/// Parse an optional positive integer arg.
185/// Accepts `null` or `"none"` when `allow_none` is true.
186pub 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
312/// Run blocking filesystem work off the async runtime.
313pub 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
323/// Run blocking work off the async runtime and return a typed value.
324pub 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
334/// Build a normalized filesystem entry for tool output.
335/// Returns the entry plus raw mtime for optional sorting.
336pub 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
434/// Build the standard result envelope returned by filesystem listing tools.
435pub 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
469/// Generate a compact unified diff between old and new content.
470/// Truncates to `max_lines` lines if the diff is too long.
471pub 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}