Source code for md2star.templates

"""``md2star templates {list,path}``: inspect bundled / cached / per-project templates.

Module summary
--------------
A diagnostic CLI verb that mirrors the resolution order of
``md2star.pipeline._resolve_reference_doc`` without actually
converting anything. Two sub-verbs:

* ``md2star templates list``: prints a table of every template
  resolution candidate (per-project / cached / bundled) and
  whether it exists. Distinguishes ``docx`` from ``pptx`` rows so
  you can see at a glance which format is missing its template.

* ``md2star templates path [--fmt {docx,pptx}] [INPUT]``: prints
  the absolute filesystem path of the template that md2star WOULD
  use for the given input (default: current directory + ``docx``).
  One line, parseable by shell scripts.

The motivation is the recurring "why isn't my branding applied?"
debug loop. Before this command, users had to read the
``_resolve_reference_doc`` docstring and re-derive the priority
order in their head. Now they ``md2star templates path file.md``
and md2star tells them.

Usage
-----
>>> # From the shell:
>>> #   md2star templates list
>>> #   md2star templates path report.md --fmt docx
>>> # gives you the per-format resolution table and the resolved
>>> # path that `md2docx report.md` would pick up.

Author
------
[Warith HARCHAOUI](https://linkedin.com/in/warith-harchaoui/)
"""

from __future__ import annotations

from pathlib import Path

from .cache import cache_dir
from .logging import get_logger

# Module logger — child of the root "md2star" logger (configured by the CLI).
logger = get_logger(__name__)

# ─────────────────────────────────────────────────────────────────────
# Candidate enumeration — mirrors `_resolve_reference_doc` priority
# ─────────────────────────────────────────────────────────────────────


def _bundled_template_path(fmt: str) -> Path:
    """Return the absolute path to the bundled ``template.<fmt>``.

    Duplicates :func:`md2star.pipeline._bundled_template` so this
    module does not have to import from :mod:`md2star.pipeline`
    (which would pull in every preprocessor phase just to print a
    table).
    """
    from importlib import resources

    # joinpath one component at a time: Python 3.10's MultiplexedPath only
    # accepts a single arg per call, so the multi-arg form would break there.
    ref = resources.files("md2star.data")
    for part in (f"template.{fmt}",):
        ref = ref.joinpath(part)
    return Path(str(ref))


def _candidates(input_dir: Path, fmt: str) -> list[tuple[str, Path]]:
    """Return ``[(label, candidate_path), ...]`` in resolution order.

    Mirrors the resolution chain in :func:`md2star.pipeline._resolve_reference_doc`
    so the user-visible "which template wins" answer stays in sync
    with the actual conversion path.
    """
    # Highest priority first: a template committed next to the source, then the
    # legacy dotfile name, then the XDG cache, then the always-present bundled
    # copy. _first_existing() walks this list top-down to pick the winner.
    #
    # Note: between "cached" and "bundled" the real resolver
    # (`_resolve_reference_doc`) fetches https://deraison.ai/template.<fmt> by
    # default (v2.5.0+) and writes it into the "cached" slot. This diagnostic is
    # a filesystem-only view, so it cannot show that network step directly — it
    # surfaces as a populated "cached" row after the first online run.
    return [
        ("per-project (template)", input_dir / f"template.{fmt}"),
        ("per-project (legacy)", input_dir / f".pandoc-reference.{fmt}"),
        ("cached", cache_dir("templates") / f"template.{fmt}"),
        ("bundled", _bundled_template_path(fmt)),
    ]


def _first_existing(candidates: list[tuple[str, Path]]) -> tuple[str, Path] | None:
    """Return the first ``(label, path)`` whose path exists, or ``None``."""
    # Order is priority (see _candidates): the first hit is the active template.
    for label, path in candidates:
        if path.exists():
            return label, path
    return None


# ─────────────────────────────────────────────────────────────────────
# `md2star templates list` — table of every candidate
# ─────────────────────────────────────────────────────────────────────


[docs] def cmd_list(directory: str | None = None) -> int: """Print a per-format resolution table to stdout. Walks the same candidate chain as the conversion path, marks each existing file with ``✓``, marks the first existing one as the active winner with ``→``. No conversion, no network. Parameters ---------- directory : str or None, optional The per-project root, i.e. the directory that would hold ``template.docx``. ``None`` means the current directory. Returns ------- int Always ``0``: a resolution table cannot fail, only be empty. """ # Resolve relative to --dir if given, else the current directory — the same # anchor the real conversion uses for per-project templates. input_dir = Path(directory).expanduser().resolve() if directory else Path.cwd() # This is program OUTPUT (a report the user reads), so it goes to stdout via # print, not the logging surface. print(f"md2star templates — resolution from {input_dir}\n") for fmt in ("docx", "pptx"): print(f" [{fmt}]") candidates = _candidates(input_dir, fmt) # The winner is the first existing candidate; mark it distinctly below. winner = _first_existing(candidates) winner_path = winner[1] if winner else None for label, path in candidates: # → active winner, ✓ present-but-shadowed, space = absent. mark = "→" if path == winner_path else ("✓" if path.exists() else " ") size_note = "" if path.exists(): size = path.stat().st_size size_note = f" ({size:,} bytes)" print(f" {mark} {label:<25} {path}{size_note}") print() return 0
# ───────────────────────────────────────────────────────────────────── # `md2star templates path` — script-friendly resolved path # ─────────────────────────────────────────────────────────────────────
[docs] def cmd_path(fmt: str = "docx", source: str | None = None) -> int: """Print the absolute path of the active template for *fmt*. Useful for shell scripts that want to operate on the template md2star is currently using (e.g. opening it in Word for branding). Parameters ---------- fmt : {"docx", "pptx"}, optional Which format's template to resolve. source : str or None, optional An input Markdown file; its containing directory becomes the per-project root. ``None`` uses the current directory. Returns ------- int ``0`` with the path on stdout, or ``2`` + a stderr diagnostic when no template exists at any candidate location (which is an install bug, since one is bundled). """ input_dir = Path(source).expanduser().resolve().parent if source else Path.cwd() winner = _first_existing(_candidates(input_dir, fmt)) if winner is None: # No template anywhere is an install bug — diagnostic to stderr. logger.error( f"md2star: no template.{fmt} found at any candidate path " f"(per-project / cached / bundled). Reinstall md2star." ) return 2 # The resolved path itself is program OUTPUT (scripts capture it), so it # stays on stdout via print — not the logging surface. print(winner[1]) return 0