alef 0.84.1

Opinionated polyglot binding generator for Rust libraries
Documentation
//! Fixture scaffolding for `alef e2e init` and `alef e2e scaffold`.

use crate::core::config::ResolvedCrateConfig;
use crate::e2e::config::E2eConfig;
use anyhow::{Context, Result};
use std::path::Path;

static FIXTURE_SCHEMA: &str = include_str!("schema/fixture.schema.json");

/// Literal text of the schema's own `$comment` field (`schema/fixture.schema.json`), used to
/// tell "alef's own copy, any released version" apart from hand-authored content. Kept as a
/// substring match rather than a byte-for-byte comparison so an older alef's schema (a
/// different `$defs` body under the same `$comment`) still reads as owned and gets refreshed,
/// matching every other self-marking backend (README, docs pages) that survive a version
/// bump. ~keep
const FIXTURE_SCHEMA_MARKER: &str = "Auto-generated by alef -- do not edit by hand.";

/// Refresh the checked-in fixture schema consumed by editors and repository tooling.
///
/// `schema.json` is plain JSON, so it cannot carry alef's usual comment-based `alef:hash:`
/// marker; without a substitute check here, this was an unconditional `std::fs::write` with no
/// ownership signal at all -- indistinguishable, at the write site, from clobbering a consumer's
/// own hand-authored schema. [`FIXTURE_SCHEMA_MARKER`] is the substitute: a pre-existing file
/// that carries it is provably alef's own prior output and is refreshed in place (this must
/// stay an overwrite, not a create-once skip -- a schema.json seeded before this file's `$defs`
/// changed needs to converge on `alef e2e init`, the same story `write_scaffold_files_report`'s
/// marker rail tells for every `generated_header: true` file). A pre-existing file *without* the
/// marker is left untouched and reported. ~keep
pub fn sync_fixture_schema(fixtures_dir: &Path) -> Result<()> {
    std::fs::create_dir_all(fixtures_dir)
        .with_context(|| format!("failed to create fixtures dir: {}", fixtures_dir.display()))?;
    let schema_path = fixtures_dir.join("schema.json");
    if let Ok(existing) = std::fs::read_to_string(&schema_path)
        && !existing.contains(FIXTURE_SCHEMA_MARKER)
    {
        tracing::warn!(
            "refusing to write {}: pre-existing file carries no alef ownership marker -- leaving it untouched",
            schema_path.display()
        );
        return Ok(());
    }
    std::fs::write(&schema_path, FIXTURE_SCHEMA).with_context(|| format!("failed to write {}", schema_path.display()))
}

/// Create the fixtures directory structure and write the schema file.
/// Called by `alef e2e init`.
pub fn init_fixtures(e2e_config: &E2eConfig, _config: &ResolvedCrateConfig) -> Result<Vec<String>> {
    let fixtures_dir = Path::new(&e2e_config.fixtures);
    let mut created = Vec::new();

    // 1. Create fixtures directory
    if !fixtures_dir.exists() {
        std::fs::create_dir_all(fixtures_dir)
            .with_context(|| format!("failed to create fixtures dir: {}", fixtures_dir.display()))?;
        created.push(fixtures_dir.display().to_string());
    }

    // 2. Write schema.json
    let schema_path = fixtures_dir.join("schema.json");
    sync_fixture_schema(fixtures_dir)?;
    created.push(schema_path.display().to_string());

    // 3. Create smoke directory
    let smoke_dir = fixtures_dir.join("smoke");
    if !smoke_dir.exists() {
        std::fs::create_dir_all(&smoke_dir)
            .with_context(|| format!("failed to create smoke dir: {}", smoke_dir.display()))?;
        created.push(smoke_dir.display().to_string());
    }

    // 4. Write smoke/basic.json example fixture, once -- this is a hand-growable seed a
    // consumer edits into a real smoke test, not derived output alef keeps in sync. The
    // directory-existence check above guards *that* node; it says nothing about this file,
    // which used to be written unconditionally on every `alef e2e init` regardless of whether
    // it already existed and had been edited. ~keep
    let basic_path = smoke_dir.join("basic.json");
    if !basic_path.exists() {
        let basic_fixture = build_example_fixture(e2e_config);
        std::fs::write(&basic_path, basic_fixture)
            .with_context(|| format!("failed to write {}", basic_path.display()))?;
        created.push(basic_path.display().to_string());
    }

    Ok(created)
}

