use std::path::{Path, PathBuf};
use syn::visit::Visit;
use crate::error::{Error, io_context};
#[derive(Debug, Clone)]
pub enum TargetSpec {
Fn(String),
File(PathBuf),
Mod(String),
}
#[derive(Debug, Clone)]
pub struct ResolvedTarget {
pub file: PathBuf,
pub functions: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkipReason {
Unsafe,
Const,
ExternAbi,
}
impl std::fmt::Display for SkipReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SkipReason::Unsafe => write!(f, "unsafe"),
SkipReason::Const => write!(f, "const"),
SkipReason::ExternAbi => write!(f, "extern"),
}
}
}
#[derive(Debug, Clone)]
pub struct SkippedFunction {
pub name: String,
pub reason: SkipReason,
pub path: PathBuf,
}
#[derive(Debug)]
pub struct ResolveResult {
pub targets: Vec<ResolvedTarget>,
pub skipped: Vec<SkippedFunction>,
}
pub fn resolve_targets(
src_dir: &Path,
specs: &[TargetSpec],
exact: bool,
) -> Result<ResolveResult, Error> {
let rs_files = walk_rs_files(src_dir)?;
let project_root = src_dir.parent().unwrap_or(src_dir);
let rel_path = |file: &Path| -> PathBuf {
file.strip_prefix(project_root)
.unwrap_or(file)
.to_path_buf()
};
let mut results: Vec<ResolvedTarget> = Vec::new();
let mut skipped: Vec<SkippedFunction> = Vec::new();
if specs.is_empty() {
for file in &rs_files {
let source = std::fs::read_to_string(file).map_err(|source| Error::RunReadError {
path: file.clone(),
source,
})?;
let (all_fns, file_skipped) = extract_functions(&source, file, rel_path(file));
if !all_fns.is_empty() {
merge_into(&mut results, file, all_fns);
}
skipped.extend(file_skipped);
}
} else {
for spec in specs {
match spec {
TargetSpec::Fn(pattern) => {
for file in &rs_files {
let source = std::fs::read_to_string(file).map_err(|source| {
Error::RunReadError {
path: file.clone(),
source,
}
})?;
let (all_fns, file_skipped) =
extract_functions(&source, file, rel_path(file));
let matched: Vec<String> = all_fns
.into_iter()
.filter(|name| {
let bare = name.rsplit("::").next().unwrap_or(name);
if exact {
bare == pattern.as_str() || name == pattern.as_str()
} else {
bare.contains(pattern.as_str())
|| name.contains(pattern.as_str())
}
})
.collect();
if !matched.is_empty() {
merge_into(&mut results, file, matched);
}
let matched_skipped: Vec<SkippedFunction> = file_skipped
.into_iter()
.filter(|s| {
let bare = s.name.rsplit("::").next().unwrap_or(&s.name);
if exact {
bare == pattern.as_str() || s.name == pattern.as_str()
} else {
bare.contains(pattern.as_str())
|| s.name.contains(pattern.as_str())
}
})
.collect();
skipped.extend(matched_skipped);
}
}
TargetSpec::File(file_path) => {
let matching_files: Vec<&PathBuf> =
rs_files.iter().filter(|f| f.ends_with(file_path)).collect();
for file in matching_files {
let source = std::fs::read_to_string(file).map_err(|source| {
Error::RunReadError {
path: file.clone(),
source,
}
})?;
let (all_fns, file_skipped) =
extract_functions(&source, file, rel_path(file));
if !all_fns.is_empty() {
merge_into(&mut results, file, all_fns);
}
skipped.extend(file_skipped);
}
}
TargetSpec::Mod(module_name) => {
for file in &rs_files {
let is_mod_file = file
.parent()
.and_then(|p| p.file_name())
.is_some_and(|dir| dir == module_name.as_str());
let is_named_file = file
.file_stem()
.is_some_and(|stem| stem == module_name.as_str());
if !is_mod_file && !is_named_file {
continue;
}
let source = std::fs::read_to_string(file).map_err(|source| {
Error::RunReadError {
path: file.clone(),
source,
}
})?;
let (all_fns, file_skipped) =
extract_functions(&source, file, rel_path(file));
if !all_fns.is_empty() {
merge_into(&mut results, file, all_fns);
}
skipped.extend(file_skipped);
}
}
}
}
if results.is_empty() {
let desc = specs
.iter()
.map(|s| match s {
TargetSpec::Fn(p) => format!("--fn {p}"),
TargetSpec::File(p) => format!("--file {}", p.display()),
TargetSpec::Mod(m) => format!("--mod {m}"),
})
.collect::<Vec<_>>()
.join(", ");
let hint = if skipped.is_empty() {
build_suggestion_hint(specs, &rs_files)
} else {
let reasons = skipped
.iter()
.map(|s| s.reason.to_string())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>()
.join(", ");
format!(
". All {} matched function(s) were skipped ({}) -- piano cannot instrument these",
skipped.len(),
reasons
)
};
return Err(Error::NoTargetsFound { specs: desc, hint });
}
}
results.sort_by(|a, b| a.file.cmp(&b.file));
for r in &mut results {
r.functions.sort();
r.functions.dedup();
}
skipped.sort_by(|a, b| a.name.cmp(&b.name));
skipped.dedup_by(|a, b| a.name == b.name);
Ok(ResolveResult {
targets: results,
skipped,
})
}
fn merge_into(results: &mut Vec<ResolvedTarget>, file: &Path, functions: Vec<String>) {
if let Some(existing) = results.iter_mut().find(|r| r.file == file) {
existing.functions.extend(functions);
} else {
results.push(ResolvedTarget {
file: file.to_path_buf(),
functions,
});
}
}
fn walk_rs_files(dir: &Path) -> Result<Vec<PathBuf>, Error> {
let mut files = Vec::new();
walk_rs_files_inner(dir, &mut files)?;
files.sort();
Ok(files)
}
fn walk_rs_files_inner(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), Error> {
let entries = std::fs::read_dir(dir).map_err(io_context("read directory", dir))?;
for entry in entries {
let entry = entry.map_err(io_context("read directory entry", dir))?;
let path = entry.path();
if path.is_dir() {
walk_rs_files_inner(&path, out)?;
} else if path.extension().is_some_and(|ext| ext == "rs") {
out.push(path);
}
}
Ok(())
}
fn extract_functions(
source: &str,
path: &Path,
rel_path: PathBuf,
) -> (Vec<String>, Vec<SkippedFunction>) {
let syntax = match syn::parse_file(source) {
Ok(f) => f,
Err(e) => {
eprintln!("warning: skipping {}: {e}", path.display());
return (Vec::new(), Vec::new());
}
};
let mut collector = FnCollector {
functions: Vec::new(),
skipped: Vec::new(),
path: rel_path,
current_impl: None,
current_trait: None,
};
collector.visit_file(&syntax);
(collector.functions, collector.skipped)
}
fn has_attr(attrs: &[syn::Attribute], name: &str) -> bool {
attrs.iter().any(|a| a.path().is_ident(name))
}
fn has_cfg_test(attrs: &[syn::Attribute]) -> bool {
attrs.iter().any(|a| {
if !a.path().is_ident("cfg") {
return false;
}
a.parse_args::<syn::Ident>()
.map(|id| id == "test")
.unwrap_or(false)
})
}
pub(crate) fn is_instrumentable(sig: &syn::Signature) -> bool {
classify_skip(sig).is_none()
}
pub(crate) fn classify_skip(sig: &syn::Signature) -> Option<SkipReason> {
if sig.unsafety.is_some() {
return Some(SkipReason::Unsafe);
}
if sig.constness.is_some() {
return Some(SkipReason::Const);
}
if let Some(abi) = &sig.abi {
let is_rust_abi = abi.name.as_ref().is_some_and(|name| name.value() == "Rust");
if !is_rust_abi {
return Some(SkipReason::ExternAbi);
}
}
None
}
struct FnCollector {
functions: Vec<String>,
skipped: Vec<SkippedFunction>,
path: PathBuf,
current_impl: Option<String>,
current_trait: Option<String>,
}
impl<'ast> Visit<'ast> for FnCollector {
fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
if has_cfg_test(&node.attrs) {
return; }
syn::visit::visit_item_mod(self, node);
}
fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
if !has_attr(&node.attrs, "test") {
if let Some(reason) = classify_skip(&node.sig) {
self.skipped.push(SkippedFunction {
name: node.sig.ident.to_string(),
reason,
path: self.path.clone(),
});
} else {
self.functions.push(node.sig.ident.to_string());
}
}
syn::visit::visit_item_fn(self, node);
}
fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) {
let type_name = type_name_from_type(&node.self_ty);
let prev = self.current_impl.replace(type_name);
syn::visit::visit_item_impl(self, node);
self.current_impl = prev;
}
fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
if !has_attr(&node.attrs, "test") {
let method_name = node.sig.ident.to_string();
let qualified = if let Some(ref impl_name) = self.current_impl {
format!("{impl_name}::{method_name}")
} else {
method_name
};
if let Some(reason) = classify_skip(&node.sig) {
self.skipped.push(SkippedFunction {
name: qualified,
reason,
path: self.path.clone(),
});
} else {
self.functions.push(qualified);
}
}
syn::visit::visit_impl_item_fn(self, node);
}
fn visit_item_trait(&mut self, node: &'ast syn::ItemTrait) {
let trait_name = node.ident.to_string();
let prev = self.current_trait.replace(trait_name);
syn::visit::visit_item_trait(self, node);
self.current_trait = prev;
}
fn visit_trait_item_fn(&mut self, node: &'ast syn::TraitItemFn) {
if node.default.is_some() {
let method_name = node.sig.ident.to_string();
let qualified = if let Some(ref trait_name) = self.current_trait {
format!("{trait_name}::{method_name}")
} else {
method_name
};
if let Some(reason) = classify_skip(&node.sig) {
self.skipped.push(SkippedFunction {
name: qualified,
reason,
path: self.path.clone(),
});
} else {
self.functions.push(qualified);
}
}
syn::visit::visit_trait_item_fn(self, node);
}
}
fn type_name_from_type(ty: &syn::Type) -> String {
match ty {
syn::Type::Path(tp) => tp
.path
.segments
.last()
.map(|seg| seg.ident.to_string())
.unwrap_or_else(|| "_".to_string()),
_ => "_".to_string(),
}
}
fn levenshtein(a: &str, b: &str) -> usize {
let b_len = b.len();
let mut row: Vec<usize> = (0..=b_len).collect();
for (i, a_ch) in a.chars().enumerate() {
let mut prev = i;
row[0] = i + 1;
for (j, b_ch) in b.chars().enumerate() {
let cost = if a_ch == b_ch { prev } else { prev + 1 };
prev = row[j + 1];
row[j + 1] = cost.min(row[j] + 1).min(prev + 1);
}
}
row[b_len]
}
fn build_suggestion_hint(specs: &[TargetSpec], rs_files: &[PathBuf]) -> String {
let fn_patterns: Vec<&str> = specs
.iter()
.filter_map(|s| match s {
TargetSpec::Fn(p) => Some(p.as_str()),
_ => None,
})
.collect();
if fn_patterns.is_empty() {
return String::new();
}
let mut all_names: Vec<String> = Vec::new();
for file in rs_files {
let Ok(source) = std::fs::read_to_string(file) else {
continue;
};
let (fns, _skipped) = extract_functions(&source, file, PathBuf::new());
all_names.extend(fns);
}
all_names.sort();
all_names.dedup();
let total_count = all_names.len();
let mut suggestions: Vec<String> = Vec::new();
for pattern in &fn_patterns {
let threshold = pattern.len() / 3;
let mut scored: Vec<(usize, &String)> = all_names
.iter()
.filter_map(|name| {
let bare = name.rsplit("::").next().unwrap_or(name);
let dist = levenshtein(pattern, bare).min(levenshtein(pattern, name));
if dist <= threshold && dist > 0 {
Some((dist, name))
} else {
None
}
})
.collect();
scored.sort_by_key(|(d, _)| *d);
suggestions.extend(scored.iter().take(5).map(|(_, name)| (*name).clone()));
}
suggestions.sort();
suggestions.dedup();
if !suggestions.is_empty() {
format!(". Did you mean: {}?", suggestions.join(", "))
} else {
format!(". Found {total_count} functions, none matched. Run without --fn to instrument all")
}
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::*;
fn create_test_project(dir: &Path) {
let src = dir.join("src");
fs::create_dir_all(src.join("walker")).unwrap();
fs::write(src.join("main.rs"), "fn main() { walk(); }\nfn walk() {}\n").unwrap();
fs::write(
src.join("resolver.rs"),
"\
struct Resolver;
impl Resolver {
pub fn resolve(&self) -> bool { true }
fn internal_resolve(&self) {}
}
fn helper() {}
",
)
.unwrap();
fs::write(
src.join("walker").join("mod.rs"),
"pub fn walk_dir() {}\nfn scan() {}\n",
)
.unwrap();
fs::write(
src.join("special_fns.rs"),
"\
const fn fixed_size() -> usize { 42 }
unsafe fn dangerous() -> i32 { 0 }
extern \"C\" fn ffi_callback() {}
fn normal_fn() {}
struct Widget;
impl Widget {
const fn none() -> Option<Self> { None }
unsafe fn raw_ptr(&self) -> *const u8 { std::ptr::null() }
fn valid_method(&self) {}
}
",
)
.unwrap();
fs::write(
src.join("with_tests.rs"),
"\
fn production_fn() {}
#[test]
fn test_something() {}
#[cfg(test)]
mod tests {
fn test_helper() {}
#[test]
fn it_works() {}
}
",
)
.unwrap();
}
#[test]
fn resolve_fn_by_substring() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
let specs = [TargetSpec::Fn("walk".into())];
let result = resolve_targets(&tmp.path().join("src"), &specs, false).unwrap();
let all_fns: Vec<&str> = result
.targets
.iter()
.flat_map(|r| r.functions.iter().map(String::as_str))
.collect();
assert!(all_fns.contains(&"walk"), "should match exact 'walk'");
assert!(
all_fns.contains(&"walk_dir"),
"should match 'walk_dir' (substring)"
);
assert!(!all_fns.contains(&"helper"), "should not match 'helper'");
assert!(!all_fns.contains(&"scan"), "should not match 'scan'");
}
#[test]
fn resolve_fn_finds_impl_methods() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
let specs = [TargetSpec::Fn("resolve".into())];
let result = resolve_targets(&tmp.path().join("src"), &specs, false).unwrap();
let all_fns: Vec<&str> = result
.targets
.iter()
.flat_map(|r| r.functions.iter().map(String::as_str))
.collect();
assert!(
all_fns.contains(&"Resolver::resolve"),
"should match impl method 'resolve'"
);
assert!(
all_fns.contains(&"Resolver::internal_resolve"),
"should match impl method 'internal_resolve'"
);
}
#[test]
fn resolve_file_gets_all_functions() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
let specs = [TargetSpec::File("resolver.rs".into())];
let result = resolve_targets(&tmp.path().join("src"), &specs, false).unwrap();
assert_eq!(result.targets.len(), 1);
let fns = &result.targets[0].functions;
assert!(fns.contains(&"helper".to_string()));
assert!(fns.contains(&"Resolver::internal_resolve".to_string()));
assert!(fns.contains(&"Resolver::resolve".to_string()));
}
#[test]
fn resolve_mod_gets_directory_module() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
let specs = [TargetSpec::Mod("walker".into())];
let result = resolve_targets(&tmp.path().join("src"), &specs, false).unwrap();
assert_eq!(result.targets.len(), 1);
let fns = &result.targets[0].functions;
assert!(fns.contains(&"walk_dir".to_string()));
assert!(fns.contains(&"scan".to_string()));
}
#[test]
fn no_match_returns_error() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
let specs = [TargetSpec::Fn("nonexistent_xyz".into())];
let result = resolve_targets(&tmp.path().join("src"), &specs, false);
assert!(result.is_err(), "should error when no functions match");
let err = result.unwrap_err().to_string();
assert!(
err.contains("nonexistent_xyz"),
"error should mention the pattern: {err}"
);
assert!(
err.contains("Found 10 functions"),
"error should show function count: {err}"
);
assert!(
err.contains("Run without --fn"),
"error should suggest running without --fn: {err}"
);
}
#[test]
fn resolve_skips_test_functions_and_cfg_test_modules() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
let specs = [TargetSpec::File("with_tests.rs".into())];
let result = resolve_targets(&tmp.path().join("src"), &specs, false).unwrap();
let all_fns: Vec<&str> = result
.targets
.iter()
.flat_map(|r| r.functions.iter().map(String::as_str))
.collect();
assert!(
all_fns.contains(&"production_fn"),
"should include production function"
);
assert!(
!all_fns.contains(&"test_something"),
"should skip #[test] function"
);
assert!(
!all_fns.contains(&"test_helper"),
"should skip function inside #[cfg(test)] module"
);
assert!(
!all_fns.contains(&"it_works"),
"should skip #[test] inside #[cfg(test)] module"
);
}
#[test]
fn resolve_skips_unparseable_files() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
let src = tmp.path().join("src");
fs::write(
src.join("template.tera.rs"),
"{% for variant in variants %}\nfn {{ variant }}() {}\n{% endfor %}\n",
)
.unwrap();
let specs = [TargetSpec::Fn("walk".into())];
let result = resolve_targets(&src, &specs, false).unwrap();
let all_fns: Vec<&str> = result
.targets
.iter()
.flat_map(|r| r.functions.iter().map(String::as_str))
.collect();
assert!(
all_fns.contains(&"walk"),
"should still find valid functions"
);
assert!(
all_fns.contains(&"walk_dir"),
"should still find valid functions"
);
}
#[test]
fn resolve_empty_specs_returns_all_functions() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
let specs: Vec<TargetSpec> = vec![];
let result = resolve_targets(&tmp.path().join("src"), &specs, false).unwrap();
let all_fns: Vec<&str> = result
.targets
.iter()
.flat_map(|r| r.functions.iter().map(String::as_str))
.collect();
assert!(all_fns.contains(&"main"), "should include main");
assert!(
all_fns.contains(&"walk"),
"should include walk from main.rs"
);
assert!(
all_fns.contains(&"helper"),
"should include helper from resolver.rs"
);
assert!(
all_fns.contains(&"Resolver::resolve"),
"should include impl methods"
);
assert!(
all_fns.contains(&"walk_dir"),
"should include walk_dir from walker"
);
assert!(all_fns.contains(&"scan"), "should include scan from walker");
assert!(
all_fns.contains(&"production_fn"),
"should include production_fn"
);
assert!(!all_fns.contains(&"test_something"), "should skip #[test]");
assert!(
!all_fns.contains(&"it_works"),
"should skip test in cfg(test)"
);
}
#[test]
fn resolve_skips_const_unsafe_extern_functions() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
let specs = [TargetSpec::File("special_fns.rs".into())];
let result = resolve_targets(&tmp.path().join("src"), &specs, false).unwrap();
let all_fns: Vec<&str> = result
.targets
.iter()
.flat_map(|r| r.functions.iter().map(String::as_str))
.collect();
assert!(all_fns.contains(&"normal_fn"), "should include normal_fn");
assert!(
all_fns.contains(&"Widget::valid_method"),
"should include Widget::valid_method"
);
assert!(!all_fns.contains(&"fixed_size"), "should skip const fn");
assert!(!all_fns.contains(&"dangerous"), "should skip unsafe fn");
assert!(!all_fns.contains(&"ffi_callback"), "should skip extern fn");
assert!(
!all_fns.contains(&"Widget::none"),
"should skip const impl method"
);
assert!(
!all_fns.contains(&"Widget::raw_ptr"),
"should skip unsafe impl method"
);
}
#[test]
fn no_match_error_includes_suggestions_for_typo() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
let specs = [TargetSpec::Fn("heper".into())];
let result = resolve_targets(&tmp.path().join("src"), &specs, false);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("helper"),
"error should suggest 'helper' for typo 'heper': {err}"
);
assert!(
err.contains("Did you mean"),
"error should include 'Did you mean' phrasing: {err}"
);
}
#[test]
fn levenshtein_basic_cases() {
assert_eq!(levenshtein("", ""), 0);
assert_eq!(levenshtein("abc", "abc"), 0);
assert_eq!(levenshtein("abc", ""), 3);
assert_eq!(levenshtein("", "abc"), 3);
assert_eq!(levenshtein("kitten", "sitting"), 3);
assert_eq!(levenshtein("parse", "prase"), 2); assert_eq!(levenshtein("walk", "wlak"), 2);
assert_eq!(levenshtein("a", "b"), 1);
}
#[test]
fn no_match_error_shows_count_for_large_project() {
let tmp = TempDir::new().unwrap();
let src = tmp.path().join("src");
fs::create_dir_all(&src).unwrap();
let mut code = String::new();
for i in 0..20 {
code.push_str(&format!("fn func_{i}() {{}}\n"));
}
fs::write(src.join("many.rs"), &code).unwrap();
let specs = [TargetSpec::Fn("nonexistent".into())];
let result = resolve_targets(&src, &specs, false);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("Found 20 functions"),
"error should show function count: {err}"
);
assert!(
err.contains("Run without --fn"),
"error should suggest running without --fn: {err}"
);
}
#[test]
fn no_match_error_shows_clean_patterns() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
let specs = [TargetSpec::Fn("zzz_nonexistent".into())];
let result = resolve_targets(&tmp.path().join("src"), &specs, false);
let err = result.unwrap_err().to_string();
assert!(
err.starts_with("no functions matched"),
"error should start with 'no functions matched': {err}"
);
assert!(
err.contains("--fn zzz_nonexistent"),
"error should include the spec: {err}"
);
}
#[test]
fn resolve_fn_substring_matches_qualified_name() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
let specs = [TargetSpec::Fn("Resolver".into())];
let result = resolve_targets(&tmp.path().join("src"), &specs, false).unwrap();
let all_fns: Vec<&str> = result
.targets
.iter()
.flat_map(|r| r.functions.iter().map(String::as_str))
.collect();
assert!(
all_fns.contains(&"Resolver::resolve"),
"should match 'Resolver::resolve' via qualified name substring"
);
assert!(
all_fns.contains(&"Resolver::internal_resolve"),
"should match 'Resolver::internal_resolve' via qualified name substring"
);
assert!(
!all_fns.contains(&"helper"),
"should not match unrelated 'helper'"
);
}
#[test]
fn resolve_fn_exact_match() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
let specs = [TargetSpec::Fn("walk".into())];
let result = resolve_targets(&tmp.path().join("src"), &specs, true).unwrap();
let all_fns: Vec<&str> = result
.targets
.iter()
.flat_map(|r| r.functions.iter().map(String::as_str))
.collect();
assert!(all_fns.contains(&"walk"), "should match exact 'walk'");
assert!(
!all_fns.contains(&"walk_dir"),
"should NOT match 'walk_dir' in exact mode"
);
}
#[test]
fn resolve_fn_exact_match_qualified() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
let specs = [TargetSpec::Fn("Resolver::resolve".into())];
let result = resolve_targets(&tmp.path().join("src"), &specs, true).unwrap();
let all_fns: Vec<&str> = result
.targets
.iter()
.flat_map(|r| r.functions.iter().map(String::as_str))
.collect();
assert!(
all_fns.contains(&"Resolver::resolve"),
"should match qualified 'Resolver::resolve'"
);
assert!(
!all_fns.contains(&"Resolver::internal_resolve"),
"should NOT match 'Resolver::internal_resolve' in exact mode"
);
}
#[test]
fn resolve_fn_exact_no_match_shows_error() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
let specs = [TargetSpec::Fn("wal".into())];
let result = resolve_targets(&tmp.path().join("src"), &specs, true);
assert!(result.is_err(), "partial match should fail in exact mode");
let err = result.unwrap_err().to_string();
assert!(
err.contains("no functions matched"),
"error should say no functions matched: {err}"
);
}
#[test]
fn resolve_skipped_filtered_by_fn_pattern() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
let specs = [TargetSpec::Fn("dangerous".into())];
let result = resolve_targets(&tmp.path().join("src"), &specs, false);
assert!(result.is_err(), "should error when all matches are skipped");
let err = result.unwrap_err().to_string();
assert!(
err.contains("no functions matched"),
"error should say no functions matched: {err}"
);
assert!(
err.contains("1 matched function(s) were skipped (unsafe)"),
"error should mention skipped unsafe function: {err}"
);
}
#[test]
fn resolve_reports_skipped_functions_with_reasons() {
let tmp = TempDir::new().unwrap();
create_test_project(tmp.path());
let specs = [TargetSpec::File("special_fns.rs".into())];
let result = resolve_targets(&tmp.path().join("src"), &specs, false).unwrap();
let skipped_names: Vec<(&str, &SkipReason)> = result
.skipped
.iter()
.map(|s| (s.name.as_str(), &s.reason))
.collect();
assert!(
skipped_names.contains(&("fixed_size", &SkipReason::Const)),
"should report const fn as skipped: {skipped_names:?}"
);
assert!(
skipped_names.contains(&("dangerous", &SkipReason::Unsafe)),
"should report unsafe fn as skipped: {skipped_names:?}"
);
assert!(
skipped_names.contains(&("ffi_callback", &SkipReason::ExternAbi)),
"should report extern fn as skipped: {skipped_names:?}"
);
assert!(
skipped_names.contains(&("Widget::none", &SkipReason::Const)),
"should report const impl method as skipped: {skipped_names:?}"
);
assert!(
skipped_names.contains(&("Widget::raw_ptr", &SkipReason::Unsafe)),
"should report unsafe impl method as skipped: {skipped_names:?}"
);
for s in &result.skipped {
assert_eq!(
s.path,
Path::new("src/special_fns.rs"),
"skipped fn '{}' should have relative path src/special_fns.rs",
s.name
);
}
let all_fns: Vec<&str> = result
.targets
.iter()
.flat_map(|r| r.functions.iter().map(String::as_str))
.collect();
assert!(all_fns.contains(&"normal_fn"));
assert!(all_fns.contains(&"Widget::valid_method"));
}
}