Repository: cookiecutter/cookiecutter

Issue:
Nested Config Files: New format for "template" key
* Cookiecutter version: 2.4.0
* Template project URL: https://github.com/plone/cookiecutter-plone/blob/main/cookiecutter.json
* Python version: 3.11
* Operating System: macOs

### Description:

The format for the `template` key requires some additional parsing:

```json
    {
        "template": [
            "Project 1 (./project-1)",
            "Project 2 (./project-2)"
        ]
    }
```

Our proposal is also to support the following format:

```json
    {
        "template": {
            "project-1": {
                "path": "./project-1",
                "title": "Project 1",
                "description": "A cookiecutter template for a project"
            },
            "project-2": {
                "path": "./package",
                "title": "Package",
                "description": "A cookiecutter template for a package"
            }
        }
    }

```

Discussion:
kurtmckee: Thanks for tagging me to review this! I hope the feedback is helpful. Have a great weekend!
ericof: The old format expects a list of strings.
kurtmckee: Note: The Windows tests are failing due to the absolute paths tests.

Patch:
diff --git a/cookiecutter/main.py b/cookiecutter/main.py
index f8b6608..65871ab 100644
--- a/cookiecutter/main.py
+++ b/cookiecutter/main.py
@@ -6,7 +6,6 @@ library rather than a script.
 """
 import logging
 import os
-import re
 import sys
 from copy import copy
 from pathlib import Path
@@ -15,6 +14,7 @@ from cookiecutter.config import get_user_config
 from cookiecutter.exceptions import InvalidModeException
 from cookiecutter.generate import generate_context, generate_files
 from cookiecutter.hooks import run_pre_prompt_hook
+from cookiecutter.prompt import choose_nested_template
 from cookiecutter.prompt import prompt_for_config
 from cookiecutter.replay import dump, load
 from cookiecutter.repository import determine_repo_dir
@@ -138,16 +138,10 @@ def cookiecutter(
     # except when 'no-input' flag is set
 
     with import_patch:
-        if context_for_prompting['cookiecutter']:
-            context['cookiecutter'].update(
-                prompt_for_config(context_for_prompting, no_input)
-            )
-        if "template" in context["cookiecutter"]:
-            nested_template = re.search(
-                r'\((.*?)\)', context["cookiecutter"]["template"]
-            ).group(1)
+        if {"template", "templates"} & set(context["cookiecutter"].keys()):
+            nested_template = choose_nested_template(context, repo_dir, no_input)
             return cookiecutter(
-                template=os.path.join(repo_dir, nested_template),
+                template=nested_template,
                 checkout=checkout,
                 no_input=no_input,
                 extra_context=extra_context,
@@ -162,6 +156,10 @@ def cookiecutter(
                 accept_hooks=accept_hooks,
                 keep_project_on_failure=keep_project_on_failure,
             )
+        if context_for_prompting['cookiecutter']:
+            context['cookiecutter'].update(
+                prompt_for_config(context_for_prompting, no_input)
+            )
 
     logger.debug('context is %s', context)
 
diff --git a/cookiecutter/prompt.py b/cookiecutter/prompt.py
index 16830d4..a2ce111 100644
--- a/cookiecutter/prompt.py
+++ b/cookiecutter/prompt.py
@@ -1,6 +1,8 @@
 """Functions for prompting the user for project info."""
 import json
+import re
 from collections import OrderedDict
+from pathlib import Path
 
 from rich.prompt import Prompt, Confirm, PromptBase, InvalidResponse
 from jinja2.exceptions import UndefinedError
@@ -217,6 +219,27 @@ def render_variable(env, raw, cookiecutter_dict):
     return template.render(cookiecutter=cookiecutter_dict)
 
 
+def _prompts_from_options(options: dict) -> dict:
+    """Process template options and return friendly prompt information."""
+    prompts = {"__prompt__": "Select a template"}
+    for option_key, option_value in options.items():
+        title = str(option_value.get("title", option_key))
+        description = option_value.get("description", option_key)
+        label = title if title == description else f"{title} ({description})"
+        prompts[option_key] = label
+    return prompts
+
+
+def prompt_choice_for_template(key, options, no_input):
+    """Prompt user with a set of options to choose from.
+
+    :param no_input: Do not prompt for user input and return the first available option.
+    """
+    opts = list(options.keys())
+    prompts = {"templates": _prompts_from_options(options)}
+    return opts[0] if no_input else read_user_choice(key, opts, prompts, "")
+
+
 def prompt_choice_for_config(
     cookiecutter_dict, env, key, options, no_input, prompts=None, prefix=""
 ):
@@ -238,16 +261,11 @@ def prompt_for_config(context, no_input=False):
     """
     cookiecutter_dict = OrderedDict([])
     env = StrictEnvironment(context=context)
