use super::{
ToolResult, ToolResultDisplay, ToolRuntime,
args::{AST_GREP_DEFAULT_LIMIT, AST_GREP_STDOUT_MAX_BYTES, AstGrepArgs, AstGrepOperation},
contract::{metadata_key as meta, tool_name},
fs::ExistingPathPolicy,
workspace::WorkspaceWalkOptions,
};
use crate::cancellation::AgentCancellation;
use serde_json::json;
use std::{
path::{Path, PathBuf},
time::{Duration, Instant},
};
const AST_GREP_TIMEOUT: Duration = Duration::from_secs(30);
mod embedded;
mod outline;
mod search;
#[derive(Default)]
struct AstGrepRunState {
stdout: String,
exit_code: i32,
timed_out: bool,
stdout_truncated: bool,
workset_truncated: bool,
}
impl ToolRuntime {
pub(super) fn ast_grep(
&self,
args: AstGrepArgs,
cancellation: &AgentCancellation,
) -> anyhow::Result<ToolResult> {
self.ast_grep_with_timeout(args, cancellation, AST_GREP_TIMEOUT)
}
fn ast_grep_with_timeout(
&self,
args: AstGrepArgs,
cancellation: &AgentCancellation,
timeout: Duration,
) -> anyhow::Result<ToolResult> {
let args = args.validate()?;
let path_arg = args.path.clone().unwrap_or_else(|| ".".to_string());
let resolved = self.resolve_existing_path(
&path_arg,
ExistingPathPolicy::ast_grep(self.ast_grep_absolute_paths),
)?;
cancellation.check()?;
if args.operation == AstGrepOperation::Search {
return search::run(self, &args, &resolved, cancellation, timeout);
}
outline::run(self, &args, &resolved, cancellation, timeout)
}
}
fn append_batch_output(aggregate: &mut String, batch: &str) {
if aggregate.is_empty() || batch.is_empty() {
aggregate.push_str(batch);
} else {
if !aggregate.ends_with('\n') {
aggregate.push('\n');
}
aggregate.push_str(batch);
}
}
struct AstGrepResultInput<'a> {
stdout: &'a str,
exit_code: i32,
timed_out: bool,
stdout_truncated: bool,
workset_truncated: bool,
}
fn ast_grep_result(input: AstGrepResultInput<'_>, args: &AstGrepArgs, limit: usize) -> ToolResult {
let AstGrepResultInput {
stdout,
exit_code,
timed_out,
stdout_truncated,
workset_truncated,
} = input;
let no_matches = exit_code == 1 && stdout.trim().is_empty();
let lines: Vec<&str> = stdout.lines().take(limit).collect();
let truncated = stdout.lines().count() > lines.len() || stdout_truncated || workset_truncated;
let success = !timed_out && (exit_code == 0 || no_matches);
let mut metadata = json!({meta::ENGINE:"ast-grep", meta::EXIT_CODE:exit_code, meta::TIMED_OUT:timed_out, meta::MATCHES_RETURNED:lines.len(), meta::TRUNCATED:truncated, meta::STDOUT_TRUNCATED:stdout_truncated});
if let Some(lang) = &args.language {
metadata[meta::LANGUAGE] = json!(lang);
}
if args.rewrite.is_some() {
metadata[meta::REWRITE] = json!(true);
}
ToolResult {
tool_name: tool_name::AST_GREP.to_string(),
success,
content: lines.join("\n"),
metadata,
display: ToolResultDisplay::default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cancellation::AgentCancellation;
use serde_json::json;
use std::{
fs,
sync::{Arc, atomic::AtomicBool},
time::Duration,
};
use tempfile::TempDir;
fn runtime() -> (TempDir, ToolRuntime) {
let temp = TempDir::new().unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
(temp, runtime)
}
#[test]
fn ast_grep_respects_absolute_paths_setting() {
let (temp, runtime) = runtime();
let outside = TempDir::new().unwrap();
fs::write(outside.path().join("outside.rs"), "fn outside() {}\n").unwrap();
let args = AstGrepArgs {
pattern: Some("fn $NAME() {}".to_string()),
language: Some("rust".to_string()),
path: Some(outside.path().display().to_string()),
rewrite: None,
limit: Some(10),
..Default::default()
};
let allowed = runtime
.ast_grep(args.clone(), &AgentCancellation::default())
.unwrap();
assert!(allowed.success, "{}", allowed.content);
assert!(allowed.content.contains("fn outside() {}"));
let mut settings = crate::config::ToolSettings::default();
settings.ast_grep.absolute_paths = false;
let restricted = ToolRuntime::new_with_settings(temp.path(), settings).unwrap();
let error = restricted
.ast_grep(args, &AgentCancellation::default())
.unwrap_err()
.to_string();
assert!(error.contains("tools.ast_grep.absolute_paths"), "{error}");
}
#[test]
fn rejects_empty_pattern() {
let (_temp, runtime) = runtime();
let result = runtime.dispatch("ast_grep", json!({"pattern": " "}));
assert!(!result.success);
assert!(result.content.contains("pattern must not be empty"));
}
#[test]
fn rejects_zero_limit() {
let (_temp, runtime) = runtime();
let result = runtime.dispatch("ast_grep", json!({"pattern": "test", "limit": 0}));
assert!(!result.success);
assert!(result.content.contains("limit must be at least 1"));
}
#[test]
fn rejects_excessive_limit() {
let (_temp, runtime) = runtime();
let result = runtime.dispatch("ast_grep", json!({"pattern": "test", "limit": 501}));
assert!(!result.success);
assert!(result.content.contains("limit must be at most"));
}
#[test]
fn rejects_unknown_field() {
let (_temp, runtime) = runtime();
let result = runtime.dispatch("ast_grep", json!({"pattern": "test", "bogus": true}));
assert!(!result.success);
}
#[test]
fn embedded_search_ignores_hidden_and_ignored_files_and_project_config() {
let (temp, runtime) = runtime();
fs::write(temp.path().join(".p4ignore"), "ignored.rs\n").unwrap();
fs::write(temp.path().join("ignored.rs"), "fn ignored() {}\n").unwrap();
fs::write(temp.path().join(".hidden.rs"), "fn hidden() {}\n").unwrap();
fs::write(temp.path().join("visible.rs"), "fn visible() {}\n").unwrap();
fs::write(
temp.path().join("sgconfig.yml"),
"customLanguages: deliberately invalid",
)
.unwrap();
let result = runtime.dispatch("ast_grep", json!({"pattern":"fn $NAME() {}"}));
assert!(result.success, "{}", result.content);
assert_eq!(result.content, "visible.rs:1:fn visible() {}");
}
#[test]
fn embedded_rewrite_substitutes_captures_without_modifying_source() {
let (temp, runtime) = runtime();
let path = temp.path().join("main.ts");
let source = "console.log(first, second);\n";
fs::write(&path, source).unwrap();
let result = runtime.dispatch("ast_grep", json!({
"path":"main.ts", "pattern":"console.log($$$ARGS)", "rewrite":"console.warn($$$ARGS)"
}));
assert!(result.success, "{}", result.content);
assert!(
result.content.contains("-console.log(first, second)"),
"{}",
result.content
);
assert!(
result.content.contains("+console.warn(first, second)"),
"{}",
result.content
);
assert_eq!(fs::read_to_string(path).unwrap(), source);
assert_eq!(result.metadata[meta::REWRITE], true);
}
#[test]
fn embedded_rewrite_preserves_trailing_comment_and_semicolon() {
let (temp, runtime) = runtime();
let path = temp.path().join("main.ts");
let source = "function value() { return 123 /* keep */; }\n";
fs::write(&path, source).unwrap();
let result = runtime.dispatch(
"ast_grep",
json!({
"path":"main.ts", "pattern":"return $X", "rewrite":"return 456"
}),
);
assert!(result.success, "{}", result.content);
assert_eq!(
result.content,
"@@ main.ts:1 bytes 19..29 (end exclusive; surrounding text unchanged) @@\n-return 123\n+return 456"
);
assert_eq!(fs::read_to_string(path).unwrap(), source);
}
#[test]
fn embedded_search_limits_and_no_matches() {
let (temp, runtime) = runtime();
fs::write(
temp.path().join("main.rs"),
"fn one() {}\nfn two() {}\nfn three() {}\n",
)
.unwrap();
let result = runtime.dispatch("ast_grep", json!({"pattern":"fn $NAME() {}", "limit":2}));
assert!(result.success, "{}", result.content);
assert_eq!(result.content.lines().count(), 2);
assert_eq!(result.metadata[meta::TRUNCATED], true);
let result = runtime.dispatch("ast_grep", json!({"pattern":"unmatched()"}));
assert!(result.success, "{}", result.content);
assert_eq!(result.content, "");
assert_eq!(result.metadata[meta::EXIT_CODE], 1);
}
#[test]
fn embedded_search_rejects_bad_patterns_and_undefined_rewrite_variables() {
let (_temp, runtime) = runtime();
for args in [
json!({"pattern":"$$$ARGS", "language":"rust"}),
json!({"pattern":"a(); b();", "language":"rust"}),
json!({"pattern":"a()", "language":"not-a-language"}),
json!({"pattern":"a($X)", "rewrite":"b($MISSING)", "language":"rust"}),
json!({"pattern":"x".repeat(4097), "language":"rust"}),
] {
let result = runtime.dispatch("ast_grep", args);
assert!(!result.success, "{}", result.content);
}
}
#[test]
fn embedded_search_reports_oversized_and_deep_files_as_partial() {
let (temp, runtime) = runtime();
fs::write(temp.path().join("large.rs"), " ".repeat(256 * 1024 + 1)).unwrap();
fs::write(
temp.path().join("deep.rs"),
format!("fn deep() {{ {}1{} }}", "(".repeat(100), ")".repeat(100)),
)
.unwrap();
fs::write(temp.path().join("small.rs"), "fn small() {}\n").unwrap();
let result = runtime.dispatch("ast_grep", json!({"pattern":"fn $NAME() {}"}));
assert!(result.success, "{}", result.content);
assert_eq!(result.content, "small.rs:1:fn small() {}");
assert_eq!(result.metadata["files_skipped"], 2);
assert_eq!(result.metadata["partial"], true);
}
#[test]
fn embedded_search_timeout_and_cancellation() {
let (temp, runtime) = runtime();
fs::write(temp.path().join("visible.rs"), "fn visible() {}\n").unwrap();
let args = AstGrepArgs {
pattern: Some("fn $NAME() {}".into()),
..Default::default()
};
let result = runtime
.ast_grep_with_timeout(args.clone(), &AgentCancellation::default(), Duration::ZERO)
.unwrap();
assert!(!result.success);
assert_eq!(result.metadata[meta::TIMED_OUT], true);
let canceled = AgentCancellation::new(Arc::new(AtomicBool::new(true)));
let error = runtime.ast_grep(args, &canceled).unwrap_err();
assert!(crate::cancellation::is_run_canceled(&error));
}
#[test]
fn empty_directory_workset_is_successful_no_match() {
let (_temp, runtime) = runtime();
let result = runtime.dispatch("ast_grep", json!({"pattern":"x"}));
assert!(result.success);
assert_eq!(result.content, "");
assert_eq!(result.metadata[meta::MATCHES_RETURNED], 0);
}
#[test]
fn embedded_search_byte_budget_and_rewrite_expansion_budget() {
let (temp, runtime) = runtime();
let source = format!("print('{}')\n", "x".repeat(1000)).repeat(100);
fs::write(temp.path().join("main.py"), &source).unwrap();
let result = runtime.dispatch(
"ast_grep",
json!({"path":"main.py", "pattern":"print($X)", "limit":500}),
);
assert!(result.success, "{}", result.content);
assert!(!result.content.is_empty());
assert!(result.content.len() <= AST_GREP_STDOUT_MAX_BYTES);
assert_eq!(result.metadata[meta::STDOUT_TRUNCATED], true);
let result = runtime.dispatch(
"ast_grep",
json!({
"path":"main.py", "pattern":"print($X)", "rewrite":"$X ".repeat(1000)
}),
);
assert!(result.success, "{}", result.content);
assert_eq!(result.content, "");
assert_eq!(result.metadata["partial"], true);
assert_eq!(
fs::read_to_string(temp.path().join("main.py")).unwrap(),
source
);
}
#[test]
fn embedded_search_infers_languages_and_does_not_match_comment_text() {
let (temp, runtime) = runtime();
for (path, source, pattern, matched) in [
(
"main.py",
"# print(fake)\nprint(real)\n",
"print($X)",
"main.py:2:print(real)",
),
(
"main.go",
"package main\nfunc main() { println(real) }\n",
"println($X)",
"main.go:2:println(real)",
),
(
"main.tsx",
"const view = <div>{value}</div>;\n",
"<div>$X</div>",
"main.tsx:1:<div>{value}</div>",
),
] {
fs::write(temp.path().join(path), source).unwrap();
let result = runtime.dispatch("ast_grep", json!({"path":path, "pattern":pattern}));
assert!(result.success, "{}", result.content);
assert_eq!(result.content, matched);
}
let result = runtime.dispatch("ast_grep", json!({"path":"main.py", "pattern":"$$$X"}));
assert!(!result.success);
assert!(result.content.contains("could not compile"));
}
#[cfg(unix)]
#[test]
fn embedded_search_rejects_special_files_without_blocking() {
use std::os::unix::net::UnixListener;
let (temp, runtime) = runtime();
let _listener = UnixListener::bind(temp.path().join("socket.rs")).unwrap();
let result = runtime.dispatch("ast_grep", json!({"path":"socket.rs", "pattern":"x"}));
assert!(result.success);
assert_eq!(result.metadata["files_skipped"], 1);
assert_eq!(result.metadata["partial"], true);
}
#[test]
fn schema_registered_in_provider_definitions() {
let definitions = crate::tools::capability::mvp_tool_definitions_json();
let ast_grep = definitions
.as_array()
.unwrap()
.iter()
.find(|d| d["name"] == "ast_grep")
.expect("ast_grep definition must exist");
let params = &ast_grep["parameters"];
assert_eq!(params["required"], json!([]));
assert_eq!(params["additionalProperties"], false);
assert!(params["properties"].get("pattern").is_some());
assert!(params["properties"].get("language").is_some());
assert!(params["properties"].get("path").is_some());
assert!(params["properties"].get("rewrite").is_some());
assert!(params["properties"].get("limit").is_some());
}
#[test]
fn empty_rewrite_ignored_not_error() {
let (_temp, runtime) = runtime();
let result = runtime.dispatch("ast_grep", json!({"pattern": "test", "rewrite": " "}));
assert!(
!result.content.contains("rewrite must not be empty"),
"empty rewrite should be ignored, not error: {}",
result.content
);
}
fn outline_args() -> AstGrepArgs {
AstGrepArgs {
operation: AstGrepOperation::Outline,
path: Some("visible.rs".into()),
..Default::default()
}
}
fn outline_entries(result: &ToolResult) -> Vec<serde_json::Value> {
assert!(result.success, "{}", result.content);
result
.content
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect()
}
#[test]
fn outline_top_level_filters_do_not_promote_members() {
let (temp, runtime) = runtime();
fs::write(
temp.path().join("visible.rs"),
"impl Thing {\n fn member() {}\n}\nfn standalone() {}\n",
)
.unwrap();
let run = |filter: serde_json::Value| {
let mut args = json!({"operation":"outline", "path":"visible.rs"});
args.as_object_mut()
.unwrap()
.extend(filter.as_object().unwrap().clone());
runtime.dispatch("ast_grep", args)
};
let baseline = outline_entries(&run(json!({})));
assert_eq!(baseline.len(), 2);
assert_eq!(baseline[0]["item"]["name"], "Thing");
assert_eq!(baseline[0]["item"]["members"][0]["name"], "member");
for filter in [
json!({"symbol_type":"function", "limit":1}),
json!({"name":"member|standalone", "limit":1}),
json!({"symbol_type":"function", "name":"member|standalone", "limit":1}),
] {
let result = run(filter);
let entries = outline_entries(&result);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["item"]["name"], "standalone");
assert_eq!(result.metadata["symbols_returned"], 1);
assert_eq!(result.metadata["partial"], false);
}
assert!(outline_entries(&run(json!({"name":"^member$"}))).is_empty());
}
#[test]
fn ast_directory_only_deadline_reports_partial_timeout() {
let (temp, runtime) = runtime();
fs::create_dir_all(temp.path().join("nested/empty")).unwrap();
for operation in [AstGrepOperation::Search, AstGrepOperation::Outline] {
let args = AstGrepArgs {
operation,
pattern: (operation == AstGrepOperation::Search).then(|| "fn $F() {}".into()),
..Default::default()
};
let result = runtime
.ast_grep_with_timeout(args, &AgentCancellation::default(), Duration::ZERO)
.unwrap();
assert!(!result.success);
assert!(result.content.is_empty());
assert_eq!(result.metadata[meta::TIMED_OUT], true);
assert_eq!(result.metadata["partial"], true);
}
}
#[test]
fn outline_filters_views_and_positions_use_real_rules() {
let (temp, runtime) = runtime();
fs::write(temp.path().join("visible.rs"), "use std::fmt;\npub struct Public { pub exposed: u32, hidden: bool }\nfn internal() {}\n").unwrap();
let run = |options: serde_json::Value| {
let mut args = json!({"operation":"outline", "path":"visible.rs"});
args.as_object_mut()
.unwrap()
.extend(options.as_object().unwrap().clone());
outline_entries(&runtime.dispatch("ast_grep", args))
};
let entries = run(json!({}));
assert_eq!(entries.len(), 2);
assert_eq!(
entries[0]["item"]["range"]["start"],
json!({"line":2,"column":1})
);
assert_eq!(
entries[0]["item"]["range"]["end"],
json!({"line":2,"column":53})
);
assert_eq!(entries[0]["item"]["members"].as_array().unwrap().len(), 2);
assert_eq!(entries[0]["item"]["members"][0]["signature"], "");
assert_eq!(run(json!({"items":"imports"})).len(), 1);
assert_eq!(run(json!({"items":"exports"})).len(), 1);
assert_eq!(run(json!({"items":"all"})).len(), 3);
assert_eq!(
run(json!({"name":"^internal$", "symbol_type":"function"})).len(),
1
);
assert!(run(json!({"name":"Public", "symbol_type":"function"})).is_empty());
let entries = run(json!({"pub_members":true,"view":"expanded","symbol_type":"struct"}));
assert_eq!(entries[0]["item"]["members"].as_array().unwrap().len(), 1);
assert!(
entries[0]["item"]["members"][0]["signature"]
.as_str()
.unwrap()
.contains("u32")
);
for view in ["names", "signatures"] {
let entries = run(json!({"view":view}));
assert!(entries[0]["item"].get("members").is_none());
assert_eq!(
entries[0]["item"]["signature"].as_str().unwrap().is_empty(),
view == "names"
);
}
assert_eq!(run(json!({"view":"names","name":"pub struct"})).len(), 1);
}
#[test]
fn outline_covers_all_bundled_languages_and_explicit_hints() {
let (temp, runtime) = runtime();
for (extension, source) in [
("rs", "fn visible() {}"),
("ts", "function visible() {}"),
("tsx", "function visible() { return <div/>; }"),
("js", "function visible() {}"),
("py", "def visible():\n pass\n"),
("go", "package main\nfunc visible() {}"),
("kt", "fun visible() {}"),
("java", "class Visible {}"),
("swift", "func visible() {}"),
] {
let path = format!("source.{extension}");
fs::write(temp.path().join(&path), source).unwrap();
let result = runtime.dispatch("ast_grep", json!({"operation":"outline","path":path}));
let entries = outline_entries(&result);
assert_eq!(entries.len(), 1, "{extension}: {}", result.content);
assert_eq!(result.metadata["partial"], false, "{extension}");
}
fs::write(temp.path().join("source.unknown"), "fn hinted() {}").unwrap();
let result = runtime.dispatch(
"ast_grep",
json!({"operation":"outline","path":"source.unknown","language":"rust"}),
);
assert_eq!(outline_entries(&result)[0]["item"]["name"], "hinted");
}
#[test]
fn outline_preserves_ignore_path_and_traversal_boundaries() {
let (temp, mut runtime) = runtime();
for name in ["visible.rs", "ignored.rs", ".hidden.rs"] {
fs::write(temp.path().join(name), "struct Visible;").unwrap();
}
fs::write(temp.path().join(".p4ignore"), "ignored.rs\n").unwrap();
fs::write(temp.path().join("sgconfig.yml"), "invalid: [must not load").unwrap();
for (walk_errors, entries_omitted, partial) in [(0, 0, false), (1, 0, true), (0, 1, true)] {
runtime.workspace_walker =
super::super::workspace::WorkspaceWalker::with_forced_diagnostics(
walk_errors,
entries_omitted,
)
.into();
let result = runtime.dispatch("ast_grep", json!({"operation":"outline"}));
assert_eq!(outline_entries(&result).len(), 1);
assert_eq!(result.metadata["partial"], partial);
}
let explicit = runtime.dispatch(
"ast_grep",
json!({"operation":"outline","path":"ignored.rs"}),
);
assert_eq!(outline_entries(&explicit).len(), 1);
let outside = TempDir::new().unwrap();
fs::write(outside.path().join("outside.rs"), "struct Outside;").unwrap();
let args = json!({"operation":"outline","path":outside.path().join("outside.rs")});
assert_eq!(
outline_entries(&runtime.dispatch("ast_grep", args.clone())).len(),
1
);
let mut settings = crate::config::ToolSettings::default();
settings.ast_grep.absolute_paths = false;
let restricted = ToolRuntime::new_with_settings(temp.path(), settings).unwrap();
assert!(!restricted.dispatch("ast_grep", args).success);
#[cfg(unix)]
{
std::os::unix::fs::symlink(
outside.path().join("outside.rs"),
temp.path().join("link.rs"),
)
.unwrap();
let result =
restricted.dispatch("ast_grep", json!({"operation":"outline","path":"link.rs"}));
assert!(!result.success);
}
}
#[test]
fn outline_entry_and_byte_limits_never_split_members() {
let (temp, runtime) = runtime();
for (count, partial) in [(0, false), (1, false), (3, true)] {
let source = (0..count)
.map(|i| format!("struct Item{i} {{ field: u32 }}\n"))
.collect::<String>();
fs::write(temp.path().join("visible.rs"), source).unwrap();
let result = runtime
.ast_grep(
AstGrepArgs {
limit: Some(1),
..outline_args()
},
&AgentCancellation::default(),
)
.unwrap();
let entries = outline_entries(&result);
assert_eq!(entries.len(), count.min(1));
assert_eq!(result.metadata["partial"], partial);
if count > 0 {
assert_eq!(entries[0]["item"]["members"].as_array().unwrap().len(), 1);
}
}
let fields = (0..600)
.map(|i| format!("field_{i}_{}: u32,\n", "x".repeat(80)))
.collect::<String>();
fs::write(
temp.path().join("visible.rs"),
format!("struct Small;\nstruct Large {{ {fields} }}"),
)
.unwrap();
let result = runtime
.ast_grep(
AstGrepArgs {
view: Some("expanded".into()),
..outline_args()
},
&AgentCancellation::default(),
)
.unwrap();
assert_eq!(outline_entries(&result).len(), 1);
assert_eq!(result.metadata["stdout_truncated"], true);
assert_eq!(result.metadata["partial"], true);
assert!(!result.content.contains("Large"));
assert!(result.content.len() <= AST_GREP_STDOUT_MAX_BYTES);
}
#[test]
fn outline_skips_unreadable_or_over_budget_sources_and_stops() {
let (temp, runtime) = runtime();
for source in [
vec![b'x'; 256 * 1024 + 1],
vec![0xff],
format!("fn deep() {{ {}0{}; }}", "(".repeat(100), ")".repeat(100)).into_bytes(),
"const X: u8 = 0;\n".repeat(2200).into_bytes(),
] {
fs::write(temp.path().join("visible.rs"), source).unwrap();
let result = runtime
.ast_grep(outline_args(), &AgentCancellation::default())
.unwrap();
assert!(outline_entries(&result).is_empty());
assert_eq!(result.metadata["files_skipped"], 1);
assert_eq!(result.metadata["partial"], true);
}
fs::write(temp.path().join("visible.rs"), "struct Visible;").unwrap();
let result = runtime
.ast_grep_with_timeout(
outline_args(),
&AgentCancellation::default(),
Duration::ZERO,
)
.unwrap();
assert!(!result.success);
assert!(result.content.is_empty());
assert_eq!(result.metadata["timed_out"], true);
assert_eq!(result.metadata["partial"], true);
let cancellation = AgentCancellation::new(Arc::new(AtomicBool::new(true)));
assert!(crate::cancellation::is_run_canceled(
&runtime.ast_grep(outline_args(), &cancellation).unwrap_err()
));
}
#[test]
fn outline_operation_validation() {
let (_temp, runtime) = runtime();
for args in [
json!({}),
json!({"operation":"unknown"}),
json!({"operation":"outline","pattern":"x"}),
json!({"operation":"outline","rewrite":""}),
json!({"pattern":"x","items":"structure"}),
json!({"pattern":"x","pub_members":false}),
json!({"operation":"outline","items":"auto"}),
json!({"operation":"outline","view":"auto"}),
json!({"operation":"outline","name":"["}),
json!({"operation":"outline","symbol_type":""}),
json!({"operation":"outline","name":"x".repeat(513)}),
] {
let result = runtime.dispatch("ast_grep", args.clone());
assert!(!result.success, "accepted {args}");
}
let result = runtime.dispatch("ast_grep", json!({"operation":"outline"}));
assert!(result.success, "{}", result.content);
assert_eq!(result.metadata["symbols_returned"], 0);
}
#[test]
fn embedded_outline_uses_bundled_rules_and_keeps_internal_symbols() {
let (temp, runtime) = runtime();
fs::write(
temp.path().join("visible.rs"),
"struct Internal { value: u32 }\nfn helper() {}\n",
)
.unwrap();
fs::write(
temp.path().join("sgconfig.yml"),
"invalid: [project config must not be loaded",
)
.unwrap();
for view in ["names", "signatures", "digest", "expanded"] {
let args = AstGrepArgs {
view: Some(view.into()),
language: Some("rust".into()),
..outline_args()
};
let result = runtime
.ast_grep(args, &AgentCancellation::default())
.unwrap();
assert!(result.success, "{view}: {}", result.content);
assert_eq!(result.metadata["symbols_returned"], 2, "{view}");
let entries: Vec<serde_json::Value> = result
.content
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect();
assert!(
entries
.iter()
.any(|entry| entry["item"]["name"] == "Internal")
);
assert!(entries.iter().any(|entry| entry["item"]["name"] == "helper"
&& entry["item"]["range"]["start"]["line"] == 2));
if matches!(view, "digest" | "expanded") {
let structure = entries
.iter()
.find(|entry| entry["item"]["name"] == "Internal")
.unwrap();
assert_eq!(structure["item"]["members"][0]["name"], "value");
assert_eq!(structure["item"]["members"][0]["isPublic"], false);
}
}
}
#[test]
fn dispatch_name_resolves_to_capability() {
assert_eq!(
crate::tools::ToolCapability::from_dispatch_name("ast_grep"),
Some(crate::tools::ToolCapability::AstGrep)
);
}
}