Skip to content

Commit 4b4e191

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 fd68b17 commit 4b4e191

2 files changed

Lines changed: 230 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: 153 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

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

0 commit comments

Comments
 (0)