guardflow 0.2.0

Validators and a retry-until-valid guard for LLM output, in Rust
Documentation
use guardflow::validators::{
    MatchesRegex, MaxLength, MinLength, NoPii, NotEmpty, OneOf, Profanity, Truncate, ValidJson,
};
use guardflow::{
    AsyncGuard, CustomAsyncValidator, Guard, guard_with_retry, guard_with_retry_async,
};

#[test]
fn guard_collects_every_failure_not_just_the_first() {
    let guard = Guard::new().with(NotEmpty).with(MaxLength(5));
    let result = guard.check("this is way too long");
    assert!(!result.passed);
    assert_eq!(result.failures.len(), 1);
    assert_eq!(result.failures[0].validator, "max_length");
}

#[test]
fn guard_passes_when_every_validator_passes() {
    let guard = Guard::new().with(NotEmpty).with(MaxLength(20));
    let result = guard.check("short and valid");
    assert!(result.passed);
    assert!(result.failures.is_empty());
}

#[test]
fn not_empty_fails_on_whitespace_only() {
    let guard = Guard::new().with(NotEmpty);
    assert!(!guard.check("   ").passed);
}

#[test]
fn min_length_fails_below_threshold() {
    let guard = Guard::new().with(MinLength(10));
    assert!(!guard.check("short").passed);
    assert!(guard.check("this is long enough").passed);
}

#[test]
fn matches_regex_validates_shape() {
    let guard = Guard::new().with(MatchesRegex::new(r"^\d{3}-\d{4}$").unwrap());
    assert!(guard.check("123-4567").passed);
    assert!(!guard.check("not a match").passed);
}

#[test]
fn one_of_requires_exact_membership() {
    let guard = Guard::new().with(OneOf::new(["yes", "no", "maybe"]));
    assert!(guard.check("yes").passed);
    assert!(!guard.check("Yes").passed);
}

#[test]
fn valid_json_rejects_malformed_input() {
    let guard = Guard::new().with(ValidJson);
    assert!(guard.check(r#"{"a": 1}"#).passed);
    assert!(!guard.check("{not json").passed);
}

#[test]
fn no_pii_flags_email_addresses() {
    let guard = Guard::new().with(NoPii::default());
    assert!(guard.check("no personal data here").passed);
    assert!(!guard.check("contact me at alice@example.com").passed);
}

#[tokio::test]
async fn guard_with_retry_stops_at_first_passing_attempt() {
    let guard = Guard::new().with(MinLength(5));
    let mut attempts = 0;
    let result = guard_with_retry(&guard, 5, |_| {
        attempts += 1;
        let attempt = attempts;
        async move {
            if attempt < 3 {
                "no".to_string()
            } else {
                "long enough".to_string()
            }
        }
    })
    .await;
    assert_eq!(result, Ok("long enough".to_string()));
    assert_eq!(attempts, 3);
}

#[tokio::test]
async fn guard_with_retry_returns_last_failure_after_exhausting_attempts() {
    let guard = Guard::new().with(MinLength(100));
    let result = guard_with_retry(&guard, 2, |_| async { "too short".to_string() }).await;
    let Err(failure) = result else {
        panic!("expected failure after exhausting attempts");
    };
    assert!(!failure.passed);
}

#[tokio::test]
async fn async_guard_skips_the_network_call_when_a_sync_check_already_fails() {
    let called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
    let called_in_closure = called.clone();
    let guard = AsyncGuard::new()
        .with(MinLength(100))
        .with_async(CustomAsyncValidator::new(
            "would_call_an_api",
            move |_text| {
                let called = called_in_closure.clone();
                async move {
                    called.store(true, std::sync::atomic::Ordering::SeqCst);
                    Ok(())
                }
            },
        ));

    let result = guard.check("too short").await;
    assert!(!result.passed);
    assert!(!called.load(std::sync::atomic::Ordering::SeqCst));
}

#[tokio::test]
async fn async_guard_runs_the_async_validator_once_sync_checks_pass() {
    let guard = AsyncGuard::new()
        .with(MinLength(3))
        .with_async(CustomAsyncValidator::new(
            "no_refund_promises",
            |text| async move {
                if text.contains("guaranteed refund") {
                    Err("promises a refund outside policy".to_string())
                } else {
                    Ok(())
                }
            },
        ));

    assert!(guard.check("Your order will ship tomorrow.").await.passed);
    let result = guard
        .check("I can offer you a guaranteed refund today.")
        .await;
    assert!(!result.passed);
    assert_eq!(result.failures[0].validator, "no_refund_promises");
}

#[tokio::test]
async fn guard_with_retry_async_retries_until_the_async_validator_passes() {
    let guard = AsyncGuard::new().with_async(CustomAsyncValidator::new(
        "no_refund_promises",
        |text| async move {
            if text.contains("guaranteed refund") {
                Err("promises a refund outside policy".to_string())
            } else {
                Ok(())
            }
        },
    ));

    let mut attempts = 0;
    let result = guard_with_retry_async(&guard, 3, |_| {
        attempts += 1;
        let attempt = attempts;
        async move {
            if attempt < 2 {
                "I can offer you a guaranteed refund today.".to_string()
            } else {
                "Your order qualifies for standard support.".to_string()
            }
        }
    })
    .await;

    assert_eq!(
        result,
        Ok("Your order qualifies for standard support.".to_string())
    );
    assert_eq!(attempts, 2);
}

#[test]
fn check_treats_a_fixable_truncation_as_a_failure() {
    let guard = Guard::new().with(Truncate(5));
    assert!(!guard.check("way too long").passed);
}

#[test]
fn fix_applies_the_truncation_and_reports_it_as_passing() {
    let guard = Guard::new().with(Truncate(5));
    let (fixed, result) = guard.fix("way too long");
    assert_eq!(fixed, "way t");
    assert!(result.passed);
}

#[test]
fn fix_runs_later_validators_against_the_already_fixed_text() {
    let guard = Guard::new().with(Truncate(5)).with(MaxLength(5));
    let (fixed, result) = guard.fix("way too long");
    assert_eq!(fixed, "way t");
    assert!(result.passed);
}

#[test]
fn fix_still_reports_failures_that_cannot_be_fixed() {
    let guard = Guard::new().with(Truncate(100)).with(NotEmpty);
    let (fixed, result) = guard.fix("");
    assert_eq!(fixed, "");
    assert!(!result.passed);
}

#[test]
fn profanity_flags_whole_word_matches_case_insensitively() {
    let guard = Guard::new().with(Profanity::new(["heck"]));
    assert!(guard.check("a perfectly fine sentence").passed);
    assert!(!guard.check("what the HECK").passed);
    assert!(guard.check("hecking around is a different word").passed);
}