Repository: gruntwork-io/terragrunt

Issue:
Predecessor for `output-module-groups` is not giving expected results (order)
## Describe the bug

Running `output-module-groups` command outputs one result while using its predecessor `find --json` (or `find --json --dag` or `find --json --depencies`) outputs another result, and by result I mean the order of the units to be applied.

Let's take as an example that we have a Virtual Network (VNet), a Resource group and a Private DNS Zone on Azure as IaC, both VNet and DNS Zone have a dependency on the Resource Group using `dependencies` terragrunt block (since I don't really need the outputs for this Resource Group).

If I run `output-module-groups` it does output me with:
```json
{
    "Group 1": [
        "/path/to/resource-group"
    ],
    "Group 2": [
        "/path/to/private-dns-zone",
        "/path/to/vnet"
    ]
}
```

When I run `find --json` or any other of the options I previously mentioned (all returns the same output, even using `--dependencies`):
```json
[
    {
        "type": "unit",
        "path": "path/to/private-dns-zone"
    },
    {
        "type": "unit",
        "path": "path/to/resource-group"
    },
    {
        "type": "unit",
        "path": "path/to/vnet"
    }
]
```

If I use the `dependencies` block, I cannot get it to output its dependencies nor order, but if I use `dependency` block it does work as expected.

## Steps To Reproduce

Steps to reproduce are exactly on a repo I created:
https://github.com/andresousadotpt/terragrunt-issues/tree/issue-4427

Just run `./run.sh` and it should output all possible ways to use the commands mentioned above.

Consider that the `.tofu` code is a random example from terraform documentation, and it is not showing a real example, but I feel like this is enough. Please let me know if it is not.

## Expected behavior

Running `$ terragrunt find --json` should give the order of applies (based on dependencies) or at least on the `$ terragrunt find --json --dependencies` show the list of dependencies.

## Versions

- terragrunt version 0.81.6
- OpenTofu v1.9.1
- MacOS Sequoia


Discussion:
@yhakbar: Thanks for the easy reproduction! It really helps to make sure we're seeing the same issue.

I can confirm this is reproducible, and that it seems like the `find` command is not respecting `dependencies` correctly. Will look to resolve.

Solution patch:
diff --git a/cli/commands/common/graph/graph.go b/cli/commands/common/graph/graph.go
index 31bda25d..8d2f9d4f 100644
--- a/cli/commands/common/graph/graph.go
+++ b/cli/commands/common/graph/graph.go
@@ -60,8 +60,8 @@ func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) err
 			r.WithFormat(opts.ReportFormat)
 		}
 
-		if opts.SummaryUnitDuration {
-			r.WithShowUnitTiming()
+		if opts.SummaryPerUnit {
+			r.WithShowUnitLevelSummary()
 		}
 
 		stackOpts = append(stackOpts, configstack.WithReport(r))
diff --git a/cli/commands/common/runall/runall.go b/cli/commands/common/runall/runall.go
index ccb3a473..e39de94e 100644
--- a/cli/commands/common/runall/runall.go
+++ b/cli/commands/common/runall/runall.go
@@ -61,8 +61,8 @@ func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) err
 			r.WithFormat(opts.ReportFormat)
 		}
 
