[docs]  1# @Test suite for CLI commands including analyse\, discover\, and write, TEST_CLI_1, test, [IMPL_CLI_ANALYZE, IMPL_CLI_DISCOVER, IMPL_CLI_WRITE]
  2import json
  3import re
  4import tomllib
  5from pathlib import Path
  6
  7import pytest
  8import toml
  9from typer.testing import CliRunner
 10
 11from sphinx_codelinks.cmd import WRITE_RST_DEPRECATED, app
 12from sphinx_codelinks.source_discover.config import CommentType
 13
 14from .conftest import DATA_DIR, TEST_DIR
 15
 16ONELINE_COMMENT_TEMPLATE = {
 17    "start_sequence": "[[",
 18    "end_sequence": "]]",
 19    "field_split_char": ",",
 20    "needs_fields": [
 21        {"name": "id"},
 22        {"name": "title"},
 23        {"name": "type"},
 24    ],
 25}
 26SRC_DISCOVER_TEMPLATE = {
 27    "src_dir": str(TEST_DIR / "data" / "dcdc"),
 28    "exclude": ["**/charge/demo_1.cpp", "**/discharge/demo_3.cpp"],
 29    "include": ["**/charge/demo_2.cpp", "**/supercharge.cpp"],
 30    "gitignore": True,
 31    "comment_type": CommentType.cpp.value,
 32}
 33ANALYSE_CONFIG_TEMPLATE = {
 34    "get_oneline_needs": True,
 35    "oneline_comment_style": ONELINE_COMMENT_TEMPLATE,
 36}
 37CODELINKS_CONFIG_TEMPLATE = {
 38    "outdir": "set/it/to/somewhere",
 39    "projects": {
 40        "project_1": {
 41            "source_discover": SRC_DISCOVER_TEMPLATE,
 42            "analyse": ANALYSE_CONFIG_TEMPLATE,
 43        }
 44    },
 45}
 46
 47
 48runner = CliRunner()
 49
 50
 51def _normalize_output(text: str) -> str:
 52    """Normalize rich panel output: strip ANSI escapes, then collapse box-drawing
 53    chars and whitespace.
 54
 55    Typer wraps error messages in rich panels whose line breaks depend on terminal
 56    width, which can cause substring assertions to fail. And typer forces a colour
 57    terminal when ``GITHUB_ACTIONS`` is set, so on CI the panel also carries ANSI
 58    escape codes -- including between the halves of a wrapped line -- which have to go
 59    first, before the whitespace between the halves can collapse.
 60    """
 61    text = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", text)
 62    # Remove box-drawing characters (─│╭╮╯╰) and collapse resulting whitespace
 63    text = re.sub(r"[─│╭╮╯╰]", " ", text)
 64    return re.sub(r"\s+", " ", text).strip()
 65
 66
 67@pytest.mark.parametrize(
 68    ("config_path"),
 69    [
 70        (DATA_DIR / "configs" / "minimum_config.toml"),
 71        (DATA_DIR / "configs" / "full_config.toml"),
 72    ],
 73)
 74def test_analyse(config_path: Path, tmp_path: Path) -> None:
 75    options: list[str] = ["analyse", str(config_path), "--outdir", str(tmp_path)]
 76    result = runner.invoke(app, options)
 77
 78    output_path = tmp_path / "marked_content.json"
 79
 80    assert output_path.exists()
 81    assert result.exit_code == 0
 82
 83    with output_path.open("r") as f:
 84        marked_content = json.load(f)
 85    assert marked_content
 86
 87
 88def test_analyse_outputs_warnings(tmp_path: Path) -> None:
 89    """Test that the analyse CLI command outputs warnings to console."""
 90    # Create a config file that will produce warnings
 91    src_dir = TEST_DIR / "data" / "oneline_comment_default"
 92    config_dict = {
 93        "codelinks": {
 94            "outdir": str(tmp_path),
 95            "projects": {
 96                "test_project": {
 97                    "source_discover": {
 98                        "src_dir": str(src_dir),
 99                        "include": ["*.c"],
100                        "comment_type": "cpp",
101                    },
102                    "analyse": {
103                        "get_oneline_needs": True,
104                        # Use default oneline_comment_style which will cause warnings
105                        # for the test file with too many fields
106                    },
107                }
108            },
109        }
110    }
111
112    config_file = tmp_path / "test_config.toml"
113    with config_file.open("w", encoding="utf-8") as f:
114        toml.dump(config_dict, f)
115
116    options: list[str] = ["analyse", str(config_file)]
117    result = runner.invoke(app, options)
118
119    assert result.exit_code == 0
120    # Verify that warnings are output to console, one shape for every kind
121    assert "Analyse warning in" in result.output
122    assert "too_many_fields" in result.output
123
124
125def test_analyse_logs_per_project_summary_and_gates_detail(tmp_path: Path) -> None:
126    """Each project gets a default-visible ``codelinks [<project>]`` summary with
127    counts; the per-type breakdown is gated behind --verbose; --quiet silences it."""
128    config_path = DATA_DIR / "configs" / "minimum_config.toml"
129    base = ["analyse", str(config_path), "--outdir", str(tmp_path)]
130
131    # default: per-project summary shown, breakdown hidden
132    result = runner.invoke(app, base)
133    assert result.exit_code == 0
134    assert "codelinks [" in result.output
135    assert "markers" in result.output
136    assert "oneline need" not in result.output
137
138    # --verbose: per-type breakdown also shown
139    verbose_result = runner.invoke(app, [*base, "--verbose"])
140    assert verbose_result.exit_code == 0
141    assert "codelinks [" in verbose_result.output
142    assert "oneline need" in verbose_result.output
143
144    # --quiet: summary silenced
145    quiet_result = runner.invoke(app, [*base, "--quiet"])
146    assert quiet_result.exit_code == 0
147    assert "codelinks [" not in quiet_result.output
148
149
150@pytest.mark.parametrize(
151    ("gitignore", "stdout"),
152    [
153        (False, "4 files discovered"),
154        (True, "3 files discovered"),
155    ],
156)
157def test_discover(gitignore: bool, stdout: str, source_directory: Path) -> None:
158    options = ["discover", str(source_directory)]
159    options.append("--gitignore" if gitignore else "--no-gitignore")
160    result = runner.invoke(app, options)
161    assert result.exit_code == 0
162    assert stdout in result.stdout
163
164
165def test_discover_cli_default_exclude_matches_ts_default(tmp_path: Path) -> None:
166    """Without ``-e``, ``discover --comment-type ts`` excludes what a ``ts`` project's
167    configuration does: ``dist/app.js`` is not listed, ``src/app.ts`` is."""
168    (tmp_path / "src").mkdir()
169    (tmp_path / "src" / "app.ts").write_text(
170        "// @Feature A, IMPL_1, impl\n", encoding="utf-8"
171    )
172    (tmp_path / "dist").mkdir()
173    (tmp_path / "dist" / "app.js").write_text(
174        "// @Feature A, IMPL_1, impl\n", encoding="utf-8"
175    )
176
177    result = runner.invoke(
178        app,
179        ["discover", str(tmp_path), "--comment-type", "ts", "--no-gitignore"],
180    )
181    assert result.exit_code == 0
182    assert "1 files discovered" in result.stdout
183    assert str(tmp_path / "src" / "app.ts") in result.stdout
184    assert str(tmp_path / "dist" / "app.js") not in result.stdout
185
186
187def test_discover_cli_excludes_replace_the_ts_default(tmp_path: Path) -> None:
188    """``-e`` replaces the ``ts`` default rather than adding to it: the test file
189    it names is dropped, and ``dist/app.js``, which the default would drop, is
190    listed."""
191    (tmp_path / "src").mkdir()
192    for name in ("app.ts", "app.test.ts"):
193        (tmp_path / "src" / name).write_text(
194            "// @Feature A, IMPL_1, impl\n", encoding="utf-8"
195        )
196    (tmp_path / "dist").mkdir()
197    (tmp_path / "dist" / "app.js").write_text(
198        "// @Feature A, IMPL_1, impl\n", encoding="utf-8"
199    )
200
201    result = runner.invoke(
202        app,
203        [
204            "discover",
205            str(tmp_path),
206            "--comment-type",
207            "ts",
208            "--no-gitignore",
209            "-e",
210            "**/*.test.ts",
211        ],
212    )
213    assert result.exit_code == 0
214    assert "2 files discovered" in result.stdout
215    assert str(tmp_path / "src" / "app.ts") in result.stdout
216    assert str(tmp_path / "dist" / "app.js") in result.stdout
217    assert str(tmp_path / "src" / "app.test.ts") not in result.stdout
218
219
220def test_discover_cli_cpp_default_exclude_is_empty(tmp_path: Path) -> None:
221    """Without ``-e``, ``discover`` for ``cpp`` (the CLI's default) excludes nothing:
222    ``lib/widget.cpp`` is listed."""
223    lib_dir = tmp_path / "lib"
224    lib_dir.mkdir()
225    (lib_dir / "widget.cpp").write_text(
226        "// @Feature A, IMPL_1, impl\n", encoding="utf-8"
227    )
228
229    result = runner.invoke(app, ["discover", str(tmp_path), "--no-gitignore"])
230    assert result.exit_code == 0
231    assert "1 files discovered" in result.stdout
232    assert str(lib_dir / "widget.cpp") in result.stdout
233
234
235@pytest.mark.parametrize(
236    ("src_discover_dict", "analyse_dict", "output_lines"),
237    [
238        (
239            {
240                src_key: (123 if src_key == "exclude" else src_value)
241                for src_key, src_value in SRC_DISCOVER_TEMPLATE.items()
242            },
243            ANALYSE_CONFIG_TEMPLATE,
244            [
245                "Invalid value: Invalid source discovery configuration:",
246                "Schema validation error in field 'exclude': 123 is not of type 'array'",
247            ],
248        ),
249        (
250            {
251                src_key: (123 if src_key == "include" else src_value)
252                for src_key, src_value in SRC_DISCOVER_TEMPLATE.items()
253            },
254            ANALYSE_CONFIG_TEMPLATE,
255            [
256                "Invalid value: Invalid source discovery configuration:",
257                "Schema validation error in field 'include': 123 is not of type 'array'",
258            ],
259        ),
260        (
261            {
262                src_key: (
263                    123 if src_key in ("exclude", "include", "src_dir") else src_value
264                )
265                for src_key, src_value in SRC_DISCOVER_TEMPLATE.items()
266            },
267            ANALYSE_CONFIG_TEMPLATE,
268            [
269                "Invalid value: Invalid source discovery configuration:",
270                "Schema validation error in field 'src_dir': 123 is not of type 'string'",
271                "Schema validation error in field 'exclude': 123 is not of type 'array'",
272                "Schema validation error in field 'include': 123 is not of type 'array'",
273            ],
274        ),
275        (
276            SRC_DISCOVER_TEMPLATE,
277            {
278                analyse_key: (
279                    {"not_expected": 123}
280                    if analyse_key == "oneline_comment_style"
281                    else analyse_value
282                )
283                for analyse_key, analyse_value in ANALYSE_CONFIG_TEMPLATE.items()
284            },
285            [
286                "Invalid value: Invalid oneline comment style configuration:",
287                "OneLineCommentStyle.__init__() got an unexpected keyword argument",
288                "'not_expected'",
289            ],
290        ),
291        (
292            SRC_DISCOVER_TEMPLATE,
293            {
294                analyse_key: (
295                    {"needs_fields": [{"name": "id"}, {"name": "id"}]}
296                    if analyse_key == "oneline_comment_style"
297                    else analyse_value
298                )
299                for analyse_key, analyse_value in ANALYSE_CONFIG_TEMPLATE.items()
300            },
301            [
302                "Invalid value: OneLineCommentStyle configuration errors:",
303                "Missing required fields: ['title', 'type']",
304                "Field 'id' is defined multiple times.",
305            ],
306        ),
307    ],
308)
309def test_analyse_config_negative(
310    src_discover_dict, analyse_dict, output_lines, tmp_path: Path
311) -> None:
312    config_file = tmp_path / "codelinks_config.toml"
313    codelink_dict = {"codelinks": CODELINKS_CONFIG_TEMPLATE}
314    codelink_dict["codelinks"]["projects"]["project_1"]["source_discover"] = (
315        src_discover_dict
316    )
317    codelink_dict["codelinks"]["projects"]["project_1"]["analyse"] = analyse_dict
318    with config_file.open("w", encoding="utf-8") as f:
319        toml.dump(codelink_dict, f)
320
321    options = [
322        "analyse",
323        str(config_file),
324    ]
325    result = runner.invoke(app, options)
326    assert result.exit_code != 0
327    normalized = _normalize_output(result.output)
328    for line in output_lines:
329        assert line in normalized
330
331
332@pytest.mark.parametrize(
333    ("projects", "output_lines"),
334    [
335        (
336            ["project_1", "project_2"],
337            [
338                "The following projects are not found:",
339                "project_2",
340            ],
341        ),
342    ],
343)
344def test_analyse_project_negative(projects, output_lines, tmp_path: Path) -> None:
345    config_file = tmp_path / "codelinks_config.toml"
346    codelink_dict = {"codelinks": CODELINKS_CONFIG_TEMPLATE}
347    with config_file.open("w", encoding="utf-8") as f:
348        toml.dump(codelink_dict, f)
349    projects_config = []
350    for project in projects:
351        projects_config.append("--project")
352        projects_config.append(project)
353
354    options = [
355        "analyse",
356        str(config_file),
357    ]
358    options.extend(projects_config)
359    result = runner.invoke(app, options)
360    assert result.exit_code != 0
361    normalized = _normalize_output(result.output)
362    for line in output_lines:
363        assert line in normalized
364
365
366def test_write_rst_invalid_json(tmp_path: Path) -> None:
367    json_obj = {
368        "whatever": "json",
369        "not": "expected",
370    }
371    json_text = json.dumps(json_obj)
372    json_text = json_text.replace(",", "")
373    jsonpath = tmp_path / "invalid_json.json"
374    with jsonpath.open("w") as f:
375        f.write(json_text)
376
377    options = [
378        "write",
379        "rst",
380        str(jsonpath),
381        "--outpath",
382        str(tmp_path / "out.rst"),
383    ]
384    result = runner.invoke(app, options)
385
386    assert result.exit_code != 0
387    assert "Expecting" in result.output
388
389
390def test_analyse_never_reads_a_need_ids_comment_as_a_need(tmp_path: Path) -> None:
391    """``codelinks analyse`` on the default one-line style: the ``@need-ids:`` comment
392    is one reference record, and ``marked_content.json`` gains no need from it."""
393    (tmp_path / "refs.cpp").write_text(
394        "// @need-ids: REQ_001, IMPL_X\nvoid a() {}\n", encoding="utf-8"
395    )
396    config = tmp_path / "ubproject.toml"
397    config.write_text(
398        '[codelinks.projects.p.source_discover]\nsrc_dir = "./"\ncomment_type = "cpp"\n',
399        encoding="utf-8",
400    )
401    outdir = tmp_path / "out"
402    outdir.mkdir()
403
404    result = runner.invoke(app, ["analyse", str(config), "--outdir", str(outdir)])
405
406    assert result.exit_code == 0, result.output
407    marked = json.loads((outdir / "marked_content.json").read_text(encoding="utf-8"))
408    types = [obj["type"] for objs in marked.values() for obj in objs]
409    assert types == ["need-id-refs"]
410
411
412def test_write_rst_still_works_and_says_it_is_deprecated(tmp_path: Path) -> None:
413    """``write rst`` is deprecated, not removed: it writes the file as before, and
414    prints the notice on stderr (stdout carries only what it always did)."""
415    marked = {
416        "project_1": [
417            {
418                "filepath": "src/dummy_1.cpp",
419                "remote_url": "https://example.com/src/dummy_1.cpp#L3",
420                "source_map": {
421                    "start": {"row": 2, "column": 13},
422                    "end": {"row": 2, "column": 21},
423                },
424                "tagged_scope": None,
425                "need_ids": ["NEED_001"],
426                "marker": "@need-ids:",
427                "type": "need-id-refs",
428            }
429        ]
430    }
431    jsonpath = tmp_path / "marked_content.json"
432    jsonpath.write_text(json.dumps(marked), encoding="utf-8")
433    outpath = tmp_path / "needextend.rst"
434
435    result = runner.invoke(
436        app, ["write", "rst", str(jsonpath), "--outpath", str(outpath)]
437    )
438
439    assert result.exit_code == 0, result.output
440    assert WRITE_RST_DEPRECATED in _normalize_output(result.stderr)
441    assert "deprecated" not in result.stdout
442    assert outpath.read_text(encoding="utf-8") == (
443        ".. needextend:: NEED_001\n"
444        "   :remote_url: https://example.com/src/dummy_1.cpp#L3\n\n"
445    )
446
447
448@pytest.mark.parametrize(
449    ("json_objs", "output_lines"),
450    [
451        (
452            [
453                {
454                    "filepath": 123,
455                    "remote_url": "https://github.com/useblocks/sphinx-codelinks/blob/951e40e7845f06d5cfc4ca20ebb984308fdaf985/tests/data/multiline_needs/dummy_1.cpp#L4",
456                    "source_map": {
457                        "start": {"row": 3, "column": 8},
458                        "end": {"row": 3, "column": 61},
459                    },
460                    "tagged_scope": "void dummy_func1(){\n     //...\n }",
461                    "type": "need-id-refs",
462                }
463            ],
464            [
465                "Invalid value: Errors occurred",
466                "Schema validation error in field 'filepath': 123 is not of type 'string'",
467                "Marker is required for marked content of type 'need_id_refs'",
468                "Need id refs are required for marked content of type 'need_id_refs'",
469            ],
470        ),
471    ],
472)
473def test_write_rst_negative(json_objs: list[dict], output_lines, tmp_path) -> None:
474    to_dump = {"project_1": json_objs}
475    jsonpath = Path("invalid_objs.json")
476    with jsonpath.open("w") as f:
477        json.dump(to_dump, f)
478    outpath = tmp_path / "needextend.rst"
479    options = [
480        "write",
481        "rst",
482        str(jsonpath),
483        "--outpath",
484        str(outpath),
485    ]
486    result = runner.invoke(app, options)
487
488    assert result.exit_code != 0
489    normalized = _normalize_output(result.output)
490    for line in output_lines:
491        assert line in normalized
492
493
494def test_analyse_with_relative_git_root(tmp_path: Path) -> None:
495    """Test that relative git_root is resolved relative to the config file location."""
496    # Create a fake git repo structure
497    fake_git_root = tmp_path / "fake_repo"
498    fake_git_root.mkdir()
499    (fake_git_root / ".git").mkdir()
500    git_config = fake_git_root / ".git" / "config"
501    git_config.write_text(
502        '[remote "origin"]\n    url = https://github.com/test/repo.git\n'
503    )
504    git_head = fake_git_root / ".git" / "HEAD"
505    git_head.write_text("ref: refs/heads/main\n")
506    refs_dir = fake_git_root / ".git" / "refs" / "heads"
507    refs_dir.mkdir(parents=True)
508    (refs_dir / "main").write_text("abc123def456\n")
509
510    # Create source file
511    src_dir = fake_git_root / "src"
512    src_dir.mkdir()
513    src_file = src_dir / "test.c"
514    src_file.write_text("// @Test, TEST_1, test\nvoid test() {}\n")
515
516    # Create config in a subdirectory using a RELATIVE git_root path
517    config_dir = tmp_path / "config"
518    config_dir.mkdir()
519    config_file = config_dir / "codelinks.toml"
520    config_content = """
521[codelinks.projects.test_project.source_discover]
522src_dir = "../fake_repo/src"
523gitignore = false
524
525[codelinks.projects.test_project.analyse]
526get_oneline_needs = true
527git_root = "../fake_repo"
528"""
529    config_file.write_text(config_content)
530
531    outdir = tmp_path / "output"
532    outdir.mkdir()
533
534    options = ["analyse", str(config_file), "--outdir", str(outdir)]
535    result = runner.invoke(app, options)
536
537    assert result.exit_code == 0, f"CLI failed: {result.stdout}"
538    output_path = outdir / "marked_content.json"
539    assert output_path.exists()
540
541    with output_path.open("r") as f:
542        marked_content = json.load(f)
543    # Verify the content was analysed using the correct git_root
544    assert len(marked_content["test_project"]) > 0
545
546
547def test_analyse_with_absolute_git_root(tmp_path: Path) -> None:
548    """Test that absolute git_root is used as-is."""
549    # Create a fake git repo structure
550    fake_git_root = tmp_path / "fake_repo"
551    fake_git_root.mkdir()
552    (fake_git_root / ".git").mkdir()
553    git_config = fake_git_root / ".git" / "config"
554    git_config.write_text(
555        '[remote "origin"]\n    url = https://github.com/test/repo.git\n'
556    )
557    git_head = fake_git_root / ".git" / "HEAD"
558    git_head.write_text("ref: refs/heads/main\n")
559    refs_dir = fake_git_root / ".git" / "refs" / "heads"
560    refs_dir.mkdir(parents=True)
561    (refs_dir / "main").write_text("abc123def456\n")
562
563    # Create source file
564    src_dir = fake_git_root / "src"
565    src_dir.mkdir()
566    src_file = src_dir / "test.c"
567    src_file.write_text("// @Test, TEST_2, test\nvoid test() {}\n")
568
569    # Create config in a different location using an ABSOLUTE git_root path
570    config_dir = tmp_path / "config"
571    config_dir.mkdir()
572    config_file = config_dir / "codelinks.toml"
573    # Use absolute path for both src_dir and git_root
574    config_content = f"""
575[codelinks.projects.test_project.source_discover]
576src_dir = "{src_dir.as_posix()}"
577gitignore = false
578
579[codelinks.projects.test_project.analyse]
580get_oneline_needs = true
581git_root = "{fake_git_root.as_posix()}"
582"""
583    config_file.write_text(config_content)
584
585    outdir = tmp_path / "output"
586    outdir.mkdir()
587
588    options = ["analyse", str(config_file), "--outdir", str(outdir)]
589    result = runner.invoke(app, options)
590
591    assert result.exit_code == 0, f"CLI failed: {result.stdout}"
592    output_path = outdir / "marked_content.json"
593    assert output_path.exists()
594
595    with output_path.open("r") as f:
596        marked_content = json.load(f)
597    # Verify the content was analysed using the correct git_root
598    assert len(marked_content["test_project"]) > 0
599
600
601# -- the TOML reader of `codelinks analyse` ----------------------------------------------
602
603
604def _analyse(tmp_path: Path, toml_text: str) -> tuple[int, str]:
605    config = tmp_path / "cl.toml"
606    config.write_text(toml_text, encoding="utf-8")
607    result = runner.invoke(app, ["analyse", str(config), "--outdir", str(tmp_path)])
608    return result.exit_code, _normalize_output(result.output)
609
610
611def test_analyse_toml_syntax_error_shows_the_reason(tmp_path: Path) -> None:
612    """The parser's own message reaches the user, not only "Failed to load"."""
613    broken = "[codelinks\n"
614    with pytest.raises(tomllib.TOMLDecodeError) as parse_error:
615        tomllib.loads(broken)
616
617    exit_code, output = _analyse(tmp_path, broken)
618
619    assert exit_code == 2
620    assert _normalize_output(str(parse_error.value)) in output
621    # ub-project's own words, not the catch-all's
622    assert "Failed to load" not in output
623
624
625@pytest.mark.parametrize(
626    ("value", "type_name"),
627    [('"x"', "str"), ("0", "int"), ("[]", "list"), ("false", "bool")],
628)
629def test_analyse_codelinks_not_a_table(
630    tmp_path: Path, value: str, type_name: str
631) -> None:
632    """``codelinks`` that is not a table is named as such, for a falsy value too."""
633    exit_code, output = _analyse(tmp_path, f"codelinks = {value}\n")
634
635    assert exit_code == 2
636    assert f"[codelinks] must be a table, got {type_name}" in output
637    assert "No 'codelinks' section" not in output
638    assert "Failed to load" not in output
639
640
641@pytest.mark.parametrize(
642    "toml_text",
643    [
644        pytest.param("[codelinks]\n", id="empty-table"),
645        pytest.param("[needs]\nid_required = true\n", id="no-table"),
646    ],
647)
648def test_analyse_without_codelinks_configuration(
649    tmp_path: Path, toml_text: str
650) -> None:
651    """A missing or empty ``[codelinks]`` keeps today's words."""
652    exit_code, output = _analyse(tmp_path, toml_text)
653
654    assert exit_code == 2
655    assert "No 'codelinks' section found" in output
656
657
658def test_analyse_too_deeply_nested_toml_is_a_bad_parameter(tmp_path: Path) -> None:
659    """``tomllib`` can fail with an exception ub-project does not wrap (a
660    ``RecursionError`` on a pathologically nested file): still a usage error, rc 2,
661    never a traceback."""
662    exit_code, _output = _analyse(tmp_path, "x = " + "[" * 5000 + "]" * 5000 + "\n")
663
664    # 1 would be the uncaught exception; typer reports a BadParameter as 2
665    assert exit_code == 2
666
667
668def _fake_git_repo(root: Path) -> None:
669    """A repository on GitHub with one commit: ``config``, ``HEAD`` and one ref."""
670    (root / ".git" / "refs" / "heads").mkdir(parents=True)
671    (root / ".git" / "config").write_text(
672        '[remote "origin"]\n    url = https://github.com/test/repo.git\n',
673        encoding="utf-8",
674    )
675    (root / ".git" / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
676    (root / ".git" / "refs" / "heads" / "main").write_text(
677        "abc123def456\n", encoding="utf-8"
678    )
679
680
681def test_analyse_writes_a_multiline_need_record(tmp_path: Path) -> None:
682    """``codelinks analyse`` writes a multi-line need as one ``multiline-need`` record:
683    the need with its option values as written, the resolved markup, and a source whose
684    path is relative to the repository root."""
685    repo = tmp_path / "repo"
686    _fake_git_repo(repo)
687    (repo / "src").mkdir()
688    src_file = repo / "src" / "demo.cpp"
689    src_file.write_text(
690        "// @need[md] impl: Demo need\n"
691        "// :id: IMPL_DEMO\n"
692        "// :links: REQ_1, REQ_2\n"
693        "//\n"
694        "// Calls *nothing*.\n"
695        "// @endneed\n"
696        "void demo() {}\n",
697        encoding="utf-8",
698    )
699    config = repo / "ubproject.toml"
700    config.write_text(
701        "[codelinks.projects.demo.source_discover]\n"
702        'src_dir = "src"\n'
703        'comment_type = "cpp"\n'
704        "gitignore = false\n"
705        "[codelinks.projects.demo.analyse]\n"
706        "get_multiline_needs = true\n",
707        encoding="utf-8",
708    )
709    outdir = tmp_path / "out"
710    outdir.mkdir()
711
712    result = runner.invoke(app, ["analyse", str(config), "--outdir", str(outdir)])
713
714    assert result.exit_code == 0, result.output
715    marked = json.loads((outdir / "marked_content.json").read_text(encoding="utf-8"))
716    (record,) = marked["demo"]
717    assert Path(record.pop("filepath")) == src_file.resolve()
718    assert record == {
719        "remote_url": "https://github.com/test/repo/blob/abc123def456/src/demo.cpp#L1",
720        "source_map": {
721            "start": {"row": 0, "column": 3},
722            "end": {"row": 5, "column": 11},
723        },
724        "tagged_scope": "void demo() {}",
725        "type": "multiline-need",
726        "need": {
727            "type": "impl",
728            "title": "Demo need",
729            "id": "IMPL_DEMO",
730            "links": "REQ_1, REQ_2",
731            "content": "Calls *nothing*.",
732            "doctype": ".md",
733        },
734        "markup": "md",
735        "source": {
736            "project": "demo",
737            "path": "src/demo.cpp",
738            "root": "git",
739            "commit": "abc123def456",
740            "start": {"line": 1, "col": 3},
741            "end": {"line": 6, "col": 11},
742            "content_start": {"line": 5, "col": 3},
743            "option_lines": {"id": 2, "links": 3},
744            "scope": {"kind": "function_definition", "start": 7, "end": 7},
745        },
746    }
747
748
749def test_analyse_prints_a_multiline_need_warning_like_a_oneline_one(
750    tmp_path: Path,
751) -> None:
752    """A refused block is printed in the one shape every analyse warning takes."""
753    (tmp_path / "open.cpp").write_text(
754        "// @need impl: Never closed\nvoid f() {}\n", encoding="utf-8"
755    )
756    config = tmp_path / "ubproject.toml"
757    config.write_text(
758        "[codelinks.projects.p.source_discover]\n"
759        'src_dir = "./"\n'
760        'comment_type = "cpp"\n'
761        "[codelinks.projects.p.analyse]\n"
762        "get_multiline_needs = true\n",
763        encoding="utf-8",
764    )
765    outdir = tmp_path / "out"
766    outdir.mkdir()
767
768    result = runner.invoke(app, ["analyse", str(config), "--outdir", str(outdir)])
769
770    assert result.exit_code == 0, result.output
771    # rich wraps the console at its width, anywhere in a long path: compare without
772    # whitespace
773    expected = (
774        f"Analyse warning in {tmp_path.resolve() / 'open.cpp'}:1 - "
775        "multiline_need_unterminated: no '@endneed' line before the comment ends"
776    )
777    assert re.sub(r"\s+", "", expected) in re.sub(r"\s+", "", result.output)
778
779
780@pytest.mark.parametrize(
781    ("analyse", "message"),
782    [
783        pytest.param(
784            {"get_rst": True},
785            "analyse: 'get_rst' is no longer supported: the marked-rst blocks were "
786            "replaced by multi-line needs (the @need and @endneed markers); use "
787            "get_multiline_needs instead",
788            id="get_rst",
789        ),
790        pytest.param(
791            {"marked_rst": {"start_sequence": "@rst"}},
792            "analyse: '[analyse.marked_rst]' is no longer supported",
793            id="marked_rst",
794        ),
795        pytest.param(
796            {"get_rst": False, "marked_rst": {"end_sequence": "@endrst"}},
797            "analyse: 'get_rst' and '[analyse.marked_rst]' are no longer supported: "
798            "the marked-rst blocks were replaced by multi-line needs (the @need and "
799            "@endneed markers); use get_multiline_needs and [analyse.multiline_needs] "
800            "instead",
801            id="both",
802        ),
803    ],
804)
805def test_analyse_names_the_replacement_of_a_removed_key(
806    analyse: dict, message: str, tmp_path: Path
807) -> None:
808    """The keys of the removed marked-rst blocks stop the CLI with one message naming
809    what replaces them, through the channel of every other configuration error."""
810    config_file = tmp_path / "codelinks_config.toml"
811    with config_file.open("w", encoding="utf-8") as f:
812        toml.dump(
813            {
814                "codelinks": {
815                    "projects": {
816                        "p": {
817                            "source_discover": {"src_dir": str(tmp_path)},
818                            "analyse": analyse,
819                        }
820                    }
821                }
822            },
823            f,
824        )
825
826    result = runner.invoke(app, ["analyse", str(config_file)])
827
828    assert result.exit_code != 0
829    assert f"Invalid value: {message}" in _normalize_output(result.output)
830
831
832@pytest.mark.parametrize(
833    ("selected", "warnings"),
834    [pytest.param([], 2, id="every_project"), pytest.param(["-p", "a"], 1, id="one")],
835)
836def test_analyse_tolerates_get_rst_false(
837    tmp_path: Path, selected: list[str], warnings: int
838) -> None:
839    """``get_rst = false`` is warned about once per project the command analyses, and
840    the analysis runs."""
841    config_file = tmp_path / "codelinks_config.toml"
842    project = {
843        "source_discover": {"src_dir": str(tmp_path)},
844        "analyse": {"get_rst": False},
845    }
846    with config_file.open("w", encoding="utf-8") as f:
847        toml.dump({"codelinks": {"projects": {"a": project, "b": project}}}, f)
848
849    result = runner.invoke(app, ["analyse", str(config_file), *selected])
850
851    assert result.exit_code == 0, result.output
852    output = _normalize_output(result.output)
853    ignored = (
854        "analyse: 'get_rst' is no longer read: the marked-rst blocks were replaced "
855        "by multi-line needs (the @need and @endneed markers); get_rst = false asks "
856        "for nothing and can be removed"
857    )
858    assert output.count(ignored) == warnings
859    # each line names its project
860    named = [name for name in ("a", "b") if f"Project {name!r}: {ignored}" in output]
861    assert named == ["a", "b"][:warnings]