rudzio-migrate 0.1.2

Best-effort converter of stock cargo-style Rust tests into rudzio tests. Runs on a clean git tree, rewrites sources in place, keeps backups and pre-migration copies as block comments, and asks before wiring a shared runner.
Documentation
//! Shared-runner scaffolding: create or append `tests/main.rs` with
//! a `#[rudzio::main] fn main() {}` entry.
//!
//! v1 behaviour: create `tests/main.rs` only when it doesn't exist. If
//! it does exist, emit a warning and leave it alone — touching the
//! user's existing main is too invasive for a best-effort tool.

use std::fs;
use std::path::Path;

use anyhow::{Context as _, Result};

use crate::discovery::LibModuleDecl;

/// Result of a scaffold attempt for `tests/main.rs`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ScaffoldOutcome {
    /// `tests/main.rs` was already present; the tool left it alone.
    AlreadyExists,
    /// The tool created a new `tests/main.rs`.
    Created,
}

/// Create `tests/main.rs` if missing, otherwise leave it alone.
///
/// # Errors
///
/// Returns an error if the parent directory cannot be created or if the
/// rendered template cannot be written to `tests_main_path`.
#[inline]
pub fn ensure_tests_main(
    tests_main_path: &Path,
    crate_lib_name: Option<&str>,
    lib_modules: &[LibModuleDecl],
    use_lib_aggregation: bool,
) -> Result<ScaffoldOutcome> {
    if tests_main_path.exists() {
        return Ok(ScaffoldOutcome::AlreadyExists);
    }
    if let Some(parent) = tests_main_path.parent() {
        fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
    }
    let content = render_template(crate_lib_name, lib_modules, use_lib_aggregation);
    fs::write(tests_main_path, content)
        .with_context(|| format!("writing {}", tests_main_path.display()))?;
    Ok(ScaffoldOutcome::Created)
}

/// Pick the right template shape for the discovered crate layout.
fn render_template(
    crate_lib_name: Option<&str>,
    lib_modules: &[LibModuleDecl],
    use_lib_aggregation: bool,
) -> String {
    if use_lib_aggregation {
        return render_lib_aggregation_template();
    }
    if lib_modules.is_empty() {
        return render_crate_use_template(crate_lib_name);
    }
    render_path_include_template(lib_modules)
}

/// Whole-lib aggregation: pull `src/lib.rs` itself into the test
/// binary as `mod __lib;`, then `pub use __lib::*;` so any
/// `crate::X` reference inside the lib resolves the same way it
/// would in the lib's own compilation. Used for libs that have
/// items at crate root, directory-form submodules, or nested
/// `mod` declarations inside submodule files — patterns where the
/// per-submodule `#[path]` shape would either lose root items or
/// hit Rust's "nested submodule lookup ignores parent `#[path]`"
/// limitation.
///
/// The single caveat: `pub use __lib::*;` only re-exports `pub`
/// items. A lib whose internal `use crate::Foo` reaches a
/// `pub(crate) Foo` won't see it through the re-export. The fix
/// is to rewrite that one usage site as `use self::Foo` (in
/// lib.rs) or `use super::Foo` (in submodules) — both are
/// semantically equivalent inside the lib and unaffected by the
/// inclusion shape.
fn render_lib_aggregation_template() -> String {
    "\
//! Rudzio test runner entry. Generated by rudzio-migrate.
//!
//! `src/lib.rs` is pulled in below as `mod __lib;` so the test
//! binary recompiles the whole lib with `cfg(test)` active and
//! sees every `#[rudzio::suite]` block \u{2014} including ones gated
//! by `#[cfg(test)]` and ones that reference items defined at
//! the lib's crate root.
//!
//! `pub use __lib::*;` mirrors the lib's public surface at this
//! binary's crate root, so `crate::X` paths inside the lib still
//! resolve to the equivalent items here. The one caveat:
//! `pub use ::*` only re-exports `pub` items \u{2014} if any
//! `use crate::Foo` inside the lib reaches a `pub(crate) Foo`,
//! rewrite that one site as `use self::Foo` (in lib.rs) or
//! `use super::Foo` (in submodules). Both are semantically
//! equivalent inside the lib and unaffected by the inclusion.

#![allow(
    unreachable_pub,
    dead_code,
    unused_crate_dependencies,
    reason = \"test binary is a recompile of the lib; visibility, usage, and dep-reach lints don't apply here\"
)]

