Skip to main content

lash_tool_support/
lib.rs

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
9/// Resolve a possibly-relative `path` against `base`, returning a lexically
10/// normalized [`PathBuf`].
11///
12/// Behavior:
13/// - Absolute `path` passes through unchanged (only normalized).
14/// - Relative `path` is joined onto `base`.
15/// - `.` and `..` components are collapsed *lexically* — purely by string
16///   manipulation, without touching the filesystem and without requiring the
17///   path (or its parents) to exist.
18///
19/// Lexical (rather than `std::fs::canonicalize`) resolution is the deliberate
20/// choice for tool path handling: write/patch tools must resolve targets that
21/// do not yet exist on disk, and canonicalization both fails for missing paths
22/// and silently rewrites symlinks. Tools that genuinely need symlink-real-path
23/// resolution for an existence/scope check should use [`canonicalize_under`]
24/// instead and accept that it requires the path to exist.
25pub 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
34/// Lexically collapse `.` and `..` components in `path` without touching the
35/// filesystem. Leading `..` components (that would escape the root) are
36/// preserved verbatim, matching `Path::join` intuitions for relative roots.
37pub 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
55/// Resolve `path` against `base` (via [`resolve_under`]) and then canonicalize
56/// it on disk, resolving symlinks to their real path. Fails if the path does
57/// not exist. Use this only when a tool needs a real, existence-checked path
58/// (e.g. a security/scope decision or distinguishing a file from a directory);
59/// prefer [`resolve_under`] for write/patch targets that may not exist yet.
60pub fn canonicalize_under(base: &Path, path: &Path) -> std::io::Result<PathBuf> {
61    std::fs::canonicalize(resolve_under(base, path))
62}
63
64/// Render `path` relative to `base` for display, falling back to the file name
65/// (then the full path) when `path` is not under `base`. Backslashes are
66/// normalized to forward slashes so output is stable across platforms.
67pub 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
84/// Shared preamble describing default filesystem-listing behavior.
85/// Used by `ls` and `glob` so both tools document hidden-file and
86/// `.gitignore` handling in identical wording.
87pub 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
106/// Extract a required non-empty string arg, or return ToolResult::err.
107pub 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
114/// Parse optional bool arg with a default.
115pub 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
132/// Parse an optional positive integer arg.
133/// Accepts `null` or `"none"` when `allow_none` is true.
134pub 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 lashlang_binding(
253    module_path: impl IntoIterator<Item = impl Into<String>>,
254    operation: impl Into<String>,
255    aliases: &[&str],
256) -> lash_core::LashlangToolBinding {
257    lash_core::LashlangToolBinding::new(module_path, operation)
258        .with_aliases(aliases.iter().copied())
259}
260
261/// Run blocking filesystem work off the async runtime.
262pub async fn run_blocking<F>(f: F) -> ToolResult
263where
264    F: FnOnce() -> ToolResult + Send + 'static,
265{
266    match tokio::task::spawn_blocking(f).await {
267        Ok(result) => result,
268        Err(e) => ToolResult::err_fmt(format_args!("blocking task failed: {e}")),
269    }
270}
271
272/// Run blocking work off the async runtime and return a typed value.
273pub async fn run_blocking_value<F, T>(f: F) -> Result<T, String>
274where
275    F: FnOnce() -> T + Send + 'static,
276    T: Send + 'static,
277{
278    tokio::task::spawn_blocking(f)
279        .await
280        .map_err(|err| format!("blocking task failed: {err}"))
281}
282
283/// Build a normalized filesystem entry for tool output.
284/// Returns the entry plus raw mtime for optional sorting.
285pub fn build_path_entry(path: &Path, with_lines: bool) -> (PathEntry, SystemTime) {
286    let fallback_mtime = UNIX_EPOCH;
287    let path_str = path.to_string_lossy().to_string();
288
289    let metadata = match std::fs::symlink_metadata(path) {
290        Ok(m) => m,
291        Err(_) => {
292            let entry = PathEntry {
293                path: path_str,
294                kind: "other".to_string(),
295                size_bytes: 0,
296                lines: None,
297                modified_at: format_time_rfc3339(fallback_mtime),
298            };
299            return (entry, fallback_mtime);
300        }
301    };
302
303    let file_type = metadata.file_type();
304    let kind = if file_type.is_symlink() {
305        "symlink"
306    } else if file_type.is_dir() {
307        "dir"
308    } else if file_type.is_file() {
309        "file"
310    } else {
311        "other"
312    };
313
314    let mtime = metadata.modified().unwrap_or(fallback_mtime);
315    let lines = if with_lines && kind == "file" {
316        count_text_lines(path)
317    } else {
318        None
319    };
320
321    let entry = PathEntry {
322        path: path_str,
323        kind: kind.to_string(),
324        size_bytes: metadata.len(),
325        lines,
326        modified_at: format_time_rfc3339(mtime),
327    };
328    (entry, mtime)
329}
330
331pub fn rg_file_list(
332    base: &Path,
333    include_hidden: bool,
334    respect_gitignore: bool,
335    max_depth: Option<usize>,
336    globs: &[String],
337) -> Result<Vec<PathBuf>, ToolResult> {
338    let mut builder = ignore::WalkBuilder::new(base);
339    builder.hidden(!include_hidden).max_depth(max_depth);
340
341    if respect_gitignore {
342        builder.git_ignore(true).git_exclude(true).git_global(true);
343        builder.require_git(true);
344    } else {
345        builder
346            .git_ignore(false)
347            .git_exclude(false)
348            .git_global(false)
349            .ignore(false)
350            .parents(false)
351            .require_git(false);
352    }
353
354    if !globs.is_empty() {
355        let mut override_builder = ignore::overrides::OverrideBuilder::new(base);
356        for glob in globs {
357            override_builder.add(glob).map_err(|err| {
358                ToolResult::err_fmt(format_args!(
359                    "invalid ignore glob for {}: {err}",
360                    base.display()
361                ))
362            })?;
363        }
364
365        let overrides = override_builder.build().map_err(|err| {
366            ToolResult::err_fmt(format_args!(
367                "failed to build ignore globs for {}: {err}",
368                base.display()
369            ))
370        })?;
371        builder.overrides(overrides);
372    }
373
374    let files = builder
375        .build()
376        .filter_map(Result::ok)
377        .filter(|entry| entry.path() != base)
378        .map(ignore::DirEntry::into_path)
379        .collect();
380    Ok(files)
381}
382
383/// Build the standard result envelope returned by filesystem listing tools.
384pub fn filesystem_entries_result(items: Vec<PathEntry>, total_count: usize) -> serde_json::Value {
385    let shown = items.len();
386    let truncated = if total_count > shown {
387        Some(TruncationMeta {
388            shown,
389            total: total_count,
390            omitted: total_count - shown,
391        })
392    } else {
393        None
394    };
395    serde_json::json!({
396        "items": items,
397        "truncated": truncated,
398    })
399}
400
401fn count_text_lines(path: &Path) -> Option<u64> {
402    let file = std::fs::File::open(path).ok()?;
403    let reader = BufReader::new(file);
404    let mut count = 0_u64;
405    for line in reader.lines() {
406        if line.is_err() {
407            return None;
408        }
409        count += 1;
410    }
411    Some(count)
412}
413
414fn format_time_rfc3339(ts: SystemTime) -> String {
415    chrono::DateTime::<chrono::Utc>::from(ts).to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
416}
417
418/// Generate a compact unified diff between old and new content.
419/// Truncates to `max_lines` lines if the diff is too long.
420pub fn compact_diff(old: &str, new: &str, path: &str, max_lines: usize) -> String {
421    let diff = similar::TextDiff::from_lines(old, new);
422    let unified = diff
423        .unified_diff()
424        .header(&format!("a/{path}"), &format!("b/{path}"))
425        .to_string();
426    if unified.is_empty() {
427        return String::new();
428    }
429    let lines: Vec<&str> = unified.lines().collect();
430    if lines.len() <= max_lines {
431        unified
432    } else {
433        let mut truncated: String = lines[..max_lines].join("\n");
434        truncated.push_str(&format!("\n... ({} more lines)", lines.len() - max_lines));
435        truncated
436    }
437}