/// Create a new fixture file from a template.
/// Called by `alef e2e scaffold --id <id> --category <cat> --description <desc>`.
pub fn scaffold_fixture(
    e2e_config: &E2eConfig,
    _config: &ResolvedCrateConfig,
    id: &str,
    category: &str,
    description: &str,
) -> Result<String> {
    let fixtures_dir = Path::new(&e2e_config.fixtures);
    let category_dir = fixtures_dir.join(category);

    // 1. Create category directory
    if !category_dir.exists() {
        std::fs::create_dir_all(&category_dir)
            .with_context(|| format!("failed to create category dir: {}", category_dir.display()))?;
    }

    // 2. Write fixture file. The directory-existence check above says nothing about whether
    // *this* file already exists -- a repeated `alef e2e scaffold --id <same id>` used to
    // silently clobber a fixture a human had already filled in with real assertions. Refuse
    // instead, the same way `alef init` now refuses to overwrite an existing `alef.toml`. ~keep
    let fixture_path = category_dir.join(format!("{id}.json"));
    if fixture_path.exists() {
        anyhow::bail!(
            "refusing to overwrite existing fixture at {}; pick a different --id or edit it directly",
            fixture_path.display()
        );
    }
    let fixture = build_scaffold_fixture(e2e_config, id, description);
    std::fs::write(&fixture_path, fixture).with_context(|| format!("failed to write {}", fixture_path.display()))?;

    Ok(fixture_path.display().to_string())
}

/// Build the example fixture JSON for `init`.
fn build_example_fixture(e2e_config: &E2eConfig) -> String {
    let mut input_fields = Vec::new();
    for arg in &e2e_config.call.args {
        let value = example_value_for_type(&arg.arg_type);
        input_fields.push(format!("    \"{}\": {value}", arg.field));
    }

    let input_block = if input_fields.is_empty() {
        "{}".to_string()
    } else {
        format!("{{\n{}\n  }}", input_fields.join(",\n"))
    };

    format!(
        r#"{{
  "id": "basic_smoke",
  "description": "Basic smoke test verifying the function returns without error",
  "input": {input_block},
  "assertions": [
    {{ "type": "not_error" }}
  ]
}}
"#
    )
}

/// Build a scaffold fixture JSON for `scaffold`.
fn build_scaffold_fixture(e2e_config: &E2eConfig, id: &str, description: &str) -> String {
    let mut input_fields = Vec::new();
    for arg in &e2e_config.call.args {
        let value = empty_value_for_type(&arg.arg_type);
        input_fields.push(format!("    \"{}\": {value}", arg.field));
    }

    let input_block = if input_fields.is_empty() {
        "{}".to_string()
    } else {
        format!("{{\n{}\n  }}", input_fields.join(",\n"))
    };

    format!(
        r#"{{
  "id": "{id}",
  "description": "{description}",
  "input": {input_block},
  "assertions": [
    {{ "type": "not_error" }}
  ]
}}
"#
    )
}

/// Return an example value for a given arg type (for init).
fn example_value_for_type(arg_type: &str) -> &'static str {
    match arg_type {
        "string" => "\"example\"",
        "int" | "integer" => "0",
        "float" | "number" => "0.0",
        "bool" | "boolean" => "true",
        "json_object" => "{}",
        "bytes" => "\"\"",
        _ => "\"\"",
    }
}

