This is an automated email from the ASF dual-hosted git repository.

jdaugherty pushed a commit to branch configuration-command
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit bdfbc7ad106944e86a3270a5742fb939768e8871
Author: James Daugherty <[email protected]>
AuthorDate: Thu Feb 19 11:09:38 2026 -0500

    proof of concept for a config report command
---
 .../grails/dev/commands/ConfigReportCommand.groovy | 158 +++++++++++++
 .../dev/commands/ConfigReportCommandSpec.groovy    | 255 +++++++++++++++++++++
 grails-test-examples/config-report/build.gradle    |  63 +++++
 .../grails-app/conf/application.groovy             |  29 +++
 .../config-report/grails-app/conf/application.yml  |  53 +++++
 .../config-report/grails-app/conf/logback.xml      |  10 +
 .../controllers/configreport/UrlMappings.groovy    |  35 +++
 .../init/configreport/Application.groovy           |  32 +++
 .../ConfigReportCommandIntegrationSpec.groovy      | 254 ++++++++++++++++++++
 .../main/groovy/configreport/AppProperties.groovy  |  52 +++++
 settings.gradle                                    |   2 +
 11 files changed, 943 insertions(+)

diff --git 
a/grails-core/src/main/groovy/grails/dev/commands/ConfigReportCommand.groovy 
b/grails-core/src/main/groovy/grails/dev/commands/ConfigReportCommand.groovy
new file mode 100644
index 0000000000..e10da60382
--- /dev/null
+++ b/grails-core/src/main/groovy/grails/dev/commands/ConfigReportCommand.groovy
@@ -0,0 +1,158 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package grails.dev.commands
+
+import groovy.transform.CompileStatic
+import groovy.util.logging.Slf4j
+
+import org.springframework.core.env.ConfigurableEnvironment
+import org.springframework.core.env.EnumerablePropertySource
+import org.springframework.core.env.PropertySource
+
+/**
+ * An {@link ApplicationCommand} that generates an AsciiDoc report
+ * of the application's resolved configuration properties.
+ *
+ * <p>Properties are collected directly from the Spring {@link 
ConfigurableEnvironment},
+ * iterating all {@link EnumerablePropertySource} instances to capture every
+ * resolvable property regardless of how it was defined (YAML, Groovy config,
+ * system properties, environment variables, etc.).
+ *
+ * <p>Usage:
+ * <pre>
+ *     grails config-report
+ *     ./gradlew configReport
+ * </pre>
+ *
+ * <p>The report is written to {@code config-report.adoc} in the project's 
base directory.
+ *
+ * @since 7.0
+ */
+@Slf4j
+@CompileStatic
+class ConfigReportCommand implements ApplicationCommand {
+
+    static final String DEFAULT_REPORT_FILE = 'config-report.adoc'
+
+    final String description = 'Generates an AsciiDoc report of the 
application configuration'
+
+    @Override
+    boolean handle(ExecutionContext executionContext) {
+        try {
+            ConfigurableEnvironment environment = (ConfigurableEnvironment) 
applicationContext.getEnvironment()
+            Map<String, String> sorted = collectProperties(environment)
+
+            File reportFile = new File(executionContext.baseDir, 
DEFAULT_REPORT_FILE)
+            writeReport(sorted, reportFile)
+
+            log.info('Configuration report written to {}', 
reportFile.absolutePath)
+            return true
+        }
+        catch (Throwable e) {
+            log.error("Failed to generate configuration report: ${e.message}", 
e)
+            return false
+        }
+    }
+
+    /**
+     * Collects all configuration properties from the Spring {@link 
ConfigurableEnvironment}
+     * by iterating its {@link EnumerablePropertySource} instances. Property 
values are
+     * resolved through the environment to ensure placeholders are expanded and
+     * the correct precedence order is applied.
+     *
+     * @param environment the Spring environment
+     * @return a sorted map of property names to their resolved values
+     */
+    Map<String, String> collectProperties(ConfigurableEnvironment environment) 
{
+        Map<String, String> sorted = new TreeMap<String, String>()
+        for (PropertySource<?> propertySource : 
environment.getPropertySources()) {
+            if (propertySource instanceof EnumerablePropertySource) {
+                EnumerablePropertySource<?> enumerable = 
(EnumerablePropertySource<?>) propertySource
+                for (String propertyName : enumerable.getPropertyNames()) {
+                    if (!sorted.containsKey(propertyName)) {
+                        try {
+                            String value = 
environment.getProperty(propertyName)
+                            if (value != null) {
+                                sorted.put(propertyName, value)
+                            }
+                        }
+                        catch (Exception e) {
+                            log.debug('Could not resolve property {}: {}', 
propertyName, e.message)
+                        }
+                    }
+                }
+            }
+        }
+        sorted
+    }
+
+    /**
+     * Writes the configuration properties as an AsciiDoc file grouped by 
top-level namespace.
+     *
+     * @param sorted the sorted configuration properties
+     * @param reportFile the file to write the report to
+     */
+    void writeReport(Map<String, String> sorted, File reportFile) {
+        reportFile.withWriter('UTF-8') { BufferedWriter writer ->
+            writer.writeLine('= Grails Application Configuration Report')
+            writer.writeLine(':toc: left')
+            writer.writeLine(':toclevels: 2')
+            writer.writeLine(':source-highlighter: coderay')
+            writer.writeLine('')
+
+            String currentSection = ''
+            sorted.each { String key, String value ->
+                String section = key.contains('.') ? key.substring(0, 
key.indexOf('.')) : key
+                if (section != currentSection) {
+                    if (currentSection) {
+                        writer.writeLine('|===')
+                        writer.writeLine('')
+                    }
+                    currentSection = section
+                    writer.writeLine("== ${section}")
+                    writer.writeLine('')
+                    writer.writeLine('[cols="2,3", options="header"]')
+                    writer.writeLine('|===')
+                    writer.writeLine('| Property | Value')
+                    writer.writeLine('')
+                }
+                writer.writeLine("| `${key}`")
+                writer.writeLine("| `${escapeAsciidoc(value)}`")
+                writer.writeLine('')
+            }
+            if (currentSection) {
+                writer.writeLine('|===')
+            }
+        }
+    }
+
+    /**
+     * Escapes special AsciiDoc characters in a value string.
+     *
+     * @param value the raw value
+     * @return the escaped value safe for AsciiDoc table cells
+     */
+    static String escapeAsciidoc(String value) {
+        if (!value) {
+            return value
+        }
+        value.replace('|', '\\|')
+    }
+
+}
diff --git 
a/grails-core/src/test/groovy/grails/dev/commands/ConfigReportCommandSpec.groovy
 
