use async_trait::async_trait;
use clap::{CommandFactory, Parser};
use kaish_glob::glob_match;
use std::path::Path;
use std::time::SystemTime;
use kaish_types::clock::system_now;
use crate::ast::Value;
use crate::backend_walker_fs::BackendWalkerFs;
use crate::vfs::DirEntry;
use crate::ignore_config::IgnoreScope;
use crate::interpreter::{EntryType, ExecResult, OutputData, OutputNode};
use crate::tools::{schema_from_clap, ExecContext, ToolCtx, GlobalFlags, Tool, ToolArgs, ToolSchema};
use crate::walker::{EntryTypes, FileWalker, GlobPath, WalkOptions};
pub struct Find;
#[derive(Parser, Debug)]
#[command(name = "find", about = "Search for files in directory hierarchy")]
struct FindArgs {
#[arg(long = "name")]
name: Option<String>,
#[arg(id = "type", long = "type")]
type_: Option<String>,
#[arg(long = "maxdepth")]
maxdepth: Option<String>,
#[arg(long = "mindepth")]
mindepth: Option<String>,
#[arg(long = "mtime")]
mtime: Option<String>,
#[arg(long = "size")]
size: Option<String>,
#[arg(long = "path", id = "path_glob")]
path_glob: Option<String>,
#[arg(long = "ipath")]
ipath: Option<String>,
#[command(flatten)]
global: GlobalFlags,
paths: Vec<String>,
}
#[async_trait]
impl Tool for Find {
fn name(&self) -> &str {
"find"
}
fn schema(&self) -> ToolSchema {
schema_from_clap(
&FindArgs::command(),
"find",
"Search for files in directory hierarchy",
[
("Find all files", "find ."),
("Find by name pattern", "find src -name '*.rs'"),
("Find directories only", "find . -type d"),
],
)
}
async fn execute(&self, args: ToolArgs, ctx: &mut dyn ToolCtx) -> ExecResult {
let Some(ctx) = ctx.as_any_mut().downcast_mut::<ExecContext>() else {
return ExecResult::failure(1, "internal error: kernel builtin requires ExecContext");
};
let argv = match args.to_argv() {
Ok(v) => v,
Err(e) => return ExecResult::failure(2, format!("find: {e}")),
};
let parsed = match FindArgs::try_parse_from(
std::iter::once("find".to_string()).chain(argv),
) {
Ok(p) => p,
Err(e) => return ExecResult::failure(2, format!("find: {e}")),
};
parsed.global.apply(ctx);
let start_paths: Vec<String> = if args.positional.is_empty() {
vec![".".to_string()]
} else {
match crate::interpreter::values_to_text_sink_named(&args.positional, "a path") {
Ok(p) => p,
Err(e) => return ExecResult::failure(1, format!("find: {e}")),
}
};
let name_pattern = parsed.name.clone();
let type_filter = parsed.type_.clone();
let entry_types = match type_filter.as_deref() {
Some("f") => EntryTypes::files_only(),
Some("d") => EntryTypes::dirs_only(),
Some(t) => {
return ExecResult::failure(1, format!("find: invalid type '{}': use 'f' or 'd'", t));
}
None => EntryTypes::all(),
};
let max_depth: Option<usize> = match parsed.maxdepth.as_deref() {
None => None,
Some(s) => match s.parse() {
Ok(d) => Some(d),
Err(_) => {
return ExecResult::failure(
1,
format!("find: invalid -maxdepth '{s}': expected a non-negative integer"),
)
}
},
};
let gnu_mindepth: Option<usize> = match parsed.mindepth.as_deref() {
None => None,
Some(s) => match s.parse() {
Ok(d) => Some(d),
Err(_) => {
return ExecResult::failure(
1,
format!("find: invalid -mindepth '{s}': expected a non-negative integer"),
)
}
},
};
let min_depth: Option<usize> = match gnu_mindepth {
None | Some(0) | Some(1) => None, Some(n) => Some(n - 1),
};
let mtime_filter = args.get_named("mtime").and_then(parse_relative_int);
let size_filter = args
.get_string("size", usize::MAX)
.and_then(|s| parse_size_filter(&s));
let path_glob = parsed.path_glob.clone();
let ipath_glob = parsed.ipath.clone();
let respect_ignore = matches!(ctx.ignore_config.scope(), IgnoreScope::Enforced)
&& ctx.ignore_config.is_active();
let mut nodes: Vec<OutputNode> = Vec::new();
let mut json_array: Vec<serde_json::Value> = Vec::new();
for start_path in &start_paths {
let resolved_path = ctx.resolve_path(start_path);
if !ctx.backend.exists(Path::new(&resolved_path)).await {
return ExecResult::failure(
1,
format!("find: '{}': No such file or directory", start_path),
);
}
let start_stat = ctx.backend.stat(Path::new(&resolved_path)).await.ok();
let start_is_file = start_stat
.as_ref()
.map(|s| !s.is_dir())
.unwrap_or(false);
if start_is_file {
if matches!(type_filter.as_deref(), Some("d")) {
continue;
}
if let Some(ref pattern) = name_pattern {
let filename = Path::new(&resolved_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
if !glob_match(pattern, filename) {
continue;
}
}
if !path_matches(&path_glob, &ipath_glob, start_path) {
continue;
}
if !passes_mtime_size(start_stat.as_ref(), mtime_filter, size_filter) {
continue;
}
let entry_type = EntryType::File;
nodes.push(OutputNode::new(start_path).with_entry_type(entry_type));
json_array.push(serde_json::Value::String(start_path.clone()));
continue;
}
if max_depth == Some(0) {
if !matches!(type_filter.as_deref(), Some("f")) {
if let Some(ref pattern) = name_pattern {
let filename = Path::new(&resolved_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(".");
if !glob_match(pattern, filename) {
continue;
}
}
if !path_matches(&path_glob, &ipath_glob, start_path) {
continue;
}
if !passes_mtime_size(start_stat.as_ref(), mtime_filter, size_filter) {
continue;
}
nodes.push(
OutputNode::new(start_path).with_entry_type(EntryType::Directory),
);
json_array.push(serde_json::Value::String(start_path.clone()));
}
continue;
}
let options = WalkOptions {
max_depth,
min_depth,
entry_types,
include_hidden: true, respect_gitignore: if respect_ignore {
ctx.ignore_config.auto_gitignore()
} else {
false
},
..WalkOptions::default()
};
let fs = BackendWalkerFs(ctx.backend.as_ref());
let mut walker = FileWalker::new(&fs, &resolved_path).with_options(options);
if respect_ignore {
if let Some(ignore_filter) = ctx.build_ignore_filter(&resolved_path).await {
walker = walker.with_ignore(ignore_filter);
}
}
if let Some(ref pattern) = name_pattern {
let glob_pattern = if pattern.contains('/') {
pattern.clone()
} else {
format!("**/{}", pattern)
};
match GlobPath::new(&glob_pattern) {
Ok(glob) => walker = walker.with_pattern(glob),
Err(e) => {
return ExecResult::failure(
1,
format!("find: invalid pattern '{}': {}", pattern, e),
);
}
}
}
let paths = match walker.collect().await {
Ok(p) => p,
Err(e) => return ExecResult::failure(1, format!("find: {}", e)),
};
let _now_secs = system_now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
for path in paths {
let info = ctx.backend.stat(&path).await.ok();
if let Some((sign, days)) = mtime_filter
&& let Some(ref info) = info
&& let Some(modified) = info.modified
{
let age_secs = modified.elapsed().map(|d| d.as_secs()).unwrap_or(0);
let age_days = age_secs / 86400;
let matches = match sign {
'+' => age_days > days,
'-' => age_days < days,
_ => age_days == days,
};
if !matches {
continue;
}
}
if let Some((sign, size)) = size_filter
&& let Some(ref info) = info
{
let matches = match sign {
'+' => info.size > size,
'-' => info.size < size,
_ => info.size == size,
};
if !matches {
continue;
}
}
let display_path = relative_display_path(&path, &resolved_path, start_path);
if !path_matches(&path_glob, &ipath_glob, &display_path) {
continue;
}
let entry_type = info
.map(|i| if i.is_dir() { EntryType::Directory } else { EntryType::File })
.unwrap_or(EntryType::File);
nodes.push(OutputNode::new(&display_path).with_entry_type(entry_type));
json_array.push(serde_json::Value::String(display_path));
}
}
let output = OutputData::nodes(nodes);
let mut result = ExecResult::with_output(output);
result.data = Some(Value::Json(serde_json::Value::Array(json_array)));
if ctx.output_format.is_none()
&& let Some(output) = result.output()
&& let Err(e) = super::guard_no_newline_names("find", output)
{
return ExecResult::failure(2, e);
}
result
}
}
fn path_matches(path_glob: &Option<String>, ipath_glob: &Option<String>, display_path: &str) -> bool {
if let Some(pat) = path_glob {
if !glob_match(pat, display_path) {
return false;
}
}
if let Some(pat) = ipath_glob {
if !glob_match(&pat.to_lowercase(), &display_path.to_lowercase()) {
return false;
}
}
true
}
fn relative_display_path(path: &Path, resolved_path: &Path, start_path: &str) -> String {
let resolved = resolved_path;
match path.strip_prefix(resolved) {
Ok(rel) if rel.as_os_str().is_empty() => start_path.to_string(),
Ok(rel) => format!("{}/{}", start_path.trim_end_matches('/'), rel.display()),
Err(_) => path.to_string_lossy().into_owned(),
}
}
fn parse_relative_int(value: &Value) -> Option<(char, u64)> {
let s = match value {
Value::Int(i) => return Some(('=', *i as u64)),
Value::String(s) => s.as_str(),
_ => return None,
};
if s.is_empty() {
return None;
}
let first = s.chars().next()?;
if first == '+' || first == '-' {
let num: u64 = s[1..].parse().ok()?;
Some((first, num))
} else {
let num: u64 = s.parse().ok()?;
Some(('=', num))
}
}
fn passes_mtime_size(
info: Option<&DirEntry>,
mtime: Option<(char, u64)>,
size: Option<(char, u64)>,
) -> bool {
if let Some((sign, days)) = mtime
&& let Some(info) = info
&& let Some(modified) = info.modified
{
let age_days = modified.elapsed().map(|d| d.as_secs()).unwrap_or(0) / 86400;
let ok = match sign {
'+' => age_days > days,
'-' => age_days < days,
_ => age_days == days,
};
if !ok {
return false;
}
}
if let Some((sign, sz)) = size
&& let Some(info) = info
{
let ok = match sign {
'+' => info.size > sz,
'-' => info.size < sz,
_ => info.size == sz,
};
if !ok {
return false;
}
}
true
}
fn parse_size_filter(s: &str) -> Option<(char, u64)> {
if s.is_empty() {
return None;
}
let first = s.chars().next()?;
let (sign, rest) = if first == '+' || first == '-' {
(first, &s[1..])
} else {
('=', s)
};
let last = rest.chars().last()?;
let (num_str, multiplier) = match last.to_ascii_uppercase() {
'K' => (&rest[..rest.len()-1], 1024u64),
'M' => (&rest[..rest.len()-1], 1024 * 1024),
'G' => (&rest[..rest.len()-1], 1024 * 1024 * 1024),
_ => (rest, 1),
};
let num: u64 = num_str.parse().ok()?;
Some((sign, num * multiplier))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vfs::{Filesystem, MemoryFs, VfsRouter};
use std::sync::Arc;
async fn make_test_ctx() -> ExecContext {
let mut vfs = VfsRouter::new();
let mem = MemoryFs::new();
mem.mkdir(Path::new("src")).await.unwrap();
mem.mkdir(Path::new("src/lib")).await.unwrap();
mem.mkdir(Path::new("test")).await.unwrap();
mem.mkdir(Path::new(".hidden")).await.unwrap();
mem.write(Path::new("src/main.rs"), b"fn main() {}")
.await
.unwrap();
mem.write(Path::new("src/lib.rs"), b"pub mod lib;")
.await
.unwrap();
mem.write(Path::new("src/lib/utils.rs"), b"pub fn util() {}")
.await
.unwrap();
mem.write(Path::new("test/test_main.rs"), b"#[test]")
.await
.unwrap();
mem.write(Path::new("README.md"), b"# Test").await.unwrap();
mem.write(Path::new(".hidden/secret.txt"), b"secret")
.await
.unwrap();
vfs.mount("/", mem);
ExecContext::new(Arc::new(vfs))
}
#[tokio::test]
async fn test_find_all() {
let mut ctx = make_test_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/".into()));
let result = Find.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("main.rs"));
assert!(result.text_out().contains("lib.rs"));
assert!(result.text_out().contains("README.md"));
}
#[tokio::test]
async fn test_find_by_name() {
let mut ctx = make_test_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/".into()));
args.named.insert("name".to_string(), Value::String("*.rs".into()));
let result = Find.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("main.rs"));
assert!(result.text_out().contains("lib.rs"));
assert!(result.text_out().contains("utils.rs"));
assert!(!result.text_out().contains("README.md"));
}
#[tokio::test]
async fn test_find_type_file() {
let mut ctx = make_test_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/".into()));
args.named.insert("type".to_string(), Value::String("f".into()));
let result = Find.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("main.rs"));
assert!(!result.text_out().contains("/src\n")); }
#[tokio::test]
async fn test_find_type_dir() {
let mut ctx = make_test_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/".into()));
args.named.insert("type".to_string(), Value::String("d".into()));
let result = Find.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("src"));
assert!(result.text_out().contains("lib"));
assert!(!result.text_out().contains("main.rs"));
}
#[tokio::test]
async fn test_find_maxdepth() {
let mut ctx = make_test_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/".into()));
args.named.insert("maxdepth".to_string(), Value::Int(1));
args.named.insert("type".to_string(), Value::String("f".into()));
let result = Find.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("main.rs"));
assert!(!result.text_out().contains("utils.rs"));
}
#[tokio::test]
async fn test_find_nonexistent_path() {
let mut ctx = make_test_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/nonexistent".into()));
let result = Find.execute(args, &mut ctx).await;
assert!(!result.ok());
assert!(result.err.contains("No such file or directory"));
}
#[tokio::test]
async fn test_find_hidden() {
let mut ctx = make_test_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/".into()));
let result = Find.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains(".hidden"));
assert!(result.text_out().contains("secret.txt"));
}
async fn make_ctx_with_artifacts() -> ExecContext {
let mut vfs = VfsRouter::new();
let mem = MemoryFs::new();
mem.mkdir(Path::new("src")).await.unwrap();
mem.mkdir(Path::new("target")).await.unwrap();
mem.mkdir(Path::new("target/debug")).await.unwrap();
mem.mkdir(Path::new("node_modules")).await.unwrap();
mem.mkdir(Path::new("node_modules/foo")).await.unwrap();
mem.write(Path::new("src/main.rs"), b"fn main() {}")
.await
.unwrap();
mem.write(Path::new("target/debug/binary"), b"\x7fELF")
.await
.unwrap();
mem.write(Path::new("node_modules/foo/index.js"), b"module.exports = {}")
.await
.unwrap();
mem.write(Path::new("README.md"), b"# Test").await.unwrap();
vfs.mount("/", mem);
ExecContext::new(Arc::new(vfs))
}
#[tokio::test]
async fn test_find_advisory_ignores_nothing() {
let mut ctx = make_ctx_with_artifacts().await;
ctx.ignore_config = crate::ignore_config::IgnoreConfig::none();
ctx.ignore_config.set_defaults(true);
let mut args = ToolArgs::new();
args.positional.push(Value::String("/".into()));
let result = Find.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("target"), "Advisory find should show target/");
assert!(result.text_out().contains("node_modules"), "Advisory find should show node_modules/");
assert!(result.text_out().contains("main.rs"));
}
#[tokio::test]
async fn test_find_enforced_filters_defaults() {
let mut ctx = make_ctx_with_artifacts().await;
ctx.ignore_config = crate::ignore_config::IgnoreConfig::agent();
let mut args = ToolArgs::new();
args.positional.push(Value::String("/".into()));
let result = Find.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(!result.text_out().contains("target"), "Enforced find should skip target/");
assert!(!result.text_out().contains("node_modules"), "Enforced find should skip node_modules/");
assert!(result.text_out().contains("main.rs"));
assert!(result.text_out().contains("README.md"));
}
#[tokio::test]
async fn test_find_enforced_but_inactive() {
let mut ctx = make_ctx_with_artifacts().await;
let mut config = crate::ignore_config::IgnoreConfig::none();
config.set_scope(crate::ignore_config::IgnoreScope::Enforced);
ctx.ignore_config = config;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/".into()));
let result = Find.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("target"), "Enforced but inactive should show target/");
assert!(result.text_out().contains("node_modules"));
}
#[test]
fn test_parse_size_filter() {
assert_eq!(parse_size_filter("+1000"), Some(('+', 1000)));
assert_eq!(parse_size_filter("-500"), Some(('-', 500)));
assert_eq!(parse_size_filter("100"), Some(('=', 100)));
assert_eq!(parse_size_filter("+1K"), Some(('+', 1024)));
assert_eq!(parse_size_filter("+1M"), Some(('+', 1024 * 1024)));
assert_eq!(parse_size_filter("2G"), Some(('=', 2 * 1024 * 1024 * 1024)));
}
#[test]
fn test_parse_relative_int() {
assert_eq!(parse_relative_int(&Value::Int(5)), Some(('=', 5)));
assert_eq!(parse_relative_int(&Value::String("+7".into())), Some(('+', 7)));
assert_eq!(parse_relative_int(&Value::String("-3".into())), Some(('-', 3)));
assert_eq!(parse_relative_int(&Value::String("10".into())), Some(('=', 10)));
}
}