[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
121    assert "Oneline parser warning" 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/marked_rst/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                    "rst": ".. impl:: implement dummy function 1\n   :id: IMPL_71\n",
392                    "type": "need-id-refs",
393                }
394            ],
395            [
396                "Invalid value: Errors occurred",
397                "Schema validation error in field 'filepath': 123 is not of type 'string'",
398                "Marker is required for marked content of type 'need_id_refs'",
399                "Need id refs are required for marked content of type 'need_id_refs'",
400            ],
401        ),
402    ],
403)
404def test_write_rst_negative(json_objs: list[dict], output_lines, tmp_path) -> None:
405    to_dump = {"project_1": json_objs}
406    jsonpath = Path("invalid_objs.json")
407    with jsonpath.open("w") as f:
408        json.dump(to_dump, f)
409    outpath = tmp_path / "needextend.rst"
410    options = [
411        "write",
412        "rst",
413        str(jsonpath),
414        "--outpath",
415        str(outpath),
416    ]
417    result = runner.invoke(app, options)
418
419    assert result.exit_code != 0
420    normalized = _normalize_output(result.output)
421    for line in output_lines:
422        assert line in normalized
423
424
425def test_analyse_with_relative_git_root(tmp_path: Path) -> None:
426    """Test that relative git_root is resolved relative to the config file location."""
427    # Create a fake git repo structure
428    fake_git_root = tmp_path / "fake_repo"
429    fake_git_root.mkdir()
430    (fake_git_root / ".git").mkdir()
431    git_config = fake_git_root / ".git" / "config"
432    git_config.write_text(
433        '[remote "origin"]\n    url = https://github.com/test/repo.git\n'
434    )
435    git_head = fake_git_root / ".git" / "HEAD"
436    git_head.write_text("ref: refs/heads/main\n")
437    refs_dir = fake_git_root / ".git" / "refs" / "heads"
438    refs_dir.mkdir(parents=True)
439    (refs_dir / "main").write_text("abc123def456\n")
440
441    # Create source file
442    src_dir = fake_git_root / "src"
443    src_dir.mkdir()
444    src_file = src_dir / "test.c"
445    src_file.write_text("// @Test, TEST_1, test\nvoid test() {}\n")
446
447    # Create config in a subdirectory using a RELATIVE git_root path
448    config_dir = tmp_path / "config"
449    config_dir.mkdir()
450    config_file = config_dir / "codelinks.toml"
451    config_content = """
452[codelinks.projects.test_project.source_discover]
453src_dir = "../fake_repo/src"
454gitignore = false
455
456[codelinks.projects.test_project.analyse]
457get_oneline_needs = true
458git_root = "../fake_repo"
459"""
460    config_file.write_text(config_content)
461
462    outdir = tmp_path / "output"
463    outdir.mkdir()
464
465    options = ["analyse", str(config_file), "--outdir", str(outdir)]
466    result = runner.invoke(app, options)
467
468    assert result.exit_code == 0, f"CLI failed: {result.stdout}"
469    output_path = outdir / "marked_content.json"
470    assert output_path.exists()
471
472    with output_path.open("r") as f:
473        marked_content = json.load(f)
474    # Verify the content was analysed using the correct git_root
475    assert len(marked_content["test_project"]) > 0
476
477
478def test_analyse_with_absolute_git_root(tmp_path: Path) -> None:
479    """Test that absolute git_root is used as-is."""
480    # Create a fake git repo structure
481    fake_git_root = tmp_path / "fake_repo"
482    fake_git_root.mkdir()
483    (fake_git_root / ".git").mkdir()
484    git_config = fake_git_root / ".git" / "config"
485    git_config.write_text(
486        '[remote "origin"]\n    url = https://github.com/test/repo.git\n'
487    )
488    git_head = fake_git_root / ".git" / "HEAD"
489    git_head.write_text("ref: refs/heads/main\n")
490    refs_dir = fake_git_root / ".git" / "refs" / "heads"
491    refs_dir.mkdir(parents=True)
492    (refs_dir / "main").write_text("abc123def456\n")
493
494    # Create source file
495    src_dir = fake_git_root / "src"
496    src_dir.mkdir()
497    src_file = src_dir / "test.c"
498    src_file.write_text("// @Test, TEST_2, test\nvoid test() {}\n")
499
500    # Create config in a different location using an ABSOLUTE git_root path
501    config_dir = tmp_path / "config"
502    config_dir.mkdir()
503    config_file = config_dir / "codelinks.toml"
504    # Use absolute path for both src_dir and git_root
505    config_content = f"""
506[codelinks.projects.test_project.source_discover]
507src_dir = "{src_dir.as_posix()}"
508gitignore = false
509
510[codelinks.projects.test_project.analyse]
511get_oneline_needs = true
512git_root = "{fake_git_root.as_posix()}"
513"""
514    config_file.write_text(config_content)
515
516    outdir = tmp_path / "output"
517    outdir.mkdir()
518
519    options = ["analyse", str(config_file), "--outdir", str(outdir)]
520    result = runner.invoke(app, options)
521
522    assert result.exit_code == 0, f"CLI failed: {result.stdout}"
523    output_path = outdir / "marked_content.json"
524    assert output_path.exists()
525
526    with output_path.open("r") as f:
527        marked_content = json.load(f)
528    # Verify the content was analysed using the correct git_root
529    assert len(marked_content["test_project"]) > 0
530
531
532# -- the TOML reader of `codelinks analyse` ----------------------------------------------
533
534
535def _analyse(tmp_path: Path, toml_text: str) -> tuple[int, str]:
536    config = tmp_path / "cl.toml"
537    config.write_text(toml_text, encoding="utf-8")
538    result = runner.invoke(app, ["analyse", str(config), "--outdir", str(tmp_path)])
539    return result.exit_code, _normalize_output(result.output)
540
541
542def test_analyse_toml_syntax_error_shows_the_reason(tmp_path: Path) -> None:
543    """The parser's own message reaches the user, not only "Failed to load"."""
544    broken = "[codelinks\n"
545    with pytest.raises(tomllib.TOMLDecodeError) as parse_error:
546        tomllib.loads(broken)
547
548    exit_code, output = _analyse(tmp_path, broken)
549
550    assert exit_code == 2
551    assert _normalize_output(str(parse_error.value)) in output
552    # ub-project's own words, not the catch-all's
553    assert "Failed to load" not in output
554
555
556@pytest.mark.parametrize(
557    ("value", "type_name"),
558    [('"x"', "str"), ("0", "int"), ("[]", "list"), ("false", "bool")],
559)
560def test_analyse_codelinks_not_a_table(
561    tmp_path: Path, value: str, type_name: str
562) -> None:
563    """``codelinks`` that is not a table is named as such, for a falsy value too."""
564    exit_code, output = _analyse(tmp_path, f"codelinks = {value}\n")
565
566    assert exit_code == 2
567    assert f"[codelinks] must be a table, got {type_name}" in output
568    assert "No 'codelinks' section" not in output
569    assert "Failed to load" not in output
570
571
572@pytest.mark.parametrize(
573    "toml_text",
574    [
575        pytest.param("[codelinks]\n", id="empty-table"),
576        pytest.param("[needs]\nid_required = true\n", id="no-table"),
577    ],
578)
579def test_analyse_without_codelinks_configuration(
580    tmp_path: Path, toml_text: str
581) -> None:
582    """A missing or empty ``[codelinks]`` keeps today's words."""
583    exit_code, output = _analyse(tmp_path, toml_text)
584
585    assert exit_code == 2
586    assert "No 'codelinks' section found" in output
587
588
589def test_analyse_too_deeply_nested_toml_is_a_bad_parameter(tmp_path: Path) -> None:
590    """``tomllib`` can fail with an exception ub-project does not wrap (a
591    ``RecursionError`` on a pathologically nested file): still a usage error, rc 2,
592    never a traceback."""
593    exit_code, _output = _analyse(tmp_path, "x = " + "[" * 5000 + "]" * 5000 + "\n")
594
595    # 1 would be the uncaught exception; typer reports a BadParameter as 2
596    assert exit_code == 2