/// Return an empty/default value for a given arg type (for scaffold).
fn empty_value_for_type(arg_type: &str) -> &'static str {
    match arg_type {
        "string" => "\"\"",
        "int" | "integer" => "0",
        "float" | "number" => "0.0",
        "bool" | "boolean" => "false",
        "json_object" => "{}",
        "bytes" => "\"\"",
        _ => "\"\"",
    }
}

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

    /// Build an `E2eConfig` whose fixtures directory is `fixtures_dir`, defaults otherwise.
    fn e2e_config_for(fixtures_dir: &Path) -> E2eConfig {
        E2eConfig {
            fixtures: fixtures_dir.to_string_lossy().into_owned(),
            ..E2eConfig::default()
        }
    }

    #[test]
    fn refreshes_a_prior_alef_owned_schema() {
        let directory = tempfile::tempdir().expect("temporary fixture directory");
        let schema = directory.path().join("schema.json");
        // Simulates an older alef release's own output: the marker is present, but the body
        // is not this release's `$defs` -- exactly the case that must still refresh. ~keep
        std::fs::write(&schema, format!("{{\"$comment\": \"{FIXTURE_SCHEMA_MARKER}\"}}"))
            .expect("write stale alef-owned schema");

        super::sync_fixture_schema(directory.path()).expect("refresh fixture schema");

        let refreshed = std::fs::read_to_string(schema).expect("read refreshed schema");
        assert!(refreshed.contains("FixtureDocsPresentation") || refreshed.contains("docs_presentation"));
    }

    #[test]
    fn refuses_to_overwrite_a_hand_written_schema() {
        let directory = tempfile::tempdir().expect("temporary fixture directory");
        let schema = directory.path().join("schema.json");
        let hand_written = "{\"note\": \"we hand-rolled a stricter schema\"}";
        std::fs::write(&schema, hand_written).expect("write hand-written schema");

        super::sync_fixture_schema(directory.path()).expect("refusal is not an error");

        let preserved = std::fs::read_to_string(schema).expect("read schema after refused sync");
        assert_eq!(
            preserved, hand_written,
            "a schema with no alef marker must be left byte-for-byte untouched"
        );
    }

    #[test]
    fn init_fixtures_does_not_overwrite_an_edited_smoke_fixture() {
        let directory = tempfile::tempdir().expect("temporary fixture directory");
        let fixtures_dir = directory.path().join("fixtures");
        let smoke_dir = fixtures_dir.join("smoke");
        std::fs::create_dir_all(&smoke_dir).expect("create smoke dir");
        let basic_path = smoke_dir.join("basic.json");
        let hand_edited = "{\"id\": \"basic_smoke\", \"description\": \"hand-edited real assertions\"}";
        std::fs::write(&basic_path, hand_edited).expect("seed hand-edited fixture");

        let e2e_config = e2e_config_for(&fixtures_dir);
        let created = init_fixtures(&e2e_config, &ResolvedCrateConfig::default()).expect("init fixtures");

        let preserved = std::fs::read_to_string(&basic_path).expect("read fixture after init");
        assert_eq!(
            preserved, hand_edited,
            "a pre-existing smoke/basic.json must survive `alef e2e init`"
        );
        assert!(
            !created.iter().any(|path| path.contains("basic.json")),
            "an untouched pre-existing file must not be reported as created: {created:?}"
        );
    }

    #[test]
    fn scaffold_fixture_refuses_to_overwrite_an_existing_id() {
        let directory = tempfile::tempdir().expect("temporary fixture directory");
        let fixtures_dir = directory.path().join("fixtures");
        let e2e_config = e2e_config_for(&fixtures_dir);
        let config = ResolvedCrateConfig::default();

        scaffold_fixture(&e2e_config, &config, "checks_something", "unit", "first description")
            .expect("first scaffold succeeds");
        let fixture_path = fixtures_dir.join("unit").join("checks_something.json");
        let first_content = std::fs::read_to_string(&fixture_path).expect("read first fixture");

        let result = scaffold_fixture(
            &e2e_config,
            &config,
            "checks_something",
            "unit",
            "clobbering description",
        );

        assert!(
            result.is_err(),
            "scaffolding the same id twice must refuse the second write"
        );
        let preserved = std::fs::read_to_string(&fixture_path).expect("read fixture after refused scaffold");
        assert_eq!(
            preserved, first_content,
            "a refused scaffold must leave the existing fixture byte-for-byte untouched"
        );
    }
}