Skip to content

Commit 74c4e9c

Browse files
committed
Recursive glob pattern matching
Update loadExamples to support recursive glob pattern matching for loading examples, maintaining backward compatibility for directory paths and ensuring deterministic output order. Added robust automated unit tests in CatalogPruningTest.kt. Port of Python SDK commit 94c1c9b
1 parent a6116c6 commit 74c4e9c

2 files changed

Lines changed: 230 additions & 8 deletions

File tree

agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/Catalog.kt

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -301,19 +301,88 @@ data class A2uiCatalog(
301301
append("\n${A2uiConstants.A2UI_SCHEMA_BLOCK_END}")
302302
}
303303

304-
/** Loads and validates examples from a directory. */
304+
/** Loads and validates examples from a directory or a glob pattern. */
305305
@JvmOverloads
306306
fun loadExamples(path: String?, validate: Boolean = false): String {
307307
if (path.isNullOrEmpty()) return ""
308-
val dir = File(path)
309-
if (!dir.isDirectory) {
310-
logger.warning("Example path $path is not a directory")
308+
309+
val isDir = File(path).isDirectory
310+
val pattern =
311+
if (isDir) {
312+
val sep = if (path.endsWith("/") || path.endsWith(File.separator)) "" else "/"
313+
"$path$sep*.json"
314+
} else {
315+
path
316+
}
317+
318+
// Extract the base directory to avoid walking the entire filesystem.
319+
val firstWildcard = pattern.indexOfFirst { it == '*' || it == '?' || it == '[' }
320+
val baseDirPath =
321+
if (firstWildcard != -1) {
322+
val lastSlash = pattern.lastIndexOfAny(charArrayOf('/', '\\'), firstWildcard)
323+
if (lastSlash != -1) {
324+
pattern.substring(startIndex = 0, endIndex = lastSlash)
325+
} else {
326+
""
327+
}
328+
} else {
329+
if (isDir) {
330+
path
331+
} else {
332+
val parent = File(path).parent
333+
parent ?: ""
334+
}
335+
}
336+
337+
val baseDirFile = if (baseDirPath.isEmpty()) File(".") else File(baseDirPath)
338+
val matchedFiles = mutableListOf<File>()
339+
340+
if (baseDirFile.exists() && baseDirFile.isDirectory) {
341+
try {
342+
val matcher = java.nio.file.FileSystems.getDefault().getPathMatcher("glob:$pattern")
343+
// To support globstar matching where ** matches zero directories, create an alternate
344+
// matcher.
345+
val altPattern =
346+
pattern
347+
.replace(oldValue = "/**/", newValue = "/")
348+
.replace(regex = "^\\*\\*/".toRegex(), replacement = "")
349+
val altMatcher =
350+
if (altPattern != pattern) {
351+
java.nio.file.FileSystems.getDefault().getPathMatcher("glob:$altPattern")
352+
} else {
353+
null
354+
}
355+
356+
val startPath =
357+
if (baseDirPath.isEmpty()) {
358+
java.nio.file.Paths.get("")
359+
} else {
360+
java.nio.file.Paths.get(baseDirPath)
361+
}
362+
363+
java.nio.file.Files.walk(startPath).use { stream ->
364+
stream.forEach { p ->
365+
if (java.nio.file.Files.isRegularFile(p)) {
366+
if (matcher.matches(p) || altMatcher?.matches(p) == true) {
367+
matchedFiles.add(p.toFile())
368+
}
369+
}
370+
}
371+
}
372+
} catch (e: Exception) {
373+
logger.warning("Error walking files for pattern $pattern: ${e.message}")
374+
}
375+
}
376+
377+
if (matchedFiles.isEmpty()) {
378+
if (!isDir && !path.any { it == '*' || it == '?' || it == '[' }) {
379+
logger.warning("Example path $path is neither a directory nor a valid glob pattern")
380+
}
311381
return ""
312382
}
313383

314-
// Sort files by name to ensure deterministic output order for tests.
315-
val files =
316-
dir.listFiles { _, name -> name.endsWith(".json") }?.sortedBy { it.name } ?: emptyList<File>()
384+
// Sort files alphabetically by path to ensure deterministic output order and logical grouping.
385+
val files = matchedFiles.sortedBy { it.path }
317386

318387
return files
319388
.mapNotNull { file ->
@@ -330,7 +399,7 @@ data class A2uiCatalog(
330399
null
331400
}
332401
}
333-
.joinToString("\n\n")
402+
.joinToString(separator = "\n\n")
334403
}
335404

336405
private fun validateExample(fullPath: String, content: String): Boolean =

agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/schema/CatalogPruningTest.kt

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,4 +422,157 @@ class CatalogPruningTest {
422422
)
423423
kotlin.test.assertEquals("/absolute/examples", config.examplesPath)
424424
}
425+
426+
@Test
427+
fun loadsExamplesWithGlob() {
428+
val tempDir = java.nio.file.Files.createTempDirectory("examples").toFile()
429+
try {
430+
val nestedDir = java.io.File(tempDir, "nested")
431+
nestedDir.mkdir()
432+
433+
java.io
434+
.File(tempDir, "top.json")
435+
.writeText("[{\"beginRendering\": {\"surfaceId\": \"top\"}}]")
436+
java.io
437+
.File(nestedDir, "deep.json")
438+
.writeText("[{\"beginRendering\": {\"surfaceId\": \"deep\"}}]")
439+
java.io.File(tempDir, "ignored.txt").writeText("not json")
440+
441+
val catalog =
442+
A2uiCatalog(
443+
version = A2uiVersion.VERSION_0_8,
444+
name = "basic",
445+
serverToClientSchema = JsonObject(emptyMap()),
446+
commonTypesSchema = JsonObject(emptyMap()),
447+
catalogSchema = JsonObject(mapOf(A2uiConstants.CATALOG_ID_KEY to JsonPrimitive("basic"))),
448+
)
449+
450+
// Match only top-level using a specific glob
451+
val examplesTop = catalog.loadExamples("${tempDir.path}/*.json")
452+
assertTrue(examplesTop.contains("---BEGIN top---"))
453+
assertFalse(examplesTop.contains("---BEGIN deep---"))
454+
455+
// Match recursively using globstar
456+
val examplesAll = catalog.loadExamples("${tempDir.path}/**/*.json")
457+
assertTrue(examplesAll.contains("---BEGIN top---"))
458+
assertTrue(examplesAll.contains("---BEGIN deep---"))
459+
} finally {
460+
tempDir.deleteRecursively()
461+
}
462+
}
463+
464+
@Test
465+
fun loadsExamplesWithGlobPrefixSuffix() {
466+
val tempDir = java.nio.file.Files.createTempDirectory("examples").toFile()
467+
try {
468+
java.io
469+
.File(tempDir, "user_profile.json")
470+
.writeText("[{\"beginRendering\": {\"surfaceId\": \"user\"}}]")
471+
java.io
472+
.File(tempDir, "user_settings.json")
473+
.writeText("[{\"beginRendering\": {\"surfaceId\": \"settings\"}}]")
474+
java.io
475+
.File(tempDir, "admin_profile.json")
476+
.writeText("[{\"beginRendering\": {\"surfaceId\": \"admin\"}}]")
477+
478+
val catalog =
479+
A2uiCatalog(
480+
version = A2uiVersion.VERSION_0_8,
481+
name = "basic",
482+
serverToClientSchema = JsonObject(emptyMap()),
483+
commonTypesSchema = JsonObject(emptyMap()),
484+
catalogSchema = JsonObject(mapOf(A2uiConstants.CATALOG_ID_KEY to JsonPrimitive("basic"))),
485+
)
486+
487+
// Filter by prefix: user_*.json
488+
val userExamples = catalog.loadExamples("${tempDir.path}/user_*.json")
489+
assertTrue(userExamples.contains("---BEGIN user_profile---"))
490+
assertTrue(userExamples.contains("---BEGIN user_settings---"))
491+
assertFalse(userExamples.contains("---BEGIN admin_profile---"))
492+
493+
// Filter by suffix: *_profile.json
494+
val profileExamples = catalog.loadExamples("${tempDir.path}/*_profile.json")
495+
assertTrue(profileExamples.contains("---BEGIN user_profile---"))
496+
assertTrue(profileExamples.contains("---BEGIN admin_profile---"))
497+
assertFalse(profileExamples.contains("---BEGIN user_settings---"))
498+
} finally {
499+
tempDir.deleteRecursively()
500+
}
501+
}
502+
503+
@Test
504+
fun loadsExamplesWithGlobAdvancedCases() {
505+
val tempDir = java.nio.file.Files.createTempDirectory("examples").toFile()
506+
try {
507+
java.io
508+
.File(tempDir, "step1.json")
509+
.writeText("[{\"beginRendering\": {\"surfaceId\": \"1\"}}]")
510+
java.io
511+
.File(tempDir, "step2.json")
512+
.writeText("[{\"beginRendering\": {\"surfaceId\": \"2\"}}]")
513+
java.io
514+
.File(tempDir, "step3.json")
515+
.writeText("[{\"beginRendering\": {\"surfaceId\": \"3\"}}]")
516+
517+
val fakeJsonDir = java.io.File(tempDir, "directory.json")
518+
fakeJsonDir.mkdir()
519+
520+
val catalog =
521+
A2uiCatalog(
522+
version = A2uiVersion.VERSION_0_8,
523+
name = "basic",
524+
serverToClientSchema = JsonObject(emptyMap()),
525+
commonTypesSchema = JsonObject(emptyMap()),
526+
catalogSchema = JsonObject(mapOf(A2uiConstants.CATALOG_ID_KEY to JsonPrimitive("basic"))),
527+
)
528+
529+
// Test character range matching
530+
val rangeExamples = catalog.loadExamples("${tempDir.path}/step[1-2].json")
531+
assertTrue(rangeExamples.contains("---BEGIN step1---"))
532+
assertTrue(rangeExamples.contains("---BEGIN step2---"))
533+
assertFalse(rangeExamples.contains("---BEGIN step3---"))
534+
535+
// Test that directory matching *.json is skipped correctly
536+
val allExamples = catalog.loadExamples("${tempDir.path}/*.json")
537+
assertTrue(allExamples.contains("---BEGIN step1---"))
538+
assertFalse(allExamples.contains("directory"))
539+
540+
// Test zero matches returns empty string
541+
kotlin.test.assertEquals(
542+
expected = "",
543+
actual = catalog.loadExamples("${tempDir.path}/*.yaml"),
544+
)
545+
} finally {
546+
tempDir.deleteRecursively()
547+
}
548+
}
549+
550+
@Test
551+
fun loadsExamplesWithGlobNegation() {
552+
val tempDir = java.nio.file.Files.createTempDirectory("examples").toFile()
553+
try {
554+
java.io
555+
.File(tempDir, "visible.json")
556+
.writeText("[{\"beginRendering\": {\"surfaceId\": \"visible\"}}]")
557+
java.io
558+
.File(tempDir, "index.json")
559+
.writeText("[{\"beginRendering\": {\"surfaceId\": \"index\"}}]")
560+
561+
val catalog =
562+
A2uiCatalog(
563+
version = A2uiVersion.VERSION_0_8,
564+
name = "basic",
565+
serverToClientSchema = JsonObject(emptyMap()),
566+
commonTypesSchema = JsonObject(emptyMap()),
567+
catalogSchema = JsonObject(mapOf(A2uiConstants.CATALOG_ID_KEY to JsonPrimitive("basic"))),
568+
)
569+
570+
// Test negation to exclude files starting with 'i' (like index.json)
571+
val negationExamples = catalog.loadExamples("${tempDir.path}/[!i]*.json")
572+
assertTrue(negationExamples.contains("---BEGIN visible---"))
573+
assertFalse(negationExamples.contains("---BEGIN index---"))
574+
} finally {
575+
tempDir.deleteRecursively()
576+
}
577+
}
425578
}

0 commit comments

Comments
 (0)