aion-core 0.31.0

Pure domain model and shared vocabulary for Aion durable workflows.
Documentation
//! The console's copy of the list TEXT rule, emitted from the rule itself.
//!
//! The ops console narrows the workflow list as the operator types, using its
//! own reading of this crate's text predicate so a keystroke costs a filter
//! over the rows already loaded rather than a request. That is only honest
//! while the two readings agree — and a hand-typed copy in TypeScript, cited by
//! line numbers into this file, agrees only until someone edits either side.
//! Nothing would go red: this crate's tests would pass, the console's would
//! pass, and the console would start hiding rows the server would have
//! returned.
//!
//! So the case table lives HERE, beside the rule, and is asserted against
//! `matches_text` case by case. The same table is serialised into the console
//! tree, where the console's own suite reads it. A change to the rule now has
//! to change these cases — which reddens this crate — and the console's copy
//! cannot be updated by hand without reddening the comparison below.
//!
//! Regenerate with:
//!
//! ```text
//! AION_UPDATE_BINDINGS=1 cargo test -p aion-core export_console_text_match_cases
//! ```
//!
//! then commit the emitted file. Without the variable the same command asserts
//! instead of writing, which is what makes it a gate.

use std::{fs, path::PathBuf};

use chrono::{DateTime, Utc};
use serde::Serialize;

use super::WorkflowListFilter;
use crate::{RunId, WorkflowId, WorkflowStatus, WorkflowSummary};

/// Where the console keeps the emitted table, relative to this crate.
const OPS_CONSOLE_CASES_PATH: &str =
    "../../apps/aion-ops-console/src/features/workflow-list/lib/text-match.cases.json";

/// Environment variable that turns this test from an assertion into a writer.
const UPDATE_VAR: &str = "AION_UPDATE_BINDINGS";

const REGENERATE_COMMAND: &str =
    "AION_UPDATE_BINDINGS=1 cargo test -p aion-core export_console_text_match_cases";

/// The id every case is asked about: fixed, so a prefix case means something.
const FIXTURE_WORKFLOW_ID: u128 = 0xabc0_0000_0000_0000_0000_0000_0000_0001;

/// One case of the text rule: what was typed, the row it is asked about, and
/// the answer this crate gives.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct TextMatchCase {
    /// What the case is about, in the words the console's test will print.
    name: &'static str,
    /// The `text` predicate as the wire carries it.
    needle: Option<&'static str>,
    /// The row's display name, or `None` for a run that was never named.
    display_name: Option<&'static str>,
    /// The row's id, in full.
    workflow_id: String,
    /// What `WorkflowListFilter::matches_text` answers.
    matches: bool,
}

/// The emitted document: the cases, and enough provenance to find this file.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct TextMatchCaseDocument {
    /// A "do not edit" banner, in the only place JSON has room for one.
    generated_by: &'static str,
    /// Where the rule lives, for a reader who arrives at the console first.
    rule: &'static str,
    cases: Vec<TextMatchCase>,
}

/// The fixture row: two fields vary, everything else is fixed and irrelevant to
/// the text rule.
fn summary(display_name: Option<&str>) -> WorkflowSummary {
    WorkflowSummary {
        workflow_id: WorkflowId::new(uuid::Uuid::from_u128(FIXTURE_WORKFLOW_ID)),
        run_id: RunId::new_v4(),
        workflow_type: String::from("checkout"),
        status: WorkflowStatus::Running,
        started_at: DateTime::<Utc>::default(),
        updated_at: DateTime::<Utc>::default() + chrono::Duration::seconds(5),
        ended_at: None,
        parent: None,
        failed_step: None,
        failure_reason: None,
        display_name: display_name.map(str::to_owned),
        kind: None,
        current_worker: None,
        package_version: None,
    }
}

