#!/usr/bin/env python3
"""Write the Cargo.toml an IDE needs before it will read this library's Rust.

The library never builds with Cargo. Every answer key comes from
`tools/run_examples.py`, which compiles each example on its own with bare
`rustc --edition 2024`, so the repo has no manifest -- and without one RustRover
opens every `.rs` file under the banner "Project not associated with a
Cargo.toml file": no inferred types, no go-to-definition, no Run button.
(rust-analyzer in VS Code or Zed reads the same manifest.)

    python3 tools/write_cargo_toml.py

writes two files at the repo root:

    Cargo.toml          one [[bin]] per .rs file git would show you -- every
                        tracked example, plus any untracked scratch file that
                        is not ignored -- named after the file. Its edition is
                        run_examples.EDITION, so the IDE checks what the
                        runner compiles.
    .cargo/config.toml  one rustflag that prints diagnostics and panic
                        locations with absolute paths, so a message names its
                        file even with several projects open.

One kind of file is left out: a tracked .rs outside an examples/ folder that
rustc refuses. The runner compiles examples/ and nothing else, so nothing
vouches for those files; each is compiled once here, and a refusal is dropped,
because one target that cannot build fails a plain `cargo build`. Some refuse
on purpose -- 18_Ownership/a_stack_slot_is_reused/refusals/ holds two whose
lesson is the error. An untracked file is always listed: a scratch file that
does not compile yet is what the IDE is for.

Both files are per-machine, like `.idea/`, and are gitignored together with the
`Cargo.lock` and `target/` that Cargo makes from them; nothing in CI reads them.
Re-run after adding a .rs file -- one the manifest does not list opens under
"The file does not belong to a known Cargo project". In RustRover, attach the
manifest once per machine: open Cargo.toml and click "Attach Cargo.toml" in the
banner.

The manifest allows four unused-binding lints, so a scratch file that binds
five names to show five forms is not buried in warnings; the runner's bare
rustc still reports them. `unused_must_use` stays on, because an ignored
`Result` is a defect, not noise.
"""

from __future__ import annotations

import json
import re
import subprocess
import sys
import tempfile
from pathlib import Path

REPO = Path(__file__).resolve().parent.parent

# First line of both files. A file that lacks it was not written here, and is
# left alone rather than overwritten.
MARKER = "# Generated by tools/write_cargo_toml.py"

# Cargo guesses targets from src/, examples/, tests/, benches/ and build.rs at
# the package root. Every guess is switched off, so the targets are exactly the
# [[bin]] entries below, whatever a lesson later adds at the top of the tree.
PACKAGE = """\
[package]
name = "rust-learning-library"
version = "0.0.0"
edition = "{edition}"
publish = false
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
build = false

[lints.rust]
unused_variables = "allow"
unused_imports = "allow"
unused_mut = "allow"
dead_code = "allow"
"""

# Binary names Cargo refuses: they are the folders it makes inside target/.
FORBIDDEN = {"build", "deps", "examples", "incremental"}

# What git should ignore once the script has run, and Cargo has run after it.
# The trailing slash asks git about a directory: this repo's `target/` rule is
# directory-only, so a plain `target` reads as not ignored until Cargo has
# made the folder.
OUTPUTS = ["Cargo.toml", "Cargo.lock", ".cargo/", "target/"]


def toml_str(s: str) -> str:
    """A TOML basic string. JSON's escapes are TOML's, as long as nothing past
    U+FFFF is split into a surrogate pair, which TOML rejects -- hence
    ensure_ascii=False."""
    return json.dumps(s, ensure_ascii=False)


def rs_files(root: Path, *which: str) -> list[str]:
    """Repo-relative .rs paths that exist on disk, from `git ls-files <which>`."""
    out = subprocess.run(
        ["git", "ls-files", "-z", *which, "--", "*.rs"],
        cwd=root, capture_output=True, text=True, check=True,
    ).stdout
    return sorted({p for p in out.split("\0") if p and (root / p).is_file()})


def is_example(path: str) -> bool:
    """What the runner compiles: a .rs directly inside a folder named examples/."""
    return Path(path).parent.name == "examples"