-
-    prompts = {}
-    if '__prompts__' in context['cookiecutter'].keys():
-        prompts = context['cookiecutter']['__prompts__']
-        del context['cookiecutter']['__prompts__']
+    prompts = context['cookiecutter'].pop('__prompts__', {})
 
     # First pass: Handle simple and raw variables, plus choices.
     # These must be done first because the dictionaries keys and
     # values might refer to them.
-
     count = 0
     all_prompts = context['cookiecutter'].items()
     visible_prompts = [k for k, _ in all_prompts if not k.startswith("_")]
@@ -313,3 +331,40 @@ def prompt_for_config(context, no_input=False):
             raise UndefinedVariableInTemplate(msg, err, context) from err
 
     return cookiecutter_dict
+
+
+def choose_nested_template(context: dict, repo_dir: str, no_input: bool = False) -> str:
+    """Prompt user to select the nested template to use.
+
+    :param context: Source for field names and sample values.
+    :param repo_dir: Repository directory.
+    :param no_input: Do not prompt for user input and use only values from context.
+    :returns: Path to the selected template.
+    """
+    cookiecutter_dict = OrderedDict([])
+    env = StrictEnvironment(context=context)
+    prefix = ""
+    prompts = context['cookiecutter'].pop('__prompts__', {})
+    key = "templates"
+    config = context['cookiecutter'].get(key, {})
+    if config:
+        # Pass
+        val = prompt_choice_for_template(key, config, no_input)
+        template = config[val]["path"]
+    else:
+        # Old style
+        key = "template"
+        config = context['cookiecutter'].get(key, [])
+        val = prompt_choice_for_config(
+            cookiecutter_dict, env, key, config, no_input, prompts, prefix
+        )
+        template = re.search(r'\((.+)\)', val).group(1)
+
+    template = Path(template) if template else None
+    if not (template and not template.is_absolute()):
+        raise ValueError("Illegal template path")
+
+    repo_dir = Path(repo_dir).resolve()
+    template_path = (repo_dir / template).resolve()
+    # Return path as string
+    return f"{template_path}"
diff --git a/docs/advanced/nested_config_files.rst b/docs/advanced/nested_config_files.rst
index 464fd8d..ca5a543 100644
--- a/docs/advanced/nested_config_files.rst
+++ b/docs/advanced/nested_config_files.rst
@@ -1,10 +1,12 @@
 .. _nested-config-files:
 
 Nested configuration files
-----------------------------------------------
+--------------------------
+
+*New in Cookiecutter 2.5.0*
 
 If you wish to create a hierarchy of templates and use cookiecutter to choose among them,
-you need just to specify the key ``template`` in the main configuration file to reach
+you need just to specify the key ``templates`` in the main configuration file to reach
 the other ones.
 
 Let's imagine to have the following structure::
@@ -14,7 +16,7 @@ Let's imagine to have the following structure::
         │   ├── cookiecutter.json
         │   ├── {{cookiecutter.project_slug}}
         |	│   ├── ...
-        ├── project-2
+        ├── package
         │   ├── cookiecutter.json
         │   ├── {{cookiecutter.project_slug}}
         |	│   ├── ...
@@ -23,6 +25,44 @@ Let's imagine to have the following structure::
 It is possible to specify in the main ``cookiecutter.json`` how to reach the other
 config files as follows:
 
