From 638d9e2c37552a5abe9004aa4b1dbe55da390307 Mon Sep 17 00:00:00 2001 From: Ben Younes <2910651+ousamabenyounes@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:54:35 +0100 Subject: [PATCH] fix(ingest): recognize Cargo.toml as a package manifest (#2434) --- graphify/detect.py | 2 +- graphify/manifest_ingest.py | 43 +++++++++++++++++++++++++++-- tests/test_manifest_ingest.py | 51 +++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/graphify/detect.py b/graphify/detect.py index 23e9198b..60c30786 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -490,7 +490,7 @@ def _shebang_file_type(path: Path) -> FileType | None: def classify_file(path: Path) -> FileType | None: - # Package manifests (apm.yml, pyproject.toml, go.mod, pom.xml) are parsed + # Package manifests (apm.yml, pyproject.toml, Cargo.toml, go.mod, pom.xml) are parsed # deterministically, so route them to the AST path (CODE) rather than the LLM # document path — otherwise apm.yml (a .yml "document") would be LLM-extracted # and a package would split into duplicate file-anchored nodes (#1377). diff --git a/graphify/manifest_ingest.py b/graphify/manifest_ingest.py index ae3aa61f..c18f2b50 100644 --- a/graphify/manifest_ingest.py +++ b/graphify/manifest_ingest.py @@ -1,7 +1,7 @@ """Deterministic package-manifest ingestion (#1377). -Package manifests (``apm.yml``, ``pyproject.toml``, ``go.mod``, ``pom.xml``) -declare a package and its dependencies. Left to the LLM document path, the same +Package manifests (``apm.yml``, ``pyproject.toml``, ``Cargo.toml``, ``go.mod``, +``pom.xml``) declare a package and its dependencies. Left to the LLM document path, the same package gets a different file-anchored node id from its own manifest than from each dependent's dependency reference, so it splits into duplicate nodes. This module parses manifests deterministically and emits ONE canonical package node @@ -29,6 +29,7 @@ PACKAGE_MANIFEST_NAMES: dict[str, str] = { "apm.yml": "apm", "apm.yaml": "apm", "pyproject.toml": "python", + "cargo.toml": "cargo", "go.mod": "go", "pom.xml": "maven", } @@ -193,6 +194,43 @@ def _parse_pyproject(text: str) -> dict | None: return {"name": name, "version": proj.get("version") or (poetry.get("version") if isinstance(poetry, dict) else None), "deps": deps} +def _parse_cargo(text: str) -> dict | None: + """Cargo.toml: name/version from ``[package]``, runtime deps from + ``[dependencies]`` plus every ``[target..dependencies]`` table (mirrors + ``_parse_pyproject``'s runtime-only scope; dev-/build-dependencies excluded).""" + try: + import tomllib as _toml + except ImportError: # pragma: no cover — Python < 3.11 without tomli only + try: + import tomli as _toml # type: ignore + except ImportError: + return None + data = _toml.loads(text) + pkg = data.get("package", {}) if isinstance(data.get("package"), dict) else {} + name = pkg.get("name") + # A virtual workspace root (``[workspace]``, no ``[package]``) declares no + # package of its own — emit nothing rather than a fabricated node. ``name`` is + # never workspace-inheritable in Cargo, but guard on the type anyway. + if not isinstance(name, str) or not name: + return None + # ``version`` may be workspace-inherited (``version.workspace = true``), which + # parses to a table; keep only a concrete string version. + version = pkg.get("version") + if not isinstance(version, str): + version = None + # A dependency value is a bare version string or an inline table; either way + # _coerce_deps keys it by the dependency NAME (the table/map key). + deps = _coerce_deps(data.get("dependencies")) + # Platform-conditional deps live under ``[target..dependencies]``; fold + # them in so a crate whose deps are entirely cfg-gated still emits its edges. + targets = data.get("target") + if isinstance(targets, dict): + for cfg in targets.values(): + if isinstance(cfg, dict): + deps += _coerce_deps(cfg.get("dependencies")) + return {"name": name, "version": version, "deps": deps} + + def _parse_gomod(text: str) -> dict | None: name = None deps: list[str] = [] @@ -242,6 +280,7 @@ def _parse_pom(text: str) -> dict | None: _PARSERS = { "apm": _parse_apm, "python": _parse_pyproject, + "cargo": _parse_cargo, "go": _parse_gomod, "maven": _parse_pom, } diff --git a/tests/test_manifest_ingest.py b/tests/test_manifest_ingest.py index 2b97f5fb..9f01675c 100644 --- a/tests/test_manifest_ingest.py +++ b/tests/test_manifest_ingest.py @@ -112,3 +112,54 @@ def test_malformed_manifest_does_not_crash(tmp_path): p = _write(tmp_path / "pom.xml", " empty, no exception assert r["nodes"] == [] and r["edges"] == [] + + +# ── #2434: Cargo.toml joins pyproject.toml/go.mod/pom.xml as a package manifest ─ + +def test_cargo_classifies_as_code_manifest(tmp_path): + p = _write(tmp_path / "Cargo.toml", '[package]\nname = "x"\n') + assert is_package_manifest_path(p) + assert classify_file(p) is FileType.CODE + + +def test_cargo_parses_name_version_and_deps(tmp_path): + # A crate declares its name/version under [package]; deps appear both as a + # bare version string and as an inline table (version + features). + p = _write(tmp_path / "Cargo.toml", + '[package]\nname = "my-crate"\nversion = "0.3.1"\nedition = "2021"\n\n' + '[dependencies]\nserde = "1.0"\ntokio = { version = "1", features = ["full"] }\n') + r = extract_package_manifest(p) + pkg = _pkg_nodes(r)[0] + assert pkg["label"] == "my-crate" and pkg["version"] == "0.3.1" + assert pkg["ecosystem"] == "cargo" + deps = {e["target"] for e in r["edges"] if e["relation"] == "depends_on"} + assert {"pkg_serde", "pkg_tokio"} <= deps # inline-table dep keyed by name + + +def test_cargo_virtual_workspace_manifest_emits_no_package(tmp_path): + # A virtual workspace root has no [package] table, so it declares no package + # of its own — it must not fabricate a node. + p = _write(tmp_path / "Cargo.toml", '[workspace]\nmembers = ["a", "b"]\n') + r = extract_package_manifest(p) + assert _pkg_nodes(r) == [] + + +def test_cargo_target_conditional_deps_are_collected(tmp_path): + # Platform-gated deps under [target.'cfg(...)'.dependencies] are common in + # real crates and must not be dropped just because they are conditional. + p = _write(tmp_path / "Cargo.toml", + '[package]\nname = "portable"\n\n' + '[dependencies]\nserde = "1"\n\n' + '[target."cfg(windows)".dependencies]\nwinapi = "0.3"\n') + r = extract_package_manifest(p) + deps = {e["target"] for e in r["edges"] if e["relation"] == "depends_on"} + assert {"pkg_serde", "pkg_winapi"} <= deps + + +def test_cargo_workspace_inherited_version_does_not_crash(tmp_path): + # `version.workspace = true` yields a table, not a string. It must be ignored + # (no bogus version attribute) rather than crash the parse. + p = _write(tmp_path / "Cargo.toml", + '[package]\nname = "member"\nversion.workspace = true\n') + pkg = _pkg_nodes(extract_package_manifest(p))[0] + assert pkg["label"] == "member" and "version" not in pkg