guardflow 0.1.0

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

#[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);
}