+.. code-block:: JSON
+
+    {
+        "templates": {
+            "project-1": {
+                "path": "./project-1",
+                "title": "Project 1",
+                "description": "A cookiecutter template for a project"
+            },
+            "package": {
+                "path": "./package",
+                "title": "Package",
+                "description": "A cookiecutter template for a package"
+            }
+        }
+    }
+
+Then, when ``cookiecutter`` is launched in the main directory it will ask to choose
+among the possible templates:
+
+.. code-block::
+
+    Select template:
+    1 - Project 1 (A cookiecutter template for a project)
+    2 - Package (A cookiecutter template for a package)
+    Choose from 1, 2 [1]:
+
+Once a template is chosen, for example ``1``, it will continue to ask the info required by
+``cookiecutter.json`` in the ``project-1`` folder, such as ``project-slug``
+
+
+Old Format
+++++++++++
+
+*New in Cookiecutter 2.2.0*
+
+In the main ``cookiecutter.json`` add a `template` key with the following format:
+
 .. code-block:: JSON
 
     {
@@ -32,10 +72,10 @@ config files as follows:
         ]
     }
 
-Then, when ``cookiecutter`` is launched in the main directory it will ask to choice
+Then, when ``cookiecutter`` is launched in the main directory it will ask to choose
 among the possible templates:
 
-.. code-block:: bash
+.. code-block::
 
     Select template:
     1 - Project 1 (./project-1)


Test patch:
diff --git a/tests/fake-nested-templates-old-style/cookiecutter.json b/tests/fake-nested-templates-old-style/cookiecutter.json
new file mode 100644
index 0000000..2cdf1d2
--- /dev/null
+++ b/tests/fake-nested-templates-old-style/cookiecutter.json
@@ -0,0 +1,11 @@
+{
+  "template": [
+    "fake-package (./fake-package)"
+  ],
+  "__prompts__": {
+    "template": {
+      "__prompt__": "Select a template",
+      "fake-package (./fake-package)": "Fake Package"
+    }
+  }
+}
diff --git a/tests/fake-nested-templates-old-style/fake-package/cookiecutter.json b/tests/fake-nested-templates-old-style/fake-package/cookiecutter.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/tests/fake-nested-templates-old-style/fake-package/cookiecutter.json
@@ -0,0 +1,1 @@
+{}
diff --git a/tests/fake-nested-templates/cookiecutter.json b/tests/fake-nested-templates/cookiecutter.json
index e3cb3a6..b0f40e8 100644
--- a/tests/fake-nested-templates/cookiecutter.json
+++ b/tests/fake-nested-templates/cookiecutter.json
@@ -1,5 +1,14 @@
 {
-  "template": [
-    "fake-project (fake-project)"
-  ]
+  "templates": {
+    "fake-project": {
+      "path": "./fake-project",
+      "title": "A Fake Project",
+      "description": "A cookiecutter template for a project"
+    },
+    "fake-package": {
+      "path": "./fake-package",
+      "title": "A Fake Package",
+      "description": "A cookiecutter template for a package"
+    }
+  }
 }
diff --git a/tests/fake-nested-templates/fake-package/cookiecutter.json b/tests/fake-nested-templates/fake-package/cookiecutter.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/tests/fake-nested-templates/fake-package/cookiecutter.json
@@ -0,0 +1,1 @@
+{}
diff --git a/tests/test_cookiecutter_nested_templates.py b/tests/test_cookiecutter_nested_templates.py
index 8dc953d..e12e546 100644
--- a/tests/test_cookiecutter_nested_templates.py
+++ b/tests/test_cookiecutter_nested_templates.py
@@ -1,14 +1,21 @@
 """Test cookiecutter invocation with nested configuration structure."""
-import os
+from pathlib import Path
+import pytest
 
 from cookiecutter import main
 
 