def refusals(root: Path, edition: str, paths: list[str]) -> dict[str, str]:
    """Each of `paths` that rustc will not compile, with its first error line.

    Compiled as the runner compiles, minus code generation: --emit=metadata
    still runs the type and borrow checkers, which is where a refusal comes
    from. The working directory is the repo root, so rustup applies the pin in
    rust-toolchain.toml exactly as it does for the runner.

    --crate-name is passed because the question here is whether CARGO can build
    the file, and Cargo names a crate after the target rather than after the
    file. Left to itself rustc takes the file stem, and refuses `my test.rs`
    for a space that Cargo, building the same file as the binary `my_test`,
    never sees.
    """
    refused: dict[str, str] = {}
    with tempfile.TemporaryDirectory() as tmp:
        for path in paths:
            try:
                proc = subprocess.run(
                    ["rustc", "--edition", edition, "--crate-name", crate_name(path),
                     "--emit=metadata", "--out-dir", tmp, path],
                    cwd=root, capture_output=True, text=True,
                )
            except FileNotFoundError:
                sys.exit("ERROR: no rustc on PATH -- and Cargo needs one too. Install the toolchain first.")
            if proc.returncode != 0:
                errors = [line for line in proc.stderr.splitlines() if line.startswith("error")]
                refused[path] = errors[0] if errors else f"rustc exited {proc.returncode}"
    return refused


def bin_name(path: str) -> str:
    """A Cargo target name from a file name: `my test.rs` -> `my_test`."""
    name = re.sub(r"[^A-Za-z0-9_-]", "_", Path(path).stem)
    return f"{name}_rs" if name in FORBIDDEN else name


def crate_name(path: str) -> str:
    """The crate name Cargo gives that target: a binary's name, dashes to
    underscores."""
    return bin_name(path).replace("-", "_")


def manifest(edition: str, paths: list[str]) -> str:
    owner: dict[str, str] = {}
    bins = []
    for path in paths:
        name = bin_name(path)
        if name in owner:
            sys.exit(
                f"ERROR: {owner[name]} and {path} would both become the binary "
                f"{name!r}, and Cargo needs every target name unique. Rename one."
            )
        owner[name] = path
        bins.append(f"[[bin]]\nname = {toml_str(name)}\npath = {toml_str(path)}\n")
    return (
        f"{MARKER} -- do not edit, do not commit.\n"
        "# RustRover's view of the library. The examples themselves are built by\n"
        "# tools/run_examples.py with bare rustc. Re-run the script after adding a .rs file.\n\n"
        + PACKAGE.format(edition=edition)
        + "\n"
        + "\n".join(bins)
    )


def config(root: Path) -> str:
    flag = f"--remap-path-prefix=={root}/"
    return (
        f"{MARKER} -- do not edit, do not commit.\n"
        "# --remap-path-prefix=FROM=TO with FROM empty: it matches every relative path\n"
        "# rustc prints and no absolute one, so a diagnostic or a panic names its file\n"
        "# in full.\n"
        f"[build]\nrustflags = [{toml_str(flag)}]\n"
    )


def not_ignored(root: Path) -> list[str]:
    return [
        name for name in OUTPUTS
        if subprocess.run(["git", "check-ignore", "-q", name], cwd=root).returncode != 0
    ]


def main(root: Path = REPO) -> int:
    sys.path.insert(0, str(REPO / "tools"))
    from run_examples import EDITION

    tracked = rs_files(root, "--cached")
    untracked = rs_files(root, "--others", "--exclude-standard")
    if not tracked + untracked:
        sys.exit("ERROR: no .rs files here, so there is nothing for a manifest to describe.")

    # The runner vouches for examples/ and CI runs it; nothing vouches for the rest.
    refused = refusals(root, EDITION, [p for p in tracked if not is_example(p)])
    listed = sorted(set(tracked + untracked) - set(refused))

    files = {
        root / "Cargo.toml": manifest(EDITION, listed),
        root / ".cargo" / "config.toml": config(root),
    }
    # Check both before writing either, so a refusal never leaves half a setup.
    for path in files:
        if path.exists() and not path.read_text(encoding="utf-8").startswith(MARKER):
            sys.exit(
                f"ERROR: {path.relative_to(root)} was not written by this script; "
                "leaving it alone. Move it aside and re-run."
            )

    created = not (root / "Cargo.toml").exists()
    for path, text in files.items():
        if path.exists() and path.read_text(encoding="utf-8") == text:
            state = "unchanged"
        else:
            path.parent.mkdir(exist_ok=True)
            path.write_text(text, encoding="utf-8")
            state = "written"
        print(f"{path.relative_to(root)}: {state}")
    print(
        f"  one [[bin]] per .rs file: {len(tracked) - len(refused)} tracked, "
        f"{len(untracked)} untracked"
    )
    for path in untracked:
        print(f"    {path}")
    if refused:
        print(f"  left out, because rustc refuses them: {len(refused)}")
        for path, error in refused.items():
            print(f"    {path}  {error}")

    loose = not_ignored(root)
    if loose:
        print(
            f"warning: git does not ignore {', '.join(loose)} in this checkout -- its "
            ".gitignore predates the rule. Do not commit them."
        )
    if created:
        print('RustRover: open Cargo.toml and click "Attach Cargo.toml" -- once per machine.')
    return 0


if __name__ == "__main__":
    sys.exit(main())
