Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 94 additions & 76 deletions bin/alsdiff.ml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
open Alsdiff_base
open Alsdiff_live
open Alsdiff_output
open Config
open Eio.Std
open View_model
open Cmdliner
Expand All @@ -17,22 +18,12 @@ let create_views (change : (Liveset.t, Liveset.Patch.t) Diff.structured_change)
let item = View_model.create_liveset_item change in
[View_model.Item item]

let render_views config (views : View_model.view list) : string =
let buffer = Buffer.create 4096 in
let ppf = Format.formatter_of_buffer buffer in
Fmt.set_style_renderer ppf `None;

List.iter (fun view ->
Text_renderer.pp config ppf view;
Fmt.pf ppf "@.";
) views;

Format.pp_print_flush ppf ();
Buffer.contents buffer
type output_mode = Tree | Stats

type config = {
positional_args: string list;
git_mode: bool;
output_mode: output_mode;
config_file: string option;
preset: [ `Compact | `Composer | `Full | `Inline | `Mixing | `Quiet | `Verbose ] option;
dump_preset: [ `Compact | `Composer | `Full | `Inline | `Mixing | `Quiet | `Verbose ] option;
Expand Down Expand Up @@ -121,75 +112,94 @@ let load_and_report_config config_path =
Fmt.pr "Loading configuration from %s@." config_path;
load_config_from_json config_path

let diff_cmd ~config ~domain_mgr : int =
let file1, file2, reference_path =
if config.git_mode then
match parse_git_args config.positional_args with
| Error msg -> failwith msg
| Ok git_args -> (git_args.old_file, git_args.new_file, git_args.path)
else
match config.positional_args with
| [f1; f2] -> (f1, f2, f2)
| _ -> failwith "FILE1.als and FILE2.als are required for diff"
in

let base_renderer_config =
match config.config_file with
| Some config_path ->
load_config_from_json config_path
let build_base_renderer_config ~default_config ~reference_path config =
match config.config_file with
| Some config_path ->
load_config_from_json config_path
| None ->
match config.preset with
| Some preset ->
let base = match preset with
| `Compact -> Text_renderer.compact
| `Composer -> Text_renderer.composer
| `Full -> Text_renderer.full
| `Inline -> Text_renderer.inline
| `Mixing -> Text_renderer.mixing
| `Quiet -> Text_renderer.quiet
| `Verbose -> Text_renderer.verbose
in base
| None ->
match config.preset with
| Some preset ->
let base = match preset with
| `Compact -> Text_renderer.compact
| `Composer -> Text_renderer.composer
| `Full -> Text_renderer.full
| `Inline -> Text_renderer.inline
| `Mixing -> Text_renderer.mixing
| `Quiet -> Text_renderer.quiet
| `Verbose -> Text_renderer.verbose
in base
| None ->
(* Auto-discover .alsdiff.json when neither --config nor --preset specified *)
match discover_config_file ~reference_path with
| Some auto_config -> load_and_report_config auto_config
| None -> Text_renderer.quiet
in
match discover_config_file ~reference_path with
| Some auto_config -> load_and_report_config auto_config
| None -> default_config

let stats_incompatible_flags_provided config =
config.prefix_added <> None
|| config.prefix_removed <> None
|| config.prefix_modified <> None
|| config.prefix_unchanged <> None
|| config.note_name_style <> None
|| config.max_collection_items <> None

let renderer_config = { base_renderer_config with
prefix_added = (match config.prefix_added with Some s -> s | None -> base_renderer_config.prefix_added);
prefix_removed = (match config.prefix_removed with Some s -> s | None -> base_renderer_config.prefix_removed);
prefix_modified = (match config.prefix_modified with Some s -> s | None -> base_renderer_config.prefix_modified);
prefix_unchanged = (match config.prefix_unchanged with Some s -> s | None -> base_renderer_config.prefix_unchanged);
note_name_style = (match config.note_name_style with Some s -> s | None -> base_renderer_config.note_name_style);
max_collection_items = (match config.max_collection_items with Some n -> Some n | None -> base_renderer_config.max_collection_items);
} in

let liveset1, liveset2 = Fiber.pair
(fun () -> load_liveset ~domain_mgr file1)
(fun () -> load_liveset ~domain_mgr file2)
in
let diff_cmd ~config ~domain_mgr : int =
if config.output_mode = Stats && stats_incompatible_flags_provided config then begin
Fmt.epr "Error: --mode stats is incompatible with --prefix-*, \
--note-name-style, and --max-collection-items@.";
1
Comment on lines +145 to +148

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return git-mode argument errors with exit code 2

This branch returns 1 for a stats-mode argument error even when --git is active, which makes Git (trustExitCode) interpret the failure as "differences found" instead of an invalid invocation. In practice, alsdiff --git --mode stats --prefix-added ... will look like a real diff result rather than a configuration error, despite the command documentation in this file defining exit code 2 for invalid arguments. Return 2 in git mode for this path so misconfiguration is surfaced correctly.

Useful? React with 👍 / 👎.

end else begin
let file1, file2, reference_path =
if config.git_mode then
match parse_git_args config.positional_args with
| Error msg -> failwith msg
| Ok git_args -> (git_args.old_file, git_args.new_file, git_args.path)
else
match config.positional_args with
| [f1; f2] -> (f1, f2, f2)
| _ -> failwith "FILE1.als and FILE2.als are required for diff"
in

let liveset_patch = Liveset.diff liveset1 liveset2 in
let has_changes = not (Liveset.Patch.is_empty liveset_patch) in
let liveset1, liveset2 = Fiber.pair
(fun () -> load_liveset ~domain_mgr file1)
(fun () -> load_liveset ~domain_mgr file2)
in

let liveset_change =
if has_changes then
`Modified liveset_patch
else
`Unchanged
in
let liveset_patch = Liveset.diff liveset1 liveset2 in
let has_changes = not (Liveset.Patch.is_empty liveset_patch) in

let views = create_views liveset_change in
let liveset_change =
if has_changes then
`Modified liveset_patch
else
`Unchanged
in

let output = render_views renderer_config views in
Fmt.pr "%s@." output;
let views = create_views liveset_change in

let output = match config.output_mode with
| Stats ->
let base_renderer_config = build_base_renderer_config ~default_config:stats_default ~reference_path config in
(* Stats mode doesn't use prefix/note_style/max_items, so no merging needed *)
Stats_renderer.render base_renderer_config views
| Tree ->
let base_renderer_config = build_base_renderer_config ~default_config:Text_renderer.quiet ~reference_path config in
let renderer_config = { base_renderer_config with
prefix_added = (match config.prefix_added with Some s -> s | None -> base_renderer_config.prefix_added);
prefix_removed = (match config.prefix_removed with Some s -> s | None -> base_renderer_config.prefix_removed);
prefix_modified = (match config.prefix_modified with Some s -> s | None -> base_renderer_config.prefix_modified);
prefix_unchanged = (match config.prefix_unchanged with Some s -> s | None -> base_renderer_config.prefix_unchanged);
note_name_style = (match config.note_name_style with Some s -> s | None -> base_renderer_config.note_name_style);
max_collection_items = (match config.max_collection_items with Some n -> Some n | None -> base_renderer_config.max_collection_items);
} in
Text_renderer.render renderer_config views
in

(* Exit code: git mode uses trustExitCode semantics *)
if config.git_mode then
if has_changes then 1 else 0
else
0
Fmt.pr "%s@." output;

if config.git_mode then
if has_changes then 1 else 0
else
0
end

let positional_args =
let doc = "Positional arguments. Normal mode: FILE1.als FILE2.als. \
Expand Down Expand Up @@ -239,6 +249,11 @@ let max_collection_items =
let doc = "Maximum number of items to show in collections (default from preset: None/10/50 depending on preset)" in
Arg.(value & opt (some int) None & info ["max-collection-items"] ~docv:"N" ~doc)

let output_mode =
let doc = "Output mode. $(b,tree)=hierarchical tree view (default), $(b,stats)=summary statistics of changes by type. \
Stats mode now supports --config and --preset for customizing which types appear in statistics. Incompatible with --prefix-*, --note-name-style, and --max-collection-items." in
Arg.(value & opt (enum ["tree", Tree; "stats", Stats]) Tree & info ["mode"] ~docv:"MODE" ~doc)

let dump_schema =
let doc = "Dump JSON schema for configuration to stdout and exit. Does not require FILE1.als or FILE2.als." in
Arg.(value & flag & info ["dump-schema"] ~doc)
Expand All @@ -259,6 +274,8 @@ let cmd =
`S Manpage.s_examples;
`P "Compare two files with default quiet preset:";
`Pre "$(cmd) v1.als v2.als";
`P "Show change statistics summary:";
`Pre "$(cmd) v1.als v2.als --mode stats";
`P "Compare with compact output:";
`Pre "$(cmd) v1.als v2.als --preset compact";
`P "Compare with full details:";
Expand Down Expand Up @@ -312,6 +329,7 @@ let cmd =
`P "Git mode with config file:";
`Pre "$(cmd) --config myconfig.json --git path old-file old-hex old-mode new-file new-hex new-mode";
`S Manpage.s_options;
`P "$(b,--mode MODE) selects the output mode. $(b,tree) (default) shows a hierarchical tree view of changes. $(b,stats) shows a flat summary of change counts by type (e.g., Tracks: 1 Added, 3 Modified). Stats mode is incompatible with --preset, --config, and other tree-specific options.";
`P "$(b,--config FILE) loads configuration from JSON file. Takes precedence over auto-discovery. The --preset option is ignored when --config is specified. Individual CLI options override values from config file.";
`P "$(b,--preset PRESET) sets the output detail preset. Available presets: $(b,compact), $(b,composer), $(b,full), $(b,mixing), $(b,quiet) (default), $(b,verbose). Takes precedence over auto-discovery but ignored when --config is specified.";
`P "$(b,--prefix-added PREFIX) overrides prefix for added items from config file.";
Expand All @@ -336,8 +354,8 @@ let cmd =
Cmd.make (Cmd.info "alsdiff" ~version:(match version () with
| None -> "dev"
| Some v -> Version.to_string v) ~doc ~man ~exits) @@
let+ positional_args and+ git_mode and+ config_file and+ preset and+ dump_preset and+ prefix_added and+ prefix_removed and+ prefix_modified and+ prefix_unchanged and+ note_name_style and+ max_collection_items and+ dump_schema and+ validate_config in
let cfg = { positional_args; git_mode; config_file; preset; dump_preset; prefix_added; prefix_removed; prefix_modified; prefix_unchanged; note_name_style; max_collection_items; dump_schema; validate_config } in
let+ positional_args and+ git_mode and+ config_file and+ preset and+ dump_preset and+ prefix_added and+ prefix_removed and+ prefix_modified and+ prefix_unchanged and+ note_name_style and+ max_collection_items and+ output_mode and+ dump_schema and+ validate_config in
let cfg = { positional_args; git_mode; output_mode; config_file; preset; dump_preset; prefix_added; prefix_removed; prefix_modified; prefix_unchanged; note_name_style; max_collection_items; dump_schema; validate_config } in
config_ref := Some cfg;
()

Expand Down
52 changes: 52 additions & 0 deletions lib/output/config.ml
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,58 @@ let quiet = {
indent_width = 2;
}

(* stats_default preset: types shown in statistics output by default.
Shows the 7 core types that were historically in stats_renderer.
All other types set to Ignore to exclude from statistics. *)
let stats_default = {
(* Base: Summary to show counts only *)
added = Summary;
removed = Summary;
modified = Summary;
unchanged = Ignore;

(* Show the 7 historically tracked types *)
type_overrides = [
{ domain_type = DTTrack; override = uniform_override Summary; };
{ domain_type = DTDevice; override = uniform_override Summary; };
{ domain_type = DTClip; override = uniform_override Summary; };
{ domain_type = DTNote; override = uniform_override Summary; };
{ domain_type = DTAutomation; override = uniform_override Summary; };
{ domain_type = DTSend; override = uniform_override Summary; };
{ domain_type = DTParam; override = uniform_override Summary; };
{ domain_type = DTLocator; override = uniform_override Summary; };

(* Explicitly ignore all other types for stats mode *)
{ domain_type = DTLiveset; override = uniform_override Ignore; };
{ domain_type = DTMixer; override = uniform_override Ignore; };
{ domain_type = DTRouting; override = uniform_override Ignore; };
{ domain_type = DTEvent; override = uniform_override Ignore; };
{ domain_type = DTPreset; override = uniform_override Ignore; };
{ domain_type = DTMacro; override = uniform_override Ignore; };
{ domain_type = DTSnapshot; override = uniform_override Ignore; };
{ domain_type = DTLoop; override = uniform_override Ignore; };
{ domain_type = DTSignature; override = uniform_override Ignore; };
{ domain_type = DTSampleRef; override = uniform_override Ignore; };
{ domain_type = DTVersion; override = uniform_override Ignore; };
{ domain_type = DTOther; override = uniform_override Ignore; };
];

(* No limit needed for stats - just counting *)
max_collection_items = None;

(* Standard prefixes *)
prefix_added = "+";
prefix_removed = "-";
prefix_modified = "*";
prefix_unchanged = "=";

(* Sharp note names *)
note_name_style = Sharp;

(* Indent width *)
indent_width = 2;
}

(* Verbose preset: show everything including unchanged items.
Useful for debugging, auditing, or understanding complete file structure.
No limits - displays all items and all fields. *)
Expand Down
7 changes: 4 additions & 3 deletions lib/output/dune
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
(name alsdiff_output)
(public_name alsdiff.output)
(modules
view_model
config
text_renderer)
view_model
config
text_renderer
stats_renderer)
(libraries
alsdiff_base
alsdiff_live
Expand Down
84 changes: 84 additions & 0 deletions lib/output/stats_renderer.ml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
open View_model
open Config

let display_name (dt : domain_type) : string =
match dt with
| DTTrack -> "Tracks"
| DTDevice -> "Devices"
| DTClip -> "Clips"
| DTNote -> "Notes"
| DTAutomation -> "Automations"
| DTSend -> "Sends"
| DTParam -> "Parameters"
| DTLocator -> "Locators"
| _ -> domain_type_to_string dt

(* Build the initial stats list from config - include types not set to Ignore *)
let stats_from_config (cfg : detail_config) : (domain_type * change_breakdown) list =
(* All domain types we might track - check if they're not Ignored *)
let all_types = [
DTLiveset; DTTrack; DTDevice; DTClip; DTNote;
DTAutomation; DTMixer; DTRouting; DTLocator;
DTParam; DTEvent; DTSend; DTPreset; DTMacro;
DTSnapshot; DTLoop; DTSignature; DTSampleRef;
DTVersion; DTOther;
] in
(* Filter to types that should be tracked (not Ignore for their change type) *)
List.filter_map (fun dt ->
(* Check if any change type for this domain type is not Ignore *)
let trackable =
(get_effective_detail cfg Added dt <> Ignore) ||
(get_effective_detail cfg Removed dt <> Ignore) ||
(get_effective_detail cfg Modified dt <> Ignore) ||
(get_effective_detail cfg Unchanged dt <> Ignore)
in
if trackable then Some (dt, { added = 0; removed = 0; modified = 0 })
else None
) all_types

let increment_stats stats dt ct =
List.map
(fun (d, b) -> if d = dt then (d, increment_breakdown b ct) else (d, b))
stats

let rec collect_view (cfg : detail_config) (stats : (domain_type * change_breakdown) list) (view : view) =
match view with
| Field _ -> stats
| Item item ->
let stats =
(* Use get_effective_detail to check if type should be tracked *)
let level = get_effective_detail cfg item.change item.domain_type in
if should_render_level level && item.change <> Unchanged then
increment_stats stats item.domain_type item.change
else stats
in
List.fold_left (collect_view cfg) stats item.children
| Collection col -> List.fold_left (collect_view cfg) stats col.items

let collect (cfg : detail_config) (views : view list) : (domain_type * change_breakdown) list =
List.fold_left (collect_view cfg) (stats_from_config cfg) views

let render_line (dt : domain_type) (b : change_breakdown) : string option =
if total_breakdown b = 0 then None
else
let parts =
List.filter_map Fun.id
[
(if b.added > 0 then Some (Printf.sprintf "%d Added" b.added)
else None);
(if b.removed > 0 then Some (Printf.sprintf "%d Removed" b.removed)
else None);
(if b.modified > 0 then Some (Printf.sprintf "%d Modified" b.modified)
else None);
]
in
Some (Printf.sprintf "%s: %s" (display_name dt) (String.concat ", " parts))

let render (cfg : detail_config) (views : view list) : string =
let stats = collect cfg views in
let lines =
List.filter_map (fun (dt, b) -> render_line dt b) stats
in
match lines with
| [] -> "No changes."
| _ -> String.concat "\n" lines
Loading
Loading