use super::*;
async fn missing(tool: &str, path: &str, ws: &std::path::Path, caveats: &Caveats) -> String {
run_tool(
tool,
serde_json::json!({"path":path, "old_string":"x", "new_string":"y", "pattern":"x"}),
ws,
caveats,
None,
)
.await
}
#[tokio::test]
async fn path_suggest_wrong_prefix_across_file_tools() {
let ws = tempfile::tempdir().unwrap();
touch(ws.path(), "src/item.rs");
let caveats = caveats_rw(ws.path());
for tool in [
"read_file",
"edit_file",
"delete_file",
"list_dir",
"find",
"grep",
] {
let target = if matches!(tool, "list_dir" | "find" | "grep") {
"src"
} else {
"src/item.rs"
};
for path in [
format!("crates/{target}"),
ws.path()
.join(format!("crates/{target}"))
.to_string_lossy()
.into_owned(),
] {
let out = missing(tool, &path, ws.path(), &caveats).await;
assert!(
out.contains(&format!("did you mean {target}?")),
"{tool}: {out}"
);
assert_eq!(out.matches("did you mean").count(), 1, "{out}");
assert!(
out.contains(&format!(
"resolved against `{}`",
dunce::simplified(ws.path()).display()
)),
"{tool}: {out}"
);
}
}
assert_eq!(
std::fs::read_to_string(ws.path().join("src/item.rs")).unwrap(),
"x"
);
}
#[tokio::test]
async fn path_suggest_no_candidate_or_read_authority_keeps_plain_error() {
let ws = tempfile::tempdir().unwrap();
touch(ws.path(), "src/item.rs");
for tool in [
"read_file",
"edit_file",
"delete_file",
"list_dir",
"find",
"grep",
] {
let out = missing(tool, "crates/missing", ws.path(), &caveats_rw(ws.path())).await;
assert!(!out.contains("did you mean"), "{tool}: {out}");
let mut caveats = caveats_rw(ws.path());
std::fs::create_dir_all(ws.path().join("crates")).unwrap();
caveats.fs_read = Scope::only([ws.path().join("crates").to_string_lossy().into_owned()]);
let out = missing(tool, "crates/src/item.rs", ws.path(), &caveats).await;
assert!(!out.contains("did you mean"), "{tool}: {out}");
}
}
#[tokio::test]
async fn path_suggest_existing_target_does_not_suggest_suffix() {
let ws = tempfile::tempdir().unwrap();
touch(ws.path(), "src/item.rs");
touch(ws.path(), "crates/src/item.rs");
let out = run_tool(
"edit_file",
serde_json::json!({"path":"crates/src/item.rs", "old_string":"absent", "new_string":"y"}),
ws.path(),
&caveats_rw(ws.path()),
None,
)
.await;
assert!(out.contains("old_string not found"), "{out}");
assert!(!out.contains("did you mean"), "{out}");
}
#[tokio::test]
async fn path_suggest_copy_source_uses_shared_read() {
let ws = tempfile::tempdir().unwrap();
touch(ws.path(), "src/item.rs");
let out = run_tool("write_file", serde_json::json!({"path":"out.rs", "content":"", "copy_from":{"path":"crates/src/item.rs", "start_line":1, "end_line":1}}), ws.path(), &caveats_rw(ws.path()), None).await;
assert!(out.contains("did you mean src/item.rs?"), "{out}");
assert!(!ws.path().join("out.rs").exists());
}
#[test]
fn path_suggest_bounded_suffix_and_plain_error_controls() {
let ws = tempfile::tempdir().unwrap();
touch(ws.path(), "src/item.rs");
touch(ws.path(), "item.rs");
let root = ws.path().to_str().unwrap();
let hint = |path: &str, scope: &Scope<String>| {
super::super::FileIoError::io(
&std::io::ErrorKind::NotFound.into(),
std::path::Path::new(path),
"error: missing".into(),
)
.render(root, scope)
};
let display_root = dunce::simplified(ws.path()).display();
assert_eq!(
hint("one/two/src/item.rs", &Scope::All),
format!("error: missing\nresolved against `{display_root}`\ndid you mean src/item.rs?")
);
for path in ["one/two/absent.rs", "wrong/../src/item.rs"] {
assert_eq!(
hint(path, &Scope::All),
format!("error: missing\nresolved against `{display_root}`")
);
}
assert_eq!(
hint("wrong/src/item.rs", &Scope::none()),
format!("error: missing\nresolved against `{display_root}`")
);
assert_eq!(
hint(&format!("{}src/item.rs", "wrong/".repeat(33)), &Scope::All),
format!(
"error: missing\nresolved against `{}`",
dunce::simplified(ws.path()).display()
)
);
assert_eq!(
hint(&format!("{}/src/item.rs", "x".repeat(4096)), &Scope::All),
format!(
"error: missing\nresolved against `{}`",
dunce::simplified(ws.path()).display()
)
);
assert_eq!(
super::super::FileIoError::from("capability denied: fs_read".to_string())
.render(root, &Scope::All),
"capability denied: fs_read"
);
}
#[cfg(unix)]
#[test]
fn path_suggest_symlink_escape_is_not_a_candidate() {
let ws = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
touch(outside.path(), "secret.rs");
std::os::unix::fs::symlink(outside.path(), ws.path().join("escape")).unwrap();
let hint = |path: &str, scope: &Scope<String>| {
super::super::FileIoError::io(
&std::io::ErrorKind::NotFound.into(),
std::path::Path::new(path),
"error: missing".into(),
)
.render(ws.path().to_str().unwrap(), scope)
};
assert_eq!(
hint("wrong/escape/secret.rs", &Scope::All),
format!(
"error: missing\nresolved against `{}`",
dunce::simplified(ws.path()).display()
)
);
touch(ws.path(), "private/secret.rs");
std::fs::create_dir(ws.path().join("allowed")).unwrap();
std::os::unix::fs::symlink(ws.path().join("private"), ws.path().join("allowed/link")).unwrap();
let scope = Scope::only([ws.path().join("allowed").to_string_lossy().into_owned()]);
assert_eq!(
hint("wrong/allowed/link/secret.rs", &scope),
format!(
"error: missing\nresolved against `{}`",
dunce::simplified(ws.path()).display()
)
);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn path_suggest_failure_then_symlink_swap_has_identical_output() {
let ws = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
touch(ws.path(), "src/item.rs");
std::fs::create_dir(ws.path().join("allowed")).unwrap();
let scope =
Scope::only(["allowed", "src"].map(|p| ws.path().join(p).to_string_lossy().into_owned()));
let requested = "allowed/wrong/src/item.rs";
let full = ws.path().join(requested);
let failure = super::super::object_bound_read(
&scope,
"fs_read",
requested,
&full,
&full.to_string_lossy(),
)
.unwrap_err();
std::os::unix::fs::symlink(outside.path(), ws.path().join("allowed/wrong")).unwrap();
let absent = failure.clone().render(ws.path().to_str().unwrap(), &scope);
touch(outside.path(), "src/item.rs");
let present = failure.render(ws.path().to_str().unwrap(), &scope);
assert_eq!(
absent, present,
"external existence must not influence output"
);
assert!(absent.contains("did you mean src/item.rs?"), "{absent}");
}
#[test]
fn path_suggest_non_not_found_stays_plain_after_deletion() {
let ws = tempfile::tempdir().unwrap();
touch(ws.path(), "src/item.rs");
touch(ws.path(), "wrong/src/item.rs");
let requested = "wrong/src/item.rs";
let full = ws.path().join(requested);
std::fs::write(&full, [0xff]).unwrap();
let failure = super::super::object_bound_read(
&Scope::All,
"fs_read",
requested,
&full,
&full.to_string_lossy(),
)
.unwrap_err();
std::fs::remove_file(&full).unwrap();
let out = failure.render(ws.path().to_str().unwrap(), &Scope::All);
assert!(!out.contains("did you mean"), "{out}");
}