-def test_cookiecutter_nested_templates(mocker):
+@pytest.mark.parametrize(
+    "template_dir,output_dir",
+    [
+        ["fake-nested-templates", "fake-project"],
+        ["fake-nested-templates-old-style", "fake-package"],
+    ],
+)
+def test_cookiecutter_nested_templates(mocker, template_dir: str, output_dir: str):
     """Verify cookiecutter nested configuration files mechanism."""
     mock_generate_files = mocker.patch("cookiecutter.main.generate_files")
-    main_dir = os.path.join("tests", "fake-nested-templates")
-    main.cookiecutter(main_dir, no_input=True)
-    assert mock_generate_files.call_args[1]["repo_dir"] == os.path.join(
-        main_dir, "fake-project"
-    )
+    main_dir = (Path("tests") / template_dir).resolve()
+    main.cookiecutter(f"{main_dir}", no_input=True)
+    expected = (Path(main_dir) / output_dir).resolve()
+    assert mock_generate_files.call_args[1]["repo_dir"] == f"{expected}"
diff --git a/tests/test_prompt.py b/tests/test_prompt.py
index f7f3b7e..1b84dac 100644
--- a/tests/test_prompt.py
+++ b/tests/test_prompt.py
@@ -1,8 +1,12 @@
 """Tests for `cookiecutter.prompt` module."""
 import platform
+import sys
+
 from collections import OrderedDict
+from pathlib import Path
 
 import click
+import json
 import pytest
 
 from cookiecutter import prompt, exceptions, environment
@@ -573,3 +577,66 @@ def test_undefined_variable(context):
     error = err.value
     assert error.message == "Unable to render variable 'foo'"
     assert error.context == context
+
+
+@pytest.mark.parametrize(
+    "template_dir,expected",
+    [
+        ["fake-nested-templates", "fake-project"],
+        ["fake-nested-templates-old-style", "fake-package"],
+    ],
+)
+def test_cookiecutter_nested_templates(template_dir: str, expected: str):
+    """Test nested_templates generation."""
+    from cookiecutter import prompt
+
+    main_dir = (Path("tests") / template_dir).resolve()
+    cookiecuter_context = json.loads((main_dir / "cookiecutter.json").read_text())
+    context = {"cookiecutter": cookiecuter_context}
+    output_dir = prompt.choose_nested_template(context, main_dir, no_input=True)
+    expected = (Path(main_dir) / expected).resolve()
+    assert output_dir == f"{expected}"
+
+
+@pytest.mark.skipif(sys.platform.startswith('win'), reason="Linux / macos test")
+@pytest.mark.parametrize(
+    "path",
+    [
+        "",
+        "/tmp",
+        "/foo",
+    ],
+)
+def test_cookiecutter_nested_templates_invalid_paths(path: str):
+    """Test nested_templates generation."""
+    from cookiecutter import prompt
+
+    main_dir = (Path("tests") / "fake-nested-templates").resolve()
+    cookiecuter_context = json.loads((main_dir / "cookiecutter.json").read_text())
+    cookiecuter_context["templates"]["fake-project"]["path"] = path
+    context = {"cookiecutter": cookiecuter_context}
+    with pytest.raises(ValueError) as exc:
+        prompt.choose_nested_template(context, main_dir, no_input=True)
+    assert "Illegal template path" in str(exc)
+
+
+@pytest.mark.skipif(not sys.platform.startswith('win'), reason="Win only test")
+@pytest.mark.parametrize(
+    "path",
+    [
+        "",
+        "C:/tmp",
+        "D:/tmp",
+    ],
+)
+def test_cookiecutter_nested_templates_invalid_win_paths(path: str):
+    """Test nested_templates generation."""
+    from cookiecutter import prompt
+
+    main_dir = (Path("tests") / "fake-nested-templates").resolve()
+    cookiecuter_context = json.loads((main_dir / "cookiecutter.json").read_text())
+    cookiecuter_context["templates"]["fake-project"]["path"] = path
+    context = {"cookiecutter": cookiecuter_context}
+    with pytest.raises(ValueError) as exc:
+        prompt.choose_nested_template(context, main_dir, no_input=True)
+    assert "Illegal template path" in str(exc)