/// Every case the console is entitled to rely on, in the order it reads them.
///
/// Each is `(name, needle, display name, expected answer)`. They are the cases
/// this module's own tests assert, so the table cannot claim something the rule
/// does not do.
const CASES: &[(&str, Option<&str>, Option<&str>, bool)] = &[
    (
        "a needle is trimmed, folded, and matched as a substring of the display name",
        Some("  NIGHTLY "),
        Some("the nightly build"),
        true,
    ),
    (
        "a display name that does not contain it does not match",
        Some("  NIGHTLY "),
        Some("weekly build"),
        false,
    ),
    (
        "a run with no display name cannot match by name",
        Some("  NIGHTLY "),
        None,
        false,
    ),
    (
        "the id matches as a case-insensitive prefix",
        Some("ABC00000"),
        None,
        true,
    ),
    (
        "the id is a PREFIX, not a substring",
        Some("bc00000"),
        None,
        false,
    ),
    (
        "a whitespace-only needle is no predicate at all",
        Some("   "),
        None,
        true,
    ),
    ("an absent needle matches everything", None, None, true),
    (
        "a needle with an embedded space is one literal substring, not two terms",
        Some("run C"),
        Some("Run c"),
        true,
    ),
    (
        "and that literal is not two terms: the words apart do not match",
        Some("run C"),
        Some("Run of c"),
        false,
    ),
    (
        "the display name is matched raw, so a name that is only whitespace matches nothing",
        Some("nightly"),
        Some("   "),
        false,
    ),
];

fn filter_for(needle: Option<&str>) -> WorkflowListFilter {
    WorkflowListFilter {
        text: needle.map(str::to_owned),
        ..WorkflowListFilter::default()
    }
}

fn rendered_cases() -> Result<String, Box<dyn std::error::Error>> {
    let document = TextMatchCaseDocument {
        generated_by: REGENERATE_COMMAND,
        rule: "crates/aion-core/src/listing.rs — WorkflowListFilter::matches_text",
        cases: CASES
            .iter()
            .map(|&(name, needle, display_name, matches)| TextMatchCase {
                name,
                needle,
                display_name,
                workflow_id: WorkflowId::new(uuid::Uuid::from_u128(FIXTURE_WORKFLOW_ID))
                    .to_string(),
                matches,
            })
            .collect(),
    };
    let mut rendered = serde_json::to_string_pretty(&document)?;
    rendered.push('\n');
    Ok(rendered)
}

fn cases_path() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(OPS_CONSOLE_CASES_PATH)
}

/// The table says what the rule does — checked here, so the emitted file cannot
/// carry a case this crate does not honour.
#[test]
fn every_emitted_case_is_what_the_rule_answers() {
    for &(name, needle, display_name, expected) in CASES {
        let filter = filter_for(needle);
        assert_eq!(
            filter.matches_text(&summary(display_name)),
            expected,
            "the emitted case `{name}` does not match what `matches_text` answers"
        );
    }
}

/// The console reads this table; this is where it is written, and where a
/// stale copy is caught.
///
/// Regenerates in memory, compares, and fails WITHOUT writing: a write-then-fail
/// would pass on the second run whether or not anyone committed, turning a gate
/// into a one-shot warning and leaving a dirty tree behind.
#[test]
fn export_console_text_match_cases() -> Result<(), Box<dyn std::error::Error>> {
    let generated = rendered_cases()?;
    let output_path = cases_path();

    if std::env::var_os(UPDATE_VAR).is_some() {
        if let Some(parent) = output_path.parent() {
            fs::create_dir_all(parent)?;
        }
        fs::write(&output_path, &generated)?;
        return Ok(());
    }

    // `read_to_string` also covers the file being absent, which is the same
    // failure with the same remedy: the committed cases are not what this rule
    // produces.
    let committed = fs::read_to_string(&output_path).unwrap_or_default();
    if committed == generated {
        return Ok(());
    }
    Err(format!(
        "the ops console's text-match cases are STALE: `{}` does not match what the list text \
         rule produces.\nNothing was written — regenerate and commit the result:\n    \
         {REGENERATE_COMMAND}\n(committed {} bytes, the rule produces {} bytes)",
        output_path.display(),
        committed.len(),
        generated.len(),
    )
    .into())
}