ralph-agent-loop 0.4.0

A Rust CLI for managing AI agent loops with a structured JSON task queue
Documentation
//! Queue loader validation-flow tests.

use super::*;

#[test]
fn load_and_validate_queues_allows_missing_done_file() -> 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");
    save_queue(
        &queue_path,
        &QueueFile {
            version: 1,
            tasks: vec![task("RQ-0001")],
        },
    )?;
    let done_path = ralph_dir.join("done.json");

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

    let (queue, done) = load_and_validate_queues(&resolved, true)?;
    assert_eq!(queue.tasks.len(), 1);
    assert!(done.is_some());
    assert!(done.expect("done queue").tasks.is_empty());
    Ok(())
}

#[test]
fn load_and_validate_queues_rejects_duplicate_ids_across_done() -> 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");
    save_queue(
        &queue_path,
        &QueueFile {
            version: 1,
            tasks: vec![task("RQ-0001")],
        },
    )?;
    let done_path = ralph_dir.join("done.json");
    save_queue(
        &done_path,
        &QueueFile {
            version: 1,
            tasks: vec![{
                let mut t = task("RQ-0001");
                t.status = TaskStatus::Done;
                t.completed_at = Some("2026-01-18T00:00:00Z".to_string());
                t
            }],
        },
    )?;

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

    let err = load_and_validate_queues(&resolved, true).expect_err("expected duplicate id error");
    assert!(
        err.to_string()
            .contains("Duplicate task ID detected across queue and done")
    );
    Ok(())
}

#[test]
fn load_and_validate_queues_rejects_invalid_deps_when_include_done_false() -> 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");
    save_queue(
        &queue_path,
        &QueueFile {
            version: 1,
            tasks: vec![{
                let mut t = task("RQ-0001");
                t.depends_on = vec!["RQ-9999".to_string()];
                t
            }],
        },
    )?;

    let done_path = ralph_dir.join("done.json");
    let resolved = resolved_with_paths(repo_root, queue_path, done_path);

    let err =
        load_and_validate_queues(&resolved, false).expect_err("should fail on invalid dependency");
    assert!(
        err.to_string().contains("Invalid dependency"),
        "Error should mention invalid dependency: {}",
        err
    );

    Ok(())
}

#[test]
fn load_and_validate_queues_rejects_non_utc_timestamps_without_persisting() -> 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 active_task = task("RQ-0001");
    active_task.created_at = Some("2026-01-18T12:00:00-05:00".to_string());
    active_task.updated_at = Some("2026-01-18T13:00:00-05:00".to_string());
    save_queue(
        &queue_path,
        &QueueFile {
            version: 1,
            tasks: vec![active_task],
        },
    )?;

    let mut done_task = task("RQ-0002");
    done_task.status = TaskStatus::Done;
    done_task.created_at = Some("2026-01-18T10:00:00-07:00".to_string());
    done_task.updated_at = Some("2026-01-18T11:00:00-07:00".to_string());
    done_task.completed_at = Some("2026-01-18T12:00:00-07:00".to_string());
    save_queue(
        &done_path,
        &QueueFile {
            version: 1,
            tasks: vec![done_task],
        },
    )?;

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

    let err = load_and_validate_queues(&resolved, true)
        .expect_err("non-UTC timestamps should fail without explicit repair");
    let err_msg = format!("{err:#}");
    assert!(
        err_msg.contains("must be a valid RFC3339 UTC timestamp"),
        "unexpected error message: {err_msg}"
    );

    let persisted_queue = load_queue(&queue_path)?;
    let persisted_done = load_queue(&done_path)?;
    assert_eq!(
        persisted_queue.tasks[0].created_at.as_deref(),
        Some("2026-01-18T12:00:00-05:00")
    );
    assert_eq!(
        persisted_done.tasks[0].completed_at.as_deref(),
        Some("2026-01-18T12:00:00-07:00")
    );

    Ok(())
}

#[test]
fn load_and_validate_queues_rejects_missing_terminal_completed_at_without_persisting() -> 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 queue_task = task("RQ-0001");
    queue_task.status = TaskStatus::Done;
    queue_task.completed_at = None;
    save_queue(
        &queue_path,
        &QueueFile {
            version: 1,
            tasks: vec![queue_task],
        },
    )?;
    save_queue(&done_path, &QueueFile::default())?;

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

    let err = load_and_validate_queues(&resolved, true)
        .expect_err("missing completed_at should fail without explicit repair");
    let err_msg = format!("{err:#}");
    assert!(
        err_msg.contains("Missing completed_at"),
        "unexpected error message: {err_msg}"
    );

    let persisted_queue = load_queue(&queue_path)?;
    assert!(
        persisted_queue.tasks[0].completed_at.is_none(),
        "read-only validation must not backfill completed_at"
    );

    Ok(())
}

#[test]
fn load_queue_with_repair_and_validate_rejects_missing_timestamps() -> 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',], 'evidence': [], 'plan': [],}]}"#;
    std::fs::write(&queue_path, malformed)?;

    let result = load_queue_with_repair_and_validate(&queue_path, None, "RQ", 4, 10);

    let err = result.expect_err("should fail validation due to missing timestamps");
    let err_msg = err
        .chain()
        .map(|e| e.to_string())
        .collect::<Vec<_>>()
        .join(" | ");
    assert!(
        err_msg.contains("created_at") || err_msg.contains("updated_at"),
        "Error should mention missing timestamp: {}",
        err_msg
    );

    Ok(())
}

#[test]
fn load_queue_with_repair_and_validate_accepts_valid_repair() -> 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',], 'evidence': ['observed',], 'plan': ['do thing',], 'created_at': '2026-01-18T00:00:00Z', 'updated_at': '2026-01-18T00:00:00Z',}]}"#;
    std::fs::write(&queue_path, malformed)?;

    let (queue, warnings) = load_queue_with_repair_and_validate(&queue_path, None, "RQ", 4, 10)?;

    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"]);
    assert!(warnings.is_empty());

    Ok(())
}

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

    let active_malformed = r#"{'version': 1, 'tasks': [{'id': 'RQ-0002', 'title': 'Second task', 'status': 'todo', 'tags': ['bug',], 'scope': ['file',], 'evidence': [], 'plan': [], 'created_at': '2026-01-18T00:00:00Z', 'updated_at': '2026-01-18T00:00:00Z', 'depends_on': ['RQ-0001',],}]}"#;
    std::fs::write(&queue_path, active_malformed)?;

    let done_queue = QueueFile {
        version: 1,
        tasks: vec![{
            let mut t = task("RQ-0001");
            t.status = TaskStatus::Done;
            t.completed_at = Some("2026-01-18T00:00:00Z".to_string());
            t
        }],
    };
    save_queue(&done_path, &done_queue)?;

    let (queue, warnings) =
        load_queue_with_repair_and_validate(&queue_path, Some(&done_queue), "RQ", 4, 10)?;

    assert_eq!(queue.tasks.len(), 1);
    assert_eq!(queue.tasks[0].id, "RQ-0002");
    assert!(warnings.is_empty());

    Ok(())
}