From 6e7df257d5f2b72ae6584837dec646d4bec2e4ad Mon Sep 17 00:00:00 2001 From: Francisco <95440147+frarredondo@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:55:24 -0700 Subject: [PATCH 1/3] Add podtx rename --from-title to fix missing episode numbers. Lets users rename already-transcribed outputs (and update JSON/DB) from title-inferred episode numbers without re-running ASR. --- README.md | 10 ++ src/podtx/cli.py | 87 ++++++++++- src/podtx/db.py | 26 ++++ src/podtx/rename_cmd.py | 153 ++++++++++++++++++ tests/test_rename_cmd.py | 324 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 599 insertions(+), 1 deletion(-) create mode 100644 src/podtx/rename_cmd.py create mode 100644 tests/test_rename_cmd.py diff --git a/README.md b/README.md index 0dae700..a50bd2d 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ podtx transcribe https://example.com/audio/ep01.mp3 --engine whisper | `podtx sync [feed]` | Transcribe new episodes | | `podtx transcribe ` | One-shot RSS / URL / file | | `podtx format ` | Re-format existing transcript JSON (no ASR) | +| `podtx rename --from-title --feed\|--all` | Fix `_000_` filenames from title episode numbers | ### Useful flags @@ -86,6 +87,15 @@ podtx format --feed corecursive-coding-stories --readable --cleanup podtx format --all --readable --cleanup ``` +Rename already-transcribed files whose episode number was missing (`_000_`) +when the title embeds a clear number (same rules as filename inference above): + +```bash +podtx rename --from-title --feed syntax-tasty-web-development-treats --dry-run +podtx rename --from-title --feed syntax-tasty-web-development-treats +podtx rename --from-title --all +``` + ## Data & config - Data (SQLite state, transcripts, temp audio): `~/.local/share/podcast-transcriber/` diff --git a/src/podtx/cli.py b/src/podtx/cli.py index 46cbf05..f9b48fc 100644 --- a/src/podtx/cli.py +++ b/src/podtx/cli.py @@ -22,6 +22,7 @@ reformat_many, reformat_transcript, ) +from podtx.rename_cmd import rename_many_from_title from podtx.rss import FeedParseError, parse_feed, suggest_unique_slug app = typer.Typer( @@ -554,4 +555,88 @@ def format_cmd( ) if result.failed: - raise typer.Exit(1) \ No newline at end of file + raise typer.Exit(1) + + +@app.command("rename") +def rename_cmd( + from_title: bool = typer.Option( + False, + "--from-title", + help="Infer episode numbers from JSON titles and rename sibling outputs", + ), + feed: Optional[str] = typer.Option( + None, + "--feed", + help="Rename transcripts for a feed slug", + ), + all_feeds: bool = typer.Option( + False, + "--all", + help="Rename transcripts across the whole library", + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Print planned renames without writing", + ), + data_dir: Optional[Path] = typer.Option( + None, "--data-dir", help="Override data directory" + ), + quiet: bool = typer.Option(False, "--quiet", "-q"), +) -> None: + """Rename transcript outputs to fix missing episode numbers (no ASR). + + Currently supports ``--from-title`` with ``--feed`` or ``--all``. + """ + if not from_title: + err_console.print("[red]Specify --from-title[/red] (only rename mode supported)") + raise typer.Exit(1) + + if sum([feed is not None, all_feeds]) != 1: + err_console.print("[red]Specify exactly one of:[/red] `--feed ` or `--all`") + raise typer.Exit(1) + + settings = load_settings(data_dir=data_dir) + ensure_data_dirs(settings) + transcripts_root = settings.transcripts_dir() + try: + targets = discover_transcript_jsons( + transcripts_root, + feed=None if all_feeds else feed, + ) + except TranscriptJsonError as exc: + err_console.print(f"[red]{exc}[/red]") + raise typer.Exit(1) from exc + + if not targets: + err_console.print("[dim]No transcript JSON files found.[/dim]") + raise typer.Exit(1) + + if not quiet: + scope = "all feeds" if all_feeds else f"feed {feed}" + mode = "Dry-run rename" if dry_run else "Renaming" + console.print(f"[bold]{mode} {len(targets)} transcript(s)[/bold] ({scope})") + + db = Database(settings.state_db_path()) + try: + result = rename_many_from_title(targets, dry_run=dry_run, db=None if dry_run else db) + finally: + db.close() + + if not quiet: + for old, new in result.renames: + verb = "Would rename" if dry_run else "Renamed" + console.print(f"[green]{verb}[/green] {old.name} → {new.name}") + for path, message in result.skips: + console.print(f"[dim]Skipped[/dim] {path.name}: {message}") + for path, message in result.errors: + err_console.print(f"[red]Failed[/red] {path.name}: {message}") + prefix = "Dry-run done" if dry_run else "Done" + console.print( + f"[bold]{prefix}[/bold]: {result.ok} ok, " + f"{result.skipped} skipped, {result.failed} failed" + ) + + if result.failed: + raise typer.Exit(1) diff --git a/src/podtx/db.py b/src/podtx/db.py index a164141..fc87180 100644 --- a/src/podtx/db.py +++ b/src/podtx/db.py @@ -186,6 +186,32 @@ def mark_done( ) self._conn.commit() + def update_episode_paths( + self, + *, + feed_id: int, + guid: str, + episode_num: int, + output_paths: list[Path], + ) -> bool: + """Update episode number and output paths after a rename (no status change).""" + cur = self._conn.execute( + """ + UPDATE episodes + SET episode_num = ?, + output_paths_json = ? + WHERE feed_id = ? AND guid = ? + """, + ( + episode_num, + json.dumps([str(p) for p in output_paths]), + feed_id, + guid, + ), + ) + self._conn.commit() + return cur.rowcount > 0 + def mark_error(self, *, feed_id: int, guid: str, message: str) -> None: self._conn.execute( """ diff --git a/src/podtx/rename_cmd.py b/src/podtx/rename_cmd.py new file mode 100644 index 0000000..109530b --- /dev/null +++ b/src/podtx/rename_cmd.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass, field, replace +from pathlib import Path + +from podtx.db import Database +from podtx.format_cmd import TranscriptJsonError, load_transcript_json +from podtx.models import Episode +from podtx.naming import parse_episode_number_from_title, transcript_basename + +# Sibling outputs that share a transcript basename. +_SIBLING_EXTS = (".json", ".txt", ".srt", ".vtt") + + +class RenameError(ValueError): + pass + + +@dataclass +class RenameAction: + old_json: Path + new_basename: str + episode_num: int + moves: list[tuple[Path, Path]] + + +@dataclass +class BatchRenameResult: + ok: int = 0 + skipped: int = 0 + failed: int = 0 + renames: list[tuple[Path, Path]] = field(default_factory=list) + skips: list[tuple[Path, str]] = field(default_factory=list) + errors: list[tuple[Path, str]] = field(default_factory=list) + + +def resolve_episode_number_for_rename(episode: Episode) -> int | None: + """Prefer a positive JSON/RSS episode number; else parse a clear title number.""" + if episode.episode_num is not None and episode.episode_num > 0: + return episode.episode_num + parsed = parse_episode_number_from_title(episode.title) + if parsed is not None and parsed > 0: + return parsed + return None + + +def plan_rename_from_title(json_path: Path) -> RenameAction | None: + """Build a rename plan from JSON title/episode metadata. + + Returns ``None`` when no rename is needed (already correct, or no clear number). + Raises ``RenameError`` if a target basename already exists. + """ + path = json_path.expanduser() + episode, _ = load_transcript_json(path) + num = resolve_episode_number_for_rename(episode) + if num is None: + return None + + updated = replace(episode, episode_num=num) + new_basename = transcript_basename(updated) + old_basename = path.stem + if new_basename == old_basename: + return None + + parent = path.parent + moves: list[tuple[Path, Path]] = [] + for ext in _SIBLING_EXTS: + src = parent / f"{old_basename}{ext}" + if not src.is_file(): + continue + dest = parent / f"{new_basename}{ext}" + if dest.exists(): + raise RenameError(f"Target already exists: {dest}") + moves.append((src, dest)) + + if not moves: + return None + + return RenameAction( + old_json=path, + new_basename=new_basename, + episode_num=num, + moves=moves, + ) + + +def apply_rename( + action: RenameAction, + *, + dry_run: bool = False, + db: Database | None = None, +) -> Path: + """Apply a rename plan. Returns the new JSON path (even on dry-run).""" + new_json = action.old_json.parent / f"{action.new_basename}.json" + if dry_run: + return new_json + + for src, dest in action.moves: + src.rename(dest) + + payload = json.loads(new_json.read_text(encoding="utf-8")) + payload["episode"] = action.episode_num + new_json.write_text( + json.dumps(payload, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + if db is not None: + guid = str(payload.get("guid") or "") + feed = db.get_feed(new_json.parent.name) + if feed is not None and guid: + db.update_episode_paths( + feed_id=feed.id, + guid=guid, + episode_num=action.episode_num, + output_paths=[dest for _, dest in action.moves], + ) + + return new_json + + +def rename_many_from_title( + json_paths: list[Path], + *, + dry_run: bool = False, + db: Database | None = None, +) -> BatchRenameResult: + """Rename many transcript JSON trees; continue on per-file skip/error.""" + result = BatchRenameResult() + for path in json_paths: + try: + plan = plan_rename_from_title(path) + except (TranscriptJsonError, RenameError, OSError, ValueError) as exc: + result.failed += 1 + result.errors.append((path, str(exc))) + continue + + if plan is None: + result.skipped += 1 + result.skips.append((path, "no rename needed")) + continue + + try: + new_json = apply_rename(plan, dry_run=dry_run, db=db) + except (OSError, ValueError) as exc: + result.failed += 1 + result.errors.append((path, str(exc))) + continue + + result.ok += 1 + result.renames.append((path, new_json)) + return result diff --git a/tests/test_rename_cmd.py b/tests/test_rename_cmd.py new file mode 100644 index 0000000..075a566 --- /dev/null +++ b/tests/test_rename_cmd.py @@ -0,0 +1,324 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path + +from typer.testing import CliRunner + +from podtx.cli import app +from podtx.db import Database +from podtx.models import Episode, Segment, Transcript +from podtx.writers import write_outputs + +runner = CliRunner() + + +def _write_episode( + out_dir: Path, + *, + basename: str, + title: str, + episode_num: int | None, + guid: str = "g1", + extensions: tuple[str, ...] = ("txt", "json"), +) -> Path: + episode = Episode( + guid=guid, + title=title, + enclosure_url="https://example.com/a.mp3", + published_at=datetime(2026, 3, 15, tzinfo=timezone.utc), + episode_num=episode_num, + show_title="Demo Show", + ) + transcript = Transcript( + text="Hello world.", + segments=[Segment(0.0, 1.0, "Hello world.")], + language="en", + model="test-model", + engine="fake", + ) + write_outputs( + out_dir=out_dir, + basename=basename, + episode=episode, + transcript=transcript, + formats=extensions, + readable=False, + cleanup=False, + ) + return out_dir / f"{basename}.json" + + +def test_plan_rename_from_title_for_zero_padded_file(tmp_path: Path) -> None: + from podtx.rename_cmd import plan_rename_from_title + + json_path = _write_episode( + tmp_path, + basename="2026-03-15_000_937-is-the-omarchy-hype-real", + title="937: Is The Omarchy Hype Real?", + episode_num=0, + extensions=("txt", "json", "srt"), + ) + plan = plan_rename_from_title(json_path) + assert plan is not None + assert plan.episode_num == 937 + assert plan.new_basename == "2026-03-15_937_937-is-the-omarchy-hype-real" + srcs = {src.name for src, _ in plan.moves} + dests = {dest.name for _, dest in plan.moves} + assert srcs == { + "2026-03-15_000_937-is-the-omarchy-hype-real.json", + "2026-03-15_000_937-is-the-omarchy-hype-real.txt", + "2026-03-15_000_937-is-the-omarchy-hype-real.srt", + } + assert dests == { + "2026-03-15_937_937-is-the-omarchy-hype-real.json", + "2026-03-15_937_937-is-the-omarchy-hype-real.txt", + "2026-03-15_937_937-is-the-omarchy-hype-real.srt", + } + + +def test_plan_rename_skips_section_style_titles(tmp_path: Path) -> None: + from podtx.rename_cmd import plan_rename_from_title + + json_path = _write_episode( + tmp_path, + basename="2026-03-15_000_1-1-introduction", + title="1.1 - Introduction", + episode_num=None, + ) + assert plan_rename_from_title(json_path) is None + + +def test_plan_rename_skips_when_already_numbered(tmp_path: Path) -> None: + from podtx.rename_cmd import plan_rename_from_title + + json_path = _write_episode( + tmp_path, + basename="2026-03-15_042_episode-forty-two", + title="Episode Forty Two", + episode_num=42, + ) + assert plan_rename_from_title(json_path) is None + + +def test_plan_rename_prefers_json_episode_over_title(tmp_path: Path) -> None: + from podtx.rename_cmd import plan_rename_from_title + + json_path = _write_episode( + tmp_path, + basename="2026-03-15_000_wrong-title-number", + title="999: Wrong Title Number", + episode_num=42, + ) + plan = plan_rename_from_title(json_path) + assert plan is not None + assert plan.episode_num == 42 + assert plan.new_basename.startswith("2026-03-15_042_") + + +def test_plan_rename_refuses_collision(tmp_path: Path) -> None: + from podtx.rename_cmd import RenameError, plan_rename_from_title + + _write_episode( + tmp_path, + basename="2026-03-15_937_937-is-the-omarchy-hype-real", + title="937: Already Exists", + episode_num=937, + guid="existing", + ) + json_path = _write_episode( + tmp_path, + basename="2026-03-15_000_937-is-the-omarchy-hype-real", + title="937: Is The Omarchy Hype Real?", + episode_num=0, + guid="new", + ) + try: + plan_rename_from_title(json_path) + assert False, "expected RenameError" + except RenameError as exc: + assert "already exists" in str(exc).lower() + + +def test_apply_rename_updates_files_and_json_episode(tmp_path: Path) -> None: + from podtx.rename_cmd import apply_rename, plan_rename_from_title + + json_path = _write_episode( + tmp_path, + basename="2026-03-15_000_ep-25-demo", + title="Ep 25: Demo", + episode_num=None, + extensions=("txt", "json"), + ) + plan = plan_rename_from_title(json_path) + assert plan is not None + new_json = apply_rename(plan, dry_run=False) + assert new_json.name == "2026-03-15_025_ep-25-demo.json" + assert new_json.is_file() + assert not json_path.exists() + assert (tmp_path / "2026-03-15_025_ep-25-demo.txt").is_file() + assert not (tmp_path / "2026-03-15_000_ep-25-demo.txt").exists() + payload = json.loads(new_json.read_text(encoding="utf-8")) + assert payload["episode"] == 25 + + +def test_apply_rename_dry_run_does_not_touch_files(tmp_path: Path) -> None: + from podtx.rename_cmd import apply_rename, plan_rename_from_title + + json_path = _write_episode( + tmp_path, + basename="2026-03-15_000_ep-9-demo", + title="Ep 9 — Demo", + episode_num=0, + ) + plan = plan_rename_from_title(json_path) + assert plan is not None + apply_rename(plan, dry_run=True) + assert json_path.is_file() + assert not (tmp_path / "2026-03-15_009_ep-9-demo.json").exists() + payload = json.loads(json_path.read_text(encoding="utf-8")) + assert payload["episode"] in (0, None) + + +def test_apply_rename_updates_db_when_tracked(tmp_path: Path) -> None: + from podtx.rename_cmd import apply_rename, plan_rename_from_title + + feed_dir = tmp_path / "transcripts" / "syntax" + feed_dir.mkdir(parents=True) + json_path = _write_episode( + feed_dir, + basename="2026-03-15_000_episode-860-module-federation", + title="Episode 860: Module Federation", + episode_num=None, + guid="syntax-860", + ) + db = Database(tmp_path / "state.db") + feed = db.add_feed("https://example.com/syntax.xml", "syntax", "Syntax") + db.upsert_episode( + feed_id=feed.id, + guid="syntax-860", + title="Episode 860: Module Federation", + published_at=datetime(2026, 3, 15, tzinfo=timezone.utc), + episode_num=None, + enclosure_url="https://example.com/a.mp3", + ) + db.mark_done( + feed_id=feed.id, + guid="syntax-860", + engine="fake", + model="test", + output_paths=[json_path, json_path.with_suffix(".txt")], + ) + + plan = plan_rename_from_title(json_path) + assert plan is not None + new_json = apply_rename(plan, dry_run=False, db=db) + row = db.list_episodes(feed.id)[0] + assert row["episode_num"] == 860 + paths = json.loads(row["output_paths_json"]) + assert str(new_json) in paths + assert str(new_json.with_suffix(".txt")) in paths + assert all("_000_" not in p for p in paths) + db.close() + + +def test_rename_many_reports_ok_skipped_failed(tmp_path: Path) -> None: + from podtx.rename_cmd import rename_many_from_title + + root = tmp_path / "transcripts" / "feed-a" + root.mkdir(parents=True) + fixable = _write_episode( + root, + basename="2026-03-15_000_937-fixable", + title="937: Fixable", + episode_num=0, + guid="fixable", + ) + _write_episode( + root, + basename="2026-03-15_000_1-1-section", + title="1.1 - Section", + episode_num=None, + guid="section", + ) + # Collision target for a third file + _write_episode( + root, + basename="2026-03-15_100_100-already-there", + title="100: Already There", + episode_num=100, + guid="exists", + ) + colliding = _write_episode( + root, + basename="2026-03-15_000_100-already-there", + title="100: Already There", + episode_num=0, + guid="collide", + ) + + result = rename_many_from_title( + [fixable, root / "2026-03-15_000_1-1-section.json", colliding], + dry_run=False, + ) + assert result.ok == 1 + assert result.skipped == 1 + assert result.failed == 1 + assert (root / "2026-03-15_937_937-fixable.json").is_file() + assert not fixable.exists() + + +def test_cli_rename_from_title_feed_dry_run(tmp_path: Path) -> None: + feed_dir = tmp_path / "transcripts" / "syntax" + feed_dir.mkdir(parents=True) + _write_episode( + feed_dir, + basename="2026-03-15_000_937-is-the-omarchy-hype-real", + title="937: Is The Omarchy Hype Real?", + episode_num=0, + ) + result = runner.invoke( + app, + [ + "rename", + "--from-title", + "--feed", + "syntax", + "--dry-run", + "--data-dir", + str(tmp_path), + ], + ) + assert result.exit_code == 0, result.stdout + result.stderr + assert "dry-run" in result.stdout.lower() or "would rename" in result.stdout.lower() + assert (feed_dir / "2026-03-15_000_937-is-the-omarchy-hype-real.json").is_file() + assert not (feed_dir / "2026-03-15_937_937-is-the-omarchy-hype-real.json").exists() + + +def test_cli_rename_from_title_all(tmp_path: Path) -> None: + for slug in ("feed-a", "feed-b"): + feed_dir = tmp_path / "transcripts" / slug + feed_dir.mkdir(parents=True) + _write_episode( + feed_dir, + basename=f"2026-03-15_000_{slug}-ep", + title=f"12: {slug}", + episode_num=None, + guid=slug, + ) + result = runner.invoke( + app, + ["rename", "--from-title", "--all", "--data-dir", str(tmp_path)], + ) + assert result.exit_code == 0, result.stdout + result.stderr + assert "2 ok" in result.stdout + assert (tmp_path / "transcripts" / "feed-a" / "2026-03-15_012_12-feed-a.json").is_file() + assert (tmp_path / "transcripts" / "feed-b" / "2026-03-15_012_12-feed-b.json").is_file() + + +def test_cli_rename_requires_from_title_and_scope(tmp_path: Path) -> None: + result = runner.invoke(app, ["rename", "--all"]) + assert result.exit_code != 0 + result2 = runner.invoke(app, ["rename", "--from-title"]) + assert result2.exit_code != 0 From 2c7916b495773cc9faabfb3c5c2f1fe440992705 Mon Sep 17 00:00:00 2001 From: Francisco <95440147+frarredondo@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:59:26 -0700 Subject: [PATCH 2/3] Cover rename CLI edge cases flagged by Codecov. Adds tests for empty/unknown targets, quiet mode, skip/fail reporting, and apply_rename error handling so the rename patch is fully covered. --- tests/test_rename_cmd.py | 129 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/tests/test_rename_cmd.py b/tests/test_rename_cmd.py index 075a566..f1376d6 100644 --- a/tests/test_rename_cmd.py +++ b/tests/test_rename_cmd.py @@ -322,3 +322,132 @@ def test_cli_rename_requires_from_title_and_scope(tmp_path: Path) -> None: assert result.exit_code != 0 result2 = runner.invoke(app, ["rename", "--from-title"]) assert result2.exit_code != 0 + + +def test_plan_rename_returns_none_when_no_sibling_files(tmp_path: Path, monkeypatch) -> None: + from podtx.rename_cmd import plan_rename_from_title + + json_path = _write_episode( + tmp_path, + basename="2026-03-15_000_ep-7-lonely", + title="Ep 7: Lonely", + episode_num=None, + ) + + def never_file(self: Path) -> bool: + return False + + monkeypatch.setattr(Path, "is_file", never_file) + assert plan_rename_from_title(json_path) is None + + +def test_rename_many_records_apply_errors(tmp_path: Path, monkeypatch) -> None: + from podtx import rename_cmd + + json_path = _write_episode( + tmp_path, + basename="2026-03-15_000_ep-8-boom", + title="Ep 8: Boom", + episode_num=None, + ) + + def boom(*_args, **_kwargs): + raise OSError("disk full") + + monkeypatch.setattr(rename_cmd, "apply_rename", boom) + result = rename_cmd.rename_many_from_title([json_path], dry_run=False) + assert result.ok == 0 + assert result.failed == 1 + assert "disk full" in result.errors[0][1] + + +def test_cli_rename_unknown_feed_exits(tmp_path: Path) -> None: + (tmp_path / "transcripts").mkdir() + result = runner.invoke( + app, + ["rename", "--from-title", "--feed", "missing", "--data-dir", str(tmp_path)], + ) + assert result.exit_code != 0 + assert "not found" in (result.stdout + result.stderr).lower() + + +def test_cli_rename_no_targets_exits(tmp_path: Path) -> None: + (tmp_path / "transcripts").mkdir() + result = runner.invoke( + app, + ["rename", "--from-title", "--all", "--data-dir", str(tmp_path)], + ) + assert result.exit_code != 0 + assert "no transcript" in (result.stdout + result.stderr).lower() + + +def test_cli_rename_reports_skips_and_failures(tmp_path: Path) -> None: + feed_dir = tmp_path / "transcripts" / "feed-a" + feed_dir.mkdir(parents=True) + _write_episode( + feed_dir, + basename="2026-03-15_000_12-fixable", + title="12: Fixable", + episode_num=0, + guid="fixable", + ) + _write_episode( + feed_dir, + basename="2026-03-15_000_no-number", + title="No Number Here", + episode_num=None, + guid="skip", + ) + _write_episode( + feed_dir, + basename="2026-03-15_100_100-already-there", + title="100: Already There", + episode_num=100, + guid="exists", + ) + _write_episode( + feed_dir, + basename="2026-03-15_000_100-already-there", + title="100: Already There", + episode_num=0, + guid="collide", + ) + result = runner.invoke( + app, + ["rename", "--from-title", "--feed", "feed-a", "--data-dir", str(tmp_path)], + ) + assert result.exit_code != 0 + out = result.stdout + result.stderr + assert "Skipped" in out + assert "Failed" in out + # Rich may soft-wrap the summary line in narrow consoles. + assert "1 ok" in out + assert "skipped" in out + assert "failed" in out + + +def test_cli_rename_quiet_suppresses_progress(tmp_path: Path) -> None: + feed_dir = tmp_path / "transcripts" / "feed-a" + feed_dir.mkdir(parents=True) + _write_episode( + feed_dir, + basename="2026-03-15_000_9-quiet", + title="9: Quiet", + episode_num=None, + ) + result = runner.invoke( + app, + [ + "rename", + "--from-title", + "--feed", + "feed-a", + "--data-dir", + str(tmp_path), + "--quiet", + ], + ) + assert result.exit_code == 0, result.stdout + result.stderr + assert "Renamed" not in result.stdout + assert "Done" not in result.stdout + assert (feed_dir / "2026-03-15_009_9-quiet.json").is_file() From 4be4d9e2907917d8efd59ef3eeb0312dbc68ef54 Mon Sep 17 00:00:00 2001 From: Francisco <95440147+frarredondo@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:00:41 -0700 Subject: [PATCH 3/3] Cover the last format failure path counted in the rename patch. Exercises format --feed when a transcript JSON is invalid so Codecov no longer marks the shared Exit(1) line as missing. --- tests/test_format_cmd.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_format_cmd.py b/tests/test_format_cmd.py index 80d9ffd..5c02a50 100644 --- a/tests/test_format_cmd.py +++ b/tests/test_format_cmd.py @@ -216,6 +216,20 @@ def test_cli_format_all(tmp_path: Path) -> None: assert "2" in result.stdout or "Wrote" in result.stdout +def test_cli_format_feed_reports_failures(tmp_path: Path) -> None: + root = _library(tmp_path) + bad = root / "feed-a" / "broken.json" + bad.write_text("{not-json", encoding="utf-8") + result = runner.invoke( + app, + ["format", "--feed", "feed-a", "--data-dir", str(tmp_path), "--cleanup"], + ) + assert result.exit_code != 0 + out = result.stdout + result.stderr + assert "Failed" in out + assert "failed" in out + + def test_cli_format_requires_target(tmp_path: Path) -> None: result = runner.invoke(app, ["format", "--cleanup"]) assert result.exit_code != 0