aristo-cli 0.6.1

Aristo CLI binary (the `aristo` command).
Documentation
//! `aristo instrument gen-c` — render read-only field accessors from
//! `// @aristo inspect(...)` directives into a gated `aristo_generated.{h,c}`
//! pair, so a harness can read a SUT's private struct fields through its
//! opaque handle. C parses (in `aristo-core`); this module renders.

use std::fs;
use std::path::PathBuf;

use aristo_core::walk::{
    extract_c_expose_directives, extract_c_inspect_directives, CExposeDirective, CInspectDirective,
};

use super::{write_file, WriteOutcome, ARISTO_ABI};
use crate::{CliError, CliResult};

/// Render the generated header: the opaque-handle include, the ABI pin, one
/// accessor prototype per inspect directive, and one function prototype per
/// expose directive — all inside `#ifdef ARISTO_INSTRUMENT`. The harness
/// includes only this header (it has the opaque type), so a prototype taking
/// `const <Type> *` is legal against an incomplete type.
fn render_header(
    inspects: &[CInspectDirective],
    exposes: &[CExposeDirective],
    handle_header: &str,
) -> String {
    let mut s = String::new();
    s.push_str("/* aristo_generated.h — generated by `aristo instrument gen-c`. Do not edit. */\n");
    s.push_str("#ifndef ARISTO_GENERATED_H\n#define ARISTO_GENERATED_H\n\n");
    s.push_str("#ifdef ARISTO_INSTRUMENT\n\n");
    s.push_str("#include \"aristo.h\"\n");
    s.push_str(&format!("#include \"{handle_header}\"\n\n"));
    s.push_str(&abi_pin());
    s.push('\n');
    for d in inspects {
        s.push_str(&format!(
            "{} {}(const {} *self);\n",
            d.ret,
            d.accessor_name(),
            d.type_name
        ));
    }
    // Exposed-function prototypes: the verbatim signature + `;`. The
    // ARISTO_TU_LOCAL prefix (if present) expands to nothing when the harness
    // compiles instrumented, giving the real declaration — type-checked against
    // the definition at link time.
    for e in exposes {
        s.push_str(&format!("{};\n", e.signature));
    }
    s.push_str("\n#endif /* ARISTO_INSTRUMENT */\n");
    s.push_str("#endif /* ARISTO_GENERATED_H */\n");
    s
}

/// Render the generated source: the accessor bodies. This file is `#include`d
/// into the translation unit where the struct is fully defined (only there is
/// `self->field` valid), so it needs `aristo.h` (for the ABI pin) but NOT the
/// handle header. Clone mode returns the field; projection hands `&self->field`
/// to the author's projector.
fn render_source(directives: &[CInspectDirective]) -> String {
    let mut s = String::new();
    s.push_str("/* aristo_generated.c — generated by `aristo instrument gen-c`. Do not edit. */\n");
    s.push_str("/* #include this into the .c where the annotated struct is fully defined. */\n");
    s.push_str("#ifdef ARISTO_INSTRUMENT\n\n");
    s.push_str("#include \"aristo.h\"\n\n");
    s.push_str(&abi_pin());
    s.push('\n');
    for d in directives {
        let body = match &d.with {
            Some(with) => format!("return {with}(&self->{});", d.field),
            None => format!("return self->{};", d.field),
        };
        s.push_str(&format!(
            "{} {}(const {} *self) {{ {body} }}\n",
            d.ret,
            d.accessor_name(),
            d.type_name
        ));
    }
    s.push_str("\n#endif /* ARISTO_INSTRUMENT */\n");
    s
}

fn abi_pin() -> String {
    format!(
        "_Static_assert(ARISTO_ABI == {ARISTO_ABI},\n  \"aristo.h ABI does not match the \
         generator that produced this file; re-vendor and re-run gen-c \
         from the same aristo CLI version\");\n"
    )
}

/// Parse the inspect + expose directives out of every input path, in path order
/// and source order within each file (deterministic for `--check`).
fn collect_directives(
    paths: &[PathBuf],
) -> CliResult<(Vec<CInspectDirective>, Vec<CExposeDirective>)> {
    let mut inspects = Vec::new();
    let mut exposes = Vec::new();
    for p in paths {
        let src = fs::read_to_string(p)?;
        let to_err = |e| CliError::Other {
            message: format!("failed to parse {}: {e}", p.display()),
            exit_code: 2,
        };
        inspects.extend(extract_c_inspect_directives(&src).map_err(to_err)?);
        exposes.extend(extract_c_expose_directives(&src).map_err(to_err)?);
    }
    Ok((inspects, exposes))
}

