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 <[email protected]>
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')


Reply via email to