Repository: P3HPC/code-base-investigator

Issue:
Provide a way to run in debug mode
### Feature/behavior summary

By default, we catch all uncaught exceptions and log these as an error. It can be difficult to get more insight into where something is going wrong, and I often find myself editing the `codebasin` script to (temporarily) disable exception handling in order to inspect an exception traceback.

I think it would be valuable to have a way to run CBI in some sort of "debug" mode with the following behavior:
- Enables all `logging.debug` statements.
- Prints a full exception traceback for uncaught exceptions.

### Request attributes

- [ ] Would this be a refactor of existing code?
- [ ] Does this proposal require new package dependencies?
- [ ] Would this change break backwards compatibility?

### Related issues

_No response_

### Solution description

One simple way to expose this would be via something like a dedicated `--debug` flag.

### Additional notes

Other options for exposing this include:
- Checking an environment variable like `CBI_DEBUG=True`.
- Enabling debug mode if there are a sufficient number of `-v` arguments.

I think a `--debug` flag would be better than these options, though, because with an explicit flag it's much harder to accidentally run in debug mode.

Discussion:

Solution patch:
diff --git a/codebasin/__main__.py b/codebasin/__main__.py
index 8f8e324..d881412 100755
--- a/codebasin/__main__.py
+++ b/codebasin/__main__.py
@@ -16,6 +16,8 @@ from codebasin._detail.logging import Formatter, WarningAggregator
 log = logging.getLogger("codebasin")
 version = "1.2.0"
 
+_traceback = False
+
 
 def _help_string(*lines: str, is_long=False, is_last=False):
     """
@@ -89,6 +91,14 @@ def _main():
         default=0,
         help=_help_string("Decrease verbosity level."),
     )
+    parser.add_argument(
+        "--debug",
+        dest="debug",
+        action="store_const",
+        default=False,
+        const=True,
+        help=_help_string("Enable debug output."),
+    )
     parser.add_argument(
         "-R",
         "--report",
@@ -96,7 +106,7 @@ def _main():
         metavar="<report>",
         action="append",
         default=[],
-        choices=["all", "summary", "clustering", "duplicates", "files"],
+        choices=["all", "summary", "clustering", "duplicates"],
         help=_help_string(
             "Generate a report of the specified type:",
             "- summary: code divergence information",
@@ -152,23 +162,35 @@ def _main():
     # Configure logging such that:
     # - All messages are written to a log file
     # - Only errors are written to the terminal by default
-    # - Messages written to terminal are based on -q and -v flags
+    # - Messages written to terminal are based on -q, -v and --debug flags
     # - Meta-warnings and statistics are generated by a WarningAggregator
     aggregator = WarningAggregator()
     log.setLevel(logging.DEBUG)
+    if args.debug:
+        min_log_level = logging.DEBUG
+        global _traceback
+        _traceback = True
+    else:
+        min_log_level = logging.INFO
 
     file_handler = logging.FileHandler("cbi.log", mode="w")
-    file_handler.setLevel(logging.INFO)
+    file_handler.setLevel(min_log_level)
     file_handler.setFormatter(Formatter())
     file_handler.addFilter(aggregator)
     log.addHandler(file_handler)
 
+    if args.debug:
+        log.debug(f"Code Base Investigator {version}; Python {sys.version}")
+
     # Inform the user that a log file has been created.
     # 'print' instead of 'log' to ensure the message is visible in the output.
     log_path = os.path.abspath("cbi.log")
     print(f"Log file created at {log_path}")
 
-    log_level = max(1, logging.ERROR - 10 * (args.verbose - args.quiet))
+    log_level = max(
+        min_log_level,
+        logging.ERROR - 10 * (args.verbose - args.quiet),
+    )
     stdout_handler = logging.StreamHandler(sys.stdout)
     stdout_handler.setLevel(log_level)
     stdout_handler.setFormatter(Formatter(colors=sys.stdout.isatty()))
@@ -246,10 +268,6 @@ def _main():
     if report_enabled("summary"):
         report.summary(setmap)
 
-    # Print files report
-    if report_enabled("files"):
-        report.files(codebase, state)
-
     # Print clustering report
     if report_enabled("clustering"):
         basename = os.path.basename(args.analysis_file)
@@ -270,7 +288,7 @@ def main():
     try:
         _main()
     except Exception as e:
-        log.error(str(e))
+        log.error(str(e), exc_info=_traceback)
         sys.exit(1)
 
 
diff --git a/codebasin/_detail/logging.py b/codebasin/_detail/logging.py
index 9500f86..9559180 100644
--- a/codebasin/_detail/logging.py
+++ b/codebasin/_detail/logging.py
@@ -61,6 +61,11 @@ class Formatter(logging.Formatter):
             color = RED
         else:
             color = DEFAULT
+
+        # Print a stack trace if explicitly requested.
+        if record.exc_info:
+            return self.formatException(record.exc_info)
+
         return f"{BOLD}{color}{level}{RESET}: {msg}"
 
 
diff --git a/codebasin/config.py b/codebasin/config.py
index d8bd2b8..f7aca83 100644
--- a/codebasin/config.py
+++ b/codebasin/config.py
@@ -6,7 +6,6 @@ defining a specific code base configuration.
 """
 
 import argparse
-import collections
 import logging
 import os
 import pkgutil
@@ -14,6 +13,7 @@ import re
 import string
 import tomllib
 from dataclasses import asdict, dataclass, field
+from itertools import chain
 from pathlib import Path
 from typing import Self
 
@@ -21,64 +21,9 @@ from codebasin import CompilationDatabase, util
 
 log = logging.getLogger(__name__)
 
-
-_importcfg = None
 _compilers = None
 
 