#[path = \"../src/lib.rs\"]
mod __lib;

pub use __lib::*;

#[rudzio::main]
fn main() {}
"
    .to_owned()
}

/// Scaffold for packages with a discoverable `src/lib.rs`: pull every
/// declaration-only `mod X;` at the crate root in via `#[path]`. Each
/// included file is compiled INTO this binary with `cfg(test)` active,
/// so suite blocks inside `#[cfg(test)] mod tests { ... }` register
/// their `linkme` entries here and the runner sees them. Internal
/// `use crate::<mod>::...` paths resolve because every top-level
/// module is mirrored under the same name in this binary's crate.
///
/// The `use <crate> as _;` line is intentionally omitted: the
/// `#[path]` includes duplicate the lib, and linking the external
/// lib in addition would cause any non-`cfg(test)` suite blocks
/// (rare but legal) to register twice.
fn render_path_include_template(lib_modules: &[LibModuleDecl]) -> String {
    let mut includes = String::new();
    for module in lib_modules {
        for attr in &module.attrs {
            includes.push_str(attr);
            includes.push('\n');
        }
        includes.push_str("#[path = \"../");
        includes.push_str(&module.rel_path);
        includes.push_str("\"]\nmod ");
        includes.push_str(&module.ident);
        includes.push_str(";\n\n");
    }
    format!(
        "\
//! Rudzio test runner entry. Generated by rudzio-migrate.
//!
//! Each `mod X;` declared at the crate root of `src/lib.rs` is
//! pulled in below via `#[path]`. That recompiles the files INTO
//! this test binary with `cfg(test)` active, so `#[rudzio::suite]`
//! blocks inside `#[cfg(test)] mod tests {{ ... }}` register their
//! `linkme::distributed_slice` entries into this binary's slice.
//! The runner then finds them.
//!
//! Because the includes mirror the lib's module tree, any
//! `use crate::<mod>::...` paths inside the included files resolve
//! to the equivalent module in this binary.

// Every lib `pub mod X;` is `mod X;` here — items that are `pub`
// inside those files aren't reachable outside this test binary,
// which trips `unreachable_pub`; and items only used by the
// non-test code paths look unused when only the `#[cfg(test)]`
// modules run. Both lints are legitimate in a normal lib but
// meaningless for a test-binary recompile.
#![allow(
    unreachable_pub,
    dead_code,
    unused_crate_dependencies,
    reason = \"test binary is a recompile of the lib; visibility, usage, and dep-reach lints don't apply here\"
)]

{includes}#[rudzio::main]
fn main() {{}}
"
    )
}

/// Scaffold for packages without a discoverable `src/lib.rs` (bin-only
/// crates, or lib crates where discovery turned up no top-level
/// declaration-only modules). Falls back to the older
/// `use <crate> as _;` pattern: keeps the external lib's rlib linked
/// so any `linkme` entries it registers at non-test scope survive.
/// `#[cfg(test)]`-gated suite blocks inside the lib won't run here —
/// add `#[path]` aggregation by hand if you need them.
fn render_crate_use_template(crate_lib_name: Option<&str>) -> String {
    let crate_use = crate_lib_name.map_or_else(String::new, |name| format!("use {name} as _;\n\n"));
    format!(
        "\
//! Rudzio test runner entry. Generated by rudzio-migrate.
//!
//! `#[rudzio::suite(...)]` blocks at the lib's non-test scope reach
//! this binary via the `use <crate> as _;` below. Suite blocks
//! gated by `#[cfg(test)]` inside the lib don't — they require
//! `#[path]` aggregation here with the lib's `crate::<mod>::...`
//! paths mirrored, which the tool emits automatically only when a
//! discoverable `src/lib.rs` is present.

#![allow(
    unused_crate_dependencies,
    reason = \"test binary has a different dep set than the lib; unused-crate warnings don't apply here\"
)]

{crate_use}#[rudzio::main]
fn main() {{}}
"
    )
}