[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
165@pytest.mark.parametrize(
166    ("src_discover_dict", "analyse_dict", "output_lines"),
167    [
168        (
169            {
170                src_key: (123 if src_key == "exclude" else src_value)
171                for src_key, src_value in SRC_DISCOVER_TEMPLATE.items()
172            },
173            ANALYSE_CONFIG_TEMPLATE,
174            [
175                "Invalid value: Invalid source discovery configuration:",
176                "Schema validation error in field 'exclude': 123 is not of type 'array'",
177            ],
178        ),
179        (
180            {
181                src_key: (123 if src_key == "include" else src_value)
182                for src_key, src_value in SRC_DISCOVER_TEMPLATE.items()
183            },
184            ANALYSE_CONFIG_TEMPLATE,
185            [
186                "Invalid value: Invalid source discovery configuration:",
187                "Schema validation error in field 'include': 123 is not of type 'array'",
188            ],
189        ),
190        (
191            {
192                src_key: (
193                    123 if src_key in ("exclude", "include", "src_dir") else src_value
194                )
195                for src_key, src_value in SRC_DISCOVER_TEMPLATE.items()
196            },
197            ANALYSE_CONFIG_TEMPLATE,
198            [
199                "Invalid value: Invalid source discovery configuration:",
200                "Schema validation error in field 'src_dir': 123 is not of type 'string'",
201                "Schema validation error in field 'exclude': 123 is not of type 'array'",
202                "Schema validation error in field 'include': 123 is not of type 'array'",
203            ],
204        ),
205        (
206            SRC_DISCOVER_TEMPLATE,
207            {
208                analyse_key: (
209                    {"not_expected": 123}
210                    if analyse_key == "oneline_comment_style"
211                    else analyse_value
212                )
213                for analyse_key, analyse_value in ANALYSE_CONFIG_TEMPLATE.items()
214            },
215            [
216                "Invalid value: Invalid oneline comment style configuration:",
217                "OneLineCommentStyle.__init__() got an unexpected keyword argument",
218                "'not_expected'",
219            ],
220        ),
221        (
222            SRC_DISCOVER_TEMPLATE,
223            {
224                analyse_key: (
225                    {"needs_fields": [{"name": "id"}, {"name": "id"}]}
226                    if analyse_key == "oneline_comment_style"
227                    else analyse_value
228                )
229                for analyse_key, analyse_value in ANALYSE_CONFIG_TEMPLATE.items()
230            },
231            [
232                "Invalid value: OneLineCommentStyle configuration errors:",
233                "Missing required fields: ['title', 'type']",
234                "Field 'id' is defined multiple times.",
235            ],
236        ),
237    ],
238)
239def test_analyse_config_negative(
240    src_discover_dict, analyse_dict, output_lines, tmp_path: Path
241) -> None:
242    config_file = tmp_path / "codelinks_config.toml"
243    codelink_dict = {"codelinks": CODELINKS_CONFIG_TEMPLATE}
244    codelink_dict["codelinks"]["projects"]["project_1"]["source_discover"] = (
245        src_discover_dict
246    )
247    codelink_dict["codelinks"]["projects"]["project_1"]["analyse"] = analyse_dict
248    with config_file.open("w", encoding="utf-8") as f:
249        toml.dump(codelink_dict, f)
250
251    options = [
252        "analyse",
253        str(config_file),
254    ]
255    result = runner.invoke(app, options)
256    assert result.exit_code != 0
257    normalized = _normalize_output(result.output)
258    for line in output_lines:
259        assert line in normalized
260
261
262@pytest.mark.parametrize(
263    ("projects", "output_lines"),
264    [
265        (
266            ["project_1", "project_2"],
267            [
268                "The following projects are not found:",
269                "project_2",
270            ],
271        ),
272    ],
273)
274def test_analyse_project_negative(projects, output_lines, tmp_path: Path) -> None:
275    config_file = tmp_path / "codelinks_config.toml"
276    codelink_dict = {"codelinks": CODELINKS_CONFIG_TEMPLATE}
277    with config_file.open("w", encoding="utf-8") as f:
278        toml.dump(codelink_dict, f)
279    projects_config = []
280    for project in projects:
281        projects_config.append("--project")
282        projects_config.append(project)
283
284    options = [
285        "analyse",
286        str(config_file),
287    ]
288    options.extend(projects_config)
289    result = runner.invoke(app, options)
290    assert result.exit_code != 0
291    normalized = _normalize_output(result.output)
292    for line in output_lines:
293        assert line in normalized
294
295
296def test_write_rst_invalid_json(tmp_path: Path) -> None:
297    json_obj = {
298        "whatever": "json",
299        "not": "expected",
300    }
301    json_text = json.dumps(json_obj)
302    json_text = json_text.replace(",", "")
303    jsonpath = tmp_path / "invalid_json.json"
304    with jsonpath.open("w") as f:
305        f.write(json_text)
306
307    options = [
308        "write",
309        "rst",
310        str(jsonpath),
311        "--outpath",
312        str(tmp_path / "out.rst"),
313    ]
314    result = runner.invoke(app, options)
315
316    assert result.exit_code != 0
317    assert "Expecting" in result.output
318
319
320def test_analyse_never_reads_a_need_ids_comment_as_a_need(tmp_path: Path) -> None:
321    """``codelinks analyse`` on the default one-line style: the ``@need-ids:`` comment
322    is one reference record, and ``marked_content.json`` gains no need from it."""
323    (tmp_path / "refs.cpp").write_text(
324        "// @need-ids: REQ_001, IMPL_X\nvoid a() {}\n", encoding="utf-8"
325    )
326    config = tmp_path / "ubproject.toml"
327    config.write_text(
328        '[codelinks.projects.p.source_discover]\nsrc_dir = "./"\ncomment_type = "cpp"\n',
329        encoding="utf-8",
330    )
331    outdir = tmp_path / "out"
332    outdir.mkdir()
333
334    result = runner.invoke(app, ["analyse", str(config), "--outdir", str(outdir)])
335
336    assert result.exit_code == 0, result.output
337    marked = json.loads((outdir / "marked_content.json").read_text(encoding="utf-8"))
338    types = [obj["type"] for objs in marked.values() for obj in objs]
339    assert types == ["need-id-refs"]
340
341
342def test_write_rst_still_works_and_says_it_is_deprecated(tmp_path: Path) -> None:
343    """``write rst`` is deprecated, not removed: it writes the file as before, and
344    prints the notice on stderr (stdout carries only what it always did)."""
345    marked = {
346        "project_1": [
347            {
348                "filepath": "src/dummy_1.cpp",
349                "remote_url": "https://example.com/src/dummy_1.cpp#L3",
350                "source_map": {
351                    "start": {"row": 2, "column": 13},
352                    "end": {"row": 2, "column": 21},
353                },
354                "tagged_scope": None,
355                "need_ids": ["NEED_001"],
356                "marker": "@need-ids:",
357                "type": "need-id-refs",
358            }
359        ]
360    }
361    jsonpath = tmp_path / "marked_content.json"
362    jsonpath.write_text(json.dumps(marked), encoding="utf-8")
363    outpath = tmp_path / "needextend.rst"
364
365    result = runner.invoke(
366        app, ["write", "rst", str(jsonpath), "--outpath", str(outpath)]
367    )
368
369    assert result.exit_code == 0, result.output
370    assert WRITE_RST_DEPRECATED in _normalize_output(result.stderr)
371    assert "deprecated" not in result.stdout
372    assert outpath.read_text(encoding="utf-8") == (
373        ".. needextend:: NEED_001\n"
374        "   :remote_url: https://example.com/src/dummy_1.cpp#L3\n\n"
375    )
376
377
378@pytest.mark.parametrize(
379    ("json_objs", "output_lines"),
380    [
381        (
382            [
383                {
384                    "filepath": 123,
385                    "remote_url": "https://github.com/useblocks/sphinx-codelinks/blob/951e40e7845f06d5cfc4ca20ebb984308fdaf985/tests/data/multiline_needs/dummy_1.cpp#L4",
386                    "source_map": {
387                        "start": {"row": 3, "column": 8},
388                        "end": {"row": 3, "column": 61},
389                    },
390                    "tagged_scope": "void dummy_func1(){\n     //...\n }",
391                    "type": "need-id-refs",
392                }
393            ],
394            [
395                "Invalid value: Errors occurred",
396                "Schema validation error in field 'filepath': 123 is not of type 'string'",
397                "Marker is required for marked content of type 'need_id_refs'",
398                "Need id refs are required for marked content of type 'need_id_refs'",
399            ],
400        ),
401    ],
402)
403def test_write_rst_negative(json_objs: list[dict], output_lines, tmp_path) -> None:
404    to_dump = {"project_1": json_objs}
405    jsonpath = Path("invalid_objs.json")
406    with jsonpath.open("w") as f:
407        json.dump(to_dump, f)
408    outpath = tmp_path / "needextend.rst"
409    options = [
410        "write",
411        "rst",
412        str(jsonpath),
413        "--outpath",
414        str(outpath),
415    ]
416    result = runner.invoke(app, options)
417
418    assert result.exit_code != 0
419    normalized = _normalize_output(result.output)
420    for line in output_lines:
421        assert line in normalized
422
423
424def test_analyse_with_relative_git_root(tmp_path: Path) -> None:
425    """Test that relative git_root is resolved relative to the config file location."""
426    # Create a fake git repo structure
427    fake_git_root = tmp_path / "fake_repo"
428    fake_git_root.mkdir()
429    (fake_git_root / ".git").mkdir()
430    git_config = fake_git_root / ".git" / "config"
431    git_config.write_text(
432        '[remote "origin"]\n    url = https://github.com/test/repo.git\n'
433    )
434    git_head = fake_git_root / ".git" / "HEAD"
435    git_head.write_text("ref: refs/heads/main\n")
436    refs_dir = fake_git_root / ".git" / "refs" / "heads"
437    refs_dir.mkdir(parents=True)
438    (refs_dir / "main").write_text("abc123def456\n")
439
440    # Create source file
441    src_dir = fake_git_root / "src"
442    src_dir.mkdir()
443    src_file = src_dir / "test.c"
444    src_file.write_text("// @Test, TEST_1, test\nvoid test() {}\n")
445
446    # Create config in a subdirectory using a RELATIVE git_root path
447    config_dir = tmp_path / "config"
448    config_dir.mkdir()
449    config_file = config_dir / "codelinks.toml"
450    config_content = """
451[codelinks.projects.test_project.source_discover]
452src_dir = "../fake_repo/src"
453gitignore = false
454
455[codelinks.projects.test_project.analyse]
456get_oneline_needs = true
457git_root = "../fake_repo"
458"""
459    config_file.write_text(config_content)
460
461    outdir = tmp_path / "output"
462    outdir.mkdir()
463
464    options = ["analyse", str(config_file), "--outdir", str(outdir)]
465    result = runner.invoke(app, options)
466
467    assert result.exit_code == 0, f"CLI failed: {result.stdout}"
468    output_path = outdir / "marked_content.json"
469    assert output_path.exists()
470
471    with output_path.open("r") as f:
472        marked_content = json.load(f)
473    # Verify the content was analysed using the correct git_root
474    assert len(marked_content["test_project"]) > 0
475
476
477def test_analyse_with_absolute_git_root(tmp_path: Path) -> None:
478    """Test that absolute git_root is used as-is."""
479    # Create a fake git repo structure
480    fake_git_root = tmp_path / "fake_repo"
481    fake_git_root.mkdir()
482    (fake_git_root / ".git").mkdir()
483    git_config = fake_git_root / ".git" / "config"
484    git_config.write_text(
485        '[remote "origin"]\n    url = https://github.com/test/repo.git\n'
486    )
487    git_head = fake_git_root / ".git" / "HEAD"
488    git_head.write_text("ref: refs/heads/main\n")
489    refs_dir = fake_git_root / ".git" / "refs" / "heads"
490    refs_dir.mkdir(parents=True)
491    (refs_dir / "main").write_text("abc123def456\n")
492
493    # Create source file
494    src_dir = fake_git_root / "src"
495    src_dir.mkdir()
496    src_file = src_dir / "test.c"
497    src_file.write_text("// @Test, TEST_2, test\nvoid test() {}\n")
498
499    # Create config in a different location using an ABSOLUTE git_root path
500    config_dir = tmp_path / "config"
501    config_dir.mkdir()
502    config_file = config_dir / "codelinks.toml"
503    # Use absolute path for both src_dir and git_root
504    config_content = f"""
505[codelinks.projects.test_project.source_discover]
506src_dir = "{src_dir.as_posix()}"
507gitignore = false
508
509[codelinks.projects.test_project.analyse]
510get_oneline_needs = true
511git_root = "{fake_git_root.as_posix()}"
512"""
513    config_file.write_text(config_content)
514
515    outdir = tmp_path / "output"
516    outdir.mkdir()
517
518    options = ["analyse", str(config_file), "--outdir", str(outdir)]
519    result = runner.invoke(app, options)
520
521    assert result.exit_code == 0, f"CLI failed: {result.stdout}"
522    output_path = outdir / "marked_content.json"
523    assert output_path.exists()
524
525    with output_path.open("r") as f:
526        marked_content = json.load(f)
527    # Verify the content was analysed using the correct git_root
528    assert len(marked_content["test_project"]) > 0
529
530
531# -- the TOML reader of `codelinks analyse` ----------------------------------------------
532
533
534def _analyse(tmp_path: Path, toml_text: str) -> tuple[int, str]:
535    config = tmp_path / "cl.toml"
536    config.write_text(toml_text, encoding="utf-8")
537    result = runner.invoke(app, ["analyse", str(config), "--outdir", str(tmp_path)])
538    return result.exit_code, _normalize_output(result.output)
539
540
541def test_analyse_toml_syntax_error_shows_the_reason(tmp_path: Path) -> None:
542    """The parser's own message reaches the user, not only "Failed to load"."""
543    broken = "[codelinks\n"
544    with pytest.raises(tomllib.TOMLDecodeError) as parse_error:
545        tomllib.loads(broken)
546
547    exit_code, output = _analyse(tmp_path, broken)
548
549    assert exit_code == 2
550    assert _normalize_output(str(parse_error.value)) in output
551    # ub-project's own words, not the catch-all's
552    assert "Failed to load" not in output
553
554
555@pytest.mark.parametrize(
556    ("value", "type_name"),
557    [('"x"', "str"), ("0", "int"), ("[]", "list"), ("false", "bool")],
558)
559def test_analyse_codelinks_not_a_table(
560    tmp_path: Path, value: str, type_name: str
561) -> None:
562    """``codelinks`` that is not a table is named as such, for a falsy value too."""
563    exit_code, output = _analyse(tmp_path, f"codelinks = {value}\n")
564
565    assert exit_code == 2
566    assert f"[codelinks] must be a table, got {type_name}" in output
567    assert "No 'codelinks' section" not in output
568    assert "Failed to load" not in output
569
570
571@pytest.mark.parametrize(
572    "toml_text",
573    [
574        pytest.param("[codelinks]\n", id="empty-table"),
575        pytest.param("[needs]\nid_required = true\n", id="no-table"),
576    ],
577)
578def test_analyse_without_codelinks_configuration(
579    tmp_path: Path, toml_text: str
580) -> None:
581    """A missing or empty ``[codelinks]`` keeps today's words."""
582    exit_code, output = _analyse(tmp_path, toml_text)
583
584    assert exit_code == 2
585    assert "No 'codelinks' section found" in output
586
587
588def test_analyse_too_deeply_nested_toml_is_a_bad_parameter(tmp_path: Path) -> None:
589    """``tomllib`` can fail with an exception ub-project does not wrap (a
590    ``RecursionError`` on a pathologically nested file): still a usage error, rc 2,
591    never a traceback."""
592    exit_code, _output = _analyse(tmp_path, "x = " + "[" * 5000 + "]" * 5000 + "\n")
593
594    # 1 would be the uncaught exception; typer reports a BadParameter as 2
595    assert exit_code == 2
596
597
598def _fake_git_repo(root: Path) -> None:
599    """A repository on GitHub with one commit: ``config``, ``HEAD`` and one ref."""
600    (root / ".git" / "refs" / "heads").mkdir(parents=True)
601    (root / ".git" / "config").write_text(
602        '[remote "origin"]\n    url = https://github.com/test/repo.git\n',
603        encoding="utf-8",
604    )
605    (root / ".git" / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
606    (root / ".git" / "refs" / "heads" / "main").write_text(
607        "abc123def456\n", encoding="utf-8"
608    )
609
610
611def test_analyse_writes_a_multiline_need_record(tmp_path: Path) -> None:
612    """``codelinks analyse`` writes a multi-line need as one ``multiline-need`` record:
613    the need with its option values as written, the resolved markup, and a source whose
614    path is relative to the repository root."""
615    repo = tmp_path / "repo"
616    _fake_git_repo(repo)
617    (repo / "src").mkdir()
618    src_file = repo / "src" / "demo.cpp"
619    src_file.write_text(
620        "// @need[md] impl: Demo need\n"
621        "// :id: IMPL_DEMO\n"
622        "// :links: REQ_1, REQ_2\n"
623        "//\n"
624        "// Calls *nothing*.\n"
625        "// @endneed\n"
626        "void demo() {}\n",
627        encoding="utf-8",
628    )
629    config = repo / "ubproject.toml"
630    config.write_text(
631        "[codelinks.projects.demo.source_discover]\n"
632        'src_dir = "src"\n'
633        'comment_type = "cpp"\n'
634        "gitignore = false\n"
635        "[codelinks.projects.demo.analyse]\n"
636        "get_multiline_needs = true\n",
637        encoding="utf-8",
638    )
639    outdir = tmp_path / "out"
640    outdir.mkdir()
641
642    result = runner.invoke(app, ["analyse", str(config), "--outdir", str(outdir)])
643
644    assert result.exit_code == 0, result.output
645    marked = json.loads((outdir / "marked_content.json").read_text(encoding="utf-8"))
646    (record,) = marked["demo"]
647    assert Path(record.pop("filepath")) == src_file.resolve()
648    assert record == {
649        "remote_url": "https://github.com/test/repo/blob/abc123def456/src/demo.cpp#L1",
650        "source_map": {
651            "start": {"row": 0, "column": 3},
652            "end": {"row": 5, "column": 11},
653        },
654        "tagged_scope": "void demo() {}",
655        "type": "multiline-need",
656        "need": {
657            "type": "impl",
658            "title": "Demo need",
659            "id": "IMPL_DEMO",
660            "links": "REQ_1, REQ_2",
661            "content": "Calls *nothing*.",
662            "doctype": ".md",
663        },
664        "markup": "md",
665        "source": {
666            "project": "demo",
667            "path": "src/demo.cpp",
668            "root": "git",
669            "commit": "abc123def456",
670            "start": {"line": 1, "col": 3},
671            "end": {"line": 6, "col": 11},
672            "content_start": {"line": 5, "col": 3},
673            "option_lines": {"id": 2, "links": 3},
674            "scope": {"kind": "function_definition", "start": 7, "end": 7},
675        },
676    }
677
678
679def test_analyse_prints_a_multiline_need_warning_like_a_oneline_one(
680    tmp_path: Path,
681) -> None:
682    """A refused block is printed in the one shape every analyse warning takes."""
683    (tmp_path / "open.cpp").write_text(
684        "// @need impl: Never closed\nvoid f() {}\n", encoding="utf-8"
685    )
686    config = tmp_path / "ubproject.toml"
687    config.write_text(
688        "[codelinks.projects.p.source_discover]\n"
689        'src_dir = "./"\n'
690        'comment_type = "cpp"\n'
691        "[codelinks.projects.p.analyse]\n"
692        "get_multiline_needs = true\n",
693        encoding="utf-8",
694    )
695    outdir = tmp_path / "out"
696    outdir.mkdir()
697
698    result = runner.invoke(app, ["analyse", str(config), "--outdir", str(outdir)])
699
700    assert result.exit_code == 0, result.output
701    # rich wraps the console at its width, anywhere in a long path: compare without
702    # whitespace
703    expected = (
704        f"Analyse warning in {tmp_path.resolve() / 'open.cpp'}:1 - "
705        "multiline_need_unterminated: no '@endneed' line before the comment ends"
706    )
707    assert re.sub(r"\s+", "", expected) in re.sub(r"\s+", "", result.output)
708
709
710@pytest.mark.parametrize(
711    ("analyse", "message"),
712    [
713        pytest.param(
714            {"get_rst": True},
715            "analyse: 'get_rst' is no longer supported: the marked-rst blocks were "
716            "replaced by multi-line needs (the @need and @endneed markers); use "
717            "get_multiline_needs instead",
718            id="get_rst",
719        ),
720        pytest.param(
721            {"marked_rst": {"start_sequence": "@rst"}},
722            "analyse: '[analyse.marked_rst]' is no longer supported",
723            id="marked_rst",
724        ),
725        pytest.param(
726            {"get_rst": False, "marked_rst": {"end_sequence": "@endrst"}},
727            "analyse: 'get_rst' and '[analyse.marked_rst]' are no longer supported: "
728            "the marked-rst blocks were replaced by multi-line needs (the @need and "
729            "@endneed markers); use get_multiline_needs and [analyse.multiline_needs] "
730            "instead",
731            id="both",
732        ),
733    ],
734)
735def test_analyse_names_the_replacement_of_a_removed_key(
736    analyse: dict, message: str, tmp_path: Path
737) -> None:
738    """The keys of the removed marked-rst blocks stop the CLI with one message naming
739    what replaces them, through the channel of every other configuration error."""
740    config_file = tmp_path / "codelinks_config.toml"
741    with config_file.open("w", encoding="utf-8") as f:
742        toml.dump(
743            {
744                "codelinks": {
745                    "projects": {
746                        "p": {
747                            "source_discover": {"src_dir": str(tmp_path)},
748                            "analyse": analyse,
749                        }
750                    }
751                }
752            },
753            f,
754        )
755
756    result = runner.invoke(app, ["analyse", str(config_file)])
757
758    assert result.exit_code != 0
759    assert f"Invalid value: {message}" in _normalize_output(result.output)