-		if opts.SummaryUnitDuration {
-			r.WithShowUnitTiming()
+		if opts.SummaryPerUnit {
+			r.WithShowUnitLevelSummary()
 		}
 
 		stackOpts = append(stackOpts, configstack.WithReport(r))
diff --git a/cli/commands/run/flags.go b/cli/commands/run/flags.go
index c81f55bc..793aec9a 100644
--- a/cli/commands/run/flags.go
+++ b/cli/commands/run/flags.go
@@ -29,7 +29,7 @@ const (
 	UnitsThatIncludeFlagName               = "units-that-include"
 	DependencyFetchOutputFromStateFlagName = "dependency-fetch-output-from-state"
 	UsePartialParseConfigCacheFlagName     = "use-partial-parse-config-cache"
-	SummaryUnitDurationFlagName            = "summary-unit-duration"
+	SummaryPerUnitFlagName                 = "summary-per-unit"
 
 	BackendBootstrapFlagName        = "backend-bootstrap"
 	BackendRequireBootstrapFlagName = "backend-require-bootstrap"
@@ -550,9 +550,9 @@ func NewFlags(l log.Logger, opts *options.TerragruntOptions, prefix flags.Prefix
 		}),
 
 		flags.NewFlag(&cli.BoolFlag{
-			Name:        SummaryUnitDurationFlagName,
-			EnvVars:     tgPrefix.EnvVars(SummaryUnitDurationFlagName),
-			Destination: &opts.SummaryUnitDuration,
+			Name:        SummaryPerUnitFlagName,
+			EnvVars:     tgPrefix.EnvVars(SummaryPerUnitFlagName),
+			Destination: &opts.SummaryPerUnit,
 			Usage:       `Show duration information for each unit in the summary output.`,
 		}),
 
diff --git a/docs-starlight/src/content/docs/02-features/16-run-report.mdx b/docs-starlight/src/content/docs/02-features/16-run-report.mdx
index 228b0502..273b1be1 100644
--- a/docs-starlight/src/content/docs/02-features/16-run-report.mdx
+++ b/docs-starlight/src/content/docs/02-features/16-run-report.mdx
@@ -25,10 +25,9 @@ $ terragrunt run --all plan
 
 # Omitted for brevity...
 
-❯❯ Run Summary
-   Duration:   62ms
-   Units:      3
-   Succeeded:  3
+❯❯ Run Summary  3 units  62ms
+   ────────────────────────────
+   Succeeded    3
 ```
 
 This output is called the "Run Summary". It provides at-a-glance information about the run that was just performed, including the following (as relevant):
@@ -42,20 +41,19 @@ This output is called the "Run Summary". It provides at-a-glance information abo
 
 ### Showing Unit Durations
 
-You can enable showing the duration of each unit in the run summary by using the `--summary-unit-duration` flag.
+You can enable showing the duration of each unit in the run summary by using the `--summary-per-unit` flag.
 
 ```bash
-$ terragrunt run --all plan --summary-unit-duration
+$ terragrunt run --all plan --summary-per-unit
 
 # Omitted for brevity...
 
-❯❯ Run Summary
-   Duration:   10m
-      long-running-unit:    10m
-      medium-running-unit:  12s
-      short-running-unit:   5ms
-   Units:      3
-   Succeeded:  3
+❯❯ Run Summary  3 units     10m
+   ──────────────────────────────
+   Succeeded (3)
+      long-running-unit     10m
+      medium-running-unit   12s
+      short-running-unit    5ms
 ```
 
 The units are sorted by duration, with the longest-running units shown first.
diff --git a/docs-starlight/src/data/commands/run.mdx b/docs-starlight/src/data/commands/run.mdx
index 3cd6411d..532e28ff 100644
--- a/docs-starlight/src/data/commands/run.mdx
+++ b/docs-starlight/src/data/commands/run.mdx
@@ -69,7 +69,7 @@ flags:
   - source-map
   - source-update
   - summary-disable
-  - summary-unit-duration
+  - summary-per-unit
   - tf-forward-stdout
   - tf-path
   - units-that-include
diff --git a/docs-starlight/src/data/flags/summary-per-unit.mdx b/docs-starlight/src/data/flags/summary-per-unit.mdx
new file mode 100644
index 00000000..1860e1ed
--- /dev/null
+++ b/docs-starlight/src/data/flags/summary-per-unit.mdx
@@ -0,0 +1,11 @@
+---
+name: summary-per-unit
+description: Summarize each unit in the run summary.
+type: bool
+env:
+  - TG_SUMMARY_PER_UNIT
+---
+
+When enabled, Terragrunt will break down the run summary by unit. The units are sorted by result, then duration, with the longest-running units shown first.
+
+For more information, see the [Run Report](/docs/features/run-report) feature.
diff --git a/docs-starlight/src/data/flags/summary-unit-duration.mdx b/docs-starlight/src/data/flags/summary-unit-duration.mdx
deleted file mode 100644
index ef649780..00000000
--- a/docs-starlight/src/data/flags/summary-unit-duration.mdx
+++ /dev/null
@@ -1,11 +0,0 @@
----
-name: summary-unit-duration
-description: Shows the duration of each unit in the run summary.
-type: bool
-env:
-  - TG_SUMMARY_UNIT_DURATION
----
-
-When enabled, Terragrunt will show the duration of each unit in the run summary. The units are sorted by duration, with the longest-running units shown first.
-
-For more information, see the [Run Report](/docs/features/run-report) feature.
diff --git a/docs/_docs/02_features/16-run-report.md b/docs/_docs/02_features/16-run-report.md
index 21a08604..2044631e 100644
--- a/docs/_docs/02_features/16-run-report.md
+++ b/docs/_docs/02_features/16-run-report.md
@@ -26,10 +26,9 @@ $ terragrunt run --all plan
 
 # Omitted for brevity...
 
-❯❯ Run Summary
-   Duration:   62ms
-   Units:      3
-   Succeeded:  3
+❯❯ Run Summary  3 units  62ms
+   ────────────────────────────
+   Succeeded    3
 ```
 
 This output is called the "Run Summary". It provides at-a-glance information about the run that was just performed, including the following (as relevant):
@@ -43,20 +42,19 @@ This output is called the "Run Summary". It provides at-a-glance information abo
 
 ### Showing Unit Durations
 
-You can enable showing the duration of each unit in the run summary by using the `--summary-unit-duration` flag.
+You can enable showing the duration of each unit in the run summary by using the `--summary-per-unit` flag.
 
 ```bash
-$ terragrunt run --all plan --summary-unit-duration
+$ terragrunt run --all plan --summary-per-unit
 
 # Omitted for brevity...
 
-❯❯ Run Summary
-   Duration:   10m
-      long-running-unit:    10m
-      medium-running-unit:  12s
-      short-running-unit:   5ms
-   Units:      3
-   Succeeded:  3
+❯❯ Run Summary  3 units     10m
+   ──────────────────────────────
+   Succeeded (3)
+      long-running-unit     10m
+      medium-running-unit   12s
+      short-running-unit    5ms
 ```
 
 The units are sorted by duration, with the longest-running units shown first.
diff --git a/docs/_docs/04_reference/02-cli-options.md b/docs/_docs/04_reference/02-cli-options.md
index 767d9ac3..3a7af906 100644
--- a/docs/_docs/04_reference/02-cli-options.md
+++ b/docs/_docs/04_reference/02-cli-options.md
@@ -1710,12 +1710,12 @@ When passed in, disable the summary output at the end of a run.
 
 For more information, see the [Run Report](/docs/features/run-report#disabling-the-summary) feature.
 
-### summary-unit-duration
+### summary-per-unit
 
-**CLI Arg**: `--summary-unit-duration`<br/>
-**Environment Variable**: `TG_SUMMARY_UNIT_DURATION`<br/>
+**CLI Arg**: `--summary-per-unit`<br/>
+**Environment Variable**: `TG_SUMMARY_PER_UNIT`<br/>
 
-When enabled, Terragrunt will show the duration of each unit in the run summary. The units are sorted by duration, with the longest-running units shown first.
+When enabled, Terragrunt will break down the run summary by unit. The units are sorted by result, then duration, with the longest-running units shown first.
 
 For more information, see the [Run Report](/docs/features/run-report) feature.
 
diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go
index 49b5adac..3ffab209 100644
--- a/internal/discovery/discovery.go
+++ b/internal/discovery/discovery.go
@@ -511,7 +511,7 @@ func (d *DependencyDiscovery) DiscoverDependencies(ctx context.Context, l log.Lo
 		depPaths = append(depPaths, depPath)
 	}
 
-	if dCfg.Dependencies != nil {
+	if dCfg.Parsed.Dependencies != nil {
 		for _, dependency := range dCfg.Parsed.Dependencies.Paths {
 			if !filepath.IsAbs(dependency) {
 				dependency = filepath.Join(dCfg.Path, dependency)
diff --git a/internal/report/colors.go b/internal/report/colors.go
new file mode 100644
index 00000000..3725e25a
--- /dev/null
+++ b/internal/report/colors.go
@@ -0,0 +1,107 @@
+package report
+
+import (
+	"fmt"
+	"os"
+	"time"
+
+	"github.com/mgutz/ansi"
+)
+
+// Colorizer is a colorizer for the run summary output.
+type Colorizer struct {
+	headingColorizer     func(string) string
+	successColorizer     func(string) string
+	failureColorizer     func(string) string
+	exitColorizer        func(string) string
+	excludeColorizer     func(string) string
+	successUnitColorizer func(string) string
+	failureUnitColorizer func(string) string
+	exitUnitColorizer    func(string) string
+	excludeUnitColorizer func(string) string
+	nanosecondColorizer  func(string) string
+	microsecondColorizer func(string) string
+	millisecondColorizer func(string) string
+	secondColorizer      func(string) string
+	minuteColorizer      func(string) string
+	defaultColorizer     func(string) string
+}
+
+// NewColorizer creates a new Colorizer.
+func NewColorizer(shouldColor bool) *Colorizer {
+	// Check if unit colorizing is enabled via environment variable
+	shouldColorizeUnits := shouldColor && os.Getenv(envTmpUndocumentedReportUnitColorize) != ""
+
+	if !shouldColor {
+		return &Colorizer{
+			headingColorizer:     func(s string) string { return s },
+			successColorizer:     func(s string) string { return s },
+			failureColorizer:     func(s string) string { return s },
+			exitColorizer:        func(s string) string { return s },
+			excludeColorizer:     func(s string) string { return s },
+			successUnitColorizer: func(s string) string { return s },
+			failureUnitColorizer: func(s string) string { return s },
+			exitUnitColorizer:    func(s string) string { return s },
+			excludeUnitColorizer: func(s string) string { return s },
+			nanosecondColorizer:  func(s string) string { return s },
+			microsecondColorizer: func(s string) string { return s },
+			millisecondColorizer: func(s string) string { return s },
+			secondColorizer:      func(s string) string { return s },
+			minuteColorizer:      func(s string) string { return s },
+			defaultColorizer:     func(s string) string { return s },
+		}
+	}
+
+	// Define unit colorizers based on environment variable
+	var successUnitColorizer, failureUnitColorizer, exitUnitColorizer, excludeUnitColorizer func(string) string
+	if shouldColorizeUnits {
+		successUnitColorizer = ansi.ColorFunc("green+h")
+		failureUnitColorizer = ansi.ColorFunc("red+h")
+		exitUnitColorizer = ansi.ColorFunc("yellow+h")
+		excludeUnitColorizer = ansi.ColorFunc("blue+h")
+	} else {
+		successUnitColorizer = func(s string) string { return s }
+		failureUnitColorizer = func(s string) string { return s }
+		exitUnitColorizer = func(s string) string { return s }
+		excludeUnitColorizer = func(s string) string { return s }
+	}
+
+	return &Colorizer{
+		headingColorizer:     ansi.ColorFunc("yellow+bh"),
+		successColorizer:     ansi.ColorFunc("green+bh"),
+		failureColorizer:     ansi.ColorFunc("red+bh"),
+		exitColorizer:        ansi.ColorFunc("yellow+bh"),
+		excludeColorizer:     ansi.ColorFunc("blue+bh"),
+		successUnitColorizer: successUnitColorizer,
+		failureUnitColorizer: failureUnitColorizer,
+		exitUnitColorizer:    exitUnitColorizer,
+		excludeUnitColorizer: excludeUnitColorizer,
+		nanosecondColorizer:  ansi.ColorFunc("cyan+bh"),
+		microsecondColorizer: ansi.ColorFunc("cyan+bh"),
+		millisecondColorizer: ansi.ColorFunc("cyan+bh"),
+		secondColorizer:      ansi.ColorFunc("green+bh"),
+		minuteColorizer:      ansi.ColorFunc("yellow+bh"),
+		defaultColorizer:     ansi.ColorFunc("white+bh"),
+	}
+}
+
+// colorDuration returns the duration as a string, colored based on the duration.
+func (c *Colorizer) colorDuration(duration time.Duration) string {
+	if duration < time.Microsecond {
+		return c.nanosecondColorizer(fmt.Sprintf("%dns", duration.Nanoseconds()))
+	}
+
+	if duration < time.Millisecond {
+		return c.microsecondColorizer(fmt.Sprintf("%dµs", duration.Microseconds()))
+	}
+
+	if duration < time.Second {
+		return c.millisecondColorizer(fmt.Sprintf("%dms", duration.Milliseconds()))
+	}
+
+	if duration < time.Minute {
+		return c.secondColorizer(fmt.Sprintf("%ds", int(duration.Seconds())))
+	}
+
+	return c.minuteColorizer(fmt.Sprintf("%dm", int(duration.Minutes())))
+}
diff --git a/internal/report/report.go b/internal/report/report.go
index 4f767701..20d71d5d 100644
--- a/internal/report/report.go
+++ b/internal/report/report.go
@@ -2,31 +2,22 @@
 package report
 
 import (
-	"encoding/csv"
-	"encoding/json"
 	"errors"
 	"fmt"
-	"io"
-	"os"
 	"path/filepath"
 	"slices"
-	"strconv"
-	"strings"
 	"sync"
 	"time"
-
-	"github.com/invopop/jsonschema"
-	"github.com/mgutz/ansi"
 )
 
 // Report captures data for a report/summary.
 type Report struct {
-	workingDir     string
-	format         Format
-	Runs           []*Run
-	mu             sync.RWMutex
-	shouldColor    bool
-	showUnitTiming bool
+	workingDir           string
+	format               Format
+	Runs                 []*Run
+	mu                   sync.RWMutex
+	shouldColor          bool
+	showUnitLevelSummary bool
 }
 
 // Run captures data for a run.
@@ -57,89 +48,21 @@ const (
 	FormatJSON Format = "json"
 )
 
-// Summary formats data from a report for output as a summary.
-type Summary struct {
-	firstRunStart  *time.Time
-	lastRunEnd     *time.Time
-	padder         string
-	workingDir     string
-	runs           []*Run
-	UnitsSucceeded int
-	UnitsFailed    int
-	EarlyExits     int
-	Excluded       int
-	shouldColor    bool
-	showUnitTiming bool
-}
-
-// Colorizer is a colorizer for the run summary output.
-type Colorizer struct {
-	headingColorizer     func(string) string
-	successColorizer     func(string) string
-	failureColorizer     func(string) string
-	exitColorizer        func(string) string
-	excludeColorizer     func(string) string
-	nanosecondColorizer  func(string) string
-	microsecondColorizer func(string) string
-	millisecondColorizer func(string) string
-	secondColorizer      func(string) string
-	minuteColorizer      func(string) string
-	defaultColorizer     func(string) string
-}
-
-// NewColorizer creates a new Colorizer.
-func NewColorizer(shouldColor bool) *Colorizer {
-	if !shouldColor {
-		return &Colorizer{
-			headingColorizer:     func(s string) string { return s },
-			successColorizer:     func(s string) string { return s },
-			failureColorizer:     func(s string) string { return s },
-			exitColorizer:        func(s string) string { return s },
-			excludeColorizer:     func(s string) string { return s },
-			nanosecondColorizer:  func(s string) string { return s },
-			microsecondColorizer: func(s string) string { return s },
-			millisecondColorizer: func(s string) string { return s },
-			secondColorizer:      func(s string) string { return s },
-			minuteColorizer:      func(s string) string { return s },
-			defaultColorizer:     func(s string) string { return s },
-		}
-	}
-
-	return &Colorizer{
-		headingColorizer:     ansi.ColorFunc("yellow+bh"),
-		successColorizer:     ansi.ColorFunc("green+bh"),
-		failureColorizer:     ansi.ColorFunc("red+bh"),
-		exitColorizer:        ansi.ColorFunc("yellow+bh"),
-		excludeColorizer:     ansi.ColorFunc("blue+bh"),
-		nanosecondColorizer:  ansi.ColorFunc("cyan+bh"),
-		microsecondColorizer: ansi.ColorFunc("cyan+bh"),
-		millisecondColorizer: ansi.ColorFunc("cyan+bh"),
-		secondColorizer:      ansi.ColorFunc("green+bh"),
-		minuteColorizer:      ansi.ColorFunc("yellow+bh"),
-		defaultColorizer:     ansi.ColorFunc("white+bh"),
-	}
-}
-
-// colorDuration returns the duration as a string, colored based on the duration.
-func (c *Colorizer) colorDuration(duration time.Duration) string {
-	if duration < time.Microsecond {
-		return c.nanosecondColorizer(fmt.Sprintf("%dns", duration.Nanoseconds()))
-	}
-
-	if duration < time.Millisecond {
-		return c.microsecondColorizer(fmt.Sprintf("%dµs", duration.Microseconds()))
-	}
-
-	if duration < time.Second {
-		return c.millisecondColorizer(fmt.Sprintf("%dms", duration.Milliseconds()))
-	}
-
-	if duration < time.Minute {
-		return c.secondColorizer(fmt.Sprintf("%ds", int(duration.Seconds())))
-	}
+const (
+	ResultSucceeded Result = "succeeded"
+	ResultFailed    Result = "failed"
+	ResultEarlyExit Result = "early exit"
+	ResultExcluded  Result = "excluded"
+)
 
-	return c.minuteColorizer(fmt.Sprintf("%dm", int(duration.Minutes())))
-}
+const (
+	ReasonRetrySucceeded Reason = "retry succeeded"
+	ReasonErrorIgnored   Reason = "error ignored"
+	ReasonRunError       Reason = "run error"
+	ReasonExcludeDir     Reason = "--queue-exclude-dir"
+	ReasonExcludeBlock   Reason = "exclude block"
+	ReasonAncestorError  Reason = "ancestor error"
+)
 
 // NewReport creates a new report.
 func NewReport() *Report {
@@ -175,11 +98,11 @@ func (r *Report) WithFormat(format Format) *Report {
 	return r
 }
 
-// WithShowUnitTiming sets the showUnitTiming flag for the report.
+// WithShowUnitLevelSummary sets the showUnitLevelSummary flag for the report.
 //
 // When enabled, the summary of the report will include timings for each unit.
-func (r *Report) WithShowUnitTiming() *Report {
-	r.showUnitTiming = true
+func (r *Report) WithShowUnitLevelSummary() *Report {
+	r.showUnitLevelSummary = true
 
 	return r
 }
@@ -294,13 +217,6 @@ func (r *Report) SortRuns() {
 // EndOption are optional configurations for ending a run.
 type EndOption func(*Run)
 
-const (
-	ResultSucceeded Result = "succeeded"
-	ResultFailed    Result = "failed"
-	ResultEarlyExit Result = "early exit"
-	ResultExcluded  Result = "excluded"
-)
-
 // WithResult sets the result of a run.
 func WithResult(result Result) EndOption {
 	return func(run *Run) {
@@ -308,15 +224,6 @@ func WithResult(result Result) EndOption {
 	}
 }
 
-const (
-	ReasonRetrySucceeded Reason = "retry succeeded"
-	ReasonErrorIgnored   Reason = "error ignored"
-	ReasonRunError       Reason = "run error"
-	ReasonExcludeDir     Reason = "--queue-exclude-dir"
-	ReasonExcludeBlock   Reason = "exclude block"
-	ReasonAncestorError  Reason = "ancestor error"
-)
-
 // WithReason sets the reason of a run.
 func WithReason(reason Reason) EndOption {
 	return func(run *Run) {
@@ -371,535 +278,3 @@ func withCause(name string) EndOption {
 		run.Cause = &cause
 	}
 }
-
-// These are undocumented temporary environment variables that are used
-// to play with the summary, so that we can experiment with it.
-const (
-	envTmpUndocumentedReportPadder = "TMP_UNDOCUMENTED_REPORT_PADDER"
-)
-
-// Summarize returns a summary of the report.
-func (r *Report) Summarize() *Summary {
-	summary := &Summary{
-		workingDir:     r.workingDir,
-		shouldColor:    r.shouldColor,
-		showUnitTiming: r.showUnitTiming,
-		padder:         " ",
-		runs:           r.Runs,
-	}
-
-	if os.Getenv(envTmpUndocumentedReportPadder) != "" {
-		summary.padder = os.Getenv(envTmpUndocumentedReportPadder)
-	}
-
-	if len(r.Runs) == 0 {
-		return summary
-	}
-
-	for _, run := range r.Runs {
-		summary.Update(run)
-	}
-
-	return summary
-}
-
-func (s *Summary) TotalUnits() int {
-	return len(s.runs)
-}
-
-func (s *Summary) Update(run *Run) {
-	run.mu.RLock()
-	defer run.mu.RUnlock()
-
-	switch run.Result {
-	case ResultSucceeded:
-		s.UnitsSucceeded++
-	case ResultFailed:
-		s.UnitsFailed++
-	case ResultEarlyExit:
-		s.EarlyExits++
-	case ResultExcluded:
-		s.Excluded++
-	}
-
-	if s.firstRunStart == nil || run.Started.Before(*s.firstRunStart) {
-		s.firstRunStart = &run.Started
-	}
-
-	if s.lastRunEnd == nil || run.Ended.After(*s.lastRunEnd) {
-		s.lastRunEnd = &run.Ended
-	}
-}
-
-// TotalDuration returns the total duration of all runs in the report.
-func (s *Summary) TotalDuration() time.Duration {
-	if s.firstRunStart == nil || s.lastRunEnd == nil {
-		return 0
-	}
-
-	return s.lastRunEnd.Sub(*s.firstRunStart)
-}
-
-// TotalDurationString returns the total duration of all runs in the report as a string.
-// It returns the duration in the format that is easy to understand by humans.
-func (s *Summary) TotalDurationString(colorizer *Colorizer) string {
-	duration := s.TotalDuration()
-
-	return colorizer.colorDuration(duration)
-}
-
-// WriteToFile writes the report to a file.
-func (r *Report) WriteToFile(path string) error {
-	// Create a temporary file to write to
-	tmpFile, err := os.CreateTemp("", "terragrunt-report-*")
-	if err != nil {
-		return err
-	}
-
-	// Sort the runs before writing to the temporary file
-	r.mu.Lock()
-	r.SortRuns()
-	r.mu.Unlock()
-
-	// Write the report to the temporary file
-	switch r.format {
-	case FormatCSV:
-		err = r.WriteCSV(tmpFile)
-	case FormatJSON:
-		err = r.WriteJSON(tmpFile)
-	default:
-		return fmt.Errorf("unsupported format: %s", r.format)
-	}
-
-	if err != nil {
-		return fmt.Errorf("failed to write report: %w", err)
-	}
-
-	// Close the temporary file
-	if err := tmpFile.Close(); err != nil {
-		return fmt.Errorf("failed to close report file: %w", err)
-	}
-
-	if r.workingDir != "" && !filepath.IsAbs(path) {
-		path = filepath.Join(r.workingDir, path)
-	}
-
-	// Move the temporary file to the final destination
-	return os.Rename(tmpFile.Name(), path)
-}
-
-// WriteCSV writes the report to a writer in CSV format.
-func (r *Report) WriteCSV(w io.Writer) error {
-	r.mu.RLock()
-	defer r.mu.RUnlock()
-
-	csvWriter := csv.NewWriter(w)
-	defer csvWriter.Flush()
-
-	err := csvWriter.Write([]string{
-		"Name",
-		"Started",
-		"Ended",
-		"Result",
-		"Reason",
-		"Cause",
-	})
-	if err != nil {
-		return err
-	}
-
-	for _, run := range r.Runs {
-		run.mu.RLock()
-		defer run.mu.RUnlock()
-
-		name := run.Path
-
-		if r.workingDir != "" {
-			name = strings.TrimPrefix(name, r.workingDir+string(os.PathSeparator))
-		}
-
-		started := run.Started.Format(time.RFC3339)
-		ended := run.Ended.Format(time.RFC3339)
-		result := string(run.Result)
-		reason := ""
-
-		if run.Reason != nil {
-			reason = string(*run.Reason)
-		}
-
-		cause := ""
-		if run.Cause != nil {
-			cause = string(*run.Cause)
-
-			if reason == string(ReasonAncestorError) && r.workingDir != "" {
-				cause = strings.TrimPrefix(cause, r.workingDir+string(os.PathSeparator))
-			}
-		}
-
-		err := csvWriter.Write([]string{
-			name,
-			started,
-			ended,
-			result,
-			reason,
-			cause,
-		})
-		if err != nil {
-			return err
-		}
-	}
-
-	return nil
-}
-
-// JSONRun represents a run in JSON format.
-type JSONRun struct {
-	// Started is the time when the run started.
-	Started time.Time `json:"Started" jsonschema:"required"`
-	// Ended is the time when the run ended.
-	Ended time.Time `json:"Ended" jsonschema:"required"`
-	// Reason is the reason for the run result, if any.
-	Reason *string `json:"Reason,omitempty" jsonschema:"enum=retry succeeded,enum=error ignored,enum=run error,enum=--queue-exclude-dir,enum=exclude block,enum=ancestor error"`
-	// Cause is the cause of the run result, if any.
-	Cause *string `json:"Cause,omitempty"`
-	// Name is the name of the run.
-	Name string `json:"Name" jsonschema:"required"`
-	// Result is the result of the run.
-	Result string `json:"Result" jsonschema:"required,enum=succeeded,enum=failed,enum=early exit,enum=excluded"`
-}
-
-// WriteJSON writes the report to a writer in JSON format.
-func (r *Report) WriteJSON(w io.Writer) error {
-	r.mu.RLock()
-	defer r.mu.RUnlock()
-
-	runs := make([]JSONRun, 0, len(r.Runs))
-
-	for _, run := range r.Runs {
-		run.mu.RLock()
-		defer run.mu.RUnlock()
-
-		name := run.Path
-		if r.workingDir != "" {
-			name = strings.TrimPrefix(name, r.workingDir+string(os.PathSeparator))
-		}
-
-		jsonRun := JSONRun{
-			Name:    name,
-			Started: run.Started,
-			Ended:   run.Ended,
-			Result:  string(run.Result),
-		}
-
-		if run.Reason != nil {
-			reason := string(*run.Reason)
-			jsonRun.Reason = &reason
-		}
-
-		if run.Cause != nil {
-			cause := string(*run.Cause)
-			if run.Reason != nil && *run.Reason == ReasonAncestorError && r.workingDir != "" {
-				cause = strings.TrimPrefix(cause, r.workingDir+string(os.PathSeparator))
-			}
-
-			jsonRun.Cause = &cause
-		}
-
-		runs = append(runs, jsonRun)
-	}
-
-	jsonBytes, err := json.MarshalIndent(runs, "", "  ")
-	if err != nil {
-		return err
-	}
-
-	jsonBytes = append(jsonBytes, '\n')
-
-	_, err = w.Write(jsonBytes)
-
-	return err
-}
-
-// WriteSchemaToFile writes a JSON schema for the report to a file.
-func (r *Report) WriteSchemaToFile(path string) error {
-	// Create a temporary file to write to
-	tmpFile, err := os.CreateTemp("", "terragrunt-schema-*")
-	if err != nil {
-		return err
-	}
-
-	// Write the schema to the temporary file
-	if err := r.WriteSchema(tmpFile); err != nil {
-		return fmt.Errorf("failed to write schema: %w", err)
-	}
-
-	// Close the temporary file
-	if err := tmpFile.Close(); err != nil {
-		return fmt.Errorf("failed to close schema file: %w", err)
-	}
-
-	if r.workingDir != "" && !filepath.IsAbs(path) {
-		path = filepath.Join(r.workingDir, path)
-	}
-
-	// Move the temporary file to the final destination
-	return os.Rename(tmpFile.Name(), path)
-}
-
-// WriteSchema writes a JSON schema for the report to a writer.
-func (r *Report) WriteSchema(w io.Writer) error {
-	// Create a new reflector
-	reflector := jsonschema.Reflector{
-		// Add descriptions from Go comments
-		DoNotReference: true,
-	}
-
-	// Generate the schema for JSONRun
-	schema := reflector.Reflect(&JSONRun{})
-
-	schema.Description = "Schema for Terragrunt run report"
-	schema.Title = "Terragrunt Run Report Schema"
-	schema.ID = "https://terragrunt.gruntwork.io/schemas/run/report/v1/schema.json"
-
-	arraySchema := &jsonschema.Schema{
-		Type:        "array",
-		Title:       "Terragrunt Run Report Schema",
-		Description: "Array of Terragrunt runs",
-		Items:       schema,
-	}
-
-	// Marshal the schema to JSON
-	jsonBytes, err := json.MarshalIndent(arraySchema, "", "  ")
-	if err != nil {
-		return err
-	}
-
-	// Add a newline at the end
-	jsonBytes = append(jsonBytes, '\n')
-
-	// Write the schema
-	_, err = w.Write(jsonBytes)
-
-	return err
-}
-
-// WriteSummary writes the summary to a writer.
-func (r *Report) WriteSummary(w io.Writer) error {
-	// Create a line gap before the summary
-	_, err := fmt.Fprintf(w, "\n")
-	if err != nil {
-		return err
-	}
-
-	// Write the summary
-	err = r.Summarize().Write(w)
-	if err != nil {
-		return err
-	}
-
-	// Write a line gap after the summary
-	_, err = fmt.Fprintf(w, "\n")
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
-
-// Write writes the summary to a writer.
-func (s *Summary) Write(w io.Writer) error {
-	colorizer := NewColorizer(s.shouldColor)
-
-	if err := s.writeSummaryHeader(w, colorizer.headingColorizer(runSummaryHeader)); err != nil {
-		return err
-	}
-
-	if err := s.writeSummaryEntry(w, durationLabel, s.TotalDurationString(colorizer)); err != nil {
-		return err
-	}
-
-	if s.showUnitTiming {
-		if err := s.writeUnitsTiming(w, colorizer); err != nil {
-			return err
-		}
-	}
-
-	if err := s.writeSummaryEntry(w, unitsLabel, colorizer.defaultColorizer(strconv.Itoa(s.TotalUnits()))); err != nil {
-		return err
-	}
-
-	if s.UnitsSucceeded > 0 {
-		if err := s.writeSummaryEntry(w, successLabel, colorizer.successColorizer(strconv.Itoa(s.UnitsSucceeded))); err != nil {
-			return err
-		}
-	}
-
-	if s.UnitsFailed > 0 {
-		if err := s.writeSummaryEntry(w, failureLabel, colorizer.failureColorizer(strconv.Itoa(s.UnitsFailed))); err != nil {
-			return err
-		}
-	}
-
-	if s.EarlyExits > 0 {
-		if err := s.writeSummaryEntry(w, earlyExitLabel, colorizer.exitColorizer(strconv.Itoa(s.EarlyExits))); err != nil {
-			return err
-		}
-	}
-
-	if s.Excluded > 0 {
-		if err := s.writeSummaryEntry(w, excludeLabel, colorizer.excludeColorizer(strconv.Itoa(s.Excluded))); err != nil {
-			return err
-		}
-	}
-
-	return nil
-}
-
-const (
-	prefix               = "   "
-	unitPrefixMultiplier = 2
-	runSummaryHeader     = "❯❯ Run Summary"
-	durationLabel        = "Duration"
-	unitsLabel           = "Units"
-	successLabel         = "Succeeded"
-	failureLabel         = "Failed"
-	earlyExitLabel       = "Early Exits"
-	excludeLabel         = "Excluded"
-	separator            = ": "
-)
-
-func (s *Summary) writeSummaryHeader(w io.Writer, value string) error {
-	_, err := fmt.Fprintf(w, "%s\n", value)
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
-
-func (s *Summary) writeSummaryEntry(w io.Writer, label string, value string) error {
-	_, err := fmt.Fprintf(w, "%s%s%s%s %s\n", prefix, label, separator, s.padding(label), value)
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
-
-func (s *Summary) writeUnitsTiming(w io.Writer, colorizer *Colorizer) error {
-	errs := []error{}
-
-	// Sort the runs by duration, longest first.
-	sortedRuns := slices.Clone(s.runs)
-	slices.SortFunc(sortedRuns, func(a, b *Run) int {
-		aDuration := a.Ended.Sub(a.Started)
-		bDuration := b.Ended.Sub(b.Started)
-
-		return int(bDuration - aDuration)
-	})
-
-	for _, run := range sortedRuns {
-		if err := s.writeUnitTiming(w, run, colorizer); err != nil {
-			errs = append(errs, err)
-		}
-	}
-
-	if len(errs) > 0 {
-		return errors.Join(errs...)
-	}
-
-	return nil
-}
-
-func (s *Summary) writeUnitTiming(w io.Writer, run *Run, colorizer *Colorizer) error {
-	duration := run.Ended.Sub(run.Started)
-
-	name := run.Path
-	if s.workingDir != "" {
-		name = strings.TrimPrefix(name, s.workingDir+string(os.PathSeparator))
-	}
-
-	_, err := fmt.Fprintf(
-		w, "%s%s%s%s %s\n",
-		strings.Repeat(prefix, unitPrefixMultiplier),
-		name,
-		separator,
-		s.unitDurationPadding(name),
-		colorizer.colorDuration(duration),
-	)
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
-
-func (s *Summary) longestLineLength() int {
-	// Start with the length of the labels
-	// That are always present
-	lengths := []int{
-		len(durationLabel),
-		len(unitsLabel),
-	}
-
-	// Add the length of the labels that are only present if there are any runs of that type
-	if s.UnitsSucceeded > 0 {
-		lengths = append(lengths, len(successLabel))
-	}
-
-	if s.UnitsFailed > 0 {
-		lengths = append(lengths, len(failureLabel))
-	}
-
-	if s.EarlyExits > 0 {
-		lengths = append(lengths, len(earlyExitLabel))
-	}
-
-	if s.Excluded > 0 {
-		lengths = append(lengths, len(excludeLabel))
-	}
-
-	// Add the length of the entry prefix to each length
-	for i, length := range lengths {
-		lengths[i] = length + len(prefix)
-	}
-
-	// Account for the separator between the label and the value
-	for i, length := range lengths {
-		lengths[i] = length + len(separator)
-	}
-
-	// Return the longest length
-	return slices.Max(lengths)
-}
-
-func (s *Summary) padding(label string) string {
-	longestLineLength := s.longestLineLength()
-
-	labelLength := len(prefix) + len(label) + len(separator)
-
-	return strings.Repeat(s.padder, longestLineLength-labelLength)
-}
-
-func (s *Summary) longestUnitDurationLineLength() int {
-	names := make([]int, 0, len(s.runs))
-
-	for _, run := range s.runs {
-		name := run.Path
-		if s.workingDir != "" {
-			name = strings.TrimPrefix(name, s.workingDir+string(os.PathSeparator))
-		}
-
-		names = append(names, len(name))
-	}
-
-	return slices.Max(names) + (len(prefix) * unitPrefixMultiplier) + len(separator)
-}
-
-func (s *Summary) unitDurationPadding(name string) string {
-	longestLineLength := s.longestUnitDurationLineLength()
-
-	labelLength := (len(prefix) * unitPrefixMultiplier) + len(name) + len(separator)
-
-	return strings.Repeat(s.padder, longestLineLength-labelLength)
-}
diff --git a/internal/report/summary.go b/internal/report/summary.go
new file mode 100644
index 00000000..818eb8d2
--- /dev/null
+++ b/internal/report/summary.go
@@ -0,0 +1,405 @@
+package report
+
+import (
+	"fmt"
+	"io"
+	"os"
+	"regexp"
+	"slices"
+	"strconv"
+	"strings"
+	"time"
+)
+
+// Summary formats data from a report for output as a summary.
+type Summary struct {
+	firstRunStart        *time.Time
+	lastRunEnd           *time.Time
+	padder               string
+	workingDir           string
+	runs                 []*Run
+	UnitsSucceeded       int
+	UnitsFailed          int
+	EarlyExits           int
+	Excluded             int
+	shouldColor          bool
+	showUnitLevelSummary bool
+}
+
+// These are undocumented temporary environment variables that are used
+// to play with the summary, so that we can experiment with it.
+const (
+	envTmpUndocumentedReportPadder       = "TMP_UNDOCUMENTED_REPORT_PADDER"
+	envTmpUndocumentedReportUnitColorize = "TMP_UNDOCUMENTED_REPORT_UNIT_COLORIZE"
+)
+
+// Summarize returns a summary of the report.
+func (r *Report) Summarize() *Summary {
+	summary := &Summary{
+		workingDir:           r.workingDir,
+		shouldColor:          r.shouldColor,
+		showUnitLevelSummary: r.showUnitLevelSummary,
+		padder:               " ",
+		runs:                 r.Runs,
+	}
+
+	if os.Getenv(envTmpUndocumentedReportPadder) != "" {
+		summary.padder = os.Getenv(envTmpUndocumentedReportPadder)
+	}
+
+	if len(r.Runs) == 0 {
+		return summary
+	}
+
+	for _, run := range r.Runs {
+		summary.Update(run)
+	}
+
+	return summary
+}
+
+func (s *Summary) TotalUnits() int {
+	return len(s.runs)
+}
+
+func (s *Summary) Update(run *Run) {
+	run.mu.RLock()
+	defer run.mu.RUnlock()
+
+	switch run.Result {
+	case ResultSucceeded:
+		s.UnitsSucceeded++
+	case ResultFailed:
+		s.UnitsFailed++
+	case ResultEarlyExit:
+		s.EarlyExits++
+	case ResultExcluded:
+		s.Excluded++
+	}
+
+	if s.firstRunStart == nil || run.Started.Before(*s.firstRunStart) {
+		s.firstRunStart = &run.Started
+	}
+
+	if s.lastRunEnd == nil || run.Ended.After(*s.lastRunEnd) {
+		s.lastRunEnd = &run.Ended
+	}
+}
+
+// TotalDuration returns the total duration of all runs in the report.
+func (s *Summary) TotalDuration() time.Duration {
+	if s.firstRunStart == nil || s.lastRunEnd == nil {
+		return 0
+	}
+
+	return s.lastRunEnd.Sub(*s.firstRunStart)
+}
+
+// TotalDurationString returns the total duration of all runs in the report as a string.
+// It returns the duration in the format that is easy to understand by humans.
+func (s *Summary) TotalDurationString(colorizer *Colorizer) string {
+	duration := s.TotalDuration()
+
+	return colorizer.colorDuration(duration)
+}
+
+// WriteSummary writes the summary to a writer.
+func (r *Report) WriteSummary(w io.Writer) error {
+	_, err := fmt.Fprintf(w, "\n")
+	if err != nil {
+		return err
+	}
+
+	err = r.Summarize().Write(w)
+	if err != nil {
+		return err
+	}
+
+	_, err = fmt.Fprintf(w, "\n")
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// Write writes the summary to a writer.
+func (s *Summary) Write(w io.Writer) error {
+	colorizer := NewColorizer(s.shouldColor)
+
+	if s.showUnitLevelSummary {
+		return s.writeUnitLevelSummary(w, colorizer)
+	}
+
+	header := fmt.Sprintf("%s  %d units  %s", runSummaryHeader, s.TotalUnits(), s.TotalDurationString(colorizer))
+	if err := s.writeSummaryHeader(w, colorizer.headingColorizer(header)); err != nil {
+		return err
+	}
+
+	separatorLine := fmt.Sprintf("%s%s", prefix, strings.Repeat("─", separatorLineLength))
+	if err := s.writeSummaryHeader(w, separatorLine); err != nil {
+		return err
+	}
+
+	if s.UnitsSucceeded > 0 {
+		if err := s.writeSummaryEntry(w, colorizer.successColorizer(successLabel), colorizer.successUnitColorizer(strconv.Itoa(s.UnitsSucceeded))); err != nil {
+			return err
+		}
+	}
+
+	if s.UnitsFailed > 0 {
+		if err := s.writeSummaryEntry(w, colorizer.failureColorizer(failureLabel), colorizer.failureUnitColorizer(strconv.Itoa(s.UnitsFailed))); err != nil {
+			return err
+		}
+	}
+
+	if s.EarlyExits > 0 {
+		if err := s.writeSummaryEntry(w, colorizer.exitColorizer(earlyExitLabel), colorizer.exitUnitColorizer(strconv.Itoa(s.EarlyExits))); err != nil {
+			return err
+		}
+	}
+
+	if s.Excluded > 0 {
+		if err := s.writeSummaryEntry(w, colorizer.excludeColorizer(excludeLabel), colorizer.excludeUnitColorizer(strconv.Itoa(s.Excluded))); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+const (
+	prefix                     = "   "
+	unitPrefixMultiplier       = 2
+	runSummaryHeader           = "❯❯ Run Summary"
+	durationLabel              = "Duration"
+	unitsLabel                 = "Units"
+	successLabel               = "Succeeded"
+	failureLabel               = "Failed"
+	earlyExitLabel             = "Early Exits"
+	excludeLabel               = "Excluded"
+	separator                  = "  "
+	separatorLineLength        = 28
+	durationAlignmentOffset    = 4
+	headerUnitCountSpacing     = 2
+	defaultUnitNameLength      = 20
+	headerPaddingAdjustment    = 3
+	separatorPaddingAdjustment = 2
+)
+
+func (s *Summary) writeSummaryHeader(w io.Writer, value string) error {
+	_, err := fmt.Fprintf(w, "%s\n", value)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (s *Summary) writeSummaryEntry(w io.Writer, label string, value string) error {
+	_, err := fmt.Fprintf(w, "%s%s%s%s%s\n", prefix, label, separator, s.padding(label), value)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// writeUnitLevelSummary writes the summary with unit level summaries grouped by categories
+func (s *Summary) writeUnitLevelSummary(w io.Writer, colorizer *Colorizer) error {
+	maxUnitNameLength := 0
+
+	for _, run := range s.runs {
+		name := run.Path
+		if s.workingDir != "" {
+			name = strings.TrimPrefix(name, s.workingDir+string(os.PathSeparator))
+		}
+
+		if len(name) > maxUnitNameLength {
+			maxUnitNameLength = len(name)
+		}
+	}
+
+	headerPadding := 0
+	if maxUnitNameLength > defaultUnitNameLength {
+		headerPadding = maxUnitNameLength - defaultUnitNameLength + headerPaddingAdjustment
+	}
+
+	header := fmt.Sprintf("%s  %d units%s  %s", runSummaryHeader, s.TotalUnits(), strings.Repeat(" ", headerPadding), s.TotalDurationString(colorizer))
+	if err := s.writeSummaryHeader(w, colorizer.headingColorizer(header)); err != nil {
+		return err
+	}
+
+	separatorAdjustment := 0
+	if headerPadding > 0 {
+		separatorAdjustment = headerPadding - separatorPaddingAdjustment
+	}
+
+	separatorLine := fmt.Sprintf("%s%s", prefix, strings.Repeat("─", separatorLineLength+separatorAdjustment))
+	if err := s.writeSummaryHeader(w, separatorLine); err != nil {
+		return err
+	}
+
+	resultGroups := map[Result][]*Run{
+		ResultSucceeded: {},
+		ResultFailed:    {},
+		ResultEarlyExit: {},
+		ResultExcluded:  {},
+	}
+
+	for _, run := range s.runs {
+		resultGroups[run.Result] = append(resultGroups[run.Result], run)
+	}
+
+	categories := []struct {
+		colorizer     func(string) string
+		unitColorizer func(string) string
+		result        Result
+		label         string
+		count         int
+	}{
+		{
+			colorizer:     colorizer.successColorizer,
+			unitColorizer: colorizer.successUnitColorizer,
+			result:        ResultSucceeded,
+			label:         successLabel,
+			count:         s.UnitsSucceeded,
+		},
+		{
+			colorizer:     colorizer.failureColorizer,
+			unitColorizer: colorizer.failureUnitColorizer,
+			result:        ResultFailed,
+			label:         failureLabel,
+			count:         s.UnitsFailed,
+		},
+		{
+			colorizer:     colorizer.exitColorizer,
+			unitColorizer: colorizer.exitUnitColorizer,
+			result:        ResultEarlyExit,
+			label:         earlyExitLabel,
+			count:         s.EarlyExits,
+		},
+		{
+			colorizer:     colorizer.excludeColorizer,
+			unitColorizer: colorizer.excludeUnitColorizer,
+			result:        ResultExcluded,
+			label:         excludeLabel,
+			count:         s.Excluded,
+		},
+	}
+
+	for _, category := range categories {
+		if category.count > 0 {
+			categoryHeader := fmt.Sprintf("%s (%d)", category.label, category.count)
+
+			categoryHeaderColored := category.colorizer(categoryHeader)
+			if _, err := fmt.Fprintf(w, "%s%s\n", prefix, categoryHeaderColored); err != nil {
+				return err
+			}
+
+			runs := resultGroups[category.result]
+			slices.SortFunc(runs, func(a, b *Run) int {
+				aDuration := a.Ended.Sub(a.Started)
+				bDuration := b.Ended.Sub(b.Started)
+
+				return int(bDuration - aDuration)
+			})
+
+			for _, run := range runs {
+				if err := s.writeUnitDuration(w, run, colorizer, category.unitColorizer); err != nil {
+					return err
+				}
+			}
+		}
+	}
+
+	return nil
+}
+
+// writeUnitDuration writes unit duration with cleaner formatting
+func (s *Summary) writeUnitDuration(w io.Writer, run *Run, colorizer *Colorizer, unitColorizer func(string) string) error {
+	duration := run.Ended.Sub(run.Started)
+
+	name := run.Path
+	if s.workingDir != "" {
+		name = strings.TrimPrefix(name, s.workingDir+string(os.PathSeparator))
+	}
+
+	padding := s.unitDurationPadding(name)
+
+	_, err := fmt.Fprintf(
+		w, "%s%s%s%s\n",
+		strings.Repeat(prefix, unitPrefixMultiplier),
+		unitColorizer(name),
+		padding,
+		colorizer.colorDuration(duration),
+	)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (s *Summary) padding(label string) string {
+	headerUnitCountVisualPosition := s.visualLength(runSummaryHeader) + headerUnitCountSpacing
+
+	currentLabelLength := s.visualLength(label)
+	currentPosition := len(prefix) + currentLabelLength + len(separator)
+
+	paddingNeeded := headerUnitCountVisualPosition - currentPosition
+
+	paddingNeeded -= 4
+
+	if paddingNeeded < 0 {
+		paddingNeeded = 0
+	}
+
+	return strings.Repeat(s.padder, paddingNeeded)
+}
+
+// ansiRegex is used to remove ANSI escape codes from strings.
+// We compile it here to avoid re-compiling it on every call to visualLength.
+var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)
+
+// visualLength calculates the visual length of a string by removing ANSI escape codes
+func (s *Summary) visualLength(text string) int {
+	cleanText := ansiRegex.ReplaceAllString(text, "")
+
+	return len(cleanText)
+}
+
+// unitDurationPadding calculates padding for unit names to align durations with header
+func (s *Summary) unitDurationPadding(name string) string {
+	maxUnitNameLength := 0
+
+	for _, run := range s.runs {
+		runName := run.Path
+		if s.workingDir != "" {
+			runName = strings.TrimPrefix(runName, s.workingDir+string(os.PathSeparator))
+		}
+
+		if len(runName) > maxUnitNameLength {
+			maxUnitNameLength = len(runName)
+		}
+	}
+
+	headerPadding := 0
+	if maxUnitNameLength > defaultUnitNameLength {
+		headerPadding = maxUnitNameLength - defaultUnitNameLength + headerPaddingAdjustment
+	}
+
+	headerPrefix := fmt.Sprintf("%s  %d units  ", runSummaryHeader, s.TotalUnits())
+	headerDurationColumn := len(headerPrefix) + headerPadding
+
+	unitPrefix := strings.Repeat(prefix, unitPrefixMultiplier)
+	currentPosition := len(unitPrefix) + len(name)
+
+	paddingNeeded := headerDurationColumn - currentPosition - durationAlignmentOffset
+
+	if paddingNeeded < 1 {
+		paddingNeeded = 1
+	}
+
+	return strings.Repeat(s.padder, paddingNeeded)
+}
diff --git a/internal/report/writer.go b/internal/report/writer.go
new file mode 100644
index 00000000..b384059e
--- /dev/null
+++ b/internal/report/writer.go
@@ -0,0 +1,234 @@
+package report
+
+import (
+	"encoding/csv"
+	"encoding/json"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/invopop/jsonschema"
+)
+
+// JSONRun represents a run in JSON format.
+type JSONRun struct {
+	// Started is the time when the run started.
+	Started time.Time `json:"Started" jsonschema:"required"`
+	// Ended is the time when the run ended.
+	Ended time.Time `json:"Ended" jsonschema:"required"`
+	// Reason is the reason for the run result, if any.
+	Reason *string `json:"Reason,omitempty" jsonschema:"enum=retry succeeded,enum=error ignored,enum=run error,enum=--queue-exclude-dir,enum=exclude block,enum=ancestor error"`
+	// Cause is the cause of the run result, if any.
+	Cause *string `json:"Cause,omitempty"`
+	// Name is the name of the run.
+	Name string `json:"Name" jsonschema:"required"`
+	// Result is the result of the run.
+	Result string `json:"Result" jsonschema:"required,enum=succeeded,enum=failed,enum=early exit,enum=excluded"`
+}
+
+// WriteToFile writes the report to a file.
+func (r *Report) WriteToFile(path string) error {
+	tmpFile, err := os.CreateTemp("", "terragrunt-report-*")
+	if err != nil {
+		return err
+	}
+
+	r.mu.Lock()
+	r.SortRuns()
+	r.mu.Unlock()
+
+	switch r.format {
+	case FormatCSV:
+		err = r.WriteCSV(tmpFile)
+	case FormatJSON:
+		err = r.WriteJSON(tmpFile)
+	default:
+		return fmt.Errorf("unsupported format: %s", r.format)
+	}
+
+	if err != nil {
+		return fmt.Errorf("failed to write report: %w", err)
+	}
+
+	if err := tmpFile.Close(); err != nil {
+		return fmt.Errorf("failed to close report file: %w", err)
+	}
+
+	if r.workingDir != "" && !filepath.IsAbs(path) {
+		path = filepath.Join(r.workingDir, path)
+	}
+
+	return os.Rename(tmpFile.Name(), path)
+}
+
+// WriteCSV writes the report to a writer in CSV format.
+func (r *Report) WriteCSV(w io.Writer) error {
+	r.mu.RLock()
+	defer r.mu.RUnlock()
+
+	csvWriter := csv.NewWriter(w)
+	defer csvWriter.Flush()
+
+	err := csvWriter.Write([]string{
+		"Name",
+		"Started",
+		"Ended",
+		"Result",
+		"Reason",
+		"Cause",
+	})
+	if err != nil {
+		return err
+	}
+
+	for _, run := range r.Runs {
+		run.mu.RLock()
+		defer run.mu.RUnlock()
+
+		name := run.Path
+
+		if r.workingDir != "" {
+			name = strings.TrimPrefix(name, r.workingDir+string(os.PathSeparator))
+		}
+
+		started := run.Started.Format(time.RFC3339)
+		ended := run.Ended.Format(time.RFC3339)
+		result := string(run.Result)
+		reason := ""
+
+		if run.Reason != nil {
+			reason = string(*run.Reason)
+		}
+
+		cause := ""
+		if run.Cause != nil {
+			cause = string(*run.Cause)
+
+			if reason == string(ReasonAncestorError) && r.workingDir != "" {
+				cause = strings.TrimPrefix(cause, r.workingDir+string(os.PathSeparator))
+			}
+		}
+
+		err := csvWriter.Write([]string{
+			name,
+			started,
+			ended,
+			result,
+			reason,
+			cause,
+		})
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// WriteJSON writes the report to a writer in JSON format.
+func (r *Report) WriteJSON(w io.Writer) error {
+	r.mu.RLock()
+	defer r.mu.RUnlock()
+
+	runs := make([]JSONRun, 0, len(r.Runs))
+
+	for _, run := range r.Runs {
+		run.mu.RLock()
+		defer run.mu.RUnlock()
+
+		name := run.Path
+		if r.workingDir != "" {
+			name = strings.TrimPrefix(name, r.workingDir+string(os.PathSeparator))
+		}
+
+		jsonRun := JSONRun{
+			Name:    name,
+			Started: run.Started,
+			Ended:   run.Ended,
+			Result:  string(run.Result),
+		}
+
+		if run.Reason != nil {
+			reason := string(*run.Reason)
+			jsonRun.Reason = &reason
+		}
+
+		if run.Cause != nil {
+			cause := string(*run.Cause)
+			if run.Reason != nil && *run.Reason == ReasonAncestorError && r.workingDir != "" {
+				cause = strings.TrimPrefix(cause, r.workingDir+string(os.PathSeparator))
+			}
+
+			jsonRun.Cause = &cause
+		}
+
+		runs = append(runs, jsonRun)
+	}
+
+	jsonBytes, err := json.MarshalIndent(runs, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	jsonBytes = append(jsonBytes, '\n')
+
+	_, err = w.Write(jsonBytes)
+
+	return err
+}
+
+// WriteSchemaToFile writes a JSON schema for the report to a file.
+func (r *Report) WriteSchemaToFile(path string) error {
+	tmpFile, err := os.CreateTemp("", "terragrunt-schema-*")
+	if err != nil {
+		return err
+	}
+
+	if err := r.WriteSchema(tmpFile); err != nil {
+		return fmt.Errorf("failed to write schema: %w", err)
+	}
+
+	if err := tmpFile.Close(); err != nil {
+		return fmt.Errorf("failed to close schema file: %w", err)
+	}
+
+	if r.workingDir != "" && !filepath.IsAbs(path) {
+		path = filepath.Join(r.workingDir, path)
+	}
+
+	return os.Rename(tmpFile.Name(), path)
+}
+
+// WriteSchema writes a JSON schema for the report to a writer.
+func (r *Report) WriteSchema(w io.Writer) error {
+	reflector := jsonschema.Reflector{
+		DoNotReference: true,
+	}
+
+	schema := reflector.Reflect(&JSONRun{})
+
+	schema.Description = "Schema for Terragrunt run report"
+	schema.Title = "Terragrunt Run Report Schema"
+	schema.ID = "https://terragrunt.gruntwork.io/schemas/run/report/v1/schema.json"
+
+	arraySchema := &jsonschema.Schema{
+		Type:        "array",
+		Title:       "Terragrunt Run Report Schema",
+		Description: "Array of Terragrunt runs",
+		Items:       schema,
+	}
+
+	jsonBytes, err := json.MarshalIndent(arraySchema, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	jsonBytes = append(jsonBytes, '\n')
+
+	_, err = w.Write(jsonBytes)
+
+	return err
+}
diff --git a/options/options.go b/options/options.go
index 011d05f8..19cd08c0 100644
--- a/options/options.go
+++ b/options/options.go
@@ -308,8 +308,8 @@ type TerragruntOptions struct {
 	ForceBackendMigrate bool
 	// SummaryDisable disables the summary output at the end of a run.
 	SummaryDisable bool
-	// SummaryUnitDuration enables showing duration information for each unit in the summary.
-	SummaryUnitDuration bool
+	// SummaryPerUnit enables showing duration information for each unit in the summary.
+	SummaryPerUnit bool
 }
 
 // TerragruntOptionsFunc is a functional option type used to pass options in certain integration tests



Test patch:
diff --git a/internal/report/report_test.go b/internal/report/report_test.go
index b0cf8628..ad9a97f6 100644
--- a/internal/report/report_test.go
+++ b/internal/report/report_test.go
@@ -799,10 +799,9 @@ func TestWriteSummary(t *testing.T) {
 				r.EndRun(run.Path)
 			},
 			expected: `
-❯❯ Run Summary
-   Duration:   x
-   Units:      1
-   Succeeded:  1
+❯❯ Run Summary  1 units  x
+   ────────────────────────────
+   Succeeded    1
 `,
 		},
 		{
@@ -845,13 +844,12 @@ func TestWriteSummary(t *testing.T) {
 				r.EndRun(secondEarlyExitRun.Path, report.WithResult(report.ResultEarlyExit))
 			},
 			expected: `
-❯❯ Run Summary
-   Duration:     x
-   Units:        8
-   Succeeded:    2
-   Failed:       2
-   Early Exits:  2
-   Excluded:     2
+❯❯ Run Summary  8 units  x
+   ────────────────────────────
+   Succeeded    2
+   Failed       2
+   Early Exits  2
+   Excluded     2
 `,
 		},
 	}
@@ -869,9 +867,10 @@ func TestWriteSummary(t *testing.T) {
 
 			output := buf.String()
 
-			// Replace the duration with x
-			re := regexp.MustCompile(`Duration:(\s+).*`)
-			output = re.ReplaceAllString(output, "Duration:${1}x")
+			// Replace the duration in the header with x
+			// Pattern matches: "❯❯ Run Summary  8 units  42µs" -> "❯❯ Run Summary  8 units  x"
+			re := regexp.MustCompile(`(❯❯ Run Summary\s+\d+\s+units\s+)[^\n]+`)
+			output = re.ReplaceAllString(output, "${1}x")
 
 			expected := strings.TrimSpace(tt.expected)
 			assert.Equal(t, expected, strings.TrimSpace(output))
@@ -1042,7 +1041,7 @@ func TestSchemaIsValid(t *testing.T) {
 	assert.Equal(t, "ignore-block", *ignored.Cause)
 }
 
-func TestWriteUnitsTiming(t *testing.T) {
+func TestWriteUnitLevelSummary(t *testing.T) {
 	t.Parallel()
 
 	tmp := t.TempDir()
@@ -1058,9 +1057,8 @@ func TestWriteUnitsTiming(t *testing.T) {
 				// No runs added
 			},
 			expected: `
-❯❯ Run Summary
-   Duration:  x
-   Units:     0
+❯❯ Run Summary  0 units  x
+   ────────────────────────────
 `,
 		},
 		{
@@ -1071,11 +1069,10 @@ func TestWriteUnitsTiming(t *testing.T) {
 				r.EndRun(run.Path)
 			},
 			expected: `
-❯❯ Run Summary
-   Duration:   x
-      single-run:  x
-   Units:      1
-   Succeeded:  1
+❯❯ Run Summary  1 units  x
+   ────────────────────────────
+   Succeeded (1)
+      single-run         x
 `,
 		},
 		{
@@ -1096,13 +1093,93 @@ func TestWriteUnitsTiming(t *testing.T) {
 				r.EndRun(longRun.Path)
 			},
 			expected: `
-❯❯ Run Summary
-   Duration:   x
-      long-run:    x
-      medium-run:  x
-      short-run:   x
-   Units:      3
-   Succeeded:  3
+❯❯ Run Summary  3 units  x
+   ────────────────────────────
+   Succeeded (3)
+      long-run           x
+      medium-run         x
+      short-run          x
+`,
+		},
+		{
+			name: "mixed results grouped by category",
+			setup: func(r *report.Report) {
+				// Add runs with different results
+				successRun1 := newRun(t, filepath.Join(tmp, "success-1"))
+				successRun2 := newRun(t, filepath.Join(tmp, "success-2"))
+				failRun := newRun(t, filepath.Join(tmp, "fail-run"))
+				excludedRun := newRun(t, filepath.Join(tmp, "excluded-run"))
+
+				r.AddRun(successRun1)
+				r.AddRun(successRun2)
+				r.AddRun(failRun)
+				r.AddRun(excludedRun)
+
+				r.EndRun(successRun1.Path)
+				r.EndRun(successRun2.Path)
+				r.EndRun(failRun.Path, report.WithResult(report.ResultFailed))
+				r.EndRun(excludedRun.Path, report.WithResult(report.ResultExcluded))
+			},
+			expected: `
+❯❯ Run Summary  4 units  x
+   ────────────────────────────
+   Succeeded (2)
+      success-1          x
+      success-2          x
+   Failed (1)
+      fail-run           x
+   Excluded (1)
+      excluded-run       x
+`,
+		},
+		{
+			name: "very short unit names",
+			setup: func(r *report.Report) {
+				// Add runs with very short names
+				a := newRun(t, filepath.Join(tmp, "a"))
+				b := newRun(t, filepath.Join(tmp, "b"))
+				c := newRun(t, filepath.Join(tmp, "c"))
+
+				r.AddRun(a)
+				r.AddRun(b)
+				r.AddRun(c)
+
+				r.EndRun(a.Path)
+				r.EndRun(b.Path)
+				r.EndRun(c.Path)
+			},
+			expected: `
+❯❯ Run Summary  3 units  x
+   ────────────────────────────
+   Succeeded (3)
+      a                  x
+      b                  x
+      c                  x
+`,
+		},
+		{
+			name: "very long unit names",
+			setup: func(r *report.Report) {
+				// Add runs with very long names
+				longName1 := newRun(t, filepath.Join(tmp, "this-is-a-very-long-name-1"))
+				longName2 := newRun(t, filepath.Join(tmp, "this-is-a-very-long-name-2"))
+				longName3 := newRun(t, filepath.Join(tmp, "this-is-a-very-long-name-3"))
+
+				r.AddRun(longName1)
+				r.AddRun(longName2)
+				r.AddRun(longName3)
+
+				r.EndRun(longName1.Path)
+				r.EndRun(longName2.Path)
+				r.EndRun(longName3.Path)
+			},
+			expected: `
+❯❯ Run Summary  3 units           x
+   ───────────────────────────────────
+   Succeeded (3)
+      this-is-a-very-long-name-1  x
+      this-is-a-very-long-name-2  x
+      this-is-a-very-long-name-3  x
 `,
 		},
 	}
@@ -1113,7 +1190,7 @@ func TestWriteUnitsTiming(t *testing.T) {
 
 			r := report.NewReport().
 				WithDisableColor().
-				WithShowUnitTiming().
+				WithShowUnitLevelSummary().
 				WithWorkingDir(tmp)
 
 			tt.setup(r)
@@ -1127,8 +1204,13 @@ func TestWriteUnitsTiming(t *testing.T) {
 			re := regexp.MustCompile(`Duration:(\s+).*`)
 			output = re.ReplaceAllString(output, "Duration:${1}x")
 
-			// Replace the actual durations with x
-			re = regexp.MustCompile(`([ ]{6})([^\s]+:)(\s+)(.*)`)
+			// Replace the duration in the header line (e.g., "3 units  8µs" -> "3 units  x")
+			// Updated to handle variable spacing for long unit names
+			re = regexp.MustCompile(`(\d+ units\s+)(\s*)(\d+[µnms]+|\d+[smh]|[^\s]+)`)
+			output = re.ReplaceAllString(output, "${1}${2}x")
+
+			// Replace the actual durations with x (for the new clean format without colons)
+			re = regexp.MustCompile(`([ ]{6})([^\s]+)(\s+)(\d+[µnms]+|\d+[smh])`)
 			output = re.ReplaceAllString(output, "${1}${2}${3}x")
 
 			expected := strings.TrimSpace(tt.expected)
diff --git a/test/fixtures/find/dag/c-mixed-deps/terragrunt.hcl b/test/fixtures/find/dag/c-mixed-deps/terragrunt.hcl
new file mode 100644
index 00000000..8e33d4de
--- /dev/null
+++ b/test/fixtures/find/dag/c-mixed-deps/terragrunt.hcl
@@ -0,0 +1,8 @@
+# Uses both dependency and dependencies blocks
+dependency "single_dep" {
+    config_path = "../a-dependent"
+}
+
+dependencies {
+    paths = ["../d-dependencies-only"]
+}
diff --git a/test/fixtures/find/dag/d-dependencies-only/terragrunt.hcl b/test/fixtures/find/dag/d-dependencies-only/terragrunt.hcl
new file mode 100644
index 00000000..1a28a2fc
--- /dev/null
+++ b/test/fixtures/find/dag/d-dependencies-only/terragrunt.hcl
@@ -0,0 +1,4 @@
+# Uses only dependencies block (plural)
+dependencies {
+    paths = ["../a-dependent"]
+}
diff --git a/test/integration_find_test.go b/test/integration_find_test.go
index bf528563..6d1c4cd3 100644
--- a/test/integration_find_test.go
+++ b/test/integration_find_test.go
@@ -96,8 +96,8 @@ func TestFindDAG(t *testing.T) {
 		sort     string
 		expected string
 	}{
-		{name: "alpha", sort: "alpha", expected: "a-dependent\nb-dependency\n"},
-		{name: "dag", sort: "dag", expected: "b-dependency\na-dependent\n"},
+		{name: "alpha", sort: "alpha", expected: "a-dependent\nb-dependency\nc-mixed-deps\nd-dependencies-only\n"},
+		{name: "dag", sort: "dag", expected: "b-dependency\na-dependent\nd-dependencies-only\nc-mixed-deps\n"},
 	}
 
 	for _, tc := range testCases {
@@ -121,6 +121,49 @@ func TestFindDAG(t *testing.T) {
 	}
 }
 
+func TestFindDAGWithMixedDependencies(t *testing.T) {
+	t.Parallel()
+
+	helpers.CleanupTerraformFolder(t, testFixtureFindDAG)
+
+	testCases := []struct {
+		name     string
+		args     string
+		expected string
+	}{
+		{
+			name:     "dag with dependencies output",
+			args:     "--dag --dependencies",
+			expected: "b-dependency\na-dependent\nd-dependencies-only\nc-mixed-deps\n",
+		},
+		{
+			name:     "dag with dependencies json output",
+			args:     "--dag --dependencies --json",
+			expected: `[{"type":"unit","path":"b-dependency"},{"type":"unit","path":"a-dependent","dependencies":["b-dependency"]},{"type":"unit","path":"d-dependencies-only","dependencies":["a-dependent"]},{"type":"unit","path":"c-mixed-deps","dependencies":["a-dependent","d-dependencies-only"]}]`,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			helpers.CleanupTerraformFolder(t, testFixtureFindDAG)
+
+			cmd := "terragrunt find --no-color --working-dir " + testFixtureFindDAG + " " + tc.args
+
+			stdout, stderr, err := helpers.RunTerragruntCommandWithOutput(t, cmd)
+			require.NoError(t, err)
+
+			assert.Empty(t, stderr)
+			if strings.Contains(tc.args, "--json") {
+				assert.JSONEq(t, tc.expected, stdout)
+			} else {
+				assert.Equal(t, tc.expected, stdout)
+			}
+		})
+	}
+}
+
 func TestFindExternalDependencies(t *testing.T) {
 	t.Parallel()
 
@@ -233,7 +276,8 @@ func TestFindQueueConstructAs(t *testing.T) {
 	t.Parallel()
 
 	// I'm using the list fixture here because it's more convenient.
-	helpers.CleanupTerraformFolder(t, testFixtureListDag)
+	testFixtureQueueConstruct := "fixtures/list/dag"
+	helpers.CleanupTerraformFolder(t, testFixtureQueueConstruct)
 
 	testCases := []struct {
 		name           string
@@ -275,9 +319,9 @@ func TestFindQueueConstructAs(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			t.Parallel()
 
-			helpers.CleanupTerraformFolder(t, testFixtureListDag)
+			helpers.CleanupTerraformFolder(t, testFixtureQueueConstruct)
 
-			cmd := fmt.Sprintf("terragrunt find --json --no-color --working-dir %s %s", testFixtureListDag, tc.args)
+			cmd := fmt.Sprintf("terragrunt find --json --no-color --working-dir %s %s", testFixtureQueueConstruct, tc.args)
 			stdout, stderr, err := helpers.RunTerragruntCommandWithOutput(t, cmd)
 			require.NoError(t, err)
 			assert.Empty(t, stderr)
diff --git a/test/integration_parse_test.go b/test/integration_parse_test.go
index 73cb1ae7..b64a7210 100644
--- a/test/integration_parse_test.go
+++ b/test/integration_parse_test.go
@@ -143,20 +143,34 @@ func TestParseFindListAllComponentsWithDAG(t *testing.T) {
 
 			fields := strings.Fields(stdout)
 
-			aDepLine := 0
-			bDepLine := 0
+			// Find positions of all fixtures in the output
+			aDepLine := -1
+			bDepLine := -1
+			cMixedLine := -1
+			dDepsLine := -1
 
 			for i, field := range fields {
-				if field == "fixtures/find/dag/a-dependent" {
+				switch field {
+				case "fixtures/find/dag/a-dependent":
 					aDepLine = i
-				}
-
-				if field == "fixtures/find/dag/b-dependency" {
+				case "fixtures/find/dag/b-dependency":
 					bDepLine = i
+				case "fixtures/find/dag/c-mixed-deps":
+					cMixedLine = i
+				case "fixtures/find/dag/d-dependencies-only":
+					dDepsLine = i
 				}
 			}
 
-			assert.Greater(t, aDepLine, bDepLine)
+			// Verify DAG ordering:
+			// b-dependency (no deps) should come first
+			// a-dependent (depends on b) should come after b
+			// d-dependencies-only (depends on b) should come after b
+			// c-mixed-deps (depends on a and d) should come last
+			assert.Greater(t, aDepLine, bDepLine, "a-dependent should come after b-dependency")
+			assert.Greater(t, dDepsLine, bDepLine, "d-dependencies-only should come after b-dependency")
+			assert.Greater(t, cMixedLine, aDepLine, "c-mixed-deps should come after a-dependent")
+			assert.Greater(t, cMixedLine, dDepsLine, "c-mixed-deps should come after d-dependencies-only")
 		})
 	}
 }
@@ -187,20 +201,38 @@ func TestParseFindListAllComponentsWithDAGAndExternal(t *testing.T) {
 
 			fields := strings.Fields(stdout)
 
-			aDepLine := 0
-			bDepLine := 0
+			// Find positions of all fixtures in the output
+			aDepLine := -1
+			bDepLine := -1
+			cMixedLine := -1
+			dDepsLine := -1
 
 			for i, field := range fields {
-				if field == "fixtures/find/dag/a-dependent" {
+				switch field {
+				case "fixtures/find/dag/a-dependent":
 					aDepLine = i
-				}
-
-				if field == "fixtures/find/dag/b-dependency" {
+				case "fixtures/find/dag/b-dependency":
 					bDepLine = i
+				case "fixtures/find/dag/c-mixed-deps":
+					cMixedLine = i
+				case "fixtures/find/dag/d-dependencies-only":
+					dDepsLine = i
 				}
 			}
 
-			assert.Greater(t, aDepLine, bDepLine)
+			// Verify DAG ordering for the core dependencies
+			// The exact ordering may vary with external dependencies included,
+			// but the basic dependency relationship should hold
+			if aDepLine >= 0 && bDepLine >= 0 {
+				assert.Greater(t, aDepLine, bDepLine, "a-dependent should come after b-dependency")
+			}
+			if dDepsLine >= 0 && bDepLine >= 0 {
+				assert.Greater(t, dDepsLine, bDepLine, "d-dependencies-only should come after b-dependency")
+			}
+			if cMixedLine >= 0 && aDepLine >= 0 && dDepsLine >= 0 {
+				assert.Greater(t, cMixedLine, aDepLine, "c-mixed-deps should come after a-dependent")
+				assert.Greater(t, cMixedLine, dDepsLine, "c-mixed-deps should come after d-dependencies-only")
+			}
 		})
 	}
 }
diff --git a/test/integration_report_test.go b/test/integration_report_test.go
index b44caaef..eb841944 100644
--- a/test/integration_report_test.go
+++ b/test/integration_report_test.go
@@ -39,24 +39,35 @@ func TestTerragruntReportExperiment(t *testing.T) {
 	// Verify the report output contains expected information
 	stdoutStr := stdout.String()
 
-	// Replace the duration line with a fixed duration
-	re := regexp.MustCompile(`Duration:(\s+)(.*)`)
-	stdoutStr = re.ReplaceAllString(stdoutStr, "Duration:${1}x")
+	// Replace the timing information with fixed values
+	re := regexp.MustCompile(`❯❯ Run Summary\s+\d+\s+units\s+\S+`)
+	stdoutStr = re.ReplaceAllString(stdoutStr, "❯❯ Run Summary  13 units  x")
 
 	// Trim stdout to only the run summary.
-	// We do this by only returning the last 8 lines (seven lines of the summary, footer gap).
-	// We add one extra to avoid an off-by-one in slice math.
+	// Find the summary section
 	lines := strings.Split(stdoutStr, "\n")
-	stdoutStr = strings.Join(lines[len(lines)-9:], "\n")
+
+	// Find the "Run Summary" line
+	summaryStartIdx := -1
+	for i, line := range lines {
+		if strings.Contains(line, "Run Summary") {
+			summaryStartIdx = i
+			break
+		}
+	}
+	require.NotEqual(t, -1, summaryStartIdx, "Could not find 'Run Summary' line")
+
+	// Extract the summary section
+	summaryLines := lines[summaryStartIdx:]
+	stdoutStr = strings.Join(summaryLines, "\n")
 
 	assert.Equal(t, strings.TrimSpace(`
-❯❯ Run Summary
-   Duration:     x
-   Units:        13
-   Succeeded:    4
-   Failed:       3
-   Early Exits:  4
-   Excluded:     2
+❯❯ Run Summary  13 units  x
+   ────────────────────────────
+   Succeeded    4
+   Failed       3
+   Early Exits  4
+   Excluded     2
 `), strings.TrimSpace(stdoutStr))
 }
 
@@ -382,19 +393,19 @@ func TestTerragruntReportExperimentWithUnitTiming(t *testing.T) {
 	// Run terragrunt with report experiment enabled and unit timing enabled
 	var stdout bytes.Buffer
 	var stderr bytes.Buffer
-	err := helpers.RunTerragruntCommand(t, "terragrunt run --all apply --experiment report --non-interactive --working-dir "+rootPath+" --summary-unit-duration", &stdout, &stderr)
+	err := helpers.RunTerragruntCommand(t, "terragrunt run --all apply --experiment report --non-interactive --working-dir "+rootPath+" --summary-per-unit", &stdout, &stderr)
 	require.NoError(t, err)
 
 	// Verify the report output contains expected information
 	stdoutStr := stdout.String()
 
-	// Replace the duration lines with fixed durations
-	re := regexp.MustCompile(`Duration:(\s+)(.*)`)
-	stdoutStr = re.ReplaceAllString(stdoutStr, "Duration:${1}x")
+	// Replace the timing information with fixed values
+	re := regexp.MustCompile(`❯❯ Run Summary\s+\d+\s+units\s+\S+`)
+	stdoutStr = re.ReplaceAllString(stdoutStr, "❯❯ Run Summary  13 units  x")
 
-	// Replace unit timing durations with x
-	re = regexp.MustCompile(`([ ]{6})([^\s]+:)(\s+)(.*)`)
-	stdoutStr = re.ReplaceAllString(stdoutStr, "${1}${2}${3}x")
+	// Replace unit timing durations with x (including minutes, seconds, milliseconds, microseconds, nanoseconds)
+	re = regexp.MustCompile(`(?m)\d+(\.\d+)?(m|s|ms|µs|μs|ns)$`)
+	stdoutStr = re.ReplaceAllString(stdoutStr, "x")
 
 	// Find and extract the run summary section
 	lines := strings.Split(stdoutStr, "\n")
@@ -418,65 +429,107 @@ func TestTerragruntReportExperimentWithUnitTiming(t *testing.T) {
 		}
 	}
 
-	// Extract unit duration lines (lines that start with 6 spaces and contain a colon)
-	var unitLogLines []string
-	var otherLines []string
-
-	for i := summaryStartIdx; i <= summaryEndIdx; i++ {
-		line := lines[i]
-		// Check if this is a unit duration line (6 spaces + unit name + colon)
-		if strings.HasPrefix(line, "      ") && strings.Contains(line, ":") && !strings.Contains(line, "Duration:") {
-			unitLogLines = append(unitLogLines, line)
-		} else {
-			otherLines = append(otherLines, line)
-		}
+	// Extract the summary section
+	summaryLines := lines[summaryStartIdx : summaryEndIdx+1]
+	stdoutStr = strings.Join(summaryLines, "\n")
+
+	// Sort lines within each category to make the test deterministic
+	// We're not testing the sorting functionality here, just the per-unit timing display
+	stdoutStr = sortLinesWithinCategories(stdoutStr)
+
+	// The expected format has units grouped by status with timing (sorted alphabetically within categories)
+	expectedOutput := `❯❯ Run Summary  13 units  x
+   ────────────────────────────
+   Succeeded (4)
+      error-ignore        x
+      first-success       x
+      retry-success       x
+      second-success      x
+   Failed (3)
+      chain-a             x
+      first-failure       x
+      second-failure      x
+   Early Exits (4)
+      chain-b             x
+      chain-c             x
+      first-early-exit    x
+      second-early-exit   x
+   Excluded (2)
+      first-exclude       x
+      second-exclude      x`
+
+	assert.Equal(t, strings.TrimSpace(expectedOutput), strings.TrimSpace(stdoutStr))
+}
+
+// lineType represents the type of line we're processing
+type lineType int
+
+const (
+	categoryHeaderLine lineType = iota
+	unitLine
+	otherLine
+)
+
+// getLineType determines what type of line we're dealing with
+func getLineType(line string, inCategory bool) lineType {
+	trimmed := strings.TrimSpace(line)
+
+	// Check if this is a category header line (ends with a count in parentheses)
+	if strings.Contains(line, "(") && strings.Contains(line, ")") &&
+		(strings.Contains(line, "Succeeded") || strings.Contains(line, "Failed") ||
+			strings.Contains(line, "Early Exits") || strings.Contains(line, "Excluded")) {
+		return categoryHeaderLine
+	}
+
+	// Check if this is a unit line within a category
+	if inCategory && strings.HasPrefix(line, "      ") && trimmed != "" {
+		return unitLine
 	}
 
-	// Sort the duration lines alphabetically so that they show up consistently
-	sort.Slice(unitLogLines, func(i, j int) bool {
-		// Extract the unit name from the line
-		unitNameI := strings.TrimSpace(re.ReplaceAllString(unitLogLines[i], "${2}"))
-		unitNameJ := strings.TrimSpace(re.ReplaceAllString(unitLogLines[j], "${2}"))
-
-		// Compare the unit names
-		return unitNameI < unitNameJ
-	})
-
-	// Reconstruct the summary with sorted unit lines
-	// Insert sorted unit lines after the "Duration:" line
-	finalLines := make([]string, 0, len(otherLines)+len(unitLogLines))
-
-	unitInserted := false
-	for _, line := range otherLines {
-		finalLines = append(finalLines, line)
-		if strings.Contains(line, "Duration:") && !unitInserted {
-			finalLines = append(finalLines, unitLogLines...)
-			unitInserted = true
+	return otherLine
+}
+
+// sortLinesWithinCategories sorts the unit lines within each category alphabetically
+// to make the test deterministic regardless of actual execution timing
+func sortLinesWithinCategories(input string) string {
+	lines := strings.Split(input, "\n")
+	var result []string
+	var currentCategoryLines []string
+	inCategory := false
+
+	for _, line := range lines {
+		switch getLineType(line, inCategory) {
+		case categoryHeaderLine:
+			// If we were in a category, sort and add those lines first
+			if inCategory && len(currentCategoryLines) > 0 {
+				sort.Strings(currentCategoryLines)
+				result = append(result, currentCategoryLines...)
+				currentCategoryLines = nil
+			}
+			// Add the category header
+			result = append(result, line)
+			inCategory = true
+		case unitLine:
+			// This is a unit line within a category
+			currentCategoryLines = append(currentCategoryLines, line)
+		case otherLine:
+			// If we were in a category, sort and add those lines first
+			if inCategory && len(currentCategoryLines) > 0 {
+				sort.Strings(currentCategoryLines)
+				result = append(result, currentCategoryLines...)
+				currentCategoryLines = nil
+				inCategory = false
+			}
+			// Add non-category lines as-is
+			result = append(result, line)
 		}
 	}
 
-	stdoutStr = strings.Join(finalLines, "\n")
+	// Handle any remaining category lines
+	if inCategory && len(currentCategoryLines) > 0 {
+		sort.Strings(currentCategoryLines)
+		result = append(result, currentCategoryLines...)
+	}
 
-	assert.Equal(t, strings.TrimSpace(`
-❯❯ Run Summary
-   Duration:     x
-      chain-a:            x
-      chain-b:            x
-      chain-c:            x
-      error-ignore:       x
-      first-early-exit:   x
-      first-exclude:      x
-      first-failure:      x
-      first-success:      x
-      retry-success:      x
-      second-early-exit:  x
-      second-exclude:     x
-      second-failure:     x
-      second-success:     x
-   Units:        13
-   Succeeded:    4
-   Failed:       3
-   Early Exits:  4
-   Excluded:     2
-`), strings.TrimSpace(stdoutStr))
+	return strings.Join(result, "\n")
 }
diff --git a/test/integration_runner_pool_test.go b/test/integration_runner_pool_test.go
index cb5dd90e..c8d74ec9 100644
--- a/test/integration_runner_pool_test.go
+++ b/test/integration_runner_pool_test.go
@@ -39,5 +39,5 @@ func TestRunnerPoolTerragruntDestroyOrder(t *testing.T) {
 	// run destroy with runner pool and check the order of the modules
 	stdout, _, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt run --experiment runner-pool --all destroy --non-interactive --tf-forward-stdout --working-dir "+rootPath)
 	require.NoError(t, err)
-	assert.Regexp(t, `(?smi)(?:(Module A|Module B|Module C).*){3}(?:(Module D|Module E).*){2}`, stdout)
+	assert.Regexp(t, `(?smi)(?:(Module B|Module D).*){2}(?:(Module A|Module E|Module C).*){3}`, stdout)
 }