Skip to content

Commit 9a287be

Browse files
committed
feat(cli): add browser-open HTML reports and timestamped default report paths
1 parent 633c890 commit 9a287be

11 files changed

Lines changed: 379 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ ahead of the final `2.0.0` release.
7575
- `--md``.cache/codeclone/report.md`
7676
- `--sarif``.cache/codeclone/report.sarif`
7777
- `--text``.cache/codeclone/report.txt`
78+
- Added local report UX helpers:
79+
- `--open-html-report` opens the generated HTML report after a successful write
80+
- `--timestamped-report-paths` appends a UTC timestamp to default report filenames requested via bare report flags
7881
- Added optional-value path flags for default-path intent:
7982
- `--baseline`
8083
- `--metrics-baseline`

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ pip install codeclone # or: uv tool install codeclone
4040

4141
codeclone . # analyze current directory
4242
codeclone . --html # generate HTML report
43+
codeclone . --html --open-html-report # generate and open HTML report
4344
codeclone . --json --md --sarif --text # generate machine-readable reports
45+
codeclone . --html --json --timestamped-report-paths # keep timestamped report snapshots
4446
codeclone . --ci # CI mode (--fail-on-new --no-color --quiet)
4547
```
4648

@@ -148,7 +150,11 @@ Contract errors (`2`) take precedence over gating failures (`3`).
148150
| Text | `--text` | `.cache/codeclone/report.txt` |
149151

150152
All report formats are rendered from one canonical JSON report document.
151-
Structural findings include:
153+
154+
- `--open-html-report` opens the generated HTML report in the default browser and requires `--html`.
155+
- `--timestamped-report-paths` appends a UTC timestamp to default report filenames for bare report flags such as
156+
`--html` or `--json`. Explicit report paths are not rewritten.
157+
Structural findings include:
152158

153159
- `duplicated_branches`
154160
- `clone_guard_exit_divergence`
@@ -284,7 +290,7 @@ Architecture: [`docs/architecture.md`](docs/architecture.md) · CFG semantics: [
284290
| Docker benchmark contract | [`docs/book/18-benchmarking.md`](docs/book/18-benchmarking.md) |
285291
| Determinism | [`docs/book/12-determinism.md`](docs/book/12-determinism.md) |
286292

287-
## * Benchmarking
293+
## * Benchmarking
288294

289295
<details>
290296
<summary>Reproducible Docker Benchmark</summary>

codeclone/_cli_args.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,8 +299,18 @@ def build_parser(version: str) -> _ArgumentParser:
299299
const=DEFAULT_TEXT_REPORT_PATH,
300300
help_text=ui.HELP_TEXT,
301301
)
302+
_add_bool_optional_argument(
303+
reporting_group,
304+
flag="--timestamped-report-paths",
305+
help_text=ui.HELP_TIMESTAMPED_REPORT_PATHS,
306+
)
302307

303308
ui_group = ap.add_argument_group("Output and UI")
309+
_add_bool_optional_argument(
310+
ui_group,
311+
flag="--open-html-report",
312+
help_text=ui.HELP_OPEN_HTML_REPORT,
313+
)
304314
ui_group.add_argument(
305315
"--no-progress",
306316
dest="no_progress",

codeclone/_cli_meta.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ def _build_report_meta(
9191
health_grade: str | None,
9292
analysis_mode: str,
9393
metrics_computed: tuple[str, ...],
94+
report_generated_at_utc: str,
9495
) -> ReportMeta:
9596
project_name = scan_root.name or str(scan_root)
9697
return {
@@ -132,5 +133,5 @@ def _build_report_meta(
132133
"health_grade": health_grade,
133134
"analysis_mode": analysis_mode,
134135
"metrics_computed": list(metrics_computed),
135-
"report_generated_at_utc": _current_report_timestamp_utc(),
136+
"report_generated_at_utc": report_generated_at_utc,
136137
}

codeclone/_cli_reports.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from __future__ import annotations
55

66
import sys
7+
import webbrowser
78
from pathlib import Path
89
from typing import Protocol
910

@@ -74,12 +75,18 @@ def _write_report_output(
7475
sys.exit(ExitCode.CONTRACT_ERROR)
7576

7677

78+
def _open_html_report_in_browser(*, path: Path) -> None:
79+
if not webbrowser.open_new_tab(path.as_uri()):
80+
raise OSError("no browser handler available")
81+
82+
7783
def write_report_outputs(
7884
*,
7985
args: _QuietArgs,
8086
output_paths: _OutputPaths,
8187
report_artifacts: _ReportArtifacts,
8288
console: _PrinterLike,
89+
open_html_report: bool = False,
8390
) -> str | None:
8491
html_report_path: str | None = None
8592
saved_reports: list[tuple[str, Path]] = []
@@ -145,4 +152,12 @@ def write_report_outputs(
145152
display = path
146153
console.print(f" [bold]{label} report saved:[/bold] [dim]{display}[/dim]")
147154

155+
if open_html_report and output_paths.html is not None:
156+
try:
157+
_open_html_report_in_browser(path=output_paths.html)
158+
except Exception as exc:
159+
console.print(
160+
ui.fmt_html_report_open_failed(path=output_paths.html, error=exc)
161+
)
162+
148163
return html_report_path

codeclone/cli.py

Lines changed: 107 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import time
99
from dataclasses import dataclass
1010
from pathlib import Path
11-
from typing import TYPE_CHECKING, Protocol, cast
11+
from typing import TYPE_CHECKING, Literal, Protocol, cast
1212

1313
from . import __version__
1414
from . import ui_messages as ui
@@ -304,6 +304,7 @@ def print(self, *objects: object, **kwargs: object) -> None: ...
304304

305305

306306
LEGACY_CACHE_PATH = Path("~/.cache/codeclone/cache.json").expanduser()
307+
ReportPathOrigin = Literal["default", "explicit"]
307308

308309

309310
def _rich_progress_symbols() -> tuple[
@@ -382,7 +383,65 @@ def _is_debug_enabled(
382383
return debug_from_flag or debug_from_env
383384

384385

385-
def _resolve_output_paths(args: Namespace) -> OutputPaths:
386+
def _report_path_origins(argv: Sequence[str]) -> dict[str, ReportPathOrigin | None]:
387+
origins: dict[str, ReportPathOrigin | None] = {
388+
"html": None,
389+
"json": None,
390+
"md": None,
391+
"sarif": None,
392+
"text": None,
393+
}
394+
flag_to_field = {
395+
"--html": "html",
396+
"--json": "json",
397+
"--md": "md",
398+
"--sarif": "sarif",
399+
"--text": "text",
400+
}
401+
index = 0
402+
while index < len(argv):
403+
token = argv[index]
404+
if token == "--":
405+
break
406+
if "=" in token:
407+
flag, _value = token.split("=", maxsplit=1)
408+
field_name = flag_to_field.get(flag)
409+
if field_name is not None:
410+
origins[field_name] = "explicit"
411+
index += 1
412+
continue
413+
field_name = flag_to_field.get(token)
414+
if field_name is None:
415+
index += 1
416+
continue
417+
next_token = argv[index + 1] if index + 1 < len(argv) else None
418+
if next_token is None or next_token.startswith("-"):
419+
origins[field_name] = "default"
420+
index += 1
421+
continue
422+
origins[field_name] = "explicit"
423+
index += 2
424+
return origins
425+
426+
427+
def _report_path_timestamp_slug(report_generated_at_utc: str) -> str:
428+
return report_generated_at_utc.replace("-", "").replace(":", "")
429+
430+
431+
def _timestamped_report_path(path: Path, *, report_generated_at_utc: str) -> Path:
432+
suffix = path.suffix
433+
stem = path.name[: -len(suffix)] if suffix else path.name
434+
return path.with_name(
435+
f"{stem}-{_report_path_timestamp_slug(report_generated_at_utc)}{suffix}"
436+
)
437+
438+
439+
def _resolve_output_paths(
440+
args: Namespace,
441+
*,
442+
report_path_origins: Mapping[str, ReportPathOrigin | None],
443+
report_generated_at_utc: str,
444+
) -> OutputPaths:
386445
printer = cast("_PrinterLike", console)
387446
resolved: dict[str, Path | None] = {
388447
"html": None,
@@ -403,14 +462,23 @@ def _resolve_output_paths(args: Namespace) -> OutputPaths:
403462
raw_value = getattr(args, arg_name, None)
404463
if not raw_value:
405464
continue
406-
resolved[field_name] = _validate_output_path(
465+
path = _validate_output_path(
407466
raw_value,
408467
expected_suffix=expected_suffix,
409468
label=label,
410469
console=printer,
411470
invalid_message=ui.fmt_invalid_output_extension,
412471
invalid_path_message=ui.fmt_invalid_output_path,
413472
)
473+
if (
474+
args.timestamped_report_paths
475+
and report_path_origins.get(field_name) == "default"
476+
):
477+
path = _timestamped_report_path(
478+
path,
479+
report_generated_at_utc=report_generated_at_utc,
480+
)
481+
resolved[field_name] = path
414482

415483
return OutputPaths(
416484
html=resolved["html"],
@@ -421,6 +489,26 @@ def _resolve_output_paths(args: Namespace) -> OutputPaths:
421489
)
422490

423491

492+
def _validate_report_ui_flags(*, args: Namespace, output_paths: OutputPaths) -> None:
493+
if args.open_html_report and output_paths.html is None:
494+
console.print(ui.fmt_contract_error(ui.ERR_OPEN_HTML_REPORT_REQUIRES_HTML))
495+
sys.exit(ExitCode.CONTRACT_ERROR)
496+
497+
if args.timestamped_report_paths and not any(
498+
(
499+
output_paths.html,
500+
output_paths.json,
501+
output_paths.md,
502+
output_paths.sarif,
503+
output_paths.text,
504+
)
505+
):
506+
console.print(
507+
ui.fmt_contract_error(ui.ERR_TIMESTAMPED_REPORT_PATHS_REQUIRES_REPORT)
508+
)
509+
sys.exit(ExitCode.CONTRACT_ERROR)
510+
511+
424512
def _resolve_cache_path(*, root_path: Path, args: Namespace, from_args: bool) -> Path:
425513
return _resolve_cache_path_impl(
426514
root_path=root_path,
@@ -629,12 +717,14 @@ def _write_report_outputs(
629717
args: Namespace,
630718
output_paths: OutputPaths,
631719
report_artifacts: ReportArtifacts,
720+
open_html_report: bool = False,
632721
) -> str | None:
633722
return _write_report_outputs_impl(
634723
args=cast("_QuietArgsLike", cast(object, args)),
635724
output_paths=output_paths,
636725
report_artifacts=report_artifacts,
637726
console=cast("_PrinterLike", console),
727+
open_html_report=open_html_report,
638728
)
639729

640730

@@ -757,7 +847,7 @@ def _main_impl() -> None:
757847
global console
758848

759849
run_started_at = time.monotonic()
760-
from ._cli_meta import _build_report_meta
850+
from ._cli_meta import _build_report_meta, _current_report_timestamp_utc
761851

762852
ap = build_parser(__version__)
763853

@@ -771,10 +861,13 @@ def _prepare_run_inputs() -> tuple[
771861
OutputPaths,
772862
Path,
773863
dict[str, object] | None,
864+
str,
774865
]:
775866
global console
776867
raw_argv = tuple(sys.argv[1:])
777868
explicit_cli_dests = collect_explicit_cli_dests(ap, argv=raw_argv)
869+
report_path_origins = _report_path_origins(raw_argv)
870+
report_generated_at_utc = _current_report_timestamp_utc()
778871
cache_path_from_args = any(
779872
arg in {"--cache-dir", "--cache-path"}
780873
or arg.startswith(("--cache-dir=", "--cache-path="))
@@ -896,7 +989,12 @@ def _prepare_run_inputs() -> tuple[
896989
if not args.quiet:
897990
print_banner(root=root_path)
898991

899-
output_paths = _resolve_output_paths(args)
992+
output_paths = _resolve_output_paths(
993+
args,
994+
report_path_origins=report_path_origins,
995+
report_generated_at_utc=report_generated_at_utc,
996+
)
997+
_validate_report_ui_flags(args=args, output_paths=output_paths)
900998
cache_path = _resolve_cache_path(
901999
root_path=root_path,
9021000
args=args,
@@ -912,6 +1010,7 @@ def _prepare_run_inputs() -> tuple[
9121010
output_paths,
9131011
cache_path,
9141012
shared_baseline_payload,
1013+
report_generated_at_utc,
9151014
)
9161015

9171016
(
@@ -924,6 +1023,7 @@ def _prepare_run_inputs() -> tuple[
9241023
output_paths,
9251024
cache_path,
9261025
shared_baseline_payload,
1026+
report_generated_at_utc,
9271027
) = _prepare_run_inputs()
9281028

9291029
cache = Cache(
@@ -1020,6 +1120,7 @@ def _prepare_run_inputs() -> tuple[
10201120
),
10211121
analysis_mode=("clones_only" if args.skip_metrics else "full"),
10221122
metrics_computed=_metrics_computed(args),
1123+
report_generated_at_utc=report_generated_at_utc,
10231124
)
10241125

10251126
baseline_for_diff = (
@@ -1096,6 +1197,7 @@ def _prepare_run_inputs() -> tuple[
10961197
args=args,
10971198
output_paths=output_paths,
10981199
report_artifacts=report_artifacts,
1200+
open_html_report=args.open_html_report,
10991201
)
11001202

11011203
_enforce_gating(

codeclone/ui_messages.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,13 @@
114114
"Generate a plain-text report.\n"
115115
"If FILE is omitted, writes to .cache/codeclone/report.txt."
116116
)
117+
HELP_OPEN_HTML_REPORT = (
118+
"Open the generated HTML report in the default browser.\nRequires --html."
119+
)
120+
HELP_TIMESTAMPED_REPORT_PATHS = (
121+
"Append a UTC timestamp to default report filenames.\n"
122+
"Applies only to report flags passed without FILE."
123+
)
117124
HELP_NO_PROGRESS = "Disable progress output.\nRecommended for CI logs."
118125
HELP_PROGRESS = "Force-enable progress output."
119126
HELP_NO_COLOR = "Disable ANSI colors."
@@ -176,6 +183,9 @@
176183
)
177184
WARN_FAILED_FILES_HEADER = "\n[warning]{count} files failed to process:[/warning]"
178185
WARN_CACHE_SAVE_FAILED = "[warning]Failed to save cache: {error}[/warning]"
186+
WARN_HTML_REPORT_OPEN_FAILED = (
187+
"[warning]Failed to open HTML report in browser: {path} ({error}).[/warning]"
188+
)
179189

180190
ERR_INVALID_OUTPUT_EXT = (
181191
"[error]Invalid {label} output extension: {path} "
@@ -194,6 +204,13 @@
194204
ERR_REPORT_WRITE_FAILED = (
195205
"[error]Failed to write {label} report: {path} ({error}).[/error]"
196206
)
207+
ERR_OPEN_HTML_REPORT_REQUIRES_HTML = (
208+
"[error]--open-html-report requires --html.[/error]"
209+
)
210+
ERR_TIMESTAMPED_REPORT_PATHS_REQUIRES_REPORT = (
211+
"[error]--timestamped-report-paths requires at least one report output "
212+
"flag.[/error]"
213+
)
197214
ERR_UNREADABLE_SOURCE_IN_GATING = (
198215
"One or more source files could not be read in CI/gating mode.\n"
199216
"Unreadable source files: {count}."
@@ -280,6 +297,10 @@ def fmt_report_write_failed(*, label: str, path: Path, error: object) -> str:
280297
return ERR_REPORT_WRITE_FAILED.format(label=label, path=path, error=error)
281298

282299

300+
def fmt_html_report_open_failed(*, path: Path, error: object) -> str:
301+
return WARN_HTML_REPORT_OPEN_FAILED.format(path=path, error=error)
302+
303+
283304
def fmt_unreadable_source_in_gating(*, count: int) -> str:
284305
return ERR_UNREADABLE_SOURCE_IN_GATING.format(count=count)
285306

0 commit comments

Comments
 (0)