88import time
99from dataclasses import dataclass
1010from pathlib import Path
11- from typing import TYPE_CHECKING , Protocol , cast
11+ from typing import TYPE_CHECKING , Literal , Protocol , cast
1212
1313from . import __version__
1414from . import ui_messages as ui
@@ -304,6 +304,7 @@ def print(self, *objects: object, **kwargs: object) -> None: ...
304304
305305
306306LEGACY_CACHE_PATH = Path ("~/.cache/codeclone/cache.json" ).expanduser ()
307+ ReportPathOrigin = Literal ["default" , "explicit" ]
307308
308309
309310def _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+
424512def _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 (
0 commit comments