You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by jd...@apache.org on 2026/02/19 16:09:51 UTC

(grails-core) branch configuration-command created (now 6748b33404)

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

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


      at 6748b33404 proof of concept for a config report command

This branch includes the following new commits:

     new 6748b33404 proof of concept for a config report command

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(grails-core) 01/01: proof of concept for a config report command

Posted by jd...@apache.org.
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 6748b33404f08b23e0d1d283e892b1446cc99585
Author: James Daugherty <jd...@jdresources.net>
AuthorDate: Thu Feb 19 11:09:38 2026 -0500

    proof of concept for a config report command
---
 .../grails/dev/commands/ConfigReportCommand.groovy | 123 ++++++++++
 .../dev/commands/ConfigReportCommandSpec.groovy    | 184 +++++++++++++++
 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, 837 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..07c5afb99a
--- /dev/null
+++ b/grails-core/src/main/groovy/grails/dev/commands/ConfigReportCommand.groovy
@@ -0,0 +1,123 @@
+/*
+ *  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 grails.core.GrailsApplication
+
+/**
+ * An {@link ApplicationCommand} that generates an AsciiDoc report
+ * of the application's resolved configuration properties.
+ *
+ * <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 {
+            GrailsApplication grailsApplication = applicationContext.getBean(GrailsApplication)
+            Properties properties = grailsApplication.config.toProperties()
+            Map<String, String> sorted = new TreeMap<String, String>()
+            properties.each { Object key, Object value ->
+                sorted.put(key.toString(), value.toString())
+            }
+
+            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
+        }
+    }
+
+    /**
+     * 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..f4a870564d
--- /dev/null
+++ b/grails-core/src/test/groovy/grails/dev/commands/ConfigReportCommandSpec.groovy
@@ -0,0 +1,184 @@
+/*
+ *  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 grails.config.Config
+import grails.core.GrailsApplication
+import org.grails.build.parsing.CommandLine
+import spock.lang.Specification
+import spock.lang.TempDir
+
+class ConfigReportCommandSpec extends Specification {
+
+    @TempDir
+    File tempDir
+
+    ConfigReportCommand command
+
+    GrailsApplication grailsApplication
+
+    ConfigurableApplicationContext applicationContext
+
+    def setup() {
+        grailsApplication = Mock(GrailsApplication)
+        applicationContext = Mock(ConfigurableApplicationContext)
+        applicationContext.getBean(GrailsApplication) >> grailsApplication
+
+        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:
+        Properties props = new Properties()
+        props.setProperty('grails.profile', 'web')
+        props.setProperty('grails.codegen.defaultPackage', 'myapp')
+        props.setProperty('server.port', '8080')
+        props.setProperty('spring.main.banner-mode', 'off')
+
+        Config config = Mock(Config)
+        config.toProperties() >> props
+        grailsApplication.getConfig() >> config
+
+        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:
+        applicationContext.getBean(GrailsApplication) >> { throw new RuntimeException('test error') }
+        ExecutionContext executionContext = new ExecutionContext(Mock(CommandLine))
+
+        when:
+        boolean result = command.handle(executionContext)
+
+        then:
+        !result
+    }
+
+    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')