feat(tools): add filesystem tools crate with glob pattern support

- Add new tools-fs crate with read, glob, and grep utilities
- Fix glob command to support actual glob patterns (**, *) instead of just directory walking
- Rename binary from "code" to "owlen" to match package name
- Fix test to reference correct binary name "owlen"
- Add API key support to OllamaClient for authentication

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-01 18:40:57 +01:00
parent dcda8216dc
commit 7f39bf1eca
8 changed files with 142 additions and 7 deletions

View File

@@ -0,0 +1,15 @@
[package]
name = "tools-fs"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[dependencies]
ignore = "0.4"
walkdir = "2.5"
globset = "0.4"
grep-regex = "0.1"
grep-searcher = "0.1"
color-eyre = "0.6"
tempfile = "3.23.0"

View File

@@ -0,0 +1,77 @@
use color_eyre::eyre::Result;
use ignore::WalkBuilder;
use grep_regex::RegexMatcher;
use grep_searcher::{sinks::UTF8, SearcherBuilder};
use globset::Glob;
pub fn read_file(path: &str) -> Result<String> {
Ok(std::fs::read_to_string(path)?)
}
pub fn glob_list(pattern: &str) -> Result<Vec<String>> {
let glob = Glob::new(pattern)?.compile_matcher();
// Extract the literal prefix to determine the root directory
let root = pattern
.split("**")
.next()
.and_then(|s| {
let trimmed = s.trim_end_matches('/');
if trimmed.is_empty() { None } else { Some(trimmed) }
})
.unwrap_or(".");
let mut out = Vec::new();
for result in WalkBuilder::new(root)
.standard_filters(true)
.git_ignore(true)
.git_global(false)
.git_exclude(false)
.require_git(false)
.build()
{
let entity = result?;
if entity.file_type().map(|filetype| filetype.is_file()).unwrap_or(false) {
if let Some(path) = entity.path().to_str() {
// Match against the glob pattern
if glob.is_match(path) {
out.push(path.to_string());
}
}
}
}
Ok(out)
}
pub fn grep(root: &str, pattern: &str) -> Result<Vec<(String, usize, String)>> {
let matcher = RegexMatcher::new_line_matcher(pattern)?;
let mut searcher = SearcherBuilder::new().line_number(true).build();
let mut results = Vec::new();
for result in WalkBuilder::new(root)
.standard_filters(true)
.git_ignore(true)
.git_global(false)
.git_exclude(false)
.require_git(false)
.build()
{
let entity = result?;
if !entity.file_type().map(|filetype| filetype.is_file()).unwrap_or(false) { continue; }
let path = entity.path().to_path_buf();
let mut line_hits: Vec<(usize, String)> = Vec::new();
let sink = UTF8(|line_number, line| {
line_hits.push((line_number as usize, line.to_string()));
Ok(true)
});
let _ = searcher.search_path(&matcher, &path, sink);
if !line_hits.is_empty() {
let p = path.to_string_lossy().to_string();
for (line_number, text) in line_hits {
results.push((p.clone(), line_number, text));
}
}
}
Ok(results)
}

View File

@@ -0,0 +1,28 @@
use tools_fs::{read_file, glob_list, grep};
use std::fs;
use tempfile::tempdir;
#[test]
fn read_and_glob_respect_gitignore() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::write(root.join("a.txt"), "hello").unwrap();
fs::create_dir(root.join("secret")).unwrap();
fs::write(root.join("secret/secret.txt"), "token=123").unwrap();
fs::write(root.join(".gitignore"), "secret/\n").unwrap();
let files = glob_list(root.to_str().unwrap()).unwrap();
assert!(files.iter().any(|p| p.ends_with("a.txt")));
assert!(!files.iter().any(|p| p.contains("secret.txt")));
assert_eq!(read_file(root.join("a.txt").to_str().unwrap()).unwrap(), "hello");
}
#[test]
fn grep_finds_lines() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::write(root.join("a.rs"), "fn main() { println!(\"hello\"); }").unwrap();
let hits = grep(root.to_str().unwrap(), "hello").unwrap();
assert!(hits.iter().any(|(_p, _ln, text)| text.contains("hello")));
}