b/grails-core/src/test/groovy/grails/dev/commands/ConfigReportCommandSpec.groovy
new file mode 100644
index 0000000000..4bd4f67370
--- /dev/null
+++ 
b/grails-core/src/test/groovy/grails/dev/commands/ConfigReportCommandSpec.groovy
@@ -0,0 +1,255 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package grails.dev.commands
+
+import org.springframework.context.ConfigurableApplicationContext
+import org.springframework.core.env.ConfigurableEnvironment
+import org.springframework.core.env.MapPropertySource
+import org.springframework.core.env.MutablePropertySources
+
+import org.grails.build.parsing.CommandLine
+import spock.lang.Specification
+import spock.lang.TempDir
+
+class ConfigReportCommandSpec extends Specification {
+
+    @TempDir
+    File tempDir
+
+    ConfigReportCommand command
+
+    ConfigurableApplicationContext applicationContext
+
+    ConfigurableEnvironment environment
+
+    MutablePropertySources propertySources
+
+    def setup() {
+        propertySources = new MutablePropertySources()
+        environment = Mock(ConfigurableEnvironment)
+        environment.getPropertySources() >> propertySources
+        applicationContext = Mock(ConfigurableApplicationContext)
+        applicationContext.getEnvironment() >> environment
+
+        command = new ConfigReportCommand()
+        command.applicationContext = applicationContext
+    }
+
+    def "command name is derived from class name"() {
+        expect:
+        command.name == 'config-report'
+    }
+
+    def "command has a description"() {
+        expect:
+        command.description == 'Generates an AsciiDoc report of the 
application configuration'
+    }
+
+    def "handle generates AsciiDoc report file"() {
+        given:
+        Map<String, Object> props = [
+            'grails.profile': 'web',
+            'grails.codegen.defaultPackage': 'myapp',
+            'server.port': '8080',
+            'spring.main.banner-mode': 'off'
+        ]
+        propertySources.addFirst(new MapPropertySource('test', props))
+        props.each { String key, Object value ->
+            environment.getProperty(key) >> value.toString()
+        }
+
+        ExecutionContext executionContext = new 
ExecutionContext(Mock(CommandLine))
+
+        when:
+        boolean result = command.handle(executionContext)
+
+        then:
+        result
+
+        and: "report file is written to the base directory"
+        File reportFile = new File(executionContext.baseDir, 
ConfigReportCommand.DEFAULT_REPORT_FILE)
+        reportFile.exists()
+
+        and:
+        String content = reportFile.text
+        content.contains('= Grails Application Configuration Report')
+        content.contains('== grails')
+        content.contains('== server')
+        content.contains('== spring')
+        content.contains('`grails.profile`')
+        content.contains('`web`')
+        content.contains('`server.port`')
+        content.contains('`8080`')
+
+        cleanup:
+        reportFile?.delete()
+    }
+
+    def "handle returns false when an error occurs"() {
+        given:
+        ConfigurableApplicationContext failingContext = 
Mock(ConfigurableApplicationContext)
+        failingContext.getEnvironment() >> { throw new RuntimeException('test 
error') }
+        ConfigReportCommand failingCommand = new ConfigReportCommand()
+        failingCommand.applicationContext = failingContext
+        ExecutionContext executionContext = new 
ExecutionContext(Mock(CommandLine))
+
+        when:
+        boolean result = failingCommand.handle(executionContext)
+
+        then:
+        !result
+    }
+
+    def "collectProperties gathers from all enumerable property sources"() {
+        given:
+        Map<String, Object> yamlProps = ['myapp.yaml.greeting': 'Hello']
+        Map<String, Object> groovyProps = ['myapp.groovy.name': 'TestApp']
+        propertySources.addLast(new MapPropertySource('yaml', yamlProps))
+        propertySources.addLast(new MapPropertySource('groovy', groovyProps))
+        environment.getProperty('myapp.yaml.greeting') >> 'Hello'
+        environment.getProperty('myapp.groovy.name') >> 'TestApp'
+
+        when:
+        Map<String, String> result = command.collectProperties(environment)
+
+        then:
+        result['myapp.yaml.greeting'] == 'Hello'
+        result['myapp.groovy.name'] == 'TestApp'
+    }
+
+    def "collectProperties respects property source precedence"() {
+        given: 'two sources with the same key, higher-priority source listed 
first'
+        Map<String, Object> overrideProps = ['app.name': 'Override']
+        Map<String, Object> defaultProps = ['app.name': 'Default']
+        propertySources.addLast(new MapPropertySource('override', 
overrideProps))
+        propertySources.addLast(new MapPropertySource('default', defaultProps))
+        environment.getProperty('app.name') >> 'Override'
+
+        when:
+        Map<String, String> result = command.collectProperties(environment)
+
+        then: 'the higher-priority value wins'
+        result['app.name'] == 'Override'
+    }
+
+    def "collectProperties skips properties that resolve to null"() {
+        given:
+        Map<String, Object> props = ['app.present': 'value', 'app.missing': 
'placeholder']
+        propertySources.addFirst(new MapPropertySource('test', props))
+        environment.getProperty('app.present') >> 'value'
+        environment.getProperty('app.missing') >> null
+
+        when:
+        Map<String, String> result = command.collectProperties(environment)
+
+        then:
+        result.containsKey('app.present')
+        !result.containsKey('app.missing')
+    }
+
+    def "collectProperties handles resolution errors gracefully"() {
+        given:
+        Map<String, Object> props = ['app.good': 'value', 'app.bad': 
'${unresolved}']
+        propertySources.addFirst(new MapPropertySource('test', props))
+        environment.getProperty('app.good') >> 'value'
+        environment.getProperty('app.bad') >> { throw new 
IllegalArgumentException('unresolved placeholder') }
+
+        when:
+        Map<String, String> result = command.collectProperties(environment)
+
+        then: 'the good property is collected and the bad one is skipped'
+        result['app.good'] == 'value'
+        !result.containsKey('app.bad')
+    }
+
+    def "writeReport groups properties by top-level namespace"() {
+        given:
+        Map<String, String> sorted = new TreeMap<String, String>()
+        sorted.put('grails.controllers.defaultScope', 'singleton')
+        sorted.put('grails.profile', 'web')
+        sorted.put('server.port', '8080')
+
+        File reportFile = new File(tempDir, 'test-report.adoc')
+
+        when:
+        command.writeReport(sorted, reportFile)
+
+        then:
+        String content = reportFile.text
+
+        and: "report has correct AsciiDoc structure"
+        content.startsWith('= Grails Application Configuration Report')
+        content.contains(':toc: left')
+        content.contains('[cols="2,3", options="header"]')
+        content.contains('| Property | Value')
+
+        and: "properties are grouped by namespace"
+        content.contains('== grails')
+        content.contains('== server')
+
+        and: "grails section appears before server section (alphabetical)"
+        content.indexOf('== grails') < content.indexOf('== server')
+
+        and: "properties are listed under correct sections"
+        content.contains('`grails.controllers.defaultScope`')
+        content.contains('`singleton`')
+        content.contains('`server.port`')
+        content.contains('`8080`')
+    }
+
+    def "writeReport escapes pipe characters in values"() {
+        given:
+        Map<String, String> sorted = new TreeMap<String, String>()
+        sorted.put('test.key', 'value|with|pipes')
+
+        File reportFile = new File(tempDir, 'escape-test.adoc')
+
+        when:
+        command.writeReport(sorted, reportFile)
+
+        then:
+        String content = reportFile.text
+        content.contains('value\\|with\\|pipes')
+        !content.contains('value|with|pipes')
+    }
+
+    def "writeReport handles empty configuration"() {
+        given:
+        Map<String, String> sorted = new TreeMap<String, String>()
+        File reportFile = new File(tempDir, 'empty-report.adoc')
+
+        when:
+        command.writeReport(sorted, reportFile)
+
+        then:
+        reportFile.exists()
+        String content = reportFile.text
+        content.contains('= Grails Application Configuration Report')
+        !content.contains('|===')
+    }
+
+    def "escapeAsciidoc handles null and empty strings"() {
+        expect:
+        ConfigReportCommand.escapeAsciidoc(null) == null
+        ConfigReportCommand.escapeAsciidoc('') == ''
+        ConfigReportCommand.escapeAsciidoc('simple') == 'simple'
+        ConfigReportCommand.escapeAsciidoc('a|b') == 'a\\|b'
+    }
+
+}
diff --git a/grails-test-examples/config-report/build.gradle 
b/grails-test-examples/config-report/build.gradle
new file mode 100644
index 0000000000..231fef7d7d
--- /dev/null
+++ b/grails-test-examples/config-report/build.gradle
@@ -0,0 +1,63 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+plugins {
+    id 'org.apache.grails.buildsrc.properties'
+    id 'org.apache.grails.buildsrc.compile'
+}
+
+version = '0.1'
+group = 'configreport'
+
+apply plugin: 'groovy'
+apply plugin: 'org.apache.grails.gradle.grails-web'
+
+dependencies {
+    implementation platform(project(':grails-bom'))
+
+    implementation 'org.apache.grails:grails-core'
+    implementation 'org.apache.grails:grails-logging'
+    implementation 'org.apache.grails:grails-databinding'
+    implementation 'org.apache.grails:grails-interceptors'
+    implementation 'org.apache.grails:grails-services'
+    implementation 'org.apache.grails:grails-url-mappings'
+    implementation 'org.apache.grails:grails-web-boot'
+    if (System.getenv('SITEMESH3_TESTING_ENABLED') == 'true') {
+        implementation 'org.apache.grails:grails-sitemesh3'
+    }
+    else {
+        implementation 'org.apache.grails:grails-layout'
+    }
+    implementation 'org.apache.grails:grails-data-hibernate5'
+    implementation 'org.springframework.boot:spring-boot-autoconfigure'
+    implementation 'org.springframework.boot:spring-boot-starter'
+    implementation 'org.springframework.boot:spring-boot-starter-logging'
+    implementation 'org.springframework.boot:spring-boot-starter-tomcat'
+    implementation 'org.springframework.boot:spring-boot-starter-validation'
+
+    runtimeOnly 'com.h2database:h2'
+    runtimeOnly 'org.apache.tomcat:tomcat-jdbc'
+
+    testImplementation 'org.apache.grails:grails-testing-support-web'
+    testImplementation 'org.spockframework:spock-core'
+}
+
+apply {
+    from 
rootProject.layout.projectDirectory.file('gradle/functional-test-config.gradle')
+    from 
rootProject.layout.projectDirectory.file('gradle/grails-extension-gradle-config.gradle')
+}
diff --git 
a/grails-test-examples/config-report/grails-app/conf/application.groovy 
b/grails-test-examples/config-report/grails-app/conf/application.groovy
new file mode 100644
index 0000000000..1ab156638c
--- /dev/null
+++ b/grails-test-examples/config-report/grails-app/conf/application.groovy
@@ -0,0 +1,29 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+// Properties defined in Groovy config that should appear in the config report
+myapp {
+    groovy {
+        appName = 'Config Report Test App'
+        version = '1.2.3'
+    }
+}
+
+// A property with a pipe character to test AsciiDoc escaping
+myapp.groovy.delimitedValue = 'value1|value2|value3'
diff --git a/grails-test-examples/config-report/grails-app/conf/application.yml 
b/grails-test-examples/config-report/grails-app/conf/application.yml
new file mode 100644
index 0000000000..7eb0d4524c
--- /dev/null
+++ b/grails-test-examples/config-report/grails-app/conf/application.yml
@@ -0,0 +1,53 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+---
+grails:
+    profile: web
+    codegen:
+        defaultPackage: configreport
+
+---
+# Properties defined in YAML that should appear in the config report
+myapp:
+    yaml:
+        greeting: Hello from YAML
+        maxRetries: 5
+        feature:
+            enabled: true
+            timeout: 30000
+    typed:
+        name: Configured App
+        pageSize: 50
+        debugEnabled: true
+
+---
+# Server configuration for testing namespace grouping
+server:
+    port: 0
+
+---
+dataSource:
+    pooled: true
+    jmxExport: true
+    driverClassName: org.h2.Driver
+    username: sa
+    password:
+
+environments:
+    test:
+        dataSource:
+            dbCreate: create-drop
+            url: 
jdbc:h2:mem:configReportTestDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE
diff --git a/grails-test-examples/config-report/grails-app/conf/logback.xml 
b/grails-test-examples/config-report/grails-app/conf/logback.xml
new file mode 100644
index 0000000000..bcc455d190
--- /dev/null
+++ b/grails-test-examples/config-report/grails-app/conf/logback.xml
@@ -0,0 +1,10 @@
+<configuration>
+    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder>
+            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - 
%msg%n</pattern>
+        </encoder>
+    </appender>
+    <root level="error">
+        <appender-ref ref="STDOUT" />
+    </root>
+</configuration>
diff --git 
a/grails-test-examples/config-report/grails-app/controllers/configreport/UrlMappings.groovy
 
b/grails-test-examples/config-report/grails-app/controllers/configreport/UrlMappings.groovy
new file mode 100644
index 0000000000..7e141297b3
--- /dev/null
+++ 
b/grails-test-examples/config-report/grails-app/controllers/configreport/UrlMappings.groovy
@@ -0,0 +1,35 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package configreport
+
+class UrlMappings {
+
+    static mappings = {
+        "/$controller/$action?/$id?(.$format)?" {
+            constraints {
+                // apply constraints here
+            }
+        }
+
+        "/"(view: '/index')
+        "500"(view: '/error')
+        "404"(view: '/notFound')
+    }
+
+}
diff --git 
a/grails-test-examples/config-report/grails-app/init/configreport/Application.groovy
 
b/grails-test-examples/config-report/grails-app/init/configreport/Application.groovy
new file mode 100644
index 0000000000..dfe6cc0ab1
--- /dev/null
+++ 
b/grails-test-examples/config-report/grails-app/init/configreport/Application.groovy
@@ -0,0 +1,32 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package configreport
+
+import grails.boot.GrailsApp
+import grails.boot.config.GrailsAutoConfiguration
+import 
org.springframework.boot.context.properties.EnableConfigurationProperties
+
+@EnableConfigurationProperties(AppProperties)
+class Application extends GrailsAutoConfiguration {
+
+    static void main(String[] args) {
+        GrailsApp.run(Application)
+    }
+
+}
diff --git 
a/grails-test-examples/config-report/src/integration-test/groovy/configreport/ConfigReportCommandIntegrationSpec.groovy
 
b/grails-test-examples/config-report/src/integration-test/groovy/configreport/ConfigReportCommandIntegrationSpec.groovy
new file mode 100644
index 0000000000..fd85880a79
--- /dev/null
+++ 
b/grails-test-examples/config-report/src/integration-test/groovy/configreport/ConfigReportCommandIntegrationSpec.groovy
@@ -0,0 +1,254 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package configreport
+
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.context.ConfigurableApplicationContext
+
+import grails.dev.commands.ConfigReportCommand
+import grails.dev.commands.ExecutionContext
+import grails.testing.mixin.integration.Integration
+import org.grails.build.parsing.CommandLine
+import spock.lang.Narrative
+import spock.lang.Specification
+import spock.lang.TempDir
+
+/**
+ * Integration tests for {@link ConfigReportCommand} that verify the command
+ * correctly reports configuration from multiple sources:
+ * <ul>
+ *   <li>{@code application.yml} - YAML-based configuration</li>
+ *   <li>{@code application.groovy} - Groovy-based configuration</li>
+ *   <li>{@code @ConfigurationProperties} - Type-safe configuration beans</li>
+ * </ul>
+ */
+@Integration
+@Narrative('Verifies that ConfigReportCommand generates an AsciiDoc report 
containing properties from application.yml, application.groovy, and 
@ConfigurationProperties sources')
+class ConfigReportCommandIntegrationSpec extends Specification {
+
+    @Autowired
+    ConfigurableApplicationContext applicationContext
+
+    @Autowired
+    AppProperties appProperties
+
+    @TempDir
+    File tempDir
+
+    private ConfigReportCommand createCommand() {
+        ConfigReportCommand command = new ConfigReportCommand()
+        command.applicationContext = applicationContext
+        return command
+    }
+
+    private File executeCommand(ConfigReportCommand command) {
+        ExecutionContext executionContext = new 
ExecutionContext(Mock(CommandLine))
+        File reportFile = new File(executionContext.baseDir, 
ConfigReportCommand.DEFAULT_REPORT_FILE)
+        command.handle(executionContext)
+        return reportFile
+    }
+
+    def "ConfigReportCommand generates a report file"() {
+        given: 'a ConfigReportCommand wired to the live application context'
+        ConfigReportCommand command = createCommand()
+
+        and: 'an execution context pointing to a temporary directory'
+        ExecutionContext executionContext = new 
ExecutionContext(Mock(CommandLine))
+        File reportFile = new File(executionContext.baseDir, 
ConfigReportCommand.DEFAULT_REPORT_FILE)
+
+        when: 'the command is executed'
+        boolean result = command.handle(executionContext)
+
+        then: 'the command succeeds'
+        result
+
+        and: 'the report file is created'
+        reportFile.exists()
+        reportFile.length() > 0
+
+        and: 'the report has valid AsciiDoc structure'
+        String content = reportFile.text
+        content.startsWith('= Grails Application Configuration Report')
+        content.contains(':toc: left')
+        content.contains('[cols="2,3", options="header"]')
+        content.contains('| Property | Value')
+
+        cleanup:
+        reportFile?.delete()
+    }
+
+    def "report contains properties from application.yml"() {
+        given: 'a ConfigReportCommand wired to the live application context'
+        ConfigReportCommand command = createCommand()
+        ExecutionContext executionContext = new 
ExecutionContext(Mock(CommandLine))
+        File reportFile = new File(executionContext.baseDir, 
ConfigReportCommand.DEFAULT_REPORT_FILE)
+
+        when: 'the command is executed'
+        command.handle(executionContext)
+        String content = reportFile.text
+
+        then: 'YAML-defined properties are present in the report'
+        content.contains('`myapp.yaml.greeting`')
+        content.contains('`Hello from YAML`')
+
+        and: 'YAML numeric properties are present'
+        content.contains('`myapp.yaml.maxRetries`')
+        content.contains('`5`')
+
+        and: 'YAML nested properties are present'
+        content.contains('`myapp.yaml.feature.enabled`')
+        content.contains('`true`')
+        content.contains('`myapp.yaml.feature.timeout`')
+        content.contains('`30000`')
+
+        and: 'standard Grails YAML properties are present'
+        content.contains('`grails.profile`')
+        content.contains('`web`')
+
+        cleanup:
+        reportFile?.delete()
+    }
+
+    def "report contains properties from application.groovy"() {
+        given: 'a ConfigReportCommand wired to the live application context'
+        ConfigReportCommand command = createCommand()
+        ExecutionContext executionContext = new 
ExecutionContext(Mock(CommandLine))
+        File reportFile = new File(executionContext.baseDir, 
ConfigReportCommand.DEFAULT_REPORT_FILE)
+
+        when: 'the command is executed'
+        command.handle(executionContext)
+        String content = reportFile.text
+
+        then: 'Groovy config properties are present in the report'
+        content.contains('`myapp.groovy.appName`')
+        content.contains('`Config Report Test App`')
+
+        and: 'Groovy config version property is present'
+        content.contains('`myapp.groovy.version`')
+        content.contains('`1.2.3`')
+
+        cleanup:
+        reportFile?.delete()
+    }
+
+    def "report escapes pipe characters from application.groovy values"() {
+        given: 'a ConfigReportCommand wired to the live application context'
+        ConfigReportCommand command = createCommand()
+        ExecutionContext executionContext = new 
ExecutionContext(Mock(CommandLine))
+        File reportFile = new File(executionContext.baseDir, 
ConfigReportCommand.DEFAULT_REPORT_FILE)
+
+        when: 'the command is executed'
+        command.handle(executionContext)
+        String content = reportFile.text
+
+        then: 'pipe characters are escaped for valid AsciiDoc'
+        content.contains('`myapp.groovy.delimitedValue`')
+        content.contains('value1\\|value2\\|value3')
+        !content.contains('value1|value2|value3')
+
+        cleanup:
+        reportFile?.delete()
+    }
+
+    def "report contains properties bound via @ConfigurationProperties"() {
+        given: 'a ConfigReportCommand wired to the live application context'
+        ConfigReportCommand command = createCommand()
+        ExecutionContext executionContext = new 
ExecutionContext(Mock(CommandLine))
+        File reportFile = new File(executionContext.baseDir, 
ConfigReportCommand.DEFAULT_REPORT_FILE)
+
+        when: 'the command is executed'
+        command.handle(executionContext)
+        String content = reportFile.text
+
+        then: 'the @ConfigurationProperties bean was correctly populated'
+        appProperties.name == 'Configured App'
+        appProperties.pageSize == 50
+        appProperties.debugEnabled == true
+
+        and: 'the typed properties appear in the config report'
+        content.contains('`myapp.typed.name`')
+        content.contains('`Configured App`')
+        content.contains('`myapp.typed.pageSize`')
+        content.contains('`50`')
+        content.contains('`myapp.typed.debugEnabled`')
+        content.contains('`true`')
+
+        cleanup:
+        reportFile?.delete()
+    }
+
+    def "report groups properties by top-level namespace"() {
+        given: 'a ConfigReportCommand wired to the live application context'
+        ConfigReportCommand command = createCommand()
+        ExecutionContext executionContext = new 
ExecutionContext(Mock(CommandLine))
+        File reportFile = new File(executionContext.baseDir, 
ConfigReportCommand.DEFAULT_REPORT_FILE)
+
+        when: 'the command is executed'
+        command.handle(executionContext)
+        String content = reportFile.text
+
+        then: 'properties are organized into namespace sections'
+        content.contains('== grails')
+        content.contains('== myapp')
+        content.contains('== dataSource')
+
+        and: 'sections are in alphabetical order'
+        content.indexOf('== dataSource') < content.indexOf('== grails')
+        content.indexOf('== grails') < content.indexOf('== myapp')
+
+        cleanup:
+        reportFile?.delete()
+    }
+
+    def "report contains properties from all three config sources 
simultaneously"() {
+        given: 'a ConfigReportCommand wired to the live application context'
+        ConfigReportCommand command = createCommand()
+        ExecutionContext executionContext = new 
ExecutionContext(Mock(CommandLine))
+        File reportFile = new File(executionContext.baseDir, 
ConfigReportCommand.DEFAULT_REPORT_FILE)
+
+        when: 'the command is executed'
+        command.handle(executionContext)
+        String content = reportFile.text
+
+        then: 'YAML properties are present'
+        content.contains('`myapp.yaml.greeting`')
+
+        and: 'Groovy properties are present'
+        content.contains('`myapp.groovy.appName`')
+
+        and: 'typed @ConfigurationProperties are present'
+        content.contains('`myapp.typed.name`')
+
+        and: 'all properties are in the same myapp section'
+        int myappSectionIndex = content.indexOf('== myapp')
+        myappSectionIndex >= 0
+
+        and: 'each table row has the correct AsciiDoc format'
+        content.contains('| `myapp.yaml.greeting`')
+        content.contains('| `Hello from YAML`')
+        content.contains('| `myapp.groovy.appName`')
+        content.contains('| `Config Report Test App`')
+        content.contains('| `myapp.typed.name`')
+        content.contains('| `Configured App`')
+
+        cleanup:
+        reportFile?.delete()
+    }
+
+}
diff --git 
a/grails-test-examples/config-report/src/main/groovy/configreport/AppProperties.groovy
 
b/grails-test-examples/config-report/src/main/groovy/configreport/AppProperties.groovy
new file mode 100644
index 0000000000..80e0b34048
--- /dev/null
+++ 
b/grails-test-examples/config-report/src/main/groovy/configreport/AppProperties.groovy
@@ -0,0 +1,52 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package configreport
+
+import groovy.transform.CompileStatic
+import org.springframework.boot.context.properties.ConfigurationProperties
+import org.springframework.validation.annotation.Validated
+
+/**
+ * A Spring Boot {@code @ConfigurationProperties} bean that binds to
+ * the {@code myapp.typed} prefix.
+ *
+ * <p>Properties for this bean are defined in {@code application.yml}
+ * and verified in the ConfigReportCommand integration test.
+ */
+@CompileStatic
+@Validated
+@ConfigurationProperties(prefix = 'myapp.typed')
+class AppProperties {
+
+    /**
+     * The display name of the application.
+     */
+    String name = 'Default App'
+
+    /**
+     * The maximum number of items per page.
+     */
+    Integer pageSize = 25
+
+    /**
+     * Whether debug mode is active.
+     */
+    Boolean debugEnabled = false
+
+}
diff --git a/settings.gradle b/settings.gradle
index 0509aef041..12f8eef8ac 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -378,6 +378,7 @@ include(
         'grails-test-examples-plugins-exploded',
         'grails-test-examples-plugins-issue-11767',
         'grails-test-examples-cache',
+        'grails-test-examples-config-report',
         'grails-test-examples-scaffolding',
         'grails-test-examples-scaffolding-fields',
         'grails-test-examples-views-functional-tests',
@@ -412,6 +413,7 @@ project(':grails-test-examples-issue-15228').projectDir = 
file('grails-test-exam
 project(':grails-test-examples-plugins-exploded').projectDir = 
file('grails-test-examples/plugins/exploded')
 project(':grails-test-examples-plugins-issue-11767').projectDir = 
file('grails-test-examples/plugins/issue-11767')
 project(':grails-test-examples-cache').projectDir = 
file('grails-test-examples/cache')
+project(':grails-test-examples-config-report').projectDir = 
file('grails-test-examples/config-report')
 project(':grails-test-examples-scaffolding').projectDir = 
file('grails-test-examples/scaffolding')
 project(':grails-test-examples-scaffolding-fields').projectDir = 
file('grails-test-examples/scaffolding-fields')
 project(':grails-test-examples-views-functional-tests').projectDir = 
file('grails-test-examples/views-functional-tests')


Reply via email to