Repository: RasaHQ/rasa
Pull request: rasa data migrate 3.0
**Proposed changes**:
- closes #10243
- added assertion that files that we want to migrate do not contain the 3.0 version tag already 
- prevent users from overwriting existing output or backup location (which prevents us from removing user data when cleaning up the backup location in case migration fails)
- added check so that `assemble_new_domain` only works on domain files
- remove backup of original domain in case a domain file is given (we only removed the backup directory previously)

**Questions:**
-  this is still missing cases where the original files did not contain a version tag and then were migrated - should we drop the "key == version" checks and force a "version: 3.0" to be added if it doesn't exist?

**Status (please check what you already did)**:
- [ ] added some tests for the functionality
- [ ] updated the documentation
- [ ] updated the changelog (please check [changelog](https://github.com/RasaHQ/rasa/tree/main/changelog) for instructions)
- [ ] reformat files using `black` (please check [Readme](https://github.com/RasaHQ/rasa#code-style) for instructions)


Problem statement:
`rasa data migrate` fails with a cryptic error if run on a 3.0 domain file
The proposed docs for `rasa data migrate` mention:
> Exceptions will be raised and the migration process terminated if invalid domain files are provided, slots or forms
are missing from your original files[...]

I (accidentally) ran the command on an already migrated domain file (a case not mentioned above) and got an error:
```bash
Traceback (most recent call last):
  File "/Users/sams/.miniconda/envs/qaenv/bin/rasa", line 8, in <module>
    sys.exit(main())
  File "/Users/sams/.miniconda/envs/qaenv/lib/python3.8/site-packages/rasa/__main__.py", line 121, in main
    cmdline_arguments.func(cmdline_arguments)
  File "/Users/sams/.miniconda/envs/qaenv/lib/python3.8/site-packages/rasa/cli/data.py", line 259, in _migrate_domain
    rasa.core.migrate.migrate_domain_format(args.domain, args.out)
  File "/Users/sams/.miniconda/envs/qaenv/lib/python3.8/site-packages/rasa/core/migrate.py", line 262, in migrate_domain_format
    new_forms, updated_slots = _migrate_form_slots(original_domain)
  File "/Users/sams/.miniconda/envs/qaenv/lib/python3.8/site-packages/rasa/core/migrate.py", line 89, in _migrate_form_slots
    for slot_name, mappings in form_data.items():
AttributeError: 'list' object has no attribute 'items'
```
I think it'd be nice to first check if the file looks like a valid 3.0 domain and if it does, inform the user instead of trying to migrate the file.

**Definition of done**
- [ ] Stop and warn user that they are trying to migrate a 3.0 domain file with the 2.0 --> 3.0 migration tool

Hints from issue discussion:

Commit message: Merge branch 'main' into 10243/data-migrate-3.0

Patch to review:
diff --git a/rasa/cli/arguments/data.py b/rasa/cli/arguments/data.py
index 081a3f81697e..dae75163fd8d 100644
--- a/rasa/cli/arguments/data.py
+++ b/rasa/cli/arguments/data.py
@@ -7,7 +7,7 @@
     add_data_param,
     add_domain_param,
 )
-from rasa.shared.constants import DEFAULT_CONVERTED_DATA_PATH, DEFAULT_DOMAIN_PATH
+from rasa.shared.constants import DEFAULT_CONVERTED_DATA_PATH
 
 
 def set_convert_arguments(parser: argparse.ArgumentParser, data_type: Text) -> None:
@@ -74,6 +74,8 @@ def set_migrate_arguments(parser: argparse.ArgumentParser) -> None:
 
     add_out_param(
         parser,
-        default=DEFAULT_DOMAIN_PATH,
-        help_text="Path (for `yaml`) where to save migrated domain in Rasa 3.0 format.",
+        default=None,
+        help_text="Path (for `yaml`) where to save migrated domain in Rasa 3.0 format."
+        "If none is specified, a generic file/folder will be created in the folder "
+        "that contains the given domain.",
     )
diff --git a/rasa/core/migrate.py b/rasa/core/migrate.py
index 07530bb136a9..b38bf423aee0 100644
--- a/rasa/core/migrate.py
+++ b/rasa/core/migrate.py
@@ -8,6 +8,7 @@
 from rasa.shared.constants import (
     REQUIRED_SLOTS_KEY,
     IGNORED_INTENTS,
+    DEFAULT_DOMAIN_PATH,
 )
 from rasa.shared.core.constants import ACTIVE_LOOP, REQUESTED_SLOT
 from rasa.shared.core.domain import (
@@ -23,8 +24,11 @@
 def _create_back_up(
     domain_file: Path, backup_location: Path
 ) -> Union[List[Any], Dict[Text, Any]]:
+    """Makes a backup and returns the content of the file."""
     original_content = rasa.shared.utils.io.read_yaml_file(domain_file)
-    rasa.shared.utils.io.write_yaml(original_content, backup_location, True)
+    rasa.shared.utils.io.write_yaml(
+        original_content, backup_location, should_preserve_key_order=True
+    )
     return original_content
 
 
@@ -178,6 +182,8 @@ def _write_final_domain(
 ) -> None:
     if domain_file.is_dir():
         for file in domain_file.iterdir():
+            if not Domain.is_domain_file(file):
+                continue
             new_domain = _assemble_new_domain(file, new_forms, new_slots)
             rasa.shared.utils.io.write_yaml(new_domain, out_file / file.name, True)
     else:
@@ -186,15 +192,33 @@ def _write_final_domain(
 
 
 def _migrate_domain_files(
-    domain_file: Path, backup_location: Path, out_file: Path
+    domain_path: Path, backup_location: Path, out_path: Path
 ) -> Dict[Text, Any]:
+    """Migrates files that only need a version update and collects the remaining info.
+
+    Moreover, backups will be created from all domain files that can be found in the
+    given domain directory.
+
+    Args:
+        domain_path: directory containing domain files
+        backup_location: where to backup all domain files
+        out_path: location where to store the migrated files
+    """
     slots = {}
     forms = {}
     entities = []
 
-    for file in domain_file.iterdir():
-        if not Domain.is_domain_file(file):
-            continue
+    domain_files = [
+        file for file in domain_path.iterdir() if Domain.is_domain_file(file)
+    ]
+
+    if not domain_files:
+        raise RasaException(
+            f"The domain directory '{domain_path}' does not contain any domain files. "
+            f"Please make sure to include these for a successful migration."
+        )
+
+    for file in domain_files:
 
         backup = backup_location / file.name
         original_content = _create_back_up(file, backup)
@@ -206,7 +230,7 @@ def _migrate_domain_files(
             # this is done so that the other domain files can be moved
             # in the migrated directory
             rasa.shared.utils.io.write_yaml(
-                original_content, out_file / file.name, True
+                original_content, out_path / file.name, True
             )
         elif KEY_SLOTS in original_content and slots:
             raise RasaException(
@@ -229,7 +253,7 @@ def _migrate_domain_files(
 
     if not slots or not forms:
         raise RasaException(
-            f"The files you have provided in '{domain_file}' are missing slots "
+            f"The files you have provided in '{domain_path}' are missing slots "
             f"or forms. Please make sure to include these for a "
             f"successful migration."
         )
@@ -238,59 +262,114 @@ def _migrate_domain_files(
 
 
 def migrate_domain_format(
-    domain_file: Union[Text, Path], out_file: Union[Text, Path]
+    domain_path: Union[Text, Path], out_path: Optional[Union[Text, Path]],
 ) -> None:
     """Converts 2.0 domain to 3.0 format."""
-    domain_file = Path(domain_file)
-    out_file = Path(out_file)
-    created_out_dir = False
-
-    current_dir = domain_file.parent
+    domain_path = Path(domain_path)
+    out_path = Path(out_path) if out_path else None
+
+    domain_parent_dir = domain_path.parent
+    migrate_file_only = domain_path.is_file()
+
+    # Ensure the backup location does not exist yet
+    # Note: We demand that file as well as folder with this name gets deleted before
+    # the command is run to avoid confusion afterwards.
+    suffix = "original_domain"
+    suffix = f"{suffix}.yml" if migrate_file_only else suffix
+    backup_location = domain_parent_dir / suffix
+    if backup_location.exists():
+        backup_location_str = "directory" if backup_location.isdir() else "file"
+        raise RasaException(
+            f"The domain from '{domain_path}' could not be migrated since the "
+            f"a {backup_location_str} {backup_location} already exists."
+            f"Please remove that there is no file or folder at {backup_location}."
+        )
 
-    if domain_file.is_dir():
-        backup_location = current_dir / "original_domain"
-        backup_location.mkdir()
-
-        if out_file.is_file() or not out_file.exists():
-            out_file = current_dir / "new_domain"
-            out_file.mkdir()
-            created_out_dir = True
-            rasa.shared.utils.io.raise_warning(
-                f"The out path provided is not a directory, "
-                f"creating a new directory '{str(out_file)}' "
-                f"for migrated domain files."
-            )
+    # Choose a default output location if nothing was specified
+    if out_path is None:
+        suffix = DEFAULT_DOMAIN_PATH if migrate_file_only else "new_domain"
+        out_path = domain_parent_dir / suffix
 
-        try:
-            original_domain = _migrate_domain_files(
-                domain_file, backup_location, out_file
+    # Ensure the output location is not already in-use
+    if not migrate_file_only:
+        if out_path.is_dir() and any(out_path.iterdir()):
+            raise RasaException(
+                f"The domain from '{domain_path}' could not be migrated to "
+                f"{out_path} because that folder is not empty."
+                "Please remove the folder and try again."
             )
-        except Exception as e:
-            shutil.rmtree(backup_location)
-            if out_file != domain_file:
-                shutil.rmtree(out_file)
-                if not created_out_dir:
-                    # we recreate the deleted directory
-                    # because it existed before
-                    out_file.mkdir()
-            raise e
     else:
-        if not Domain.is_domain_file(domain_file):
+        if out_path.is_file():
             raise RasaException(
-                f"The file '{domain_file}' could not be validated as a "
-                f"domain file. Only domain yaml files can be migrated. "
+                f"The domain from '{domain_path}' could not be migrated to "
+                f"{out_path} because a file already exists."
+                "Please remove the file and try again."
             )
 
-        backup_location = current_dir / "original_domain.yml"
-        original_domain = _create_back_up(domain_file, backup_location)
-
-    new_forms, updated_slots = _migrate_form_slots(original_domain)
-    new_slots = _migrate_auto_fill_and_custom_slots(original_domain, updated_slots)
+    # Sanity Check: Assert the files to be migrated aren't in 3.0 format already
+    # Note: we do not enforce that the version tag is 2.0 everywhere + validate that
+    # migrate-able domain files are among these files later
+    original_files = (
+        [file for file in domain_path.iterdir() if Domain.is_domain_file(file)]
+        if domain_path.is_dir()
+        else [domain_path]
+    )
+    migrated_files = [
+        file
+        for file in original_files
+        if rasa.shared.utils.io.read_yaml_file(file).get("version") == "3.0"
+    ]
+    if migrated_files:
+        raise RasaException(
+            f"Some of the given files ({[file for file in migrated_files]}) "
+            f"have already been migrated to Rasa 3.0 format. Please remove these "
+            f"migrated files (or replace them with files in 2.0 format) and try again."
+        )
 
-    _write_final_domain(domain_file, new_forms, new_slots, out_file)
+    # Validate given domain file(s) and migrate them
+    try:
+        created_out_dir = False
+        if not migrate_file_only:
+            if not out_path.is_dir():
+                rasa.shared.utils.io.raise_warning(
+                    f"The out path provided did not exist yet. Created directory "
+                    f"{out_path}."
+                )
+                out_path.mkdir(parents=True)
+                created_out_dir = True
+            backup_location.mkdir()
+            original_domain = _migrate_domain_files(
+                domain_path, backup_location, out_path
+            )
+        else:
+            if not Domain.is_domain_file(domain_path):
+                raise RasaException(
+                    f"The file '{domain_path}' could not be validated as a "
+                    f"domain file. Only domain yaml files can be migrated. "
+                )
+            original_domain = _create_back_up(domain_path, backup_location)
+
+        new_forms, updated_slots = _migrate_form_slots(original_domain)
+        new_slots = _migrate_auto_fill_and_custom_slots(original_domain, updated_slots)
+
+        _write_final_domain(domain_path, new_forms, new_slots, out_path)
+
+        rasa.shared.utils.cli.print_success(
+            f"Your domain file '{str(domain_path)}' was successfully migrated! "
+            f"The migrated version is now '{str(out_path)}'. "
+            f"The original domain file is backed-up at '{str(backup_location)}'."
+        )
 
-    rasa.shared.utils.cli.print_success(
-        f"Your domain file '{str(domain_file)}' was successfully migrated! "
-        f"The migrated version is now '{str(out_file)}'. "
-        f"The original domain file is backed-up at '{str(backup_location)}'."
-    )
+    except Exception as e:
+        # Remove the backups if migration couldn't be completed
+        if backup_location.is_dir():
+            shutil.rmtree(backup_location)
+        if out_path.is_dir():
+            if created_out_dir:
+                shutil.rmtree(out_path)
+            else:  # just remove contained files so we do not mess with access rights
+                for f in out_path.glob("*"):
+                    f.unlink()
+        if backup_location.is_file():
+            backup_location.unlink()
+        raise e
diff --git a/tests/core/test_migrate.py b/tests/core/test_migrate.py
index b80cff9b6c95..fa3c821e2b26 100644
--- a/tests/core/test_migrate.py
+++ b/tests/core/test_migrate.py
@@ -21,7 +21,7 @@ def prepare_domain_path(directory: Path, domain_content: Text, file_name: Text)
 
 @pytest.fixture()
 def domain_out_file(tmp_path: Path) -> Path:
-    return tmp_path / "new_domain.yml"
+    return tmp_path / "custom_new_domain.yml"
 
 
 def test_migrate_domain_format_with_required_slots(
@@ -333,6 +333,7 @@ def test_migrate_domain_format_from_dir(tmp_path: Path):
 
     rasa.core.migrate.migrate_domain_format(domain_dir, domain_out_dir)
     domain = Domain.from_directory(str(domain_out_dir))
+
     assert domain
 
     old_domain_path = tmp_path / "original_domain"
@@ -595,7 +596,7 @@ def test_migrate_domain_format_duplicated_slots_in_forms(
     }
 
 
-def test_migrate_domain_dir_with_out_path_as_file(tmp_path: Path):
+def test_migrate_domain_dir_with_out_path_None(tmp_path: Path):
     domain_dir = tmp_path / "domain"
     domain_dir.mkdir()
 
@@ -628,9 +629,7 @@ def test_migrate_domain_dir_with_out_path_as_file(tmp_path: Path):
         "forms.yml",
     )
 
-    domain_out = tmp_path / "domain.yml"
-
-    rasa.core.migrate.migrate_domain_format(domain_dir, domain_out)
+    rasa.core.migrate.migrate_domain_format(domain_dir, None)
 
     domain_out = tmp_path / "new_domain"
     assert domain_out.exists()
@@ -678,7 +677,7 @@ def test_migrate_domain_multiple_files_with_duplicate_slots(tmp_path: Path,):
         RasaException,
         match="Domain files with multiple 'slots' sections were provided.",
     ):
-        rasa.core.migrate.migrate_domain_format(domain_dir, domain_dir)
+        rasa.core.migrate.migrate_domain_format(domain_dir, None)
 
 
 def test_migrate_domain_with_multiple_files_with_duplicate_forms(tmp_path: Path):
@@ -718,7 +717,7 @@ def test_migrate_domain_with_multiple_files_with_duplicate_forms(tmp_path: Path)
         RasaException,
         match="Domain files with multiple 'forms' sections were provided.",
     ):
-        rasa.core.migrate.migrate_domain_format(domain_dir, domain_dir)
+        rasa.core.migrate.migrate_domain_format(domain_dir, None)
 
 
 def test_migrate_domain_from_dir_with_other_sections(tmp_path: Path):
@@ -757,11 +756,13 @@ def test_migrate_domain_from_dir_with_other_sections(tmp_path: Path):
         """,
         domain_file_two,
     )
-    rasa.core.migrate.migrate_domain_format(domain_dir, domain_dir)
-    domain = Domain.from_directory(str(domain_dir))
+
+    new_domain_dir = tmp_path / "migrated_domain"
+    rasa.core.migrate.migrate_domain_format(domain_dir, new_domain_dir)
+    domain = Domain.from_directory(new_domain_dir)
     assert domain
 
-    for file in domain_dir.iterdir():
+    for file in new_domain_dir.iterdir():
         migrated = rasa.shared.utils.io.read_yaml_file(file)
 
         migrated_training_data_version = migrated.get("version")
@@ -781,24 +782,26 @@ def test_migrate_domain_raises_exception_for_non_domain_file(tmp_path: Path):
         nlu:
         - intent: greet
           examples: |
-          - hey
-          - hello
-          - hi
-          - hello there
-          - good morning
-          - good evening
-          - moin
-          - hey there
+            - hey
+            - hello
+            - hi
+            - hello there
+            - good morning
+            - good evening
+            - moin
+            - hey there
         """,
         "domain.yml",
     )
 
+    new_domain_file = tmp_path / "new_domain.yml"
+
     with pytest.raises(
         RasaException,
-        match=f"The file '{re.escape(str(domain_file))}' could not "
+        match=f"The file '{str(domain_file)}' could not "
         f"be validated as a domain file.",
     ):
-        rasa.core.migrate.migrate_domain_format(domain_file, domain_file)
+        rasa.core.migrate.migrate_domain_format(domain_file, new_domain_file)
 
 
 def test_migrate_domain_raises_for_non_domain_files(tmp_path: Path):
@@ -812,14 +815,36 @@ def test_migrate_domain_raises_for_non_domain_files(tmp_path: Path):
         nlu:
         - intent: greet
           examples: |
-          - hey
-          - hello
-          - hi
-          - hello there
-          - good morning
-          - good evening
-          - moin
-          - hey there
+            - hey
+            - hello
+            - hi
+            - hello there
+            - good morning
+            - good evening
+            - moin
+            - hey there
+        """,
+        "domain.yml",
+    )
+
+    with pytest.raises(
+        RasaException,
+        match=f"The domain directory '{domain_dir}' does not contain any domain files. "
+        f"Please make sure to include these for a successful migration.",
+    ):
+        rasa.core.migrate.migrate_domain_format(domain_dir, None)
+
+
+def test_migrate_domain_raises_for_missing_slots_and_forms(tmp_path: Path):
+    domain_dir = tmp_path / "domain"
+    domain_dir.mkdir()
+
+    prepare_domain_path(
+        domain_dir,
+        """
+        version: "2.0"
+        entities:
+        - bla
         """,
         "domain.yml",
     )
@@ -830,7 +855,7 @@ def test_migrate_domain_raises_for_non_domain_files(tmp_path: Path):
         f"are missing slots or forms. "
         f"Please make sure to include these for a successful migration.",
     ):
-        rasa.core.migrate.migrate_domain_format(domain_dir, domain_dir)
+        rasa.core.migrate.migrate_domain_format(domain_dir, None)
 
 
 def test_migration_cleanup(tmp_path: Path,):
@@ -881,7 +906,7 @@ def test_migration_cleanup(tmp_path: Path,):
         RasaException,
         match="Domain files with multiple 'slots' sections were provided.",
     ):
-        rasa.core.migrate.migrate_domain_format(domain_dir, domain_dir)
+        rasa.core.migrate.migrate_domain_format(domain_dir, None)
 
     # here the migration fails but we check if the domain was
     # not deleted in the process of cleanup
@@ -916,3 +941,22 @@ def test_migration_cleanup(tmp_path: Path,):
     )
     assert not Path.exists(current_dir / "original_domain")
     assert not Path.exists(current_dir / "new_domain")
+
+
+def test_migration_stops_when_migrated_files_are_found(tmp_path: Path,):
+    domain_dir = tmp_path / "domain"
+    domain_dir.mkdir()
+    prepare_domain_path(
+        domain_dir,
+        """
+        version: "3.0"
+        """,
+        "domain.yml",
+    )
+
+    with pytest.raises(
+        RasaException,
+        match=f"The domain directory '{domain_dir}' does not contain any domain "
+        f"files.*",
+    ):
+        rasa.core.migrate.migrate_domain_format(domain_dir, None)


Code review comments:
diff_hunk: @@ -74,6 +74,8 @@ def set_migrate_arguments(parser: argparse.ArgumentParser) -> None:
 
     add_out_param(
         parser,
-        default=DEFAULT_DOMAIN_PATH,
-        help_text="Path (for `yaml`) where to save migrated domain in Rasa 3.0 format.",
+        default=None,
+        help_text="Path (for `yaml`) where to save migrated domain in Rasa 3.0 format."
+        "If none is specified, a generic file/folder will be created in the folder "
path: rasa/cli/arguments/data.py
text: @user1:
This can even be more specific regarding the names of the generated `out_path`:
```suggestion
        "If none is specified, either a `new_domain.yml` file or `new_domain` folder will be created in the folder "
```

diff_hunk: @@ -238,59 +262,114 @@ def _migrate_domain_files(
 
 
 def migrate_domain_format(
-    domain_file: Union[Text, Path], out_file: Union[Text, Path]
+    domain_path: Union[Text, Path], out_path: Optional[Union[Text, Path]],
 ) -> None:
     """Converts 2.0 domain to 3.0 format."""
-    domain_file = Path(domain_file)
-    out_file = Path(out_file)
-    created_out_dir = False
-
-    current_dir = domain_file.parent
+    domain_path = Path(domain_path)
+    out_path = Path(out_path) if out_path else None
+
+    domain_parent_dir = domain_path.parent
+    migrate_file_only = domain_path.is_file()
+
+    # Ensure the backup location does not exist yet
+    # Note: We demand that file as well as folder with this name gets deleted before
+    # the command is run to avoid confusion afterwards.
+    suffix = "original_domain"
+    suffix = f"{suffix}.yml" if migrate_file_only else suffix
+    backup_location = domain_parent_dir / suffix
+    if backup_location.exists():
+        backup_location_str = "directory" if backup_location.isdir() else "file"
+        raise RasaException(
+            f"The domain from '{domain_path}' could not be migrated since the "
+            f"a {backup_location_str} {backup_location} already exists."
+            f"Please remove that there is no file or folder at {backup_location}."
+        )
 
-    if domain_file.is_dir():
-        backup_location = current_dir / "original_domain"
-        backup_location.mkdir()
-
-        if out_file.is_file() or not out_file.exists():
-            out_file = current_dir / "new_domain"
-            out_file.mkdir()
-            created_out_dir = True
-            rasa.shared.utils.io.raise_warning(
-                f"The out path provided is not a directory, "
-                f"creating a new directory '{str(out_file)}' "
-                f"for migrated domain files."
-            )
+    # Choose a default output location if nothing was specified
+    if out_path is None:
+        suffix = DEFAULT_DOMAIN_PATH if migrate_file_only else "new_domain"
+        out_path = domain_parent_dir / suffix
 
-        try:
-            original_domain = _migrate_domain_files(
-                domain_file, backup_location, out_file
+    # Ensure the output location is not already in-use
+    if not migrate_file_only:
+        if out_path.is_dir() and any(out_path.iterdir()):
+            raise RasaException(
+                f"The domain from '{domain_path}' could not be migrated to "
+                f"{out_path} because that folder is not empty."
path: rasa/core/migrate.py
text: @user1:
```suggestion
                f"'{out_path}' because that folder is not empty."
```

diff_hunk: @@ -238,59 +262,114 @@ def _migrate_domain_files(
 
 
 def migrate_domain_format(
-    domain_file: Union[Text, Path], out_file: Union[Text, Path]
+    domain_path: Union[Text, Path], out_path: Optional[Union[Text, Path]],
 ) -> None:
     """Converts 2.0 domain to 3.0 format."""
-    domain_file = Path(domain_file)
-    out_file = Path(out_file)
-    created_out_dir = False
-
-    current_dir = domain_file.parent
+    domain_path = Path(domain_path)
+    out_path = Path(out_path) if out_path else None
+
+    domain_parent_dir = domain_path.parent
+    migrate_file_only = domain_path.is_file()
+
+    # Ensure the backup location does not exist yet
+    # Note: We demand that file as well as folder with this name gets deleted before
+    # the command is run to avoid confusion afterwards.
+    suffix = "original_domain"
+    suffix = f"{suffix}.yml" if migrate_file_only else suffix
+    backup_location = domain_parent_dir / suffix
+    if backup_location.exists():
+        backup_location_str = "directory" if backup_location.isdir() else "file"
+        raise RasaException(
+            f"The domain from '{domain_path}' could not be migrated since the "
+            f"a {backup_location_str} {backup_location} already exists."
path: rasa/core/migrate.py
text: @user1:
```suggestion
            f"{backup_location_str} '{backup_location}' already exists."
```

diff_hunk: @@ -238,59 +262,114 @@ def _migrate_domain_files(
 
 
 def migrate_domain_format(
-    domain_file: Union[Text, Path], out_file: Union[Text, Path]
+    domain_path: Union[Text, Path], out_path: Optional[Union[Text, Path]],
 ) -> None:
     """Converts 2.0 domain to 3.0 format."""
-    domain_file = Path(domain_file)
-    out_file = Path(out_file)
-    created_out_dir = False
-
-    current_dir = domain_file.parent
+    domain_path = Path(domain_path)
+    out_path = Path(out_path) if out_path else None
+
+    domain_parent_dir = domain_path.parent
+    migrate_file_only = domain_path.is_file()
+
+    # Ensure the backup location does not exist yet
+    # Note: We demand that file as well as folder with this name gets deleted before
+    # the command is run to avoid confusion afterwards.
+    suffix = "original_domain"
+    suffix = f"{suffix}.yml" if migrate_file_only else suffix
+    backup_location = domain_parent_dir / suffix
+    if backup_location.exists():
+        backup_location_str = "directory" if backup_location.isdir() else "file"
+        raise RasaException(
+            f"The domain from '{domain_path}' could not be migrated since the "
+            f"a {backup_location_str} {backup_location} already exists."
+            f"Please remove that there is no file or folder at {backup_location}."
+        )
 
-    if domain_file.is_dir():
-        backup_location = current_dir / "original_domain"
-        backup_location.mkdir()
-
-        if out_file.is_file() or not out_file.exists():
-            out_file = current_dir / "new_domain"
-            out_file.mkdir()
-            created_out_dir = True
-            rasa.shared.utils.io.raise_warning(
-                f"The out path provided is not a directory, "
-                f"creating a new directory '{str(out_file)}' "
-                f"for migrated domain files."
-            )
+    # Choose a default output location if nothing was specified
+    if out_path is None:
+        suffix = DEFAULT_DOMAIN_PATH if migrate_file_only else "new_domain"
+        out_path = domain_parent_dir / suffix
 
-        try:
-            original_domain = _migrate_domain_files(
-                domain_file, backup_location, out_file
+    # Ensure the output location is not already in-use
+    if not migrate_file_only:
+        if out_path.is_dir() and any(out_path.iterdir()):
+            raise RasaException(
+                f"The domain from '{domain_path}' could not be migrated to "
+                f"{out_path} because that folder is not empty."
+                "Please remove the folder and try again."
path: rasa/core/migrate.py
text: @user1:
```suggestion
                "Please remove the contents of the folder and try again."
```

diff_hunk: @@ -238,59 +262,114 @@ def _migrate_domain_files(
 
 
 def migrate_domain_format(
-    domain_file: Union[Text, Path], out_file: Union[Text, Path]
+    domain_path: Union[Text, Path], out_path: Optional[Union[Text, Path]],
 ) -> None:
     """Converts 2.0 domain to 3.0 format."""
-    domain_file = Path(domain_file)
-    out_file = Path(out_file)
-    created_out_dir = False
-
-    current_dir = domain_file.parent
+    domain_path = Path(domain_path)
+    out_path = Path(out_path) if out_path else None
+
+    domain_parent_dir = domain_path.parent
+    migrate_file_only = domain_path.is_file()
+
+    # Ensure the backup location does not exist yet
+    # Note: We demand that file as well as folder with this name gets deleted before
+    # the command is run to avoid confusion afterwards.
+    suffix = "original_domain"
+    suffix = f"{suffix}.yml" if migrate_file_only else suffix
+    backup_location = domain_parent_dir / suffix
+    if backup_location.exists():
+        backup_location_str = "directory" if backup_location.isdir() else "file"
+        raise RasaException(
+            f"The domain from '{domain_path}' could not be migrated since the "
+            f"a {backup_location_str} {backup_location} already exists."
+            f"Please remove that there is no file or folder at {backup_location}."
+        )
 
-    if domain_file.is_dir():
-        backup_location = current_dir / "original_domain"
-        backup_location.mkdir()
-
-        if out_file.is_file() or not out_file.exists():
-            out_file = current_dir / "new_domain"
-            out_file.mkdir()
-            created_out_dir = True
-            rasa.shared.utils.io.raise_warning(
-                f"The out path provided is not a directory, "
-                f"creating a new directory '{str(out_file)}' "
-                f"for migrated domain files."
-            )
+    # Choose a default output location if nothing was specified
+    if out_path is None:
+        suffix = DEFAULT_DOMAIN_PATH if migrate_file_only else "new_domain"
path: rasa/core/migrate.py
text: @user1:
I'm actually debating now if we shouldn't actually use a new suffix for the new domain file, instead of overwriting `DEFAULT_DOMAIN_PATH`, it would be more consistent this way. WDYT?
```suggestion
        suffix = "new_domain.yml" if migrate_file_only else "new_domain"
```

@author:
yesss, I like that :)

diff_hunk: @@ -238,59 +262,114 @@ def _migrate_domain_files(
 
 
 def migrate_domain_format(
-    domain_file: Union[Text, Path], out_file: Union[Text, Path]
+    domain_path: Union[Text, Path], out_path: Optional[Union[Text, Path]],
 ) -> None:
     """Converts 2.0 domain to 3.0 format."""
-    domain_file = Path(domain_file)
-    out_file = Path(out_file)
-    created_out_dir = False
-
-    current_dir = domain_file.parent
+    domain_path = Path(domain_path)
+    out_path = Path(out_path) if out_path else None
+
+    domain_parent_dir = domain_path.parent
+    migrate_file_only = domain_path.is_file()
+
+    # Ensure the backup location does not exist yet
+    # Note: We demand that file as well as folder with this name gets deleted before
+    # the command is run to avoid confusion afterwards.
+    suffix = "original_domain"
+    suffix = f"{suffix}.yml" if migrate_file_only else suffix
+    backup_location = domain_parent_dir / suffix
+    if backup_location.exists():
+        backup_location_str = "directory" if backup_location.isdir() else "file"
+        raise RasaException(
+            f"The domain from '{domain_path}' could not be migrated since the "
+            f"a {backup_location_str} {backup_location} already exists."
+            f"Please remove that there is no file or folder at {backup_location}."
+        )
 
-    if domain_file.is_dir():
-        backup_location = current_dir / "original_domain"
-        backup_location.mkdir()
-
-        if out_file.is_file() or not out_file.exists():
-            out_file = current_dir / "new_domain"
-            out_file.mkdir()
-            created_out_dir = True
-            rasa.shared.utils.io.raise_warning(
-                f"The out path provided is not a directory, "
-                f"creating a new directory '{str(out_file)}' "
-                f"for migrated domain files."
-            )
+    # Choose a default output location if nothing was specified
+    if out_path is None:
+        suffix = DEFAULT_DOMAIN_PATH if migrate_file_only else "new_domain"
+        out_path = domain_parent_dir / suffix
 
-        try:
-            original_domain = _migrate_domain_files(
-                domain_file, backup_location, out_file
+    # Ensure the output location is not already in-use
+    if not migrate_file_only:
+        if out_path.is_dir() and any(out_path.iterdir()):
+            raise RasaException(
+                f"The domain from '{domain_path}' could not be migrated to "
+                f"{out_path} because that folder is not empty."
+                "Please remove the folder and try again."
             )
-        except Exception as e:
-            shutil.rmtree(backup_location)
-            if out_file != domain_file:
-                shutil.rmtree(out_file)
-                if not created_out_dir:
-                    # we recreate the deleted directory
-                    # because it existed before
-                    out_file.mkdir()
-            raise e
     else:
-        if not Domain.is_domain_file(domain_file):
+        if out_path.is_file():
             raise RasaException(
-                f"The file '{domain_file}' could not be validated as a "
-                f"domain file. Only domain yaml files can be migrated. "
+                f"The domain from '{domain_path}' could not be migrated to "
+                f"{out_path} because a file already exists."
path: rasa/core/migrate.py
text: @user1:
```suggestion
                f"'{out_path}' because a file already exists."
```
Plus I think this might need a unit test too?

@author:
done

diff_hunk: @@ -916,3 +941,22 @@ def test_migration_cleanup(tmp_path: Path,):
     )
     assert not Path.exists(current_dir / "original_domain")
     assert not Path.exists(current_dir / "new_domain")
+
+
+def test_migration_stops_when_migrated_files_are_found(tmp_path: Path,):
+    domain_dir = tmp_path / "domain"
+    domain_dir.mkdir()
+    prepare_domain_path(
+        domain_dir,
+        """
+        version: "3.0"
+        """,
+        "domain.yml",
+    )
+
+    with pytest.raises(
+        RasaException,
+        match=f"The domain directory '{domain_dir}' does not contain any domain "
path: tests/core/test_migrate.py
text: @user1:
A bit confused here why this exception message gets raised? Shouldn't the message be the one that says that `some of the given files were already migrated to 3.0`?

@author:
Ohhh, yep, forgot to fix this. Files not containing any of the indicator keys (like "intents" and so on) are not considered domain files. So the above is no domain file actually -- Need to add some key and change the message here

@author:
done

diff_hunk: @@ -238,59 +262,114 @@ def _migrate_domain_files(
 
 
 def migrate_domain_format(
-    domain_file: Union[Text, Path], out_file: Union[Text, Path]
+    domain_path: Union[Text, Path], out_path: Optional[Union[Text, Path]],
 ) -> None:
     """Converts 2.0 domain to 3.0 format."""
-    domain_file = Path(domain_file)
-    out_file = Path(out_file)
-    created_out_dir = False
-
-    current_dir = domain_file.parent
+    domain_path = Path(domain_path)
+    out_path = Path(out_path) if out_path else None
+
+    domain_parent_dir = domain_path.parent
+    migrate_file_only = domain_path.is_file()
+
+    # Ensure the backup location does not exist yet
+    # Note: We demand that file as well as folder with this name gets deleted before
+    # the command is run to avoid confusion afterwards.
+    suffix = "original_domain"
+    suffix = f"{suffix}.yml" if migrate_file_only else suffix
+    backup_location = domain_parent_dir / suffix
+    if backup_location.exists():
+        backup_location_str = "directory" if backup_location.isdir() else "file"
+        raise RasaException(
+            f"The domain from '{domain_path}' could not be migrated since the "
+            f"a {backup_location_str} {backup_location} already exists."
+            f"Please remove that there is no file or folder at {backup_location}."
+        )
 
-    if domain_file.is_dir():
-        backup_location = current_dir / "original_domain"
-        backup_location.mkdir()
-
-        if out_file.is_file() or not out_file.exists():
-            out_file = current_dir / "new_domain"
-            out_file.mkdir()
-            created_out_dir = True
-            rasa.shared.utils.io.raise_warning(
-                f"The out path provided is not a directory, "
-                f"creating a new directory '{str(out_file)}' "
-                f"for migrated domain files."
-            )
+    # Choose a default output location if nothing was specified
+    if out_path is None:
+        suffix = DEFAULT_DOMAIN_PATH if migrate_file_only else "new_domain"
+        out_path = domain_parent_dir / suffix
 
-        try:
-            original_domain = _migrate_domain_files(
-                domain_file, backup_location, out_file
+    # Ensure the output location is not already in-use
+    if not migrate_file_only:
+        if out_path.is_dir() and any(out_path.iterdir()):
+            raise RasaException(
+                f"The domain from '{domain_path}' could not be migrated to "
+                f"{out_path} because that folder is not empty."
+                "Please remove the folder and try again."
             )
-        except Exception as e:
-            shutil.rmtree(backup_location)
-            if out_file != domain_file:
-                shutil.rmtree(out_file)
-                if not created_out_dir:
-                    # we recreate the deleted directory
-                    # because it existed before
-                    out_file.mkdir()
-            raise e
     else:
-        if not Domain.is_domain_file(domain_file):
+        if out_path.is_file():
             raise RasaException(
-                f"The file '{domain_file}' could not be validated as a "
-                f"domain file. Only domain yaml files can be migrated. "
+                f"The domain from '{domain_path}' could not be migrated to "
+                f"{out_path} because a file already exists."
+                "Please remove the file and try again."
             )
 
-        backup_location = current_dir / "original_domain.yml"
-        original_domain = _create_back_up(domain_file, backup_location)
-
-    new_forms, updated_slots = _migrate_form_slots(original_domain)
-    new_slots = _migrate_auto_fill_and_custom_slots(original_domain, updated_slots)
+    # Sanity Check: Assert the files to be migrated aren't in 3.0 format already
+    # Note: we do not enforce that the version tag is 2.0 everywhere + validate that
+    # migrate-able domain files are among these files later
+    original_files = (
+        [file for file in domain_path.iterdir() if Domain.is_domain_file(file)]
+        if domain_path.is_dir()
+        else [domain_path]
+    )
+    migrated_files = [
+        file
+        for file in original_files
+        if rasa.shared.utils.io.read_yaml_file(file).get("version") == "3.0"
+    ]
+    if migrated_files:
+        raise RasaException(
+            f"Some of the given files ({[file for file in migrated_files]}) "
+            f"have already been migrated to Rasa 3.0 format. Please remove these "
+            f"migrated files (or replace them with files in 2.0 format) and try again."
+        )
 
-    _write_final_domain(domain_file, new_forms, new_slots, out_file)
+    # Validate given domain file(s) and migrate them
+    try:
+        created_out_dir = False
+        if not migrate_file_only:
+            if not out_path.is_dir():
+                rasa.shared.utils.io.raise_warning(
+                    f"The out path provided did not exist yet. Created directory "
+                    f"{out_path}."
path: rasa/core/migrate.py
text: @user1:
```suggestion
                    f"The out path provided does not exist. Created directory "
                    f"'{out_path}'."
```

diff_hunk: @@ -238,59 +262,114 @@ def _migrate_domain_files(
 
 
 def migrate_domain_format(
-    domain_file: Union[Text, Path], out_file: Union[Text, Path]
+    domain_path: Union[Text, Path], out_path: Optional[Union[Text, Path]],
 ) -> None:
     """Converts 2.0 domain to 3.0 format."""
-    domain_file = Path(domain_file)
-    out_file = Path(out_file)
-    created_out_dir = False
-
-    current_dir = domain_file.parent
+    domain_path = Path(domain_path)
+    out_path = Path(out_path) if out_path else None
+
+    domain_parent_dir = domain_path.parent
+    migrate_file_only = domain_path.is_file()
+
+    # Ensure the backup location does not exist yet
+    # Note: We demand that file as well as folder with this name gets deleted before
+    # the command is run to avoid confusion afterwards.
+    suffix = "original_domain"
+    suffix = f"{suffix}.yml" if migrate_file_only else suffix
+    backup_location = domain_parent_dir / suffix
+    if backup_location.exists():
+        backup_location_str = "directory" if backup_location.isdir() else "file"
+        raise RasaException(
+            f"The domain from '{domain_path}' could not be migrated since the "
+            f"a {backup_location_str} {backup_location} already exists."
+            f"Please remove that there is no file or folder at {backup_location}."
path: rasa/core/migrate.py
text: @user1:
```suggestion
            f"Please make sure that there is no {back_up_location_str} at '{backup_location}'."
```