pub(crate) fn run(
    paths: Vec<PathBuf>,
    handle_header: String,
    out: PathBuf,
    check: bool,
) -> CliResult<()> {
    let (inspects, exposes) = collect_directives(&paths)?;
    let header = render_header(&inspects, &exposes, &handle_header);
    let source = render_source(&inspects);
    let files = [
        ("aristo_generated.h", header),
        ("aristo_generated.c", source),
    ];

    if check {
        let mut drifted = Vec::new();
        for (name, content) in &files {
            let path = out.join(name);
            let current = fs::read_to_string(&path).unwrap_or_default();
            if current != *content {
                drifted.push(name.to_string());
            }
        }
        if drifted.is_empty() {
            println!(
                "ok: generated C instrumentation is up to date ({} accessor(s), {} exposed fn(s)).",
                inspects.len(),
                exposes.len()
            );
            return Ok(());
        }
        return Err(CliError::Other {
            message: format!(
                "generated C instrumentation is stale: {} differ from the directives.\n       \
                 Re-run `aristo instrument gen-c` and commit the result.",
                drifted.join(", ")
            ),
            exit_code: 2,
        });
    }

    println!(
        "→ Generating {} accessor(s) + {} exposed fn(s) to {}/ …",
        inspects.len(),
        exposes.len(),
        out.display()
    );
    for (name, content) in &files {
        let outcome = write_file(&out.join(name), content)?;
        let verb = match outcome {
            WriteOutcome::Created => "created",
            WriteOutcome::Updated => "updated",
            WriteOutcome::Unchanged => "unchanged",
        };
        println!("{name}  {verb}");
    }
    println!();
    println!(
        "ok: C instrumentation generated ({} accessor(s), {} exposed fn(s)).",
        inspects.len(),
        exposes.len()
    );
    println!("    `#include \"aristo_generated.c\"` (gated) into the .c that defines the struct;");
    println!("    the harness includes `aristo_generated.h`.");
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn dir(type_name: &str, field: &str, ret: &str, with: Option<&str>) -> CInspectDirective {
        CInspectDirective {
            type_name: type_name.into(),
            field: field.into(),
            ret: ret.into(),
            with: with.map(str::to_string),
            name: None,
            line: 1,
        }
    }

    #[test]
    fn renders_clone_accessor_prototype_and_body() {
        let ds = vec![dir("Db", "next_seqno", "uint64_t", None)];
        let h = render_header(&ds, &[], "bitcask.h");
        let c = render_source(&ds);
        assert!(h.contains("uint64_t aristo_inspect_Db_next_seqno(const Db *self);"));
        assert!(h.contains("#include \"bitcask.h\""));
        assert!(c.contains(
            "uint64_t aristo_inspect_Db_next_seqno(const Db *self) { return self->next_seqno; }"
        ));
        // Both files are gated and carry the ABI pin.
        assert!(h.contains("#ifdef ARISTO_INSTRUMENT"));
        assert!(c.contains("#ifdef ARISTO_INSTRUMENT"));
        assert!(h.contains(&format!("ARISTO_ABI == {ARISTO_ABI}")));
        assert!(c.contains(&format!("ARISTO_ABI == {ARISTO_ABI}")));
    }

    #[test]
    fn renders_projection_body_through_the_projector() {
        let ds = vec![dir("Db", "keydir", "size_t", Some("keydir_live_count"))];
        let c = render_source(&ds);
        assert!(c.contains(
            "size_t aristo_inspect_Db_keydir(const Db *self) { return keydir_live_count(&self->keydir); }"
        ));
    }

    #[test]
    fn accessors_render_in_directive_order() {
        let ds = vec![dir("Db", "a", "int", None), dir("Db", "b", "long", None)];
        let c = render_source(&ds);
        let ia = c.find("aristo_inspect_Db_a").unwrap();
        let ib = c.find("aristo_inspect_Db_b").unwrap();
        assert!(ia < ib, "accessors must render in directive (source) order");
    }

    #[test]
    fn empty_directives_still_render_a_valid_gated_pair() {
        let h = render_header(&[], &[], "db.h");
        let c = render_source(&[]);
        assert!(h.contains("#ifndef ARISTO_GENERATED_H"));
        assert!(h.contains("#ifdef ARISTO_INSTRUMENT"));
        assert!(c.contains("#ifdef ARISTO_INSTRUMENT"));
    }

    #[test]
    fn renders_exposed_function_prototype_verbatim() {
        let exposes = vec![CExposeDirective {
            name: "recover_replay".into(),
            signature: "ARISTO_TU_LOCAL int recover_replay(Db *db)".into(),
            line: 1,
        }];
        let h = render_header(&[], &exposes, "db.h");
        // The prototype is the verbatim signature + `;`, inside the gate.
        assert!(h.contains("ARISTO_TU_LOCAL int recover_replay(Db *db);"));
        // The exposed prototype goes in the header, not the source.
        let c = render_source(&[]);
        assert!(!c.contains("recover_replay"));
    }

    #[test]
    fn end_to_end_reads_a_c_fixture_and_writes_files() {
        let tmp = tempfile::tempdir().unwrap();
        let src = tmp.path().join("db.c");
        fs::write(
            &src,
            "// @aristo inspect(field = \"next_seqno\", ret = \"uint64_t\")\n\
             typedef struct { unsigned long next_seqno; } Db;\n",
        )
        .unwrap();
        let out = tmp.path().join("aristo");
        run(vec![src.clone()], "db.h".into(), out.clone(), false).unwrap();
        let c = fs::read_to_string(out.join("aristo_generated.c")).unwrap();
        assert!(
            c.contains("aristo_inspect_Db_next_seqno(const Db *self) { return self->next_seqno; }")
        );

        // --check passes on the just-written output, fails after a directive edit.
        run(vec![src.clone()], "db.h".into(), out.clone(), true).unwrap();
        fs::write(
            &src,
            "// @aristo inspect(field = \"next_seqno\", ret = \"uint64_t\", name = \"seq\")\n\
             typedef struct { unsigned long next_seqno; } Db;\n",
        )
        .unwrap();
        let stale = run(vec![src], "db.h".into(), out, true);
        assert!(
            stale.is_err(),
            "--check must fail when directives drift from generated files"
        );
    }
}