from __future__ import annotations
import argparse
import json
import pathlib
import re
import subprocess
import sys
from html.parser import HTMLParser
from typing import Optional
class LinkCollector(HTMLParser):
def __init__(self) -> None:
super().__init__()
self.links: set[str] = set()
def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None:
if tag != "a":
return
href = dict(attrs).get("href") or ""
match = re.search(r"(?:^|/)([A-Za-z0-9_\-]+)/index\.html$", href)
if match:
self.links.add(match.group(1))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Verify docs-site completeness for workspace library crates.")
parser.add_argument("--root-dir", default=".", help="Repository root (default: current directory)")
parser.add_argument("--doc-root", help="Rustdoc output directory (default: <root>/target/doc)")
parser.add_argument("--api-index-md", help="Markdown API index (default: <root>/docs/api_index.md if it exists)")
parser.add_argument("--site-index", help="Rendered API landing page HTML (default: <root>/target/docs-site/api/index.html if it exists)")
parser.add_argument("--quiet", action="store_true", help="Suppress success output")
return parser.parse_args()
def load_workspace_libs(root: pathlib.Path) -> list[tuple[str, str, str]]:
metadata = json.loads(
subprocess.run(
[
"cargo",
"metadata",
"--no-deps",
"--format-version",
"1",
"--manifest-path",
str(root / "Cargo.toml"),
],
check=True,
capture_output=True,
text=True,
).stdout
)
workspace_members = set(metadata["workspace_members"])
crates: list[tuple[str, str, str]] = []
for package in metadata["packages"]:
if package["id"] not in workspace_members:
continue
if not any("lib" in target["kind"] for target in package["targets"]):
continue
manifest_dir = pathlib.Path(package["manifest_path"]).parent.resolve()
member = manifest_dir.relative_to(root).as_posix()
package_name = package["name"]
crates.append((member, package_name, package_name.replace("-", "_")))
return crates
def markdown_links(path: pathlib.Path) -> set[str]:
text = path.read_text(encoding="utf-8")
return set(re.findall(r"\((?:\./)?([A-Za-z0-9_\-]+)/index\.html\)", text))
def html_links(path: pathlib.Path) -> set[str]:
parser = LinkCollector()
parser.feed(path.read_text(encoding="utf-8"))
return parser.links
def main() -> int:
args = parse_args()
root = pathlib.Path(args.root_dir).resolve()
doc_root = pathlib.Path(args.doc_root) if args.doc_root else root / "target" / "doc"
api_index_md = pathlib.Path(args.api_index_md) if args.api_index_md else root / "docs" / "api_index.md"
site_index = pathlib.Path(args.site_index) if args.site_index else root / "target" / "docs-site" / "api" / "index.html"
crates = load_workspace_libs(root)
missing_doc = [pkg for _member, pkg, doc_dir in crates if not (doc_root / doc_dir / "index.html").exists()]
if missing_doc:
print("missing rustdoc output for:", file=sys.stderr)
for pkg in missing_doc:
print(f"- {pkg}", file=sys.stderr)
return 1
linked_dirs: Optional[set[str]] = None
link_source: Optional[pathlib.Path] = None
if site_index.exists():
linked_dirs = html_links(site_index)
link_source = site_index
elif api_index_md.exists():
linked_dirs = markdown_links(api_index_md)
link_source = api_index_md
if linked_dirs is not None:
missing_links = [pkg for _member, pkg, doc_dir in crates if doc_dir not in linked_dirs]
if missing_links:
print(f"missing crate links in {link_source}:", file=sys.stderr)
for pkg in missing_links:
print(f"- {pkg}", file=sys.stderr)
return 1
if not args.quiet:
print(f"docs-site-ok: {len(crates)} workspace library crates verified")
return 0
if __name__ == "__main__":
raise SystemExit(main())