ralph-agent-loop 0.4.0

A Rust CLI for managing AI agent loops with a structured JSON task queue
Documentation
//! Queue loader parsing and repair edge-case tests.

use super::*;

#[test]
fn load_and_validate_queues_rejects_malformed_timestamps_without_rewrite() -> Result<()> {
    let temp = TempDir::new()?;
    let repo_root = temp.path();
    let ralph_dir = repo_root.join(".ralph");
    std::fs::create_dir_all(&ralph_dir)?;

    let queue_path = ralph_dir.join("queue.json");
    let done_path = ralph_dir.join("done.json");

    let mut bad_task = task("RQ-0001");
    bad_task.created_at = Some("not-a-timestamp".to_string());
    save_queue(
        &queue_path,
        &QueueFile {
            version: 1,
            tasks: vec![bad_task],
        },
    )?;

    let resolved = resolved_with_paths(repo_root, queue_path.clone(), done_path);

    let err = load_and_validate_queues(&resolved, false)
        .expect_err("expected malformed timestamp to fail validation");
    let err_msg = format!("{:#}", err);
    assert!(
        err_msg.contains("must be a valid RFC3339 UTC timestamp"),
        "unexpected error message: {err_msg}"
    );

    let persisted = std::fs::read_to_string(&queue_path)?;
    assert!(
        persisted.contains("not-a-timestamp"),
        "malformed timestamp should not be rewritten during conservative repair"
    );

    Ok(())
}

#[test]
fn load_queue_with_repair_fixes_malformed_json() -> Result<()> {
    let temp = TempDir::new()?;
    let queue_path = temp.path().join("queue.json");

    let malformed = r#"{"version": 1, "tasks": [{"id": "RQ-0001", "title": "Test", "status": "todo", "tags": ["bug",],}]}"#;
    std::fs::write(&queue_path, malformed)?;

    let queue = load_queue_with_repair(&queue_path)?;
    assert_eq!(queue.tasks.len(), 1);
    assert_eq!(queue.tasks[0].id, "RQ-0001");
    assert_eq!(queue.tasks[0].tags, vec!["bug"]);

    Ok(())
}

#[test]
fn load_queue_with_repair_fixes_complex_malformed_json() -> Result<()> {
    let temp = TempDir::new()?;
    let queue_path = temp.path().join("queue.json");

    let malformed = r#"{'version': 1, tasks: [{'id': 'RQ-0001', 'title': 'Test task', 'status': 'todo', 'tags': ['bug',], 'scope': ['file',],}]}"#;
    std::fs::write(&queue_path, malformed)?;

    let queue = load_queue_with_repair(&queue_path)?;
    assert_eq!(queue.tasks.len(), 1);
    assert_eq!(queue.tasks[0].id, "RQ-0001");
    assert_eq!(queue.tasks[0].title, "Test task");
    assert_eq!(queue.tasks[0].tags, vec!["bug"]);

    Ok(())
}

#[test]
fn load_queue_accepts_scalar_custom_fields_and_save_normalizes_to_strings() -> Result<()> {
    let temp = TempDir::new()?;
    let queue_path = temp.path().join("queue.json");

    std::fs::write(
        &queue_path,
        r#"{"version":1,"tasks":[{"id":"RQ-0001","title":"t","created_at":"2026-01-18T00:00:00Z","updated_at":"2026-01-18T00:00:00Z","custom_fields":{"n":1411,"b":false}}]}"#,
    )?;

    let queue = load_queue(&queue_path)?;
    assert_eq!(
        queue.tasks[0].custom_fields.get("n").map(String::as_str),
        Some("1411")
    );
    assert_eq!(
        queue.tasks[0].custom_fields.get("b").map(String::as_str),
        Some("false")
    );

    save_queue(&queue_path, &queue)?;
    let rendered = std::fs::read_to_string(&queue_path)?;
    assert!(rendered.contains("\"n\": \"1411\""));
    assert!(rendered.contains("\"b\": \"false\""));

    Ok(())
}

#[test]
fn load_queue_malformed_json_returns_error() -> Result<()> {
    let temp = TempDir::new()?;
    let queue_path = temp.path().join("queue.json");

    let malformed = r#"{"version": 1, "tasks": [{"id": "RQ-0001", "title": }]}"#;
    std::fs::write(&queue_path, malformed)?;

    let result = load_queue(&queue_path);
    assert!(result.is_err(), "Should error on malformed JSON");
    let err = result.expect_err("malformed JSON should fail");
    let err_msg = err.to_string();
    assert!(
        err_msg.contains("parse") || err_msg.contains("JSON"),
        "Error should mention parsing/JSON: {}",
        err_msg
    );

    Ok(())
}

#[test]
fn load_queue_with_repair_fails_on_unrepairable_json() -> Result<()> {
    let temp = TempDir::new()?;
    let queue_path = temp.path().join("queue.json");

    let unrepairable = r#"{this is not valid json at all"#;
    std::fs::write(&queue_path, unrepairable)?;

    let result = load_queue_with_repair(&queue_path);
    assert!(result.is_err(), "Should error on unrepairable JSON");
    let err = result.expect_err("unrepairable JSON should fail");
    let err_msg = format!("{:#}", err);
    assert!(
        err_msg.contains("parse") || err_msg.contains("JSON") || err_msg.contains("repair"),
        "Error should mention parsing or repair failure: {}",
        err_msg
    );

    Ok(())
}

#[test]
fn load_queue_handles_empty_file() -> Result<()> {
    let temp = TempDir::new()?;
    let queue_path = temp.path().join("queue.json");

    std::fs::write(&queue_path, "")?;

    let result = load_queue(&queue_path);
    assert!(result.is_err(), "Should error on empty file");
    let err_msg = format!("{:#}", result.expect_err("empty file should fail"));
    assert!(
        err_msg.contains("EOF") || err_msg.contains("parse") || err_msg.contains("empty"),
        "Error should indicate empty or unparseable file: {}",
        err_msg
    );

    Ok(())
}

#[test]
fn load_queue_detects_truncated_file() -> Result<()> {
    let temp = TempDir::new()?;
    let queue_path = temp.path().join("queue.json");

    let truncated = r#"{"version": 1, "tasks": [{"id": "RQ-0001", "title": "Test""#;
    std::fs::write(&queue_path, truncated)?;

    let result = load_queue(&queue_path);
    assert!(result.is_err(), "Should error on truncated JSON");
    let err_msg = format!("{:#}", result.expect_err("truncated file should fail"));
    assert!(
        err_msg.contains("EOF") || err_msg.contains("unexpected end") || err_msg.contains("parse"),
        "Error should indicate truncated file or EOF: {}",
        err_msg
    );

    Ok(())
}