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 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
260/// Run blocking filesystem work off the async runtime.
261pub 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
271/// Run blocking work off the async runtime and return a typed value.
272pub 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
282/// Build a normalized filesystem entry for tool output.
283/// Returns the entry plus raw mtime for optional sorting.
284pub 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
382/// Build the standard result envelope returned by filesystem listing tools.
383pub 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
417/// Generate a compact unified diff between old and new content.
418/// Truncates to `max_lines` lines if the diff is too long.
419pub 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}