-def _parse_compiler_args(argv: list[str]):
-    """
-    Parameters
-    ----------
-    argv: list[str]
-        A list of arguments passed to a compiler.
-
-    Returns
-    -------
-    argparse.Namespace
-        The result of parsing `argv[1:]`.
-        - defines: -D arguments
-        - include_paths: -I/-isystem arguments
-        - include_files: -include arguments
-    """
-    parser = argparse.ArgumentParser()
-    parser.add_argument("-D", dest="defines", action="append", default=[])
-    parser.add_argument(
-        "-I",
-        "-isystem",
-        dest="include_paths",
-        action="append",
-        default=[],
-    )
-    parser.add_argument(
-        "-include",
-        dest="include_files",
-        action="append",
-        default=[],
-    )
-    args, _ = parser.parse_known_args(argv)
-    return args
-
-
-def load_importcfg():
-    """
-    Load the import configuration file, if it exists.
-    """
-    global _importcfg
-    _importcfg = collections.defaultdict(list)
-    path = ".cbi/config"
-    if os.path.exists(path):
-        log.info(f"Found configuration file at {path}")
-        with open(path, "rb") as f:
-            try:
-                _importcfg_toml = util._load_toml(f, "cbiconfig")
-            except ValueError as e:
-                log.error(str(e))
-                return
-        for name, compiler in _importcfg_toml["compiler"].items():
-            _importcfg[name] = compiler["options"]
-
-
 class _StoreSplitAction(argparse.Action):
     """
     A custom argparse.Action that splits the value based on a user-provided
@@ -222,6 +167,7 @@ def _load_compilers():
     """
     Load the configuration from the following files:
     - ${PACKAGE}/compilers/*.toml
+    - .cbi/config
     """
     global _compilers
     _compilers = {}
@@ -241,6 +187,67 @@ def _load_compilers():
         for name, definition in toml["compiler"].items():
             _compilers[name] = _Compiler.from_toml(definition)
 
+    # Check for any user-defined compiler behavior.
+    path = ".cbi/config"
+    if os.path.exists(path):
+        log.info(f"Found configuration file at {path}.")
+        with open(path, "rb") as f:
+            try:
+                toml = util._load_toml(f, "cbiconfig")
+            except ValueError as e:
+                log.error(str(e))
+                return
+
+        for name, definition in toml["compiler"].items():
+            # Check if the user is defining a new compiler.
+            if name not in _compilers:
+                _compilers[name] = _Compiler.from_toml(definition)
+                continue
+
+            compiler = _compilers[name]
+
+            # Check if the user is redefining a compiler as an alias.
+            # Warn because options may be lost.
+            if "alias_of" in definition:
+                log.warning(
+                    f'{name} redefined as alias of {definition["alias_of"]}.',
+                )
+                _compilers[name] = _Compiler.from_toml(definition)
+                continue
+
+            # Check if the user is redefining an alias as a compiler.
+            # Warn because options may be lost.
+            if compiler.alias_of:
+                log.warning(
+                    f"definition of {name} in .cbi/config overrides alias.",
+                )
+                compiler.alias_of = None
+
+            # Append options, parser options, modes and passes.
+            # Warn if modes/passes are redefined because options may be lost.
+            if "options" in definition:
+                compiler.options.extend(definition["options"])
+            if "parser" in definition:
+                for option in definition["parser"]:
+                    option = option.copy()
+                    if option["action"] == "store_split":
+                        option["action"] = _StoreSplitAction
+                    if option["action"] == "extend_match":
+                        option["action"] = _ExtendMatchAction
+                    compiler.parser.append(option)
+            if "modes" in definition:
+                for m in definition["modes"]:
+                    name = m["name"]
+                    if name in compiler.modes:
+                        log.warning(f"compiler mode '{name}' redefined")
+                    compiler.modes[name] = _CompilerMode.from_toml(m)
+            if "passes" in definition:
+                for p in definition["passes"]:
+                    name = p["name"]
+                    if name in compiler.passes:
+                        log.warning(f"compiler pass '{name}' redefined")
+                    compiler.passes[name] = _CompilerPass.from_toml(p)
+
 
 # Load the compiler configuration when this module is imported.
 if not _compilers:
@@ -262,6 +269,21 @@ class PreprocessorConfiguration:
     include_files: list[str]
     pass_name: str = "default"
 
+    def _update(self, pass_or_mode: _CompilerPass | _CompilerMode):
+        """
+        Update this PreprocessorConfiguration by extending the
+        defines, include paths and include files using the values
+        contained in the provided _CompilerPass or _CompilerMode.
+
+        Parameters
+        ----------
+        pass_or_mode: _CompilerPass | _CompilerMode
+            The pass or mode to enable.
+        """
+        self.defines.extend(pass_or_mode.defines)
+        self.include_paths.extend(pass_or_mode.include_paths)
+        self.include_files.extend(pass_or_mode.include_files)
+
 
 class ArgumentParser:
     """
@@ -271,10 +293,36 @@ class ArgumentParser:
     def __init__(self, path: str):
         self.name = os.path.basename(path)
 
-        # Check for any user-defined compiler behavior.
-        # Currently, users can only override default defines.
-        if _importcfg is None:
-            load_importcfg()
+        self.compiler = _Compiler()
+        if self.name not in _compilers:
+            log.warning(f"Compiler '{self.name}' not recognized.")
+            return
+
+        # If a compiler is not an alias, use its configuration directly.
+        if not _compilers[self.name].alias_of:
+            self.compiler = _compilers[self.name]
+            log.info(f"Compiler '{self.name}' recognized.")
+
+        # If a compiler is an alias of another, resolve the alias.
+        # An alias may itself be an alias, so we may need to iterate.
+        alias_chain = [self.name]
+        while _compilers[alias_chain[-1]].alias_of:
+            alias = _compilers[alias_chain[-1]].alias_of
+            if alias in alias_chain:
+                log.error(f"Compiler '{self.name}' alias results in a loop.")
+                return
+            if alias not in _compilers:
+                log.error(
+                    f"Compiler '{self.name}' aliases unrecognized '{alias}'.",
+                )
+                return
+            alias_chain.append(alias)
+
+        alias = alias_chain[-1]
+        self.compiler = _compilers[alias]
+        log.info(
+            f"Compiler '{self.name}' recognized; aliases '{alias}'.",
+        )
 
     def parse_args(self, argv: list[str]) -> list[PreprocessorConfiguration]:
         """
@@ -290,186 +338,105 @@ class ArgumentParser:
             a separate pass, that describe the compiler's behavior
             after parsing `argv`.
         """
-        args = _parse_compiler_args(argv + _importcfg[self.name])
-        configuration = PreprocessorConfiguration(
-            args.defines,
-            args.include_paths,
-            args.include_files,
+        # There may be no actions defined with these destinations.
+        namespace = argparse.Namespace()
+        namespace.defines = []
+        namespace.include_paths = []
+        namespace.include_files = []
+        namespace.modes = []
+
+        # "passes" requires special handling to emulate compilers accurately.
+        # - passes: Passes from built-in actions (e.g., store, append).
+        # - _passes: Flag-specific defaults from custom actions.
+        namespace.passes = []
+        namespace._passes = {}
+
+        # Configure the parser for arguments common to all compilers.
+        parser = argparse.ArgumentParser(
+            add_help=False,
+            exit_on_error=False,
+            allow_abbrev=False,
         )
-        return [configuration]
-
-
-class ClangArgumentParser(ArgumentParser):
-    """
-    Represents the behavior of Clang-based compilers.
-    """
-
-    device_passes = [
-        "spir64",
-        "x86_64",
-        "spir64_x86_64",
-        "spir64_gen",
-        "spir64_fpga",
-    ]
-
-    def parse_args(self, argv: list[str]) -> list[PreprocessorConfiguration]:
-        args = _parse_compiler_args(argv + _importcfg[self.name])
-
-        sycl = False
-        omp = False
-        sycl_targets = []
-        passes = {"default"}
-
-        for arg in argv:
-            if arg == "-fsycl":
-                sycl = True
-                continue
-
-            m = re.search("-fsycl-targets=", arg)
-            if m:
-                sycl_targets = arg.split("=")[1].split(",")
-                passes |= set(sycl_targets)
-                continue
-
-            if arg in ["-fopenmp", "-fiopenmp", "-qopenmp"]:
-                omp = True
-                continue
-
-            if arg in ["-fsycl-is-device"]:
-                args.defines.append("__SYCL_DEVICE_ONLY__")
-                continue
-
-        if sycl and sycl_targets == []:
-            passes |= {"spir64"}
-
-        configurations = []
-        for pass_ in passes:
-            defines = args.defines.copy()
-            include_files = args.include_files.copy()
-            include_paths = args.include_paths.copy()
-
-            if pass_ in ClangArgumentParser.device_passes:
-                defines.append("__SYCL_DEVICE_ONLY__")
-
-            if "spir64" in pass_ or pass_ == "x86_64":
-                defines.append("__SPIR__")
-                defines.append("__SPIRV__")
-
-            if pass_ == "default" and omp:
-                defines.append("_OPENMP")
-
-            configuration = PreprocessorConfiguration(
-                defines,
-                include_paths,
-                include_files,
-                pass_name=pass_,
-            )
-
-            configurations.append(configuration)
-
-        return configurations
-
-
-class GnuArgumentParser(ArgumentParser):
-    """
-    Represents the behavior of GNU-based compilers.
-    """
-
-    def parse_args(self, argv: list[str]) -> list[PreprocessorConfiguration]:
-        args = _parse_compiler_args(argv + _importcfg[self.name])
-        for arg in argv:
-            if arg == "-fopenmp":
-                args.defines.append("_OPENMP")
-        configuration = PreprocessorConfiguration(
-            args.defines,
-            args.include_paths,
-            args.include_files,
+        parser.add_argument("-D", dest="defines", action="append")
+        parser.add_argument(
+            "-I",
+            "-isystem",
+            dest="include_paths",
+            action="append",
+        )
+        parser.add_argument(
+            "-include",
+            dest="include_files",
+            action="append",
         )
-        return [configuration]
-
-
-class NvccArgumentParser(ArgumentParser):
-    """
-    Represents the behavior of the NVCC compiler.
-    """
-
-    def parse_args(self, argv: list[str]) -> list[PreprocessorConfiguration]:
-        args = _parse_compiler_args(argv + _importcfg[self.name])
 
-        omp = False
-        passes = {"default"}
+        # Suppress warnings for common arguments we don't care about.
+        parser.add_argument("-O", dest=None)
+        parser.add_argument("-o", dest=None)
+        parser.add_argument("-g", action="store_const", dest=None)
+        parser.add_argument("-c", action="store_const", dest=None)
+        parser.add_argument("file", nargs="*")
+
+        # Add additional options for this specific compiler.
+        for option in self.compiler.parser:
+            kwargs = {k: v for k, v in option.items() if k != "flags"}
+
+            # If a custom action, handle special-case for default passes.
+            if not isinstance(kwargs["action"], str):
+                if (
+                    "dest" in kwargs
+                    and kwargs["dest"] == "passes"
+                    and "default" in kwargs
+                ):
+                    default_value = kwargs.pop("default")
+                    flag_name = option["flags"][0]
+                    namespace._passes[flag_name] = default_value
+            parser.add_argument(*option["flags"], **kwargs)
+
+        # Make a best-effort attempt to parse arguments.
+        args, unrecognized = parser.parse_known_args(
+            argv + self.compiler.options,
+            namespace,
+        )
+        if unrecognized:
+            log.warning(f"Unrecognized arguments: '{' '.join(unrecognized)}'")
 
-        for arg in argv:
-            archs = re.findall("sm_(\\d+)", arg)
-            archs += re.findall("compute_(\\d+)", arg)
-            passes |= set(archs)
+        # Construct final list of active modes.
+        args.modes = set(args.modes)
 
-            if arg in ["-fopenmp", "-fiopenmp", "-qopenmp"]:
-                omp = True
-                continue
+        # Construct final list of active passes.
+        args.passes = set(args.passes)
+        args.passes |= set(chain(*args._passes.values()))
+        args.passes |= {"default"}
 
+        # Convert the arguments into a list of preprocessor configurations.
         configurations = []
-        for pass_ in passes:
-            defines = args.defines.copy()
-            include_files = args.include_files.copy()
-            include_paths = args.include_paths.copy()
-
-            defines.append("__NVCC__")
-            defines.append("__CUDACC__")
-
-            if pass_ != "default":
-                # The __CUDA_ARCH__ macro always has three digits.
-                # Multiplying the SM version by 10 gives the macro value.
-                # e.g., sm_71 corresponds to __CUDA_ARCH__=710.
-                arch = int(pass_) * 10
-                defines.append(f"__CUDA_ARCH__={arch}")
-
-            if pass_ == "default" and omp:
-                defines.append("_OPENMP")
-
-            configuration = PreprocessorConfiguration(
-                defines,
-                include_paths,
-                include_files,
-                pass_name=pass_,
+        for pass_name in args.passes:
+            config = PreprocessorConfiguration(
+                args.defines.copy(),
+                args.include_paths.copy(),
+                args.include_files.copy(),
+                pass_name,
             )
 
-            configurations.append(configuration)
-
-        return configurations
-
+            if pass_name == "default":
+                modes = args.modes
+            else:
+                if pass_name not in self.compiler.passes:
+                    log.error(f"Unrecognized compiler pass: {pass_name}")
+                    continue
+                config._update(self.compiler.passes[pass_name])
+                modes = self.compiler.passes[pass_name].modes
 
-_seen_compiler = collections.defaultdict(lambda: False)
+            for mode_name in modes:
+                if mode_name not in self.compiler.modes:
+                    log.error(f"Unrecognized compiler mode: {mode_name}")
+                    continue
+                config._update(self.compiler.modes[mode_name])
 
+            configurations.append(config)
 
-def recognize_compiler(path: str) -> ArgumentParser:
-    """
-    Attempt to recognize the compiler, given a path.
-    Return a ArgumentParser object.
-    """
-    parser = None
-    compiler_name = os.path.basename(path)
-    if compiler_name in ["clang", "clang++"]:
-        parser = ClangArgumentParser(path)
-    elif compiler_name in ["gcc", "g++"]:
-        parser = GnuArgumentParser(path)
-    elif compiler_name in ["icx", "icpx", "ifx"]:
-        parser = ClangArgumentParser(path)
-    elif compiler_name == "nvcc":
-        parser = NvccArgumentParser(path)
-    else:
-        parser = ArgumentParser(path)
-
-    if not _seen_compiler[compiler_name]:
-        if parser:
-            log.info(f"Recognized compiler: {compiler_name}.")
-        else:
-            log.warning(
-                f"Unrecognized compiler: {compiler_name}. "
-                + "Some implicit behavior may be missed.",
-            )
-        _seen_compiler[compiler_name] = True
-    return parser
+        return configurations
 
 
 def load_database(dbpath, rootdir):
@@ -513,7 +480,7 @@ def load_database(dbpath, rootdir):
 
         # Parse command-line arguments, emulating compiler-specific behavior.
         compiler_name = os.path.basename(command.arguments[0])
-        parser = recognize_compiler(compiler_name)
+        parser = ArgumentParser(compiler_name)
         preprocessor_configs = parser.parse_args(command.arguments[1:])
 
         # Create a configuration entry for each compiler pass.
diff --git a/codebasin/coverage/__main__.py b/codebasin/coverage/__main__.py
index fbfb20d..8c221c7 100755
--- a/codebasin/coverage/__main__.py
+++ b/codebasin/coverage/__main__.py
@@ -133,19 +133,22 @@ def _compute(args: argparse.Namespace):
         with open(filename, "rb") as f:
             digest = hashlib.file_digest(f, "sha512")
 
-        lines = []
+        used_lines = []
+        unused_lines = []
         tree = state.get_tree(filename)
         association = state.get_map(filename)
         for node in [n for n in tree.walk() if isinstance(n, CodeNode)]:
             if association[node] == frozenset([]):
-                continue
-            lines.extend(node.lines)
+                unused_lines.extend(node.lines)
+            else:
+                used_lines.extend(node.lines)
 
         covarray.append(
             {
                 "file": relative_path,
                 "id": digest.hexdigest(),
-                "lines": lines,
+                "used_lines": used_lines,
+                "unused_lines": unused_lines,
             },
         )
 
diff --git a/codebasin/report.py b/codebasin/report.py
index 0270a4f..fb7638f 100644
--- a/codebasin/report.py
+++ b/codebasin/report.py
@@ -649,9 +649,11 @@ class FileTree:
     def _print(
         self,
         node: Node,
+        depth: int = 0,
         prefix: str = "",
         connector: str = "",
         fancy: bool = True,
+        levels: int = None,
     ):
         """
         Recursive helper function to print all nodes in a FileTree.
@@ -669,7 +671,14 @@ class FileTree:
 
         fancy: bool, default: True
             Whether to use fancy formatting (including colors).
+
+        levels: int, optional
+            The maximum number of levels to print.
         """
+        # Skip this node and its children if we have hit the maximum depth.
+        if levels and depth > levels:
+            return []
+
         if fancy:
             dash = "\u2500"
             cont = "\u251C"
@@ -722,14 +731,16 @@ class FileTree:
                 next_connector = cont
             lines += self._print(
                 node.children[name],
+                depth + 1,
                 next_prefix,
                 next_connector,
                 fancy,
+                levels,
             )
 
         return lines
 
-    def write_to(self, stream: TextIO):
+    def write_to(self, stream: TextIO, levels: int = None):
         """
         Write the FileTree to the specified stream.
 
@@ -737,8 +748,12 @@ class FileTree:
         ----------
         stream: TextIO
             The text stream to write to.
+
+        levels: int, optional
+            The maximum number of levels to print.
+            If 0, print only the top-level summary.
         """
-        lines = self._print(self.root, fancy=stream.isatty())
+        lines = self._print(self.root, fancy=stream.isatty(), levels=levels)
         output = "\n".join(lines)
         if not stream.isatty():
             output = _strip_colors(output)
@@ -748,7 +763,10 @@ class FileTree:
 def files(
     codebase: CodeBase,
     state: ParserState | None = None,
+    *,
     stream: TextIO = sys.stdout,
+    prune: bool = False,
+    levels: int = None,
 ):
     """
     Produce a file tree representing the code base.
@@ -787,11 +805,13 @@ def files(
             ):
                 platform = frozenset(association[node])
                 setmap[platform] += node.num_lines
+        if prune:
+            # Prune unused files from the tree.
+            platforms = set().union(*setmap.keys())
+            if len(platforms) == 0:
+                continue
         tree.insert(f, setmap)
 
-    print("", file=stream)
-    print(_heading("Files", stream), file=stream)
-
     # Print a legend.
     legend = []
     legend += ["Legend:"]
@@ -814,4 +834,4 @@ def files(
     print(legend, file=stream)
 
     # Print the tree.
-    tree.write_to(stream)
+    tree.write_to(stream, levels=levels)
diff --git a/codebasin/tree.py b/codebasin/tree.py
new file mode 100755
index 0000000..c6a2408
--- /dev/null
+++ b/codebasin/tree.py
@@ -0,0 +1,187 @@
+#!/usr/bin/env python3
+# Copyright (C) 2019-2024 Intel Corporation
+# SPDX-License-Identifier: BSD-3-Clause
+
+import argparse
+import logging
+import os
+import sys
+
+from codebasin import CodeBase, config, finder, report, util
+
+# TODO: Refactor to avoid imports from __main__
+from codebasin.__main__ import Formatter, _help_string, version
+
+log = logging.getLogger("codebasin")
+
+
+def _build_parser() -> argparse.ArgumentParser:
+    """
+    Build argument parser.
+    """
+    parser = argparse.ArgumentParser(
+        description="CBI Tree Tool " + version,
+        formatter_class=argparse.RawTextHelpFormatter,
+        add_help=False,
+    )
+    parser.add_argument(
+        "-h",
+        "--help",
+        action="help",
+        help=_help_string("Display help message and exit."),
+    )
+    parser.add_argument(
+        "--version",
+        action="version",
+        version=f"CBI Coverage Tool {version}",
+        help=_help_string("Display version information and exit."),
+    )
+    parser.add_argument(
+        "-x",
+        "--exclude",
+        dest="excludes",
+        metavar="<pattern>",
+        action="append",
+        default=[],
+        help=_help_string(
+            "Exclude files matching this pattern from the code base.",
+            "May be specified multiple times.",
+            is_long=True,
+        ),
+    )
+    parser.add_argument(
+        "-p",
+        "--platform",
+        dest="platforms",
+        metavar="<platform>",
+        action="append",
+        default=[],
+        help=_help_string(
+            "Include the specified platform in the analysis.",
+            "May be specified multiple times.",
+            "If not specified, all platforms will be included.",
+            is_long=True,
+        ),
+    )
+    parser.add_argument(
+        "--prune",
+        dest="prune",
+        action="store_true",
+        help=_help_string(
+            "Prune unused files from the tree.",
+        ),
+    )
+    parser.add_argument(
+        "-L",
+        "--levels",
+        dest="levels",
+        metavar="<level>",
+        type=int,
+        help=_help_string(
+            "Print only the specified number of levels.",
+            is_long=True,
+            is_last=True,
+        ),
+    )
+
+    parser.add_argument(
+        "analysis_file",
+        metavar="<analysis-file>",
+        help=_help_string(
+            "TOML file describing the analysis to be performed, "
+            + "including the codebase and platform descriptions.",
+            is_last=True,
+        ),
+    )
+
+    return parser
+
+
+def _tree(args: argparse.Namespace):
+    # Refuse to print a tree with no levels, consistent with tree utility.
+    if args.levels is not None and args.levels <= 0:
+        raise ValueError("Number of levels must be greater than 0.")
+
+    # TODO: Refactor this to avoid duplication in __main__
+    # Determine the root directory based on where codebasin is run.
+    rootdir = os.path.abspath(os.getcwd())
+
+    # Set up a default configuration object.
+    configuration = {}
+
+    # Load the analysis file if it exists.
+    if args.analysis_file is not None:
+        path = os.path.abspath(args.analysis_file)
+        if os.path.exists(path):
+            if not os.path.splitext(path)[1] == ".toml":
+                raise RuntimeError(f"Analysis file {path} must end in .toml.")
+
+        with open(path, "rb") as f:
+            analysis_toml = util._load_toml(f, "analysis")
+
+        if "codebase" in analysis_toml:
+            if "exclude" in analysis_toml["codebase"]:
+                args.excludes += analysis_toml["codebase"]["exclude"]
+
+        for name in args.platforms:
+            if name not in analysis_toml["platform"].keys():
+                raise KeyError(
+                    f"Platform {name} requested on the command line "
+                    + "does not exist in the configuration file.",
+                )
+
+        cmd_platforms = args.platforms.copy()
+        for name in analysis_toml["platform"].keys():
+            if cmd_platforms and name not in cmd_platforms:
+                continue
+            if "commands" not in analysis_toml["platform"][name]:
+                raise ValueError(f"Missing 'commands' for platform {name}")
+            p = analysis_toml["platform"][name]["commands"]
+            db = config.load_database(p, rootdir)
+            args.platforms.append(name)
+            configuration.update({name: db})
+
+    # Construct a codebase object associated with the root directory.
+    codebase = CodeBase(rootdir, exclude_patterns=args.excludes)
+
+    # Parse the source tree, and determine source line associations.
+    # The trees and associations are housed in state.
+    state = finder.find(
+        rootdir,
+        codebase,
+        configuration,
+        show_progress=True,
+    )
+
+    # Print the file tree.
+    report.files(codebase, state, prune=args.prune, levels=args.levels)
+    sys.exit(0)
+
+
+def cli(argv: list[str]) -> int:
+    parser = _build_parser()
+    args = parser.parse_args(argv)
+
+    # Configure logging such that:
+    # - Only errors are written to the terminal
+    log.setLevel(logging.DEBUG)
+
+    stderr_handler = logging.StreamHandler(sys.stderr)
+    stderr_handler.setLevel(logging.ERROR)
+    stderr_handler.setFormatter(Formatter(colors=sys.stderr.isatty()))
+    log.addHandler(stderr_handler)
+
+    return _tree(args)
+
+
+def main():
+    try:
+        cli(sys.argv[1:])
+    except Exception as e:
+        log.error(str(e))
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    sys.argv[0] = "codebasin.tree"
+    main()
diff --git a/pyproject.toml b/pyproject.toml
index dc113f3..59b8bc7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -38,6 +38,7 @@ dependencies = [
 [project.scripts]
 codebasin = "codebasin:__main__.main"
 cbi-cov = "codebasin.coverage:__main__.main"
+cbi-tree = "codebasin:tree.main"
 
 [project.urls]
 "Github" = "https://www.github.com/intel/code-base-investigator"


Test patch:
diff --git a/tests/cli/test_cbicov.py b/tests/cli/test_cbicov.py
index 3d49fe9..07b8a77 100644
--- a/tests/cli/test_cbicov.py
+++ b/tests/cli/test_cbicov.py
@@ -97,12 +97,14 @@ class TestCbiCov(unittest.TestCase):
             {
                 "file": "bar.h",
                 "id": "3ba8372282f8f1bafc59bb3d0472dcd7ecd5f13a54f17585c6012bfc40bfba7b9afb905f24ccea087546f4c90363bba97d988e4067ec880f619d0ab623c3a7a1",  # noqa: E501
-                "lines": [],
+                "used_lines": [],
+                "unused_lines": [1],
             },
             {
                 "file": "foo.cpp",
                 "id": "1359957a144db36091624c1091ac6a47c47945a3ff63a47ace3dc5c1b13159929adac14c21733ec1054f7f1f8809ac416e643483191aab6687d7849ee17edaa0",  # noqa: E501
-                "lines": [1, 3, 4],
+                "used_lines": [1, 3, 4],
+                "unused_lines": [2],
             },
         ]
         self.assertCountEqual(coverage, expected_coverage)
diff --git a/tests/compilers/test_compilers.py b/tests/compilers/test_compilers.py
index 357fe63..0d05cb9 100644
--- a/tests/compilers/test_compilers.py
+++ b/tests/compilers/test_compilers.py
@@ -8,6 +8,7 @@ import unittest
 from pathlib import Path
 
 from codebasin import config
+from codebasin.config import ArgumentParser
 
 
 class TestCompilers(unittest.TestCase):
@@ -42,23 +43,28 @@ class TestCompilers(unittest.TestCase):
             "MACRO_AFTER_SPACE",
             "test.cpp",
         ]
-        args = config._parse_compiler_args(argv)
-        self.assertEqual(
-            args.defines,
+        parser = ArgumentParser(argv[0])
+
+        passes = parser.parse_args(argv[1:])
+        self.assertEqual(len(passes), 1)
+
+        self.assertEqual(passes[0].pass_name, "default")
+
+        self.assertCountEqual(
+            passes[0].defines,
             ["MACRO", "FUNCTION_MACRO=1", "MACRO_AFTER_SPACE"],
         )
-        self.assertEqual(
-            args.include_paths,
+        self.assertCountEqual(
+            passes[0].include_paths,
             ["/path", "/path/after/space", "/system/path"],
         )
-        self.assertEqual(args.include_files, ["foo.inc", "bar.inc"])
+        self.assertCountEqual(passes[0].include_files, ["foo.inc", "bar.inc"])
 
     def test_gnu(self):
         """compilers/gnu"""
         argv = ["g++", "-fopenmp", "test.cpp"]
 
-        parser = config.recognize_compiler(argv[0])
-        self.assertTrue(type(parser) is config.GnuArgumentParser)
+        parser = ArgumentParser(argv[0])
 
         passes = parser.parse_args(argv[1:])
         self.assertEqual(len(passes), 1)
@@ -66,14 +72,13 @@ class TestCompilers(unittest.TestCase):
         self.assertEqual(passes[0].pass_name, "default")
 
         defines = passes[0].defines
-        self.assertEqual(defines, ["_OPENMP"])
+        self.assertCountEqual(defines, ["_OPENMP"])
 
     def test_clang(self):
         """compilers/clang"""
         argv = ["clang", "-fsycl-is-device", "test.cpp"]
 
-        parser = config.recognize_compiler(argv[0])
-        self.assertTrue(type(parser) is config.ClangArgumentParser)
+        parser = ArgumentParser(argv[0])
 
         passes = parser.parse_args(argv[1:])
         self.assertEqual(len(passes), 1)
@@ -81,81 +86,93 @@ class TestCompilers(unittest.TestCase):
         self.assertEqual(passes[0].pass_name, "default")
 
         defines = passes[0].defines
-        self.assertEqual(defines, ["__SYCL_DEVICE_ONLY__"])
+        self.assertCountEqual(defines, ["__SYCL_DEVICE_ONLY__"])
 
     def test_intel_sycl(self):
         """compilers/intel_sycl"""
         argv = ["icpx", "-fsycl", "test.cpp"]
 
-        parser = config.recognize_compiler(argv[0])
-        self.assertTrue(type(parser) is config.ClangArgumentParser)
+        parser = ArgumentParser(argv[0])
 
         passes = parser.parse_args(argv[1:])
         self.assertEqual(len(passes), 2)
 
         pass_names = {p.pass_name for p in passes}
-        self.assertEqual(pass_names, {"default", "spir64"})
+        self.assertCountEqual(pass_names, {"default", "sycl-spir64"})
 
         for p in passes:
             if p.pass_name == "default":
-                expected = []
+                expected = ["SYCL_LANGUAGE_VERSION"]
             else:
-                expected = ["__SYCL_DEVICE_ONLY__", "__SPIR__", "__SPIRV__"]
-            self.assertEqual(p.defines, expected)
+                expected = [
+                    "SYCL_LANGUAGE_VERSION",
+                    "__SYCL_DEVICE_ONLY__",
+                    "__SPIR__",
+                    "__SPIRV__",
+                ]
+            self.assertCountEqual(p.defines, expected)
 
     def test_intel_targets(self):
         """compilers/intel_targets"""
         argv = [
             "icpx",
             "-fsycl",
-            "-fsycl-targets=spir64,x86_64",
+            "-fsycl-targets=spir64,spir64_x86_64",
             "-fopenmp",
             "test.cpp",
         ]
 
-        parser = config.recognize_compiler(argv[0])
-        self.assertTrue(type(parser) is config.ClangArgumentParser)
+        parser = ArgumentParser(argv[0])
 
         passes = parser.parse_args(argv[1:])
 
         pass_names = {p.pass_name for p in passes}
-        self.assertEqual(pass_names, {"default", "spir64", "x86_64"})
+        self.assertCountEqual(
+            pass_names,
+            {"default", "sycl-spir64", "sycl-spir64_x86_64"},
+        )
 
         for p in passes:
             if p.pass_name == "default":
-                expected = ["_OPENMP"]
-                self.assertEqual(p.defines, ["_OPENMP"])
-            elif p.pass_name == "spir64" or p.pass_name == "x86_64":
-                expected = ["__SYCL_DEVICE_ONLY__", "__SPIR__", "__SPIRV__"]
-            self.assertEqual(p.defines, expected)
+                expected = ["SYCL_LANGUAGE_VERSION", "_OPENMP"]
+            elif (
+                p.pass_name == "sycl-spir64"
+                or p.pass_name == "sycl-spir64_x86_64"
+            ):
+                expected = [
+                    "SYCL_LANGUAGE_VERSION",
+                    "__SYCL_DEVICE_ONLY__",
+                    "__SPIR__",
+                    "__SPIRV__",
+                ]
+            self.assertCountEqual(p.defines, expected)
 
     def test_nvcc(self):
         """compilers/nvcc"""
         argv = [
             "nvcc",
             "-fopenmp",
-            "--gpu-architecture=compute_50",
-            "--gpu-code=compute_50,sm_50,sm_52",
+            "--gpu-architecture=compute_70",
+            "--gpu-code=compute_70,sm_70,sm_75",
             "test.cpp",
         ]
 
-        parser = config.recognize_compiler(argv[0])
-        self.assertTrue(type(parser) is config.NvccArgumentParser)
+        parser = ArgumentParser(argv[0])
 
         passes = parser.parse_args(argv[1:])
 
         pass_names = {p.pass_name for p in passes}
-        self.assertEqual(pass_names, {"default", "50", "52"})
+        self.assertCountEqual(pass_names, {"default", "sm_70", "sm_75"})
 
         defaults = ["__NVCC__", "__CUDACC__"]
         for p in passes:
             if p.pass_name == "default":
                 expected = defaults + ["_OPENMP"]
-            elif p.pass_name == "50":
-                expected = defaults + ["__CUDA_ARCH__=500"]
-            elif p.pass_name == "52":
-                expected = defaults + ["__CUDA_ARCH__=520"]
-            self.assertEqual(p.defines, expected)
+            elif p.pass_name == "sm_70":
+                expected = defaults + ["__CUDA_ARCH__=700"]
+            elif p.pass_name == "sm_75":
+                expected = defaults + ["__CUDA_ARCH__=750"]
+            self.assertCountEqual(p.defines, expected)
 
     def test_user_options(self):
         """Check that we import user-defined options"""
@@ -166,20 +183,245 @@ class TestCompilers(unittest.TestCase):
         with open(path / ".cbi" / "config", mode="w") as f:
             f.write('[compiler."c++"]\n')
             f.write('options = ["-D", "ASDF"]\n')
-        config.load_importcfg()
+        config._load_compilers()
 
         argv = [
             "c++",
             "test.cpp",
         ]
 
-        parser = config.recognize_compiler(argv[0])
+        parser = ArgumentParser(argv[0])
         passes = parser.parse_args(argv[1:])
         self.assertEqual(len(passes), 1)
         self.assertCountEqual(passes[0].defines, ["ASDF"])
 
         tmp.cleanup()
 
+    def test_user_alias(self):
+        """Check that we import user-defined aliases"""
+        tmp = tempfile.TemporaryDirectory()
+        path = Path(tmp.name)
+        os.chdir(tmp.name)
+        os.mkdir(".cbi")
+        with open(path / ".cbi" / "config", mode="w") as f:
+            f.write('[compiler."c++"]\n')
+            f.write('alias_of = "clang"\n')
+        config._load_compilers()
+
+        parser = ArgumentParser("c++")
+        passes = parser.parse_args(["-fopenmp", "test.cpp"])
+
+        self.assertEqual(len(passes), 1)
+        self.assertCountEqual(passes[0].defines, ["_OPENMP"])
+
+        tmp.cleanup()
+
+    def test_nested_aliases(self):
+        """Check that we handle user-defined aliases to aliases"""
+        tmp = tempfile.TemporaryDirectory()
+        path = Path(tmp.name)
+        os.chdir(tmp.name)
+        os.mkdir(".cbi")
+        with open(path / ".cbi" / "config", mode="w") as f:
+            f.write('[compiler."c++"]\n')
+            f.write('alias_of = "g++"\n')
+        config._load_compilers()
+
+        parser = ArgumentParser("c++")
+        passes = parser.parse_args(["-fopenmp", "test.cpp"])
+
+        self.assertEqual(len(passes), 1)
+        self.assertCountEqual(passes[0].defines, ["_OPENMP"])
+
+        tmp.cleanup()
+
+    def test_alias_loops(self):
+        """Check that we identify and complain about alias loops"""
+        tmp = tempfile.TemporaryDirectory()
+        path = Path(tmp.name)
+        os.chdir(tmp.name)
+        os.mkdir(".cbi")
+        with open(path / ".cbi" / "config", mode="w") as f:
+            f.write("[compiler.foo]\n")
+            f.write('alias_of = "bar"\n')
+            f.write("[compiler.bar]\n")
+            f.write('alias_of = "foo"\n')
+        config._load_compilers()
+
+        logging.disable(logging.NOTSET)
+        with self.assertLogs("codebasin", level=logging.ERROR):
+            _ = ArgumentParser("foo")
+        logging.disable()
+
+        tmp.cleanup()
+
+    def test_user_parser(self):
+        """Check that we import user-defined parser"""
+        tmp = tempfile.TemporaryDirectory()
+        path = Path(tmp.name)
+        os.chdir(tmp.name)
+        os.mkdir(".cbi")
+        with open(path / ".cbi" / "config", mode="w") as f:
+            f.write('[[compiler."c++".parser]]\n')
+            f.write('flags = ["-fasdf"]\n')
+            f.write('action = "append_const"\n')
+            f.write('dest = "passes"\n')
+            f.write('const = "asdf-pass"\n')
+            f.write('[[compiler."c++".modes]]\n')
+            f.write('name = "asdf-mode"\n')
+            f.write('defines = ["ASDF"]\n')
+            f.write('[[compiler."c++".passes]]\n')
+            f.write('name = "asdf-pass"\n')
+            f.write('modes = ["asdf-mode"]\n')
+
+        config._load_compilers()
+
+        parser = ArgumentParser("c++")
+        passes = parser.parse_args(["-fasdf", "test.cpp"])
+
+        self.assertEqual(len(passes), 2)
+        pass_names = {p.pass_name for p in passes}
+        self.assertCountEqual(pass_names, {"default", "asdf-pass"})
+
+        for p in passes:
+            if p.pass_name == "default":
+                expected = []
+            elif p.pass_name == "asdf-pass":
+                expected = ["ASDF"]
+            self.assertCountEqual(p.defines, expected)
+
+        tmp.cleanup()
+
+    def test_user_overrides(self):
+        """Check that we can merge user-defined overrides"""
+        tmp = tempfile.TemporaryDirectory()
+        path = Path(tmp.name)
+        os.chdir(tmp.name)
+        os.mkdir(".cbi")
+        with open(path / ".cbi" / "config", mode="w") as f:
+            f.write('[[compiler."g++".parser]]\n')
+            f.write('flags = ["-fopenmp"]\n')
+            f.write('action = "append_const"\n')
+            f.write('dest = "passes"\n')
+            f.write('const = "new-openmp-pass"\n')
+            f.write("\n")
+            f.write('[[compiler."g++".parser]]\n')
+            f.write('flags = ["-fextend"]\n')
+            f.write('action = "extend_match"\n')
+            f.write('pattern = "*"\n')
+            f.write('dest = "passes"\n')
+            f.write("\n")
+            f.write('[[compiler."g++".parser]]\n')
+            f.write('flags = ["-fstore"]\n')
+            f.write('action = "store_split"\n')
+            f.write('sep = ","\n')
+            f.write('dest = "passes"\n')
+            f.write("\n")
+            f.write('[[compiler."g++".passes]]\n')
+            f.write('name = "new-openmp-pass"\n')
+            f.write('modes = ["new-openmp-mode"]\n')
+            f.write("\n")
+            f.write('[[compiler."g++".modes]]\n')
+            f.write('name = "new-openmp-mode"\n')
+            f.write('defines = ["_NEW_OPENMP"]\n')
+            f.write("\n")
+            f.write('[compiler."g++"]\n')
+            f.write('options = ["-D", "EXTRA_DEFINE"]\n')
+            f.write("\n")
+            f.write("[[compiler.clang.modes]]\n")
+            f.write('name = "openmp"\n')
+            f.write("defines = []\n")
+            f.write("\n")
+            f.write("[[compiler.icx.passes]]\n")
+            f.write('name = "sycl-spir64"\n')
+            f.write("defines = []\n")
+            f.write("\n")
+            f.write("[compiler.nvcc]\n")
+            f.write('alias_of = "unknown"\n')
+
+        # Verify that the correct warnings are generated when loading the file.
+        logging.disable(logging.NOTSET)
+        with self.assertLogs("codebasin", level=logging.WARNING) as cm:
+            config._load_compilers()
+        logging.disable()
+
+        self.assertEqual(
+            cm.output,
+            [
+                "WARNING:codebasin.config:definition of g++ in .cbi/config overrides alias.",  # noqa: E501
+                "WARNING:codebasin.config:compiler mode 'openmp' redefined",  # noqa: E501
+                "WARNING:codebasin.config:compiler pass 'sycl-spir64' redefined",  # noqa: E501
+                "WARNING:codebasin.config:nvcc redefined as alias of unknown.",  # noqa: E501
+            ],
+        )
+
+        # Verify that user-defined overrides affect passes, etc.
+        parser = ArgumentParser("g++")
+        argv = ["-fopenmp"]
+        passes = parser.parse_args(argv)
+
+        pass_names = {p.pass_name for p in passes}
+        self.assertCountEqual(pass_names, {"default", "new-openmp-pass"})
+
+        for p in passes:
+            if p.pass_name == "default":
+                expected = ["EXTRA_DEFINE"]
+            else:
+                expected = ["EXTRA_DEFINE", "_NEW_OPENMP"]
+            self.assertCountEqual(p.defines, expected)
+
+        # Verify that invalid aliases are identified.
+        logging.disable(logging.NOTSET)
+        with self.assertLogs("codebasin", level=logging.ERROR) as cm:
+            _ = ArgumentParser("nvcc")
+        logging.disable()
+
+        self.assertEqual(
+            cm.output,
+            [
+                "ERROR:codebasin.config:Compiler 'nvcc' aliases unrecognized 'unknown'.",  # noqa: E501
+            ],
+        )
+
+        tmp.cleanup()
+
+    def test_unrecognized(self):
+        """Check that we report unrecognized compilers, modes and passes."""
+        tmp = tempfile.TemporaryDirectory()
+        path = Path(tmp.name)
+        os.chdir(tmp.name)
+        os.mkdir(".cbi")
+        with open(path / ".cbi" / "config", mode="w") as f:
+            f.write("[[compiler.foo.parser]]\n")
+            f.write('flags = ["-pass"]\n')
+            f.write('action = "append_const"\n')
+            f.write('dest = "passes"\n')
+            f.write('const = "unrecognized-pass"\n')
+            f.write("[[compiler.foo.parser]]\n")
+            f.write('flags = ["-mode"]\n')
+            f.write('action = "append_const"\n')
+            f.write('dest = "modes"\n')
+            f.write('const = "unrecognized-mode"\n')
+        config._load_compilers()
+
+        logging.disable(logging.NOTSET)
+        with self.assertLogs("codebasin", level=logging.WARNING) as cm:
+            _ = ArgumentParser("foo").parse_args(
+                ["-pass", "-mode", "-unrecognized"],
+            )
+        logging.disable()
+
+        self.assertCountEqual(
+            cm.output,
+            [
+                "WARNING:codebasin.config:Unrecognized arguments: '-unrecognized'",  # noqa: E501
+                "ERROR:codebasin.config:Unrecognized compiler pass: unrecognized-pass",  # noqa: E501
+                "ERROR:codebasin.config:Unrecognized compiler mode: unrecognized-mode",  # noqa: E501
+            ],
+        )
+
+        tmp.cleanup()
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/tests/files/test_filetree.py b/tests/files/test_filetree.py
index 22ed3c4..2583a24 100644
--- a/tests/files/test_filetree.py
+++ b/tests/files/test_filetree.py
@@ -32,7 +32,6 @@ class TestFileTree(unittest.TestCase):
         self.path = Path(self.tmp.name)
         open(self.path / "file.cpp", mode="w").close()
         open(self.path / "other.cpp", mode="w").close()
-        open(self.path / "unused.cpp", mode="w").close()
         os.symlink(self.path / "file.cpp", self.path / "symlink.cpp")
 
     @classmethod
@@ -153,6 +152,115 @@ class TestFileTree(unittest.TestCase):
         self.assertTrue("[-B | 1 | 100.00 |  50.00]" in output)
         self.assertTrue("[A- | 1 | 100.00 |  50.00]" in output)
 
+    def test_levels(self):
+        """Check report --levels flag works correctly"""
+        # Set up subdirectories for this test
+        tmp = tempfile.TemporaryDirectory()
+        path = Path(tmp.name)
+        os.makedirs(path / "first" / "second" / "third")
+        open(path / "first" / "one.cpp", mode="w").close()
+        open(path / "first" / "second" / "two.cpp", mode="w").close()
+
+        codebase = CodeBase(path)
+        configuration = {
+            "X": [
+                {
+                    "file": str(path / "first" / "one.cpp"),
+                    "defines": [],
+                    "include_paths": [],
+                    "include_files": [],
+                },
+            ],
+            "Y": [
+                {
+                    "file": str(path / "first" / "second" / "two.cpp"),
+                    "defines": [],
+                    "include_paths": [],
+                    "include_files": [],
+                },
+            ],
+        }
+        state = finder.find(
+            path,
+            codebase,
+            configuration,
+            show_progress=False,
+        )
+
+        # By default, we should see all levels of the tree.
+        stream = io.StringIO()
+        report.files(codebase, state, stream=stream)
+        output = stream.getvalue()
+        self.assertTrue(str(path) in output)
+        self.assertTrue("first/" in output)
+        self.assertTrue("one.cpp" in output)
+        self.assertTrue("two.cpp" in output)
+
+        # With two levels, the "second" directory should be collapsed.
+        # This will hide "two.cpp" from the output.
+        stream = io.StringIO()
+        report.files(codebase, state, stream=stream, levels=2)
+        output = stream.getvalue()
+        self.assertTrue(str(path) in output)
+        self.assertTrue("first/" in output)
+        self.assertTrue("one.cpp" in output)
+        self.assertFalse("two.cpp" in output)
+
+        # With just one level, the "first" directory should be collapsed.
+        # This will hide "one.cpp" and "two.cpp" from the output.
+        stream = io.StringIO()
+        report.files(codebase, state, stream=stream, levels=1)
+        output = stream.getvalue()
+        self.assertTrue(str(path) in output)
+        self.assertTrue("first/" in output)
+        self.assertFalse("one.cpp" in output)
+        self.assertFalse("two.cpp" in output)
+
+        tmp.cleanup()
+
+    def test_prune(self):
+        """Check report --prune flag works correctly"""
+        # Set up subdirectories for this test
+        tmp = tempfile.TemporaryDirectory()
+        path = Path(tmp.name)
+        with open(path / "foo.cpp", mode="w") as f:
+            f.write("void foo();")
+        open(path / "unused.cpp", mode="w").close()
+
+        codebase = CodeBase(path)
+        configuration = {
+            "X": [
+                {
+                    "file": str(path / "foo.cpp"),
+                    "defines": [],
+                    "include_paths": [],
+                    "include_files": [],
+                },
+            ],
+        }
+        state = finder.find(
+            path,
+            codebase,
+            configuration,
+            show_progress=False,
+        )
+
+        # By default, we should see both used and unused files.
+        stream = io.StringIO()
+        report.files(codebase, state, stream=stream)
+        output = stream.getvalue()
+        self.assertTrue("foo.cpp" in output)
+        self.assertTrue("unused.cpp" in output)
+
+        # With prune, we should only see the used files.
+        stream = io.StringIO()
+        report.files(codebase, state, stream=stream, prune=True)
+        output = stream.getvalue()
+        self.assertTrue("foo.cpp" in output)
+        self.assertFalse("unused.cpp" in output)
+
+        tmp.cleanup()
+
 
 if __name__ == "__main__":
     unittest.main()