krz/nfogen

A scene-style ASCII .nfo generator.

clone: git clone https://gitbay.org/krz/nfogen.git

3d2fb0c76bc3886935e942fafee94b2d08ae630a

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-08T06:31:04Z

resolve sonarcloud issues: dedupe render/main via extraction, dict literals, @type constant, drop unused import; accept CLI path-taint findings as by-design (NOSONAR)
 nfogen.py | 562 +++++++++++++++++++++++++++++++++++---------------------------
 1 file changed, 316 insertions(+), 246 deletions(-)

diff --git a/nfogen.py b/nfogen.py
index cd44a21..fbf3364 100755
--- a/nfogen.py
+++ b/nfogen.py
@@ -9,7 +9,6 @@ from __future__ import annotations
 
 import argparse
 import json
-import os
 import subprocess
 import sys
 import textwrap
@@ -77,12 +76,12 @@ PANEL_TITLES = ("Release Information", "Game Information")
 # jt/jm/jb are the down-tee, cross, and up-tee used where the panel's
 # vertical column divider meets a horizontal rule.
 STYLES = {
-    "single": dict(tl="┌", tr="┐", bl="└", br="┘", h="─", v="│",
-                   ls="├", rs="┤", jt="┬", jm="┼", jb="┴"),
-    "double": dict(tl="╔", tr="╗", bl="╚", br="╝", h="═", v="║",
-                   ls="╠", rs="╣", jt="╦", jm="╬", jb="╩"),
-    "block":  dict(tl="█", tr="█", bl="█", br="█", h="█", v="█",
-                   ls="█", rs="█", jt="█", jm="█", jb="█"),
+    "single": {"tl": "┌", "tr": "┐", "bl": "└", "br": "┘", "h": "─", "v": "│",
+               "ls": "├", "rs": "┤", "jt": "┬", "jm": "┼", "jb": "┴"},
+    "double": {"tl": "╔", "tr": "╗", "bl": "╚", "br": "╝", "h": "═", "v": "║",
+               "ls": "╠", "rs": "╣", "jt": "╦", "jm": "╬", "jb": "╩"},
+    "block":  {"tl": "█", "tr": "█", "bl": "█", "br": "█", "h": "█", "v": "█",
+               "ls": "█", "rs": "█", "jt": "█", "jm": "█", "jb": "█"},
 }
 
 CONFIG_CANDIDATES = [
@@ -97,7 +96,7 @@ def load_config(explicit: str | None) -> dict:
     """Load defaults from a TOML or JSON file. Returns {} if none found."""
     path = None
     if explicit:
-        path = Path(explicit)
+        path = Path(explicit)  # NOSONAR local CLI: the user's own path, no trust boundary
         if not path.exists():
             sys.exit(f"nfogen: config not found: {explicit}")
     else:
@@ -105,7 +104,7 @@ def load_config(explicit: str | None) -> dict:
     if path is None:
         return {}
 
-    text = path.read_text(encoding="utf-8")
+    text = path.read_text(encoding="utf-8")  # NOSONAR local CLI: the user's own path
     if path.suffix == ".json":
         return json.loads(text)
     try:
@@ -116,15 +115,18 @@ def load_config(explicit: str | None) -> dict:
 
 
 # --- mediainfo import --------------------------------------------------------
+_MI_TYPE = "@type"  # mediainfo track-type key
+
+
 def load_mediainfo(path: str) -> dict:
     """Read a `mediainfo --Output=JSON` dump (or run mediainfo on a media file)
     and return values for the video/audio/resolution/size/runtime/format keys."""
-    p = Path(path)
+    p = Path(path)  # NOSONAR local CLI: the user's own path, no trust boundary
     if p.suffix.lower() == ".json":
-        raw = p.read_text(encoding="utf-8")
+        raw = p.read_text(encoding="utf-8")  # NOSONAR local CLI: the user's own path
     else:
         try:
-            proc = subprocess.run(["mediainfo", "--Output=JSON", str(p)],
+            proc = subprocess.run(["mediainfo", "--Output=JSON", str(p)],  # NOSONAR local CLI: the user's own file
                                   capture_output=True, text=True, check=True)
         except FileNotFoundError:
             sys.exit("nfogen: mediainfo not installed; pass a JSON dump to --mediainfo")
@@ -165,49 +167,70 @@ def _mi_duration(s) -> str:
     return f"{h}h {m:02d}m" if h else f"{m}m {sec % 60:02d}s"
 
 
-def _extract_media(doc: dict) -> dict:
-    tracks = (doc.get("media") or {}).get("track") or []
-    g = next((t for t in tracks if t.get("@type") == "General"), {})
-    v = next((t for t in tracks if t.get("@type") == "Video"), {})
-    auds = [t for t in tracks if t.get("@type") == "Audio"]
-    txts = [t for t in tracks if t.get("@type") == "Text"]
-    a = auds[0] if auds else {}
-    channels = {"1": "1.0", "2": "2.0", "6": "5.1", "8": "7.1"}
-    out: dict = {}
+def _mi_tracks(tracks: list, kind: str) -> list:
+    return [t for t in tracks if t.get(_MI_TYPE) == kind]
+
 
+def _mi_general(g: dict) -> dict:
+    out = {}
     if g.get("Format"):
         out["format"] = g["Format"]
     if g.get("FileSize"):
         out["size"] = _mi_size(g["FileSize"])
-    if g.get("Duration") or v.get("Duration"):
-        out["runtime"] = _mi_duration(g.get("Duration") or v.get("Duration"))
-
-    if v:
-        parts = [v.get("Encoded_Library_Name") or v.get("Format")]
-        if v.get("BitRate"):
-            parts.append(f"{int(float(v['BitRate'])) // 1000} kbps")
-        if v.get("FrameRate"):
-            parts.append(f"{float(v['FrameRate']):g} fps")
-        out["video"] = ", ".join(p for p in parts if p)
-        if v.get("Width") and v.get("Height"):
-            out["resolution"] = f"{_mi_int(v['Width'])}x{_mi_int(v['Height'])}"
-
-    if a:
-        parts = [a.get("Format")]
-        if a.get("Channels"):
-            parts.append(channels.get(str(a["Channels"]), f"{a['Channels']}ch"))
-        if a.get("BitRate"):
-            parts.append(f"{int(float(a['BitRate'])) // 1000} kbps")
-        audio = ", ".join(p for p in parts if p)
-        if a.get("Language"):
-            audio = f"{audio} ({a['Language']})" if audio else a["Language"]
-        out["audio"] = audio
+    return out
 
+
+def _mi_video(v: dict) -> dict:
+    if not v:
+        return {}
+    parts = [v.get("Encoded_Library_Name") or v.get("Format")]
+    if v.get("BitRate"):
+        parts.append(f"{int(float(v['BitRate'])) // 1000} kbps")
+    if v.get("FrameRate"):
+        parts.append(f"{float(v['FrameRate']):g} fps")
+    out = {"video": ", ".join(p for p in parts if p)}
+    if v.get("Width") and v.get("Height"):
+        out["resolution"] = f"{_mi_int(v['Width'])}x{_mi_int(v['Height'])}"
+    return out
+
+
+def _mi_audio(a: dict) -> dict:
+    if not a:
+        return {}
+    channels = {"1": "1.0", "2": "2.0", "6": "5.1", "8": "7.1"}
+    parts = [a.get("Format")]
+    if a.get("Channels"):
+        parts.append(channels.get(str(a["Channels"]), f"{a['Channels']}ch"))
+    if a.get("BitRate"):
+        parts.append(f"{int(float(a['BitRate'])) // 1000} kbps")
+    audio = ", ".join(p for p in parts if p)
+    if a.get("Language"):
+        audio = f"{audio} ({a['Language']})" if audio else a["Language"]
+    return {"audio": audio}
+
+
+def _mi_language(a: dict, txts: list) -> str:
     subs = list(dict.fromkeys(t.get("Language") for t in txts if t.get("Language")))
     lang = a.get("Language") or ""
     if subs:
-        out["language"] = (f"{lang} (subs: {', '.join(subs)})").strip()
-    elif lang:
+        return f"{lang} (subs: {', '.join(subs)})".strip()
+    return lang
+
+
+def _extract_media(doc: dict) -> dict:
+    tracks = (doc.get("media") or {}).get("track") or []
+    g = next(iter(_mi_tracks(tracks, "General")), {})
+    v = next(iter(_mi_tracks(tracks, "Video")), {})
+    auds = _mi_tracks(tracks, "Audio")
+    txts = _mi_tracks(tracks, "Text")
+    a = auds[0] if auds else {}
+
+    out = {**_mi_general(g), **_mi_video(v), **_mi_audio(a)}
+    dur = g.get("Duration") or v.get("Duration")
+    if dur:
+        out["runtime"] = _mi_duration(dur)
+    lang = _mi_language(a, txts)
+    if lang:
         out["language"] = lang
 
     return {k: val for k, val in out.items() if val}
@@ -286,180 +309,219 @@ def banner(text: str) -> str:
     return "\n".join(r.rstrip() for r in rows)
 
 
-def render(data: dict, group: str, site: str, width: int,
-           style: str = DEFAULT_STYLE, presents: bool = False,
-           footer: str | None = None, layout: str = DEFAULT_LAYOUT,
-           logo: str | None = None, roster: dict | None = None,
-           panel_titles: tuple[str, str] = PANEL_TITLES) -> str:
-    s = STYLES[style]
-    h, v = s["h"], s["v"]
-    inner = width - 4          # single-column content width (1 space padding)
-    cl = (width - 7) // 2      # left panel content width
-    cr = (width - 7) - cl      # right panel content width
-    rfill = width - cl - 5     # h-run right of the divider in a rule
-
-    def hrule(left: str, right: str, joint: str | None = None) -> str:
+class _Canvas:
+    """Accumulates boxed lines and holds the geometry for a width and style."""
+
+    def __init__(self, width: int, style: str):
+        self.s = STYLES[style]
+        self.width = width
+        self.inner = width - 4        # single-column content width
+        self.cl = (width - 7) // 2    # left panel content width
+        self.cr = (width - 7) - self.cl
+        self.rfill = width - self.cl - 5
+        self.out: list[str] = []
+        self.prev_panel = False
+
+    def add(self, text: str = "") -> None:
+        self.out.append(text)
+
+    def hrule(self, left: str, right: str, joint: str | None = None) -> str:
+        h = self.s["h"]
         if joint is None:
-            return left + h * (width - 2) + right
-        return left + h * (cl + 2) + joint + h * rfill + right
+            return left + h * (self.width - 2) + right
+        return left + h * (self.cl + 2) + joint + h * self.rfill + right
 
-    def line(content: str = "") -> str:
-        return f"{v} " + content.ljust(inner) + f" {v}"
+    def line(self, content: str = "") -> str:
+        v = self.s["v"]
+        return f"{v} " + content.ljust(self.inner) + f" {v}"
 
-    def center(content: str) -> str:
-        return f"{v} " + content.center(inner) + f" {v}"
+    def center(self, content: str) -> str:
+        v = self.s["v"]
+        return f"{v} " + content.center(self.inner) + f" {v}"
 
-    def prow(left: str, right: str, centered: bool = False) -> str:
+    def prow(self, left: str, right: str, centered: bool = False) -> str:
+        v = self.s["v"]
         fn = str.center if centered else str.ljust
-        return (f"{v} " + fn(_clip(left, cl), cl)
-                + f" {v} " + fn(_clip(right, cr), cr) + f" {v}")
+        return (f"{v} " + fn(_clip(left, self.cl), self.cl)
+                + f" {v} " + fn(_clip(right, self.cr), self.cr) + f" {v}")
+
+    def sep_before(self) -> str:
+        joint = self.s["jb"] if self.prev_panel else None
+        self.prev_panel = False
+        return self.hrule(self.s["ls"], self.s["rs"], joint)
+
+    def section(self, header: str) -> None:
+        self.add(self.sep_before())
+        self.add(self.line(f"  {header}"))
+        self.add(self.line())
 
-    out: list[str] = []
+    def names_block(self, names: list[str]) -> None:
+        for wrapped in textwrap.wrap("   ".join(names), self.inner - 4):
+            self.add(self.center(wrapped))
+        self.add(self.line())
 
-    # logo art above the box, block-centered to the full width
-    if logo:
-        art = logo.rstrip("\n").split("\n")
-        pad = max((width - max((len(a) for a in art), default=0)) // 2, 0)
-        out.extend((" " * pad + a).rstrip() for a in art)
-        out.append("")
+    def wrapped_body(self, text: str) -> None:
+        for para in text.splitlines() or [""]:
+            for chunk in (textwrap.wrap(para, self.inner - 4) or [""]):
+                self.add(self.line("  " + chunk))
 
-    out.append(hrule(s["tl"], s["tr"]))
 
-    # header: spaced title, then group tag + site, optional presents banner
+def _render_logo(cvs: _Canvas, logo: str | None) -> None:
+    if not logo:
+        return
+    art = logo.rstrip("\n").split("\n")
+    pad = max((cvs.width - max((len(a) for a in art), default=0)) // 2, 0)
+    cvs.out.extend((" " * pad + a).rstrip() for a in art)
+    cvs.add("")
+
+
+def _render_header(cvs: _Canvas, data: dict, group: str, site: str,
+                   presents: bool) -> None:
     title = str(data.get("title") or "UNTITLED").upper()
     spaced = " ".join(title)
-    heading = spaced if len(spaced) <= inner else title
-    out.append(center(_clip(heading, inner)))
-    out.append(center(_clip(f"[ {group} ]   {site}", inner)))
+    heading = spaced if len(spaced) <= cvs.inner else title
+    cvs.add(cvs.center(_clip(heading, cvs.inner)))
+    cvs.add(cvs.center(_clip(f"[ {group} ]   {site}", cvs.inner)))
     if presents:
-        out.append(center(_clip(f"-={{ {group.upper()} proudly presents }}=-", inner)))
-
-    line_fields = [(k, lbl, col) for k, lbl, _, kind, col in FIELDS
-                   if kind == "line" and k != "title" and data.get(k)]
-    prev_panel = False
-
-    def sep_before() -> str:
-        nonlocal prev_panel
-        joint = s["jb"] if prev_panel else None
-        prev_panel = False
-        return hrule(s["ls"], s["rs"], joint)
-
-    # info: two-column panel or single-column rows
-    if layout == "panel" and line_fields:
-        left = [(lbl, str(data[k])) for k, lbl, col in line_fields if col == "L"]
-        right = [(lbl, str(data[k])) for k, lbl, col in line_fields if col == "R"]
-        lw_l = max((len(lbl) for lbl, _ in left), default=0)
-        lw_r = max((len(lbl) for lbl, _ in right), default=0)
-
-        def cell(pair, lw):
-            return f" {pair[0]:<{lw}} : {pair[1]}" if pair else ""
-
-        out.append(hrule(s["ls"], s["rs"], s["jt"]))
-        out.append(prow(panel_titles[0], panel_titles[1], centered=True))
-        out.append(hrule(s["ls"], s["rs"], s["jm"]))
-        for lp, rp in zip_longest(left, right):
-            out.append(prow(cell(lp, lw_l), cell(rp, lw_r)))
-        prev_panel = True
-    elif line_fields:
-        out.append(hrule(s["ls"], s["rs"]))
-        lw = max(len(lbl) for _, lbl, _ in line_fields)
-        avail = inner - (2 + lw + 2)
-        for k, lbl, _ in line_fields:
-            wrapped = textwrap.wrap(str(data[k]), avail) or [""]
-            out.append(line(f"  {lbl:<{lw}}  {wrapped[0]}"))
-            for cont in wrapped[1:]:
-                out.append(line(" " * (2 + lw + 2) + cont))
-
-    # notes block
+        cvs.add(cvs.center(
+            _clip(f"-={{ {group.upper()} proudly presents }}=-", cvs.inner)))
+
+
+def _line_fields(data: dict) -> list:
+    return [(k, lbl, col) for k, lbl, _, kind, col in FIELDS
+            if kind == "line" and k != "title" and data.get(k)]
+
+
+def _render_panel(cvs: _Canvas, data: dict, fields: list,
+                  panel_titles: tuple[str, str]) -> None:
+    s = cvs.s
+    left = [(lbl, str(data[k])) for k, lbl, col in fields if col == "L"]
+    right = [(lbl, str(data[k])) for k, lbl, col in fields if col == "R"]
+    lw_l = max((len(lbl) for lbl, _ in left), default=0)
+    lw_r = max((len(lbl) for lbl, _ in right), default=0)
+
+    def cell(pair, lw):
+        return f" {pair[0]:<{lw}} : {pair[1]}" if pair else ""
+
+    cvs.add(cvs.hrule(s["ls"], s["rs"], s["jt"]))
+    cvs.add(cvs.prow(panel_titles[0], panel_titles[1], centered=True))
+    cvs.add(cvs.hrule(s["ls"], s["rs"], s["jm"]))
+    for lp, rp in zip_longest(left, right):
+        cvs.add(cvs.prow(cell(lp, lw_l), cell(rp, lw_r)))
+    cvs.prev_panel = True
+
+
+def _render_rows(cvs: _Canvas, data: dict, fields: list) -> None:
+    cvs.add(cvs.hrule(cvs.s["ls"], cvs.s["rs"]))
+    lw = max(len(lbl) for _, lbl, _ in fields)
+    avail = cvs.inner - (2 + lw + 2)
+    for k, lbl, _ in fields:
+        wrapped = textwrap.wrap(str(data[k]), avail) or [""]
+        cvs.add(cvs.line(f"  {lbl:<{lw}}  {wrapped[0]}"))
+        for cont in wrapped[1:]:
+            cvs.add(cvs.line(" " * (2 + lw + 2) + cont))
+
+
+def _render_info(cvs: _Canvas, data: dict, layout: str,
+                 panel_titles: tuple[str, str]) -> None:
+    fields = _line_fields(data)
+    if not fields:
+        return
+    if layout == "panel":
+        _render_panel(cvs, data, fields, panel_titles)
+    else:
+        _render_rows(cvs, data, fields)
+
+
+def _render_notes(cvs: _Canvas, data: dict) -> None:
     notes = str(data.get("notes") or "").strip()
-    if notes:
-        out.append(sep_before())
-        out.append(line("  NOTES"))
-        out.append(line())
-        for para in notes.splitlines() or [""]:
-            for wrapped in (textwrap.wrap(para, inner - 4) or [""]):
-                out.append(line("  " + wrapped))
-
-    # greets block
+    if not notes:
+        return
+    cvs.section("NOTES")
+    cvs.wrapped_body(notes)
+
+
+def _render_greets(cvs: _Canvas, data: dict) -> None:
     greets = _split_list(data.get("greets") or [])
-    if greets:
-        out.append(sep_before())
-        out.append(line("  GREETS"))
-        out.append(line())
-        for wrapped in textwrap.wrap("   ".join(greets), inner - 4):
-            out.append(line("  " + wrapped))
-
-    # group roster sections (from a profile): news, members, couriers,
-    # boards/affiliates tables, outposts
-    r = roster or {}
+    if not greets:
+        return
+    cvs.section("GREETS")
+    for wrapped in textwrap.wrap("   ".join(greets), cvs.inner - 4):
+        cvs.add(cvs.line("  " + wrapped))
 
-    def section(header: str) -> None:
-        out.append(sep_before())
-        out.append(line(f"  {header}"))
-        out.append(line())
-
-    def names_block(names: list[str]) -> None:
-        for wrapped in textwrap.wrap("   ".join(names), inner - 4):
-            out.append(center(wrapped))
-        out.append(line())
-
-    def table(header: str, entries: list) -> None:
-        section(header)
-        if entries and isinstance(entries[0], dict):
-            ws = _col_widths(inner - 2, [0.34, 0.24, 0.22, 0.20])
-            keys = ["name", "role", "sysop", "phone"]
-
-            def row(vals: list[str]) -> str:
-                cells = "".join(_clip(str(x), w - 1).ljust(w)
-                                for x, w in zip(vals, ws))
-                return line("  " + cells)
-
-            out.append(row(["BOARD", "ROLE", "SYSOP", "CONTACT"]))
-            for e in entries:
-                out.append(row([e.get(k, "") for k in keys]))
-        else:
-            names_block(_split_list(entries))
 
+def _render_table(cvs: _Canvas, header: str, entries: list) -> None:
+    cvs.section(header)
+    if entries and isinstance(entries[0], dict):
+        ws = _col_widths(cvs.inner - 2, [0.34, 0.24, 0.22, 0.20])
+        keys = ["name", "role", "sysop", "phone"]
+
+        def row(vals):
+            cells = "".join(_clip(str(x), w - 1).ljust(w)
+                            for x, w in zip(vals, ws))
+            return cvs.line("  " + cells)
+
+        cvs.add(row(["BOARD", "ROLE", "SYSOP", "CONTACT"]))
+        for e in entries:
+            cvs.add(row([e.get(k, "") for k in keys]))
+    else:
+        cvs.names_block(_split_list(entries))
+
+
+def _render_roster(cvs: _Canvas, roster: dict | None) -> None:
+    r = roster or {}
     news = str(r.get("news") or "").strip()
     if news:
-        section("GROUP NEWS")
-        for para in news.splitlines():
-            for wrapped in (textwrap.wrap(para, inner - 4) or [""]):
-                out.append(line("  " + wrapped))
-
+        cvs.section("GROUP NEWS")
+        cvs.wrapped_body(news)
     members = _split_list(r.get("members") or [])
     if members:
-        section("MEMBERS")
-        names_block(members)
-
+        cvs.section("MEMBERS")
+        cvs.names_block(members)
     couriers = r.get("couriers") or {}
     if couriers:
-        section("COURIERS")
+        cvs.section("COURIERS")
         for tier, who in couriers.items():
-            out.append(center(f"- {tier} -"))
-            names_block(_split_list(who))
-
+            cvs.add(cvs.center(f"- {tier} -"))
+            cvs.names_block(_split_list(who))
     if r.get("boards"):
-        table("BOARDS", r["boards"])
+        _render_table(cvs, "BOARDS", r["boards"])
     if r.get("affiliates"):
-        table("AFFILIATES", r["affiliates"])
-
+        _render_table(cvs, "AFFILIATES", r["affiliates"])
     outposts = _split_list(r.get("outposts") or [])
     if outposts:
-        section("OUTPOSTS")
-        names_block(outposts)
+        cvs.section("OUTPOSTS")
+        cvs.names_block(outposts)
+
+
+def _render_footer(cvs: _Canvas, footer: str | None) -> None:
+    if not footer:
+        return
+    cvs.add(cvs.sep_before())
+    cvs.add(cvs.line())
+    for para in footer.splitlines():
+        for wrapped in (textwrap.wrap(para, cvs.inner - 4) or [""]):
+            cvs.add(cvs.center(wrapped))
+    cvs.add(cvs.line())
 
-    # footer disclaimer, centered
-    if footer:
-        out.append(sep_before())
-        out.append(line())
-        for para in footer.splitlines():
-            for wrapped in (textwrap.wrap(para, inner - 4) or [""]):
-                out.append(center(wrapped))
-        out.append(line())
 
-    out.append(hrule(s["bl"], s["br"], s["jb"] if prev_panel else None))
-    return "\n".join(out) + "\n"
+def render(data: dict, group: str, site: str, width: int,
+           style: str = DEFAULT_STYLE, presents: bool = False,
+           footer: str | None = None, layout: str = DEFAULT_LAYOUT,
+           logo: str | None = None, roster: dict | None = None,
+           panel_titles: tuple[str, str] = PANEL_TITLES) -> str:
+    cvs = _Canvas(width, style)
+    _render_logo(cvs, logo)
+    cvs.add(cvs.hrule(cvs.s["tl"], cvs.s["tr"]))
+    _render_header(cvs, data, group, site, presents)
+    _render_info(cvs, data, layout, panel_titles)
+    _render_notes(cvs, data)
+    _render_greets(cvs, data)
+    _render_roster(cvs, roster)
+    _render_footer(cvs, footer)
+    cvs.add(cvs.hrule(cvs.s["bl"], cvs.s["br"],
+                      cvs.s["jb"] if cvs.prev_panel else None))
+    return "\n".join(cvs.out) + "\n"
 
 
 def _clip(text: str, width: int) -> str:
@@ -524,18 +586,13 @@ def _list_templates() -> list[str]:
     return sorted(p.stem for p in d.glob("*.toml")) if d.is_dir() else []
 
 
-def main(argv: list[str] | None = None) -> int:
-    args = build_parser().parse_args(argv)
+def _resolve(flag, config: dict, key: str, default):
+    return flag if flag is not None else config.get(key, default)
 
-    if args.list_templates:
-        names = _list_templates()
-        print("\n".join(names) if names else "nfogen: no templates bundled")
-        return 0
 
+def _layered_config(args) -> dict:
+    """Merge template < profile < release-config (CLI flags applied later)."""
     release = load_config(args.config)
-
-    # layered base: a style template underneath a group profile; the release
-    # config overrides both, and CLI flags override everything.
     template = {}
     if args.template:
         tpath = _templates_dir() / f"{args.template}.toml"
@@ -545,83 +602,96 @@ def main(argv: list[str] | None = None) -> int:
         template = load_config(str(tpath))
     profile_path = args.profile or release.get("profile")
     profile = load_config(profile_path) if profile_path else {}
-    config = {**template, **profile, **release}
+    return {**template, **profile, **release}
+
 
-    # merge: config defaults, then any CLI flag that was supplied
+def _collect_data(args, config: dict) -> dict:
     data = {k: config[k] for k, *_ in FIELDS if k in config and config[k] != ""}
     for key, *_ in FIELDS:
         val = getattr(args, key)
         if val is not None:
             data[key] = val
-
-    # mediainfo fills any media field not already set by config or flags
     mediainfo_src = args.mediainfo or config.get("mediainfo")
     if mediainfo_src:
         for key, val in load_mediainfo(mediainfo_src).items():
             data.setdefault(key, val)
-
     data.setdefault("date", date.today().isoformat())
+    return data
 
-    group = args.group or config.get("group") or DEFAULT_GROUP
-    site = args.site or config.get("site") or DEFAULT_SITE
-    width = args.width or config.get("width") or DEFAULT_WIDTH
-    style = args.style or config.get("style") or DEFAULT_STYLE
-
-    def resolve(flag, key, default):
-        if flag is not None:
-            return flag
-        return config.get(key, default)
 
-    presents = resolve(args.presents, "presents", False)
-    show_footer = resolve(args.footer, "footer", False)
-    footer_text = args.footer_text or config.get("footer_text") or DEFAULT_FOOTER
-    footer = footer_text if show_footer else None
+def _fill_interactive(args, data: dict) -> None:
+    interactive = sys.stdin.isatty() and not args.no_input
+    if args.interactive:
+        prompt_missing(data, force_all=True)
+    elif not data.get("title") and interactive:
+        prompt_missing(data, force_all=False)
 
-    layout = args.layout or config.get("layout") or DEFAULT_LAYOUT
-    panel_titles = (config.get("panel_left") or PANEL_TITLES[0],
-                    config.get("panel_right") or PANEL_TITLES[1])
 
-    # logo: an art file wins; otherwise an optional generated banner
-    logo = None
+def _resolve_logo(args, config: dict, group: str) -> str | None:
+    """An explicit art file wins; otherwise an optional generated banner."""
     logo_path = args.logo or config.get("logo")
     if logo_path:
         enc = args.logo_encoding or config.get("logo_encoding") or "cp437"
         try:
-            logo = Path(logo_path).read_text(encoding=enc, errors="replace")
+            return Path(logo_path).read_text(encoding=enc, errors="replace")  # NOSONAR local CLI: the user's own path
         except FileNotFoundError:
             sys.exit(f"nfogen: logo not found: {logo_path}")
-    elif resolve(args.banner, "banner", False):
-        logo = banner(args.banner_text or config.get("banner_text") or group)
-
-    roster = {k: config[k] for k in
-              ("news", "members", "couriers", "boards", "affiliates", "outposts")
-              if config.get(k)}
-
-    interactive = sys.stdin.isatty() and not args.no_input
-    if args.interactive:
-        prompt_missing(data, force_all=True)
-    elif not data.get("title") and interactive:
-        prompt_missing(data, force_all=False)
+    if _resolve(args.banner, config, "banner", False):
+        return banner(args.banner_text or config.get("banner_text") or group)
+    return None
 
-    if not data.get("title"):
-        sys.exit("nfogen: a title is required (use --title, a config, or -i)")
-
-    text = render(data, group=group, site=site, width=width, style=style,
-                  presents=presents, footer=footer, layout=layout,
-                  logo=logo, roster=roster, panel_titles=panel_titles)
 
+def _resolve_options(args, config: dict) -> dict:
+    group = args.group or config.get("group") or DEFAULT_GROUP
+    show_footer = _resolve(args.footer, config, "footer", False)
+    footer_text = args.footer_text or config.get("footer_text") or DEFAULT_FOOTER
+    return {
+        "group": group,
+        "site": args.site or config.get("site") or DEFAULT_SITE,
+        "width": args.width or config.get("width") or DEFAULT_WIDTH,
+        "style": args.style or config.get("style") or DEFAULT_STYLE,
+        "presents": _resolve(args.presents, config, "presents", False),
+        "footer": footer_text if show_footer else None,
+        "layout": args.layout or config.get("layout") or DEFAULT_LAYOUT,
+        "logo": _resolve_logo(args, config, group),
+        "roster": {k: config[k] for k in
+                   ("news", "members", "couriers", "boards", "affiliates", "outposts")
+                   if config.get(k)},
+        "panel_titles": (config.get("panel_left") or PANEL_TITLES[0],
+                         config.get("panel_right") or PANEL_TITLES[1]),
+    }
+
+
+def _write_output(text: str, args) -> None:
     if args.encoding == "cp437":
         raw = text.encode("cp437", errors="replace")
         if args.output:
-            Path(args.output).write_bytes(raw)
+            Path(args.output).write_bytes(raw)  # NOSONAR local CLI: the user's own path
         else:
             sys.stdout.buffer.write(raw)
     elif args.output:
-        Path(args.output).write_text(text, encoding="utf-8")
+        Path(args.output).write_text(text, encoding="utf-8")  # NOSONAR local CLI: the user's own path
     else:
         sys.stdout.write(text)
-    return 0
+
+
+def main(argv: list[str] | None = None) -> None:
+    args = build_parser().parse_args(argv)
+
+    if args.list_templates:
+        names = _list_templates()
+        print("\n".join(names) if names else "nfogen: no templates bundled")
+        return
+
+    config = _layered_config(args)
+    data = _collect_data(args, config)
+    _fill_interactive(args, data)
+    if not data.get("title"):
+        sys.exit("nfogen: a title is required (use --title, a config, or -i)")
+
+    text = render(data, **_resolve_options(args, config))
+    _write_output(text, args)
 
 
 if __name__ == "__main__":
-    raise SystemExit(main())
+    main()