From 7513c0a3e27b2be469976295a3b41aee57352e08 Mon Sep 17 00:00:00 2001 From: Kavisi <68240897+kekavc24@users.noreply.github.com> Date: Sun, 4 Feb 2024 11:27:08 +0000 Subject: [PATCH] feat: add walk command (#48) * test: add generic types to infer type > remove dynamic type * feat: add recursive function to update file > add extension on map to update nested keys > add extension to convert file read as String to Map > clean up code to edit yaml file * refactor: add context to thrown errors > add information to error to give hint to user on what to fix * test: add tests for recursive function in 39841af > update tests for refactored code to test via recursive function > move tests to map extension test file > add tests for recursively reading nested keys > add tests for keys created when missing in file * style: run dart format * docs: add missing key in example * test: update test to check for new exception > check exception matcher one added in 7348ac0 * refactor: make argsChecker class abstract * refactor: reduce code duplication > reduce number of classes managing version modifiers > update code to reflect changes * refactor: clean-up arg checker class > make base class abstract > add custom overrides for subtypes > update test to to reflect changes * refactor: replace mixin with handler class > consolidate file operations and reused properties to one class > reduce number of inputs from outside the handler * refactor: create template for handling a command > reduce code repetition. Move common functionality to super class > move file I/O inputs to FileHandler a42d8dd > move core functionality to subclasses with custom implementation * test: update file handler tests * refactor: add extended json/yaml support #42 > allow for adding of maps in list > allow for adding string/lists to map. map will be converted to list > add support for recursive update of keys within lists > add support for updates within lists nested in lists * refactor: add recursive helper class > add helper function added in 532bb6e to class > update recursive read to support changed added in 532bb6e * test: update tests to reflect 532bb6e > add new tests for new functionality * style: run dart format * fix: fix issues with test > fix bug caught where value returned is not a string but list > update tests based on changes added in 532bb6e * fix: fix bug caught in test > add ignore rest args for set and bump command arg checkers > add missing override for set command handler * refactor: clean up code > reduce constant conversion of file content > add yml as valid file extension * style: remove unused file import * fix: fix bug saving yaml files as json > remove direct conversion of file to dart map > add file ouput containing the file as YamlMap from file handler > remove dead unreferenced code > update tests to use update file output * refactor: clean up code > remove multiple "continue" in loops * feat(initial): add finder implementation > add recursive indexer for yaml/json nodes > add finder that matches based on keys, values or pairs provided > add data types for indexing & data nodes found based on conditions * style: run dart format * fix: ensure length is same in strict order > add extension method to check for this * refactor: clean up code > add hasAny method to iterable > add minor documentation * refactor: add support for multi-directory commands > extend `FileHandler` to support multiple file reads * test: update `FileHandler` tests based on e1b0105 > add test for reading file from multiple directories * refactor: clean up code > abstract finder functionality * feat: add support for replacer > add initial `Replacer` implementation > add `UpdateMode` enum for easy control > add support for replace and renaming keys in recursive update > move recursive helpers to own file > add supporting typedefs for `Replacer` & `Finder` > clean up code to reflect `UpdateMode` enum changes * test: update old tests to support Update Enum * test: add tests for UpdateMode.Replace > add tests for recursion based on UpdateMode.Replace * refactor: clean up code > add equality & hashcode to `NodeData` & `MatchedNodeData` classes > add collection for equality comparison > run dart format * fix: fix bug caught in test > convert fileAsYamlMap to modifiable map * fix: fix bug caught in test > fix typo in prompt requesting file from console * fix: fix bug caught in test > fix bug introduced when refactoring code (ref commit 3555e37) * refactor: code cleaup & improvements > remove flag from base command > rename arg checkers to normalizers > update name changes * feat: add partial implementation for new commands > add normalizers for `find`, `rename` & `replace` commands > add enums, typedefs & extensions for functionality * feat: add custom `Replacer` > add `MagicalReplacer` for values & `MagicalRenamer` for keys > move implementations to relevant folders * feat: add custom lightweight pair definition > add key and value with level and indices pointing to index in list > port old NodeData object to support functionality * refactor(initial): extend 676ee02 to existing code > declutter and revert changes made in e53d8a0 while adding `UpdateMode` > remove class with static methods in favour of Dart top level functions > add method to handle UpdateMode.replace on previously indexed map/list > update `Replacer` & `Finder` to use pair definition (676ee02) > extende `MatchedNodeData` to provide additional info * feat(initial): add initial manager implementation > add `FindManager` & `ReplacerManager` for simple aggregation > add `TransformTracker` to managers to abstract aggregation progress * refactor: clean up & optimize code > simplify pair definition using Dart records > apply changes to `NodeData` object > add method to return shortest key path for recursive rename > update replacer to use shortest key path * fix: fix bugs caught in local test run > switch to target if path is exhausted and is nested in list > return modified list at "n" instead of "n+1" when recursing list * test: add tests reverted in c4df240 > tests in question where reverted when UpdateMode.replace was removed * refactor: extend Replacer functionality > reduce code repetition. Return pair used for replacement * fix: fix pair definition bug 194b9c0 > map key to value instead of calling toString on dart records added in commit * refactor: make `MatchedNodeData` a subclass of `NodeData` > remove "composition" allowing `MatchedNodeData` to use important methods available for `NodeData` > add method to get "path" of node with key & value indices stripped * feat(initial PoC): add custom printer class for aggregating info > add utility methods for parsing and creating tree-like view > make code ready for future extensibility * refactor: extend custom tracker functionality > require file index when resetting tracker for better file tracking > add method to get count using key * refactor: clean up manager & subclasses > add `ConsolePrinter` as required arg d9df525 > clean up `FinderManager` & `ReplacerManager`. Reduce bloated code which made it hard to follow code > add file number when calling reset in tracker 909ad98 > add initial final touches that makes the managers complete and ready for tests > customize enums & typedefs for manager use * chore(deps): bump test from 1.24.9 to 1.25.0 (#47) Bumps [test](https://github.com/dart-lang/test/tree/master/pkgs) from 1.24.9 to 1.25.0. - [Release notes](https://github.com/dart-lang/test/releases) - [Commits](https://github.com/dart-lang/test/commits/test-v1.25.0/pkgs) --- updated-dependencies: - dependency-name: test dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix: switch to class `PairType` > replace `Key` & `Value` record with subclasses of `PairType` > add package Equatable to ease equality comparison. Poor hashing led to test failures despite objects being same. > refactor `NodeData` to use new pair types > clean up code references using old `PairType` record * test: add tests for `MagicalIndexer` class > update extension tests to use updated `PairType` 4af6107 * refactor: switch to `Equatable` * refactor: add reusable generic tracker class > remove bloated narrow class that was hard to customize > clean up code referencing old generic class * refactor: simplify generic tracker class f97ad57 > rename appropriately to `Counter` as this reflects functionality > remove unnecessary `MultiValue` class. Duplicate as simple `Counter` performs same functionality > add separate counter class that maintains previous counter histories * refactor: move granular tracking for each value to `Finder` class > add custom counter to increment using `MatchedNodeData` > move members to `ValueFinder` subclass. * refactor: add method to get sum of count in `Counter` * refactor: add capability to swap map being indexed once complete > make map member public in `MagicalIndexer` > move counter history functionality to `Finder` itself. Switch to `CounterWithHistory` * refactor(initial): clean up `TranformManager` class > reduce code complexity in favour of granularity > add `Counter` for keeping track of count of values found in each file > refine `FinderManager` functionality to queue files & manage Finder. Builds on 8933949 & d3091c5 * fix: fix bug where execution continues after yielding a generator * refactor(initial): clean up `ReplacerManager` > clean class and add changes introduced in 629cc95 and any linked commits > switch to normal list instead of queue to store modified maps > add docs to `FinderManager` members used in `ReplacerManager` * refactor: improve printer aggregation > add method to enable easy access to counter history using cursor & value > clean code due changes introduced in 629cc95 > add better formatting of info aggregated * test: add tests for `ValueFinder` > move tests for `MagicalIndexer` to dedicated folder > remove print statement in map extension tests * fix: fixed `Counter` issues caught in tests added in 47f7fe4 > make `Origin` a required parameter as it is always passed in all methods > prefer a DualKey when adding map entries to preserve hashcode * fix: fixed `Finder` bugs caught in tests added in 47f7fe4 > add origin when adding entries to `MatchCounter` > move `MatchCounter` setup to methods called by `Finder.find` > switch to named constructors in `ValueFinder` * refactor: add extensible tracker class > add generic tracker class for reusability > clean up rigid `Counter` class * refactor: clean up manager > split manager to uphold encapsulation > add custom tracker for Replacer manager thanks to 6aa1486 * refactor: remove unnecessary protected method for creating keys > remove need for implicit type when creating key * refactor: add contextual names to replacer subclasses * feat(WIP): add modular Formatter in place of ConsolePrinter > simplify formatter to simply format strings > explore generics & modularity to ease testing > clean up utility methods > add custom Tracker(6aa1486) class for custom functionality * refactor(WIP): cleanup Managers > add custom Formatters dd8d15d > reduce bloated code * refactor: clean up file handler class > make static methods a top level function > remove unnecessary typedefs and annotations * refactor: remove unused utility methods & classes > convert static methods to top level function in VersionModifier object > remove dead code and anti-pattern behavior * refactor: clean up core functionality code > clean up ripple effects of a560c88 > optimize existing code > remove class with static methods in favour of top level functions * refactor: clean up code > add context to base generic classes & other classes > remove dead unused code > undo unnecessary encapsulation * refactor: remove unnecessary mutations on targets for replacer > remove need to indicate if targets are keys/values since each Replacer subclass does that > remove typedefs * test: add Replacer tests & helper * test: update old tests to use latest code refactor > Refer to commits -> f76f4f1, 4afea55, 6ab2c73 * test: add tests for base `Tracker` class > rename exception helper to provide more context * test: add tests from base `Counter` class * test: add `ReplacerTracker` tests * fix: fix bug where tracker overwrites first match of a path > add method on parent Tracker class allowing to check if it contains a value > not much of a destructive bug, but this would have resulted in wasting additional CPU time updating a path that already exists? > replace guard clause with `map.putIfAbsent` * test: add tests for custom tracker used by the `Formatter` class * refactor: make tracker testable (refer 8ae19f0) > allow for custom max tolerance injection * test: add tests for utility methods used by `Formatter` & its subclasses * refactor: encapsulate formatter functionality > move formatting method to actual class instead of making it a top level method > rename file with utility methods for formatter * test: add tests for `Formatter` and its subclasses * refactor: add record for storing formatted path info > remove confusing use of DualTrackerKey > remove generics bloat which made it hard to understand functionality * test: update newly added test to match changes in a6067c8 > remove test for DualTrackerKey > updated tests commit ref: b465b31, 9361917 & 8ae19f0 * refactor: clean up manager classes > remove static members & factory constructors. Make use of late & named constructors as is. > make file inputs a generic map instead of a concrete type > add way of accessing modified files in replacer manager * refactor: allow option to show progress in FileHandler * refactor: encapsulate & consolidate core members of manager subtype > initialize all code within constructor body in ReplacerManager > A manager just manages the file queue. Most importantly, the FinderManager does the heavy lifting. * test: add find & replacer manager tests * feat(WIP): add `walk` command to `mag` > add find subcommand for find values in yaml/json files > add rename subcommand for renaming/swapping keys > add replace subcommand for replacing values > add handlers for each subcommand * style: run dart format * chore: manually bump to WIP dev release * chore: update changelog for 80044e7 * style: remove reference to console printer * style: run dart format * fix: make targets nullable when normalizing arguments for bump subcommand > allow targets to "prepped" if null --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .gitignore | 4 +- CHANGELOG.md | 9 + example/SET_COMMAND.md | 2 +- lib/src/command_runner.dart | 3 +- .../{base_command.dart => base_commands.dart} | 54 +- .../modify/modify_base_subcommand.dart | 4 +- lib/src/commands/modify/modify_command.dart | 4 +- lib/src/commands/update/update_command.dart | 2 +- .../walk/finders/find_subcommand.dart | 44 ++ .../walk/replacers/rename_subcommand.dart | 20 + .../walk/replacers/replace_subcommand.dart | 20 + .../commands/walk/walk_base_subcommands.dart | 44 ++ lib/src/commands/walk/walk_command.dart | 48 ++ .../core/argument_checkers/arg_checker.dart | 41 -- .../argument_checkers/bump_args_checker.dart | 41 -- .../argument_checkers/set_args_checker.dart | 116 ---- .../argument_normalizers/arg_normalizer.dart | 52 ++ .../bump_args_normalizer.dart | 41 ++ .../find_args_normalizer.dart | 78 +++ .../replace_args_normalizer.dart | 82 +++ .../set_args_normalizer.dart | 68 ++ .../command_handlers/command_handlers.dart | 21 - .../command_handlers/handle_set_command.dart | 80 --- .../semver_version_modifer.dart | 374 ++++++----- .../command_handlers/command_handlers.dart | 80 +++ .../bump_command_handler.dart} | 65 +- .../set_command_handler.dart | 74 +++ .../walk_command_handlers/base.dart | 69 ++ .../find_command_handler.dart | 36 + .../replace_command_handler.dart | 41 ++ .../handlers/file_handler/file_handler.dart | 147 +++++ .../file_handler/file_handler_util.dart | 11 + .../data/matched_node_data.dart | 70 ++ .../yaml_transformers/data/node_data.dart | 143 ++++ .../pair_definition/custom_pair_type.dart | 56 ++ .../data/pair_definition/pair_subtypes.dart | 15 + .../finders/custom_tracker.dart | 42 ++ .../yaml_transformers/finders/finder.dart | 174 +++++ .../finders/value_finder.dart | 215 ++++++ .../formatter/custom_tracker.dart | 76 +++ .../formatter/formatter.dart | 102 +++ .../formatter/formatter_util.dart | 250 +++++++ .../indexers/yaml_indexer.dart | 164 +++++ .../finder_manager/finder_formatter.dart | 25 + .../finder_manager/finder_manager.dart | 242 +++++++ .../yaml_transformers/managers/manager.dart | 53 ++ .../replacer_manager/replacer_formatter.dart | 26 + .../replacer_manager/replacer_manager.dart | 167 +++++ .../replacer_manager/replacer_tracker.dart | 65 ++ .../replacers/key_swapper.dart | 44 ++ .../yaml_transformers/replacers/replacer.dart | 101 +++ .../replacers/value_replacer.dart | 38 ++ .../counter/counter_with_history.dart | 22 + .../trackers/counter/generic_counter.dart | 67 ++ .../yaml_transformers/trackers/tracker.dart | 103 +++ .../trackers/tracker_key.dart | 55 ++ .../yaml_transformers/yaml_transformer.dart | 14 + lib/src/utils/data/version_modifiers.dart | 120 ++-- lib/src/utils/enums/enums.dart | 63 ++ .../utils/exceptions/command_exceptions.dart | 1 - ...exceptions.dart => magical_exception.dart} | 8 +- .../extensions/arg_results_extension.dart | 97 ++- lib/src/utils/extensions/extensions.dart | 9 +- .../map_extension/predetermined_updates.dart | 70 ++ .../recursive_data_mod_helper.dart | 115 ++++ .../map_extension/recursive_helper.dart | 165 +++++ .../utils/extensions/iterable_extension.dart | 98 ++- lib/src/utils/extensions/map_extensions.dart | 305 ++++++++- .../utils/extensions/string_extensions.dart | 4 +- .../utils/extensions/version_extension.dart | 2 +- lib/src/utils/mixins/command_mixins.dart | 1 - lib/src/utils/mixins/handle_file_mixin.dart | 78 --- lib/src/utils/mixins/modify_yaml_mixin.dart | 327 +-------- lib/src/utils/typedefs/typedefs.dart | 70 +- lib/src/version.dart | 2 +- pubspec.yaml | 31 +- test/helpers/custom_matchers.dart | 7 + test/helpers/helpers.dart | 11 +- test/helpers/magical_exception_message.dart | 7 - test/helpers/matched_node_builder.dart | 16 + test/helpers/read_nested_nodes.dart | 4 +- test/helpers/set_up_sanitizers.dart | 6 +- test/helpers/version_modifier.dart | 66 ++ .../modify/bump_subcommand_test.dart | 2 +- .../modify/set_subcommand_test.dart | 18 +- .../bump_arg_normalizer_test.dart} | 12 +- .../set_arg_normalizer_test.dart} | 120 ++-- .../semver_version_modifier_test.dart | 106 +-- .../extensions/map_extension_test.dart | 623 ++++++++++++++++++ .../handlers/file_handler_test.dart | 150 +++++ .../mixins/handle_file_mixin_test.dart | 99 --- .../mixins/modify_yaml_mixin_test.dart | 616 +---------------- .../finders/value_finder_test.dart | 320 +++++++++ .../formatters/formatter_test.dart | 123 ++++ .../formatters/formatter_util_test.dart | 259 ++++++++ .../indexers/magical_indexer_test.dart | 218 ++++++ .../managers/base_manager_test.dart | 238 +++++++ .../replacers/key_swapper_test.dart | 110 ++++ .../replacers/value_replacer_test.dart | 154 +++++ .../trackers/base_counter_test.dart | 100 +++ .../trackers/base_tracker_test.dart | 84 +++ .../trackers/formatter_tracker_test.dart | 127 ++++ .../trackers/replacer_tracker_test.dart | 116 ++++ 103 files changed, 7441 insertions(+), 1941 deletions(-) rename lib/src/commands/{base_command.dart => base_commands.dart} (53%) create mode 100644 lib/src/commands/walk/finders/find_subcommand.dart create mode 100644 lib/src/commands/walk/replacers/rename_subcommand.dart create mode 100644 lib/src/commands/walk/replacers/replace_subcommand.dart create mode 100644 lib/src/commands/walk/walk_base_subcommands.dart create mode 100644 lib/src/commands/walk/walk_command.dart delete mode 100644 lib/src/core/argument_checkers/arg_checker.dart delete mode 100644 lib/src/core/argument_checkers/bump_args_checker.dart delete mode 100644 lib/src/core/argument_checkers/set_args_checker.dart create mode 100644 lib/src/core/argument_normalizers/arg_normalizer.dart create mode 100644 lib/src/core/argument_normalizers/bump_args_normalizer.dart create mode 100644 lib/src/core/argument_normalizers/find_args_normalizer.dart create mode 100644 lib/src/core/argument_normalizers/replace_args_normalizer.dart create mode 100644 lib/src/core/argument_normalizers/set_args_normalizer.dart delete mode 100644 lib/src/core/command_handlers/command_handlers.dart delete mode 100644 lib/src/core/command_handlers/handle_set_command.dart create mode 100644 lib/src/core/handlers/command_handlers/command_handlers.dart rename lib/src/core/{command_handlers/handle_bump_command.dart => handlers/command_handlers/modify_command_handlers/bump_command_handler.dart} (51%) create mode 100644 lib/src/core/handlers/command_handlers/modify_command_handlers/set_command_handler.dart create mode 100644 lib/src/core/handlers/command_handlers/walk_command_handlers/base.dart create mode 100644 lib/src/core/handlers/command_handlers/walk_command_handlers/find_command_handler.dart create mode 100644 lib/src/core/handlers/command_handlers/walk_command_handlers/replace_command_handler.dart create mode 100644 lib/src/core/handlers/file_handler/file_handler.dart create mode 100644 lib/src/core/handlers/file_handler/file_handler_util.dart create mode 100644 lib/src/core/yaml_transformers/data/matched_node_data.dart create mode 100644 lib/src/core/yaml_transformers/data/node_data.dart create mode 100644 lib/src/core/yaml_transformers/data/pair_definition/custom_pair_type.dart create mode 100644 lib/src/core/yaml_transformers/data/pair_definition/pair_subtypes.dart create mode 100644 lib/src/core/yaml_transformers/finders/custom_tracker.dart create mode 100644 lib/src/core/yaml_transformers/finders/finder.dart create mode 100644 lib/src/core/yaml_transformers/finders/value_finder.dart create mode 100644 lib/src/core/yaml_transformers/formatter/custom_tracker.dart create mode 100644 lib/src/core/yaml_transformers/formatter/formatter.dart create mode 100644 lib/src/core/yaml_transformers/formatter/formatter_util.dart create mode 100644 lib/src/core/yaml_transformers/indexers/yaml_indexer.dart create mode 100644 lib/src/core/yaml_transformers/managers/finder_manager/finder_formatter.dart create mode 100644 lib/src/core/yaml_transformers/managers/finder_manager/finder_manager.dart create mode 100644 lib/src/core/yaml_transformers/managers/manager.dart create mode 100644 lib/src/core/yaml_transformers/managers/replacer_manager/replacer_formatter.dart create mode 100644 lib/src/core/yaml_transformers/managers/replacer_manager/replacer_manager.dart create mode 100644 lib/src/core/yaml_transformers/managers/replacer_manager/replacer_tracker.dart create mode 100644 lib/src/core/yaml_transformers/replacers/key_swapper.dart create mode 100644 lib/src/core/yaml_transformers/replacers/replacer.dart create mode 100644 lib/src/core/yaml_transformers/replacers/value_replacer.dart create mode 100644 lib/src/core/yaml_transformers/trackers/counter/counter_with_history.dart create mode 100644 lib/src/core/yaml_transformers/trackers/counter/generic_counter.dart create mode 100644 lib/src/core/yaml_transformers/trackers/tracker.dart create mode 100644 lib/src/core/yaml_transformers/trackers/tracker_key.dart create mode 100644 lib/src/core/yaml_transformers/yaml_transformer.dart delete mode 100644 lib/src/utils/exceptions/command_exceptions.dart rename lib/src/utils/exceptions/{magical_exceptions.dart => magical_exception.dart} (52%) create mode 100644 lib/src/utils/extensions/helpers/map_extension/predetermined_updates.dart create mode 100644 lib/src/utils/extensions/helpers/map_extension/recursive_data_mod_helper.dart create mode 100644 lib/src/utils/extensions/helpers/map_extension/recursive_helper.dart delete mode 100644 lib/src/utils/mixins/handle_file_mixin.dart create mode 100644 test/helpers/custom_matchers.dart delete mode 100644 test/helpers/magical_exception_message.dart create mode 100644 test/helpers/matched_node_builder.dart create mode 100644 test/helpers/version_modifier.dart rename test/src/unit_tests/{argument_checkers/bump_arg_checker_test.dart => argument_normalizer/bump_arg_normalizer_test.dart} (89%) rename test/src/unit_tests/{argument_checkers/set_arg_checker_test.dart => argument_normalizer/set_arg_normalizer_test.dart} (78%) create mode 100644 test/src/unit_tests/extensions/map_extension_test.dart create mode 100644 test/src/unit_tests/handlers/file_handler_test.dart delete mode 100644 test/src/unit_tests/mixins/handle_file_mixin_test.dart create mode 100644 test/src/unit_tests/yaml_transformers/finders/value_finder_test.dart create mode 100644 test/src/unit_tests/yaml_transformers/formatters/formatter_test.dart create mode 100644 test/src/unit_tests/yaml_transformers/formatters/formatter_util_test.dart create mode 100644 test/src/unit_tests/yaml_transformers/indexers/magical_indexer_test.dart create mode 100644 test/src/unit_tests/yaml_transformers/managers/base_manager_test.dart create mode 100644 test/src/unit_tests/yaml_transformers/replacers/key_swapper_test.dart create mode 100644 test/src/unit_tests/yaml_transformers/replacers/value_replacer_test.dart create mode 100644 test/src/unit_tests/yaml_transformers/trackers/base_counter_test.dart create mode 100644 test/src/unit_tests/yaml_transformers/trackers/base_tracker_test.dart create mode 100644 test/src/unit_tests/yaml_transformers/trackers/formatter_tracker_test.dart create mode 100644 test/src/unit_tests/yaml_transformers/trackers/replacer_tracker_test.dart diff --git a/.gitignore b/.gitignore index bd2b592..2754fe0 100644 --- a/.gitignore +++ b/.gitignore @@ -8,12 +8,14 @@ pubspec.lock doc/api/ # Files generated during & for tests +.vscode/ .test_coverage.dart coverage/ .test_runner.dart -fake.dart +fake*.dart fake.yaml fake.json +mock_data.json .markdownlint.yaml # Android studio and IntelliJ diff --git a/CHANGELOG.md b/CHANGELOG.md index 34d242b..deb10e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +# 1.1.0-dev.1 + +* `refactor`: extend existing code with missing functionality. Closes: + * [#42](https://github.com/kekavc24/magical_version_bump/issues/42) + * [#44](https://github.com/kekavc24/magical_version_bump/issues/44) + * [#45](https://github.com/kekavc24/magical_version_bump/issues/45) +* `feat`: add `walk` command with `find`, `rename` and `replace` subcommands. +* `test`: update existing & add new tests. + # 1.0.1 * `refactor` : improve code readability and testability. diff --git a/example/SET_COMMAND.md b/example/SET_COMMAND.md index 641e6bc..200c52e 100644 --- a/example/SET_COMMAND.md +++ b/example/SET_COMMAND.md @@ -432,7 +432,7 @@ root-key: - Furthermore, make the nested key `nested-key` accept a map of values like so: ```bash -mag modify set --dict "root-key|nested-key=map->value" --add "root-key=anotherMap->value,otherMap->value" +mag modify set --dict "root-key|nested-key=map->value" --add "root-key|nested-key=anotherMap->value,otherMap->value" ``` Updated file will look like so: diff --git a/lib/src/command_runner.dart b/lib/src/command_runner.dart index e2e4734..48b183e 100644 --- a/lib/src/command_runner.dart +++ b/lib/src/command_runner.dart @@ -2,6 +2,7 @@ import 'package:args/args.dart'; import 'package:args/command_runner.dart'; import 'package:cli_completion/cli_completion.dart'; import 'package:magical_version_bump/src/commands/commands.dart'; +import 'package:magical_version_bump/src/commands/walk/walk_command.dart'; import 'package:magical_version_bump/src/version.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:pub_updater/pub_updater.dart'; @@ -30,7 +31,6 @@ class MagicalVersionBumpCommandRunner extends CompletionCommandRunner { argParser ..addFlag( 'version', - abbr: 'v', negatable: false, help: 'Print the current version.', ) @@ -42,6 +42,7 @@ class MagicalVersionBumpCommandRunner extends CompletionCommandRunner { // Add sub commands addCommand(UpdateCommand(logger: _logger, pubUpdater: _pubUpdater)); addCommand(ModifyCommand(logger: _logger)); + addCommand(WalkCommand(logger: _logger)); } @override diff --git a/lib/src/commands/base_command.dart b/lib/src/commands/base_commands.dart similarity index 53% rename from lib/src/commands/base_command.dart rename to lib/src/commands/base_commands.dart index ff8b9f4..b411fe5 100644 --- a/lib/src/commands/base_command.dart +++ b/lib/src/commands/base_commands.dart @@ -1,12 +1,12 @@ import 'dart:io'; import 'package:args/command_runner.dart'; -import 'package:magical_version_bump/src/core/command_handlers/command_handlers.dart'; -import 'package:magical_version_bump/src/utils/exceptions/command_exceptions.dart'; +import 'package:magical_version_bump/src/core/handlers/command_handlers/command_handlers.dart'; +import 'package:magical_version_bump/src/utils/exceptions/magical_exception.dart'; import 'package:mason_logger/mason_logger.dart'; /// Abstract command used by all commands. Every command or subcommand will -/// have access to the `request-path` flag & `directory` option. +/// have access to the `request-path` flag /// /// This class will **ONLY** be extended by commands that : /// * Have a set of subcommands @@ -14,21 +14,7 @@ import 'package:mason_logger/mason_logger.dart'; /// command itself or its subcommands. /// abstract class MagicalCommand extends Command { - MagicalCommand({required this.logger}) { - argParser - ..addFlag( - 'request-path', - help: 'Prompt for directory to find yaml/json file', - negatable: false, - aliases: ['reqPath'], - ) - ..addOption( - 'directory', - help: 'Directory where to find yaml/json file', - aliases: ['dir'], - defaultsTo: 'pubspec.yaml', - ); - } + MagicalCommand({required this.logger}); /// Logger for utility purposes final Logger logger; @@ -37,7 +23,14 @@ abstract class MagicalCommand extends Command { /// Generic runnable command template. Will be extended by commands or /// subcommands that are "run"-able. abstract class RunnableCommand extends MagicalCommand { - RunnableCommand({required super.logger, required this.handler}); + RunnableCommand({required super.logger, required this.handler}) { + argParser.addFlag( + 'request-path', + help: 'Prompt for directory to find yaml/json file', + negatable: false, + aliases: ['reqPath'], + ); + } /// Each command will always have a handler class with custom logic final CommandHandler handler; @@ -62,3 +55,26 @@ abstract class RunnableCommand extends MagicalCommand { return ExitCode.success.code; } } + +/// A command that reads a yaml/json from a single directory +abstract class SingleDirectoryCommand extends RunnableCommand { + SingleDirectoryCommand({required super.logger, required super.handler}) { + argParser.addOption( + 'directory', + help: 'Directory where to find yaml/json file', + aliases: ['dir'], + defaultsTo: 'pubspec.yaml', + ); + } +} + +/// A command that can read yaml/json files from multiple directories +abstract class MultiDirectoryCommand extends RunnableCommand { + MultiDirectoryCommand({required super.logger, required super.handler}) { + argParser.addMultiOption( + 'directory', + help: 'Directory where to find yaml/json files', + aliases: ['dir'], + ); + } +} diff --git a/lib/src/commands/modify/modify_base_subcommand.dart b/lib/src/commands/modify/modify_base_subcommand.dart index 0980249..cbc571b 100644 --- a/lib/src/commands/modify/modify_base_subcommand.dart +++ b/lib/src/commands/modify/modify_base_subcommand.dart @@ -1,4 +1,4 @@ -import 'package:magical_version_bump/src/commands/base_command.dart'; +import 'package:magical_version_bump/src/commands/base_commands.dart'; part 'subcommands/bump_subcommand.dart'; part 'subcommands/set_subcommand.dart'; @@ -9,7 +9,7 @@ part 'subcommands/set_subcommand.dart'; /// * Allow modification of a single node - `Bump` subcommand /// * Allow modification of multiple nodes - `Set` subcommand /// -abstract class ModifySubCommand extends RunnableCommand { +abstract class ModifySubCommand extends SingleDirectoryCommand { ModifySubCommand({required super.logger, required super.handler}) { argParser ..addOption( diff --git a/lib/src/commands/modify/modify_command.dart b/lib/src/commands/modify/modify_command.dart index 898d394..9c80fcd 100644 --- a/lib/src/commands/modify/modify_command.dart +++ b/lib/src/commands/modify/modify_command.dart @@ -1,6 +1,6 @@ -import 'package:magical_version_bump/src/commands/base_command.dart'; +import 'package:magical_version_bump/src/commands/base_commands.dart'; import 'package:magical_version_bump/src/commands/modify/modify_base_subcommand.dart'; -import 'package:magical_version_bump/src/core/command_handlers/command_handlers.dart'; +import 'package:magical_version_bump/src/core/handlers/command_handlers/command_handlers.dart'; /// This command is the base command for all sub-commands that modify 1 or more /// nodes in the yaml/json file diff --git a/lib/src/commands/update/update_command.dart b/lib/src/commands/update/update_command.dart index 2214206..4b5ee8c 100644 --- a/lib/src/commands/update/update_command.dart +++ b/lib/src/commands/update/update_command.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'package:magical_version_bump/src/command_runner.dart'; -import 'package:magical_version_bump/src/commands/base_command.dart'; +import 'package:magical_version_bump/src/commands/base_commands.dart'; import 'package:magical_version_bump/src/version.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:pub_updater/pub_updater.dart'; diff --git a/lib/src/commands/walk/finders/find_subcommand.dart b/lib/src/commands/walk/finders/find_subcommand.dart new file mode 100644 index 0000000..4407a77 --- /dev/null +++ b/lib/src/commands/walk/finders/find_subcommand.dart @@ -0,0 +1,44 @@ +part of '../walk_base_subcommands.dart'; + +/// Command for finding keys/values in a yaml/json file +class FindSubCommand extends WalkSubCommand { + FindSubCommand({required super.logger, required super.handler}) { + argParser + ..addMultiOption( + 'keys', + help: 'Keys to find', + abbr: 'k', + ) + ..addMultiOption( + 'values', + help: 'Values to find', + abbr: 'v', + ) + ..addMultiOption( + 'pairs', + help: 'Key-Value pair to find', + abbr: 'p', + splitCommas: false, + ) + ..addOption( + 'key-order', + help: 'Order based on provided key/values', + aliases: ['ko'], + defaultsTo: 'loose', + allowed: ['loose', 'grouped', 'strict'], + ); + // ..addMultiOption( + // 'bounds', + // help: 'Targets to use "aggregate" argument passed', + // abbr: 'b', + // allowed: ['keys', 'values', 'pairs'], + // ); + } + + @override + String get name => 'find'; + + @override + String get description => + '''A subcommand that finds exact values for a key/value in a yaml/json file'''; +} diff --git a/lib/src/commands/walk/replacers/rename_subcommand.dart b/lib/src/commands/walk/replacers/rename_subcommand.dart new file mode 100644 index 0000000..312c036 --- /dev/null +++ b/lib/src/commands/walk/replacers/rename_subcommand.dart @@ -0,0 +1,20 @@ +part of '../walk_base_subcommands.dart'; + +/// Command for replacing values in a yaml/json file +class RenameSubCommand extends ReplacerTemplate { + RenameSubCommand({required super.logger, required super.handler}) { + argParser.addMultiOption( + 'keys', + help: 'Keys to find', + abbr: 'k', + splitCommas: false, + ); + } + + @override + String get name => 'rename'; + + @override + String get description => + '''A subcommand that renames a key/list of keys in a yaml/json file'''; +} diff --git a/lib/src/commands/walk/replacers/replace_subcommand.dart b/lib/src/commands/walk/replacers/replace_subcommand.dart new file mode 100644 index 0000000..b771a5e --- /dev/null +++ b/lib/src/commands/walk/replacers/replace_subcommand.dart @@ -0,0 +1,20 @@ +part of '../walk_base_subcommands.dart'; + +/// Command for replacing values in a yaml/json file +class ReplaceSubCommand extends ReplacerTemplate { + ReplaceSubCommand({required super.logger, required super.handler}) { + argParser.addMultiOption( + 'values', + help: 'Values to find', + abbr: 'v', + splitCommas: false, + ); + } + + @override + String get name => 'replace'; + + @override + String get description => + '''A subcommand that replaces a value/list of values in a yaml/json file'''; +} diff --git a/lib/src/commands/walk/walk_base_subcommands.dart b/lib/src/commands/walk/walk_base_subcommands.dart new file mode 100644 index 0000000..9f76707 --- /dev/null +++ b/lib/src/commands/walk/walk_base_subcommands.dart @@ -0,0 +1,44 @@ +import 'package:magical_version_bump/src/commands/base_commands.dart'; + +part 'finders/find_subcommand.dart'; +part 'replacers/rename_subcommand.dart'; +part 'replacers/replace_subcommand.dart'; + +/// Base parent for finder and replacer subcommands which has shared properties +abstract class WalkSubCommand extends MultiDirectoryCommand { + WalkSubCommand({required super.logger, required super.handler}) { + argParser + ..addOption( + 'view-format', + help: 'Output to console based on "walk" output', + aliases: ['vf'], + defaultsTo: 'grouped', + allowed: ['grouped', 'live', 'hide'], + ) + ..addOption( + 'aggregate', + help: 'Type of count to use on "walk"', + abbr: 'a', + defaultsTo: 'all', + allowed: ['all', 'count', 'first'], + ) + ..addOption( + 'limit-to', + help: + '''Denotes upper limit for "aggregate". Requires a numeric value for "count".''', + aliases: ['lmt'], + defaultsTo: '', + ); + } +} + +/// Base class for replacer command +abstract class ReplacerTemplate extends WalkSubCommand { + ReplacerTemplate({required super.logger, required super.handler}) { + argParser.addMultiOption( + 'subtitute', + help: 'Replacement for value(s) provided', + abbr: 's', + ); + } +} diff --git a/lib/src/commands/walk/walk_command.dart b/lib/src/commands/walk/walk_command.dart new file mode 100644 index 0000000..973d51d --- /dev/null +++ b/lib/src/commands/walk/walk_command.dart @@ -0,0 +1,48 @@ +import 'package:magical_version_bump/src/commands/base_commands.dart'; +import 'package:magical_version_bump/src/commands/walk/walk_base_subcommands.dart'; +import 'package:magical_version_bump/src/core/handlers/command_handlers/command_handlers.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; + +/// This command is the base command for all commands that "walk" nodes of a +/// yaml/json to read or read & modify it +class WalkCommand extends MagicalCommand { + WalkCommand({required super.logger}) { + addSubcommand( + FindSubCommand( + logger: logger, + handler: HandleFindCommand(logger: logger), + ), + ); + addSubcommand( + RenameSubCommand( + logger: logger, + handler: HandleReplaceCommand( + logger: logger, + subCommandType: WalkSubCommandType.rename, + ), + ), + ); + addSubcommand( + ReplaceSubCommand( + logger: logger, + handler: HandleReplaceCommand( + logger: logger, + subCommandType: WalkSubCommandType.replace, + ), + ), + ); + } + + @override + String get name => 'walk'; + + @override + String get description => + '"Walks" to one or more nodes to read or read & modify them in a yaml/json file'; + + @override + String get invocation => 'mag walk [arguments]'; + + @override + String get summary => '$invocation\n$description'; +} diff --git a/lib/src/core/argument_checkers/arg_checker.dart b/lib/src/core/argument_checkers/arg_checker.dart deleted file mode 100644 index 3644feb..0000000 --- a/lib/src/core/argument_checkers/arg_checker.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:args/args.dart'; -import 'package:magical_version_bump/src/utils/data/version_modifiers.dart'; -import 'package:magical_version_bump/src/utils/enums/enums.dart'; -import 'package:magical_version_bump/src/utils/exceptions/command_exceptions.dart'; -import 'package:magical_version_bump/src/utils/extensions/extensions.dart'; -import 'package:magical_version_bump/src/utils/typedefs/typedefs.dart'; - -part 'bump_args_checker.dart'; -part 'set_args_checker.dart'; - -/// Contains basic code implementations to -/// * Prep args to desired format for each command -/// * Validate arguments -base class ArgumentsChecker { - ArgumentsChecker({required this.argResults}); - - /// Argument results from command - final ArgResults? argResults; - - /// Basic implementation to check if args are empty or null - ({bool isValid, InvalidReason? reason}) defaultValidation() { - // Args must not be empty or null - if (argResults == null || argResults!.arguments.isEmpty) { - return ( - isValid: false, - reason: const InvalidReason( - 'Missing arguments', - 'Arguments cannot be empty or null', - ), - ); - } - - return (isValid: true, reason: null); - } - - /// Prep args to desired format - void prepArgs() {} - - /// Validate arguments - void validateArgs() {} -} diff --git a/lib/src/core/argument_checkers/bump_args_checker.dart b/lib/src/core/argument_checkers/bump_args_checker.dart deleted file mode 100644 index de475f4..0000000 --- a/lib/src/core/argument_checkers/bump_args_checker.dart +++ /dev/null @@ -1,41 +0,0 @@ -part of 'arg_checker.dart'; - -/// Preps and validates args for modify command -final class BumpArgumentsChecker extends ArgumentsChecker { - BumpArgumentsChecker({required super.argResults}); - - static const error = - '''You need to pass in a target i.e. major, minor, patch or build-number'''; - - @override - ({bool isValid, InvalidReason? reason}) validateArgs() { - // Check args normally - final checkArgs = defaultValidation(); - - if (!checkArgs.isValid) { - return checkArgs; - } - - return ( - isValid: argResults!.targets.isNotEmpty, - reason: argResults!.targets.isNotEmpty - ? null - : const InvalidReason('Invalid targets', error) - ); - } - - /// Prep modify args - @override - ({BumpVersionModifiers modifiers, List targets}) prepArgs() { - final parsedTargets = argResults!.targets; - - final modifiers = BumpVersionModifiers.fromArgResults(argResults!); - - return ( - modifiers: modifiers, - targets: modifiers.strategy == ModifyStrategy.relative - ? parsedTargets.getRelative() - : parsedTargets, - ); - } -} diff --git a/lib/src/core/argument_checkers/set_args_checker.dart b/lib/src/core/argument_checkers/set_args_checker.dart deleted file mode 100644 index 0f36219..0000000 --- a/lib/src/core/argument_checkers/set_args_checker.dart +++ /dev/null @@ -1,116 +0,0 @@ -part of 'arg_checker.dart'; - -final class SetArgumentsChecker extends ArgumentsChecker { - SetArgumentsChecker({required super.argResults}); - - /// Prep dictionaries - @override - ({ - DefaultVersionModifiers modifiers, - List dictionaries, - }) prepArgs() { - final dictionaries = []; - - // Get dictionaries to add/overwrite first - final dictsToAdd = argResults!['dictionary'] as List; - - if (dictsToAdd.isNotEmpty) { - for (final result in dictsToAdd) { - final dict = extractDictionary(result, append: false); - - dictionaries.add(dict); - } - } - - // Get dictionaries to append to - final dictsToAppendTo = argResults!['add'] as List; - - if (dictsToAppendTo.isNotEmpty) { - for (final result in dictsToAppendTo) { - final dict = extractDictionary(result, append: true); - - dictionaries.add(dict); - } - } - - return ( - modifiers: DefaultVersionModifiers.fromArgResults(argResults!), - dictionaries: dictionaries, - ); - } - - /// Extract dictionaries/lists - Dictionary extractDictionary(String parsedValue, {required bool append}) { - /// - /// Format is "key=value,value" or "key=value:value" - /// - /// Should never be empty - if (parsedValue.isEmpty) { - throw MagicalException( - violation: 'The root key cannot be empty/null', - ); - } - - // Must have 2 values, the keys & value(s) - final keysAndValue = parsedValue.splitAndTrim('='); - final hasNoBlanks = keysAndValue.every((element) => element.isNotEmpty); - - if (keysAndValue.length != 2 || !hasNoBlanks) { - throw MagicalException( - violation: 'Invalid keys and value pair at "$parsedValue"', - ); - } - - /// Format for specifying more than 1 key is using "|" as a separator - /// - /// i.e. `rootKey`|`nextKey`|`otherKey` - final keys = keysAndValue.first.splitAndTrim('|').retainNonEmpty(); - - /// Format for specifying more than 1 value is "," - /// - /// i.e `value`,`nextValue`,`otherValue` - final values = keysAndValue.last.splitAndTrim(',').retainNonEmpty(); - - final isMappy = values.first.contains('->'); - - /// If more than one value is passed in, we have to check all follow - /// the same format. - /// - /// The first value determines the format the rest should follow! - if (values.length > 1) { - final allFollowFormat = values.every( - (element) => isMappy ? element.contains('->') : !element.contains('->'), - ); - - if (!allFollowFormat) { - throw MagicalException( - violation: 'Mixed format at $parsedValue', - ); - } - } - - if (isMappy) { - final valueMap = values.fold( - {}, - (previousValue, element) { - final mappedValues = element.splitAndTrim('->'); - previousValue.update( - mappedValues.first, - (value) => mappedValues.last.isEmpty ? 'null' : mappedValues.last, - ifAbsent: () => - mappedValues.last.isEmpty ? 'null' : mappedValues.last, - ); - return previousValue; - }, - ); - - return (rootKeys: keys.toList(), append: append, data: valueMap); - } - - return ( - rootKeys: keys.toList(), - append: append, - data: values.length == 1 ? values.first : values.toList(), - ); - } -} diff --git a/lib/src/core/argument_normalizers/arg_normalizer.dart b/lib/src/core/argument_normalizers/arg_normalizer.dart new file mode 100644 index 0000000..af76f27 --- /dev/null +++ b/lib/src/core/argument_normalizers/arg_normalizer.dart @@ -0,0 +1,52 @@ +import 'package:args/args.dart'; +import 'package:collection/collection.dart'; + +import 'package:magical_version_bump/src/utils/data/version_modifiers.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; +import 'package:magical_version_bump/src/utils/exceptions/magical_exception.dart'; +import 'package:magical_version_bump/src/utils/extensions/extensions.dart'; +import 'package:magical_version_bump/src/utils/typedefs/typedefs.dart'; +import 'package:meta/meta.dart'; + +part 'bump_args_normalizer.dart'; +part 'find_args_normalizer.dart'; +part 'replace_args_normalizer.dart'; +part 'set_args_normalizer.dart'; + +/// Contains basic code implementations to +/// * Prep args to desired format for each command +/// * Validate arguments +abstract class ArgumentsNormalizer { + ArgumentsNormalizer({required this.argResults}); + + /// Argument results from command + final ArgResults? argResults; + + /// Basic implementation to check if args are empty or null + ({bool isValid, InvalidReason? reason}) validateArgs({ + bool ignoreRestArgs = true, + }) { + /// Args must not be empty or null. + if (argResults == null || + argResults!.arguments.isEmpty || + (ignoreRestArgs && argResults!.rest.isNotEmpty)) { + return ( + isValid: false, + reason: const InvalidReason( + 'Missing arguments', + 'Arguments cannot be empty or null', + ), + ); + } + return customValidate(); + } + + @protected + ({bool isValid, InvalidReason? reason}) customValidate() => ( + isValid: true, + reason: null, + ); + + /// Prep args to desired format + void prepArgs(); +} diff --git a/lib/src/core/argument_normalizers/bump_args_normalizer.dart b/lib/src/core/argument_normalizers/bump_args_normalizer.dart new file mode 100644 index 0000000..cb70c77 --- /dev/null +++ b/lib/src/core/argument_normalizers/bump_args_normalizer.dart @@ -0,0 +1,41 @@ +part of 'arg_normalizer.dart'; + +/// Preps and validates args for modify command +final class BumpArgumentsNormalizer extends ArgumentsNormalizer { + BumpArgumentsNormalizer({required super.argResults}); + + List? _targets; + + @override + ({bool isValid, InvalidReason? reason}) customValidate() { + // Get targets + _targets = argResults!.targets; + + if (_targets!.isEmpty) { + return ( + isValid: false, + reason: const InvalidReason( + 'Invalid targets', + '''You need to pass in a target i.e. major, minor, patch or build-number''', + ), + ); + } + return super.customValidate(); + } + + /// Prep modify args + @override + ({VersionModifiers modifiers, List targets}) prepArgs() { + _targets ??= argResults!.targets; + + // Get version modifiers + final modifiers = VersionModifiers.fromBumpArgResults(argResults!); + + return ( + modifiers: modifiers, + targets: modifiers.strategy == ModifyStrategy.relative + ? _targets!.getRelative() + : _targets!, + ); + } +} diff --git a/lib/src/core/argument_normalizers/find_args_normalizer.dart b/lib/src/core/argument_normalizers/find_args_normalizer.dart new file mode 100644 index 0000000..2abf4f5 --- /dev/null +++ b/lib/src/core/argument_normalizers/find_args_normalizer.dart @@ -0,0 +1,78 @@ +part of 'arg_normalizer.dart'; + +final class FindArgumentsNormalizer extends ArgumentsNormalizer { + FindArgumentsNormalizer({required super.argResults}); + + late ValuesToFind _keysToFind; + late ValuesToFind _valuesToFind; + late PairsToFind _pairsToFind; + + @override + ({bool isValid, InvalidReason? reason}) customValidate() { + _keysToFind = _extractKeyOrValue(argResults!, extractKeys: true); + _valuesToFind = _extractKeyOrValue(argResults!, extractKeys: false); + _pairsToFind = extractPairs(argResults!); + + if (_keysToFind.isEmpty && _valuesToFind.isEmpty && _pairsToFind.isEmpty) { + return ( + isValid: false, + reason: const InvalidReason( + 'Missing arguments', + 'You need to provide at least a key/value/key-value pair to be found', + ), + ); + } + return super.customValidate(); + } + + @override + ({ + Aggregator aggregator, + KeysToFind keysToFind, + ValuesToFind valuesToFind, + PairsToFind pairsToFind, + }) prepArgs() { + return ( + aggregator: argResults!.getAggregator(), + keysToFind: (keys: _keysToFind, orderType: argResults!.keyOrder), + valuesToFind: _valuesToFind, + pairsToFind: _pairsToFind, + ); + } + + /// Extracts keys/value in argument results + List _extractKeyOrValue( + ArgResults argResults, { + required bool extractKeys, + }) { + // Remove duplicates + return Set.from( + extractKeys ? argResults.mapKeys : argResults.mapValues, + ).toList(); + } + + /// Extracts pairs. + /// + /// Throws error when a pair is not complete + PairsToFind extractPairs(ArgResults argResults) { + final listOfPairs = argResults.mapPairs.flattened; + + if (listOfPairs.isEmpty) return {}; + + final pairsToFind = {}; + + // No pair should have a missing partner, throw error if so + for (final pair in listOfPairs) { + final keyAndPair = pair.splitAndTrim(':'); // Separated by ":" + + if (keyAndPair.length != 2) { + throw MagicalException( + message: 'Invalid pair parsed and found at $pair ', + ); + } + + pairsToFind.addAll({keyAndPair.first: keyAndPair.last}); + } + return pairsToFind; + } +} diff --git a/lib/src/core/argument_normalizers/replace_args_normalizer.dart b/lib/src/core/argument_normalizers/replace_args_normalizer.dart new file mode 100644 index 0000000..b5c350c --- /dev/null +++ b/lib/src/core/argument_normalizers/replace_args_normalizer.dart @@ -0,0 +1,82 @@ +part of 'arg_normalizer.dart'; + +final class ReplacerArgumentsNormalizer extends ArgumentsNormalizer { + ReplacerArgumentsNormalizer({ + required super.argResults, + required this.isRename, + }); + + final bool isRename; + + late ParsedValues _replacementCandidates; + late ListOfParsedValues _targetCandidates; + + @override + ({bool isValid, InvalidReason? reason}) customValidate() { + _replacementCandidates = argResults!.replacementCandidates; + + if (_replacementCandidates.isEmpty) { + return ( + isValid: false, + reason: const InvalidReason( + 'Missing replacements', + 'You need to provide at one replacement', + ), + ); + } + + // Get targets based on Replacer Type + _targetCandidates = + isRename ? argResults!.targetKeys : argResults!.targetValues; + + if (_targetCandidates.isEmpty) { + return ( + isValid: false, + reason: InvalidReason( + "Missing ${isRename ? 'keys' : 'values'}", + "You need to provide at least one ${isRename ? 'key' : 'value'}", + ), + ); + } + return super.customValidate(); // Defaults to success + } + + @override + ({ + Aggregator aggregator, + Map> substituteToMatchers, + }) prepArgs() { + // Create modifiable list + final replacementCandidates = [..._replacementCandidates]; + + /// + /// The [ArgParser] parses in sequence, so order of arguments remains. + /// + /// We match each target to replacement based on the corresponding index + /// in replacement. + /// + /// If length is less, last replacement value will act as the replacement + /// for others + final substituteToMatchers = >{}; + + for (final candidate in _targetCandidates) { + // Get replacement. If empty, use last key in linked map + final replacement = + replacementCandidates.firstOrNull ?? substituteToMatchers.keys.last; + + substituteToMatchers.update( + replacement, + (current) => [...current, ...candidate], + ifAbsent: () => candidate, + ); + + // Remove candidate from list + if (replacementCandidates.isNotEmpty) replacementCandidates.removeAt(0); + } + + return ( + aggregator: argResults!.getAggregator(), + substituteToMatchers: substituteToMatchers, + ); + } +} diff --git a/lib/src/core/argument_normalizers/set_args_normalizer.dart b/lib/src/core/argument_normalizers/set_args_normalizer.dart new file mode 100644 index 0000000..c479aca --- /dev/null +++ b/lib/src/core/argument_normalizers/set_args_normalizer.dart @@ -0,0 +1,68 @@ +part of 'arg_normalizer.dart'; + +final class SetArgumentsNormalizer extends ArgumentsNormalizer { + SetArgumentsNormalizer({required super.argResults}); + + /// Prep dictionaries + @override + ({VersionModifiers modifiers, List dictionaries}) prepArgs() { + final dictionaries = [ + ...argResults! + .parsedValues('dictionary') + .map((result) => extractDictionary(result, append: false)), + ...argResults! + .parsedValues('add') + .map((result) => extractDictionary(result, append: true)), + ]; + + return ( + modifiers: VersionModifiers.fromArgResults(argResults!), + dictionaries: dictionaries, + ); + } + + /// Extract dictionaries/lists + Dictionary extractDictionary(String parsedValue, {required bool append}) { + /// + /// Format is "key=value,value" or "key=value:value" + /// + /// Should never be empty + if (parsedValue.isEmpty) { + throw MagicalException( + message: 'The root key cannot be empty/null', + ); + } + + // Must have 2 values, the keys & value(s) + final keysAndValue = parsedValue.splitAndTrim('=').retainNonEmpty(); + + if (keysAndValue.length != 2) { + throw MagicalException( + message: 'Invalid keys and value pair at "$parsedValue"', + ); + } + + /// Format for specifying more than 1 key is using "|" as a separator + /// + /// i.e. `rootKey`|`nextKey`|`otherKey` + final keys = keysAndValue.first.splitAndTrim('|').retainNonEmpty(); + + /// Format for specifying more than 1 value is "," + /// + /// i.e `value`,`nextValue`,`otherValue` + /// + /// Remove any empty values in list. + /// + /// Dynamically extract any maps present. + final values = keysAndValue.last + .splitAndTrim(',') + .retainNonEmpty() + .splitBasedOnMatch(); + + return ( + rootKeys: keys.toList(), + updateMode: append ? UpdateMode.append : UpdateMode.overwrite, + data: values is List && values.length == 1 ? values.first : values, + ); + } +} diff --git a/lib/src/core/command_handlers/command_handlers.dart b/lib/src/core/command_handlers/command_handlers.dart deleted file mode 100644 index b8900b3..0000000 --- a/lib/src/core/command_handlers/command_handlers.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:args/args.dart'; -import 'package:magical_version_bump/src/core/argument_checkers/arg_checker.dart'; -import 'package:magical_version_bump/src/core/custom_version_modifiers/semver_version_modifer.dart'; -import 'package:magical_version_bump/src/utils/enums/enums.dart'; -import 'package:magical_version_bump/src/utils/exceptions/command_exceptions.dart'; -import 'package:magical_version_bump/src/utils/extensions/extensions.dart'; -import 'package:magical_version_bump/src/utils/mixins/command_mixins.dart'; -import 'package:mason_logger/mason_logger.dart'; - -part 'handle_bump_command.dart'; -part 'handle_set_command.dart'; - -/// Each command has a unique way to handle incoming arguments -abstract class CommandHandler with HandleFile, ValidateVersion, ModifyYaml { - CommandHandler({required this.logger}); - - final Logger logger; - - /// Handle command - Future handleCommand(ArgResults? argResults); -} diff --git a/lib/src/core/command_handlers/handle_set_command.dart b/lib/src/core/command_handlers/handle_set_command.dart deleted file mode 100644 index 981d393..0000000 --- a/lib/src/core/command_handlers/handle_set_command.dart +++ /dev/null @@ -1,80 +0,0 @@ -part of 'command_handlers.dart'; - -class HandleSetCommand extends CommandHandler { - HandleSetCommand({required super.logger}); - - /// Change specified node in yaml file - @override - Future handleCommand(ArgResults? argResults) async { - // Start progress - final prepProgress = logger.progress('Checking arguments'); - - final sanitizer = SetArgumentsChecker(argResults: argResults); - - /// Use default validation. - final validatedArgs = sanitizer.defaultValidation(); - - if (!validatedArgs.isValid) { - prepProgress.fail(validatedArgs.reason!.key); - throw MagicalException( - violation: validatedArgs.reason!.value, - ); - } - - final checkedPath = argResults!.pathInfo; - final preppedArgs = sanitizer.prepArgs(); - - final versionModifiers = preppedArgs.modifiers; - - prepProgress.complete('Checked arguments'); - - // Read pubspec.yaml file - final fileData = await readFile( - requestPath: checkedPath.requestPath, - logger: logger, - setPath: checkedPath.path, - ); - - // Set up re-usable file - var editedFile = fileData.file; - - final changeProgress = logger.progress('Updating nodes'); - - if (preppedArgs.dictionaries.isNotEmpty) { - for (final dictionary in preppedArgs.dictionaries) { - editedFile = await updateYamlFile(editedFile, dictionary: dictionary); - } - } - - /// Incase `set-version` was used instead of using the `dictionary` syntax, - /// update it - if (versionModifiers.presetType != PresetType.none) { - final version = MagicalSEMVER.addPresets( - fileData.version ?? '', - modifiers: versionModifiers, - ); - - editedFile = await updateYamlFile( - editedFile, - dictionary: ( - append: false, - rootKeys: ['version'], - data: version, - ), - ); - } - - changeProgress.complete('Changed all nodes'); - - /// Save file changes - await saveFile( - file: editedFile, - path: fileData.path, - logger: logger, - type: fileData.fileType, - ); - - /// Show success - logger.success('Updated your yaml file!'); - } -} diff --git a/lib/src/core/custom_version_modifiers/semver_version_modifer.dart b/lib/src/core/custom_version_modifiers/semver_version_modifer.dart index 8f081e6..42e492d 100644 --- a/lib/src/core/custom_version_modifiers/semver_version_modifer.dart +++ b/lib/src/core/custom_version_modifiers/semver_version_modifer.dart @@ -1,228 +1,224 @@ import 'package:magical_version_bump/src/utils/data/version_modifiers.dart'; import 'package:magical_version_bump/src/utils/enums/enums.dart'; -import 'package:magical_version_bump/src/utils/exceptions/command_exceptions.dart'; +import 'package:magical_version_bump/src/utils/exceptions/magical_exception.dart'; import 'package:magical_version_bump/src/utils/extensions/extensions.dart'; import 'package:pub_semver/pub_semver.dart'; -/// Class with helper methods to bump SEMVER version -class MagicalSEMVER { - static Version _parseVersion(String version) => Version.parse(version); - - /// Add any presets based on the `presetType` and return version - static String addPresets( - String versionFromFile, { - required VersionModifiers modifiers, - }) { - // If no preset was set, return version from file as is. - if (modifiers.presetType == PresetType.none) return versionFromFile; - - /// If only the version will be preset, return the version passed in from - /// `set-version` stored in the version modifier class. - /// - /// Must not be `NULL`. Also make sure no old prerelease/build needs to be - /// retained - if (modifiers.presetType == PresetType.version && - !modifiers.keepBuild && - !modifiers.keepPre) { - return modifiers.version ?? ''; - } +Version _parseVersion(String version) => Version.parse(version); - /// `Preset.all` is inclusive i.e may include: - /// * Only the version - /// * Only the prerelease/build info - /// * All mentioned above +/// Add any presets based on the `presetType` and return version +String addPresets( + String versionFromFile, { + required VersionModifiers modifiers, +}) { + // If no preset was set, return version from file as is. + if (modifiers.presetType == PresetType.none) return versionFromFile; - // Attempt to parse old version - Version? oldVersion; + /// If only the version will be preset, return the version passed in from + /// `set-version` stored in the version modifier class. + /// + /// Must not be `NULL`. Also make sure no old prerelease/build needs to be + /// retained + if (modifiers.presetType == PresetType.version && + !modifiers.keepBuild && + !modifiers.keepPre) { + return modifiers.version ?? ''; + } - if (versionFromFile.isNotEmpty) { - oldVersion = _parseVersion(versionFromFile); - } + /// `Preset.all` is inclusive i.e may include: + /// * Only the version + /// * Only the prerelease/build info + /// * All mentioned above - /// Version from file or Version from `set-version` can be null. Never both - if (oldVersion == null && modifiers.version == null) { - throw MagicalException( - violation: 'At least one valid version is required.', - ); - } + // Attempt to parse old version + Version? oldVersion; - /// Old version can never be null if we are retaining prerelease & build - /// info - if (oldVersion == null && (modifiers.keepBuild || modifiers.keepPre)) { - throw MagicalException( - violation: 'Old version cannot be empty or null', - ); - } + if (versionFromFile.isNotEmpty) { + oldVersion = _parseVersion(versionFromFile); + } - /// As mentioned above both can never be null. Give version from - /// `set-version` has a higher precedence and only fallback to - /// old version (version from file) if version from modifiers is null. - /// - /// Why? - /// - /// Because `set-prerelease` or `set-build` in `preset` may be used but not - /// `set-version` - /// - final version = _parseVersion( - modifiers.version ?? versionFromFile, - ).setPreAndBuild( - updatedPre: modifiers.keepPre - ? (oldVersion!.preRelease.isNotEmpty - ? oldVersion.preRelease.join('.') - : null) - : modifiers.prerelease, - updatedBuild: modifiers.keepBuild - ? (oldVersion!.build.isNotEmpty ? oldVersion.build.join('.') : null) - : modifiers.build, + /// Version from file or Version from `set-version` can be null. Never both + if (oldVersion == null && modifiers.version == null) { + throw MagicalException( + message: 'At least one valid version is required.', ); + } - return version; + /// Old version can never be null if we are retaining prerelease & build + /// info + if (oldVersion == null && (modifiers.keepBuild || modifiers.keepPre)) { + throw MagicalException( + message: 'Old version cannot be empty or null', + ); } - /// Bump version by 1. Used by the `Bump` subcommand. - /// - /// With `absolute`, each version number will be bumped independently. + /// As mentioned above both can never be null. Give version from + /// `set-version` has a higher precedence and only fallback to + /// old version (version from file) if version from modifiers is null. /// - /// 1.1.1 -> bump major version -> 2.1.1 + /// Why? /// - /// With `relative`, each version is modified relative to its position which - /// is the `DEFAULT` behaviour i.e + /// Because `set-prerelease` or `set-build` in `preset` may be used but not + /// `set-version` /// - /// 1.1.1 -> bump major version -> 2.0.0 - static ({bool buildHadIssues, String version}) bumpVersion( - String version, { - required List versionTargets, - required ModifyStrategy strategy, - }) { - final currentVersion = _parseVersion(version); - var modifiedVersion = ''; - - // Get version targets less build-number - final nonBuildTargets = versionTargets.where( - (element) => element != 'build-number', - ); - - // Whether we can bump non-build targets - final bumpNonBuild = nonBuildTargets.isNotEmpty; + final version = _parseVersion( + modifiers.version ?? versionFromFile, + ).setPreAndBuild( + updatedPre: modifiers.keepPre + ? (oldVersion!.preRelease.isNotEmpty + ? oldVersion.preRelease.join('.') + : null) + : modifiers.prerelease, + updatedBuild: modifiers.keepBuild + ? (oldVersion!.build.isNotEmpty ? oldVersion.build.join('.') : null) + : modifiers.build, + ); + + return version; +} - // Bump version relatively - if (strategy == ModifyStrategy.relative && bumpNonBuild) { - if (nonBuildTargets.length > 1) { - throw MagicalException( - violation: 'Expected only one target for this versioning strategy', - ); - } +/// Bump version by 1. Used by the `Bump` subcommand. +/// +/// With `absolute`, each version number will be bumped independently. +/// +/// 1.1.1 -> bump major version -> 2.1.1 +/// +/// With `relative`, each version is modified relative to its position which +/// is the `DEFAULT` behaviour i.e +/// +/// 1.1.1 -> bump major version -> 2.0.0 +({bool buildHadIssues, String version}) bumpVersion( + String version, { + required List versionTargets, + required ModifyStrategy strategy, +}) { + final currentVersion = _parseVersion(version); + var modifiedVersion = ''; + + // Get version targets less build-number + final nonBuildTargets = versionTargets.where( + (element) => element != 'build-number', + ); + + // Whether we can bump non-build targets + final bumpNonBuild = nonBuildTargets.isNotEmpty; + + // Bump version relatively + if (strategy == ModifyStrategy.relative && bumpNonBuild) { + if (nonBuildTargets.length > 1) { + throw MagicalException( + message: 'Expected only one target for this versioning strategy', + ); + } - final target = nonBuildTargets.first; + final target = nonBuildTargets.first; - modifiedVersion = currentVersion.nextRelativeVersion(target).toString(); + modifiedVersion = currentVersion.nextRelativeVersion(target).toString(); - // - } else if (strategy == ModifyStrategy.absolute && bumpNonBuild) { - // Just perform an absolute bump - final mappedVersion = currentVersion.getVersionAsMap(); + // + } else if (strategy == ModifyStrategy.absolute && bumpNonBuild) { + // Just perform an absolute bump + final mappedVersion = currentVersion.getVersionAsMap(); - // Loop all targets and bump by one - for (final target in nonBuildTargets) { - final targetVersion = mappedVersion[target] ?? 0; + // Loop all targets and bump by one + for (final target in nonBuildTargets) { + final targetVersion = mappedVersion[target] ?? 0; - final moddedVersion = targetVersion + 1; + final moddedVersion = targetVersion + 1; - mappedVersion.update( - target, - (value) => moddedVersion, - ); - } + mappedVersion.update( + target, + (value) => moddedVersion, + ); + } - modifiedVersion = mappedVersion.values.map((e) => e.toString()).join('.'); + modifiedVersion = mappedVersion.values.map((e) => e.toString()).join('.'); - // If pre-release, append it only when we are not relatively bumping it - if (currentVersion.isPreRelease) { - modifiedVersion += "-${currentVersion.preRelease.join('.')}"; - } + // If pre-release, append it only when we are not relatively bumping it + if (currentVersion.isPreRelease) { + modifiedVersion += "-${currentVersion.preRelease.join('.')}"; } + } - // An empty modified version means user targeted the build number - if (modifiedVersion.isEmpty) { - modifiedVersion = - '''${currentVersion.major}.${currentVersion.minor}.${currentVersion.patch}'''; + // An empty modified version means user targeted the build number + if (modifiedVersion.isEmpty) { + modifiedVersion = + '''${currentVersion.major}.${currentVersion.minor}.${currentVersion.patch}'''; - if (currentVersion.isPreRelease) { - modifiedVersion += "-${currentVersion.preRelease.join('.')}"; - } + if (currentVersion.isPreRelease) { + modifiedVersion += "-${currentVersion.preRelease.join('.')}"; } + } - // Check if build is just one integer. This makes it "bump-able" - final buildIsBumpable = currentVersion.buildIsNumber(); - - // Check whether we should bump the build. - final shouldBumpBuild = - (buildIsBumpable && versionTargets.contains('build-number')) || - (currentVersion.build.isEmpty && - versionTargets.contains('build-number')); - - // If build is bumpable, bump it - if (shouldBumpBuild) { - // Get build number just incase - final buildFromVersion = currentVersion.build.firstOrNull as int? ?? 0; - - final buildNumber = buildFromVersion + 1; - - modifiedVersion += '+$buildNumber'; - } else { - // Just add build number as is. - var buildNumber = currentVersion.build.isEmpty - ? '' - : currentVersion.build.fold( - '+', - (previousValue, element) => '$previousValue.$element', - ); - - // If build number was added, remove first "." added - if (buildNumber.isNotEmpty) { - buildNumber = buildNumber.replaceFirst('.', ''); - } - - modifiedVersion += buildNumber; + // Check if build is just one integer. This makes it "bump-able" + final buildIsBumpable = currentVersion.buildIsNumber(); + + // Check whether we should bump the build. + final shouldBumpBuild = (buildIsBumpable && + versionTargets.contains('build-number')) || + (currentVersion.build.isEmpty && versionTargets.contains('build-number')); + + // If build is bumpable, bump it + if (shouldBumpBuild) { + // Get build number just incase + final buildFromVersion = currentVersion.build.firstOrNull as int? ?? 0; + + final buildNumber = buildFromVersion + 1; + + modifiedVersion += '+$buildNumber'; + } else { + // Just add build number as is. + var buildNumber = currentVersion.build.isEmpty + ? '' + : currentVersion.build.fold( + '+', + (previousValue, element) => '$previousValue.$element', + ); + + // If build number was added, remove first "." added + if (buildNumber.isNotEmpty) { + buildNumber = buildNumber.replaceFirst('.', ''); } - // Check if build was bumped on user's request. - // - // Fails if build ended up being "un-bumpable" but user wanted it bumped! - final didFail = !buildIsBumpable && versionTargets.contains('build-number'); - - return (buildHadIssues: didFail, version: modifiedVersion); + modifiedVersion += buildNumber; } - /// Add any dangling `set-prerelease` or `set-build` info if `preset` was - /// false or only `set-version` was used - static String appendPreAndBuild( - String version, { - required VersionModifiers modifiers, - }) { - // Check if preset was used - final wasPreset = modifiers.presetType == PresetType.all; - - // Check if any info is available to set - final canAddInfo = modifiers.prerelease != null || modifiers.build != null; - - // Will always return current version if preset was true before checking - // if any info is available to append - if (wasPreset || !canAddInfo) { - return version; - } + // Check if build was bumped on user's request. + // + // Fails if build ended up being "un-bumpable" but user wanted it bumped! + final didFail = !buildIsBumpable && versionTargets.contains('build-number'); - final versionToSave = _parseVersion(version); - - return versionToSave.setPreAndBuild( - updatedPre: modifiers.prerelease ?? - (modifiers.keepPre && versionToSave.preRelease.isNotEmpty - ? versionToSave.preRelease.join('.') - : null), - updatedBuild: modifiers.build ?? - (modifiers.keepBuild && versionToSave.build.isNotEmpty - ? versionToSave.build.join('.') - : null), - ); + return (buildHadIssues: didFail, version: modifiedVersion); +} + +/// Add any dangling `set-prerelease` or `set-build` info if `preset` was +/// false or only `set-version` was used +String appendPreAndBuild( + String version, { + required VersionModifiers modifiers, +}) { + // Check if preset was used + final wasPreset = modifiers.presetType == PresetType.all; + + // Check if any info is available to set + final canAddInfo = modifiers.prerelease != null || modifiers.build != null; + + // Will always return current version if preset was true before checking + // if any info is available to append + if (wasPreset || !canAddInfo) { + return version; } + + final versionToSave = _parseVersion(version); + + return versionToSave.setPreAndBuild( + updatedPre: modifiers.prerelease ?? + (modifiers.keepPre && versionToSave.preRelease.isNotEmpty + ? versionToSave.preRelease.join('.') + : null), + updatedBuild: modifiers.build ?? + (modifiers.keepBuild && versionToSave.build.isNotEmpty + ? versionToSave.build.join('.') + : null), + ); } diff --git a/lib/src/core/handlers/command_handlers/command_handlers.dart b/lib/src/core/handlers/command_handlers/command_handlers.dart new file mode 100644 index 0000000..8c168a8 --- /dev/null +++ b/lib/src/core/handlers/command_handlers/command_handlers.dart @@ -0,0 +1,80 @@ +import 'package:args/args.dart'; +import 'package:magical_version_bump/src/core/argument_normalizers/arg_normalizer.dart'; +import 'package:magical_version_bump/src/core/custom_version_modifiers/semver_version_modifer.dart'; +import 'package:magical_version_bump/src/core/handlers/file_handler/file_handler.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/trackers/counter/generic_counter.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/yaml_transformer.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; +import 'package:magical_version_bump/src/utils/exceptions/magical_exception.dart'; +import 'package:magical_version_bump/src/utils/mixins/command_mixins.dart'; +import 'package:mason_logger/mason_logger.dart'; + +part 'modify_command_handlers/bump_command_handler.dart'; +part 'modify_command_handlers/set_command_handler.dart'; +part 'walk_command_handlers/base.dart'; +part 'walk_command_handlers/find_command_handler.dart'; +part 'walk_command_handlers/replace_command_handler.dart'; + +/// Each command has a unique way to handle incoming arguments +abstract class CommandHandler with ValidateVersion, ModifyYaml { + CommandHandler({required this.logger}); + + /// For logging + final Logger logger; + + /// File handler for file io operations + late FileHandler _fileHandler; + + /// Argument checker that validates and preps arguments + late ArgumentsNormalizer _argumentsNormalizer; + + /// Each subclass must implement this as each command has its own core logic. + Future _coreCommandHandler(ArgResults? argResults); + + /// Set up file handler + void _setupFileHandler(ArgResults? argResults) { + _fileHandler = FileHandler.fromParsedArgs( + argResults, + logger, + ); + } + + /// Each subclass behaves differently based on args passed in by user. Thus + /// must be overriden/implemented. + void _setUpArgChecker(ArgResults? argResults); + + /// Handle command. Commands call this method with args the user passed + /// in. + Future handleCommand(ArgResults? argResults) async { + /// Args checker. Args must be validated before setting up any other + /// utility classes. + /// + /// Throw any errors + _setUpArgChecker(argResults); + + final validationProgress = logger.progress('Checking arguments'); + + final validatedArgs = _argumentsNormalizer.validateArgs(); + + if (!validatedArgs.isValid) { + validationProgress.fail(validatedArgs.reason!.key); + throw MagicalException(message: validatedArgs.reason!.value); + } + + validationProgress.complete('Checked arguments'); + + // Setup file handler for use + _setupFileHandler(argResults); + + /// Any other operation will be handled by core logic. + /// + /// I'm leaving a wildcard for file reading as future functionality of + /// this tool may require multiple files to be read from disk + /// + /// Same for file saving. + await _coreCommandHandler(argResults); + } + + /// Get arguments checker + T _getChecker() => _argumentsNormalizer as T; +} diff --git a/lib/src/core/command_handlers/handle_bump_command.dart b/lib/src/core/handlers/command_handlers/modify_command_handlers/bump_command_handler.dart similarity index 51% rename from lib/src/core/command_handlers/handle_bump_command.dart rename to lib/src/core/handlers/command_handlers/modify_command_handlers/bump_command_handler.dart index e273a79..9ce086a 100644 --- a/lib/src/core/command_handlers/handle_bump_command.dart +++ b/lib/src/core/handlers/command_handlers/modify_command_handlers/bump_command_handler.dart @@ -1,42 +1,34 @@ -part of 'command_handlers.dart'; +part of '../command_handlers.dart'; final class HandleBumpCommand extends CommandHandler { HandleBumpCommand({required super.logger}); - /// Modify the version in pubspec.yaml + /// Setup our bump arguments @override - Future handleCommand(ArgResults? argResults) async { - // Command progress - final prepProgress = logger.progress('Checking arguments'); - - final sanitizer = BumpArgumentsChecker(argResults: argResults); - - // Validate args - final validatedArgs = sanitizer.validateArgs(); + void _setUpArgChecker(ArgResults? argResults) { + super._argumentsNormalizer = BumpArgumentsNormalizer( + argResults: argResults, + ); + } - if (!validatedArgs.isValid) { - prepProgress.fail(validatedArgs.reason!.key); - throw MagicalException(violation: validatedArgs.reason!.value); - } + /// Modify the version in pubspec.yaml + @override + Future _coreCommandHandler(ArgResults? argResults) async { + final checker = _getChecker(); // Required information to bump version - final preppedArgs = sanitizer.prepArgs(); - final pathInfo = argResults!.pathInfo; + final preppedArgs = checker.prepArgs(); final versionModifiers = preppedArgs.modifiers; - prepProgress.complete('Checked arguments'); - // Read pubspec.yaml file - final fileData = await readFile( - requestPath: pathInfo.requestPath, - logger: logger, - setPath: pathInfo.path, - ); + final fileOuput = await _fileHandler.readFile(); + + final localVersion = fileOuput.fileAsMap['version'] as String?; /// Preset any values before validating the version. When `--preset` flag /// is used or `--set-version` option - final currentVersion = MagicalSEMVER.addPresets( - fileData.version ?? '', + final currentVersion = addPresets( + localVersion ?? '', modifiers: versionModifiers, ); @@ -49,7 +41,7 @@ final class HandleBumpCommand extends CommandHandler { // Bump the version final modProgress = logger.progress('Bumping up version'); - final modifiedVersion = MagicalSEMVER.bumpVersion( + final modifiedVersion = bumpVersion( validatedVersion, versionTargets: preppedArgs.targets, strategy: versionModifiers.strategy, @@ -61,27 +53,26 @@ final class HandleBumpCommand extends CommandHandler { } // Add final touches before updating yaml file - final versionToSave = MagicalSEMVER.appendPreAndBuild( + final versionToSave = appendPreAndBuild( modifiedVersion.version, modifiers: versionModifiers, ); final modifiedFile = await updateYamlFile( - fileData.file, - dictionary: (append: false, rootKeys: ['version'], data: versionToSave), + fileOuput, + dictionary: ( + updateMode: UpdateMode.overwrite, + rootKeys: ['version'], + data: versionToSave, + ), ); modProgress.complete('Modified version'); - /// Save file changes - await saveFile( - file: modifiedFile, - path: fileData.path, - logger: logger, - type: fileData.fileType, - ); + // Save file changes + await _fileHandler.saveFile(modifiedFile); - /// Show success + // Show success logger.success( 'Version bumped up from $currentVersion to $versionToSave', ); diff --git a/lib/src/core/handlers/command_handlers/modify_command_handlers/set_command_handler.dart b/lib/src/core/handlers/command_handlers/modify_command_handlers/set_command_handler.dart new file mode 100644 index 0000000..6067719 --- /dev/null +++ b/lib/src/core/handlers/command_handlers/modify_command_handlers/set_command_handler.dart @@ -0,0 +1,74 @@ +part of '../command_handlers.dart'; + +class HandleSetCommand extends CommandHandler { + HandleSetCommand({required super.logger}); + + @override + void _setUpArgChecker(ArgResults? argResults) { + super._argumentsNormalizer = SetArgumentsNormalizer( + argResults: argResults, + ); + } + + /// Change specified node in yaml file + @override + Future _coreCommandHandler(ArgResults? argResults) async { + final checker = _getChecker(); + + final preppedArgs = checker.prepArgs(); + final versionModifiers = preppedArgs.modifiers; + + // Read pubspec.yaml file + final fileOuput = await _fileHandler.readFile(); + + // Set up re-usable file + var editedFile = fileOuput.file; + + final changeProgress = logger.progress('Updating nodes'); + + if (preppedArgs.dictionaries.isNotEmpty) { + /// + /// Loop all entries. The first entry will use file read fresh from disk + /// while successive entries will use the previously modified file + for (final (index, dictionary) in preppedArgs.dictionaries.indexed) { + editedFile = index == 0 + ? await updateYamlFile(fileOuput, dictionary: dictionary) + : await updateYamlFile( + ( + file: editedFile, + fileAsMap: _fileHandler.convertToMap(editedFile) + ), + dictionary: dictionary, + ); + } + } + + /// Incase `set-version` was used instead of using the `dictionary` syntax, + /// update it + if (versionModifiers.presetType != PresetType.none) { + final localVersion = fileOuput.fileAsMap['version'] as String?; + + final version = addPresets( + localVersion ?? '', + modifiers: versionModifiers, + ); + + editedFile = await updateYamlFile( + (file: editedFile, fileAsMap: _fileHandler.convertToMap(editedFile)), + dictionary: ( + updateMode: UpdateMode.overwrite, + rootKeys: ['version'], + data: version, + ), + ); + } + + changeProgress.complete('Changed all nodes'); + + /// Save file changes + await _fileHandler.saveFile(editedFile); + + /// Show success + logger.success('Updated your yaml file!'); + } +} diff --git a/lib/src/core/handlers/command_handlers/walk_command_handlers/base.dart b/lib/src/core/handlers/command_handlers/walk_command_handlers/base.dart new file mode 100644 index 0000000..8edadac --- /dev/null +++ b/lib/src/core/handlers/command_handlers/walk_command_handlers/base.dart @@ -0,0 +1,69 @@ +part of '../command_handlers.dart'; + +abstract base class HandleWalkCommand extends CommandHandler { + HandleWalkCommand({ + required super.logger, + required WalkSubCommandType subCommandType, + }) : _subCommandType = subCommandType; + + /// Indicate the specific subcommand being executed + final WalkSubCommandType _subCommandType; + + /// A manager that handles internal "map-walk" functionalities + late TransformerManager _manager; + + /// Set up [TransformerManager] to handle recursions + /// + /// Internally, the [TransformerManager] sets up a `Formatter`. + /// + /// Logic includes any prepped args as each command is set differently + void _setUpManager(List> fileQueue); + + @override + Future _coreCommandHandler(ArgResults? argResults) async { + // Read all files added to be used + final files = await _fileHandler.readAll(multiple: true); + + _setUpManager(files.map((e) => e.fileAsMap).toList()); + + await _manager.transform(); + + final isReplaceMode = !_subCommandType.isFinder; + final counters = _getCounters(); + + // Save changes as replace mode modifies them + if (isReplaceMode) { + final saveProgress = logger.progress('Saving changes'); + + final modifiedFiles = (_manager as ReplacerManager).modifiedFiles; + + if (modifiedFiles == null || modifiedFiles.isEmpty) { + saveProgress.complete('No changes made'); + } else { + // Save changes + for (final modifiedFile in modifiedFiles) { + await _fileHandler.saveFile( + modifiedFile.modifiedFile.toString(), + index: modifiedFile.fileIndex, + showProgress: false, + ); + } + saveProgress.complete('Saved changes'); + } + } + + final output = _manager.formatter.format( + isReplaceMode: isReplaceMode, + fileNames: _fileHandler.filePaths, + finderFileCounter: counters.$1, + replacerFileCounter: counters.$2, + ); + + logger.info(output); + } + + ( + Counter finderFileCounter, + Counter? replacerFileCounter, + ) _getCounters(); +} diff --git a/lib/src/core/handlers/command_handlers/walk_command_handlers/find_command_handler.dart b/lib/src/core/handlers/command_handlers/walk_command_handlers/find_command_handler.dart new file mode 100644 index 0000000..7582a72 --- /dev/null +++ b/lib/src/core/handlers/command_handlers/walk_command_handlers/find_command_handler.dart @@ -0,0 +1,36 @@ +part of '../command_handlers.dart'; + +final class HandleFindCommand extends HandleWalkCommand { + HandleFindCommand({ + required super.logger, + }) : super(subCommandType: WalkSubCommandType.find); + + @override + void _setUpManager(List> fileQueue) { + // Obtain prepped args from checker + final checker = _getChecker(); + final preppedArgs = checker.prepArgs(); + + _manager = FinderManager.fullSetup( + fileQueue: fileQueue, + aggregator: preppedArgs.aggregator, + logger: logger, + finderType: FinderType.byValue, + keysToFind: preppedArgs.keysToFind, + valuesToFind: preppedArgs.valuesToFind, + pairsToFind: preppedArgs.pairsToFind, + ); + } + + @override + void _setUpArgChecker(ArgResults? argResults) { + _argumentsNormalizer = FindArgumentsNormalizer(argResults: argResults); + } + + @override + (Counter, Counter?) _getCounters() { + final manager = _manager as FinderManager; + + return (manager.managerCounter, null); + } +} diff --git a/lib/src/core/handlers/command_handlers/walk_command_handlers/replace_command_handler.dart b/lib/src/core/handlers/command_handlers/walk_command_handlers/replace_command_handler.dart new file mode 100644 index 0000000..7b5e06f --- /dev/null +++ b/lib/src/core/handlers/command_handlers/walk_command_handlers/replace_command_handler.dart @@ -0,0 +1,41 @@ +part of '../command_handlers.dart'; + +final class HandleReplaceCommand extends HandleWalkCommand { + HandleReplaceCommand({ + required super.logger, + required super.subCommandType, + }); + + @override + void _setUpManager(List> fileQueue) { + // Obtain prepped args from checker + final checker = _getChecker(); + final preppedArgs = checker.prepArgs(); + + _manager = ReplacerManager.defaultSetup( + commandType: _subCommandType, + fileQueue: fileQueue, + aggregator: preppedArgs.aggregator, + logger: logger, + substituteToMatchers: preppedArgs.substituteToMatchers, + ); + } + + @override + void _setUpArgChecker(ArgResults? argResults) { + _argumentsNormalizer = ReplacerArgumentsNormalizer( + argResults: argResults, + isRename: _subCommandType == WalkSubCommandType.rename, + ); + } + + @override + (Counter, Counter?) _getCounters() { + final manager = _manager as ReplacerManager; + + return ( + manager.finderManagerCounter, + manager.managerCounter, + ); + } +} diff --git a/lib/src/core/handlers/file_handler/file_handler.dart b/lib/src/core/handlers/file_handler/file_handler.dart new file mode 100644 index 0000000..8b1d2bb --- /dev/null +++ b/lib/src/core/handlers/file_handler/file_handler.dart @@ -0,0 +1,147 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; +import 'package:magical_version_bump/src/utils/extensions/extensions.dart'; +import 'package:magical_version_bump/src/utils/typedefs/typedefs.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:meta/meta.dart'; +import 'package:yaml/yaml.dart'; + +part 'file_handler_util.dart'; + +class FileHandler { + FileHandler(); + + /// Create a file handler based on arguments + factory FileHandler.fromParsedArgs(ArgResults? argResults, Logger logger) { + final pathInfo = argResults!.pathInfo; + final handler = FileHandler() + ..requestPath = pathInfo.requestPath + ..fileLogger = logger; + + // Just set path if user doesn't want prompts + if (!pathInfo.requestPath) { + handler.files = getFileTypes(pathInfo.paths); + } + + return handler; + } + + /// Whether to use path from args/request path + late bool requestPath; + + /// File paths and their file types + late Map files; + + List get filePaths => files.keys.toList(); + + /// Logger for interacting with Command line + late Logger fileLogger; + + /// Read first file only. Used by handlers for commands that read from a + /// single directory. + Future readFile() async { + final outputs = await readAll(); + return outputs.first; + } + + /// Read multiple files provided by user. If not provided, requests them. + /// + /// `SingleDirectoryCommand`s should use [readFile] as it + /// returns just one file. + /// + Future> readAll({bool multiple = false}) async { + // Get file paths + final paths = + requestPath ? requestPaths(multiple: multiple) : files.keys.toList(); + + // Update file type + final outputs = []; + + final readProgress = fileLogger.progress( + "Reading ${multiple ? 'files' : 'file'}", + ); + + // Start reading all files + for (final path in paths) { + final file = await File(path).readAsString(); + final output = (file: file, fileAsMap: convertToMap(file)); + outputs.add(output); + } + + // Set file and their types if not initially set + if (requestPath) files = getFileTypes(paths); + + readProgress.complete('Read files'); + return outputs; + } + + /// Save file. + Future saveFile( + String modifiedFile, { + int index = 0, + bool showProgress = true, + }) async { + Progress? saveProgress; + + if (showProgress) saveProgress = fileLogger.progress('Saving changes'); + + // File path details + final fileDetails = files.entries.elementAt(index); + + final fileTosave = fileDetails.value == FileType.json + ? _convertMapToString(modifiedFile) + : modifiedFile; + + await File(fileDetails.key).writeAsString(fileTosave); + + return saveProgress?.complete('Saved changes'); + } + + /// Save multiple files. + /// + /// NOTE: The order of altered files must match order of their request. All + /// file outputs must order of file requests/paths provided. + Future saveAll(List modifiedFiles) async { + // Loop all + for (final (index, modifiedFile) in modifiedFiles.indexed) { + await saveFile(modifiedFile, index: index); + } + } + + /// Convert read file to YAML map + YamlMap convertToMap(String file) => loadYaml(file) as YamlMap; + + /// Convert to pretty json/yaml string + String _convertMapToString(String file) { + // Convert to yaml + final yamlMap = convertToMap(file); + + // For json files add indent + final indent = ' ' * 4; + final encoder = JsonEncoder.withIndent(indent); + + return encoder.convert(yamlMap); + } + + /// Request file paths from user + @protected + List requestPaths({required bool multiple}) { + // Request input from user + final request = multiple + ? 'Please enter all paths to files (use comma to separate): ' + : 'Please enter the path to file: '; + + final userInput = fileLogger.prompt( + request, + defaultValue: 'pubspec.yaml', + ); + + // Return lists of path + return multiple + ? userInput.splitAndTrim(',').retainNonEmpty() + : [userInput]; + } +} diff --git a/lib/src/core/handlers/file_handler/file_handler_util.dart b/lib/src/core/handlers/file_handler/file_handler_util.dart new file mode 100644 index 0000000..2ae9f0c --- /dev/null +++ b/lib/src/core/handlers/file_handler/file_handler_util.dart @@ -0,0 +1,11 @@ +part of 'file_handler.dart'; + +/// Obtains the file type for each file name +Map getFileTypes(List paths) { + return paths.fold({}, (previousValue, path) { + previousValue.addAll( + {path: path.split('.').last.toLowerCase().fileType}, + ); + return previousValue; + }); +} diff --git a/lib/src/core/yaml_transformers/data/matched_node_data.dart b/lib/src/core/yaml_transformers/data/matched_node_data.dart new file mode 100644 index 0000000..bc5fe34 --- /dev/null +++ b/lib/src/core/yaml_transformers/data/matched_node_data.dart @@ -0,0 +1,70 @@ +part of '../yaml_transformer.dart'; + +/// Data object specifically created when a `Finder` finds it based on some +/// predefined condition +@immutable +class MatchedNodeData extends NodeData { + const MatchedNodeData( + super.precedingKeys, + super.key, + super.value, + this.matchedKeys, + this.matchedValue, + this.matchedPairs, + ); + + /// Data from finder + factory MatchedNodeData.fromFinder({ + required NodeData nodeData, + required List matchedKeys, + required String matchedValue, + required Map matchedPairs, + }) { + return MatchedNodeData( + nodeData.precedingKeys, + nodeData.key, + nodeData.value, + matchedKeys, + matchedValue, + matchedPairs, + ); + } + + /// List of keys in [ NodeData ] path that matched any preset conditions + final List matchedKeys; + + /// First/Only value matched from any of the values provided + final String matchedValue; + + /// Map of pairs that matched any pairs provided + final Map matchedPairs; + + /// Check if valid match, at least one should not be empty + bool isValidMatch() { + return matchedKeys.isNotEmpty || + matchedValue.isNotEmpty || + matchedPairs.isNotEmpty; + } + + /// Get list of keys upto the last renameable key + Iterable getUptoLastRenameable() { + final keys = super.getKeysAsString(); + final lastIndex = matchedKeys.map(keys.lastIndexOf).max; + + // Keys to be taken, include last index plus one + return super.getKeys().take(lastIndex + 1); + } + + /// Get path of keys upto the last renameable key + String getPathToLastKey() { + return getUptoLastRenameable().map((key) => key.toString()).join('/'); + } + + @override + List get props => [ + ...super.props, + matchedKeys, + matchedPairs, + matchedValue, + ]; +} diff --git a/lib/src/core/yaml_transformers/data/node_data.dart b/lib/src/core/yaml_transformers/data/node_data.dart new file mode 100644 index 0000000..bfaeecb --- /dev/null +++ b/lib/src/core/yaml_transformers/data/node_data.dart @@ -0,0 +1,143 @@ +part of '../yaml_transformer.dart'; + +/// Every Node has preceding keys and data at the end of it, even if null. +/// +/// Typically denotes a terminal node found while indexing a Yaml map or any +/// map +@immutable +class NodeData extends Equatable { + const NodeData(this.precedingKeys, this.key, this.value); + + /// Create with default constructor + const NodeData.skeleton({ + required List precedingKeys, + required Key key, + required Value value, + }) : this(precedingKeys, key, value); + + /// Create using List path and key + NodeData.stringSkeleton({ + required List path, + required String key, + required String value, + }) : this.skeleton( + precedingKeys: path.map((e) => createPair(value: e)).toList(), + key: createPair(value: key), + value: createPair(value: value), + ); + + /// Create from the root anchor key + NodeData.fromRoot({required dynamic key, required dynamic value}) + : this.skeleton( + precedingKeys: const [], + key: createPair(value: key), + value: createPair(value: value), + ); + + /// Creates from entry in map. + /// + /// * By default, the level & index will apply to the key itself rather than + /// the value. + /// * This is because we recursed the list to reach this key rather than the + /// value. + NodeData.fromMapEntry({ + required NodeData parent, + required MapEntry current, + required List indices, + }) : this.skeleton( + precedingKeys: [...parent.precedingKeys, parent.key], + key: createPair(value: current.key, indices: indices), + value: createPair(value: current.value), + ); + + /// Creates from terminal value at the end of a node + /// + /// * By default, the level & index will apply to the value rather than + /// the key as we recursed the list to reach this value rather than the + /// key. + /// + /// * The parent's key will be this terminal value's key too as it's the + /// nearest key linking this value to a map. + NodeData.atRootTerminal({ + required NodeData parent, + required dynamic terminalValue, + required List indices, + }) : this.skeleton( + precedingKeys: [...parent.precedingKeys], + key: parent.key, + value: createPair(value: terminalValue, indices: indices), + ); + + /// Any preceding keys for this node + final List precedingKeys; + + /// Current key for this node + final Key key; + + /// Current data at this node + final Value value; + + /// Gets the actual value at terminal end as a string. A null value will be + /// returned as 'null'. + String get data => value.toString(); + + /// Transform to key value pairs, based on this node data's path. + /// + /// Note: the terminal value must be a string + Map transformToPairs() { + // Get length of list + final lastIndex = precedingKeys.length - 1; + + final mapOfPairs = {}; + + // Loop all and create keys in tandem + for (final (index, candidate) in precedingKeys.indexed) { + // If we reached the last value, pair it with key for this node + if (index == lastIndex) { + mapOfPairs.addAll({candidate.toString(): key.toString()}); + } + + // Just get the next key + else { + final nextCandidate = precedingKeys[index + 1]; + mapOfPairs.addAll({candidate.toString(): nextCandidate.toString()}); + } + } + + // Add key and value as last pair + mapOfPairs.addAll({key.toString(): data}); + return mapOfPairs; + } + + /// Obtains the keys as they were indexed. + /// + /// Typically includes any indices if the key was nested in a list for + /// easy access when reading + List getKeys() { + return precedingKeys.isEmpty ? [key] : [...precedingKeys, key]; + } + + /// Obtains the keys as string. Ignores any indices present + List getKeysAsString() { + return getKeys().map((e) => e.toString()).toList(); + } + + /// Get key path for this node + String getKeyPath() { + return getKeysAsString().join('/'); + } + + /// Obtains full path to terminal value of this node + @override + String toString() => '${getKeyPath()}/$data'; + + /// Checks whether this node is nested in a list + bool isNestedInList() => + key.isNested() || + value.isNested() || + precedingKeys.isNotEmpty && + precedingKeys.any((element) => element.isNested()); + + @override + List get props => [precedingKeys, key, value]; +} diff --git a/lib/src/core/yaml_transformers/data/pair_definition/custom_pair_type.dart b/lib/src/core/yaml_transformers/data/pair_definition/custom_pair_type.dart new file mode 100644 index 0000000..31693b5 --- /dev/null +++ b/lib/src/core/yaml_transformers/data/pair_definition/custom_pair_type.dart @@ -0,0 +1,56 @@ +import 'package:equatable/equatable.dart'; +import 'package:magical_version_bump/src/utils/extensions/map_extensions.dart'; +import 'package:meta/meta.dart'; + +part 'pair_subtypes.dart'; + +/// Custom key/value definition +@immutable +abstract base class PairType extends Equatable { + PairType({required dynamic value, List? indices}) + : _value = value, + _indices = indices ?? []; + + /// Indicates the actual value + final dynamic _value; + + /// Indicates the list of indices when nested in 1 or more lists + final List _indices; + + /// Obtains the actual value stored here at runtime. + dynamic get rawValue => _value; + + /// Obtains the list of indices when nested in 1 or more lists + List get indices => _indices; + + @override + List get props => [rawValue.toString(), indices]; + + /// Returns the value stored at this node as a string + @override + String toString() { + return _value.toString(); + } + + /// Checks if nested in a list + bool isNested() => _indices.isNotEmpty; +} + +/// Creates desired pair type on the fly. +T createPair({ + required dynamic value, + List? indices, +}) { + if (T == Key) return Key(value: value, indices: indices) as T; + return Value(value: value, indices: indices) as T; +} + +/// Creates a list of desired pairs. +List createListOfPair({ + required List values, + required Map> indices, +}) { + return values + .map((value) => createPair(value: value, indices: indices[value])) + .toList(); +} diff --git a/lib/src/core/yaml_transformers/data/pair_definition/pair_subtypes.dart b/lib/src/core/yaml_transformers/data/pair_definition/pair_subtypes.dart new file mode 100644 index 0000000..f2574cd --- /dev/null +++ b/lib/src/core/yaml_transformers/data/pair_definition/pair_subtypes.dart @@ -0,0 +1,15 @@ +part of 'custom_pair_type.dart'; + +/// A key in a custom key/value definition +final class Key extends PairType { + Key({required super.value, required super.indices}) + : assert( + isTerminal(value), + 'A type of ${value.runtimeType} cannot be a key', + ); +} + +/// A value in a custom key/value definition +final class Value extends PairType { + Value({required super.value, required super.indices}); +} diff --git a/lib/src/core/yaml_transformers/finders/custom_tracker.dart b/lib/src/core/yaml_transformers/finders/custom_tracker.dart new file mode 100644 index 0000000..37c1c45 --- /dev/null +++ b/lib/src/core/yaml_transformers/finders/custom_tracker.dart @@ -0,0 +1,42 @@ +part of 'finder.dart'; + +/// Custom counter that increments using [MatchedNodeData]. It uses a file +/// index as cursor that links it with its counter. +/// +/// See [Counter]. +final class MatchCounter extends CounterWithHistory { + MatchCounter({required int? limit}) : _limit = limit; + + final int? _limit; + + /// Increments from [ MatchedNodeData ]. + /// + /// Returns true if all elements are found. Useful if counter was prefilled. + /// + /// Always returns false if limit is null. + bool incrementUsingMatch(MatchedNodeData data) { + // Add any matched keys + if (data.matchedKeys.isNotEmpty) { + increment(data.matchedKeys, origin: Origin.key); + } + + // Add matched value if not empty + if (data.matchedValue.isNotEmpty) { + increment([data.matchedValue], origin: Origin.value); + } + + // Add all pairs + if (data.matchedPairs.isNotEmpty) { + increment(data.matchedPairs.entries, origin: Origin.pair); + } + + /// All must be equal or greater than limit. Other keys may have + /// been found before more than once. + /// + /// Get set of all counts and check if any is below limit + final anyBelowLimit = _limit == null || + super.trackerState.values.toSet().any((element) => element < _limit!); + + return !anyBelowLimit; + } +} diff --git a/lib/src/core/yaml_transformers/finders/finder.dart b/lib/src/core/yaml_transformers/finders/finder.dart new file mode 100644 index 0000000..5ec5e7c --- /dev/null +++ b/lib/src/core/yaml_transformers/finders/finder.dart @@ -0,0 +1,174 @@ +import 'package:magical_version_bump/src/core/yaml_transformers/trackers/counter/generic_counter.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/yaml_transformer.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; +import 'package:magical_version_bump/src/utils/exceptions/magical_exception.dart'; +import 'package:magical_version_bump/src/utils/extensions/extensions.dart'; +import 'package:magical_version_bump/src/utils/typedefs/typedefs.dart'; +import 'package:meta/meta.dart'; +import 'package:yaml/yaml.dart'; + +part 'custom_tracker.dart'; +part 'value_finder.dart'; + +typedef FinderOutput = ({bool reachedLimit, MatchedNodeData data}); + +/// Abstract class for looking for values in yaml maps +/// +/// Both [ValueFinder] & ValueSearcher will extend this +abstract base class Finder { + Finder({ + required this.indexer, + bool? saveCounterToHistory, + }) : _saveCounterToHistory = saveCounterToHistory ?? true; + + /// An indexer that recurses through the map and spits out terminal + /// values sequentially. + MagicalIndexer indexer; + + /// Indicates whether to save the current counter to history when a + /// map to be indexed is swapped + final bool _saveCounterToHistory; + + /// A tracker to keep track of aggregated values. May be null if not + /// initialized yet. + MatchCounter? counter; + + /// Adds limit to [MatchCounter] only when [Finder.findAllSync] or + /// [Finder.findByCountSync] is called. + /// + /// Will always be called if this finder's `saveCounterToHistory` was set + /// to false. And is reused to by swapping map via [Finder.swapMap]. + void _setUpCounter(int? count) { + // If history was false, setup again to ensure count accuracy. + if (counter == null || counter != null && !_saveCounterToHistory) { + counter = MatchCounter(limit: count); + } + } + + /// Prefill counter with values to find for accurate counting + /// + /// All subclasses must override this method. + void _prefillCounter(); + + /// Swaps the map currently being indexed by the [MagicalIndexer] tied to + /// this [Finder] and returns the current counter state. Throws an error if + /// [MatchCounter] is still null when swapping. + /// + /// May be null if [Finder.find] or [Finder.findByCountSync] or + /// [Finder.findAllSync] were never called at all. + /// + /// Try swapping manually or calling the methods specified above if you want + /// to avoid the error. + MatchCounter? swapMap(Map map, {int? cursor}) { + indexer.map = map; + + /// If [_saveCounterToHistory] is true, a cursor must be provided + if (_saveCounterToHistory) { + if (cursor == null || counter == null) { + throw MagicalException( + message: 'Neither cursor/counter should be null', + ); + } + counter!.reset(cursor: cursor); + } + + return counter; + } + + /// An on-demand generator that is indexing a map. + Iterable get _generator => indexer.indexYaml(); + + /// Default entry point for finding values. Finds values based on + /// [AggregateType] specified. + /// + /// Internally uses [Finder.findByCountSync] & [Finder.findAllSync] based on + /// [AggregateType]. + /// + /// If [AggregateType.all], count is ignored. For any other [AggregateType], + /// count `MUST` be specified. + /// + Iterable find({ + required AggregateType aggregateType, + required bool applyToEach, + int? count, + }) sync* { + // For AggregateType.all + if (aggregateType == AggregateType.all) { + yield* findAllSync(); + } else { + // Count must be valid going forward. + if (count == null || count < 0) { + throw MagicalException( + message: 'Count must be a value equal/greater than 1', + ); + } + + yield* findByCountSync( + count, + applyToEach: applyToEach, + ); + } + } + + /// Find by count synchronously, value by value + Iterable findByCountSync( + int count, { + required bool applyToEach, + }) sync* { + /// Incase this method is called directly instead of [Finder.find] + _setUpCounter(count); + + // Prefill tracker with everything being tracked. + _prefillCounter(); + + /// If we are not applying to each argument. Take count as is + if (!applyToEach) { + yield* findAllSync(prefilledCounter: true).take(count); + } + + /// If not take as until limit is reached + else { + FinderOutput? lastValue; + + yield* findAllSync(prefilledCounter: true).takeWhile( + (value) { + /// Last value may be ignored. Last value itself causes the limit + /// to be reached. The limit is never reaches before. + if (value.reachedLimit) lastValue = value; + return !value.reachedLimit; + }, + ); + + if (lastValue != null) yield lastValue!; + } + } + + /// Find all values + List findAll() => + findAllSync().toList().map((output) => output.data).toList(); + + /// Find all matches synchronously + Iterable findAllSync({bool prefilledCounter = false}) sync* { + /// Incase this method is called indirectly via [Finder.find] + /// + /// [Finder.findByCount] always prefills the counter thus always + /// sets up the [MatchCounter] + if (!prefilledCounter) _setUpCounter(null); + + for (final nodeData in _generator) { + // Generate matched node data + final matchedNodeData = generateMatch(nodeData); + + // We only yield it if it is valid + if (matchedNodeData.isValidMatch()) { + yield ( + data: matchedNodeData, + reachedLimit: counter!.incrementUsingMatch(matchedNodeData), + ); + } + } + } + + /// Generates a matched based on internal functionality + MatchedNodeData generateMatch(NodeData nodeData); +} diff --git a/lib/src/core/yaml_transformers/finders/value_finder.dart b/lib/src/core/yaml_transformers/finders/value_finder.dart new file mode 100644 index 0000000..a319639 --- /dev/null +++ b/lib/src/core/yaml_transformers/finders/value_finder.dart @@ -0,0 +1,215 @@ +part of 'finder.dart'; + +/// Find the first value matching a condition +base class ValueFinder extends Finder { + ValueFinder._({ + required super.indexer, + super.saveCounterToHistory, + KeysToFind? keysToFind, + ValuesToFind? valuesToFind, + PairsToFind? pairsToFind, + }) : _keysToFind = keysToFind ?? (keys: [], orderType: OrderType.loose), + _valuesToFind = valuesToFind ?? [], + _pairsToFind = pairsToFind ?? {}; + + /// Set up with predefined indexer + ValueFinder.findWithIndexer( + MagicalIndexer indexer, { + required bool saveCounterToHistory, + required KeysToFind? keysToFind, + required ValuesToFind? valuesToFind, + required PairsToFind? pairsToFind, + }) : this._( + indexer: indexer, + saveCounterToHistory: saveCounterToHistory, + keysToFind: keysToFind, + valuesToFind: valuesToFind, + pairsToFind: pairsToFind, + ); + + ValueFinder.findInMap( + Map map, { + required bool saveCounterToHistory, + required KeysToFind? keysToFind, + required ValuesToFind? valuesToFind, + required PairsToFind? pairsToFind, + }) : this.findWithIndexer( + MagicalIndexer.forDartMap(map), + saveCounterToHistory: saveCounterToHistory, + keysToFind: keysToFind, + valuesToFind: valuesToFind, + pairsToFind: pairsToFind, + ); + + /// Setup everything that may need to found + ValueFinder.findInYaml( + YamlMap yamlMap, { + required bool saveCounterToHistory, + required KeysToFind? keysToFind, + required ValuesToFind? valuesToFind, + required PairsToFind? pairsToFind, + }) : this.findInMap( + yamlMap, + saveCounterToHistory: saveCounterToHistory, + keysToFind: keysToFind, + valuesToFind: valuesToFind, + pairsToFind: pairsToFind, + ); + + /// Keys to find from indexed values + final KeysToFind _keysToFind; + + /// Values to find at terminal end of node + final ValuesToFind _valuesToFind; + + /// Key-value pairs to find. Will match all keys & terminal value to + final PairsToFind _pairsToFind; + + @override + void _prefillCounter() { + counter!.prefill(_keysToFind.keys, origin: Origin.key); + counter!.prefill(_valuesToFind, origin: Origin.value); + counter!.prefill(_pairsToFind.entries.toList(), origin: Origin.pair); + } + + @override + MatchedNodeData generateMatch(NodeData nodeData) { + return MatchedNodeData.fromFinder( + nodeData: nodeData, + matchedKeys: getMatchingKeys(nodeData), + matchedValue: getFirstMachingValue(nodeData), + matchedPairs: getMatchingPairs(nodeData), + ); + } + + /// Check if keys contain a value + @protected + List getMatchingKeys(NodeData nodeData) { + // If empty, just return null, as we can't match for it + if (_keysToFind.keys.isEmpty) return []; + + // Get all nodes keys together in order + final nodeKeys = nodeData.getKeysAsString(); + + // If not grouped, we check if any key we are searching for is present + if (_keysToFind.orderType == OrderType.loose) { + return nodeKeys.where(_keysToFind.keys.contains).toList(); + } + + /// + /// If grouped: + /// * We first check if all keys in the set we have, are contained in + /// the node keys + /// + /// If key-order is strict: + /// * We check if indices are in sequence + + // Check every element is there + var hasAll = nodeKeys.hasAll(_keysToFind.keys); + + // If key order is strict, check if all elements are present in order + if (_keysToFind.orderType == OrderType.strict && hasAll) { + // Create a map of all possible indices + final mapOfPossibleIndexes = nodeKeys.indexed.fold( + >{}, + (previousValue, element) { + // Just ignore if not present + if (!_keysToFind.keys.contains(element.$2)) return previousValue; + + previousValue.update( + element.$2, + (value) => [...value, element.$1], + ifAbsent: () => [element.$1], + ); + return previousValue; + }, + ); + + // Re-validate this check to mean has all and in sequence/order + hasAll = mapOfPossibleIndexes.values.satisfiesSequence(); + } + + return hasAll ? nodeKeys.toList() : []; + } + + /// Check if any values are a match + @protected + String getFirstMachingValue(NodeData nodeData) { + // No need to check if empty + if (_valuesToFind.isEmpty) return ''; + + return _valuesToFind.firstWhere( + (element) => element == nodeData.data, + orElse: () => '', + ); + } + + /// Check if any pairs are a match. Returns all pairs that are in this node. + /// Uses its path and terminal key & value + @protected + Map getMatchingPairs(NodeData nodeData) { + // No need to check if empty + if (_pairsToFind.isEmpty) return {}; + + // Format the data to matching data type + final nodeDataAsPairs = nodeData.transformToPairs(); + + /// + /// The quirk about indexing every node in the yaml/json file and returning + /// the terminal value, we can just check for pairs with 1-1 relationship. + /// + /// Additionally, the arguments are parsed elegantly guaranteeing we have + /// this down the line. + /// + return _pairsToFind.entries.fold( + {}, + (previousValue, foldedValue) { + // Check if a match + final canAdd = nodeDataAsPairs.entries.any( + (element) => + element.key == foldedValue.key && + element.value == foldedValue.value, + ); + + if (canAdd) previousValue.addEntries([foldedValue]); + + return previousValue; + }, + ); + } +} + +extension _CheckStrictOrderCandidate on Iterable> { + /// Check if a map satisfies a sequence based on available indices. + /// + /// A sequence differs just by one. The deeper we go, the smaller the list + /// to match becomes or not. + /// + /// A list can only be a sequence candidate if and only if any of its values + /// differ by based on preceding list. This rule excludes the first element. + bool satisfiesSequence() { + // Get the value at first index, since it determines the start + var previousListToMatch = first; + + // Get all remaining entries + final tailValues = skip(1).toList(); + + // Loop all and look for any that dont satisfy sequence + for (final tail in tailValues) { + final matchCandidate = tail.where( + (element) => previousListToMatch.any( + (matcher) => element - matcher == 1, + ), + ); + + // If none is available, always false + if (matchCandidate.isEmpty) return false; + + // If not, swap array for next element + previousListToMatch = matchCandidate.toList(); + } + + // Will always be true if the last element ended up being a candidate too + return true; + } +} diff --git a/lib/src/core/yaml_transformers/formatter/custom_tracker.dart b/lib/src/core/yaml_transformers/formatter/custom_tracker.dart new file mode 100644 index 0000000..1e58fd4 --- /dev/null +++ b/lib/src/core/yaml_transformers/formatter/custom_tracker.dart @@ -0,0 +1,76 @@ +part of 'formatter.dart'; + +/// A tracker used by [NodePathFormatter] and any of its subclasses to track +/// paths formatted by linking it to a value wrapped by a [TrackerKey]. +/// +/// History is linked to a file index. +final class FormatterTracker + extends SingleValueTracker> + with MapHistory> { + FormatterTracker({int? maxTolerance}) : maxTolerance = maxTolerance ?? 0; + + /// Current cursor for current file info being tracked + int currentCursor = 0; + + /// Indicates the current tolerance state of this tracker, in that, how + /// many times was the state fetched from history/new state. + int currentTolerance = 0; + + /// Indicates the max times to tolerate this cursor as the key before + /// swapping to a new cursor(file index). Think cache miss/hit. Max is [2] + /// but currently at [0]. + /// + /// Future versions may include some concurrency, thus values may come in + /// any order + final int maxTolerance; + + /// Saves values to this tracker + void add({ + required int fileIndex, + required List> keys, + required FormattedPathInfo pathInfo, + }) { + final useLocalCopy = currentCursor == fileIndex; + + // Increment tolerance incase not same index + if (!useLocalCopy) currentTolerance++; + + // Fallback to empty map incase not in history + final copy = useLocalCopy ? trackerState : getFromHistory(fileIndex) ?? {}; + + for (final key in keys) { + copy.update( + key, + (current) => [...current, pathInfo], + ifAbsent: () => [pathInfo], + ); + } + + if (!useLocalCopy) { + _attempSwap( + reachedMaxTolerance: currentTolerance > maxTolerance, + fileIndex: fileIndex, + state: copy, + ); + } + } + + /// Swaps the current cursor to point to an updated if [maxTolerance] is + /// reached. Otherwise, saves current cursor to history for accurate + /// tracking + void _attempSwap({ + required bool reachedMaxTolerance, + required int fileIndex, + required Map, List> state, + }) { + if (reachedMaxTolerance) { + reset(cursor: currentCursor); + currentCursor = fileIndex; + currentTolerance = 0; + trackerState.addAll(state); + dropCursor(fileIndex); + } else { + history.putIfAbsent(fileIndex, () => state); + } + } +} diff --git a/lib/src/core/yaml_transformers/formatter/formatter.dart b/lib/src/core/yaml_transformers/formatter/formatter.dart new file mode 100644 index 0000000..f83827d --- /dev/null +++ b/lib/src/core/yaml_transformers/formatter/formatter.dart @@ -0,0 +1,102 @@ +import 'package:magical_version_bump/src/core/yaml_transformers/trackers/counter/generic_counter.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/trackers/tracker.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/yaml_transformer.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; +import 'package:mason_logger/mason_logger.dart'; + +part 'custom_tracker.dart'; +part 'formatter_util.dart'; + +/// Formats info for each node based on matches found/replaced. +abstract base class NodePathFormatter { + NodePathFormatter({ + FormatterTracker? tracker, + }) : tracker = tracker ?? FormatterTracker(); + + /// Stores the info for this formatter for easy access + final FormatterTracker tracker; + + /// Each `TransformerManager` extracts values differently. See fine grained + /// implementations for each. + ({List> keys, FormattedPathInfo pathInfo}) extractFrom( + InputT input, + ); + + /// Adds inputs from a single file based on its index to this formatter's + /// tracker. + void add(int fileIndex, List inputs) { + for (final input in inputs) { + final info = extractFrom(input); + tracker.add( + fileIndex: fileIndex, + keys: info.keys, + pathInfo: info.pathInfo, + ); + } + } + + /// Adds inputs from multiple files based on their index to this formatter's + /// tracker. + void addAll(List<(int fileIndex, List inputs)> fileInputs) { + for (final (fileIndex, outputs) in fileInputs) { + add(fileIndex, outputs); + } + } + + /// Formats & aggregates all info linked to each file based on info stored + /// in this formatter's tracker. + String format({ + required bool isReplaceMode, + required List fileNames, + required Counter finderFileCounter, + Counter? replacerFileCounter, + }) { + if (isReplaceMode) { + assert( + replacerFileCounter != null, + 'Missing counter from replace manager!', + ); + } + + final aggregateBuffer = StringBuffer(); + + // Reset the last tracker to ease access from history + tracker.reset(cursor: tracker.currentCursor); + + // Use index to access each file info, order is always maintained + for (final (index, fileName) in fileNames.indexed) { + final infoToAggregate = tracker.getFromHistory(index); + + // Add top level header with info about + aggregateBuffer.write( + createHeader( + isReplaceMode: isReplaceMode, + fileName: fileName, + countOfMatches: finderFileCounter.getCount( + index, + origin: Origin.custom, + ), + countOfReplacements: replacerFileCounter?.getCount( + index, + origin: Origin.custom, + ), + ), + ); + + if (infoToAggregate == null) continue; + + // Loop all files and create their tree-like string + for (final entry in infoToAggregate.entries) { + final formattedInfo = formatInfo( + isReplaceMode: isReplaceMode, + key: entry.key.key, // weird? + formattedPaths: entry.value, + ); + + aggregateBuffer.write(formattedInfo); + } + } + + return aggregateBuffer.toString(); + } +} diff --git a/lib/src/core/yaml_transformers/formatter/formatter_util.dart b/lib/src/core/yaml_transformers/formatter/formatter_util.dart new file mode 100644 index 0000000..4bdfee8 --- /dev/null +++ b/lib/src/core/yaml_transformers/formatter/formatter_util.dart @@ -0,0 +1,250 @@ +// Huge thanks to [Nathan Friend](https://gitlab.com/nfriend). His project +// [tree-online](https://gitlab.com/nfriend/tree-online) was an +// inpiration for this. + +part of 'formatter.dart'; + +typedef FormattedPathInfo = ({String path, String? updatedPath}); + +enum CharSet { utf8, ascii } + +/// Magenta for heading for any file names +const headerColor = magenta; + +/// Light cyan for value matched with a yaml/json node path. +const anchorColor = lightCyan; + +/// Light green for a valid value matched/added in yaml/json node path to +/// terminal value +const matchColor = lightGreen; + +/// Light red for any key/value removed from yaml/json node path to +/// terminal value +const replacedColor = lightRed; + +/// Light yellow for any branch/related separator +const branchColor = lightYellow; + +/// EXtracts a key/list of keys from a value based on [Origin]. +/// +/// The [Origin] guarantees that a value will be unique and prevent duplication +/// as various keys/values/pairs may have been found/replaced from different +/// keys. +T extractKey({ + required Origin origin, + required dynamic value, + bool isReplacement = false, +}) { + // For value, return as is + if (origin == Origin.value && !isReplacement) { + return TrackerKey(key: value as String, origin: origin) as T; + } + + // For key, we get a list of values + if (origin == Origin.key || isReplacement) { + return (value as Iterable) + .map((e) => TrackerKey(key: e, origin: origin)) + .toList() as T; + } + + // For pair, we have to save a dual key + return (value as Map) + .entries + .map( + (e) => DualTrackerKey.fromEntry( + entry: e, + origin: origin, + ), + ) + .toList() as T; +} + +/// Extracts all keys from +List> getKeysFromMatch(MatchedNodeData matchedNodeData) { + final keysForMatch = >[]; + + if (matchedNodeData.matchedValue.isNotEmpty) { + keysForMatch.add( + extractKey( + origin: Origin.value, + value: matchedNodeData.matchedValue, + ), + ); + } + + if (matchedNodeData.matchedKeys.isNotEmpty) { + keysForMatch.addAll( + extractKey>>( + origin: Origin.key, + value: matchedNodeData.matchedKeys, + ), + ); + } + + if (matchedNodeData.matchedPairs.isNotEmpty) { + keysForMatch.addAll( + extractKey>>( + origin: Origin.pair, + value: matchedNodeData.matchedPairs, + ), + ); + } + + return keysForMatch; +} + +/// Wraps matches with a lightGreen [AnsiCode] for matches +FormattedPathInfo wrapMatches({ + required String path, + required List matches, +}) { + /// Wrap any match with green. + return ( + path: path + .split('/') + .map((e) => matches.contains(e) ? matchColor.wrap(e) : e) + .join('/'), + updatedPath: null, + ); +} + +/// Wraps updated values with lightGreen [AnsiCode] and values to be +/// replaced with a lightRed [AnsiCode] +FormattedPathInfo replaceAndWrap({ + required String path, + required bool replacedKeys, + required Map replacements, +}) { + final replaced = []; + + final tempPath = path.split('/'); + final lastIndex = tempPath.length - 1; + + /// + /// Number of elements is equal to last index i.e. + /// + /// if index of last element is 3, list has 4 elements total. So taking 3 + /// gets all elements excluding the last + final keys = tempPath.take(lastIndex); + final lastElement = tempPath[lastIndex]; + + if (replacedKeys) { + final oldKeyPath = keys.map((element) { + // Ignore if we don't need to replace + if (!replacements.containsKey(element)) { + replaced.add(element); + return element; + } + + // Wrap replacement with light green + final update = matchColor.wrap(replacements[element]); + replaced.add(update!); + + return replacedColor.wrap(element)!; + }); + return ( + path: [...oldKeyPath, lastElement].join('/'), + updatedPath: [...replaced, lastElement].join('/'), + ); + } + + // Create old path before swap + final oldPath = [...keys, replacedColor.wrap(lastElement)].join('/'); + + // Add all keys & wrapped key + replaced + ..addAll(keys) + ..add(matchColor.wrap(replacements[lastElement])!); + + return (path: oldPath, updatedPath: replaced.join('/')); +} + +/// Used to separate different children. This is mainly used to show clear +/// distinction when showing values replaced in various paths. +String getChildSeparator({ + CharSet charSet = CharSet.utf8, +}) { + final separator = charSet == CharSet.utf8 ? '│' : '|'; + + return branchColor.wrap(separator)!; +} + +/// Used to separate value found/replaced with its count +String getCountSeparator({CharSet charSet = CharSet.utf8}) { + return charSet == CharSet.utf8 ? '──' : '--'; +} + +/// A tree-like string denoting a branch in which a value was found or replaced. +/// +/// [charSet] - defaults to `utf8` if not provided. +/// +String getBranch({ + CharSet charSet = CharSet.utf8, + bool isLastChild = false, +}) { + // Branch based on level + final branch = switch (charSet) { + CharSet.utf8 when isLastChild => '└──', + CharSet.ascii when isLastChild => '`--', + CharSet.utf8 => '├──', + CharSet.ascii => '|--' + }; + + return branchColor.wrap(branch)!; +} + +/// Creates a file header based on Console format +String createHeader({ + required bool isReplaceMode, + required String fileName, + required int countOfMatches, + required int? countOfReplacements, +}) { + return headerColor.wrap( + '''\n** Aggregated Info for ${styleItalic.wrap(fileName)} : Found $countOfMatches matches${isReplaceMode ? ', Replaced $countOfReplacements' : ''} **\n''', + )!; +} + +/// A tree-like view of info for each match/value added. Replace mode stores value in a +/// [DualTrackerKey] with old path and new path +String formatInfo({ + required bool isReplaceMode, + required String key, + required List formattedPaths, +}) { + final countOfPaths = formattedPaths.length; + + // Key acts as the "anchor" + final formatBuffer = StringBuffer( + anchorColor.wrap( + '''$key ${getCountSeparator()} ${isReplaceMode ? 'Replaced ' : 'Found '} $countOfPaths\n''', + )!, + ); + + // Loop all links and create a tree-like structure + for (final (index, formattedPath) in formattedPaths.indexed) { + // Replace mode has 2 sub branches before the next. Create one for old path + if (isReplaceMode) { + final oldPathBranch = getBranch(); // Never last + formatBuffer.writeln('$oldPathBranch ${formattedPath.path}'); + } + + final isLastChild = index == (countOfPaths - 1); + + // Use updated path in replace mode. + final branchInfo = + isReplaceMode ? formattedPath.updatedPath! : formattedPath.path; + + final defaultBranch = getBranch(isLastChild: isLastChild); + + formatBuffer.writeln('$defaultBranch $branchInfo'); + + // Write pipe separator for replace mode + if (isReplaceMode && !isLastChild) { + formatBuffer.writeln(getChildSeparator()); + } + } + + formatBuffer.writeln(); // Add empty line + return formatBuffer.toString(); +} diff --git a/lib/src/core/yaml_transformers/indexers/yaml_indexer.dart b/lib/src/core/yaml_transformers/indexers/yaml_indexer.dart new file mode 100644 index 0000000..8a56ea7 --- /dev/null +++ b/lib/src/core/yaml_transformers/indexers/yaml_indexer.dart @@ -0,0 +1,164 @@ +part of '../yaml_transformer.dart'; + +/// This class holds methods for indexing a yaml file down to a terminal value +/// i.e the value marking the end of a key path +/// +/// Example for json: +/// ```json +/// { +/// "myRoot" : { +/// "nested" : "myValue" +/// } +/// } +/// ``` +/// +/// The valid Node data for this is : +/// * path (in order from root) = [ "myRoot" ] +/// * key for value = "nested" +/// * value for the key = "myValue" +/// +/// Same applies for yaml file that mimics the json file as shown below : +/// ```yaml +/// myRoot: +/// nested: myValue +/// ``` +/// +/// All values in a list are valid terminal end points. Nested keys will be +/// marked as nested. See [ NodeData ] +/// +class MagicalIndexer { + MagicalIndexer._(this.map); + + /// Instantiate with yaml map + MagicalIndexer.forYaml(YamlMap yamlMap) : this._(yamlMap); + + /// Instantiate with dart map + MagicalIndexer.forDartMap(Map map) : this._(map); + + /// Yaml map to search and index + Map map; + + /// Triggers this indexer to generate any terminal values found in a + /// yaml/json map + Iterable indexYaml() sync* { + for (final entry in map.entries) { + final setUpData = NodeData.fromRoot( + key: entry.key as String, + value: entry.value, + ); + + yield* _recursiveIndex(setUpData); + } + } + + /// Entry point for indexing a node. Can be called recursively. + Iterable _recursiveIndex(NodeData parent) sync* { + if (isTerminal(parent.value.rawValue)) { + yield parent; + } else if (parent.value.rawValue is Map) { + yield* _indexNestedMap( + parent: parent, + child: parent.value.rawValue as Map, + indices: [], + ); + } else { + yield* _indexNestedList( + parent: parent, + children: parent.value.rawValue as List, + indices: [], + ); + } + } + + /// Recursively indexes a map and yield any terminal values found. + /// + /// A recursion on the map always resets the list of indices for any further + /// recursive calls we may make as we are no longer in a list but a map. + /// + Iterable _indexNestedMap({ + required NodeData parent, + required Map child, + required List indices, + }) sync* { + // Loop all keys and values + for (final entry in child.entries) { + // Create new object + final nestedData = NodeData.fromMapEntry( + parent: parent, + current: entry, + indices: indices, + ); + + /// If terminal, we return it as is + if (isTerminal(nestedData.value.rawValue)) { + yield nestedData; + } + + /// If not, the data is either a list or map + /// + /// For a list, we index the list value by value. + /// + else if (entry.value is List) { + yield* _indexNestedList( + parent: nestedData, + children: entry.value as List, + indices: [], + ); + } + + // We just use this recursive function + else { + yield* _indexNestedMap( + parent: nestedData, + child: nestedData.value.rawValue as Map, + indices: [], + ); + } + } + } + + /// Recursively indexes a nested list and yields any terminal values found. + /// + /// A list will always generate new indices for a key, value or map, + /// forcing all [ NodeData ] key/value to be marked as nested with indices + /// in order from root list. + /// + Iterable _indexNestedList({ + required NodeData parent, + required List children, + required List indices, + }) sync* { + // Loop nested children with all indexed + for (final (index, child) in children.indexed) { + // Update indices we have so far + final updatedIndices = [...indices, index]; + + // If we reached the end, we yield a terminal value + if (isTerminal(child)) { + yield NodeData.atRootTerminal( + parent: parent, + terminalValue: child.toString(), + indices: updatedIndices, + ); + continue; + } + + // For another list, we recurse with function + else if (child is List) { + yield* _indexNestedList( + parent: parent, + children: child, + indices: updatedIndices, + ); + continue; + } + + // If map, we call recursive map indexer + yield* _indexNestedMap( + parent: parent, + child: child as Map, + indices: updatedIndices, + ); + } + } +} diff --git a/lib/src/core/yaml_transformers/managers/finder_manager/finder_formatter.dart b/lib/src/core/yaml_transformers/managers/finder_manager/finder_formatter.dart new file mode 100644 index 0000000..50b7cc2 --- /dev/null +++ b/lib/src/core/yaml_transformers/managers/finder_manager/finder_formatter.dart @@ -0,0 +1,25 @@ +import 'package:collection/collection.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/formatter/formatter.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/trackers/tracker.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/yaml_transformer.dart'; + +final class FinderFormatter extends NodePathFormatter { + FinderFormatter({super.tracker}); + + @override + ({List> keys, FormattedPathInfo pathInfo}) extractFrom( + MatchedNodeData input, + ) { + // Remove duplicates before highlighting + final matches = { + ...input.matchedKeys, + input.matchedValue, + ...input.matchedPairs.entries.map((e) => [e.key, e.value]).flattened, + }.toList(); + + return ( + keys: getKeysFromMatch(input), + pathInfo: wrapMatches(path: input.toString(), matches: matches), + ); + } +} diff --git a/lib/src/core/yaml_transformers/managers/finder_manager/finder_manager.dart b/lib/src/core/yaml_transformers/managers/finder_manager/finder_manager.dart new file mode 100644 index 0000000..4b4a305 --- /dev/null +++ b/lib/src/core/yaml_transformers/managers/finder_manager/finder_manager.dart @@ -0,0 +1,242 @@ +import 'package:collection/collection.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/managers/finder_manager/finder_formatter.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/yaml_transformer.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; +import 'package:magical_version_bump/src/utils/typedefs/typedefs.dart'; +import 'package:mason_logger/mason_logger.dart'; + +enum FinderType { byValue, bySearch, both } + +typedef FindManagerOutput = ({ + int currentFile, + MatchedNodeData data, +}); + +class FinderManager extends TransformerManager { + FinderManager._({ + required super.fileQueue, + required super.aggregator, + required super.logger, + required FinderType finderType, + required KeysToFind? keysToFind, + required ValuesToFind? valuesToFind, + required PairsToFind? pairsToFind, + }) : super(formatter: FinderFormatter()) { + /// Save history when, [!applyToEachFile && applyToEachArg] is false + final saveCounterHistory = + !(!aggregator.applyToEachFile && aggregator.applyToEachArg); + + _finder = _setUpFinder( + fileQueue.first, + saveCounterToHistory: saveCounterHistory, + finderType: finderType, + keysToFind: keysToFind, + valuesToFind: valuesToFind, + pairsToFind: pairsToFind, + ); + } + + FinderManager.fullSetup({ + required List> fileQueue, + required Aggregator aggregator, + required Logger? logger, + required FinderType finderType, + required KeysToFind? keysToFind, + required ValuesToFind? valuesToFind, + required PairsToFind? pairsToFind, + }) : this._( + fileQueue: fileQueue, + aggregator: aggregator, + logger: logger, + finderType: finderType, + keysToFind: keysToFind, + valuesToFind: valuesToFind, + pairsToFind: pairsToFind, + ); + + FinderManager.findValues({ + required List> fileQueue, + required Aggregator aggregator, + required ValuesToFind valuesToFind, + }) : this.fullSetup( + fileQueue: fileQueue, + aggregator: aggregator, + logger: null, + keysToFind: null, + valuesToFind: valuesToFind, + pairsToFind: null, + finderType: FinderType.byValue, + ); + + FinderManager.findKeys({ + required List> fileQueue, + required Aggregator aggregator, + required KeysToFind keysToFind, + }) : this.fullSetup( + fileQueue: fileQueue, + aggregator: aggregator, + logger: null, + finderType: FinderType.byValue, + keysToFind: keysToFind, + valuesToFind: null, + pairsToFind: null, + ); + + /// Indicates a finder used by this manager to generate matches. + /// + /// This manager just queues file based on some conditions. + late Finder _finder; + + /// + Iterable _internalGenerator() { + return _finder.find( + aggregateType: aggregator.type, + applyToEach: aggregator.applyToEachArg, + count: aggregator.count, + ); + } + + @override + Future transform() async { + final finderProgress = showProgress(ManagerProgress.findingMatches); + + // Accumulate all values + final matches = generate().toList().fold( + >{}, + (previousValue, element) { + previousValue.update( + element.currentFile, + (value) => [...value, element.data], + ifAbsent: () => [element.data], + ); + return previousValue; + }, + ); + + if (matches.isEmpty) { + finderProgress.fail('No matches found'); + return; + } + + formatter.addAll( + matches.entries.map((element) => (element.key, element.value)).toList(), + ); // Add matches to formatter + + finderProgress.complete('Found matches in ${matches.length} files(s)'); + } + + Iterable generate() sync* { + /// Finding values is the hardest part. + /// + /// Rules for each condition of the [Aggregator] based on: + /// * [applyToEachFile] - applies conditions to each file + /// * [applyToEachArg] - applies to each argument to find. This is + /// handled seamlessly by the [Finder] itself + /// + /// This manager handles [applyToEachFile]. + /// + /// When [applyToEachFile] is [false] and : + /// + /// * [applyToEachArg] is [false] - Always peek the count of values + /// obtained so far for each file at the end of the loop iteration and + /// terminate when count is reached for surety. [Finder] ensures we + /// never exceed count for each argument but may return less which will + /// require us to look in the next file. + /// + /// * [applyToEachArg] is [true] - Arguments get a wildcard. + /// Only condition that never uses [CounterWithHistory] functionality + /// of [Finder]. Each argument gets an equal chance to get to specified + /// count when not [AggregateType.all]. Even if we have to index + /// every file & check! + /// + /// When [applyToEachFile] is [true] and : + /// + /// * [applyToEachArg] is [false] - for each file we take a + /// specified count when not [AggregateType.all]. [Finder] handles the + /// trivial `!applyToEach` which guarantees exact or less based on + /// file passed. + /// + /// * [applyToEachArg] is [true] - Files get a wildcard. Each + /// file gets equal chance to reach the count of each argument when + /// not [AggregateType.all]. Even if we have to index the whole file! + final numOfFiles = fileQueue.length; + final localQueue = QueueList.from(fileQueue); // Local editable queue + + /// Keep track of file index to use as a cursor. We use it to reset the + /// last counter state to history. For easy access by [ConsolePrinter] + var fileIndex = 0; + + /// Label for our loop queueing file for [Finder] + fileLooper: + do { + fileIndex = numOfFiles - localQueue.length; // File index + final yamlMap = localQueue.removeFirst(); // Current file + + // Add yaml if we are not starting. Finder always has the first file + if (fileIndex != 0) { + // Swap and use previous file. + _finder.swapMap(yamlMap, cursor: fileIndex - 1); + } + + // Loop all matches + for (final output in _internalGenerator()) { + // Yield value first before checking conditions. + yield (currentFile: fileIndex, data: output.data); + + super.incrementFileIndex(fileIndex); + + /// If we are finding all, just increment file count and continue. + /// + /// [Finder] handles everything if each file gets equal chance when + /// [applyToEachFile] is [true] + /// + if (aggregator.type == AggregateType.all || + aggregator.applyToEachFile) { + continue; + } + + /// + /// When [applyToEachFile] is [false], we break loop only when: + /// + /// * [applyToEach] is [true] and output limit was reached since + /// the counter state is never reset. [MatchCounter] unknowingly + /// returns true while still matching a new file. + /// + /// * [applyToEach] is [false] just uses the file counter for this + /// [FindManager] which tracks how many values where found in each + /// file + /// + if ((aggregator.applyToEachArg && output.reachedLimit) || + (!aggregator.applyToEachArg && + managerCounter.getSumOfCount() == aggregator.count)) { + break fileLooper; + } + } + } while (localQueue.isNotEmpty); + + /// Reset counter with current file index to history. + /// + /// This index denotes the file whose counter is active. No need to + /// subtract "1" to go back to previous file. + _finder.counter!.reset(cursor: fileIndex); + } +} + +Finder _setUpFinder( + Map yamlMap, { + required FinderType finderType, + required bool saveCounterToHistory, + KeysToFind? keysToFind, + ValuesToFind? valuesToFind, + PairsToFind? pairsToFind, +}) { + return switch (finderType) { + _ => ValueFinder.findInMap( + yamlMap, + saveCounterToHistory: saveCounterToHistory, + keysToFind: keysToFind, + valuesToFind: valuesToFind, + pairsToFind: pairsToFind, + ), + }; +} diff --git a/lib/src/core/yaml_transformers/managers/manager.dart b/lib/src/core/yaml_transformers/managers/manager.dart new file mode 100644 index 0000000..4a780d6 --- /dev/null +++ b/lib/src/core/yaml_transformers/managers/manager.dart @@ -0,0 +1,53 @@ +import 'package:magical_version_bump/src/core/yaml_transformers/formatter/formatter.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/trackers/counter/generic_counter.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; +import 'package:magical_version_bump/src/utils/typedefs/typedefs.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:meta/meta.dart'; + +export 'finder_manager/finder_manager.dart'; +export 'replacer_manager/replacer_manager.dart'; + +enum ManagerProgress { findingMatches, replacingValues } + +abstract class TransformerManager { + TransformerManager({ + required this.fileQueue, + required this.aggregator, + required this.formatter, + required this.logger, + }); + + /// A queue of all yaml/ordinary maps to run a transform operation on + final List> fileQueue; + + /// A custom Aggregator for this transformer + final Aggregator aggregator; + + /// Tracker for keeping track of transformations made for each file + final managerCounter = Counter(); + + /// Path formatter for tree like format + final NodePathFormatter formatter; + + final Logger? logger; + + /// Adds a specified file index to a [Counter] in this manager + @protected + void incrementFileIndex(int fileIndex) { + return managerCounter.increment([fileIndex], origin: Origin.custom); + } + + @protected + Progress showProgress(ManagerProgress managerProgress, {String? info}) { + return switch (managerProgress) { + ManagerProgress.findingMatches => logger!.progress( + info ?? 'Finding matches', + ), + ManagerProgress.replacingValues => logger!.progress('Replacing matches') + }; + } + + /// Initializes transformer manager + Future transform(); +} diff --git a/lib/src/core/yaml_transformers/managers/replacer_manager/replacer_formatter.dart b/lib/src/core/yaml_transformers/managers/replacer_manager/replacer_formatter.dart new file mode 100644 index 0000000..397ac65 --- /dev/null +++ b/lib/src/core/yaml_transformers/managers/replacer_manager/replacer_formatter.dart @@ -0,0 +1,26 @@ +import 'package:magical_version_bump/src/core/yaml_transformers/formatter/formatter.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/managers/replacer_manager/replacer_manager.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/trackers/tracker.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; + +final class ReplacerFormatter extends NodePathFormatter { + ReplacerFormatter({super.tracker}); + + @override + ({List> keys, FormattedPathInfo pathInfo}) extractFrom( + ReplaceManagerOutput input, + ) { + return ( + keys: extractKey>>( + origin: input.origin, + value: input.mapping.keys, + isReplacement: true, + ), + pathInfo: replaceAndWrap( + path: input.oldPath, + replacedKeys: input.origin == Origin.key, + replacements: input.mapping, + ), + ); + } +} diff --git a/lib/src/core/yaml_transformers/managers/replacer_manager/replacer_manager.dart b/lib/src/core/yaml_transformers/managers/replacer_manager/replacer_manager.dart new file mode 100644 index 0000000..3415cba --- /dev/null +++ b/lib/src/core/yaml_transformers/managers/replacer_manager/replacer_manager.dart @@ -0,0 +1,167 @@ +import 'package:magical_version_bump/src/core/yaml_transformers/managers/replacer_manager/replacer_formatter.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/trackers/counter/generic_counter.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/trackers/tracker.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/yaml_transformer.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; +import 'package:magical_version_bump/src/utils/typedefs/typedefs.dart'; +import 'package:mason_logger/mason_logger.dart'; + +part 'replacer_tracker.dart'; + +typedef ReplaceManagerOutput = ({ + Origin origin, + Map mapping, + String oldPath, +}); + +typedef ModifiedFiles = ({int fileIndex, Map modifiedFile}); + +class ReplacerManager extends TransformerManager { + ReplacerManager._({ + required super.fileQueue, + required super.aggregator, + required super.logger, + required this.commandType, + required Map> substituteToMatchers, + }) : assert(!commandType.isFinder, 'Find command not allowed'), + super(formatter: ReplacerFormatter()) { + // Replacer based on replace mode + _replacer = switch (commandType) { + WalkSubCommandType.rename => KeySwapper(substituteToMatchers), + _ => ValueReplacer(substituteToMatchers), + }; + + _finderManager = commandType == WalkSubCommandType.rename + ? FinderManager.findKeys( + fileQueue: fileQueue, + aggregator: aggregator, + keysToFind: _replacer.getTargets(), + ) + : FinderManager.findValues( + fileQueue: fileQueue, + aggregator: aggregator, + valuesToFind: _replacer.getTargets(), + ); + } + + ReplacerManager.defaultSetup({ + required WalkSubCommandType commandType, + required List> fileQueue, + required Aggregator aggregator, + required Logger logger, + required Map> substituteToMatchers, + }) : this._( + fileQueue: fileQueue, + aggregator: aggregator, + logger: logger, + commandType: commandType, + substituteToMatchers: substituteToMatchers, + ); + + /// Indicates the command that using this manager. Accepts only + /// [WalkSubCommandType.rename] or [WalkSubCommandType.replace] for now. + final WalkSubCommandType commandType; + + /// Generates [MatchedNodeData] objects using a [Finder] for replacement + late FinderManager _finderManager; + + /// Represents a replacer used by this manager to rename keys or replace + /// values. + late Replacer _replacer; + + Iterable? modifiedFiles; + + /// Obtains the counter used by the [FinderManager] used to manage the + /// finding of matches in different files + Counter get finderManagerCounter => _finderManager.managerCounter; + + @override + Future transform() async { + final finderProgress = showProgress( + ManagerProgress.findingMatches, + info: 'Finding matches to replace', + ); + + // Modifiable queue we can read and swap modifiable values back and forth + final localQueue = [...fileQueue]; + + /// Accumulate all matches from [FinderManager] + final matches = _finderManager.generate().toList(); + + if (matches.isEmpty) { + finderProgress.fail('No matches found'); + return; + } + + finderProgress.complete(); + + final replacerProgress = showProgress(ManagerProgress.replacingValues); + + var targets = []; + + if (commandType == WalkSubCommandType.rename) { + final tracker = ReplacerTracker()..addAll(matches); + + targets.addAll(tracker.getMatches()); + } else { + targets = matches + .fold(>{}, (previousValue, element) { + previousValue.update( + element.currentFile, + (value) => [...value, element.data], + ifAbsent: () => [element.data], + ); + return previousValue; + }) + .entries + .map((e) => (fileNumber: e.key, matches: e.value)) + .toList(); + } + + final accumulator = >{}; + + for (final target in targets) { + var file = localQueue[target.fileNumber]; // File to edit + + for (final match in target.matches) { + final modifiedFile = _replacer.replace(file, matchedNodeData: match); + + file = modifiedFile.updatedMap; + + final output = ( + origin: commandType == WalkSubCommandType.rename + ? Origin.key + : Origin.value, + mapping: modifiedFile.mapping, + oldPath: match.toString(), + ); + + accumulator.update( + target.fileNumber, + (current) => [...current, output], + ifAbsent: () => [output], + ); + + // Track current count of replacements for each file + super.incrementFileIndex(target.fileNumber); + } + + localQueue[target.fileNumber] = file; // Swap with updated + } + + formatter.addAll( + accumulator.entries + .map((element) => (element.key, element.value)) + .toList(), + ); + + // Add modified files + modifiedFiles = accumulator.keys.map( + (element) => (fileIndex: element, modifiedFile: localQueue[element]), + ); + + replacerProgress.complete( + 'Replaced matches in ${accumulator.length} file(s)', + ); + } +} diff --git a/lib/src/core/yaml_transformers/managers/replacer_manager/replacer_tracker.dart b/lib/src/core/yaml_transformers/managers/replacer_manager/replacer_tracker.dart new file mode 100644 index 0000000..27847fc --- /dev/null +++ b/lib/src/core/yaml_transformers/managers/replacer_manager/replacer_tracker.dart @@ -0,0 +1,65 @@ +part of 'replacer_manager.dart'; + +typedef TrackerOutput = ({int fileNumber, List matches}); + +final class ReplacerTracker extends SingleValueTracker + with MapHistory { + ReplacerTracker() : _isRename = true; + + /// Current walksubcommand type. Determines which path to store + final bool _isRename; + + /// Keeps track of current file number so far + int _internalCursor = 0; + + /// Adds all matches and removes any duplicates. Helps prevent any recursions + /// on same path + void addAll(List outputs) { + for (final output in outputs) { + _addMatch(output); + } + } + + /// Adds a single match. + void _addMatch(FindManagerOutput output) { + if (_internalCursor != output.currentFile) { + reset(cursor: _internalCursor); // Save current history + _internalCursor = output.currentFile; // Change cursor to current file + } + + final matchedNode = output.data; + + /// A node is usually indexed to the terminal value. While this is great + /// for replacing values, replacing keys may be cumbersome since: + /// 1. Values in same list with have the same set of keys + /// 2. Values in same map will have same set of keys upto the last + /// one linking a terminal value + /// + /// Recursing to replace the same key a 1000 times? Inefficient! + /// + /// This only applies to [WalkSubcommand.rename]. Remove duplicates and + /// recurse only keys yet to be replaced + final path = + _isRename ? matchedNode.getPathToLastKey() : matchedNode.toString(); + + trackerState.putIfAbsent( + createKey(path, origin: Origin.custom), + () => matchedNode, + ); + } + + /// Obtains all matches without any duplicate paths. + /// + /// NOTE: a full path may be nested in another path but the impact is lower. + List getMatches() { + // Reset last tracker + reset(cursor: _internalCursor); + + return history.entries.fold([], (previousValue, element) { + previousValue.add( + (fileNumber: element.key, matches: element.value.values.toList()), + ); + return previousValue; + }); + } +} diff --git a/lib/src/core/yaml_transformers/replacers/key_swapper.dart b/lib/src/core/yaml_transformers/replacers/key_swapper.dart new file mode 100644 index 0000000..3ff5755 --- /dev/null +++ b/lib/src/core/yaml_transformers/replacers/key_swapper.dart @@ -0,0 +1,44 @@ +part of 'replacer.dart'; + +/// Exclusively renames keys +class KeySwapper extends Replacer { + KeySwapper(super.substituteToMatchers); + + @override + T getTargets() { + return super.generateTargets(areKeys: true) as T; + } + + @override + ReplacementOutput replace( + Map map, { + required MatchedNodeData matchedNodeData, + }) { + final modifiable = {...map}; + + // Get replacement pair + final replacementPair = super.getReplacement>( + matchedNodeData, + checkForKey: true, + ); + + // Get path to last renameable key inclusive of last key to be renamed + final pathToLastKey = [...matchedNodeData.getUptoLastRenameable()]; + + // Remove last which will act as our pseudo target + final target = pathToLastKey.removeLast(); + + final updatedMap = modifiable.updateIndexedMap( + null, + target: target, + path: pathToLastKey, + keyAndReplacement: replacementPair, + value: null, + ); + + return ( + mapping: replacementPair, + updatedMap: YamlMap.wrap(updatedMap), + ); + } +} diff --git a/lib/src/core/yaml_transformers/replacers/replacer.dart b/lib/src/core/yaml_transformers/replacers/replacer.dart new file mode 100644 index 0000000..3fd5413 --- /dev/null +++ b/lib/src/core/yaml_transformers/replacers/replacer.dart @@ -0,0 +1,101 @@ +import 'package:magical_version_bump/src/core/yaml_transformers/yaml_transformer.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; +import 'package:magical_version_bump/src/utils/extensions/map_extensions.dart'; +import 'package:magical_version_bump/src/utils/typedefs/typedefs.dart'; +import 'package:meta/meta.dart'; +import 'package:yaml/yaml.dart'; + +part 'key_swapper.dart'; +part 'value_replacer.dart'; + +typedef ReplacementOutput = ({ + YamlMap updatedMap, + Map mapping, +}); + +/// Abstract class for renaming keys/replacing values +/// +/// [ValueReplacer] & [KeySwapper] extend this. +abstract class Replacer { + Replacer(this.substituteToMatchers); + + /// Targets to find and their replacements as keys + final Map> substituteToMatchers; + + /// Join all targets that need to be replaced for a `Finder` to find + @protected + T generateTargets({ + required bool areKeys, + }) { + // Get values + final values = substituteToMatchers.values; + + // Get all keys with no duplicates + final targets = {for (final value in values) ...value}.toList(); + if (areKeys) { + return (keys: targets, orderType: OrderType.loose) as T; + } + return targets as T; + } + + /// Gets the matching replacements for a matched node. + /// + /// Key takeaway, if a key has another replacement candidate. By default, + /// the first replacement will count. However, the last occuring will be used + /// if specified by user. + /// + /// For keys, we will return a `Map`, pairs, of key and its + /// replacement. + /// For values we just return the replacement. + @protected + T getReplacement( + MatchedNodeData matchedNodeData, { + required bool checkForKey, + bool useFirst = true, + }) { + // For keys, we need to loop all matched keys + if (checkForKey) { + final replacementPairs = {}; + + for (final matchedKey in matchedNodeData.matchedKeys) { + final candidateReplacement = _getReplacementCandidate( + matchedKey, + useFirst: useFirst, + ); + + // Add key to be replaced as key, while its replacement as its value + replacementPairs.addAll({matchedKey: candidateReplacement}); + } + return replacementPairs as T; + } + + // Just return the value instead as a string + return _getReplacementCandidate( + matchedNodeData.data, + useFirst: useFirst, + ) as T; + } + + /// Gets first replament based on requirement. + String _getReplacementCandidate( + String matcher, { + required bool useFirst, + }) { + // Get all matches + final candidates = substituteToMatchers.entries.where( + (element) => element.value.contains(matcher), + ); + + // If using first add first value, if not use last replacement candidate + return useFirst ? candidates.first.key : candidates.last.key; + } + + /// Gets the corresponding targets for all subclasses of this type + T getTargets(); + + /// Replaces a matched node in a yaml map and returns an updated yaml map + ReplacementOutput replace( + Map map, { + required MatchedNodeData matchedNodeData, + }); +} diff --git a/lib/src/core/yaml_transformers/replacers/value_replacer.dart b/lib/src/core/yaml_transformers/replacers/value_replacer.dart new file mode 100644 index 0000000..80998d7 --- /dev/null +++ b/lib/src/core/yaml_transformers/replacers/value_replacer.dart @@ -0,0 +1,38 @@ +part of 'replacer.dart'; + +/// Exclusively replaces values +class ValueReplacer extends Replacer { + ValueReplacer(super.substituteToMatchers); + + @override + T getTargets() { + return super.generateTargets(areKeys: false) as T; + } + + @override + ReplacementOutput replace( + Map map, { + required MatchedNodeData matchedNodeData, + }) { + final modifiable = {...map}; // Make modifiable + + // Get replacement + final replacement = super.getReplacement( + matchedNodeData, + checkForKey: false, + ); + + final updatedMap = modifiable.updateIndexedMap( + replacement, + target: matchedNodeData.key, + path: matchedNodeData.precedingKeys, + keyAndReplacement: {}, + value: matchedNodeData.value, + ); + + return ( + mapping: {matchedNodeData.data: replacement}, + updatedMap: YamlMap.wrap(updatedMap), + ); + } +} diff --git a/lib/src/core/yaml_transformers/trackers/counter/counter_with_history.dart b/lib/src/core/yaml_transformers/trackers/counter/counter_with_history.dart new file mode 100644 index 0000000..c03c2f6 --- /dev/null +++ b/lib/src/core/yaml_transformers/trackers/counter/counter_with_history.dart @@ -0,0 +1,22 @@ +part of 'generic_counter.dart'; + +/// A [Counter] class that tracks a specific values and also maintains the +/// history of previously tracked value based on a cursor. +/// +/// [C] - denotes the cursor type. Think of it as a key linking any tracker +/// state stored. Can be any data type as long as you make it unique. +/// +/// [K] - denotes the value being tracked. Acts as a key to the map used +/// internally. It is wrapped with a [TrackerKey] for equality & hashing ease. +/// +/// [L] - denotes an optional type incase a [MapEntry] is passed. In which +/// case, the value of [K] & [L] are both wrapped in a [DualTrackerKey]. +/// +/// See [TrackerKey], [DualTrackerKey] +base class CounterWithHistory extends Counter + with MapHistory { + int? getCountFromHistory(C cursor, dynamic value, Origin origin) { + final key = createKey(value, origin: origin); + return getFromHistory(cursor)?[key]; + } +} diff --git a/lib/src/core/yaml_transformers/trackers/counter/generic_counter.dart b/lib/src/core/yaml_transformers/trackers/counter/generic_counter.dart new file mode 100644 index 0000000..5818027 --- /dev/null +++ b/lib/src/core/yaml_transformers/trackers/counter/generic_counter.dart @@ -0,0 +1,67 @@ +import 'package:magical_version_bump/src/core/yaml_transformers/trackers/tracker.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; + +part 'counter_with_history.dart'; + +/// A generic counter class that keeps count of values. +/// +/// [K] - denotes the value being tracked. Acts as a key to the map used +/// internally. It is wrapped with a [TrackerKey] for equality & hashing ease. +/// +/// [L] - denotes an optional type incase a [MapEntry] is passed. In which +/// case, the value of [K] & [L] are both wrapped in a [DualTrackerKey]. +/// +/// See [TrackerKey]. +base class Counter extends DualTracker { + /// Adds a key if missing and increments the count if present. + void _addKey(TrackerKey key, {bool isStartingTracker = false}) { + trackerState.update( + key, + (value) => ++value, + ifAbsent: () => isStartingTracker ? 0 : 1, + ); + } + + /// Prefills counter with values we need to accurately keep count of. Use + /// when you know each value being counted before hand. + void prefill(List? keys, {required Origin origin}) { + if (keys == null) return; + for (final value in keys) { + final key = createKey(value, origin: origin); + _addKey(key, isStartingTracker: true); + } + } + + /// Increments count with dynamic value + void increment(Iterable values, {required Origin origin}) { + for (final candidate in values) { + final key = createKey(candidate, origin: origin); + _addKey(key); + } + } + + /// Obtains count based on a value being being tracked. Returns zero if + /// value is not being tracked or its count is zero. + int getCount(dynamic value, {required Origin origin}) { + final key = createKey(value, origin: origin); + return getCountFromKey(key); + } + + /// Obtains count based on a [TrackerKey] wrapping the value. Returns zero if + /// [TrackerKey] is not being tracked or its count is zero. + /// + /// Internally used by [Counter.getCount] which wraps a value with a + /// [TrackerKey] before obtaining the count use this method. + int getCountFromKey(TrackerKey key) { + return trackerState[key] ?? 0; // Return 0 if missing + } + + /// Obtains the sum of all counts of values being counted + int getSumOfCount() { + if (trackerState.isEmpty) return 0; + return trackerState.values.reduce((value, element) => value + element); + } + + @override + String toString() => trackerState.toString(); +} diff --git a/lib/src/core/yaml_transformers/trackers/tracker.dart b/lib/src/core/yaml_transformers/trackers/tracker.dart new file mode 100644 index 0000000..925c07e --- /dev/null +++ b/lib/src/core/yaml_transformers/trackers/tracker.dart @@ -0,0 +1,103 @@ +import 'package:equatable/equatable.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; +import 'package:magical_version_bump/src/utils/exceptions/magical_exception.dart'; +import 'package:meta/meta.dart'; + +part 'tracker_key.dart'; + +/// A tracker that tracks a value of type [KeyT] and its info of type [ValueT]. +/// +/// [KeyT] - denotes the value being tracked. Acts as a key to the map used +/// internally. It is wrapped with a [TrackerKey] for equality & hashing ease. +/// +/// [ValueT] - denotes the tracking info. Stored as is. +base class SingleValueTracker { + /// Current state of the tracker + final Map, ValueT> trackerState = {}; + + /// Creates a tracker key tracking a value + @protected + TrackerKey createKey(KeyT value, {required Origin origin}) { + return TrackerKey.fromValue(value, origin); + } + + /// Checks if the map contains the specified [key] + bool containsTrackerKey(KeyT key, {required Origin origin}) { + return trackerState.containsKey( + createKey(key, origin: origin), + ); + } +} + +/// An extension of [SingleValueTracker] which includes an additional value +/// i.e from a [MapEntry] where [KeyT] is its key and [OtherKeyT] its value. +/// +/// [KeyT] - denotes the value being tracked. Acts as a key to the map used +/// internally. It is wrapped with a [TrackerKey] for equality & hashing ease. +/// +/// [OtherKeyT] - denotes an optional type incase a [MapEntry] is passed. In +/// which case, the value of [KeyT] & [OtherKeyT] are both wrapped in a +/// [DualTrackerKey]. +/// +/// [ValueT] - denotes the tracking info. Stored as is. +/// +base class DualTracker + extends SingleValueTracker { + @override + @protected + TrackerKey createKey(dynamic value, {required Origin origin}) { + if (value is MapEntry) { + return DualTrackerKey.fromEntry( + entry: value, + origin: origin, + ); + } + + return super.createKey(value as KeyT, origin: origin); + } +} + +/// A mixin to add history functionality to any [SingleValueTracker] class/ +/// its subclasses. +/// +/// [CursorT] - denotes the cursor type. Think of it as a key linking any +/// tracker state stored. Can be any data type as long as you make it unique +/// +/// [KeyT] - denotes the value being tracked. Acts as a key to the map used +/// internally. It is wrapped with a [TrackerKey] for equality & hashing ease. +/// +/// [OtherKeyT] - denotes an optional type incase a [MapEntry] is passed. In +/// which case, the value of [KeyT] & [OtherKeyT] are both wrapped in a +/// [DualTrackerKey]. +/// +/// [ValueT] - denotes the tracking info. Stored as is. +base mixin MapHistory + on SingleValueTracker { + /// Stores the tracker history for a [SingleValueTracker] or + /// [DualTracker] + final Map, ValueT>> history = {}; + + /// Obtains the tracker state linked to this [cursor] from history. + Map, ValueT>? getFromHistory(CursorT cursor) => + history[cursor]; + + /// Stores a Map linked to a cursor and returns current map/tracker state + /// before clearing it. + /// + /// Throws an exception if cursor already exists. + Map, ValueT> reset({required CursorT cursor}) { + if (history.containsKey(cursor)) { + throw MagicalException(message: 'This cursor is already tracked!'); + } + + final stateToSave = Map, ValueT>.from(trackerState); + history[cursor] = stateToSave; + trackerState.clear(); + return stateToSave; + } + + /// Removes cursor from history together with any data present + void dropCursor(CursorT cursor) { + history.remove(cursor); + } +} diff --git a/lib/src/core/yaml_transformers/trackers/tracker_key.dart b/lib/src/core/yaml_transformers/trackers/tracker_key.dart new file mode 100644 index 0000000..acec842 --- /dev/null +++ b/lib/src/core/yaml_transformers/trackers/tracker_key.dart @@ -0,0 +1,55 @@ +part of 'tracker.dart'; + +/// Represents a custom key to use as a tracker. It holds a single value. +@immutable +base class TrackerKey extends Equatable { + const TrackerKey({required this.key, required this.origin}); + + const TrackerKey.fromValue( + dynamic value, + Origin origin, + ) : this(key: value as KeyT, origin: origin); + + final KeyT key; + + final Origin origin; + + @override + List get props => [key as Object, origin]; + + @override + String toString() => key.toString(); +} + +/// Represents a customs key. Hold 2 values. Mostly used for pairs +@immutable +final class DualTrackerKey extends TrackerKey { + const DualTrackerKey({ + required super.key, + required this.otherKey, + Origin? origin, + }) : super(origin: origin ?? Origin.pair); + + const DualTrackerKey.fromValue({ + required dynamic key, + required dynamic otherKey, + Origin? origin, + }) : this(key: key as KeyT, otherKey: otherKey as OtherKeyT, origin: origin); + + DualTrackerKey.fromEntry({ + required MapEntry entry, + Origin? origin, + }) : this( + key: entry.key as KeyT, + otherKey: entry.value as OtherKeyT, + origin: origin, + ); + + final OtherKeyT otherKey; + + @override + List get props => [...super.props, otherKey as Object]; + + @override + String toString() => '${super.toString()}:$otherKey'; +} diff --git a/lib/src/core/yaml_transformers/yaml_transformer.dart b/lib/src/core/yaml_transformers/yaml_transformer.dart new file mode 100644 index 0000000..948c37f --- /dev/null +++ b/lib/src/core/yaml_transformers/yaml_transformer.dart @@ -0,0 +1,14 @@ +import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/data/pair_definition/custom_pair_type.dart'; +import 'package:magical_version_bump/src/utils/extensions/extensions.dart'; +import 'package:meta/meta.dart'; +import 'package:yaml/yaml.dart'; + +export './finders/finder.dart'; +export './managers/manager.dart'; +export './replacers/replacer.dart'; + +part 'data/matched_node_data.dart'; +part 'data/node_data.dart'; +part 'indexers/yaml_indexer.dart'; diff --git a/lib/src/utils/data/version_modifiers.dart b/lib/src/utils/data/version_modifiers.dart index bd4eea4..c177202 100644 --- a/lib/src/utils/data/version_modifiers.dart +++ b/lib/src/utils/data/version_modifiers.dart @@ -1,4 +1,6 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first import 'package:args/args.dart'; + import 'package:magical_version_bump/src/utils/enums/enums.dart'; import 'package:magical_version_bump/src/utils/extensions/extensions.dart'; @@ -8,101 +10,61 @@ import 'package:magical_version_bump/src/utils/extensions/extensions.dart'; /// * `set-build` /// * `keep-pre` /// * `keep-build` -abstract class VersionModifiers { - VersionModifiers({ - required this.presetType, - required this.version, - required this.prerelease, - required this.build, - required this.keepPre, - required this.keepBuild, - }); +class VersionModifiers { + /// Default constructor + VersionModifiers.fromArgResults( + ArgResults argResults, { + bool initializePreset = true, + }) { + version = argResults.setVersion; + if (initializePreset) presetType = _initializePreset(argResults); + prerelease = argResults.setPrerelease; + build = argResults.setBuild; + keepPre = argResults.keepPre; + keepBuild = argResults.keepBuild; + } + + /// Constructor that adds strategy for `bump` subcommand + factory VersionModifiers.fromBumpArgResults(ArgResults argResults) { + return VersionModifiers.fromArgResults(argResults, initializePreset: false) + ..presetType = argResults.checkPreset(ignoreFlag: false) + ..strategy = argResults.strategy(); + } /// Preset type - final PresetType presetType; + late final PresetType presetType; /// Version - final String? version; + late final String? version; /// Prerelease - final String? prerelease; + late final String? prerelease; /// Build info - final String? build; + late final String? build; /// Whether to keep old prerelease info - final bool keepPre; + late final bool keepPre; /// Whether to keep old build info - final bool keepBuild; -} - -/// Default class just returns the abstract class -class DefaultVersionModifiers extends VersionModifiers { - DefaultVersionModifiers({ - required super.presetType, - required super.version, - required super.prerelease, - required super.build, - required super.keepPre, - required super.keepBuild, - }); + late final bool keepBuild; - /// Basic factory - factory DefaultVersionModifiers.fromArgResults(ArgResults argResults) { - return DefaultVersionModifiers( - presetType: _sortPreset(argResults), - version: argResults.setVersion, - prerelease: argResults.setPrerelease, - build: argResults.setBuild, - keepPre: argResults.keepPre, - keepBuild: argResults.keepBuild, - ); - } - - static PresetType _sortPreset(ArgResults argResults) { - final currentPreset = argResults.checkPreset(ignoreFlag: true); - - // If preset is none or version, confirm if any of build or prerelease was - // set - if (currentPreset == PresetType.none || - currentPreset == PresetType.version) { - return argResults.setBuild != null || argResults.setPrerelease != null - ? PresetType.all - : currentPreset; - } - - return currentPreset; - } + /// Modify Strategy + late final ModifyStrategy strategy; } -/// Bump command version modifiers. Checks for: -/// * `preset` everything or just the version -/// * `strategy` -class BumpVersionModifiers extends VersionModifiers { - BumpVersionModifiers({ - required super.presetType, - required super.version, - required super.prerelease, - required super.build, - required super.keepPre, - required super.keepBuild, - required this.strategy, - }); +/// Initialized preset for any `Modify` subcommand other than `Bump` +/// subcommand +PresetType _initializePreset(ArgResults argResults) { + final currentPreset = argResults.checkPreset(ignoreFlag: true); - /// Basic factory - factory BumpVersionModifiers.fromArgResults(ArgResults argResults) { - return BumpVersionModifiers( - presetType: argResults.checkPreset(), - version: argResults.setVersion, - prerelease: argResults.setPrerelease, - build: argResults.setBuild, - keepPre: argResults.keepPre, - keepBuild: argResults.keepBuild, - strategy: argResults.strategy, - ); + // If preset is none or version, confirm if any of build or prerelease was + // set + if (currentPreset == PresetType.none || currentPreset == PresetType.version) { + return argResults.setBuild != null || argResults.setPrerelease != null + ? PresetType.all + : currentPreset; } - /// Modify Strategy - final ModifyStrategy strategy; + return currentPreset; } diff --git a/lib/src/utils/enums/enums.dart b/lib/src/utils/enums/enums.dart index c103040..5ba44aa 100644 --- a/lib/src/utils/enums/enums.dart +++ b/lib/src/utils/enums/enums.dart @@ -19,3 +19,66 @@ enum PresetType { /// Preset version, prerelease & build number all; } + +/// Amount of values that match condition in list of string +enum MatchCount { + /// No match + none, + + /// At least 1 or more + some, + + /// All values match + all +} + +/// Type of update to map +enum UpdateMode { + /// Adds value to terminal end + append, + + /// Replaces a single value + replace, + + /// Removes and replaces old value with a new one + overwrite +} + +/// Type of subcommand that may have been triggered +enum WalkSubCommandType { + find(isFinder: true), + search(isFinder: true), + rename(isFinder: false), + replace(isFinder: false); + + const WalkSubCommandType({required this.isFinder}); + + final bool isFinder; +} + +/// Type of ordering based on a list of targets +enum OrderType { + /// At least one of any + loose, + + /// All present + grouped, + + /// All present and in exact order specified + strict +} + +/// Aggregation type based on count +enum AggregateType { + /// Only first matching value + first, + + /// Count of values + count, + + /// Find all + all; +} + +/// Indicates a custom origin of a value +enum Origin { key, value, pair, custom } diff --git a/lib/src/utils/exceptions/command_exceptions.dart b/lib/src/utils/exceptions/command_exceptions.dart deleted file mode 100644 index 4de5bae..0000000 --- a/lib/src/utils/exceptions/command_exceptions.dart +++ /dev/null @@ -1 +0,0 @@ -export 'magical_exceptions.dart'; diff --git a/lib/src/utils/exceptions/magical_exceptions.dart b/lib/src/utils/exceptions/magical_exception.dart similarity index 52% rename from lib/src/utils/exceptions/magical_exceptions.dart rename to lib/src/utils/exceptions/magical_exception.dart index 73e216e..a7a51b2 100644 --- a/lib/src/utils/exceptions/magical_exceptions.dart +++ b/lib/src/utils/exceptions/magical_exception.dart @@ -1,11 +1,11 @@ /// This is a custom exception class. Nothing fancy, added to catch command /// exceptions class MagicalException implements Exception { - MagicalException({required this.violation}); + MagicalException({required this.message}); - /// Command violation or error - final String violation; + /// Command message or error + final String message; @override - String toString() => violation; + String toString() => message; } diff --git a/lib/src/utils/extensions/arg_results_extension.dart b/lib/src/utils/extensions/arg_results_extension.dart index d277215..6de474c 100644 --- a/lib/src/utils/extensions/arg_results_extension.dart +++ b/lib/src/utils/extensions/arg_results_extension.dart @@ -2,10 +2,30 @@ part of 'extensions.dart'; /// Extension group with general info extension SharedArgResults on ArgResults { + /// Get nullable value + String? getNullableValue(String argument) => this[argument] as String?; + + /// Get a non-nullable value + String getValue(String argument) => getNullableValue(argument)!; + + /// Get a boolean value + bool getBooleanValue(String argument) => this[argument] as bool; + + /// Get a list of values in multi-option + ParsedValues parsedValues(String argument) => this[argument] as List; + + /// Get list of list of values in order from multioption + ListOfParsedValues parsedValueList(String argument) => parsedValues(argument) + .map((e) => e.splitAndTrim(',').retainNonEmpty()) + .toList(); + PathInfo get pathInfo { + // Read paths before hand + final paths = this['directory']; + return ( requestPath: this['request-path'] as bool, - path: this['directory'] as String, + paths: paths is List ? paths : [paths as String], ); } } @@ -13,40 +33,87 @@ extension SharedArgResults on ArgResults { /// Extension group with version modifier results extension VersionModifierResults on ArgResults { /// Check set version - String? get setVersion => this['set-version'] as String?; + String? get setVersion => getNullableValue('set-version'); /// Check set prerelease - String? get setPrerelease => this['set-prerelease'] as String?; + String? get setPrerelease => getNullableValue('set-prerelease'); /// Check set build - String? get setBuild => this['set-build'] as String?; + String? get setBuild => getNullableValue('set-build'); /// Check whether to retain prerelease - bool get keepPre => this['keep-pre'] as bool; + bool get keepPre => getBooleanValue('keep-pre'); /// Check whether to retain build - bool get keepBuild => this['keep-build'] as bool; + bool get keepBuild => getBooleanValue('keep-build'); /// Check targets - List get targets => this['targets'] as List; + List get targets => parsedValues('targets'); /// Check strategy - ModifyStrategy get strategy => this['strategy'] == 'absolute' - ? ModifyStrategy.absolute - : ModifyStrategy.relative; + ModifyStrategy strategy() => + ModifyStrategy.values.byName(getValue('strategy')); /// Check preset - PresetType checkPreset({bool ignoreFlag = false}) { + PresetType checkPreset({required bool ignoreFlag}) { // Check preset flag. Set to false if we want to ignore - final preset = ignoreFlag ? !ignoreFlag : this['preset'] as bool; + final presetAll = ignoreFlag ? !ignoreFlag : getBooleanValue('preset'); // Preset only version if preset is false & version is not null - final presetOnlyVersion = !preset && this['set-version'] != null; + final presetVersion = !presetAll && getNullableValue('set-version') != null; - if (presetOnlyVersion) return PresetType.version; + if (presetVersion) return PresetType.version; - if (preset) return PresetType.all; + if (presetAll) return PresetType.all; return PresetType.none; } } + +/// Extension for obtaining walker results +extension WalkerResults on ArgResults { + /// Get aggregator type + AggregateType get _aggregatorType => + AggregateType.values.byName(getValue('aggregate')); + + /// Get limit of aggregation + int? get _limit => int.tryParse(getValue('limit-to')); + + /// Get keys to find + ParsedValues get mapKeys => parsedValues('keys'); + + /// Get keys to be renamed + ListOfParsedValues get targetKeys => parsedValueList('keys'); + + /// Get values to find + ParsedValues get mapValues => parsedValues('values'); + + /// Get values to be replaced + ListOfParsedValues get targetValues => parsedValueList('values'); + + /// Get pairs + ListOfParsedValues get mapPairs => parsedValueList('pairs'); + + /// Get key order + OrderType get keyOrder => OrderType.values.byName(getValue('key-order')); + + /// Get replacements for key/values + ParsedValues get replacementCandidates => parsedValues('subtitute'); + + /// Get the aggregator to use to "walk" + Aggregator getAggregator() { + // If Aggregator is count and limit is null, throw + if (_aggregatorType == AggregateType.count && _limit == null) { + throw MagicalException( + message: 'A valid count is required for "limit-to"', + ); + } + + return ( + type: _aggregatorType, + applyToEachArg: true, + applyToEachFile: true, + count: _aggregatorType == AggregateType.first ? 1 : _limit, + ); + } +} diff --git a/lib/src/utils/extensions/extensions.dart b/lib/src/utils/extensions/extensions.dart index 4637ae9..947b05c 100644 --- a/lib/src/utils/extensions/extensions.dart +++ b/lib/src/utils/extensions/extensions.dart @@ -1,9 +1,12 @@ import 'package:args/args.dart'; import 'package:magical_version_bump/src/utils/enums/enums.dart'; +import 'package:magical_version_bump/src/utils/exceptions/magical_exception.dart'; import 'package:magical_version_bump/src/utils/typedefs/typedefs.dart'; +import 'package:pub_semver/pub_semver.dart'; -export 'iterable_extension.dart'; -export 'string_extensions.dart'; -export 'version_extension.dart'; +export 'map_extensions.dart'; part 'arg_results_extension.dart'; +part 'iterable_extension.dart'; +part 'string_extensions.dart'; +part 'version_extension.dart'; diff --git a/lib/src/utils/extensions/helpers/map_extension/predetermined_updates.dart b/lib/src/utils/extensions/helpers/map_extension/predetermined_updates.dart new file mode 100644 index 0000000..bf8c5f9 --- /dev/null +++ b/lib/src/utils/extensions/helpers/map_extension/predetermined_updates.dart @@ -0,0 +1,70 @@ +part of '../../map_extensions.dart'; + +/// +/// Updates a "known" list. This denotes a list indexed by `MagicalIndexer` +/// to its root value +/// +List _updateIndexedList({ + required bool isTerminal, + required bool isKey, + required List list, + required List indices, + required dynamic update, + Key? target, + List? path, + KeyAndReplacement? keyAndReplacement, + Value? value, +}) { + // Modifiable list + final modifiableList = [...list]; + final modifiableIndices = [...indices]; + + // We exit once only one index is remaining + if (modifiableIndices.length == 1) { + // If at the end and just the value + if (isTerminal && !isKey) { + modifiableList[indices.first] = update; + } else { + // Get map to recurse + final recursible = modifiableList[indices.first] as Map; + + final updatedMap = isTerminal + ? {...recursible}._updateIndexedTerminal( + update, + target: target!, + keyAndReplacement: keyAndReplacement!, + value: value, + ) + : {...recursible}.updateIndexedMap( + update, + target: target!, + path: path!, + keyAndReplacement: keyAndReplacement!, + value: value, + ); + + // Recurse it + modifiableList[indices.first] = updatedMap; + } + + return modifiableList; + } + + // Remove the first index + final currentIndex = modifiableIndices.removeAt(0); + + // Update index recursively + modifiableList[currentIndex] = _updateIndexedList( + isTerminal: isTerminal, + isKey: isKey, + list: modifiableList[currentIndex] as List, + indices: modifiableIndices, + update: update, + target: target, + path: path, + keyAndReplacement: keyAndReplacement, + value: value, + ); + + return modifiableList; +} diff --git a/lib/src/utils/extensions/helpers/map_extension/recursive_data_mod_helper.dart b/lib/src/utils/extensions/helpers/map_extension/recursive_data_mod_helper.dart new file mode 100644 index 0000000..ab6ef15 --- /dev/null +++ b/lib/src/utils/extensions/helpers/map_extension/recursive_data_mod_helper.dart @@ -0,0 +1,115 @@ +part of '../../map_extensions.dart'; + +({String key, Map map}) _attemptSwap({ + required String currentKey, + required String? replacement, + required Map currentMap, +}) { + // Return as is, if replacement is null + if (replacement == null) { + return (key: currentKey, map: currentMap); + } + + return ( + key: replacement, + map: _swapKey( + map: currentMap, + target: currentKey, + replacement: replacement, + ), + ); +} + +/// Swap a key with another in a map in `UpdateMode.replace`. +Map _swapKey({ + required Map map, + required String target, + required String replacement, +}) { + /// If map already contains it. Maybe it was replaced earilier + /// by another key + if (!map.containsKey(target) && map.containsKey(replacement)) { + return map; + } + + // Read existing value + final valueAtKey = map[target]; + + final swapped = {}; + + // Replace/rename it at existing index. Avoid putting it at the end + for (final entry in map.entries) { + entry.key == target + ? swapped.addAll({replacement: valueAtKey}) + : swapped.addEntries([entry]); + } + return swapped; +} + +/// Updates a terminal value of node after exhausting all keys in path. +/// +/// In `UpdateMode.append`, the value is "appended" to the existing value. +/// +/// In `UpdateMode.overwrite`, the entire value held at the terminal key is +/// replaced. +/// +/// `NOTE:` +/// Functionality of `UpdateMode.replace` & `UpdateMode.overwrite` may look +/// similar by definition but `UpdateMode.replace` singles out a value +/// if in a list whereas `UpdateMode.overwrite` treats the entire list as a +/// single value. +/// +dynamic _updateTerminalValue({ + required UpdateMode updateMode, + required dynamic update, + required dynamic currentValue, +}) { + dynamic updatedTerminal; + + final targetValIsNull = currentValue == null; + + /// + /// If we are overwriting or value we are appending is null, just set + /// a value. A guarantee of [UpdateMode.overwrite] & [UpdateMode.append] + /// is that missing values & keys are created + /// + if (updateMode == UpdateMode.append && targetValIsNull || + updateMode == UpdateMode.overwrite) { + return update is String && updateMode == UpdateMode.overwrite + ? update + : update is String && updateMode == UpdateMode.append + ? [update] + : update is List + ? [...update] + : update as Map; + } + + // Convert to list by default since only a single value exists + if (currentValue is String) { + updatedTerminal = update is List + ? [currentValue, ...update] + : [currentValue, update]; + } + + // For List, just spread in old values + else if (currentValue is List) { + updatedTerminal = update is List + ? [...currentValue, ...update] + : [...currentValue, update]; + } + + // For maps, anything that is not a map forces it to be list + else { + if (update is Map) { + updatedTerminal = {} + ..addAll(currentValue as Map) + ..addAll(update as Map); + } else { + updatedTerminal = update is String + ? [currentValue, update] + : [currentValue, ...update as List]; + } + } + + return updatedTerminal; +} diff --git a/lib/src/utils/extensions/helpers/map_extension/recursive_helper.dart b/lib/src/utils/extensions/helpers/map_extension/recursive_helper.dart new file mode 100644 index 0000000..186fcec --- /dev/null +++ b/lib/src/utils/extensions/helpers/map_extension/recursive_helper.dart @@ -0,0 +1,165 @@ +part of '../../map_extensions.dart'; + +/// Recursive Functions for lists nested in maps. This is private utility +/// class for this extension +/// + +/// Check if a value is terminal. A terminal value can ONLY be null or a +/// string. +bool isTerminal(dynamic data) => + data == null || + data is String || + data is int || + data is double || + data is bool; + +/// +/// Recurses a list and searches for a `target` and returns its value. This +/// is a READ-ONLY version of [ _recurseNestedList ]. +/// +/// * `nestedList` - denotes list nested in map. +/// * `target` - denotes terminal key with value. +/// * `path` - denotes list of keys preceding `target`. +/// +/// NOTE: +/// 1. If target key is a value rather than a key, null is returned. +/// 2. If a target key has no value, null is returned +/// 3. If a target key is not found, null will (and SHOULD) be returned +/// by the caller of this method. +T? _readNestedList( + List nestedList, { + required dynamic target, + required List path, +}) { + final keyWanted = path.isEmpty ? target : path.first; + + // Loop it + for (final value in nestedList) { + if (value is String) { + if (value != keyWanted) continue; + + // If value is string, means there is no value + return null; + } + + // For maps check if key exists + else if (value is Map) { + if (!value.containsKey(keyWanted)) continue; + + return value.recursiveRead(path: path, target: target); + } + + // For lists, return this function + return _readNestedList(value as List, target: target, path: path); + } + + return null; +} + +/// +/// Recurses a list and updates any value(s) nested within the list. +/// +/// If the value is within a map i.e. the path to target key has not been +/// exhausted, we recursively call the initial caller that is, +/// `recursivelyUpdate` in `MapUtility` extension. +/// +/// For `String`, value is updated at current index while looping. +/// +/// A `List` calls this method (recursively). +/// +RecursiveListOutput _recurseNestedList( + List listToRecurse, { + required dynamic update, + required String target, + required List currentPath, + required UpdateMode updateMode, +}) { + // We first make list modifiable + final modifiableList = [...listToRecurse]; + + /// Tracks if the loop managed to modify the desired value. + var didFindAndModify = false; + + /// + /// For lists, we want to look for the next key so that we recurse on it + /// as a map. + /// + /// * If the key is a string, we convert to map to recurse on it only if + /// append is false. We cannot append to an existing value. We, + /// however, can overwrite it. + /// + /// * If we find a map, check if it contains the key. If it does, we + /// recurse on it. + /// + /// * If we find a list, call this function recursively + + // Get the next key + final wantedKey = currentPath.isEmpty ? target : currentPath.first; + + // Loop it value by value + for (final (index, valueInList) in modifiableList.indexed) { + // For strings, only matching values + if (valueInList is String && valueInList == wantedKey) { + // We cannot append, only overwrite + if (updateMode == UpdateMode.append) { + throw MagicalException( + message: + '''Cannot append new values at "$valueInList". You need to overwrite this value as it is nested in a list.''', + ); + } + + // We convert to map + final mapForString = {}.recursivelyUpdate( + update, + target: target, + path: currentPath, + updateMode: updateMode, + ); + + // Update value with map + modifiableList[index] = mapForString; + + // Mark as modified + didFindAndModify = true; + } + + // For maps. It must contain key + else if (valueInList is Map && valueInList.containsKey(wantedKey)) { + // If it does, we know it will be updated if we recurse on the map + modifiableList[index] = {...valueInList}.recursivelyUpdate( + update, + target: target, + path: currentPath, + updateMode: updateMode, + ); + + // Mark as modified + didFindAndModify = true; + } + + // Call itself till we find key needed + else if (valueInList is List) { + final recursedList = _recurseNestedList( + valueInList, + update: update, + target: target, + currentPath: currentPath, + updateMode: updateMode, + ); + + if (!recursedList.didModify) continue; + + modifiableList[index] = recursedList.modified; + + didFindAndModify = true; + } + + // Break this loop if was modified + if (didFindAndModify) break; + } + + return ( + didModify: didFindAndModify, + modified: didFindAndModify ? modifiableList : [], + ); +} diff --git a/lib/src/utils/extensions/iterable_extension.dart b/lib/src/utils/extensions/iterable_extension.dart index 2a2afbc..57baed0 100644 --- a/lib/src/utils/extensions/iterable_extension.dart +++ b/lib/src/utils/extensions/iterable_extension.dart @@ -1,45 +1,89 @@ -extension Operations on Iterable { +part of 'extensions.dart'; + +extension IterableOperations on Iterable { /// Get target that will be used to update whole version relative to its /// position. Affinity/preference in descending order is: /// /// major > minor > patch /// List getRelative() { - final targets = []; + // Sort aphabetically to order them naturally/ascending order + final targets = [ + ...where((element) => element != 'build-number'), + ]..sort(); + + return [ + if (targets.isNotEmpty) targets.first, + if (contains('build-number')) 'build-number', + ]; + } + + /// Retain non-empty values + List retainNonEmpty() { + return where((element) => element.isNotEmpty).toList(); + } + + /// Check how many values match a condition of search string + MatchCount checkMatchCount(String pattern) { + if (every((element) => element.contains(pattern))) { + return MatchCount.all; + } - // Assign weights - final weighted = fold({}, (previousValue, element) { - final score = element == 'major' - ? 20 - : element == 'minor' - ? 10 - : element == 'patch' - ? 5 - : 0; + if (any((element) => element.contains(pattern))) { + return MatchCount.some; + } + + return MatchCount.none; + } - previousValue.addEntries([MapEntry(element, score)]); + /// Output list of values for dictionary based on match count + dynamic splitBasedOnMatch() { + const pattern = '->'; - return previousValue; - }); + final matchCount = checkMatchCount(pattern); - final maxWeight = weighted.entries.reduce( - (value, element) => value.value > element.value ? value : element, - ); + // If all are maps, return an enclosed map of values + if (matchCount == MatchCount.all) { + return fold( + {}, + (previousValue, element) { + final map = element.splitAndTrim(pattern); + previousValue.update( + map.first, + (value) => map.last.isEmpty ? 'null' : map.last, + ifAbsent: () => map.last.isEmpty ? 'null' : map.last, + ); + return previousValue; + }, + ); + } - targets.add(maxWeight.key); + // If mixed with some strings, split individually + if (matchCount == MatchCount.some) { + return fold( + [], + (previousValue, element) { + // Extract map and add it + if (element.contains(pattern)) { + final map = element.splitAndTrim(pattern); + previousValue.add( + {map.first: map.last.isEmpty ? 'null' : map.last}, + ); + } else { + previousValue.add(element); + } - if (contains('build-number')) { - targets.add('build-number'); + return previousValue; + }, + ); } - return targets; + // Just return "as-is" if none + return this; } - /// Retain non-empty values - List retainNonEmpty() { - final retainedList = [...this]..retainWhere( - (element) => element.isNotEmpty, - ); - return retainedList; + /// Has all values found in another list + bool hasAll(Iterable other) { + return every(other.contains) && (length == other.length); } } diff --git a/lib/src/utils/extensions/map_extensions.dart b/lib/src/utils/extensions/map_extensions.dart index 0083fdc..f5458a4 100644 --- a/lib/src/utils/extensions/map_extensions.dart +++ b/lib/src/utils/extensions/map_extensions.dart @@ -1,12 +1,21 @@ +import 'package:magical_version_bump/src/core/yaml_transformers/data/pair_definition/custom_pair_type.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; +import 'package:magical_version_bump/src/utils/exceptions/magical_exception.dart'; +import 'package:magical_version_bump/src/utils/typedefs/typedefs.dart'; + +part 'helpers/map_extension/recursive_helper.dart'; +part 'helpers/map_extension/recursive_data_mod_helper.dart'; +part 'helpers/map_extension/predetermined_updates.dart'; + /// Extension to help read nested values extension MapUtility on Map { /// Read nested values recursively - dynamic recursiveRead({ + T? recursiveRead({ required List path, required dynamic target, }) { if (path.isEmpty) { - return this[target]; + return this[target] as T?; } final currentKey = path.first; @@ -14,13 +23,301 @@ extension MapUtility on Map { final currentValue = this[currentKey]; - if (currentValue is! Map) return null; + if (currentValue is String) return null; final modifiedPath = [...path]..removeAt(0); - return currentValue.recursiveRead( + if (currentValue is List) { + return _readNestedList( + currentValue, + target: target, + path: modifiedPath, + ); + } + + return (currentValue as Map).recursiveRead( path: modifiedPath, target: target, + ) as T?; + } + + /// Recursively update a [target] key specified based on the [path] of + /// preceding keys. + /// + /// `UpdateMode` specifies mode to use while recursively updating + /// this map. Only `UpdateMode.append` && `UpdateMode.overwrite` + /// + /// `update` is dynamic and allows any data type support by `Dart`. With + /// this package, the value will either be a `String`, `List` or + /// `Map` + /// + Map recursivelyUpdate( + dynamic update, { + required String target, + required List path, + required UpdateMode updateMode, + }) { + // Throw error if replace + if (updateMode == UpdateMode.replace) { + throw MagicalException( + message: + '''This update mode is not supported. Use the "updateIndexedMap()" method instead''', + ); + } + + // Base condition for our recursive function on reaching end. + if (path.isEmpty) { + // Read current value at the end + final targetKeyValue = this[target]; + + final updatedTerminal = _updateTerminalValue( + updateMode: updateMode, + update: update, + currentValue: targetKeyValue, + ); + + // Value to set + final terminalValueToSet = updatedTerminal is RecursiveListOutput + ? updatedTerminal.modified + : updatedTerminal; + + this.update( + target, + (value) => terminalValueToSet, + ifAbsent: () => terminalValueToSet, + ); + return this; + } + + /// If path is not empty, just read the next key in sequence and do + /// another update recursively! + final currentKey = path.first; + + // Read current value + final valueAtKey = this[currentKey]; + + /// + /// All [ UpdateMode ]s require the next section. + /// + + /// + /// Since we have not exhausted all keys in this path, the value must + /// either be a: + /// * `Map`. For `UpdateMode.replace`, this is a + /// guarantee as we previously indexed this map. + /// * `null` value or `UpdateMode.overwrite`. + /// + /// For `null`, we will create missing key as this was a guarantee for + /// updating the yaml file. + /// + /// Thus, a value can never be a string if not at terminal end and + /// in `UpdateMode.append`. A key must exist! + if (valueAtKey != null && + valueAtKey is String && + updateMode == UpdateMode.append) { + throw MagicalException( + message: + '''Cannot append new values due to an existing value at "$currentKey". You need to overwrite this path key.''', + ); + } + + // Update path, as this key will be updated + final updatedPath = [...path]..removeAt(0); + + /// If value is `null`, we recreate the missing keys + if (valueAtKey == null) { + this[currentKey] = {}.recursivelyUpdate( + update, + target: target, + path: updatedPath, + updateMode: updateMode, + ); + + return this; + } + + /// + /// As earlier stated, we guarantee that a missing key will always be + /// recreated. Thus: + /// + /// * If a String is encountered, we force it into a list and iterate it. + /// and create the key if missing + /// * If value is a list, we iterate and look for our key + if (valueAtKey is List || valueAtKey is String) { + final modifiableValueAtKey = valueAtKey is String + ? [valueAtKey] + : [...valueAtKey as List]; + + // Recursive read all values of list + final recursedOutput = _recurseNestedList( + modifiableValueAtKey, + update: update, + target: target, + currentPath: updatedPath, + updateMode: updateMode, + ); + + /// + /// If after recursing it wasn't modified, we add the wanted key as a + /// null map, which guarantees further recursions will update it. + /// + /// Thus, guarantee-ing our guarantee of creating any missing keys we + /// find missing while recursing + /// + if (!recursedOutput.didModify) { + final wantedKeyUpdate = {}.recursivelyUpdate( + update, + target: target, + path: updatedPath, + updateMode: updateMode, + ); + + modifiableValueAtKey.add(wantedKeyUpdate); + } else { + modifiableValueAtKey + ..clear() + ..addAll(recursedOutput.modified); + } + + this[currentKey] = modifiableValueAtKey; + + return this; + } + + // Current value as is from map + final castedValueAtKey = { + ...valueAtKey as Map, + }; + + this[currentKey] = castedValueAtKey.recursivelyUpdate( + update, + target: target, + path: updatedPath, + updateMode: updateMode, + ); + + return this; + } + + /// Recurse a known map and update values or swap keys + Map updateIndexedMap( + dynamic update, { + required Key target, + required List path, + required KeyAndReplacement keyAndReplacement, + required Value? value, + }) { + /// + if (path.isEmpty) { + return _updateIndexedTerminal( + update, + target: target, + keyAndReplacement: keyAndReplacement, + value: value, + ); + } + + /// Current key. + /// + /// This will always be called on a map, not a list thus we won't + /// need to recurse list + final keyFromPath = path.first.toString(); + + // Attempt a swap. If replacement is null, no swap. + final attemptedSwap = _attemptSwap( + currentKey: keyFromPath, + replacement: keyAndReplacement[keyFromPath], + currentMap: this, + ); + + final currentKey = attemptedSwap.key; // Key to used by default + final recursableMap = attemptedSwap.map; // Map + + // Read value at key + final valueAtKey = recursableMap[currentKey]; + + // Update path + final updatedPath = [...path]..removeAt(0); + + /// If list, we get the next Key with indices + if (valueAtKey is List) { + final nextKey = updatedPath.firstOrNull ?? target; + + recursableMap[currentKey] = _updateIndexedList( + isTerminal: updatedPath.isEmpty, + isKey: true, + list: valueAtKey, + indices: nextKey.indices, + update: update, + target: target, + path: updatedPath, + keyAndReplacement: keyAndReplacement, + value: value, + ); + + return recursableMap; + } + + // Current value as is from map + final castedValueAtKey = { + ...valueAtKey as Map, + }; + + recursableMap[currentKey] = castedValueAtKey.updateIndexedMap( + update, + target: target, + path: updatedPath, + keyAndReplacement: keyAndReplacement, + value: value, + ); + + return recursableMap; + } + + Map _updateIndexedTerminal( + dynamic update, { + required Key target, + required KeyAndReplacement keyAndReplacement, + required Value? value, + }) { + final candidate = target.toString(); // Key that may change + + // Attempt a swap to have latest version of this map + final attemptedSwap = _attemptSwap( + currentKey: candidate, + replacement: keyAndReplacement[candidate], + currentMap: this, + ); + + // No need to update value if none was used as replacement + if (value == null) return attemptedSwap.map; + + // Get key and map + final keyAtRoot = attemptedSwap.key; + final terminalMap = attemptedSwap.map; + + final valueAtRoot = terminalMap[keyAtRoot]; + + dynamic valueToSet; // Value to set + + // If list update it as it was indexed before + if (valueAtRoot is List) { + valueToSet = _updateIndexedList( + isTerminal: true, + isKey: false, + list: valueAtRoot, + indices: value.indices, + update: update, + ); + } else { + valueToSet = update; + } + + terminalMap.update( + keyAtRoot, + (value) => valueToSet, + ifAbsent: () => valueToSet, ); + return terminalMap; } } diff --git a/lib/src/utils/extensions/string_extensions.dart b/lib/src/utils/extensions/string_extensions.dart index 5718580..2e1d425 100644 --- a/lib/src/utils/extensions/string_extensions.dart +++ b/lib/src/utils/extensions/string_extensions.dart @@ -1,9 +1,9 @@ -import 'package:magical_version_bump/src/utils/enums/enums.dart'; +part of 'extensions.dart'; extension StringExtension on String { /// Get file type FileType get fileType => switch (this) { - 'yaml' => FileType.yaml, + 'yaml' || 'yml' => FileType.yaml, 'json' => FileType.json, _ => FileType.unknown }; diff --git a/lib/src/utils/extensions/version_extension.dart b/lib/src/utils/extensions/version_extension.dart index 7818a95..5a84187 100644 --- a/lib/src/utils/extensions/version_extension.dart +++ b/lib/src/utils/extensions/version_extension.dart @@ -1,4 +1,4 @@ -import 'package:pub_semver/pub_semver.dart'; +part of 'extensions.dart'; extension VersionExtension on Version { /// Set prerelease and build-number diff --git a/lib/src/utils/mixins/command_mixins.dart b/lib/src/utils/mixins/command_mixins.dart index 8271ea2..1001574 100644 --- a/lib/src/utils/mixins/command_mixins.dart +++ b/lib/src/utils/mixins/command_mixins.dart @@ -1,3 +1,2 @@ -export 'handle_file_mixin.dart'; export 'modify_yaml_mixin.dart'; export 'validate_version_mixin.dart'; diff --git a/lib/src/utils/mixins/handle_file_mixin.dart b/lib/src/utils/mixins/handle_file_mixin.dart deleted file mode 100644 index 9aaf051..0000000 --- a/lib/src/utils/mixins/handle_file_mixin.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:magical_version_bump/src/utils/enums/enums.dart'; -import 'package:magical_version_bump/src/utils/extensions/extensions.dart'; -import 'package:mason_logger/mason_logger.dart'; -import 'package:yaml/yaml.dart'; - -/// This mixin reads and updates the yaml file -mixin HandleFile { - /// Read yaml file from path. If: - /// * `requestPath` is true. The user will be prompted for the path-to-file - /// * `requestPath` is false. Uses default `setPath` - /// - Future< - ({ - String file, - FileType fileType, - String path, - String? version, - })> readFile({ - required bool requestPath, - required Logger logger, - required String setPath, - }) async { - if (requestPath) { - // Request path to file - setPath = logger.prompt( - 'Please enter the path to file:', - defaultValue: 'pubspec.yaml', - ); - } - - final readProgress = logger.progress('Reading file'); - final file = await File(setPath).readAsString(); - - // Convert file to map - final fileAsMap = _convertToMap(file); - - readProgress.complete('Read file'); - - return ( - path: setPath, - fileType: setPath.split('.').last.toLowerCase().fileType, - file: file, - version: fileAsMap['version'] as String?, - ); - } - - /// Save file changes - Future saveFile({ - required String file, - required String path, - required Logger logger, - required FileType type, - }) async { - final saveProgress = logger.progress('Saving changes'); - - if (type == FileType.json) { - file = _convertToPrettyJson(file); - } - - await File(path).writeAsString(file); - - saveProgress.complete('Saved changes'); - } - - /// Convert read file to YAML map - YamlMap _convertToMap(String file) => loadYaml(file) as YamlMap; - - /// Convert to pretty json - String _convertToPrettyJson(String file) { - final indent = ' ' * 4; - final encoder = JsonEncoder.withIndent(indent); - - return encoder.convert(_convertToMap(file)); - } -} diff --git a/lib/src/utils/mixins/modify_yaml_mixin.dart b/lib/src/utils/mixins/modify_yaml_mixin.dart index bd4c42a..c58df21 100644 --- a/lib/src/utils/mixins/modify_yaml_mixin.dart +++ b/lib/src/utils/mixins/modify_yaml_mixin.dart @@ -1,18 +1,17 @@ -import 'package:magical_version_bump/src/utils/exceptions/command_exceptions.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; import 'package:magical_version_bump/src/utils/extensions/map_extensions.dart'; import 'package:magical_version_bump/src/utils/typedefs/typedefs.dart'; -import 'package:yaml/yaml.dart'; import 'package:yaml_edit/yaml_edit.dart'; -/// This mixin modifies a yaml node to desired option +/// This mixin modifies a yaml/json node to desired option mixin ModifyYaml { /// Update yaml file Future updateYamlFile( - String file, { + FileOutput fileOutput, { required Dictionary dictionary, }) async { // Setup editor - final editor = YamlEditor(file); + final editor = YamlEditor(fileOutput.file); // Get keys to use in update final rootKeys = [...dictionary.rootKeys]; @@ -23,14 +22,14 @@ mixin ModifyYaml { /// 2. Root is being overwritten /// /// Update and return updated file - if (rootKeys.length == 1 && !dictionary.append) { + if (rootKeys.length == 1 && dictionary.updateMode != UpdateMode.append) { editor.update([rootKeys.first], dictionary.data); return editor.toString(); } - // Convert file to required yaml map - final fileAsYamlMap = loadYaml(file) as YamlMap; + /// Convert file to map + final fileAsDynamicMap = {...fileOutput.fileAsMap}; /// When more than 1 key is provided, we need to guarantee that: /// @@ -38,7 +37,6 @@ mixin ModifyYaml { /// absent, create the necessary missing nodes. /// 2. Update a node storing a string to list, if append is true /// 3. Never append a list to an existing map and vice versa - /// 4. Never throw an error in our recursive function /// /// For this we need, /// `depth` - how far deep the node is in yaml map, which is the number @@ -53,308 +51,25 @@ mixin ModifyYaml { /// * `targetKey` is the last element removed final targetKey = rootKeys.removeAt(depth); - final recursiveOutput = updateNestedTarget( - keys: rootKeys, - yamlMap: fileAsYamlMap, - targetKey: targetKey, - update: dictionary.data, - append: dictionary.append, + // Recursively update the file (map representation of it) + final updatedMap = fileAsDynamicMap.recursivelyUpdate( + dictionary.data, + target: targetKey, + path: rootKeys, + updateMode: dictionary.updateMode, ); - // If update failed, throw error - if (recursiveOutput.failed) { - throw MagicalException( - violation: recursiveOutput.failedReason!, - ); - } - - final formattedOutput = formatOutput( - fileAsYamlMap, - append: dictionary.append, - rootKeys: rootKeys, - output: recursiveOutput, - fallbackData: {targetKey: dictionary.data}, - ); - - editor.update( - formattedOutput.path, - formattedOutput.dataToSave, - ); - - return editor.toString(); - } - - /// - NestedUpdate updateNestedTarget({ - required List keys, - required YamlMap yamlMap, - required String targetKey, - required dynamic update, - required bool append, - }) { - final output = {targetKey: null}; - - // Value to add to target - dynamic valueToAppend; - - var tempOutput = {}..addAll(yamlMap); - - /// Loop all keys reading values associated with them - for (final (index, key) in keys.indexed) { - final depth = keys.length - index; - - // Must have key - if (!tempOutput.containsKey(key)) { - return ( - failed: false, - failedReason: null, - finalDepth: depth == keys.length ? depth : depth + 1, - updatedValue: null, - ); - } - - final valueFromMap = tempOutput[key]; - - /// Only return a failed status if user wanted to append and current - /// value is not a map of other values. - /// - /// If append is false, indicates we need to overwrite any existing value - if (valueFromMap is! Map) { - return ( - failed: valueFromMap != null && append, - failedReason: - valueFromMap != null && append ? 'Cannot append at $key' : null, - finalDepth: valueFromMap != null && append && depth != keys.length - ? (depth + 1) - - // If maybe value was null or not but we are not appending - : !append - ? depth - 1 - : depth, - updatedValue: null, - ); - } - - tempOutput = valueFromMap; - } - - // Target must be in map - if (!tempOutput.containsKey(targetKey)) { - return ( - failed: false, - failedReason: null, - finalDepth: 0, - updatedValue: null, - ); - } - - final targetKeyValue = tempOutput[targetKey]; - final targetValIsNull = targetKeyValue == null; - - if (append && targetValIsNull || !append) { - valueToAppend = update is String && !append - ? update - : update is String && append - ? [update] - : update is List - ? [...update] - : update as Map; - - output.update(targetKey, (value) => valueToAppend); - - return ( - failed: false, - failedReason: null, - finalDepth: 0, - updatedValue: output, - ); - } - - // Check for mismatch when target value is not null - if ((targetKeyValue is String || targetKeyValue is YamlList) && - (update is! String && update is! List)) { - return ( - failed: true, - failedReason: 'Cannot append new values at $targetKey', - finalDepth: 0, - updatedValue: null, - ); - } else if (targetKeyValue is YamlMap && update is! Map) { - return ( - failed: true, - failedReason: 'Cannot append new mapped values at $targetKey', - finalDepth: 0, - updatedValue: null, - ); - } - - // Convert all strings to list of strings - if (targetKeyValue is String) { - valueToAppend = update is String - ? [targetKeyValue, update] - : [targetKeyValue, ...update as List]; - - output.update(targetKey, (value) => valueToAppend); - } else if (targetKeyValue is YamlList) { - valueToAppend = update is String - ? [...targetKeyValue, update] - : [...targetKeyValue, ...update as List]; - - output.update(targetKey, (value) => valueToAppend); - } else { - valueToAppend = {} - ..addAll(targetKeyValue as YamlMap) - ..addAll(update as Map); - - output.update(targetKey, (value) => valueToAppend); - } - - return ( - failed: false, - failedReason: null, - finalDepth: 0, - updatedValue: output, - ); - } - - /// Format recursive output - ({List path, dynamic dataToSave}) formatOutput( - YamlMap fileAsMap, { - required bool append, - required List rootKeys, - required NestedUpdate output, - required Map fallbackData, - }) { - /// In case recursive function managed to reach the end. - /// - /// * If data is null, means the final key doesn't exist and needs to be - /// created. - /// * Recursive function will update and return the target key with all - /// its data if successful - /// - /// * Data will always be null if the recursive function never reached the - /// end. - final depthDifference = rootKeys.length - output.finalDepth; - - final modifiableKeys = [...rootKeys]; - - /// Account for the stopping point. Why? - /// Consider, array [6 ,5 , 4] with decreaseing order where: - /// * `6 -> 3` - /// * `5 -> 2` - /// * `4 -> 1` + /// Get the key that appears first in the file. /// - /// Thus, first element's order = size of array. This decreases till the - /// last element has 1. - /// - /// Imagine a cursor ran from start of array and stopped randomly at `5`. - /// To obtain the number of elements it "transversed" including the - /// stopping point, - /// - /// `Number of elements = (Start order - Order of stop) + 1` - /// - /// In our case, [depthDifference + 1] - final unBiasedDiff = depthDifference + 1; - - /// To obtain the non-visited keys, - /// - /// 1. The `depthDifference` is the number of elements visited in the - /// list and are available as paths. - /// 3. The `depthDifference` is also the number of elements we need to skip - /// to get all elements not visited - final pathKeys = modifiableKeys.take(unBiasedDiff).toList(); - final otherKeys = modifiableKeys.skip(unBiasedDiff).toList(); + /// The `anchorKey` will be first root key or the `targetKey` we want to + /// update of no root keys are available + final anchorKey = rootKeys.isEmpty ? targetKey : rootKeys.first; - final pathData = convertToDartMap( - fileAsMap, - append: append, - pathKeys: pathKeys, - missingKeys: otherKeys, - updatedData: output.updatedValue, - fallbackData: fallbackData, - ); + final dataToSave = updatedMap[anchorKey]; - /// We "overwrite" old data with new. Why quotes? Since old data may be - /// retained when append is true! - /// - /// If the anchor is the target, no pathkeys will be available. Get the key - /// and data from the fallback data - /// - final anchorKey = - pathKeys.isEmpty ? fallbackData.keys.first : pathKeys.first; + // Update as a whole instead of being granular + editor.update([anchorKey], dataToSave); - return ( - path: [anchorKey], - dataToSave: pathKeys.isNotEmpty - ? pathData[anchorKey] - : output.updatedValue == null - ? fallbackData[anchorKey] - : output.updatedValue![anchorKey], - ); - } - - /// Convert to map - Map convertToDartMap( - YamlMap fileAsMap, { - required bool append, - required List pathKeys, - required List missingKeys, - required Map? updatedData, - required Map fallbackData, - }) { - var dataAsMap = {}; - - // Missing keys are not in map currently, need to be created - if (missingKeys.isEmpty) { - dataAsMap.addAll(updatedData ?? fallbackData); - } else { - for (final (index, value) in missingKeys.reversed.indexed) { - if (index == 0) { - dataAsMap.addAll({ - value: updatedData ?? fallbackData, - }); - } else { - dataAsMap = { - value: {...dataAsMap}, - }; - } - } - } - - /// Path keys exist and we need to get all data. This prevents any data - /// from being overwritten. Only the target key changes. - - if (pathKeys.isNotEmpty) { - final pathsInReverse = pathKeys.reversed; - - // Loop all keys in reverse. Appending or adding data - for (final (index, currentKey) in pathsInReverse.indexed) { - /// - /// The current index indicates number of elements to skip. Add 1 since - /// we have to include the target key - final numOfSkippable = index + 1; - - final pathsToTarget = pathsInReverse.skip(numOfSkippable); - - final keyData = fileAsMap.recursiveRead( - path: pathsToTarget.toList(), - target: currentKey, - ) as Map?; - - // Data to add - final existingData = keyData == null || !append - ? {currentKey: dataAsMap} - : { - currentKey: { - ...keyData, - ...dataAsMap, - }, - }; - - dataAsMap = {...existingData}; - } - } - - return dataAsMap; + return editor.toString(); } } diff --git a/lib/src/utils/typedefs/typedefs.dart b/lib/src/utils/typedefs/typedefs.dart index f23527b..c19e7b2 100644 --- a/lib/src/utils/typedefs/typedefs.dart +++ b/lib/src/utils/typedefs/typedefs.dart @@ -1,3 +1,7 @@ +import 'package:collection/collection.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; +import 'package:yaml/yaml.dart'; + /// Reason why an error was thrown: /// /// * Key - title (shown in progress) @@ -7,22 +11,58 @@ typedef InvalidReason = MapEntry; /// Custom dictionary /// /// * `List` - all roots keys preceding data -typedef Dictionary = ({List rootKeys, bool append, dynamic data}); +typedef Dictionary = ({ + List rootKeys, + UpdateMode updateMode, + dynamic data, +}); /// Path info from commands -typedef PathInfo = ({bool requestPath, String path}); +typedef PathInfo = ({bool requestPath, List paths}); -/// Recursive return value -/// -/// * `failed` - whether operation to add value failed -/// * `reason` - what key caused the recursive update to fail -/// * `finalDepth` - how far deep the recursive function managed to reach -/// * `updatedValue` - final value updated. Will be null when `finalDepth` is -/// not 0 and when operation failed -/// -typedef NestedUpdate = ({ - bool failed, - String? failedReason, - int finalDepth, - Map? updatedValue, +/// File output after file has been read +typedef FileOutput = ({String file, YamlMap fileAsMap}); + +/// Keys to find in yaml/json file +typedef KeysToFind = ({ + /// Desired Order type + OrderType orderType, + + /// Keys to find + List keys, +}); + +/// List of values to find in yaml/json file +typedef ValuesToFind = List; + +/// Map of pairs to find in yaml/json file +typedef PairsToFind = Map; + +/// Simple name for Map of keys and their replacements +typedef KeyAndReplacement = PairsToFind; + +/// An output from recursive function call on a list +typedef RecursiveListOutput = ({bool didModify, List modified}); + +/// Check if collections match. Check for order too. +bool collectionsMatch(dynamic e1, dynamic e2) => + const DeepCollectionEquality().equals(e1, e2); + +/// Check if collections match. Ignores order +bool collectionsUnorderedMatch(dynamic e1, dynamic e2) => + const DeepCollectionEquality.unordered().equals(e1, e2); + +/// Denotes aggregate type to use, count and whether to aggregation type to +/// each output +typedef Aggregator = ({ + AggregateType type, + bool applyToEachArg, + bool applyToEachFile, + int? count, }); + +/// List of values in arguments +typedef ParsedValues = List; + +/// List of list of values in arguments +typedef ListOfParsedValues = List>; diff --git a/lib/src/version.dart b/lib/src/version.dart index 92a0d66..8d4540b 100644 --- a/lib/src/version.dart +++ b/lib/src/version.dart @@ -1,2 +1,2 @@ // Generated code. Do not modify. -const packageVersion = '1.0.1'; +const packageVersion = '1.1.0-dev.1'; diff --git a/pubspec.yaml b/pubspec.yaml index f418673..bec0963 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: magical_version_bump description: A commandline tool for automatically modifying/changing nodes - specified in your pubspec.yaml -version: 1.0.1 + specified in your pubspec.yaml +version: 1.1.0-dev.1 homepage: https://github.com/kekavc24/magical_version_bump repository: https://github.com/kekavc24/magical_version_bump issue_tracker: https://github.com/kekavc24/magical_version_bump/issues @@ -20,21 +20,24 @@ platforms: macos: dependencies: - args: ^2.3.1 - cli_completion: ">=0.3.0 <0.5.0" - mason_logger: ^0.2.4 + args: ^2.4.2 + cli_completion: ^0.4.0 + collection: ^1.18.0 + equatable: ^2.0.5 + mason_logger: ^0.2.11 + meta: ^1.11.0 pub_semver: ^2.1.4 - pub_updater: ">=0.3.0 <0.5.0" - yaml: ^3.1.1 - yaml_edit: ^2.1.0 + pub_updater: ^0.4.0 + yaml: ^3.1.2 + yaml_edit: ^2.1.1 dev_dependencies: - build_runner: ^2.0.0 - build_verify: ^3.0.0 - build_version: ^2.0.0 - mocktail: ^1.0.0 - test: ^1.19.2 - very_good_analysis: ^5.0.0+1 + build_runner: ^2.4.8 + build_verify: ^3.1.0 + build_version: ^2.1.1 + mocktail: ^1.0.3 + test: ^1.25.2 + very_good_analysis: ^5.1.0 executables: mag: diff --git a/test/helpers/custom_matchers.dart b/test/helpers/custom_matchers.dart new file mode 100644 index 0000000..59cbc79 --- /dev/null +++ b/test/helpers/custom_matchers.dart @@ -0,0 +1,7 @@ +part of 'helpers.dart'; + +Matcher throwsCustomException(String message) { + return throwsA( + isA().having((e) => e.message, 'message', message), + ); +} diff --git a/test/helpers/helpers.dart b/test/helpers/helpers.dart index 044b97d..06d9443 100644 --- a/test/helpers/helpers.dart +++ b/test/helpers/helpers.dart @@ -1,14 +1,19 @@ import 'dart:io'; import 'package:args/args.dart'; -import 'package:magical_version_bump/src/core/argument_checkers/arg_checker.dart'; -import 'package:magical_version_bump/src/utils/exceptions/command_exceptions.dart'; +import 'package:magical_version_bump/src/core/argument_normalizers/arg_normalizer.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/yaml_transformer.dart'; +import 'package:magical_version_bump/src/utils/data/version_modifiers.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; +import 'package:magical_version_bump/src/utils/exceptions/magical_exception.dart'; import 'package:magical_version_bump/src/utils/extensions/map_extensions.dart'; import 'package:test/test.dart'; import 'package:yaml/yaml.dart'; import 'package:yaml_edit/yaml_edit.dart'; -part 'magical_exception_message.dart'; +part 'custom_matchers.dart'; part 'set_reset_yaml.dart'; part 'set_up_sanitizers.dart'; part 'read_nested_nodes.dart'; +part 'version_modifier.dart'; +part 'matched_node_builder.dart'; diff --git a/test/helpers/magical_exception_message.dart b/test/helpers/magical_exception_message.dart deleted file mode 100644 index 7ce7658..0000000 --- a/test/helpers/magical_exception_message.dart +++ /dev/null @@ -1,7 +0,0 @@ -part of 'helpers.dart'; - -Matcher throwsViolation(String message) { - return throwsA( - isA().having((e) => e.violation, 'violation', message), - ); -} diff --git a/test/helpers/matched_node_builder.dart b/test/helpers/matched_node_builder.dart new file mode 100644 index 0000000..eba651f --- /dev/null +++ b/test/helpers/matched_node_builder.dart @@ -0,0 +1,16 @@ +part of 'helpers.dart'; + +/// Creates a single [MatchedNodeData] based on a provided predicate +MatchedNodeData buildMatchedNode( + List nodes, { + required bool Function(NodeData) predicate, + List? matchedKeys, + String? matchedValue, + Map? matchedPairs, +}) => + MatchedNodeData.fromFinder( + nodeData: nodes.firstWhere(predicate), + matchedKeys: matchedKeys ?? [], + matchedValue: matchedValue ?? '', + matchedPairs: matchedPairs ?? {}, + ); diff --git a/test/helpers/read_nested_nodes.dart b/test/helpers/read_nested_nodes.dart index cc5f9e8..dbf2499 100644 --- a/test/helpers/read_nested_nodes.dart +++ b/test/helpers/read_nested_nodes.dart @@ -1,7 +1,7 @@ part of 'helpers.dart'; /// Read nested nodes -Future readNestedNodes(String? file, List path) async { +Future readNestedNodes(String? file, List path) async { final yamlMap = loadYaml( file ?? await File(getTestFile()).readAsString(), ) as YamlMap; @@ -10,7 +10,7 @@ Future readNestedNodes(String? file, List path) async { final modifiedPath = [...path]; final target = modifiedPath.removeAt(depth); - return yamlMap.recursiveRead( + return yamlMap.recursiveRead( path: modifiedPath, target: target, ); diff --git a/test/helpers/set_up_sanitizers.dart b/test/helpers/set_up_sanitizers.dart index 3ac532f..ab3e0d3 100644 --- a/test/helpers/set_up_sanitizers.dart +++ b/test/helpers/set_up_sanitizers.dart @@ -53,15 +53,15 @@ ArgParser setUpArgParser() { } /// Set up desired sanitizer type on demand -ArgumentsChecker setUpSanitizer( +ArgumentsNormalizer setUpSanitizer( ArgCheckerType type, { required ArgParser argParser, required List args, }) { return switch (type) { - ArgCheckerType.setter => SetArgumentsChecker( + ArgCheckerType.setter => SetArgumentsNormalizer( argResults: argParser.parse(args), ), - _ => BumpArgumentsChecker(argResults: argParser.parse(args)) + _ => BumpArgumentsNormalizer(argResults: argParser.parse(args)) }; } diff --git a/test/helpers/version_modifier.dart b/test/helpers/version_modifier.dart new file mode 100644 index 0000000..2491490 --- /dev/null +++ b/test/helpers/version_modifier.dart @@ -0,0 +1,66 @@ +part of 'helpers.dart'; + +class TestVersionModifier implements VersionModifiers { + TestVersionModifier({ + required this.presetType, + required this.version, + required this.prerelease, + required this.build, + required this.keepPre, + required this.keepBuild, + required this.strategy, + }); + + // Default + factory TestVersionModifier.forTest() { + return TestVersionModifier( + presetType: PresetType.none, + version: null, + prerelease: null, + build: null, + keepPre: false, + keepBuild: false, + strategy: ModifyStrategy.relative, + ); + } + + TestVersionModifier copyWith({ + PresetType? presetType, + String? version, + String? prerelease, + String? build, + bool? keepPre, + bool? keepBuild, + }) { + return TestVersionModifier( + presetType: presetType ?? this.presetType, + version: version ?? this.version, + prerelease: prerelease ?? this.prerelease, + build: build ?? this.build, + keepPre: keepPre ?? this.keepPre, + keepBuild: keepBuild ?? this.keepBuild, + strategy: strategy, + ); + } + + @override + String? build; + + @override + bool keepBuild; + + @override + bool keepPre; + + @override + String? prerelease; + + @override + PresetType presetType; + + @override + ModifyStrategy strategy; + + @override + String? version; +} diff --git a/test/src/end_to_end_tests/modify/bump_subcommand_test.dart b/test/src/end_to_end_tests/modify/bump_subcommand_test.dart index 61571c8..e6c7fcd 100644 --- a/test/src/end_to_end_tests/modify/bump_subcommand_test.dart +++ b/test/src/end_to_end_tests/modify/bump_subcommand_test.dart @@ -26,7 +26,7 @@ void main() { when(() => logger.progress(any())).thenReturn(_MockProgress()); when( () => logger.prompt( - 'Please enter the path to file:', + 'Please enter the path to file: ', defaultValue: any( named: 'defaultValue', ), diff --git a/test/src/end_to_end_tests/modify/set_subcommand_test.dart b/test/src/end_to_end_tests/modify/set_subcommand_test.dart index 5ec1692..48917cb 100644 --- a/test/src/end_to_end_tests/modify/set_subcommand_test.dart +++ b/test/src/end_to_end_tests/modify/set_subcommand_test.dart @@ -2,6 +2,7 @@ import 'package:magical_version_bump/src/command_runner.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; import 'package:test/test.dart'; +import 'package:yaml/yaml.dart'; import '../../../helpers/helpers.dart'; @@ -56,7 +57,11 @@ void main() { final result = await commandRunner.run(args); expect(result, equals(ExitCode.usage.code)); - verify(() => logger.err('Cannot append at name')).called(1); + verify( + () => logger.err( + '''Cannot append new values due to an existing value at "name". You need to overwrite this path key.''', + ), + ).called(1); }, ); }); @@ -136,15 +141,20 @@ void main() { final defaultStart = ['nested']; - final createdValue = await readNestedNodes( + final createdValue = await readNestedNodes( null, [...defaultStart, 'value'], ); - final createdList = await readNestedNodes( + + final createdList = await readNestedNodes( null, [...defaultStart, 'list'], ); - final createdMap = await readNestedNodes(null, [...defaultStart, 'map']); + + final createdMap = await readNestedNodes( + null, + [...defaultStart, 'map'], + ); await resetFile(node: 'nested', remove: true); diff --git a/test/src/unit_tests/argument_checkers/bump_arg_checker_test.dart b/test/src/unit_tests/argument_normalizer/bump_arg_normalizer_test.dart similarity index 89% rename from test/src/unit_tests/argument_checkers/bump_arg_checker_test.dart rename to test/src/unit_tests/argument_normalizer/bump_arg_normalizer_test.dart index d36dcc5..0cd8dee 100644 --- a/test/src/unit_tests/argument_checkers/bump_arg_checker_test.dart +++ b/test/src/unit_tests/argument_normalizer/bump_arg_normalizer_test.dart @@ -1,12 +1,12 @@ import 'package:args/args.dart'; -import 'package:magical_version_bump/src/core/argument_checkers/arg_checker.dart'; +import 'package:magical_version_bump/src/core/argument_normalizers/arg_normalizer.dart'; import 'package:magical_version_bump/src/utils/enums/enums.dart'; import 'package:test/test.dart'; import '../../../helpers/helpers.dart'; void main() { - late BumpArgumentsChecker argsChecker; + late BumpArgumentsNormalizer argsChecker; late ArgParser argParser; const error = @@ -29,7 +29,7 @@ void main() { ArgCheckerType.bump, argParser: argParser, args: args, - ) as BumpArgumentsChecker; + ) as BumpArgumentsNormalizer; final preppedArgs = argsChecker.prepArgs(); @@ -49,7 +49,7 @@ void main() { ArgCheckerType.bump, argParser: argParser, args: args, - ) as BumpArgumentsChecker; + ) as BumpArgumentsNormalizer; final preppedArgs = argsChecker.prepArgs(); @@ -67,7 +67,7 @@ void main() { ArgCheckerType.bump, argParser: argParser, args: [], - ) as BumpArgumentsChecker; + ) as BumpArgumentsNormalizer; final validatedArgs = argsChecker.validateArgs(); @@ -84,7 +84,7 @@ void main() { ArgCheckerType.bump, argParser: argParser, args: args, - ) as BumpArgumentsChecker; + ) as BumpArgumentsNormalizer; final validatedArgs = argsChecker.validateArgs(); diff --git a/test/src/unit_tests/argument_checkers/set_arg_checker_test.dart b/test/src/unit_tests/argument_normalizer/set_arg_normalizer_test.dart similarity index 78% rename from test/src/unit_tests/argument_checkers/set_arg_checker_test.dart rename to test/src/unit_tests/argument_normalizer/set_arg_normalizer_test.dart index 0e361ba..cc96d48 100644 --- a/test/src/unit_tests/argument_checkers/set_arg_checker_test.dart +++ b/test/src/unit_tests/argument_normalizer/set_arg_normalizer_test.dart @@ -1,18 +1,19 @@ import 'package:args/args.dart'; -import 'package:magical_version_bump/src/core/argument_checkers/arg_checker.dart'; +import 'package:magical_version_bump/src/core/argument_normalizers/arg_normalizer.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; import 'package:test/test.dart'; import '../../../helpers/helpers.dart'; void main() { - late SetArgumentsChecker argsChecker; - late SetArgumentsChecker nullableChecker; + late SetArgumentsNormalizer argsChecker; + late SetArgumentsNormalizer nullableChecker; late ArgParser argParser; setUp(() { argParser = setUpArgParser(); - nullableChecker = SetArgumentsChecker(argResults: null); + nullableChecker = SetArgumentsNormalizer(argResults: null); }); group('parses dictionaries to be overwritten', () { @@ -21,20 +22,23 @@ void main() { final expectedDictionary = ( rootKeys: ['test'], - append: false, data: '1', + updateMode: UpdateMode.overwrite, ); argsChecker = setUpSanitizer( ArgCheckerType.setter, argParser: argParser, args: args, - ) as SetArgumentsChecker; + ) as SetArgumentsNormalizer; final preppedArgs = argsChecker.prepArgs(); expect(preppedArgs.dictionaries.length, 1); - expect(preppedArgs.dictionaries.first.append, expectedDictionary.append); + expect( + preppedArgs.dictionaries.first.updateMode, + equals(expectedDictionary.updateMode), + ); expect( preppedArgs.dictionaries.first.rootKeys, equals(expectedDictionary.rootKeys), @@ -47,7 +51,7 @@ void main() { final expectedDictionary = ( rootKeys: ['test', 'test2'], - append: false, + updateMode: UpdateMode.overwrite, data: ['1', '2'], ); @@ -55,12 +59,15 @@ void main() { ArgCheckerType.setter, argParser: argParser, args: args, - ) as SetArgumentsChecker; + ) as SetArgumentsNormalizer; final preppedArgs = argsChecker.prepArgs(); expect(preppedArgs.dictionaries.length, 1); - expect(preppedArgs.dictionaries.first.append, expectedDictionary.append); + expect( + preppedArgs.dictionaries.first.updateMode, + equals(expectedDictionary.updateMode), + ); expect( preppedArgs.dictionaries.first.rootKeys, equals(expectedDictionary.rootKeys), @@ -76,7 +83,7 @@ void main() { final expectedDictionary = ( rootKeys: ['test', 'test2'], - append: false, + updateMode: UpdateMode.overwrite, data: {'1': '2'}, ); @@ -84,12 +91,15 @@ void main() { ArgCheckerType.setter, argParser: argParser, args: args, - ) as SetArgumentsChecker; + ) as SetArgumentsNormalizer; final preppedArgs = argsChecker.prepArgs(); expect(preppedArgs.dictionaries.length, 1); - expect(preppedArgs.dictionaries.first.append, expectedDictionary.append); + expect( + preppedArgs.dictionaries.first.updateMode, + equals(expectedDictionary.updateMode), + ); expect( preppedArgs.dictionaries.first.rootKeys, equals(expectedDictionary.rootKeys), @@ -107,7 +117,7 @@ void main() { final expectedDictionary = ( rootKeys: ['test'], - append: true, + updateMode: UpdateMode.append, data: '1', ); @@ -115,12 +125,15 @@ void main() { ArgCheckerType.setter, argParser: argParser, args: args, - ) as SetArgumentsChecker; + ) as SetArgumentsNormalizer; final preppedArgs = argsChecker.prepArgs(); expect(preppedArgs.dictionaries.length, 1); - expect(preppedArgs.dictionaries.first.append, expectedDictionary.append); + expect( + preppedArgs.dictionaries.first.updateMode, + equals(expectedDictionary.updateMode), + ); expect( preppedArgs.dictionaries.first.rootKeys, equals(expectedDictionary.rootKeys), @@ -133,7 +146,7 @@ void main() { final expectedDictionary = ( rootKeys: ['test', 'test2'], - append: true, + updateMode: UpdateMode.append, data: ['1', '2'], ); @@ -141,12 +154,15 @@ void main() { ArgCheckerType.setter, argParser: argParser, args: args, - ) as SetArgumentsChecker; + ) as SetArgumentsNormalizer; final preppedArgs = argsChecker.prepArgs(); expect(preppedArgs.dictionaries.length, 1); - expect(preppedArgs.dictionaries.first.append, expectedDictionary.append); + expect( + preppedArgs.dictionaries.first.updateMode, + equals(expectedDictionary.updateMode), + ); expect( preppedArgs.dictionaries.first.rootKeys, equals(expectedDictionary.rootKeys), @@ -162,7 +178,7 @@ void main() { final expectedDictionary = ( rootKeys: ['test', 'test2'], - append: true, + updateMode: UpdateMode.append, data: {'1': '2'}, ); @@ -170,12 +186,15 @@ void main() { ArgCheckerType.setter, argParser: argParser, args: args, - ) as SetArgumentsChecker; + ) as SetArgumentsNormalizer; final preppedArgs = argsChecker.prepArgs(); expect(preppedArgs.dictionaries.length, 1); - expect(preppedArgs.dictionaries.first.append, expectedDictionary.append); + expect( + preppedArgs.dictionaries.first.updateMode, + equals(expectedDictionary.updateMode), + ); expect( preppedArgs.dictionaries.first.rootKeys, equals(expectedDictionary.rootKeys), @@ -200,7 +219,7 @@ void main() { ArgCheckerType.setter, argParser: argParser, args: args, - ) as SetArgumentsChecker; + ) as SetArgumentsNormalizer; final preppedArgs = argsChecker.prepArgs(); @@ -217,9 +236,9 @@ void main() { ArgCheckerType.setter, argParser: argParser, args: [], - ) as SetArgumentsChecker; + ) as SetArgumentsNormalizer; - final validatedArgs = argsChecker.defaultValidation(); + final validatedArgs = argsChecker.validateArgs(); expect(validatedArgs.isValid, false); expect(validatedArgs.reason, isNotNull); @@ -236,7 +255,7 @@ void main() { ); expect(dictionary.rootKeys, equals(['testKey'])); - expect(dictionary.data is String, true); + expect(dictionary.data, isA()); expect(dictionary.data, 'testValue'); }); @@ -247,7 +266,7 @@ void main() { ); expect(dictionary.rootKeys, equals(['testKey', 'anotherKey'])); - expect(dictionary.data is String, true); + expect(dictionary.data, isA()); expect(dictionary.data, 'testValue'); }); @@ -258,7 +277,7 @@ void main() { ); expect(dictionary.rootKeys, equals(['testKey'])); - expect(dictionary.data is List, true); + expect(dictionary.data, isList); expect(dictionary.data, equals(['testValue', 'anotherValue'])); }); @@ -269,7 +288,7 @@ void main() { ); expect(dictionary.rootKeys, equals(['testKey'])); - expect(dictionary.data is List, true); + expect(dictionary.data, isList); expect(dictionary.data, equals(['testValue', 'anotherValue'])); }); @@ -280,7 +299,7 @@ void main() { ); expect(dictionary.rootKeys, equals(['testKey', 'anotherKey'])); - expect(dictionary.data is List, true); + expect(dictionary.data, isList); expect(dictionary.data, equals(['testValue', 'anotherValue'])); }); @@ -291,13 +310,30 @@ void main() { ); expect(dictionary.rootKeys, equals(['testKey'])); - expect(dictionary.data is Map, true); + expect(dictionary.data, isMap); expect( dictionary.data, equals({'testMapKey': 'testMapValue'}), ); }); + test('extracts strings and mapped values as list of values', () { + final dictionary = nullableChecker.extractDictionary( + 'key=value,mapKey->mapValue', + append: false, + ); + + expect(dictionary.rootKeys, equals(['key'])); + expect(dictionary.data, isList); + expect( + dictionary.data, + equals([ + 'value', + {'mapKey': 'mapValue'}, + ]), + ); + }); + test('extracts key and mapped values, sets empty pairs to null', () { final dictionary = nullableChecker.extractDictionary( 'testKey=testMapKey->', @@ -305,7 +341,7 @@ void main() { ); expect(dictionary.rootKeys, equals(['testKey'])); - expect(dictionary.data is Map, true); + expect(dictionary.data, isMap); expect( dictionary.data, equals({'testMapKey': 'null'}), @@ -324,14 +360,14 @@ void main() { }; expect(dictionary.rootKeys, equals(['testKey', 'anotherKey'])); - expect(dictionary.data is Map, true); + expect(dictionary.data, isMap); expect(dictionary.data, equals(expectedMappedValues)); }); test('throws error when parsed value is empty', () { expect( () => nullableChecker.extractDictionary('', append: false), - throwsViolation('The root key cannot be empty/null'), + throwsCustomException('The root key cannot be empty/null'), ); }); @@ -344,7 +380,7 @@ void main() { valueWithBlanks, append: false, ), - throwsViolation( + throwsCustomException( 'Invalid keys and value pair at "$valueWithBlanks"', ), ); @@ -354,22 +390,10 @@ void main() { valueWithOnePair, append: false, ), - throwsViolation( + throwsCustomException( 'Invalid keys and value pair at "$valueWithOnePair"', ), ); }); - - test('throws error when parsed value has non-uniform formats', () { - const nonUniformValue = 'key=value,mapKey->mapValue'; - - expect( - () => nullableChecker.extractDictionary( - nonUniformValue, - append: false, - ), - throwsViolation('Mixed format at $nonUniformValue'), - ); - }); }); } diff --git a/test/src/unit_tests/custom_version_modifiers/semver_version_modifier_test.dart b/test/src/unit_tests/custom_version_modifiers/semver_version_modifier_test.dart index b8f3cfc..9b7e288 100644 --- a/test/src/unit_tests/custom_version_modifiers/semver_version_modifier_test.dart +++ b/test/src/unit_tests/custom_version_modifiers/semver_version_modifier_test.dart @@ -1,51 +1,9 @@ import 'package:magical_version_bump/src/core/custom_version_modifiers/semver_version_modifer.dart'; -import 'package:magical_version_bump/src/utils/data/version_modifiers.dart'; import 'package:magical_version_bump/src/utils/enums/enums.dart'; import 'package:test/test.dart'; import '../../../helpers/helpers.dart'; -class _TestVersionModifier extends VersionModifiers { - _TestVersionModifier({ - required super.presetType, - required super.version, - required super.prerelease, - required super.build, - required super.keepPre, - required super.keepBuild, - }); - - // Default - factory _TestVersionModifier.forTest() { - return _TestVersionModifier( - presetType: PresetType.none, - version: null, - prerelease: null, - build: null, - keepPre: false, - keepBuild: false, - ); - } - - _TestVersionModifier copyWith({ - PresetType? presetType, - String? version, - String? prerelease, - String? build, - bool? keepPre, - bool? keepBuild, - }) { - return _TestVersionModifier( - presetType: presetType ?? this.presetType, - version: version ?? this.version, - prerelease: prerelease ?? this.prerelease, - build: build ?? this.build, - keepPre: keepPre ?? this.keepPre, - keepBuild: keepBuild ?? this.keepBuild, - ); - } -} - void main() { const versionFromFile = '0.0.0-alpha+0'; const version = '10.10.10-prerelease+21'; @@ -53,11 +11,11 @@ void main() { group('add presets', () { test('returns version as is when preset is not set', () { - final modifier = _TestVersionModifier.forTest().copyWith( + final modifier = TestVersionModifier.forTest().copyWith( presetType: PresetType.none, ); - final versionFromPreset = MagicalSEMVER.addPresets( + final versionFromPreset = addPresets( versionFromFile, modifiers: modifier, ); @@ -66,12 +24,12 @@ void main() { }); test('returns preset version when only version is preset', () { - final modifier = _TestVersionModifier.forTest().copyWith( + final modifier = TestVersionModifier.forTest().copyWith( presetType: PresetType.version, version: '1.0.0', ); - final versionFromPreset = MagicalSEMVER.addPresets( + final versionFromPreset = addPresets( versionFromFile, modifiers: modifier, ); @@ -80,11 +38,11 @@ void main() { }); test('returns empty string when preset version is null', () { - final modifier = _TestVersionModifier.forTest().copyWith( + final modifier = TestVersionModifier.forTest().copyWith( presetType: PresetType.version, ); - final versionFromPreset = MagicalSEMVER.addPresets( + final versionFromPreset = addPresets( versionFromFile, modifiers: modifier, ); @@ -93,14 +51,14 @@ void main() { }); test('returns modified version when all values are preset', () { - final modifier = _TestVersionModifier.forTest().copyWith( + final modifier = TestVersionModifier.forTest().copyWith( presetType: PresetType.all, version: '1.0.0', prerelease: 'production', build: '1', ); - final versionFromPreset = MagicalSEMVER.addPresets( + final versionFromPreset = addPresets( versionFromFile, modifiers: modifier, ); @@ -109,14 +67,14 @@ void main() { }); test('returns modified version, preserves prerelease & build info', () { - final modifier = _TestVersionModifier.forTest().copyWith( + final modifier = TestVersionModifier.forTest().copyWith( presetType: PresetType.all, version: '1.0.0', keepBuild: true, keepPre: true, ); - final versionFromPreset = MagicalSEMVER.addPresets( + final versionFromPreset = addPresets( versionFromFile, modifiers: modifier, ); @@ -127,14 +85,14 @@ void main() { test( 'returns modified version, sets new prerelease and keeps build-number', () { - final modifier = _TestVersionModifier.forTest().copyWith( + final modifier = TestVersionModifier.forTest().copyWith( presetType: PresetType.all, version: '1.0.0', prerelease: 'production', keepBuild: true, ); - final versionFromPreset = MagicalSEMVER.addPresets( + final versionFromPreset = addPresets( versionFromFile, modifiers: modifier, ); @@ -146,14 +104,14 @@ void main() { test( 'returns modified version, sets new build-number and keeps prerelease', () { - final modifier = _TestVersionModifier.forTest().copyWith( + final modifier = TestVersionModifier.forTest().copyWith( presetType: PresetType.all, version: '1.0.0', keepPre: true, build: '1', ); - final versionFromPreset = MagicalSEMVER.addPresets( + final versionFromPreset = addPresets( versionFromFile, modifiers: modifier, ); @@ -165,11 +123,11 @@ void main() { group('add final touches', () { test('returns version as is when version info was preset', () { - final modifier = _TestVersionModifier.forTest().copyWith( + final modifier = TestVersionModifier.forTest().copyWith( presetType: PresetType.all, ); - final completeVersion = MagicalSEMVER.appendPreAndBuild( + final completeVersion = appendPreAndBuild( versionFromFile, modifiers: modifier, ); @@ -180,11 +138,11 @@ void main() { test( '''returns version as is when prerelease & build are null and never preset''', () { - final modifier = _TestVersionModifier.forTest().copyWith( + final modifier = TestVersionModifier.forTest().copyWith( presetType: PresetType.none, ); - final completeVersion = MagicalSEMVER.appendPreAndBuild( + final completeVersion = appendPreAndBuild( versionFromFile, modifiers: modifier, ); @@ -194,13 +152,13 @@ void main() { ); test('returns version with updated prerelease & build info', () { - final modifier = _TestVersionModifier.forTest().copyWith( + final modifier = TestVersionModifier.forTest().copyWith( presetType: PresetType.none, prerelease: 'production', build: '1', ); - final completeVersion = MagicalSEMVER.appendPreAndBuild( + final completeVersion = appendPreAndBuild( versionFromFile, modifiers: modifier, ); @@ -213,7 +171,7 @@ void main() { test('bumps major version', () { const expectedBumpedVersion = '11.0.0+21'; - final bumpedVersion = MagicalSEMVER.bumpVersion( + final bumpedVersion = bumpVersion( version, versionTargets: ['major'], strategy: ModifyStrategy.relative, @@ -225,7 +183,7 @@ void main() { test('bumps minor version', () { const expectedBumpedVersion = '10.11.0+21'; - final bumpedVersion = MagicalSEMVER.bumpVersion( + final bumpedVersion = bumpVersion( version, versionTargets: ['minor'], strategy: ModifyStrategy.relative, @@ -237,7 +195,7 @@ void main() { test('bumps patch version', () { const expectedBumpedVersion = '10.10.10+21'; - final bumpedVersion = MagicalSEMVER.bumpVersion( + final bumpedVersion = bumpVersion( version, versionTargets: ['patch'], strategy: ModifyStrategy.relative, @@ -249,7 +207,7 @@ void main() { test('bumps build number', () { const expectedBumpedVersion = '10.10.10-prerelease+22'; - final bumpedVersion = MagicalSEMVER.bumpVersion( + final bumpedVersion = bumpVersion( version, versionTargets: ['build-number'], strategy: ModifyStrategy.relative, @@ -259,7 +217,7 @@ void main() { }); test('ignores custom build numbers', () { - final bumpedVersion = MagicalSEMVER.bumpVersion( + final bumpedVersion = bumpVersion( versionWithCustomBuild, versionTargets: ['build-number'], strategy: ModifyStrategy.relative, @@ -270,12 +228,12 @@ void main() { test('throws error if more than one target is passed in', () { expect( - () => MagicalSEMVER.bumpVersion( + () => bumpVersion( version, versionTargets: ['major', 'minor'], strategy: ModifyStrategy.relative, ), - throwsViolation( + throwsCustomException( 'Expected only one target for this versioning strategy', ), ); @@ -286,7 +244,7 @@ void main() { test('bumps up the major version', () { const expectedBumpedVersion = '11.10.10-prerelease+21'; - final bumpedVersion = MagicalSEMVER.bumpVersion( + final bumpedVersion = bumpVersion( version, versionTargets: ['major'], strategy: ModifyStrategy.absolute, @@ -298,7 +256,7 @@ void main() { test('bumps up minor version', () { const expectedBumpedVersion = '10.11.10-prerelease+21'; - final bumpedVersion = MagicalSEMVER.bumpVersion( + final bumpedVersion = bumpVersion( version, versionTargets: ['minor'], strategy: ModifyStrategy.absolute, @@ -310,7 +268,7 @@ void main() { test('bumps up patch version', () { const expectedBumpedVersion = '10.10.11-prerelease+21'; - final bumpedVersion = MagicalSEMVER.bumpVersion( + final bumpedVersion = bumpVersion( version, versionTargets: ['patch'], strategy: ModifyStrategy.absolute, @@ -322,7 +280,7 @@ void main() { test('bumps up build number', () { const expectedBumpedVersion = '10.10.10-prerelease+22'; - final bumpedVersion = MagicalSEMVER.bumpVersion( + final bumpedVersion = bumpVersion( version, versionTargets: ['build-number'], strategy: ModifyStrategy.absolute, @@ -332,7 +290,7 @@ void main() { }); test('ignores custom build numbers', () { - final bumpedVersion = MagicalSEMVER.bumpVersion( + final bumpedVersion = bumpVersion( versionWithCustomBuild, versionTargets: ['build-number'], strategy: ModifyStrategy.absolute, diff --git a/test/src/unit_tests/extensions/map_extension_test.dart b/test/src/unit_tests/extensions/map_extension_test.dart new file mode 100644 index 0000000..505a8d9 --- /dev/null +++ b/test/src/unit_tests/extensions/map_extension_test.dart @@ -0,0 +1,623 @@ +import 'package:magical_version_bump/src/core/yaml_transformers/data/pair_definition/custom_pair_type.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/yaml_transformer.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; +import 'package:magical_version_bump/src/utils/extensions/map_extensions.dart'; +import 'package:magical_version_bump/src/utils/typedefs/typedefs.dart'; +import 'package:test/test.dart'; + +import '../../../helpers/helpers.dart'; + +void main() { + // Map with test values + final mappy = { + 'key': 'value', + 'deep key': { + 'deeper key': { + 'deepest key': { + 'absolute deep key': 'value', + 'another key': ['value'], + }, + }, + }, + 'key-with-list': [ + 'one value', + {'nested key': 'value'}, + ['value-in-list'], + ], + }; + + group('recursively reads value', () { + test('when key is at root', () { + const targetKey = 'key'; + + final valueAtKey = mappy.recursiveRead( + path: [], + target: targetKey, + ); + + expect(valueAtKey, equals('value')); + }); + + test('when key is deeply nested', () { + final keys = ['deep key', 'deeper key', 'deepest key']; + const targetKey = 'absolute deep key'; + + final valueAtKey = mappy.recursiveRead( + path: keys, + target: targetKey, + ); + + expect(valueAtKey, equals('value')); + }); + + test('when key is in map nested in a list', () { + final keys = ['key-with-list']; + const targetKey = 'nested key'; + + final valueAtKey = mappy.recursiveRead( + path: keys, + target: targetKey, + ); + + expect(valueAtKey, equals('value')); + }); + }); + + group('nested update appends', () { + test( + 'string to deepest key/value pair and converts value to list', + () { + final localMap = {...mappy}; + + final keys = ['deep key', 'deeper key', 'deepest key']; + const targetKey = 'absolute deep key'; + const update = 'another value'; + + final updatedMap = localMap.recursivelyUpdate( + update, + target: targetKey, + path: keys, + updateMode: UpdateMode.append, + ); + + final valueAtKey = updatedMap.recursiveRead>( + path: keys, + target: targetKey, + ); + + expect(valueAtKey, equals(['value', update])); + }, + ); + + test( + 'list of values to deepest key/value pair and converts value to list', + () { + final localMap = {...mappy}; + + final keys = ['deep key', 'deeper key', 'deepest key']; + const targetKey = 'another key'; + const update = ['another value', 'double other value']; + + final updatedMap = localMap.recursivelyUpdate( + update, + target: targetKey, + path: keys, + updateMode: UpdateMode.append, + ); + + final valueAtKey = updatedMap.recursiveRead>( + path: keys, + target: targetKey, + ); + + expect(valueAtKey, equals(['value', ...update])); + }, + ); + + test( + 'adds string to deepest key whose value is a list', + () { + final localMap = {...mappy}; + + final keys = ['deep key', 'deeper key', 'deepest key']; + const targetKey = 'another key'; + const update = 'another value'; + + final updatedMap = localMap.recursivelyUpdate( + update, + target: targetKey, + path: keys, + updateMode: UpdateMode.append, + ); + + final valueAtKey = updatedMap.recursiveRead>( + path: keys, + target: targetKey, + ); + + expect(valueAtKey, equals(['value', update])); + }, + ); + + test( + 'adds list of values to deepest key whose value is a list', + () { + final localMap = {...mappy}; + + final keys = ['deep key', 'deeper key', 'deepest key']; + const targetKey = 'another key'; + const update = ['another value', 'double other value']; + + final updatedMap = localMap.recursivelyUpdate( + update, + target: targetKey, + path: keys, + updateMode: UpdateMode.append, + ); + + final valueAtKey = updatedMap.recursiveRead>( + path: keys, + target: targetKey, + ); + + expect(valueAtKey, equals(['value', ...update])); + }, + ); + + test( + 'adds map of values to deepest key whose value is a map', + () { + final localMap = {...mappy}; + + final keys = ['deep key', 'deeper key']; + const targetKey = 'deepest key'; + const update = {'another value': 'double other value'}; + + final updatedMap = localMap.recursivelyUpdate( + update, + target: targetKey, + path: keys, + updateMode: UpdateMode.append, + ); + + final valueAtKey = updatedMap.recursiveRead>( + path: keys, + target: targetKey, + ); + + expect( + valueAtKey, + equals({ + 'absolute deep key': 'value', + 'another key': ['value'], + ...update, + }), + ); + }, + ); + + test('a value when key is in a map nested in a list', () { + final localMap = {...mappy}; + + final keys = ['key-with-list']; + const targetKey = 'nested key'; + const update = 'another value'; + + final updatedMap = localMap.recursivelyUpdate( + update, + target: targetKey, + path: keys, + updateMode: UpdateMode.append, + ); + + final valueAtKey = updatedMap.recursiveRead>( + path: keys, + target: targetKey, + ); + + expect(valueAtKey, equals(['value', update])); + }); + + test( + 'a map to a key with only a string, converts it to list', + () { + final localMap = {...mappy}; + + const targetKey = 'key'; + const update = {'test': 'works'}; + + final updatedMap = localMap.recursivelyUpdate( + update, + target: targetKey, + path: [], + updateMode: UpdateMode.append, + ); + + final valueAtKey = updatedMap.recursiveRead>( + path: [], + target: targetKey, + ); + + expect(valueAtKey, equals(['value', update])); + }, + ); + + test( + 'when appending a string or list of values to a map of values', + () { + final localMap = {...mappy}; + + const targetKey = 'deep key'; + const update = 'test'; + + final valueBefore = localMap[targetKey]; + + final updatedMap = localMap.recursivelyUpdate( + update, + target: targetKey, + path: [], + updateMode: UpdateMode.append, + ); + + final valueAtKey = updatedMap.recursiveRead>( + path: [], + target: targetKey, + ); + + expect(valueAtKey, equals([valueBefore, update])); + }, + ); + }); + + group('nested update overwrites', () { + test( + 'deepest key/value pair and converts value to another string', + () { + final localMap = {...mappy}; + + final keys = ['deep key', 'deeper key', 'deepest key']; + const targetKey = 'absolute deep key'; + const update = 'another value'; + + final updatedMap = localMap.recursivelyUpdate( + update, + target: targetKey, + path: keys, + updateMode: UpdateMode.overwrite, + ); + + final valueAtKey = updatedMap.recursiveRead( + path: keys, + target: targetKey, + ); + + expect(valueAtKey, equals(update)); + }, + ); + + test( + 'deepest key/value pair and converts value to a list of values', + () { + final localMap = {...mappy}; + + final keys = ['deep key', 'deeper key', 'deepest key']; + const targetKey = 'absolute deep key'; + const update = ['another value', 'double other value']; + + final updatedMap = localMap.recursivelyUpdate( + update, + target: targetKey, + path: keys, + updateMode: UpdateMode.overwrite, + ); + + final valueAtKey = updatedMap.recursiveRead>( + path: keys, + target: targetKey, + ); + + expect(valueAtKey, equals(update)); + }, + ); + + test( + 'deepest key/value pair and converts value to a key/value pair', + () { + final localMap = {...mappy}; + + final keys = ['deep key', 'deeper key']; + const targetKey = 'deepest key'; + const update = {'another value': 'double other value'}; + + final updatedMap = localMap.recursivelyUpdate( + update, + target: targetKey, + path: keys, + updateMode: UpdateMode.overwrite, + ); + + final valueAtKey = updatedMap.recursiveRead>( + path: keys, + target: targetKey, + ); + + expect(valueAtKey, equals(update)); + }, + ); + + test('when key is in a map nested in a list', () { + final localMap = {...mappy}; + + final keys = ['key-with-list']; + const targetKey = 'nested key'; + const update = 'another value'; + + final updatedMap = localMap.recursivelyUpdate( + update, + target: targetKey, + path: keys, + updateMode: UpdateMode.overwrite, + ); + + final valueAtKey = updatedMap.recursiveRead( + path: keys, + target: targetKey, + ); + + expect(valueAtKey, equals(update)); + }); + }); + + group('nested update adds missing key', () { + test('when at root', () { + final localMap = {...mappy}; + + final missingRootKeys = ['missing root']; + const missingTargetkey = 'missing target'; + const update = 'update'; + + final updatedMap = localMap.recursivelyUpdate( + update, + target: missingTargetkey, + path: missingRootKeys, + updateMode: UpdateMode.append, + ); + + final valueAtKey = updatedMap.recursiveRead>( + path: missingRootKeys, + target: missingTargetkey, + ); + + expect(valueAtKey, equals([update])); + }); + + test('when nested', () { + final localMap = {...mappy}; + + final rootKeys = ['deep key']; + const missingTargetkey = 'missing target'; + const update = 'update'; + + final updatedMap = localMap.recursivelyUpdate( + update, + target: missingTargetkey, + path: rootKeys, + updateMode: UpdateMode.append, + ); + + final valueAtKey = updatedMap.recursiveRead>( + path: rootKeys, + target: missingTargetkey, + ); + + expect(valueAtKey, equals([update])); + }); + }); + + group('nested update replace', () { + test('renames list of keys in path', () { + final localMap = { + 'deeper key': { + 'deepest key': { + 'absolute deep key': 'value', + 'another key': ['value'], + }, + }, + }; + + final replacements = { + 'deeper key': 'updated key', + 'another key': 'final update', + }; + + // We don't care about the value + final nodeData = NodeData.stringSkeleton( + path: const ['deeper key', 'deepest key'], + key: 'another key', + value: '', + ); + + final expectedMap = { + 'updated key': { + 'deepest key': { + 'absolute deep key': 'value', + 'final update': ['value'], + }, + }, + }; + + final updatedMap = localMap.updateIndexedMap( + null, + target: nodeData.key, + path: nodeData.precedingKeys, + keyAndReplacement: replacements, + value: null, + ); + + expect(updatedMap, equals(expectedMap)); + }); + + test('renames key when nested in a list', () { + final localMap = { + 'key-with-list': [ + 'one value', + {'nested key': 'value'}, + ['value-in-list'], + ], + }; + + final replacements = { + 'nested key': 'updated key', + }; + + final target = createPair(value: 'nested key', indices: [1]); + final path = ['key-with-list'] + .map( + (e) => createPair(value: e), + ) + .toList(); + + final expectedMap = { + 'key-with-list': [ + 'one value', + {'updated key': 'value'}, + ['value-in-list'], + ], + }; + + final updatedMap = localMap.updateIndexedMap( + null, + target: target, + path: path, + keyAndReplacement: replacements, + value: null, + ); + + expect(collectionsMatch(expectedMap, updatedMap), true); + }); + + test('replaces value', () { + final localMap = { + 'deep key': { + 'deeper key': 'value', + }, + }; + + final target = createPair(value: 'deeper key'); + final path = createListOfPair(values: ['deep key'], indices: {}); + final value = createPair(value: 'value', indices: [0]); + + final updatedMap = localMap.updateIndexedMap( + 'update', + target: target, + path: path, + keyAndReplacement: {}, + value: value, + ); + + final expectedMap = { + 'deep key': { + 'deeper key': 'update', + }, + }; + + expect(collectionsMatch(expectedMap, updatedMap), true); + }); + + test('replaces value nested in list', () { + final localMap = { + 'deep key': { + 'deeper key': [ + 'one value', + {'nested key': 'value'}, + ['value-in-list'], + ], + }, + }; + + final target = createPair(value: 'deeper key'); + final path = createListOfPair(values: ['deep key'], indices: {}); + final value = createPair(value: 'one value', indices: [0]); + + final updatedMap = localMap.updateIndexedMap( + 'update', + target: target, + path: path, + keyAndReplacement: {}, + value: value, + ); + + final expectedMap = { + 'deep key': { + 'deeper key': [ + 'update', + {'nested key': 'value'}, + ['value-in-list'], + ], + }, + }; + + expect(collectionsMatch(expectedMap, updatedMap), true); + }); + + test('replaces value in list nested in another list', () { + final localMap = { + 'deep key': { + 'deeper key': [ + 'one value', + {'nested key': 'value'}, + ['value-in-list'], + ], + }, + }; + + final target = createPair(value: 'deeper key'); + final path = createListOfPair(values: ['deep key'], indices: {}); + final value = createPair(value: 'value-in-list', indices: [2, 0]); + + final updatedMap = localMap.updateIndexedMap( + 'update', + target: target, + path: path, + keyAndReplacement: {}, + value: value, + ); + + final expectedMap = { + 'deep key': { + 'deeper key': [ + 'one value', + {'nested key': 'value'}, + ['update'], + ], + }, + }; + + expect(collectionsMatch(expectedMap, updatedMap), true); + }); + }); + + group('nested update throws exception', () { + test( + '''when path is not exhausted and value encountered at path key is not a map''', + () { + final localMap = {...mappy}; + + const targetKey = 'missing key'; + const update = {'test': 'error'}; + + expect( + () => localMap.recursivelyUpdate( + update, + target: targetKey, + path: ['key'], + updateMode: UpdateMode.append, + ), + throwsCustomException( + '''Cannot append new values due to an existing value at "key". You need to overwrite this path key.''', + ), + ); + }, + ); + }); +} diff --git a/test/src/unit_tests/handlers/file_handler_test.dart b/test/src/unit_tests/handlers/file_handler_test.dart new file mode 100644 index 0000000..b3e0eb6 --- /dev/null +++ b/test/src/unit_tests/handlers/file_handler_test.dart @@ -0,0 +1,150 @@ +import 'package:magical_version_bump/src/core/handlers/file_handler/file_handler.dart'; +import 'package:magical_version_bump/src/utils/typedefs/typedefs.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +import '../../../helpers/helpers.dart'; + +class _MockLogger extends Mock implements Logger {} + +class _MockProgress extends Mock implements Progress {} + +class _FakeFileHandler extends FileHandler { + _FakeFileHandler(); + + factory _FakeFileHandler.forTest({ + required bool requestPath, + required String path, + required Logger logger, + }) { + final handler = _FakeFileHandler() + ..requestPath = requestPath + ..fileLogger = logger; + if (!requestPath) handler.files = getFileTypes([path]); + return handler; + } + + _FakeFileHandler copyWith({ + String? path, + bool? requestPath, + }) { + return _FakeFileHandler.forTest( + path: path ?? getPath(0), + requestPath: requestPath ?? this.requestPath, + logger: fileLogger, + ); + } + + String getPath(int index) => getAllPaths()[index]; + + List getAllPaths() => files.keys.toList(); +} + +void main() { + late Logger logger; + late _FakeFileHandler handler; + + final testpath = getTestFile(); + const defaultPath = 'pubspec.yaml'; + const wrongPath = 'pubspec'; + + setUp(() { + logger = _MockLogger(); + handler = _FakeFileHandler.forTest( + requestPath: false, + path: defaultPath, + logger: logger, + ); + + when(() => logger.progress(any())).thenReturn(_MockProgress()); + }); + + group('handle file test', () { + test('reads pubspec.yaml file from path', () async { + final data = await handler.readFile(); + + verify(() => logger.progress('Reading file')).called(1); + + expect(data, isA()); + expect(handler.getPath(0), defaultPath); + }); + + test('reads pubspec.yaml file from path set by user', () async { + handler = handler.copyWith(path: 'fake.yaml'); + final data = await handler.readFile(); + + verify(() => logger.progress('Reading file')).called(1); + + expect(data, isA()); + expect(handler.getPath(0), 'fake.yaml'); + }); + + test( + 'reads pubspec.yaml file from path provided by user in prompt', + () async { + handler = handler.copyWith(requestPath: true); + + when( + () => logger.prompt( + 'Please enter the path to file: ', + defaultValue: any( + named: 'defaultValue', + ), + ), + ).thenReturn(testpath); + + final data = await handler.readFile(); + + verify(() => logger.progress('Reading file')).called(1); + + expect(data, isA()); + expect(handler.getPath(0), testpath); + }, + ); + + test( + 'read multiple files from paths provided by user in prompt', + () async { + handler = handler.copyWith(requestPath: true); + + final multiPaths = [defaultPath, testpath]; + + when( + () => logger.prompt( + 'Please enter all paths to files (use comma to separate): ', + defaultValue: any( + named: 'defaultValue', + ), + ), + ).thenReturn(multiPaths.join(',')); + + final data = await handler.readAll(multiple: true); + + verify(() => logger.progress('Reading files')).called(1); + + expect(data, isA>()); + expect(handler.getAllPaths(), equals(multiPaths)); + }, + ); + + test('throws error if path provided is not absolute', () async { + handler = handler.copyWith(requestPath: true); + + when( + () => logger.prompt( + 'Please enter the path to file: ', + defaultValue: any( + named: 'defaultValue', + ), + ), + ).thenReturn(wrongPath); + + final data = handler.readFile(); + + verify(() => logger.progress('Reading file')).called(1); + + expect(() async => data, throwsA(isException)); + }); + }); +} diff --git a/test/src/unit_tests/mixins/handle_file_mixin_test.dart b/test/src/unit_tests/mixins/handle_file_mixin_test.dart deleted file mode 100644 index 9580bea..0000000 --- a/test/src/unit_tests/mixins/handle_file_mixin_test.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'package:magical_version_bump/src/utils/mixins/command_mixins.dart'; -import 'package:mason_logger/mason_logger.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:test/test.dart'; - -import '../../../helpers/helpers.dart'; - -class _MockLogger extends Mock implements Logger {} - -class _MockProgress extends Mock implements Progress {} - -class _FakeFileHandler with HandleFile {} - -void main() { - late Logger logger; - late _FakeFileHandler handler; - - final testpath = getTestFile(); - const defaultPath = 'pubspec.yaml'; - const wrongPath = 'pubspec'; - - setUp(() { - logger = _MockLogger(); - handler = _FakeFileHandler(); - - when(() => logger.progress(any())).thenReturn(_MockProgress()); - }); - - group('handle file mixin test', () { - test('reads pubspec.yaml file from path', () async { - final data = await handler.readFile( - logger: logger, - requestPath: false, - setPath: defaultPath, - ); - - verify(() => logger.progress('Reading file')).called(1); - - expect(data.path, defaultPath); - }); - - test('reads pubspec.yaml file from path set by user', () async { - final data = await handler.readFile( - logger: logger, - requestPath: false, - setPath: testpath, - ); - - verify(() => logger.progress('Reading file')).called(1); - - expect(data.path, testpath); - }); - - test( - 'reads pubspec.yaml file from path provided by user in prompt', - () async { - when( - () => logger.prompt( - 'Please enter the path to file:', - defaultValue: any( - named: 'defaultValue', - ), - ), - ).thenReturn(testpath); - - final data = await handler.readFile( - requestPath: true, - logger: logger, - setPath: '', - ); - - verify(() => logger.progress('Reading file')).called(1); - - expect(data.path, testpath); - }, - ); - - test('throws error if path provided is not absolute', () async { - when( - () => logger.prompt( - 'Please enter the path to file:', - defaultValue: any( - named: 'defaultValue', - ), - ), - ).thenReturn(wrongPath); - - final data = handler.readFile( - requestPath: true, - logger: logger, - setPath: '', - ); - - verify(() => logger.progress('Reading file')).called(1); - - expect(() async => data, throwsA(isException)); - }); - }); -} diff --git a/test/src/unit_tests/mixins/modify_yaml_mixin_test.dart b/test/src/unit_tests/mixins/modify_yaml_mixin_test.dart index 151f76f..808b6f4 100644 --- a/test/src/unit_tests/mixins/modify_yaml_mixin_test.dart +++ b/test/src/unit_tests/mixins/modify_yaml_mixin_test.dart @@ -1,4 +1,6 @@ +import 'package:magical_version_bump/src/utils/enums/enums.dart'; import 'package:magical_version_bump/src/utils/mixins/command_mixins.dart'; +import 'package:magical_version_bump/src/utils/typedefs/typedefs.dart'; import 'package:test/test.dart'; import 'package:yaml/yaml.dart'; @@ -8,7 +10,7 @@ class _FakeYamlModifier with ModifyYaml {} void main() { late _FakeYamlModifier modifier; - late YamlMap testYamlMap; + late FileOutput yamlOutput; const version = '11.11.11'; @@ -25,613 +27,45 @@ void main() { value: another value '''; - // Map with test values - final mappy = { - 'key': 'value', - 'deep key': { - 'deeper key': { - 'deepest key': { - 'deeper deepest key': 'value', - 'another deepest key': ['value'], - 'other depeest key': { - 'absolute deep key': 'value', - }, - }, - }, - }, - }; - setUp(() { modifier = _FakeYamlModifier(); - testYamlMap = YamlMap.wrap(mappy); - }); - - group('nested update appends', () { - test( - 'string to deepest key/value pair and converts value to list', - () { - final keys = ['deep key', 'deeper key', 'deepest key']; - const targetKey = 'deeper deepest key'; - const update = 'another value'; - - final updatedValue = modifier.updateNestedTarget( - keys: keys, - yamlMap: testYamlMap, - targetKey: targetKey, - update: update, - append: true, - ); - - final expectedValue = { - targetKey: ['value', update], - }; - - expect(updatedValue.failed, false); - expect(updatedValue.failedReason, isNull); - expect(updatedValue.finalDepth, 0); - expect(updatedValue.updatedValue, equals(expectedValue)); - }, - ); - - test( - 'list of values to deepest key/value pair and converts value to list', - () { - final keys = ['deep key', 'deeper key', 'deepest key']; - const targetKey = 'deeper deepest key'; - const update = ['another value', 'double other value']; - - final updatedValue = modifier.updateNestedTarget( - keys: keys, - yamlMap: testYamlMap, - targetKey: targetKey, - update: update, - append: true, - ); - - final expectedValue = { - targetKey: ['value', ...update], - }; - - expect(updatedValue.failed, false); - expect(updatedValue.failedReason, isNull); - expect(updatedValue.finalDepth, 0); - expect(updatedValue.updatedValue, equals(expectedValue)); - }, - ); - - test( - 'adds string to deepest key whose value is a list', - () { - final keys = ['deep key', 'deeper key', 'deepest key']; - const targetKey = 'another deepest key'; - const update = 'another value'; - - final updatedValue = modifier.updateNestedTarget( - keys: keys, - yamlMap: testYamlMap, - targetKey: targetKey, - update: update, - append: true, - ); - - final expectedValue = { - targetKey: ['value', update], - }; - - expect(updatedValue.failed, false); - expect(updatedValue.failedReason, isNull); - expect(updatedValue.finalDepth, 0); - expect(updatedValue.updatedValue, equals(expectedValue)); - }, - ); - - test( - 'adds list of values to deepest key whose value is a list', - () { - final keys = ['deep key', 'deeper key', 'deepest key']; - const targetKey = 'another deepest key'; - const update = ['another value', 'double other value']; - - final updatedValue = modifier.updateNestedTarget( - keys: keys, - yamlMap: testYamlMap, - targetKey: targetKey, - update: update, - append: true, - ); - - final expectedValue = { - targetKey: ['value', ...update], - }; - - expect(updatedValue.failed, false); - expect(updatedValue.failedReason, isNull); - expect(updatedValue.finalDepth, 0); - expect(updatedValue.updatedValue, equals(expectedValue)); - }, - ); - - test( - 'adds map of values to deepest key whose value is a map', - () { - final keys = ['deep key', 'deeper key', 'deepest key']; - const targetKey = 'other depeest key'; - const update = {'another value': 'double other value'}; - - final updatedValue = modifier.updateNestedTarget( - keys: keys, - yamlMap: testYamlMap, - targetKey: targetKey, - update: update, - append: true, - ); - - final expectedValue = { - targetKey: { - 'absolute deep key': 'value', - ...update, - }, - }; - - expect(updatedValue.failed, false); - expect(updatedValue.failedReason, isNull); - expect(updatedValue.finalDepth, 0); - expect(updatedValue.updatedValue, equals(expectedValue)); - }, - ); - }); - - group('nested update overwrites', () { - test( - 'deepest key/value pair and converts value to string', - () { - final keys = ['deep key', 'deeper key', 'deepest key']; - const targetKey = 'deeper deepest key'; - const update = 'another value'; - - final updatedValue = modifier.updateNestedTarget( - keys: keys, - yamlMap: testYamlMap, - targetKey: targetKey, - update: update, - append: false, - ); - - final expectedValue = { - targetKey: 'another value', - }; - - expect(updatedValue.failed, false); - expect(updatedValue.failedReason, isNull); - expect(updatedValue.finalDepth, 0); - expect(updatedValue.updatedValue, equals(expectedValue)); - }, - ); - - test( - 'deepest key/value pair and converts value to list of values', - () { - final keys = ['deep key', 'deeper key', 'deepest key']; - const targetKey = 'deeper deepest key'; - const update = ['another value', 'double other value']; - - final updatedValue = modifier.updateNestedTarget( - keys: keys, - yamlMap: testYamlMap, - targetKey: targetKey, - update: update, - append: false, - ); - - final expectedValue = { - targetKey: update, - }; - - expect(updatedValue.failed, false); - expect(updatedValue.failedReason, isNull); - expect(updatedValue.finalDepth, 0); - expect(updatedValue.updatedValue, equals(expectedValue)); - }, - ); - - test( - 'deepest key/value pair and converts value to map of values', - () { - final keys = ['deep key', 'deeper key', 'deepest key']; - const targetKey = 'deeper deepest key'; - const update = {'another value': 'double other value'}; - - final updatedValue = modifier.updateNestedTarget( - keys: keys, - yamlMap: testYamlMap, - targetKey: targetKey, - update: update, - append: false, - ); - - final expectedValue = { - targetKey: update, - }; - - expect(updatedValue.failed, false); - expect(updatedValue.failedReason, isNull); - expect(updatedValue.finalDepth, 0); - expect(updatedValue.updatedValue, equals(expectedValue)); - }, - ); - }); - - group('nested update terminates', () { - test('when key is missing at root', () { - final keys = ['missing root key', 'deeper key']; - const targetKey = 'deeper deepest key'; - const update = 'another value'; - - final updatedValue = modifier.updateNestedTarget( - keys: keys, - yamlMap: testYamlMap, - targetKey: targetKey, - update: update, - append: true, - ); - - expect(updatedValue.failed, false); - expect(updatedValue.failedReason, null); - expect(updatedValue.finalDepth, 2); - expect(updatedValue.updatedValue, null); - }); - - test('when nested key is missing', () { - final keys = ['deep key', 'missing root key']; - const targetKey = 'deeper deepest key'; - const update = 'another value'; - - final updatedValue = modifier.updateNestedTarget( - keys: keys, - yamlMap: testYamlMap, - targetKey: targetKey, - update: update, - append: true, - ); - - expect(updatedValue.failed, false); - expect(updatedValue.failedReason, null); - expect(updatedValue.finalDepth, 2); - expect(updatedValue.updatedValue, null); - }); - - test('when target key is missing', () { - final keys = ['deep key', 'deeper key']; - const targetKey = 'missing key'; - const update = 'another value'; - - final updatedValue = modifier.updateNestedTarget( - keys: keys, - yamlMap: testYamlMap, - targetKey: targetKey, - update: update, - append: true, - ); - - expect(updatedValue.failed, false); - expect(updatedValue.failedReason, null); - expect(updatedValue.finalDepth, 0); - expect(updatedValue.updatedValue, null); - }); - - test( - 'when target key is missing but current root key will be overwritten', - () { - final keys = ['key']; - const targetKey = 'missing key'; - const update = 'another value'; - - final updatedValue = modifier.updateNestedTarget( - keys: keys, - yamlMap: testYamlMap, - targetKey: targetKey, - update: update, - append: false, - ); - - expect(updatedValue.failed, false); - expect(updatedValue.failedReason, isNull); - expect(updatedValue.finalDepth, 0); - expect(updatedValue.updatedValue, null); - }, - ); - - test( - 'and fails to append to a nested target key if root key is not a map', - () { - final keys = ['key']; - const targetKey = 'missing key'; - const update = 'another value'; - - final updatedValue = modifier.updateNestedTarget( - keys: keys, - yamlMap: testYamlMap, - targetKey: targetKey, - update: update, - append: true, - ); - - expect(updatedValue.failed, true); - expect(updatedValue.failedReason, 'Cannot append at ${keys.first}'); - expect(updatedValue.finalDepth, 1); - expect(updatedValue.updatedValue, null); - }, - ); - - test('and fails to append map to string/list of values', () { - final keys = ['deep key', 'deeper key', 'deepest key']; - const targetKey = 'deeper deepest key'; - const update = {'another value': 'double other value'}; - - final updatedValue = modifier.updateNestedTarget( - keys: keys, - yamlMap: testYamlMap, - targetKey: targetKey, - update: update, - append: true, - ); - - expect(updatedValue.failed, true); - expect( - updatedValue.failedReason, - 'Cannot append new values at $targetKey', - ); - expect(updatedValue.finalDepth, 0); - expect(updatedValue.updatedValue, isNull); - }); - - test('and fails to append string to map', () { - final keys = ['deep key', 'deeper key', 'deepest key']; - const targetKey = 'other depeest key'; - const update = 'append value'; - - final updatedValue = modifier.updateNestedTarget( - keys: keys, - yamlMap: testYamlMap, - targetKey: targetKey, - update: update, - append: true, - ); - - expect(updatedValue.failed, true); - expect( - updatedValue.failedReason, - 'Cannot append new mapped values at $targetKey', - ); - expect(updatedValue.finalDepth, 0); - expect(updatedValue.updatedValue, isNull); - }); - - test('and fails to append list of values to map', () { - final keys = ['deep key', 'deeper key', 'deepest key']; - const targetKey = 'other depeest key'; - const update = ['append value']; - - final updatedValue = modifier.updateNestedTarget( - keys: keys, - yamlMap: testYamlMap, - targetKey: targetKey, - update: update, - append: true, - ); - - expect(updatedValue.failed, true); - expect( - updatedValue.failedReason, - 'Cannot append new mapped values at $targetKey', - ); - expect(updatedValue.finalDepth, 0); - expect(updatedValue.updatedValue, isNull); - }); - }); - - group('converts unused keys to dart map', () { - test('for one key', () { - final map = modifier.convertToDartMap( - YamlMap.wrap({}), - append: false, - pathKeys: [], - missingKeys: ['one key'], - fallbackData: {}, - updatedData: {'targetKey': 'data'}, - ); - - final expectedMap = { - 'one key': { - 'targetKey': 'data', - }, - }; - - expect(map, equals(expectedMap)); - }); - - test('for multiple keys', () { - final map = modifier.convertToDartMap( - YamlMap.wrap({}), - append: false, - pathKeys: [], - missingKeys: ['one key', 'two key', 'three key'], - fallbackData: {}, - updatedData: {'targetKey': 'data'}, - ); - - final expectedMap = { - 'one key': { - 'two key': { - 'three key': { - 'targetKey': 'data', - }, - }, - }, - }; - - expect(map, equals(expectedMap)); - }); - }); - - group('formats output correctly', () { - test('when recursive function reached 0 depth', () { - final output = ( - failed: false, - failedReason: null, - finalDepth: 0, - updatedValue: { - 'value': 'updated', - }, - ); - - final formattedOutput = modifier.formatOutput( - YamlMap.wrap({}), - append: false, - rootKeys: ['test'], - fallbackData: {'target': 'data'}, - output: output, - ); - - final expectedDataToSave = output.updatedValue; - - expect(formattedOutput.path, equals(['test'])); - expect(formattedOutput.dataToSave, expectedDataToSave); - }); - - test('when recursive function reached 0 depth but key was missing', () { - const output = ( - failed: false, - failedReason: null, - finalDepth: 0, - updatedValue: null, - ); - - final formattedOutput = modifier.formatOutput( - YamlMap.wrap({}), - append: false, - rootKeys: ['test'], - fallbackData: {'target': 'data'}, - output: output, - ); - - final expectedDataToSave = {'target': 'data'}; - - expect(formattedOutput.path, equals(['test'])); - expect(formattedOutput.dataToSave, expectedDataToSave); - }); - - test('when the first and only root key was missing', () { - const output = ( - failed: false, - failedReason: null, - finalDepth: 1, - updatedValue: null, - ); - - final formattedOutput = modifier.formatOutput( - YamlMap.wrap({}), - append: false, - rootKeys: ['test'], - fallbackData: {'target': 'data'}, - output: output, - ); - - final expectedDataToSave = {'target': 'data'}; - - expect(formattedOutput.path, equals(['test'])); - expect(formattedOutput.dataToSave, expectedDataToSave); - }); - - test('when the first root key was missing in a list of keys', () { - const output = ( - failed: false, - failedReason: null, - finalDepth: 1, - updatedValue: null, - ); - - final formattedOutput = modifier.formatOutput( - YamlMap.wrap({}), - append: false, - rootKeys: ['test', 'other test key'], - fallbackData: {'target': 'data'}, - output: output, - ); - - final expectedDataToSave = { - 'other test key': {'target': 'data'}, - }; - - expect(formattedOutput.path, equals(['test'])); - expect(formattedOutput.dataToSave, expectedDataToSave); - }); - - test('when the missing root key is not at index 0', () { - const output = ( - failed: false, - failedReason: null, - finalDepth: 3, - updatedValue: null, - ); - - final formattedOutput = modifier.formatOutput( - YamlMap.wrap({}), - append: false, - rootKeys: [ - 'test', - 'other test key', - 'another key', - 'another test key', - ], - fallbackData: {'target': 'data'}, - output: output, - ); - - final expectedDataToSave = { - 'other test key': { - 'another key': { - 'another test key': {'target': 'data'}, - }, - }, - }; - - expect(formattedOutput.path, equals(['test'])); - expect(formattedOutput.dataToSave, expectedDataToSave); - }); + yamlOutput = (file: fakeYaml, fileAsMap: loadYaml(fakeYaml)); }); group('updates', () { test('key at root', () async { final dictionary = ( - append: false, + updateMode: UpdateMode.overwrite, rootKeys: ['version'], data: '10.10.10+10', ); final updatedFile = await modifier.updateYamlFile( - fakeYaml, + yamlOutput, dictionary: dictionary, ); - final updateValue = await readNestedNodes(updatedFile, ['version']); + final updateValue = await readNestedNodes( + updatedFile, + ['version'], + ); expect(updateValue, '10.10.10+10'); }); test('creates missing root key', () async { final dictionary = ( - append: false, + updateMode: UpdateMode.overwrite, rootKeys: ['name', 'test name'], data: 'Test One, Two, Three', ); final updatedFile = await modifier.updateYamlFile( - fakeYaml, + yamlOutput, dictionary: dictionary, ); - final updateValue = await readNestedNodes( + final updateValue = await readNestedNodes( updatedFile, ['name', 'test name'], ); @@ -641,17 +75,17 @@ void main() { test('overwrites existing key with new values', () async { final dictionary = ( - append: false, + updateMode: UpdateMode.overwrite, rootKeys: ['test', 'nested-test'], data: 'Test One, Two, Three', ); final updatedFile = await modifier.updateYamlFile( - fakeYaml, + yamlOutput, dictionary: dictionary, ); - final updateValue = await readNestedNodes( + final updateValue = await readNestedNodes( updatedFile, ['test', 'nested-test'], ); @@ -661,17 +95,17 @@ void main() { test('appends value to existing key with one value', () async { final dictionary = ( - append: true, + updateMode: UpdateMode.append, rootKeys: ['test', 'nested-test', 'nested-value'], data: 'Test One, Two, Three', ); final updatedFile = await modifier.updateYamlFile( - fakeYaml, + yamlOutput, dictionary: dictionary, ); - final updateValue = await readNestedNodes( + final updateValue = await readNestedNodes( updatedFile, ['test', 'nested-test', 'nested-value'], ); @@ -681,17 +115,17 @@ void main() { test('appends value to existing key with list of values', () async { final dictionary = ( - append: true, + updateMode: UpdateMode.append, rootKeys: ['test', 'nested-test', 'nested-list'], data: 'Test One, Two, Three', ); final updatedFile = await modifier.updateYamlFile( - fakeYaml, + yamlOutput, dictionary: dictionary, ); - final updateValue = await readNestedNodes( + final updateValue = await readNestedNodes( updatedFile, ['test', 'nested-test', 'nested-list'], ); @@ -704,7 +138,7 @@ void main() { test('appends map to existing key with map of values', () async { final dictionary = ( - append: true, + updateMode: UpdateMode.append, rootKeys: ['test', 'nested-test', 'nested-map'], data: { 'value': 'another value', @@ -713,11 +147,11 @@ void main() { ); final updatedFile = await modifier.updateYamlFile( - fakeYaml, + yamlOutput, dictionary: dictionary, ); - final updateValue = await readNestedNodes( + final updateValue = await readNestedNodes( updatedFile, ['test', 'nested-test', 'nested-map'], ); diff --git a/test/src/unit_tests/yaml_transformers/finders/value_finder_test.dart b/test/src/unit_tests/yaml_transformers/finders/value_finder_test.dart new file mode 100644 index 0000000..f48e0fe --- /dev/null +++ b/test/src/unit_tests/yaml_transformers/finders/value_finder_test.dart @@ -0,0 +1,320 @@ +import 'package:magical_version_bump/src/core/yaml_transformers/finders/finder.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; +import 'package:test/test.dart'; + +void main() { + final defaultMap = { + 'key': 'value', + 'keyWithList': [ + 'value', + {'key': 'value'}, + ], + 'anotherKey': { + 'key': 'value', + }, + }; + + group('find all matches', () { + group('in keys', () { + test('when OrderType is loose. Any exist', () { + final keysToFind = ( + keys: ['key', 'anotherKey'], + orderType: OrderType.loose, + ); + + final finder = ValueFinder.findInMap( + defaultMap, + saveCounterToHistory: false, + keysToFind: keysToFind, + valuesToFind: null, + pairsToFind: null, + ); + + final matches = finder.findAll().map((e) => e.toString()); + + final expectedMatches = [ + 'key/value', + 'keyWithList/key/value', + 'anotherKey/key/value', + ]; + + final counter = finder.counter!; + + expect(matches, equals(expectedMatches)); + expect(counter.getCount('key', origin: Origin.key), 3); + expect(counter.getCount('anotherKey', origin: Origin.key), 1); + }); + + test('when OrderType is grouped. Both must exist', () { + final keysToFind = ( + keys: ['key', 'anotherKey'], + orderType: OrderType.grouped, + ); + + final finder = ValueFinder.findInMap( + defaultMap, + saveCounterToHistory: false, + keysToFind: keysToFind, + valuesToFind: null, + pairsToFind: null, + ); + + final matches = finder.findAll().map((e) => e.toString()); + + final expectedMatches = [ + 'anotherKey/key/value', + ]; + + final counter = finder.counter!; + + expect(matches, equals(expectedMatches)); + expect(counter.getCount('key', origin: Origin.key), 1); + expect(counter.getCount('anotherKey', origin: Origin.key), 1); + }); + + test('when OrderType is strict. Both must exist in specified order', () { + final keysToFind = ( + keys: ['anotherKey', 'key'], + orderType: OrderType.strict, + ); + + final finder = ValueFinder.findInMap( + defaultMap, + saveCounterToHistory: false, + keysToFind: keysToFind, + valuesToFind: null, + pairsToFind: null, + ); + + final matches = finder.findAll().map((e) => e.toString()); + + final expectedMatches = [ + 'anotherKey/key/value', + ]; + + final counter = finder.counter!; + + expect(matches, equals(expectedMatches)); + expect(counter.getCount('key', origin: Origin.key), 1); + expect(counter.getCount('anotherKey', origin: Origin.key), 1); + }); + }); + + group('in values', () { + test( + 'when at root, nested in key with list or a value to a key in map', + () { + final finder = ValueFinder.findInMap( + defaultMap, + saveCounterToHistory: false, + keysToFind: null, + valuesToFind: ['value'], + pairsToFind: null, + ); + + final matches = finder.findAll().map((e) => e.toString()); + + final expectedMatches = [ + 'key/value', + 'keyWithList/value', + 'keyWithList/key/value', + 'anotherKey/key/value', + ]; + + expect(matches, equals(expectedMatches)); + expect(finder.counter!.getCount('value', origin: Origin.value), 4); + }, + ); + }); + + group('in pairs', () { + test('when only keys are pairs', () { + final pairsToFind = { + 'keyWithList': 'key', + 'anotherKey': 'key', + }; + + final finder = ValueFinder.findInMap( + defaultMap, + saveCounterToHistory: false, + keysToFind: null, + valuesToFind: null, + pairsToFind: pairsToFind, + ); + + final matches = finder.findAll().map((e) => e.toString()); + + final expectedMatches = [ + 'keyWithList/key/value', + 'anotherKey/key/value', + ]; + + final counter = finder.counter!; + + expect(matches, equals(expectedMatches)); + expect( + counter.getCount( + pairsToFind.entries.elementAt(0), + origin: Origin.pair, + ), + 1, + ); + expect( + counter.getCount( + pairsToFind.entries.elementAt(1), + origin: Origin.pair, + ), + 1, + ); + }); + + test('when both keys & values are pairs', () { + // TODO(kekavc24): Consider using list instead of map for pairs + final pairsToFind = { + 'keyWithList': 'key', + 'anotherKey': 'key', + 'key': 'value', + }; + + final finder = ValueFinder.findInMap( + defaultMap, + saveCounterToHistory: false, + keysToFind: null, + valuesToFind: null, + pairsToFind: pairsToFind, + ); + + final matches = finder.findAll().map((e) => e.toString()); + + final expectedMatches = [ + 'key/value', + 'keyWithList/key/value', + 'anotherKey/key/value', + ]; + + final counter = finder.counter!; + + expect(matches, equals(expectedMatches)); + expect( + counter.getCount( + const MapEntry('keyWithList', 'key'), + origin: Origin.pair, + ), + 1, + ); + expect( + counter.getCount( + const MapEntry('anotherKey', 'key'), + origin: Origin.pair, + ), + 1, + ); + expect( + counter.getCount( + const MapEntry('key', 'value'), + origin: Origin.pair, + ), + 3, + ); + }); + }); + }); + + group('find matches by count', () { + test('without applying to each. Plain count', () { + final keysToFind = ( + keys: ['key', 'anotherKey'], + orderType: OrderType.loose, + ); + + final valuesToFind = ['value']; + final pairsToFind = { + 'keyWithList': 'key', + }; + + final finder = ValueFinder.findInMap( + defaultMap, + saveCounterToHistory: false, + keysToFind: keysToFind, + valuesToFind: valuesToFind, + pairsToFind: pairsToFind, + ); + + final matches = finder + .findByCountSync(1, applyToEach: false) + .map((output) => output.data.toString()) + .toList(); + + // Just the first match + final expectedMatches = ['key/value']; + + final counter = finder.counter!; + + expect(matches, expectedMatches); + + // Both 'key' & 'value' were found, even though we wanted just 1 match + expect(counter.getCount('key', origin: Origin.key), 1); + expect(counter.getCount('value', origin: Origin.value), 1); + expect(counter.getCount('anotherKey', origin: Origin.key), 0); + expect( + counter.getCount( + const MapEntry('keyWithList', 'key'), + origin: Origin.pair, + ), + 0, + ); + }); + + test( + 'applies to each. Count must be at least equal to or greater than limit', + () { + final keysToFind = ( + keys: ['key', 'anotherKey'], + orderType: OrderType.loose, + ); + + final valuesToFind = ['value']; + final pairsToFind = { + 'keyWithList': 'key', + }; + + final finder = ValueFinder.findInMap( + defaultMap, + saveCounterToHistory: false, + keysToFind: keysToFind, + valuesToFind: valuesToFind, + pairsToFind: pairsToFind, + ); + + final matches = finder + .findByCountSync(1, applyToEach: true) + .map((output) => output.data.toString()) + .toList(); + + // Just the first match + final expectedMatches = [ + 'key/value', + 'keyWithList/value', + 'keyWithList/key/value', + 'anotherKey/key/value', + ]; + + final counter = finder.counter!; + expect(matches, expectedMatches); + + /// * Each argument has equal chance to reach count of "1". + /// * Some may be found more than once. + expect(counter.getCount('key', origin: Origin.key), 3); + expect(counter.getCount('value', origin: Origin.value), 4); + expect(counter.getCount('anotherKey', origin: Origin.key), 1); + expect( + counter.getCount( + const MapEntry('keyWithList', 'key'), + origin: Origin.pair, + ), + 1, + ); + }, + ); + }); +} diff --git a/test/src/unit_tests/yaml_transformers/formatters/formatter_test.dart b/test/src/unit_tests/yaml_transformers/formatters/formatter_test.dart new file mode 100644 index 0000000..d8c67db --- /dev/null +++ b/test/src/unit_tests/yaml_transformers/formatters/formatter_test.dart @@ -0,0 +1,123 @@ +import 'package:magical_version_bump/src/core/yaml_transformers/formatter/formatter.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/managers/finder_manager/finder_formatter.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/managers/replacer_manager/replacer_formatter.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/trackers/tracker.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/yaml_transformer.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; +import 'package:test/test.dart'; + +void main() { + late FormattedPathInfo pathInfo; + + group('finder formatter', () { + final finderFormatter = FinderFormatter(); + + setUpAll( + () => pathInfo = ( + path: [ + 'root', + 'key', + 'value', + ].map((e) => matchColor.wrap(e)).join('/'), + updatedPath: null, + ), + ); + + final match = MatchedNodeData.fromFinder( + nodeData: NodeData.stringSkeleton( + path: const ['root'], + key: 'key', + value: 'value', + ), + matchedKeys: const ['key'], + matchedValue: 'value', + matchedPairs: const {'root': 'key'}, + ); + + const keys = [ + TrackerKey(key: 'value', origin: Origin.value), + TrackerKey(key: 'key', origin: Origin.key), + DualTrackerKey(key: 'root', otherKey: 'key'), + ]; + + test('extracts keys and path from MatchedNodeData', () { + final extractedInfo = finderFormatter.extractFrom(match); + + expect(extractedInfo.keys, equals(keys)); + expect(extractedInfo.pathInfo, equals(pathInfo)); + }); + + test('extracts and adds all inputs for each file index', () { + finderFormatter.addAll([ + (0, [match]), + (1, [match]), + ]); + + // Force a reset so all values are in history + finderFormatter.tracker.reset(cursor: 1); // Last index is the cursor + + final defaultTrackerState = + , List>{}..addEntries( + keys.map((e) => MapEntry(e, [pathInfo])), + ); + + expect( + finderFormatter.tracker.history, + equals({ + 0: defaultTrackerState, + 1: defaultTrackerState, + }), + ); + }); + }); + + group('replacer formatter', () { + final replacerFormatter = ReplacerFormatter(); + + setUpAll( + () => pathInfo = ( + path: "${replacedColor.wrap('key')}/value", + updatedPath: "${matchColor.wrap('updatedKey')}/value", + ), + ); + + final replacerInput = ( + mapping: {'key': 'updatedKey'}, + oldPath: 'key/value', + origin: Origin.key + ); + + const keys = [ + TrackerKey(key: 'key', origin: Origin.key), + ]; + + test('extracts keys and path from ReplacerManagerOutput', () { + final extractedInfo = replacerFormatter.extractFrom(replacerInput); + + expect(extractedInfo.keys, equals(keys)); + expect(extractedInfo.pathInfo, equals(pathInfo)); + }); + + test('extracts and adds all inputs for each file index', () { + replacerFormatter.addAll([ + (0, [replacerInput]), + (1, [replacerInput]), + ]); + + // Force a reset so all values are in history + replacerFormatter.tracker.reset(cursor: 1); // Last index is the cursor + + final defaultTrackerState = { + keys.first: [pathInfo], + }; + + expect( + replacerFormatter.tracker.history, + equals({ + 0: defaultTrackerState, + 1: defaultTrackerState, + }), + ); + }); + }); +} diff --git a/test/src/unit_tests/yaml_transformers/formatters/formatter_util_test.dart b/test/src/unit_tests/yaml_transformers/formatters/formatter_util_test.dart new file mode 100644 index 0000000..972d99d --- /dev/null +++ b/test/src/unit_tests/yaml_transformers/formatters/formatter_util_test.dart @@ -0,0 +1,259 @@ +import 'package:magical_version_bump/src/core/yaml_transformers/formatter/formatter.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/trackers/tracker.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/yaml_transformer.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:test/test.dart'; + +typedef _ListOfKeys = List>; + +void main() { + group('util extractors based on Origin', () { + test('returns single key when Origin.value', () { + final key = extractKey>( + origin: Origin.value, + value: 'value', + ); + + const expectedKey = TrackerKey.fromValue('value', Origin.value); + + expect(key, equals(expectedKey)); + }); + + test( + 'throws an error when Origin.value and single key is not a string', + () { + expect( + () => extractKey>( + origin: Origin.value, + value: ['value'], + ), + throwsA(isA()), + ); + }, + ); + + test('returns a list of keys when Origin.key', () { + final viableKeys = ['key', 'otherKey']; + + final keys = extractKey<_ListOfKeys>( + origin: Origin.key, + value: viableKeys, + ); + + final expectedKeys = viableKeys.map( + (e) => TrackerKey(key: e, origin: Origin.key), + ); + + expect(keys, equals(expectedKeys)); + }); + + test('returns a list of keys when Origin.pair', () { + final pairs = { + 'key': 'value', + 'otherKey': 'value', + }; + + final keys = extractKey<_ListOfKeys>( + origin: Origin.pair, + value: pairs, + ); + + final expectedKeys = pairs.entries.map( + (e) => DualTrackerKey.fromEntry(entry: e), + ); + + expect(keys, equals(expectedKeys)); + }); + }); + + group('util extractors based on MatchedNodeData', () { + test('returns a list of all possible keys', () { + final node = NodeData.stringSkeleton( + path: const ['root'], + key: 'key', + value: 'value', + ); + + final match = MatchedNodeData.fromFinder( + nodeData: node, + matchedKeys: const ['key'], + matchedValue: 'value', + matchedPairs: const {'root': 'key'}, + ); + + final keys = getKeysFromMatch(match); + + const expectedKeys = [ + TrackerKey.fromValue('value', Origin.value), + TrackerKey.fromValue('key', Origin.key), + DualTrackerKey.fromValue(key: 'root', otherKey: 'key'), + ]; + + expect(keys, equals(expectedKeys)); + }); + }); + + group('util match wrappers', () { + test( + 'wrap matches with green ANSI code for matching values in path', + () { + const path = 'test/matches/in/path'; + + final matches = ['matches', 'path']; + + final expectedPath = + "test/${matchColor.wrap('matches')}/in/${matchColor.wrap('path')}"; + + final wrappedPath = wrapMatches(path: path, matches: matches); + + expect(wrappedPath.path, expectedPath); + }, + ); + + test( + 'wraps keys matched with green ANSI code & replaced keys with red', + () { + const defaultPath = 'key/value'; + + final replacements = {'key': 'updatedKey'}; + + final oldPathWithTarget = "${replacedColor.wrap('key')}/value"; + final updatedPath = "${matchColor.wrap('updatedKey')}/value"; + + final pathInfo = replaceAndWrap( + path: defaultPath, + replacedKeys: true, + replacements: replacements, + ); + + expect(pathInfo.path, equals(oldPathWithTarget)); + expect(pathInfo.updatedPath, equals(updatedPath)); + }, + ); + + test( + 'wraps values matched with green ANSI code & replaced keys with red', + () { + const defaultPath = 'key/value'; + + final replacements = {'value': 'updatedValue'}; + + final oldPathWithTarget = "key/${replacedColor.wrap('value')}"; + final updatedPath = "key/${matchColor.wrap('updatedValue')}"; + + final pathInfo = replaceAndWrap( + path: defaultPath, + replacedKeys: false, + replacements: replacements, + ); + + expect(pathInfo.path, equals(oldPathWithTarget)); + expect(pathInfo.updatedPath, equals(updatedPath)); + }, + ); + }); + + group('util tree-builder', () { + test('returns custom separators based on CharSet', () { + expect(getChildSeparator(), equals(branchColor.wrap('│'))); + expect( + getChildSeparator(charSet: CharSet.ascii), + equals(branchColor.wrap('|')), + ); + }); + + test('returns custom count separator based on CharSet', () { + expect(getCountSeparator(), equals('──')); + expect(getCountSeparator(charSet: CharSet.ascii), '--'); + }); + + test('returns valid branch based on CharSet and position', () { + final lastChildUtf8 = branchColor.wrap('└──'); + final lastChildAscii = branchColor.wrap('`--'); + + final branchUtf8 = branchColor.wrap('├──'); + final branchAscii = branchColor.wrap('|--'); + + expect(getBranch(), equals(branchUtf8)); + expect(getBranch(charSet: CharSet.ascii), equals(branchAscii)); + + expect(getBranch(isLastChild: true), equals(lastChildUtf8)); + expect( + getBranch(charSet: CharSet.ascii, isLastChild: true), + equals(lastChildAscii), + ); + }); + + test('returns valid header for a file', () { + const file = 'test.file'; + + final infoForFinder = + '''Aggregated Info for ${styleItalic.wrap(file)} : Found 1 matches'''; + final infoForReplacer = '$infoForFinder, Replaced 1'; + + expect( + createHeader( + isReplaceMode: false, + fileName: file, + countOfMatches: 1, + countOfReplacements: null, + ), + equalsIgnoringWhitespace('** $infoForFinder **'), + ); + + expect( + createHeader( + isReplaceMode: true, + fileName: file, + countOfMatches: 1, + countOfReplacements: 1, + ), + equalsIgnoringWhitespace('** $infoForReplacer **'), + ); + }); + + test('returns valid tree for single paths', () { + const match = 'key'; + const path = 'my/path'; + + const tree = ''' + $match ── Found 1 + └── $path + '''; + + expect( + formatInfo( + isReplaceMode: false, + key: match, + formattedPaths: const [ + (path: path, updatedPath: null), + ], + ), + equalsIgnoringWhitespace(tree), + ); + }); + + test('returns valid tree for dual paths in replace mode', () { + const match = 'key'; + const path = 'my/path'; + + const tree = ''' + $match ── Replaced 1 + ├── $path + └── $path + '''; + + expect( + formatInfo( + isReplaceMode: true, + key: match, + formattedPaths: const [ + (path: path, updatedPath: path), + ], + ), + equalsIgnoringWhitespace(tree), + ); + }); + }); +} diff --git a/test/src/unit_tests/yaml_transformers/indexers/magical_indexer_test.dart b/test/src/unit_tests/yaml_transformers/indexers/magical_indexer_test.dart new file mode 100644 index 0000000..01ee48d --- /dev/null +++ b/test/src/unit_tests/yaml_transformers/indexers/magical_indexer_test.dart @@ -0,0 +1,218 @@ +import 'package:magical_version_bump/src/core/yaml_transformers/data/pair_definition/custom_pair_type.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/yaml_transformer.dart'; +import 'package:test/test.dart'; +import 'package:yaml/yaml.dart'; + +void main() { + group('indexer for map', () { + test('index map with one key and value', () { + final map = {'key': 'value'}; + + // Index it + final node = MagicalIndexer.forDartMap(map).indexYaml().first; + + expect(node.toString(), 'key/value'); + }); + + test('index map with multiple keys', () { + final map = {'key': 'value', 'anotherKey': 'value'}; + + // Index & get paths + final nodes = MagicalIndexer.forDartMap(map) + .indexYaml() + .toList() + .map((node) => node.toString()); + + expect(nodes, equals(['key/value', 'anotherKey/value'])); + }); + + test('index map with map nested at a key', () { + final map = { + 'key': {'nestedKey': 'value'}, + }; + + // Index it + final node = MagicalIndexer.forDartMap(map).indexYaml().first; + + expect(node.toString(), 'key/nestedKey/value'); + }); + + test('index map with map nested at multiple keys', () { + final map = { + 'key': {'nestedKey': 'value'}, + 'anotherKey': {'nestedKey': 'value'}, + }; + + // Index it + final nodes = MagicalIndexer.forDartMap(map) + .indexYaml() + .toList() + .map((node) => node.toString()); + + expect( + nodes, + equals(['key/nestedKey/value', 'anotherKey/nestedKey/value']), + ); + }); + }); + + group('indexer for map with list', () { + test('index map with list with correct indices for terminal nodes', () { + final map = { + 'key': ['value', 'anotherValue'], + }; + + final nodes = MagicalIndexer.forDartMap(map).indexYaml().toList(); + + // Expected nodes + final expectedNodes = [ + NodeData.skeleton( + precedingKeys: const [], + key: createPair(value: 'key'), + value: createPair(value: 'value', indices: [0]), + ), + NodeData.skeleton( + precedingKeys: const [], + key: createPair(value: 'key'), + value: createPair(value: 'anotherValue', indices: [1]), + ), + ]; + + expect(nodes, equals(expectedNodes)); + }); + + test('index map in list with correct indices for terminal nodes', () { + final map = { + 'key': [ + {'nestedKey': 'value'}, + ], + }; + + final node = MagicalIndexer.forDartMap(map).indexYaml().first; + + // Key will be at index 0. Index goes to key rather than value + final expectedNode = NodeData.skeleton( + precedingKeys: [createPair(value: 'key')], + key: createPair(value: 'nestedKey', indices: [0]), + value: createPair(value: 'value'), + ); + + expect(node, equals(expectedNode)); + }); + + test('index list in list with correct indices for terminal nodes', () { + final map = { + 'key': [ + ['value'], + ], + }; + + final node = MagicalIndexer.forDartMap(map).indexYaml().first; + + // Index goes to value rather than key + final expectedNode = NodeData.skeleton( + precedingKeys: const [], + key: createPair(value: 'key'), + + // Will be 2 indices deep from nearest key + value: createPair(value: 'value', indices: [0, 0]), + ); + + expect(node, equals(expectedNode)); + }); + + test('index anything encountered in list with correct indices ', () { + final map = { + 'key': [ + 'value', + [ + {'nested': 'value'}, + 'nestedValue', + ['deeplyNestedValue'], + ], + ], + }; + + final rootKey = createPair(value: 'key'); + + final nodes = MagicalIndexer.forDartMap(map).indexYaml().toList(); + + // Expected nodes + final expectedNodes = [ + NodeData.skeleton( + precedingKeys: const [], + key: rootKey, + value: createPair(value: 'value', indices: [0]), + ), + + // Nested list at index 1, key at index 0. Inherits index from list + NodeData.skeleton( + precedingKeys: [rootKey], + key: createPair(value: 'nested', indices: [1, 0]), + value: createPair(value: 'value'), + ), + + // Nested list index = 1, value at index 1. + // Nearest key is the root key + NodeData.skeleton( + precedingKeys: const [], + key: rootKey, + value: createPair(value: 'nestedValue', indices: [1, 1]), + ), + + // Nested list index = 1, next list index = 2, value at index 0. + // Nearest key is the root key. Also inherits index of list + NodeData.skeleton( + precedingKeys: const [], + key: rootKey, + value: createPair( + value: 'deeplyNestedValue', + indices: [1, 2, 0], + ), + ), + ]; + + expect(nodes, equals(expectedNodes)); + }); + }); + + group('indexer for yaml', () { + YamlMap createMap(String yaml) => loadYaml(yaml) as YamlMap; + + test('indexes simple yaml string', () { + const yaml = 'key: value'; + + final node = MagicalIndexer.forYaml( + createMap(yaml), + ).indexYaml().toList().first; + + expect(node.toString(), 'key/value'); + }); + + test('indexes fairly complex yaml string', () { + const yaml = ''' + root: value + key: + - first + - second: + - deep + - deeper + - third + '''; + + final nodePaths = MagicalIndexer.forYaml( + createMap(yaml), + ).indexYaml().toList().map((e) => e.toString()); + + final expectedPaths = [ + 'root/value', + 'key/first', + 'key/second/deep', + 'key/second/deeper', + 'key/third', + ]; + + expect(nodePaths, equals(expectedPaths)); + }); + }); +} diff --git a/test/src/unit_tests/yaml_transformers/managers/base_manager_test.dart b/test/src/unit_tests/yaml_transformers/managers/base_manager_test.dart new file mode 100644 index 0000000..5dccecf --- /dev/null +++ b/test/src/unit_tests/yaml_transformers/managers/base_manager_test.dart @@ -0,0 +1,238 @@ +import 'package:magical_version_bump/src/core/yaml_transformers/yaml_transformer.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class _MockLogger extends Mock implements Logger {} + +class _MockProgress extends Mock implements Progress {} + +void main() { + late Logger logger; + + final fileAsMap = { + 'key': 'value', + 'otherKey': [ + 'value', + {'key': 'value'}, + ], + }; + + final keysToFind = (keys: ['key'], orderType: OrderType.loose); + final valuesToFind = ['value']; + final pairsToFind = {'key': 'value'}; + + setUpAll(() { + logger = _MockLogger(); + + when(() => logger.progress(any())).thenReturn(_MockProgress()); + }); + + group('finder manager', () { + test('finds matches in a single file', () async { + final manager = FinderManager.fullSetup( + fileQueue: [fileAsMap], + aggregator: ( + applyToEachArg: true, + applyToEachFile: true, + type: AggregateType.all, + count: null, + ), + logger: logger, + finderType: FinderType.byValue, + keysToFind: keysToFind, + valuesToFind: valuesToFind, + pairsToFind: pairsToFind, + ); + + await manager.transform(); + + final counter = manager.managerCounter; + + // 3 matches will be found in first file + expect(counter.getCount(0, origin: Origin.custom), 3); + }); + + test('finds matches in multiple files', () async { + final manager = FinderManager.fullSetup( + fileQueue: [fileAsMap, fileAsMap], // just add same file twice + aggregator: ( + applyToEachArg: true, + applyToEachFile: true, + type: AggregateType.all, + count: null, + ), + logger: logger, + finderType: FinderType.byValue, + keysToFind: keysToFind, + valuesToFind: valuesToFind, + pairsToFind: pairsToFind, + ); + + await manager.transform(); + + final counter = manager.managerCounter; + + // 7 matches will be found in first file + expect(counter.getCount(0, origin: Origin.custom), 3); + expect(counter.getCount(1, origin: Origin.custom), 3); + }); + + test( + 'finds matches for each arg but not each file when count is provided', + () async { + final manager = FinderManager.fullSetup( + fileQueue: [fileAsMap, fileAsMap], + aggregator: ( + applyToEachArg: true, + applyToEachFile: false, + type: AggregateType.count, + count: 1, + ), + logger: logger, + finderType: FinderType.byValue, + keysToFind: null, + valuesToFind: valuesToFind, + pairsToFind: null, + ); + + await manager.transform(); + + final counter = manager.managerCounter; + + // 1 match will be found in first file. Second file ignored + expect(counter.getCount(0, origin: Origin.custom), 1); + expect(counter.getCount(1, origin: Origin.custom), 0); + }, + ); + + test('finds matches for each file but not each argument', () async { + final manager = FinderManager.fullSetup( + fileQueue: [fileAsMap, fileAsMap], + aggregator: ( + applyToEachArg: false, + applyToEachFile: true, + type: AggregateType.count, + count: 1, + ), + logger: logger, + finderType: FinderType.byValue, + keysToFind: null, + valuesToFind: valuesToFind, + pairsToFind: null, + ); + + await manager.transform(); + + final counter = manager.managerCounter; + + // 1 match will be found in both files + expect(counter.getCount(0, origin: Origin.custom), 1); + expect(counter.getCount(1, origin: Origin.custom), 1); + }); + + test( + 'finds matches based on simple count when not applying to each arg/file', + () async { + // Exits once count has been reached. + final fileOne = {'key': 'notValue'}; + final fileTwo = {'key': 'notValue'}; + final fileThree = {'key': 'value'}; + + // Will have to check until last file to get count of 1 + final manager = FinderManager.fullSetup( + fileQueue: [fileOne, fileTwo, fileThree], + aggregator: ( + applyToEachArg: false, + applyToEachFile: false, + type: AggregateType.count, + count: 1, + ), + logger: logger, + finderType: FinderType.byValue, + keysToFind: null, + valuesToFind: valuesToFind, + pairsToFind: null, + ); + + await manager.transform(); + + final counter = manager.managerCounter; + + // 1 match will be found in third file + expect(counter.getCount(0, origin: Origin.custom), 0); + expect(counter.getCount(1, origin: Origin.custom), 0); + expect(counter.getCount(2, origin: Origin.custom), 1); + }, + ); + }); + + group('replacer manager', () { + test('replaces keys found by Finder Manager', () async { + final manager = ReplacerManager.defaultSetup( + commandType: WalkSubCommandType.rename, + fileQueue: [fileAsMap], + aggregator: ( + applyToEachArg: true, + applyToEachFile: true, + type: AggregateType.all, + count: null, + ), + logger: logger, + substituteToMatchers: { + 'replacedKey': ['key'], + }, + ); + + await manager.transform(); + + final modifiedFile = { + 'replacedKey': 'value', + 'otherKey': [ + 'value', + {'replacedKey': 'value'}, + ], + }; + + final counter = manager.managerCounter; + + // Will replace two matches + expect(counter.getCount(0, origin: Origin.custom), 2); + expect(manager.modifiedFiles?.first.modifiedFile, equals(modifiedFile)); + }); + + test('replaces values found by Finder Manager', () async { + final manager = ReplacerManager.defaultSetup( + commandType: WalkSubCommandType.replace, + fileQueue: [fileAsMap], + aggregator: ( + applyToEachArg: true, + applyToEachFile: true, + type: AggregateType.all, + count: null, + ), + logger: logger, + substituteToMatchers: { + 'replacedValue': ['value'], + }, + ); + + await manager.transform(); + + final modifiedFile = { + 'key': 'replacedValue', + 'otherKey': [ + 'replacedValue', + {'key': 'replacedValue'}, + ], + }; + + final counter = manager.managerCounter; + + // Will replace 3 matches + expect(counter.getCount(0, origin: Origin.custom), 3); + expect(manager.modifiedFiles?.first.modifiedFile, equals(modifiedFile)); + }); + }); +} diff --git a/test/src/unit_tests/yaml_transformers/replacers/key_swapper_test.dart b/test/src/unit_tests/yaml_transformers/replacers/key_swapper_test.dart new file mode 100644 index 0000000..6f25ff2 --- /dev/null +++ b/test/src/unit_tests/yaml_transformers/replacers/key_swapper_test.dart @@ -0,0 +1,110 @@ +import 'package:magical_version_bump/src/core/yaml_transformers/yaml_transformer.dart'; +import 'package:test/test.dart'; +import 'package:yaml/yaml.dart'; + +import '../../../../helpers/helpers.dart'; + +void main() { + final map = { + 'normalKey': 'value', + 'keyWithMap': {'keyInMap': 'value'}, + 'keyWithList': [ + {'keyInList': 'value'}, + ], + }; + + late YamlMap yamlMap; + + /// Generated one time nodes for ease of access + late List nodes; + + /// Has no notion of the underlying data. Only mutates what is presented + /// to it + late KeySwapper keySwapper; + + /// Value is the same, only predicate changes + MatchedNodeData getMatch({ + required bool Function(NodeData) predicate, + required List matchedKeys, + }) { + return buildMatchedNode( + nodes, + predicate: predicate, + matchedKeys: matchedKeys, + ); + } + + setUp(() { + yamlMap = YamlMap.wrap(map); + nodes = MagicalIndexer.forDartMap(map).indexYaml().toList(); + keySwapper = KeySwapper( + { + 'replacedKeyAtRoot': ['normalKey'], + 'replacedKeyInAnotherMap': ['keyInMap'], + 'replacedKeyInList': ['keyInList'], + }, + ); + }); + + group('swaps key', () { + test('at root level', () { + final match = getMatch( + predicate: (node) => node.precedingKeys.isEmpty, + matchedKeys: const ['normalKey'], + ); + + final output = keySwapper.replace(yamlMap, matchedNodeData: match); + + final expectedMap = { + 'replacedKeyAtRoot': 'value', + 'keyWithMap': {'keyInMap': 'value'}, + 'keyWithList': [ + {'keyInList': 'value'}, + ], + }; + + expect(output.mapping, equals({'normalKey': 'replacedKeyAtRoot'})); + expect(output.updatedMap, equals(expectedMap)); + }); + + test('nested in another map', () { + final match = getMatch( + predicate: (node) => node.precedingKeys.isNotEmpty, + matchedKeys: const ['keyInMap'], + ); + + final output = keySwapper.replace(yamlMap, matchedNodeData: match); + + final expectedMap = { + 'normalKey': 'value', + 'keyWithMap': {'replacedKeyInAnotherMap': 'value'}, + 'keyWithList': [ + {'keyInList': 'value'}, + ], + }; + + expect(output.mapping, equals({'keyInMap': 'replacedKeyInAnotherMap'})); + expect(output.updatedMap, equals(expectedMap)); + }); + + test('nested in a list', () { + final match = getMatch( + predicate: (node) => node.isNestedInList(), + matchedKeys: const ['keyInList'], + ); + + final output = keySwapper.replace(yamlMap, matchedNodeData: match); + + final expectedMap = { + 'normalKey': 'value', + 'keyWithMap': {'keyInMap': 'value'}, + 'keyWithList': [ + {'replacedKeyInList': 'value'}, + ], + }; + + expect(output.mapping, equals({'keyInList': 'replacedKeyInList'})); + expect(output.updatedMap, equals(expectedMap)); + }); + }); +} diff --git a/test/src/unit_tests/yaml_transformers/replacers/value_replacer_test.dart b/test/src/unit_tests/yaml_transformers/replacers/value_replacer_test.dart new file mode 100644 index 0000000..2c3b2f5 --- /dev/null +++ b/test/src/unit_tests/yaml_transformers/replacers/value_replacer_test.dart @@ -0,0 +1,154 @@ +import 'package:magical_version_bump/src/core/yaml_transformers/yaml_transformer.dart'; +import 'package:test/test.dart'; +import 'package:yaml/yaml.dart'; + +import '../../../../helpers/helpers.dart'; + +void main() { + final map = { + 'keyAtRoot': 'value', + 'keyWithList': ['value'], + 'keyWithListOfList': [ + {'keyInList': 'value'}, + [ + 'value', + {'keyInListOfList': 'value'}, + ], + ], + }; + + late YamlMap yamlMap; + + /// Generated one time nodes for ease of access + late List nodes; + + /// Has no notion of the underlying data. Only mutates what is presented + /// to it + late ValueReplacer valueReplacer; + + /// Value is the same, only predicate changes + MatchedNodeData getMatch(bool Function(NodeData) predicate) { + return buildMatchedNode( + nodes, + predicate: predicate, + matchedValue: 'value', + ); + } + + const defaultMapping = {'value': 'replacedValue'}; // All values are same + + setUp(() { + yamlMap = YamlMap.wrap(map); + nodes = MagicalIndexer.forDartMap(map).indexYaml().toList(); + valueReplacer = ValueReplacer({ + 'replacedValue': ['value'], + }); + }); + + group('replaces value', () { + test('with key at root', () { + final match = getMatch((node) => node.precedingKeys.isEmpty); + + final output = valueReplacer.replace(yamlMap, matchedNodeData: match); + + final expectedMap = { + 'keyAtRoot': 'replacedValue', + 'keyWithList': ['value'], + 'keyWithListOfList': [ + {'keyInList': 'value'}, + [ + 'value', + {'keyInListOfList': 'value'}, + ], + ], + }; + + expect(output.mapping, equals(defaultMapping)); + expect(output.updatedMap, equals(expectedMap)); + }); + + test('in list at a key', () { + final match = getMatch((node) => node.value.isNested()); + + final output = valueReplacer.replace(yamlMap, matchedNodeData: match); + + final expectedMap = { + 'keyAtRoot': 'value', + 'keyWithList': ['replacedValue'], + 'keyWithListOfList': [ + {'keyInList': 'value'}, + [ + 'value', + {'keyInListOfList': 'value'}, + ], + ], + }; + + expect(output.mapping, equals(defaultMapping)); + expect(output.updatedMap, equals(expectedMap)); + }); + + test('with key nested in list', () { + final match = getMatch((node) => node.key.isNested()); + + final output = valueReplacer.replace(yamlMap, matchedNodeData: match); + + final expectedMap = { + 'keyAtRoot': 'value', + 'keyWithList': ['value'], + 'keyWithListOfList': [ + {'keyInList': 'replacedValue'}, + [ + 'value', + {'keyInListOfList': 'value'}, + ], + ], + }; + + expect(output.mapping, equals(defaultMapping)); + expect(output.updatedMap, equals(expectedMap)); + }); + + test('in sublist of a list at a key', () { + final match = getMatch((node) => node.value.indices.length > 1); + + final output = valueReplacer.replace(yamlMap, matchedNodeData: match); + + final expectedMap = { + 'keyAtRoot': 'value', + 'keyWithList': ['value'], + 'keyWithListOfList': [ + {'keyInList': 'value'}, + [ + 'replacedValue', + {'keyInListOfList': 'value'}, + ], + ], + }; + + expect(output.mapping, equals(defaultMapping)); + expect(output.updatedMap, equals(expectedMap)); + }); + + test('with key nested in sublist of list', () { + final match = getMatch((node) => node.key.indices.length > 1); + + final output = valueReplacer.replace(yamlMap, matchedNodeData: match); + + final expectedMap = { + 'keyAtRoot': 'value', + 'keyWithList': ['value'], + 'keyWithListOfList': [ + {'keyInList': 'value'}, + [ + 'value', + {'keyInListOfList': 'replacedValue'}, + ], + ], + }; + + expect(output.mapping, equals(defaultMapping)); + expect(output.updatedMap, equals(expectedMap)); + }); + }); +} diff --git a/test/src/unit_tests/yaml_transformers/trackers/base_counter_test.dart b/test/src/unit_tests/yaml_transformers/trackers/base_counter_test.dart new file mode 100644 index 0000000..1d621b3 --- /dev/null +++ b/test/src/unit_tests/yaml_transformers/trackers/base_counter_test.dart @@ -0,0 +1,100 @@ +import 'package:magical_version_bump/src/core/yaml_transformers/trackers/counter/generic_counter.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/trackers/tracker.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; +import 'package:test/test.dart'; + +/// Use counter with history. Direct inherits from base [Counter] +typedef _MockCounter = CounterWithHistory; + +void main() { + final counter = _MockCounter(); + + group('basic counter with no history', () { + test('returns 0 when entry is missing or not yet incremented', () { + expect(counter.getCount('myValue', origin: Origin.custom), 0); + }); + + test('tracks count of a value', () { + counter.increment(['myValue'], origin: Origin.custom); + + expect(counter.getCount('myValue', origin: Origin.custom), 1); + }); + + test('tracks count of value when from different origins', () { + // same as previous, different origin + counter.increment(['myValue'], origin: Origin.key); + + expect(counter.getCount('myValue', origin: Origin.key), 1); + expect(counter.getCount('myValue', origin: Origin.custom), 1); // previous + }); + + test('obtains count when a TrackerKey wrapping value is used', () { + const key = TrackerKey(key: 'myValue', origin: Origin.custom); + + expect(counter.getCountFromKey(key), 1); + }); + + test('obtains total count for all keys stored in it', () { + // We added 2 keys once each + expect(counter.getSumOfCount(), 2); + + addTearDown(counter.trackerState.clear); // Clear for next test + }); + + test('prefills keys into map whose count may be tracked in future', () { + final keys = ['myValue', 'myOtherValue']; + + counter.prefill(keys, origin: Origin.custom); + + expect(counter.trackerState.keys.map((e) => e.key), equals(keys)); + expect(counter.getSumOfCount(), 0); + }); + + test('increments count of key if prefilled once more', () { + final keys = ['myValue', 'myOtherValue', 'anotherValue']; + + counter.prefill(keys, origin: Origin.custom); + + expect(counter.trackerState.keys.map((e) => e.key), equals(keys)); + expect(counter.getSumOfCount(), 2); // Two keys added once more each. + }); + }); + + group('basic counter with history', () { + test('returns null when no history is present', () { + expect( + counter.getCountFromHistory('test', 'myValue', Origin.custom), + isNull, + ); + }); + + test('resets history and clears current state', () { + final oldState = counter.reset(cursor: 'test'); + + expect(counter.trackerState, equals({})); + + // We add various values in previous test + expect( + oldState, + equals({ + const TrackerKey.fromValue('myValue', Origin.custom): 1, + const TrackerKey.fromValue('myOtherValue', Origin.custom): 1, + const TrackerKey.fromValue('anotherValue', Origin.custom): 0, + }), + ); + }); + + test('obtains valid count when stored in history', () { + expect( + counter.getCountFromHistory('test', 'myValue', Origin.custom), + 1, + ); + }); + + test('drops cursor from history', () { + counter.dropCursor('test'); + + expect(counter.getFromHistory('test'), isNull); // State linked to cursor + }); + }); +} diff --git a/test/src/unit_tests/yaml_transformers/trackers/base_tracker_test.dart b/test/src/unit_tests/yaml_transformers/trackers/base_tracker_test.dart new file mode 100644 index 0000000..320c46c --- /dev/null +++ b/test/src/unit_tests/yaml_transformers/trackers/base_tracker_test.dart @@ -0,0 +1,84 @@ +import 'package:magical_version_bump/src/core/yaml_transformers/trackers/tracker.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; +import 'package:test/test.dart'; + +import '../../../../helpers/helpers.dart'; + +final class _MockTracker extends DualTracker + with MapHistory { + @override + TrackerKey createKey(dynamic value, {required Origin origin}) { + return super.createKey(value, origin: origin); + } +} + +void main() { + final tracker = _MockTracker(); + + group('tracker adds key', () { + test('of type TrackerKey for non-"MapEntry" type', () { + final trackerKey = tracker.createKey('key', origin: Origin.custom); + + expect( + trackerKey, + isA>() + .having((tracker) => tracker.key, 'key', 'key'), + ); + }); + + test('of type DualTrackerKey for "MapEntry" type', () { + final trackerKey = tracker.createKey( + const MapEntry('key', 'value'), + origin: Origin.custom, + ); + + expect( + trackerKey, + isA>() + .having((tracker) => tracker.key, 'key', 'key') + .having((tracker) => tracker.otherKey, 'otherKey', 'value'), + ); + }); + }); + + group('tracks data', () { + test('and allows for keys with same value but different origin', () { + final key = tracker.createKey('key', origin: Origin.key); + final customKey = tracker.createKey('key', origin: Origin.custom); + final valueKey = tracker.createKey('key', origin: Origin.value); + final pairKey = tracker.createKey('key', origin: Origin.pair); + + final keys = {key, customKey, valueKey, pairKey}; + + tracker.trackerState.addEntries( + keys.map((e) => MapEntry(e, 'value')), + ); + + expect(tracker.trackerState.keys, equals(keys)); + addTearDown(tracker.trackerState.clear); + }); + }); + + group('manages history', () { + test('and returns null when empty', () { + final data = tracker.getFromHistory('someMissingKey'); + + expect(data, isNull); + }); + + test('stores current data in tracker to history with cursor', () { + final key = tracker.createKey('myKey', origin: Origin.custom); + const value = 'myValue'; + + tracker.trackerState.putIfAbsent(key, () => value); + expect(tracker.reset(cursor: 'myCursor'), equals({key: value})); + }); + + test('throws an exception when trying to store a duplicate cursor', () { + expect( + () => tracker.reset(cursor: 'myCursor'), + throwsCustomException('This cursor is already tracked!'), + ); + }); + }); +} diff --git a/test/src/unit_tests/yaml_transformers/trackers/formatter_tracker_test.dart b/test/src/unit_tests/yaml_transformers/trackers/formatter_tracker_test.dart new file mode 100644 index 0000000..b5b284a --- /dev/null +++ b/test/src/unit_tests/yaml_transformers/trackers/formatter_tracker_test.dart @@ -0,0 +1,127 @@ +import 'package:magical_version_bump/src/core/yaml_transformers/formatter/formatter.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/trackers/tracker.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; +import 'package:test/test.dart'; + +/// Mainly tracks paths from a node to be printed to screen. +typedef _MockFormatterTracker = FormatterTracker; + +void main() { + late _MockFormatterTracker tracker; + + const key = TrackerKey.fromValue('key', Origin.custom); + const otherKey = TrackerKey.fromValue('otherKey', Origin.custom); + + const FormattedPathInfo pathInfo = (path: 'this/path', updatedPath: null); + + void resetTracker( + _MockFormatterTracker tracker, { + int? cursor, + int? currentTolerance, + }) { + tracker.history.clear(); + tracker.trackerState.clear(); + tracker + ..currentCursor = cursor ?? 0 + ..currentTolerance = currentTolerance ?? 0; + } + + group('tracker with default max tolerance', () { + setUpAll(() => tracker = _MockFormatterTracker()); + + test('adds single path being tracked', () { + final expectedState = { + key: [pathInfo], + otherKey: [pathInfo], + }; + + // Tracks by file index + tracker.add(fileIndex: 0, keys: [key, otherKey], pathInfo: pathInfo); + + expect(tracker.trackerState, equals(expectedState)); + + addTearDown(() => resetTracker(tracker)); + }); + + test('swaps current cursor when new file index is added', () { + tracker + ..add(fileIndex: 0, keys: [key], pathInfo: pathInfo) // initial index + ..add(fileIndex: 1, keys: [otherKey], pathInfo: pathInfo); // new index + + expect(tracker.currentCursor, equals(1)); + expect(tracker.currentTolerance, equals(0)); + expect(tracker.maxTolerance, equals(0)); + + /// Current state is last index with respect to max tolerance allowable. + /// + /// Max tolerance is 0, thus immediately swaps tracker state to last + /// index. And old state moved to history linked to file index + expect( + tracker.trackerState, + equals({ + otherKey: [pathInfo], + }), + ); + + expect( + tracker.getFromHistory(0), // old state is in history + equals({ + key: [pathInfo], + }), + ); + + addTearDown(() => resetTracker(tracker)); + }); + }); + + group('tracker with custom max tolerance', () { + setUpAll(() => tracker = _MockFormatterTracker(maxTolerance: 1)); + + test('does not swap until max tolerance is exceeded', () { + tracker + ..add(fileIndex: 0, keys: [key], pathInfo: pathInfo) // initial index + ..add(fileIndex: 1, keys: [otherKey], pathInfo: pathInfo); // new index + + expect(tracker.currentCursor, equals(0)); + expect(tracker.currentTolerance, equals(1)); + expect(tracker.maxTolerance, equals(1)); + + /// Current state remains until [currentTolerance > maxTolerance] + expect( + tracker.trackerState, + equals({ + key: [pathInfo], + }), + ); + + expect( + tracker.getFromHistory(1), // updated state is in history + equals({ + otherKey: [pathInfo], + }), + ); + }); + + test('swaps once max tolerance is exceeded', () { + // Exceed max tolerance + tracker.add(fileIndex: 1, keys: [key], pathInfo: pathInfo); + + expect(tracker.currentCursor, equals(1)); + expect(tracker.currentTolerance, equals(0)); // Resets tolerance + expect( + tracker.trackerState, + equals({ + otherKey: [pathInfo], + key: [pathInfo], + }), + ); + + expect( + tracker.getFromHistory(0), // old state is in history + equals({ + key: [pathInfo], + }), + ); + }); + }); +} diff --git a/test/src/unit_tests/yaml_transformers/trackers/replacer_tracker_test.dart b/test/src/unit_tests/yaml_transformers/trackers/replacer_tracker_test.dart new file mode 100644 index 0000000..f6e3fcb --- /dev/null +++ b/test/src/unit_tests/yaml_transformers/trackers/replacer_tracker_test.dart @@ -0,0 +1,116 @@ +import 'package:collection/collection.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/trackers/tracker.dart'; +import 'package:magical_version_bump/src/core/yaml_transformers/yaml_transformer.dart'; +import 'package:magical_version_bump/src/utils/enums/enums.dart'; +import 'package:test/test.dart'; + +typedef _MockReplacerTracker = ReplacerTracker; + +void main() { + final tracker = _MockReplacerTracker(); + final defaultMatch = MatchedNodeData.fromFinder( + nodeData: NodeData.stringSkeleton( + path: const ['root'], + key: 'key', + value: 'value', + ), + matchedKeys: const ['key'], + matchedValue: '', + matchedPairs: const {}, + ); + + group('rename tracker', () { + test('adds all matches while renaming keys', () { + // Replacer tracker uses a file index as a cursor. Use same match + final outputs = [ + (currentFile: 0, data: defaultMatch), + (currentFile: 1, data: defaultMatch), + ]; + + tracker.addAll(outputs); + + // Path to last renameable key is 'root/key' + const basicKey = TrackerKey.fromValue( + 'root/key', + Origin.custom, + ); + + /// File with index [0] will be pushed to history with path upto + /// last renameable key as its key. + expect(tracker.getFromHistory(0), equals({basicKey: defaultMatch})); + + /// Curent state will have state of last index + expect(tracker.trackerState, equals({basicKey: defaultMatch})); + + /// All matches linked to each file + final linkedMatches = tracker.getMatches(); + + expect(linkedMatches.map((e) => e.fileNumber), equals([0, 1])); + expect( + linkedMatches.map((e) => e.matches).flattened.toSet(), + {defaultMatch}, + ); + }); + + test('never adds duplicate paths for keys to be renamed from same file', + () { + // Match nested further along same path as default match + final anotherMatch = MatchedNodeData.fromFinder( + nodeData: NodeData.stringSkeleton( + path: const ['root', 'key'], + key: 'anotherKey', + value: 'value', + ), + matchedKeys: const ['key'], + matchedValue: '', + matchedPairs: const {}, + ); + + final outputs = [ + (currentFile: 0, data: defaultMatch), + (currentFile: 0, data: anotherMatch), + ]; + + tracker.addAll(outputs); + + expect( + tracker.getMatches().map((e) => e.matches).flattened, + equals([defaultMatch]), + ); + }); + + test( + 'adds paths if last renameable key further than previously added match', + () { + // Match nested further along same path as default match + final anotherMatch = MatchedNodeData.fromFinder( + nodeData: NodeData.stringSkeleton( + path: const ['root', 'key'], + key: 'anotherKey', + value: 'value', + ), + matchedKeys: const ['key', 'anotherKey'], + matchedValue: '', + matchedPairs: const {}, + ); + + final outputs = [ + (currentFile: 0, data: defaultMatch), + (currentFile: 0, data: anotherMatch), + ]; + + tracker.addAll(outputs); + + expect( + tracker.getMatches().map((e) => e.matches).flattened, + equals([defaultMatch, anotherMatch]), + ); + }, + ); + }); + + tearDown(() { + tracker.history.clear(); + tracker.trackerState.clear(); + }); +}