Skip to content

Commit 8f3406d

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 110cd12 commit 8f3406d

2 files changed

Lines changed: 234 additions & 8 deletions

File tree

  • agent_sdks/kotlin/src

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/CatalogTest.kt

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@ package com.google.a2ui.core.schema
1919
import kotlin.test.Test
2020
import kotlin.test.assertEquals
2121
import kotlin.test.assertFailsWith
22+
import kotlin.test.assertFalse
2223
import kotlin.test.assertNull
2324
import kotlin.test.assertTrue
25+
import kotlinx.serialization.json.JsonObject
26+
import kotlinx.serialization.json.JsonPrimitive
2427

2528
class CatalogTest {
2629

@@ -82,4 +85,158 @@ class CatalogTest {
8285
)
8386
assertEquals("/absolute/examples", config.examplesPath)
8487
}
88+
89+
@Test
90+
fun loadsExamplesWithGlob() {
91+
val tempDir = java.nio.file.Files.createTempDirectory("examples").toFile()
92+
try {
93+
val nestedDir = java.io.File(tempDir, "nested")
94+
nestedDir.mkdir()
95+
96+
java.io
97+
.File(tempDir, "top.json")
98+
.writeText("[{\"beginRendering\": {\"surfaceId\": \"top\"}}]")
99+
java.io
100+
.File(nestedDir, "deep.json")
101+
.writeText("[{\"beginRendering\": {\"surfaceId\": \"deep\"}}]")
102+
java.io.File(tempDir, "ignored.txt").writeText("not json")
103+
104+
val catalog =
105+
A2uiCatalog(
106+
version = A2uiVersion.VERSION_0_8,
107+
name = "basic",
108+
serverToClientSchema = JsonObject(emptyMap()),
109+
commonTypesSchema = JsonObject(emptyMap()),
110+
catalogSchema = JsonObject(mapOf(A2uiConstants.CATALOG_ID_KEY to JsonPrimitive("basic"))),
111+
)
112+
113+
// Match only top-level using a specific glob
114+
val examplesTop = catalog.loadExamples("${tempDir.path}/*.json")
115+
assertTrue(examplesTop.contains("---BEGIN top---"))
116+
assertFalse(examplesTop.contains("---BEGIN deep---"))
117+
118+
// Match recursively using globstar
119+
val examplesAll = catalog.loadExamples("${tempDir.path}/**/*.json")
120+
assertTrue(examplesAll.contains("---BEGIN top---"))
121+
assertTrue(examplesAll.contains("---BEGIN deep---"))
122+
} finally {
123+
tempDir.deleteRecursively()
124+
}
125+
}
126+
127+
@Test
128+
fun loadsExamplesWithGlobPrefixSuffix() {
129+
val tempDir = java.nio.file.Files.createTempDirectory("examples").toFile()
130+
try {
131+
java.io
132+
.File(tempDir, "user_profile.json")
133+
.writeText("[{\"beginRendering\": {\"surfaceId\": \"user\"}}]")
134+
java.io
135+
.File(tempDir, "user_settings.json")
136+
.writeText("[{\"beginRendering\": {\"surfaceId\": \"settings\"}}]")
137+
java.io
138+
.File(tempDir, "admin_profile.json")
139+
.writeText("[{\"beginRendering\": {\"surfaceId\": \"admin\"}}]")
140+
141+
val catalog =
142+
A2uiCatalog(
143+
version = A2uiVersion.VERSION_0_8,
144+
name = "basic",
145+
serverToClientSchema = JsonObject(emptyMap()),
146+
commonTypesSchema = JsonObject(emptyMap()),
147+
catalogSchema = JsonObject(mapOf(A2uiConstants.CATALOG_ID_KEY to JsonPrimitive("basic"))),
148+
)
149+
150+
// Filter by prefix: user_*.json
151+
val userExamples = catalog.loadExamples("${tempDir.path}/user_*.json")
152+
assertTrue(userExamples.contains("---BEGIN user_profile---"))
153+
assertTrue(userExamples.contains("---BEGIN user_settings---"))
154+
assertFalse(userExamples.contains("---BEGIN admin_profile---"))
155+
156+
// Filter by suffix: *_profile.json
157+
val profileExamples = catalog.loadExamples("${tempDir.path}/*_profile.json")
158+
assertTrue(profileExamples.contains("---BEGIN user_profile---"))
159+
assertTrue(profileExamples.contains("---BEGIN admin_profile---"))
160+
assertFalse(profileExamples.contains("---BEGIN user_settings---"))
161+
} finally {
162+
tempDir.deleteRecursively()
163+
}
164+
}
165+
166+
@Test
167+
fun loadsExamplesWithGlobAdvancedCases() {
168+
val tempDir = java.nio.file.Files.createTempDirectory("examples").toFile()
169+
try {
170+
java.io
171+
.File(tempDir, "step1.json")
172+
.writeText("[{\"beginRendering\": {\"surfaceId\": \"1\"}}]")
173+
java.io
174+
.File(tempDir, "step2.json")
175+
.writeText("[{\"beginRendering\": {\"surfaceId\": \"2\"}}]")
176+
java.io
177+
.File(tempDir, "step3.json")
178+
.writeText("[{\"beginRendering\": {\"surfaceId\": \"3\"}}]")
179+
180+
val fakeJsonDir = java.io.File(tempDir, "directory.json")
181+
fakeJsonDir.mkdir()
182+
183+
val catalog =
184+
A2uiCatalog(
185+
version = A2uiVersion.VERSION_0_8,
186+
name = "basic",
187+
serverToClientSchema = JsonObject(emptyMap()),
188+
commonTypesSchema = JsonObject(emptyMap()),
189+
catalogSchema = JsonObject(mapOf(A2uiConstants.CATALOG_ID_KEY to JsonPrimitive("basic"))),
190+
)
191+
192+
// Test character range matching
193+
val rangeExamples = catalog.loadExamples("${tempDir.path}/step[1-2].json")
194+
assertTrue(rangeExamples.contains("---BEGIN step1---"))
195+
assertTrue(rangeExamples.contains("---BEGIN step2---"))
196+
assertFalse(rangeExamples.contains("---BEGIN step3---"))
197+
198+
// Test that directory matching *.json is skipped correctly
199+
val allExamples = catalog.loadExamples("${tempDir.path}/*.json")
200+
assertTrue(allExamples.contains("---BEGIN step1---"))
201+
assertFalse(allExamples.contains("directory"))
202+
203+
// Test zero matches returns empty string
204+
assertEquals(
205+
expected = "",
206+
actual = catalog.loadExamples("${tempDir.path}/*.yaml"),
207+
)
208+
} finally {
209+
tempDir.deleteRecursively()
210+
}
211+
}
212+
213+
@Test
214+
fun loadsExamplesWithGlobNegation() {
215+
val tempDir = java.nio.file.Files.createTempDirectory("examples").toFile()
216+
try {
217+
java.io
218+
.File(tempDir, "visible.json")
219+
.writeText("[{\"beginRendering\": {\"surfaceId\": \"visible\"}}]")
220+
java.io
221+
.File(tempDir, "index.json")
222+
.writeText("[{\"beginRendering\": {\"surfaceId\": \"index\"}}]")
223+
224+
val catalog =
225+
A2uiCatalog(
226+
version = A2uiVersion.VERSION_0_8,
227+
name = "basic",
228+
serverToClientSchema = JsonObject(emptyMap()),
229+
commonTypesSchema = JsonObject(emptyMap()),
230+
catalogSchema = JsonObject(mapOf(A2uiConstants.CATALOG_ID_KEY to JsonPrimitive("basic"))),
231+
)
232+
233+
// Test negation to exclude files starting with 'i' (like index.json)
234+
val negationExamples = catalog.loadExamples("${tempDir.path}/[!i]*.json")
235+
assertTrue(negationExamples.contains("---BEGIN visible---"))
236+
assertFalse(negationExamples.contains("---BEGIN index---"))
237+
} finally {
238+
tempDir.deleteRecursively()
239+
}
240+
}
85241
}
242+

0 commit comments

Comments
 (0)