diff --git a/agent/src/main/java/com/appland/appmap/config/AppMapConfig.java b/agent/src/main/java/com/appland/appmap/config/AppMapConfig.java index cc49f313..f896472d 100644 --- a/agent/src/main/java/com/appland/appmap/config/AppMapConfig.java +++ b/agent/src/main/java/com/appland/appmap/config/AppMapConfig.java @@ -143,15 +143,6 @@ static AppMapConfig load(Path configFile, boolean mustExist) { singleton.configFile = configFile; logger.debug("config: {}", singleton); - int count = singleton.packages.length; - count = Arrays.stream(singleton.packages).map(p -> p.exclude).reduce(count, - (acc, e) -> acc += e.length, Integer::sum); - - int pattern_threshold = Properties.PatternThreshold; - if (count > pattern_threshold) { - logger.warn("{} patterns found in config, startup performance may be impacted", count); - } - return singleton; } diff --git a/agent/src/main/java/com/appland/appmap/config/AppMapPackage.java b/agent/src/main/java/com/appland/appmap/config/AppMapPackage.java index b5021987..1a6ff8cf 100644 --- a/agent/src/main/java/com/appland/appmap/config/AppMapPackage.java +++ b/agent/src/main/java/com/appland/appmap/config/AppMapPackage.java @@ -1,42 +1,96 @@ package com.appland.appmap.config; -import static com.appland.appmap.util.ClassUtil.safeClassForName; - import java.util.regex.Pattern; import org.tinylog.TaggedLogger; -import com.appland.appmap.transform.annotations.CtClassUtil; import com.appland.appmap.util.FullyQualifiedName; +import com.appland.appmap.util.PrefixTrie; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; import javassist.CtBehavior; + +/** + * Represents a package configuration for AppMap recording. + * + *

+ * Configuration modes (mutually exclusive): + *

+ * + * @see AppMap + * Java Configuration + */ public class AppMapPackage { private static final TaggedLogger logger = AppMapConfig.getLogger(null); private static String tracePrefix = Properties.DebugClassPrefix; public String path; + public final String packagePrefix; public String[] exclude = new String[] {}; public boolean shallow = false; - public Boolean allMethods = true; + private final PrefixTrie excludeTrie = new PrefixTrie(); - public static class LabelConfig { + @JsonCreator + public AppMapPackage(@JsonProperty("path") String path, + @JsonProperty("exclude") String[] exclude, + @JsonProperty("shallow") Boolean shallow, + @JsonProperty("methods") LabelConfig[] methods) { + this.path = path; + this.exclude = exclude == null ? new String[] {} : exclude; + this.shallow = shallow != null && shallow; + this.methods = methods; + this.packagePrefix = this.path == null ? "!!dummy!!" : this.path + "."; + + // Warn if both exclude and methods are specified (methods takes precedence) + if (exclude != null && exclude.length > 0 && methods != null && methods.length > 0) { + logger.warn("Package '{}': both 'exclude' and 'methods' are specified. " + + "The 'exclude' field will be ignored when 'methods' is set.", path); + } + + // Build the exclusion trie only if we're in exclude mode + if (exclude != null && methods == null) { + for (String exclusion : exclude) { + // Allow exclusions to use both '.' and '#' as separators + // for backward compatibility + exclusion = exclusion.replace('#', '.'); + if (exclusion.startsWith(this.packagePrefix)) { + // Absolute path: strip the package prefix + this.excludeTrie.insert(exclusion.substring(this.packagePrefix.length())); + } else { + // Relative path: use as-is + this.excludeTrie.insert(exclusion); + } + } + } + } + /** + * Configuration for matching specific methods with labels. + * Used in "methods mode" to specify which methods to record. + */ + public static class LabelConfig { private Pattern className = null; private Pattern name = null; - private String[] labels = new String[] {}; - private Class cls; + /** Empty constructor for exclude mode (no labels). */ public LabelConfig() {} @JsonCreator - public LabelConfig(@JsonProperty("class") String className, @JsonProperty("name") String name, + public LabelConfig(@JsonProperty("class") String className, + @JsonProperty("name") String name, @JsonProperty("labels") String[] labels) { + // Anchor patterns to match whole symbols only this.className = Pattern.compile("\\A(" + className + ")\\z"); - this.cls = safeClassForName(Thread.currentThread().getContextClassLoader(), className); - logger.trace("this.cls: {}", this.cls); this.name = Pattern.compile("\\A(" + name + ")\\z"); this.labels = labels; } @@ -45,63 +99,126 @@ public String[] getLabels() { return this.labels; } - public boolean matches(FullyQualifiedName name) { - return matches(name.className, name.methodName); - } - - public boolean matches(String className, String methodName) { - boolean traceClass = tracePrefix == null || className.startsWith(tracePrefix); - Class cls = safeClassForName(Thread.currentThread().getContextClassLoader(), className); - - if (traceClass) { - logger.trace("this.cls: {} cls: {}, isChildOf?: {}", this.cls, cls, CtClassUtil.isChildOf(cls, this.cls)); + /** + * Checks if the given fully qualified name matches this configuration. + * Supports matching against both simple and fully qualified class names for + * flexibility. + * + * @param fqn the fully qualified name to check + * @return true if the patterns match + */ + public boolean matches(FullyQualifiedName fqn) { + // Try matching with simple class name (package-relative) + if (matches(fqn.className, fqn.methodName)) { + return true; } - return this.className.matcher(className).matches() && this.name.matcher(methodName).matches(); + // Also try matching with fully qualified class name for better UX + String fullyQualifiedClassName = fqn.getClassName(); + return matches(fullyQualifiedClassName, fqn.methodName); } + /** + * Checks if the given class name and method name match this configuration. + * + * @param className the class name (simple or fully qualified) + * @param methodName the method name + * @return true if both patterns match + */ + public boolean matches(String className, String methodName) { + return this.className.matcher(className).matches() + && this.name.matcher(methodName).matches(); + } } public LabelConfig[] methods = null; /** - * Check if a class/method is included in the configuration. - * - * @param canonicalName the canonical name of the class/method to be checked - * @return {@code true} if the class/method is included in the configuration. {@code false} if it - * is not included or otherwise explicitly excluded. + * Determines if a class/method should be recorded based on this package + * configuration. + * + *

+ * Behavior depends on configuration mode: + *

+ * + * @param canonicalName the fully qualified name of the method to check + * @return the label config if the method should be recorded, or null otherwise */ public LabelConfig find(FullyQualifiedName canonicalName) { - String className = canonicalName != null ? canonicalName.getClassName() : null; - boolean traceClass = tracePrefix == null || className.startsWith(tracePrefix); - if (traceClass) { - logger.trace(canonicalName); + // Early validation + if (this.path == null || canonicalName == null) { + return null; } - if (this.path == null) { - return null; + // Debug logging + if (tracePrefix == null || canonicalName.getClassName().startsWith(tracePrefix)) { + logger.trace("Checking {}", canonicalName); } - if (canonicalName == null) { - return null; + if (isExcludeMode()) { + return findInExcludeMode(canonicalName); + } else { + return findInMethodsMode(canonicalName); } + } - // If no method configs are set, use the old matching behavior. - if (this.methods == null) { - if (!canonicalName.toString().startsWith(this.path)) { + /** + * Checks if this package is configured in exclude mode (records everything + * except exclusions). + */ + private boolean isExcludeMode() { + return this.methods == null; + } + + /** + * Finds a method in exclude mode: match if in package and not excluded. + */ + private LabelConfig findInExcludeMode(FullyQualifiedName canonicalName) { + String canonicalString = canonicalName.toString(); + + // Check if the method is in this package or a subpackage + if (!canonicalString.startsWith(this.path)) { + return null; + } else if (canonicalString.length() > this.path.length()) { + // Must either equal the path exactly or start with "path." or "path#" + // The "#" check is needed for unnamed packages + // or when path specifies a class name + final char nextChar = canonicalString.charAt(this.path.length()); + if (nextChar != '.' && nextChar != '#') { return null; } + } - return this.excludes(canonicalName) ? null : new LabelConfig(); + // Check if it's explicitly excluded + if (this.excludes(canonicalName)) { + return null; } + // Include it (no labels in exclude mode) + return new LabelConfig(); + } + + /** + * Finds a method in methods mode: match only if it matches a configured + * pattern. + */ + private LabelConfig findInMethodsMode(FullyQualifiedName canonicalName) { + // Must be in the exact package (not subpackages) if (!canonicalName.packageName.equals(this.path)) { return null; } - for (LabelConfig ls : this.methods) { - if (ls.matches(canonicalName)) { - return ls; + // Check each method pattern + for (LabelConfig config : this.methods) { + if (config.matches(canonicalName)) { + return config; } } @@ -109,35 +226,56 @@ public LabelConfig find(FullyQualifiedName canonicalName) { } /** - * Returns whether or not the canonical name is explicitly excluded - * - * @param canonicalName the canonical name of the class/method to be checked + * Converts a fully qualified class name to a package-relative name. + * For example, "com.example.foo.Bar" with package "com.example" becomes + * "foo.Bar". + * + * @param fqcn the fully qualified class name + * @return the relative class name, or the original if it doesn't start with the + * package prefix + */ + private String getRelativeClassName(String fqcn) { + if (fqcn.startsWith(this.packagePrefix)) { + return fqcn.substring(this.packagePrefix.length()); + } + return fqcn; + } + + /** + * Checks whether a behavior is explicitly excluded by this package + * configuration. + * Only meaningful in exclude mode; in methods mode, use {@link #find} instead. + * + * @param behavior the behavior to check + * @return true if the behavior matches an exclusion pattern */ public Boolean excludes(CtBehavior behavior) { - FullyQualifiedName fqn = null; - for (String exclusion : this.exclude) { - if (behavior.getDeclaringClass().getName().startsWith(exclusion)) { - return true; - } else { - if (fqn == null) { - fqn = new FullyQualifiedName(behavior); - } - if (fqn.toString().startsWith(exclusion)) { - return true; - } - } + String fqClass = behavior.getDeclaringClass().getName(); + String relativeClassName = getRelativeClassName(fqClass); + + // Check if the class itself is excluded + if (this.excludeTrie.startsWith(relativeClassName)) { + return true; } - return false; + // Check if the specific method is excluded + String methodName = behavior.getName(); + String relativeMethodPath = String.format("%s.%s", relativeClassName, methodName); + return this.excludeTrie.startsWith(relativeMethodPath); } + /** + * Checks whether a fully qualified method name is explicitly excluded. + * Only meaningful in exclude mode; in methods mode, use {@link #find} instead. + * + * @param canonicalName the fully qualified method name + * @return true if the method matches an exclusion pattern + */ public Boolean excludes(FullyQualifiedName canonicalName) { - for (String exclusion : this.exclude) { - if (canonicalName.toString().startsWith(exclusion)) { - return true; - } - } - - return false; + String fqcn = canonicalName.toString(); + String relativeName = getRelativeClassName(fqcn); + // Convert # to . to match the format stored in the trie + relativeName = relativeName.replace('#', '.'); + return this.excludeTrie.startsWith(relativeName); } } diff --git a/agent/src/main/java/com/appland/appmap/config/Properties.java b/agent/src/main/java/com/appland/appmap/config/Properties.java index 5c4c168e..3cadf444 100644 --- a/agent/src/main/java/com/appland/appmap/config/Properties.java +++ b/agent/src/main/java/com/appland/appmap/config/Properties.java @@ -30,12 +30,12 @@ public class Properties { public static final Boolean RecordingRequests = resolveProperty("appmap.recording.requests", true); public static final String[] IgnoredPackages = resolveProperty("appmap.recording.ignoredPackages", new String[] {"java.", "jdk.", "sun."}); + public static final String[] ExcludedHooks = + resolveProperty("appmap.hooks.exclude", new String[0]); public static final String DefaultConfigFile = "appmap.yml"; public static final String ConfigFile = resolveProperty("appmap.config.file", (String) null); - public static final Integer PatternThreshold = - resolveProperty("appmap.config.patternThreshold", 10); public static final Boolean DisableValue = resolveProperty("appmap.event.disableValue", false); public static final Integer MaxValueSize = resolveProperty("appmap.event.valueSize", 1024); diff --git a/agent/src/main/java/com/appland/appmap/transform/ClassFileTransformer.java b/agent/src/main/java/com/appland/appmap/transform/ClassFileTransformer.java index 2a84a138..774a610d 100644 --- a/agent/src/main/java/com/appland/appmap/transform/ClassFileTransformer.java +++ b/agent/src/main/java/com/appland/appmap/transform/ClassFileTransformer.java @@ -153,7 +153,21 @@ private Hook[] getHooks(String methodId) { return methodHooks != null ? methodHooks : sortedUnkeyedHooks; } + private boolean isExcludedHook(String className) { + for (String excluded : Properties.ExcludedHooks) { + if (className.equals(excluded)) { + return true; + } + } + return false; + } + private void processClass(CtClass ctClass) { + if (isExcludedHook(ctClass.getName())) { + logger.debug("excluding hook class {}", ctClass.getName()); + return; + } + boolean traceClass = tracePrefix == null || ctClass.getName().startsWith(tracePrefix); if (traceClass) { diff --git a/agent/src/main/java/com/appland/appmap/util/PrefixTrie.java b/agent/src/main/java/com/appland/appmap/util/PrefixTrie.java new file mode 100644 index 00000000..bf19d45e --- /dev/null +++ b/agent/src/main/java/com/appland/appmap/util/PrefixTrie.java @@ -0,0 +1,64 @@ +package com.appland.appmap.util; + +import java.util.HashMap; +import java.util.Map; + +/** + * A simple Trie (Prefix Tree) for efficient prefix-based string matching. + * This is used to check if a class name matches any of the exclusion patterns. + */ +public class PrefixTrie { + private static class TrieNode { + Map children = new HashMap<>(); + boolean isEndOfWord = false; + } + + private final TrieNode root; + + public PrefixTrie() { + root = new TrieNode(); + } + + /** + * Inserts a word into the Trie. + * @param word The word to insert. + */ + public void insert(String word) { + if (word == null) { + return; + } + TrieNode current = root; + for (char ch : word.toCharArray()) { + current = current.children.computeIfAbsent(ch, c -> new TrieNode()); + } + current.isEndOfWord = true; + } + + /** + * Checks if any prefix of the given word exists in the Trie. + * For example, if "java." is in the Trie, this will return true for "java.lang.String". + * @param word The word to check. + * @return {@code true} if a prefix of the word is found in the Trie, {@code false} otherwise. + */ + public boolean startsWith(String word) { + if (word == null) { + return false; + } + TrieNode current = root; + for (int i = 0; i < word.length(); i++) { + if (current.isEndOfWord) { + // We've found a stored pattern that is a prefix of the word. + // e.g., Trie has "java." and word is "java.lang.String" + return true; + } + char ch = word.charAt(i); + current = current.children.get(ch); + if (current == null) { + return false; // No prefix match + } + } + // The word itself is a prefix or an exact match for a pattern in the Trie + // e.g., Trie has "java.lang" and word is "java.lang" + return current.isEndOfWord; + } +} diff --git a/agent/src/main/java/com/appland/appmap/util/tinylog/AppMapConfigurationLoader.java b/agent/src/main/java/com/appland/appmap/util/tinylog/AppMapConfigurationLoader.java index defb275e..d99cb64a 100644 --- a/agent/src/main/java/com/appland/appmap/util/tinylog/AppMapConfigurationLoader.java +++ b/agent/src/main/java/com/appland/appmap/util/tinylog/AppMapConfigurationLoader.java @@ -16,7 +16,7 @@ public class AppMapConfigurationLoader implements ConfigurationLoader { @Override - public Properties load() throws IOException { + public Properties load() { Properties properties = new Properties(); final File localConfigFile = new File("appmap-log.local.properties"); final String[] configFiles = {"appmap-log.properties", localConfigFile.getName()}; @@ -28,6 +28,8 @@ public Properties load() throws IOException { if (stream != null) { properties.load(stream); } + } catch (IOException e) { + InternalLogger.log(Level.ERROR, e, "Failed to load " + configFile + " from classloader " + cl); } } } diff --git a/agent/src/test/java/com/appland/appmap/config/AppMapConfigTest.java b/agent/src/test/java/com/appland/appmap/config/AppMapConfigTest.java index f655ac4d..fef83891 100644 --- a/agent/src/test/java/com/appland/appmap/config/AppMapConfigTest.java +++ b/agent/src/test/java/com/appland/appmap/config/AppMapConfigTest.java @@ -118,6 +118,19 @@ public void loadPackagesKeyWithScalarValue() throws Exception { String actualErr = tapSystemErr(() -> AppMapConfig.load(configFile, false)); assertTrue(actualErr.contains("AppMap: encountered syntax error in appmap.yml")); } -} + @Test + public void loadEmptyExcludeField() throws Exception { + Path configFile = tmpdir.resolve("appmap.yml"); + final String contents = "name: test\npackages:\n- path: com.example\n exclude:\n"; + Files.write(configFile, contents.getBytes()); + + AppMapConfig config = AppMapConfig.load(configFile, false); + assertNotNull(config); + assertEquals(1, config.packages.length); + assertEquals("com.example", config.packages[0].path); + assertNotNull(config.packages[0].exclude); + assertEquals(0, config.packages[0].exclude.length); + } +} diff --git a/agent/src/test/java/com/appland/appmap/config/AppMapPackageTest.java b/agent/src/test/java/com/appland/appmap/config/AppMapPackageTest.java index eea6fdaf..67e1906c 100644 --- a/agent/src/test/java/com/appland/appmap/config/AppMapPackageTest.java +++ b/agent/src/test/java/com/appland/appmap/config/AppMapPackageTest.java @@ -1,14 +1,18 @@ package com.appland.appmap.config; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.appland.appmap.util.FullyQualifiedName; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -103,4 +107,583 @@ public void testLoadConfig() throws Exception { assertNotNull(appMapPackage.methods); } } + + @Nested + class ExcludeModeTests { + @Nested + class BasicMatching { + AppMapPackage pkg; + + @BeforeEach + public void setup() throws Exception { + String[] yaml = { + "---", + "path: com.example" + }; + pkg = loadYaml(yaml, AppMapPackage.class); + } + + @Test + public void testMatchesMethodInPackage() { + FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Foo", false, "bar"); + AppMapPackage.LabelConfig result = pkg.find(fqn); + assertNotNull(result, "Should match methods in the configured package"); + // In exclude mode, a new LabelConfig() is returned which has an empty array for + // labels + assertNotNull(result.getLabels(), "Labels should be non-null"); + assertEquals(0, result.getLabels().length, "Should have no labels in exclude mode"); + } + + @Test + public void testMatchesMethodInSubpackage() { + FullyQualifiedName fqn = new FullyQualifiedName("com.example.sub", "Foo", false, "bar"); + AppMapPackage.LabelConfig result = pkg.find(fqn); + assertNotNull(result, "Should match methods in subpackages"); + } + + @Test + public void testDoesNotMatchMethodOutsidePackage() { + FullyQualifiedName fqn = new FullyQualifiedName("org.other", "Foo", false, "bar"); + AppMapPackage.LabelConfig result = pkg.find(fqn); + assertNull(result, "Should not match methods outside the package"); + } + + @Test + public void testDoesNotMatchPartialPackageName() { + // Package is "com.example", should not match "com.examples" + FullyQualifiedName fqn = new FullyQualifiedName("com.examples", "Foo", false, "bar"); + AppMapPackage.LabelConfig result = pkg.find(fqn); + assertNull(result, "Should not match partial package names"); + } + } + + @Nested + class WithExclusions { + AppMapPackage pkg; + + @BeforeEach + public void setup() throws Exception { + String[] yaml = { + "---", + "path: com.example", + "exclude: [Internal, com.example.Private, Secret.sensitiveMethod]" + }; + pkg = loadYaml(yaml, AppMapPackage.class); + } + + @Test + public void testExcludesRelativeClassName() { + FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Internal", false, "foo"); + AppMapPackage.LabelConfig result = pkg.find(fqn); + assertNull(result, "Should exclude relative class name"); + } + + @Test + public void testExcludesAbsoluteClassName() { + FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Private", false, "foo"); + AppMapPackage.LabelConfig result = pkg.find(fqn); + assertNull(result, "Should exclude absolute class name"); + } + + @Test + public void testExcludesSpecificMethod() { + FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Secret", false, "sensitiveMethod"); + AppMapPackage.LabelConfig result = pkg.find(fqn); + assertNull(result, "Should exclude specific method"); + } + + @Test + public void testDoesNotExcludeOtherMethodsInExcludedClass() { + FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Secret", false, "publicMethod"); + AppMapPackage.LabelConfig result = pkg.find(fqn); + assertNotNull(result, "Should not exclude other methods in partially excluded class"); + } + + @Test + public void testIncludesNonExcludedClass() { + FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Public", false, "foo"); + AppMapPackage.LabelConfig result = pkg.find(fqn); + assertNotNull(result, "Should include non-excluded classes"); + } + + @Test + public void testExcludesSubclassesOfExcludedClass() { + FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Internal$Inner", false, "foo"); + AppMapPackage.LabelConfig result = pkg.find(fqn); + assertNull(result, "Should exclude nested classes of excluded class"); + } + } + + @Nested + class WithHashSeparator { + AppMapPackage pkg; + + @BeforeEach + public void setup() throws Exception { + String[] yaml = { + "---", + "path: com.example", + "exclude: [Foo#bar, Internal#secretMethod]" + }; + pkg = loadYaml(yaml, AppMapPackage.class); + } + + @Test + public void testConvertsHashToDot() { + FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Foo", false, "bar"); + AppMapPackage.LabelConfig result = pkg.find(fqn); + assertNull(result, "Should convert # to . for backward compatibility"); + } + + @Test + public void testDoesNotExcludeOtherMethods() { + FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Foo", false, "baz"); + AppMapPackage.LabelConfig result = pkg.find(fqn); + assertNotNull(result, "Should not exclude methods not specified"); + } + } + } + + @Nested + class MethodsModeTests { + @Nested + class BasicPatternMatching { + AppMapPackage pkg; + + @BeforeEach + public void setup() throws Exception { + String[] yaml = { + "---", + "path: com.example", + "methods:", + "- class: Controller", + " name: handle.*", + " labels: [controller]", + "- class: Service", + " name: process", + " labels: [service, business-logic]" + }; + pkg = loadYaml(yaml, AppMapPackage.class); + } + + @Test + public void testMatchesSimpleClassName() { + FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Controller", false, "handleRequest"); + AppMapPackage.LabelConfig result = pkg.find(fqn); + assertNotNull(result, "Should match simple class name"); + assertArrayEquals(new String[] { "controller" }, result.getLabels()); + } + + @Test + public void testMatchesMethodPattern() { + FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Controller", false, "handleResponse"); + AppMapPackage.LabelConfig result = pkg.find(fqn); + assertNotNull(result, "Should match method name pattern"); + assertArrayEquals(new String[] { "controller" }, result.getLabels()); + } + + @Test + public void testDoesNotMatchNonMatchingMethod() { + FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Controller", false, "initialize"); + AppMapPackage.LabelConfig result = pkg.find(fqn); + assertNull(result, "Should not match non-matching method name"); + } + + @Test + public void testMatchesExactMethodName() { + FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Service", false, "process"); + AppMapPackage.LabelConfig result = pkg.find(fqn); + assertNotNull(result, "Should match exact method name"); + assertArrayEquals(new String[] { "service", "business-logic" }, result.getLabels()); + } + + @Test + public void testDoesNotMatchPartialMethodName() { + FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Service", false, "processData"); + AppMapPackage.LabelConfig result = pkg.find(fqn); + assertNull(result, "Should not match partial method name (no implicit wildcards)"); + } + + @Test + public void testDoesNotMatchDifferentPackage() { + FullyQualifiedName fqn = new FullyQualifiedName("org.other", "Controller", false, "handleRequest"); + AppMapPackage.LabelConfig result = pkg.find(fqn); + assertNull(result, "Should not match methods in different package"); + } + + @Test + public void testDoesNotMatchSubpackage() { + // In methods mode, package must match exactly (not subpackages) + FullyQualifiedName fqn = new FullyQualifiedName("com.example.sub", "Controller", false, "handleRequest"); + AppMapPackage.LabelConfig result = pkg.find(fqn); + assertNull(result, "Methods mode should not match subpackages"); + } + } + + @Nested + class FullyQualifiedClassNames { + AppMapPackage pkg; + + @BeforeEach + public void setup() throws Exception { + String[] yaml = { + "---", + "path: com.example", + "methods:", + "- class: com.example.web.Controller", + " name: handle.*", + " labels: [web-controller]" + }; + pkg = loadYaml(yaml, AppMapPackage.class); + } + + @Test + public void testMatchesFullyQualifiedClassName() { + FullyQualifiedName fqn = new FullyQualifiedName("com.example", "web.Controller", false, "handleGet"); + AppMapPackage.LabelConfig result = pkg.find(fqn); + assertNotNull(result, "Should match fully qualified class name pattern"); + assertArrayEquals(new String[] { "web-controller" }, result.getLabels()); + } + } + + @Nested + class RegexPatterns { + AppMapPackage pkg; + + @BeforeEach + public void setup() throws Exception { + String[] yaml = { + "---", + "path: com.example", + "methods:", + "- class: (Controller|Handler)", + " name: (get|set).*", + " labels: [accessor]", + "- class: .*Service", + " name: execute", + " labels: [service-executor]" + }; + pkg = loadYaml(yaml, AppMapPackage.class); + } + + @Test + public void testMatchesClassAlternation() { + FullyQualifiedName fqn1 = new FullyQualifiedName("com.example", "Controller", false, "getData"); + FullyQualifiedName fqn2 = new FullyQualifiedName("com.example", "Handler", false, "setData"); + + AppMapPackage.LabelConfig result1 = pkg.find(fqn1); + AppMapPackage.LabelConfig result2 = pkg.find(fqn2); + + assertNotNull(result1, "Should match first class alternative"); + assertNotNull(result2, "Should match second class alternative"); + assertArrayEquals(new String[] { "accessor" }, result1.getLabels()); + assertArrayEquals(new String[] { "accessor" }, result2.getLabels()); + } + + @Test + public void testMatchesClassWildcard() { + FullyQualifiedName fqn1 = new FullyQualifiedName("com.example", "UserService", false, "execute"); + FullyQualifiedName fqn2 = new FullyQualifiedName("com.example", "OrderService", false, "execute"); + + AppMapPackage.LabelConfig result1 = pkg.find(fqn1); + AppMapPackage.LabelConfig result2 = pkg.find(fqn2); + + assertNotNull(result1, "Should match first service"); + assertNotNull(result2, "Should match second service"); + assertArrayEquals(new String[] { "service-executor" }, result1.getLabels()); + assertArrayEquals(new String[] { "service-executor" }, result2.getLabels()); + } + } + + @Nested + class IgnoresExcludeField { + AppMapPackage pkg; + + @BeforeEach + public void setup() throws Exception { + String[] yaml = { + "---", + "path: com.example", + "exclude: [Controller]", // This should be ignored + "methods:", + "- class: Controller", + " name: handleRequest", + " labels: [controller]" + }; + pkg = loadYaml(yaml, AppMapPackage.class); + } + + @Test + public void testIgnoresExcludeWhenMethodsIsSet() { + FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Controller", false, "handleRequest"); + AppMapPackage.LabelConfig result = pkg.find(fqn); + assertNotNull(result, "Should ignore exclude field when methods is set"); + assertArrayEquals(new String[] { "controller" }, result.getLabels()); + } + } + } + + @Nested + class EdgeCases { + @Test + public void testNullPath() throws Exception { + String[] yaml = { + "---", + "path: null" + }; + AppMapPackage pkg = loadYaml(yaml, AppMapPackage.class); + FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Foo", false, "bar"); + assertNull(pkg.find(fqn), "Should handle null path gracefully"); + } + + @Test + public void testNullCanonicalName() throws Exception { + String[] yaml = { + "---", + "path: com.example" + }; + AppMapPackage pkg = loadYaml(yaml, AppMapPackage.class); + assertNull(pkg.find(null), "Should handle null canonical name gracefully"); + } + + @Test + public void testEmptyExclude() throws Exception { + String[] yaml = { + "---", + "path: com.example", + "exclude: []" + }; + AppMapPackage pkg = loadYaml(yaml, AppMapPackage.class); + assertEquals(0, pkg.exclude.length, "Should handle empty exclude array"); + } + + @Test + public void testNullExclude() throws Exception { + String[] yaml = { + "---", + "path: com.example", + "exclude:" + }; + AppMapPackage pkg = loadYaml(yaml, AppMapPackage.class); + assertNotNull(pkg.exclude, "Should initialize exclude to empty array"); + assertEquals(0, pkg.exclude.length, "Should handle null exclude array"); + } + + @Test + public void testNoExclude() throws Exception { + String[] yaml = { + "---", + "path: com.example" + }; + AppMapPackage pkg = loadYaml(yaml, AppMapPackage.class); + assertNotNull(pkg.exclude, "Should initialize exclude to empty array"); + assertEquals(0, pkg.exclude.length); + } + + @Test + public void testShallowDefault() throws Exception { + String[] yaml = { + "---", + "path: com.example" + }; + AppMapPackage pkg = loadYaml(yaml, AppMapPackage.class); + assertFalse(pkg.shallow, "shallow should default to false"); + } + + @Test + public void testShallowTrue() throws Exception { + String[] yaml = { + "---", + "path: com.example", + "shallow: true" + }; + AppMapPackage pkg = loadYaml(yaml, AppMapPackage.class); + assertTrue(pkg.shallow, "shallow should be set to true"); + } + } + + @Nested + class EnhancedLabelConfigTests { + @Test + public void testEmptyLabelConfig() { + AppMapPackage.LabelConfig lc = new AppMapPackage.LabelConfig(); + // Empty constructor uses field initialization, which is an empty array + assertNotNull(lc.getLabels(), "Empty LabelConfig should have non-null labels"); + assertEquals(0, lc.getLabels().length, "Empty LabelConfig should have empty labels array"); + } + + @Test + public void testLabelConfigWithLabels() throws Exception { + String[] yaml = { + "---", + "class: Foo", + "name: bar", + "labels: [test, example]" + }; + AppMapPackage.LabelConfig lc = loadYaml(yaml, AppMapPackage.LabelConfig.class); + assertNotNull(lc.getLabels()); + assertEquals(2, lc.getLabels().length); + assertEquals("test", lc.getLabels()[0]); + assertEquals("example", lc.getLabels()[1]); + } + + @Test + public void testLabelConfigMatchesSimpleClass() { + AppMapPackage.LabelConfig lc = new AppMapPackage.LabelConfig("Controller", "handle.*", new String[] { "web" }); + FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Controller", false, "handleGet"); + assertTrue(lc.matches(fqn), "Should match simple class name"); + } + + @Test + public void testLabelConfigMatchesFullyQualifiedClass() { + AppMapPackage.LabelConfig lc = new AppMapPackage.LabelConfig("com.example.Controller", "handle.*", + new String[] { "web" }); + FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Controller", false, "handleGet"); + assertTrue(lc.matches(fqn), "Should match fully qualified class name"); + } + + @Test + public void testLabelConfigDoesNotMatchWrongClass() { + AppMapPackage.LabelConfig lc = new AppMapPackage.LabelConfig("Controller", "handle.*", new String[] { "web" }); + FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Service", false, "handleGet"); + assertFalse(lc.matches(fqn), "Should not match wrong class"); + } + + @Test + public void testLabelConfigDoesNotMatchWrongMethod() { + AppMapPackage.LabelConfig lc = new AppMapPackage.LabelConfig("Controller", "handle.*", new String[] { "web" }); + FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Controller", false, "process"); + assertFalse(lc.matches(fqn), "Should not match wrong method"); + } + + @Test + public void testLabelConfigMatchesExactPattern() { + AppMapPackage.LabelConfig lc = new AppMapPackage.LabelConfig("Foo", "bar", new String[] { "test" }); + assertTrue(lc.matches("Foo", "bar"), "Should match exact patterns"); + } + + @Test + public void testLabelConfigDoesNotMatchPartialClass() { + // Pattern "Foo" should not match "Foo1" due to anchoring + AppMapPackage.LabelConfig lc = new AppMapPackage.LabelConfig("Foo", "bar", new String[] { "test" }); + assertFalse(lc.matches("Foo1", "bar"), "Should not match partial class name"); + } + + @Test + public void testLabelConfigDoesNotMatchPartialMethod() { + // Pattern "bar" should not match "bar!" due to anchoring + AppMapPackage.LabelConfig lc = new AppMapPackage.LabelConfig("Foo", "bar", new String[] { "test" }); + assertFalse(lc.matches("Foo", "bar!"), "Should not match partial method name"); + } + } + + @Nested + class ExcludesMethodTests { + @Test + public void testExcludesFullyQualifiedName() throws Exception { + String[] yaml = { + "---", + "path: com.example", + "exclude: [Internal, Private.secret]" + }; + AppMapPackage pkg = loadYaml(yaml, AppMapPackage.class); + + FullyQualifiedName fqn1 = new FullyQualifiedName("com.example", "Internal", false, "foo"); + FullyQualifiedName fqn2 = new FullyQualifiedName("com.example", "Private", false, "secret"); + FullyQualifiedName fqn3 = new FullyQualifiedName("com.example", "Public", false, "method"); + + assertTrue(pkg.excludes(fqn1), "Should exclude Internal class"); + assertTrue(pkg.excludes(fqn2), "Should exclude Private.secret method"); + assertFalse(pkg.excludes(fqn3), "Should not exclude Public class"); + } + } + + @Nested + class ComplexScenarios { + @Test + public void testMultipleMethodConfigs() throws Exception { + String[] yaml = { + "---", + "path: com.example.api", + "methods:", + "- class: .*Controller", + " name: handle.*", + " labels: [web, controller]", + "- class: .*Service", + " name: execute.*", + " labels: [service]", + "- class: Repository", + " name: (find|save|delete).*", + " labels: [data-access, repository]" + }; + AppMapPackage pkg = loadYaml(yaml, AppMapPackage.class); + + FullyQualifiedName controller = new FullyQualifiedName("com.example.api", "UserController", false, "handleGet"); + FullyQualifiedName service = new FullyQualifiedName("com.example.api", "UserService", false, "executeQuery"); + FullyQualifiedName repo = new FullyQualifiedName("com.example.api", "Repository", false, "findById"); + + AppMapPackage.LabelConfig result1 = pkg.find(controller); + AppMapPackage.LabelConfig result2 = pkg.find(service); + AppMapPackage.LabelConfig result3 = pkg.find(repo); + + assertNotNull(result1); + assertArrayEquals(new String[] { "web", "controller" }, result1.getLabels()); + + assertNotNull(result2); + assertArrayEquals(new String[] { "service" }, result2.getLabels()); + + assertNotNull(result3); + assertArrayEquals(new String[] { "data-access", "repository" }, result3.getLabels()); + } + + @Test + public void testComplexExclusionPatterns() throws Exception { + String[] yaml = { + "---", + "path: com.example", + "exclude:", + " - internal", + " - util.Helper", + " - com.example.test.Mock", + " - Secret.getPassword", + " - Cache.clear" + }; + AppMapPackage pkg = loadYaml(yaml, AppMapPackage.class); + + FullyQualifiedName internal = new FullyQualifiedName("com.example.internal", "Foo", false, "bar"); + FullyQualifiedName helper = new FullyQualifiedName("com.example.util", "Helper", false, "help"); + FullyQualifiedName mock = new FullyQualifiedName("com.example.test", "Mock", false, "setup"); + FullyQualifiedName secretGet = new FullyQualifiedName("com.example", "Secret", false, "getPassword"); + FullyQualifiedName secretSet = new FullyQualifiedName("com.example", "Secret", false, "setPassword"); + FullyQualifiedName cacheClear = new FullyQualifiedName("com.example", "Cache", false, "clear"); + FullyQualifiedName cacheGet = new FullyQualifiedName("com.example", "Cache", false, "get"); + + assertNull(pkg.find(internal), "Should exclude internal package"); + assertNull(pkg.find(helper), "Should exclude util.Helper"); + assertNull(pkg.find(mock), "Should exclude test.Mock"); + assertNull(pkg.find(secretGet), "Should exclude Secret.getPassword"); + assertNotNull(pkg.find(secretSet), "Should not exclude Secret.setPassword"); + assertNull(pkg.find(cacheClear), "Should exclude Cache.clear"); + assertNotNull(pkg.find(cacheGet), "Should not exclude Cache.get"); + } + + @Test + public void testUnnamedPackage() throws Exception { + String[] yaml = { + "---", + "path: HelloWorld" + }; + AppMapPackage pkg = loadYaml(yaml, AppMapPackage.class); + + // Test a method in the unnamed package (empty package name) + FullyQualifiedName method = new FullyQualifiedName("", "HelloWorld", false, "getGreetingWithPunctuation"); + + AppMapPackage.LabelConfig result = pkg.find(method); + assertNotNull(result, "Should find method in unnamed package when path specifies the class name"); + + // Test that other classes in the unnamed package are not matched + FullyQualifiedName otherClass = new FullyQualifiedName("", "OtherClass", false, "someMethod"); + assertNull(pkg.find(otherClass), "Should not match other classes in the unnamed package"); + } + } } \ No newline at end of file diff --git a/agent/src/test/java/com/appland/appmap/util/PrefixTrieTest.java b/agent/src/test/java/com/appland/appmap/util/PrefixTrieTest.java new file mode 100644 index 00000000..a28cbe43 --- /dev/null +++ b/agent/src/test/java/com/appland/appmap/util/PrefixTrieTest.java @@ -0,0 +1,388 @@ +package com.appland.appmap.util; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +public class PrefixTrieTest { + + @Nested + class BasicOperations { + private PrefixTrie trie; + + @BeforeEach + void setUp() { + trie = new PrefixTrie(); + } + + @Test + void testEmptyTrie() { + assertFalse(trie.startsWith("anything"), "Empty trie should not match any string"); + assertFalse(trie.startsWith(""), "Empty trie should not match empty string"); + } + + @Test + void testSingleInsertExactMatch() { + trie.insert("foo"); + assertTrue(trie.startsWith("foo"), "Should match exact string"); + } + + @Test + void testSingleInsertPrefixMatch() { + trie.insert("foo"); + assertTrue(trie.startsWith("foobar"), "Should match when pattern is a prefix"); + assertTrue(trie.startsWith("foo.bar"), "Should match when pattern is a prefix"); + } + + @Test + void testSingleInsertNoMatch() { + trie.insert("foo"); + assertFalse(trie.startsWith("bar"), "Should not match unrelated string"); + assertFalse(trie.startsWith("fo"), "Should not match partial prefix"); + assertFalse(trie.startsWith("f"), "Should not match single character"); + } + + @Test + void testEmptyStringInsert() { + trie.insert(""); + assertTrue(trie.startsWith(""), "Should match empty string when empty string is inserted"); + assertTrue(trie.startsWith("anything"), "Empty pattern at root matches non-empty strings"); + } + + @Test + void testNullHandling() { + trie.insert(null); + assertFalse(trie.startsWith(null), "Null should not match anything"); + + trie.insert("foo"); + assertFalse(trie.startsWith(null), "Null should not match even when trie has entries"); + } + } + + @Nested + class MultiplePatterns { + private PrefixTrie trie; + + @BeforeEach + void setUp() { + trie = new PrefixTrie(); + } + + @Test + void testMultipleDistinctPatterns() { + trie.insert("foo"); + trie.insert("bar"); + trie.insert("baz"); + + assertTrue(trie.startsWith("foobar"), "Should match first pattern"); + assertTrue(trie.startsWith("barbell"), "Should match second pattern"); + assertTrue(trie.startsWith("bazinga"), "Should match third pattern"); + assertFalse(trie.startsWith("qux"), "Should not match uninserted pattern"); + } + + @Test + void testOverlappingPatterns() { + trie.insert("foo"); + trie.insert("foobar"); + + assertTrue(trie.startsWith("foo"), "Should match shorter pattern"); + assertTrue(trie.startsWith("foobar"), "Should match longer pattern"); + assertTrue(trie.startsWith("foobarbaz"), "Should match shortest prefix (foo)"); + } + + @Test + void testPrefixOfPrefix() { + trie.insert("a"); + trie.insert("ab"); + trie.insert("abc"); + + assertTrue(trie.startsWith("a"), "Should match 'a'"); + assertTrue(trie.startsWith("ab"), "Should match 'ab'"); + assertTrue(trie.startsWith("abc"), "Should match 'abc'"); + assertTrue(trie.startsWith("abcd"), "Should match via 'a' prefix"); + assertFalse(trie.startsWith("b"), "Should not match 'b'"); + } + } + + @Nested + class PackageScenarios { + private PrefixTrie trie; + + @BeforeEach + void setUp() { + trie = new PrefixTrie(); + } + + @Test + void testPackageExclusion() { + trie.insert("internal."); + + assertTrue(trie.startsWith("internal.Foo"), "Should match class in excluded package"); + assertTrue(trie.startsWith("internal.sub.Bar"), "Should match class in excluded subpackage"); + assertFalse(trie.startsWith("internal"), "Should not match package name without separator"); + assertFalse(trie.startsWith("internals.Foo"), "Should not match similar package with separator"); + } + + @Test + void testPackageBoundary() { + trie.insert("test."); + + assertTrue(trie.startsWith("test.Foo"), "Should match class in test package"); + assertTrue(trie.startsWith("test.sub.Bar"), "Should match class in test subpackage"); + assertFalse(trie.startsWith("test"), "Should not match package name without separator"); + assertFalse(trie.startsWith("testing"), "Should not match similar package"); + } + + @Test + void testClassExclusion() { + trie.insert("util.Helper."); + + assertTrue(trie.startsWith("util.Helper.method"), "Should match method in excluded class"); + assertFalse(trie.startsWith("util.Helper"), "Should not match class name without separator"); + assertFalse(trie.startsWith("util.HelperUtils"), "Should not match similar class name"); + assertFalse(trie.startsWith("util"), "Should not match package alone"); + } + + @Test + void testMethodExclusion() { + trie.insert("Cache.clear"); + + assertTrue(trie.startsWith("Cache.clear"), "Should match method exactly"); + assertTrue(trie.startsWith("Cache.clearAll"), "Will match since 'Cache.clear' is a prefix of 'Cache.clearAll'"); + assertFalse(trie.startsWith("Cache"), "Should not match class alone"); + } + + @Test + void testMixedExclusions() { + trie.insert("internal"); // whole package + trie.insert("util.Helper"); // specific class + trie.insert("Cache.clear"); // specific method + trie.insert("test."); // package with separator + + assertTrue(trie.startsWith("internal.Foo.bar"), "Should match package exclusion"); + assertTrue(trie.startsWith("util.Helper.method"), "Should match class exclusion"); + assertTrue(trie.startsWith("Cache.clear"), "Should match method exclusion"); + assertTrue(trie.startsWith("test.Foo"), "Should match package with separator"); + + assertFalse(trie.startsWith("util.Other"), "Should not match other class in util"); + assertFalse(trie.startsWith("Cache.get"), "Should not match other method in Cache"); + } + } + + @Nested + class HierarchicalPatterns { + private PrefixTrie trie; + + @BeforeEach + void setUp() { + trie = new PrefixTrie(); + } + + @Test + void testDeeplyNestedPackages() { + trie.insert("com.example.internal"); + + assertTrue(trie.startsWith("com.example.internal"), "Should match exact package"); + assertTrue(trie.startsWith("com.example.internal.Foo"), "Should match class in package"); + assertTrue(trie.startsWith("com.example.internal.sub.Bar"), "Should match class in subpackage"); + assertFalse(trie.startsWith("com.example"), "Should not match parent package"); + assertFalse(trie.startsWith("com.example.public"), "Should not match sibling package"); + } + + @Test + void testMultipleLevelsOfExclusion() { + trie.insert("com"); + trie.insert("com.example"); + trie.insert("com.example.foo"); + + assertTrue(trie.startsWith("com.anything"), "Should match via 'com' prefix"); + assertTrue(trie.startsWith("com.example.anything"), "Should match via 'com' prefix"); + assertTrue(trie.startsWith("com.example.foo.Bar"), "Should match via 'com' prefix"); + } + + @Test + void testFullyQualifiedNames() { + trie.insert("com.example.MyClass.myMethod"); + + assertTrue(trie.startsWith("com.example.MyClass.myMethod"), "Should match exact FQN"); + assertFalse(trie.startsWith("com.example.MyClass.otherMethod"), "Should not match different method"); + assertFalse(trie.startsWith("com.example.MyClass"), "Should not match just the class"); + } + } + + @Nested + class EdgeCases { + private PrefixTrie trie; + + @BeforeEach + void setUp() { + trie = new PrefixTrie(); + } + + @Test + void testSingleCharacterPatterns() { + trie.insert("a"); + + assertTrue(trie.startsWith("a"), "Should match single character"); + assertTrue(trie.startsWith("abc"), "Should match when single char is prefix"); + assertFalse(trie.startsWith("b"), "Should not match different character"); + } + + @Test + void testSpecialCharacters() { + trie.insert("foo$bar"); + trie.insert("baz#qux"); + + assertTrue(trie.startsWith("foo$bar"), "Should match pattern with $"); + assertTrue(trie.startsWith("foo$barbaz"), "Should match when $ pattern is prefix"); + assertTrue(trie.startsWith("baz#qux"), "Should match pattern with #"); + assertFalse(trie.startsWith("foo"), "Should not match partial before special char"); + } + + @Test + void testDuplicateInsertions() { + trie.insert("foo"); + trie.insert("foo"); + trie.insert("foo"); + + assertTrue(trie.startsWith("foobar"), "Should still work after duplicate insertions"); + } + + @Test + void testLongStrings() { + String longPattern = "com.example.very.long.package.name.with.many.segments.MyClass.myMethod"; + trie.insert(longPattern); + + assertTrue(trie.startsWith(longPattern), "Should match long pattern exactly"); + assertTrue(trie.startsWith(longPattern + ".extra"), "Should match long pattern as prefix"); + assertFalse(trie.startsWith("com.example.very.long.package"), "Should not match partial"); + } + + @Test + void testUnicodeCharacters() { + trie.insert("café"); + trie.insert("日本語"); + + assertTrue(trie.startsWith("café.method"), "Should match unicode pattern"); + assertTrue(trie.startsWith("日本語.クラス"), "Should match Japanese characters"); + } + } + + @Nested + class PrefixMatchingBehavior { + private PrefixTrie trie; + + @BeforeEach + void setUp() { + trie = new PrefixTrie(); + } + + @Test + void testExactMatchIsPrefix() { + trie.insert("exact"); + + assertTrue(trie.startsWith("exact"), "Exact match should return true"); + } + + @Test + void testLongerThanPattern() { + trie.insert("short"); + + assertTrue(trie.startsWith("short.longer.path"), "Longer string should match"); + } + + @Test + void testShorterThanPattern() { + trie.insert("verylongpattern"); + + assertFalse(trie.startsWith("verylong"), "Shorter string should not match"); + assertFalse(trie.startsWith("very"), "Much shorter string should not match"); + } + + @Test + void testFirstMatchWins() { + trie.insert("foo"); + trie.insert("foobar"); + trie.insert("foobarbaz"); + + // When checking "foobarbazqux", it should match "foo" first + assertTrue(trie.startsWith("foobarbazqux"), "Should match shortest prefix"); + } + + @Test + void testNoPartialPrefixMatch() { + trie.insert("complete"); + + assertFalse(trie.startsWith("comp"), "Should not match partial prefix"); + assertFalse(trie.startsWith("compl"), "Should not match partial prefix"); + assertFalse(trie.startsWith("complet"), "Should not match partial prefix"); + assertTrue(trie.startsWith("complete"), "Should match complete pattern"); + assertTrue(trie.startsWith("complete.more"), "Should match with additional text"); + } + } + + @Nested + class RealWorldScenarios { + private PrefixTrie trie; + + @BeforeEach + void setUp() { + trie = new PrefixTrie(); + } + + @Test + void testCommonExclusionPatterns() { + // Typical AppMap exclusion patterns + trie.insert("internal"); + trie.insert("test"); + trie.insert("generated"); + trie.insert("impl.Helper"); + trie.insert("util.StringUtil.intern"); + + // Should match + assertTrue(trie.startsWith("internal.SecretClass.method")); + assertTrue(trie.startsWith("test.MockService.setup")); + assertTrue(trie.startsWith("generated.AutoValue_Foo")); + assertTrue(trie.startsWith("impl.Helper.doSomething")); + assertTrue(trie.startsWith("util.StringUtil.intern")); + + // Should not match + assertFalse(trie.startsWith("impl.OtherClass")); + assertFalse(trie.startsWith("util.StringUtil.format")); + assertFalse(trie.startsWith("public.ApiClass")); + } + + @Test + void testJavaStandardLibraryExclusions() { + trie.insert("java."); + trie.insert("javax."); + trie.insert("sun."); + trie.insert("com.sun."); + + assertTrue(trie.startsWith("java.lang.String")); + assertTrue(trie.startsWith("javax.servlet.HttpServlet")); + assertTrue(trie.startsWith("sun.misc.Unsafe")); + assertTrue(trie.startsWith("com.sun.management.GarbageCollectorMXBean")); + + assertFalse(trie.startsWith("javalin.Context")); + assertFalse(trie.startsWith("com.example.Service")); + } + + @Test + void testFrameworkInternalExclusions() { + trie.insert("org.springframework.cglib"); + trie.insert("org.hibernate.internal"); + trie.insert("net.bytebuddy"); + + assertTrue(trie.startsWith("org.springframework.cglib.Enhancer")); + assertTrue(trie.startsWith("org.hibernate.internal.SessionImpl")); + assertTrue(trie.startsWith("net.bytebuddy.ByteBuddy")); + + assertFalse(trie.startsWith("org.springframework.web.Controller")); + assertFalse(trie.startsWith("org.hibernate.Session")); + } + } +}