Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
- Fix: the incremental rebuild no longer purges AST nodes it just reported as fail-closed "kept" — the eviction pass re-checks the kept set, so a moved-file/symlink layout can't deadlock the shrink guard into refusing every update (#3697, #3695, thanks @hopstreax).
- Fix: `graph.html` no longer crashes vis-network with a stack overflow on large graphs — nodes are seeded on a spiral before physics runs so overlap-avoidance can't blow the layout recursion (#3699, thanks @sanjaiyan-dev).
- Fix: node and edge tooltips now show special characters literally (C++ templates like `vector<int>`, generics, `&`, quotes) instead of raw HTML entities, while the HTML sinks that need escaping keep it (#3686, #3664, thanks @hopstreax).
- Fix: `graphify extract --cargo` now finds a `Cargo.toml` one or two levels below the scan root (a Tauri app's crate, for example, lives one level down from the project root) instead of only ever looking at the scan root itself, so a repository whose crate isn't at the top no longer gets zero crate nodes and zero dependency edges (#3678, thanks @ExhibitJ).
- Docs: repository links now point at `Graphify-Labs/graphify` instead of the old account (including in generated wiki output), translated READMEs use the current logo, GitHub issue/PR templates were added, and the Enterprise link was corrected (#3692, #3694, #3693, thanks @Abdul535).

## 0.9.64 (2026-09-18)
Expand Down
42 changes: 41 additions & 1 deletion graphify/cargo_introspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

from __future__ import annotations

import os
from pathlib import Path
from typing import Any


_CONFIDENCE_EXTRACTED = "EXTRACTED"
_MAX_DISCOVERY_DEPTH = 2


def _load_toml(path: Path) -> dict[str, Any]:
Expand All @@ -24,6 +26,37 @@ def _load_toml(path: Path) -> dict[str, Any]:
return tomllib.load(manifest)


def _discover_root_manifest(root: Path) -> Path | None:
"""Find a Cargo.toml under *root* when none sits directly at the root.

A bounded-depth search rather than an unbounded walk: a Tauri app's
crate lives in src-tauri/, a polyglot repo's in rust/ or crates/, but
going arbitrarily deep risks matching a vendored or example manifest
nested well inside an unrelated dependency (#3678). Reuses the same
noise-directory pruning the AST scanner already applies (venvs, caches,
node_modules, build output) so those never get searched into. A
genuinely ambiguous layout with more than one candidate at the same
depth is resolved by sorted path order rather than guessed at; the
caller is free to point --cargo at a subdirectory directly for a repo
where that is not the right answer.
"""
from graphify.detect import _is_noise_dir

found: list[Path] = []
for dirpath, dirnames, filenames in os.walk(root):
current = Path(dirpath)
depth = len(current.relative_to(root).parts)
if depth > 0 and "Cargo.toml" in filenames:
found.append(current / "Cargo.toml")
if depth >= _MAX_DISCOVERY_DEPTH:
dirnames[:] = []
continue
dirnames[:] = [d for d in dirnames if not _is_noise_dir(d, current)]
if not found:
return None
return sorted(found)[0]


def _member_manifest_paths(root: Path, root_data: dict[str, Any]) -> list[Path]:
paths: list[Path] = []
if isinstance(root_data.get("package"), dict):
Expand All @@ -48,9 +81,16 @@ def introspect_cargo(root: str | Path) -> dict[str, Any]:
"""Return crate nodes and internal dependency edges from Cargo manifests."""
root_path = Path(root).resolve()
root_manifest = root_path / "Cargo.toml"
if not root_manifest.is_file():
discovered = _discover_root_manifest(root_path)
if discovered is not None:
root_manifest = discovered
root_data = _load_toml(root_manifest)

manifests = _member_manifest_paths(root_path, root_data)
# Workspace members are resolved relative to the manifest's OWN
# directory, not necessarily root_path (#3678: the manifest can live in
# a discovered subdirectory, e.g. src-tauri/).
manifests = _member_manifest_paths(root_manifest.parent, root_data)
crates: dict[str, tuple[str, Path, dict[str, Any]]] = {}

for manifest in manifests:
Expand Down
110 changes: 110 additions & 0 deletions tests/test_cargo_introspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,3 +455,113 @@ def test_cargo_introspect_package_rename_falls_through_when_unresolved(tmp_path)

assert {node["id"] for node in result["nodes"]} == {"crate:app"}
assert result["edges"] == []


def test_cargo_introspect_discovers_a_manifest_in_a_subdirectory(tmp_path):
"""#3678: a Tauri-style layout keeps Cargo.toml in src-tauri/, not at the
scan root. introspect_cargo must find it rather than erroring as if no
crate existed at all."""
tauri = tmp_path / "src-tauri"
tauri.mkdir()
_write_manifest(
tauri / "Cargo.toml",
"""
[package]
name = "app"
version = "0.1.0"
edition = "2021"
""",
)

result = introspect_cargo(tmp_path)

assert {node["id"] for node in result["nodes"]} == {"crate:app"}
node = next(n for n in result["nodes"] if n["id"] == "crate:app")
assert node["source_file"] == "src-tauri/Cargo.toml", (
f"the discovered manifest's source_file should stay relative to the "
f"scan root, not the crate subdirectory; got {node['source_file']!r}"
)


def test_cargo_introspect_discovery_resolves_workspace_members_from_the_manifest(tmp_path):
"""A discovered workspace manifest's own members are still resolved
relative to ITS directory, not the outer scan root (#3678)."""
tauri = tmp_path / "src-tauri"
tauri.mkdir()
_write_manifest(
tauri / "Cargo.toml",
"""
[workspace]
members = ["core"]
""",
)
core = tauri / "core"
core.mkdir()
_write_manifest(
core / "Cargo.toml",
"""
[package]
name = "core"
version = "0.1.0"
edition = "2021"
""",
)

result = introspect_cargo(tmp_path)

assert {node["id"] for node in result["nodes"]} == {"crate:core"}
node = next(n for n in result["nodes"] if n["id"] == "crate:core")
assert node["source_file"] == "src-tauri/core/Cargo.toml"


def test_cargo_introspect_discovery_skips_noise_directories(tmp_path):
"""A Cargo.toml sitting inside node_modules/ (a vendored or example crate
pulled in by an npm dependency) must never win over a real, non-noise
subdirectory (#3678)."""
noise = tmp_path / "node_modules" / "some-pkg"
noise.mkdir(parents=True)
_write_manifest(
noise / "Cargo.toml",
"""
[package]
name = "decoy"
version = "0.1.0"
edition = "2021"
""",
)
real = tmp_path / "src-tauri"
real.mkdir()
_write_manifest(
real / "Cargo.toml",
"""
[package]
name = "app"
version = "0.1.0"
edition = "2021"
""",
)

result = introspect_cargo(tmp_path)

assert {node["id"] for node in result["nodes"]} == {"crate:app"}, (
"discovery must never search into node_modules/"
)


def test_cargo_introspect_discovery_does_not_search_past_the_depth_cap(tmp_path):
"""A manifest nested deeper than the discovery cap is left unfound rather
than searched for indefinitely (#3678)."""
deep = tmp_path / "a" / "b" / "c" / "d"
deep.mkdir(parents=True)
_write_manifest(
deep / "Cargo.toml",
"""
[package]
name = "too_deep"
version = "0.1.0"
edition = "2021"
""",
)

with pytest.raises(FileNotFoundError):
introspect_cargo(tmp_path)
Loading