use anyhow::Result;
use ralph_core::truncate_with_ellipsis;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn ralph_loops(temp_path: &std::path::Path, args: &[&str]) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_ralph"))
.arg("loops")
.args(args)
.current_dir(temp_path)
.output()
.expect("Failed to execute ralph loops command")
}
fn ralph_loops_ok(temp_path: &std::path::Path, args: &[&str]) -> String {
let output = ralph_loops(temp_path, args);
assert!(
output.status.success(),
"Command 'ralph loops {}' failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).to_string()
}
fn setup_workspace() -> Result<TempDir> {
let temp_dir = TempDir::new()?;
let temp_path = temp_dir.path();
Command::new("git")
.args(["init"])
.current_dir(temp_path)
.output()?;
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(temp_path)
.output()?;
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp_path)
.output()?;
fs::write(temp_path.join("README.md"), "# Test Repo")?;
Command::new("git")
.args(["add", "."])
.current_dir(temp_path)
.output()?;
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(temp_path)
.output()?;
fs::create_dir_all(temp_path.join(".ralph"))?;
Ok(temp_dir)
}
fn write_merge_queue_entry(temp_path: &std::path::Path, entry: &str) -> Result<()> {
let queue_path = temp_path.join(".ralph/merge-queue.jsonl");
let mut content = if queue_path.exists() {
fs::read_to_string(&queue_path)?
} else {
String::new()
};
content.push_str(entry);
content.push('\n');
fs::write(queue_path, content)?;
Ok(())
}
#[test]
fn test_merge_queue_transition_queued_to_merging() -> Result<()> {
let temp_dir = setup_workspace()?;
let temp_path = temp_dir.path();
let ts = chrono::Utc::now().to_rfc3339();
write_merge_queue_entry(
temp_path,
&format!(
r#"{{"ts":"{}","loop_id":"ralph-test-001","event":{{"type":"queued","prompt":"test prompt"}}}}"#,
ts
),
)?;
let queue = ralph_core::MergeQueue::new(temp_path);
queue.mark_merging("ralph-test-001", 12345)?;
let entry = queue
.get_entry("ralph-test-001")?
.expect("Entry should exist");
assert_eq!(entry.state, ralph_core::MergeState::Merging);
assert_eq!(entry.merge_pid, Some(12345));
Ok(())
}
#[test]
fn test_merge_queue_transition_merging_to_merged() -> Result<()> {
let temp_dir = setup_workspace()?;
let temp_path = temp_dir.path();
let queue = ralph_core::MergeQueue::new(temp_path);
queue.enqueue("ralph-test-002", "test prompt")?;
queue.mark_merging("ralph-test-002", 12345)?;
queue.mark_merged("ralph-test-002", "abc123def")?;
let entry = queue
.get_entry("ralph-test-002")?
.expect("Entry should exist");
assert_eq!(entry.state, ralph_core::MergeState::Merged);
assert_eq!(entry.merge_commit, Some("abc123def".to_string()));
Ok(())
}
#[test]
fn test_merge_queue_transition_merging_to_needs_review() -> Result<()> {
let temp_dir = setup_workspace()?;
let temp_path = temp_dir.path();
let queue = ralph_core::MergeQueue::new(temp_path);
queue.enqueue("ralph-test-003", "test prompt")?;
queue.mark_merging("ralph-test-003", 12345)?;
queue.mark_needs_review("ralph-test-003", "merge conflicts in src/main.rs")?;
let entry = queue
.get_entry("ralph-test-003")?
.expect("Entry should exist");
assert_eq!(entry.state, ralph_core::MergeState::NeedsReview);
assert_eq!(
entry.failure_reason,
Some("merge conflicts in src/main.rs".to_string())
);
Ok(())
}
#[test]
fn test_merge_queue_cannot_skip_merging_state() -> Result<()> {
let temp_dir = setup_workspace()?;
let temp_path = temp_dir.path();
let queue = ralph_core::MergeQueue::new(temp_path);
queue.enqueue("ralph-test-004", "test prompt")?;
let result = queue.mark_merged("ralph-test-004", "abc123");
assert!(matches!(
result,
Err(ralph_core::MergeQueueError::InvalidTransition(_, _, _))
));
Ok(())
}
#[test]
fn test_loops_list_shows_summary_header() -> Result<()> {
let temp_dir = setup_workspace()?;
let temp_path = temp_dir.path();
let queue = ralph_core::MergeQueue::new(temp_path);
queue.enqueue("ralph-test-101", "prompt 1")?;
queue.enqueue("ralph-test-102", "prompt 2")?;
queue.mark_merging("ralph-test-102", 123)?;
let stdout = ralph_loops_ok(temp_path, &["list"]);
assert!(
stdout.contains("queued:") || stdout.contains("Queued:"),
"Summary should show queued count. Got:\n{}",
stdout
);
assert!(
stdout.contains("merging:") || stdout.contains("Merging:"),
"Summary should show merging count. Got:\n{}",
stdout
);
Ok(())
}
#[test]
fn test_loops_list_shows_age_column() -> Result<()> {
let temp_dir = setup_workspace()?;
let temp_path = temp_dir.path();
let queue = ralph_core::MergeQueue::new(temp_path);
queue.enqueue("ralph-test-201", "test prompt")?;
let stdout = ralph_loops_ok(temp_path, &["list"]);
assert!(
stdout.contains("AGE") || stdout.contains("Age"),
"Output should include AGE column header. Got:\n{}",
stdout
);
Ok(())
}
#[test]
fn test_loops_list_hides_terminal_states_by_default() -> Result<()> {
let temp_dir = setup_workspace()?;
let temp_path = temp_dir.path();
let queue = ralph_core::MergeQueue::new(temp_path);
queue.enqueue("ralph-active-001", "active prompt")?;
queue.enqueue("ralph-merged-001", "merged prompt")?;
queue.mark_merging("ralph-merged-001", 123)?;
queue.mark_merged("ralph-merged-001", "abc123")?;
let stdout = ralph_loops_ok(temp_path, &["list"]);
assert!(
stdout.contains("ralph-active-001") || stdout.contains("queued"),
"Should show active (queued) loop. Got:\n{}",
stdout
);
assert!(
!stdout.contains("ralph-merged-001") || stdout.contains("Use --all"),
"Should hide merged loop by default. Got:\n{}",
stdout
);
Ok(())
}
#[test]
fn test_loops_list_all_shows_terminal_states() -> Result<()> {
let temp_dir = setup_workspace()?;
let temp_path = temp_dir.path();
let queue = ralph_core::MergeQueue::new(temp_path);
queue.enqueue("ralph-merged-002", "merged prompt")?;
queue.mark_merging("ralph-merged-002", 123)?;
queue.mark_merged("ralph-merged-002", "abc123def")?;
let stdout = ralph_loops_ok(temp_path, &["list", "--all"]);
assert!(
stdout.contains("ralph-merged-002") || stdout.contains("merged"),
"Should show merged loop with --all. Got:\n{}",
stdout
);
assert!(
stdout.contains("abc123"),
"Should show commit SHA for merged loop. Got:\n{}",
stdout
);
Ok(())
}
#[test]
fn test_loops_list_shows_footer_hints() -> Result<()> {
let temp_dir = setup_workspace()?;
let temp_path = temp_dir.path();
let queue = ralph_core::MergeQueue::new(temp_path);
queue.enqueue("ralph-review-001", "test prompt")?;
queue.mark_merging("ralph-review-001", 123)?;
queue.mark_needs_review("ralph-review-001", "conflicts")?;
let stdout = ralph_loops_ok(temp_path, &["list"]);
assert!(
stdout.contains("retry") || stdout.contains("Retry") || stdout.contains("--help"),
"Should show actionable hints. Got:\n{}",
stdout
);
Ok(())
}
#[test]
fn test_spawn_merge_ralph_uses_exclusive_flag() -> Result<()> {
let source = include_str!("../src/loop_runner.rs");
assert!(
source.contains(r#""--exclusive""#),
"loop_runner.rs should use --exclusive flag for merge-ralph spawns.\n\
Per spec: 'Auto-spawned merge loops use --exclusive to wait for the primary lock'"
);
Ok(())
}
#[test]
fn test_manual_merge_uses_exclusive_flag() -> Result<()> {
let source = include_str!("../src/loops.rs");
assert!(
source.contains(r#""--exclusive""#),
"loops.rs spawn_merge_ralph should use --exclusive flag.\n\
Per spec: 'Manual merge (ralph loops merge) uses --exclusive as well'\n\
The spawn_merge_ralph helper must pass --exclusive to ralph run."
);
Ok(())
}
#[test]
fn test_merge_commit_format_conventional() -> Result<()> {
let preset_path =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("presets/merge-loop.yml");
if preset_path.exists() {
let content = fs::read_to_string(&preset_path)?;
assert!(
content.contains("merge(ralph)")
|| content.contains("conventional")
|| content.contains("commit format")
|| content.contains("commit message"),
"merge-loop preset should specify conventional commit format.\n\
Per spec: 'merge(ralph): <summary> (loop <id>)'\n\
Preset content: {}",
&content[..content.len().min(500)]
);
}
Ok(())
}
#[test]
fn test_merge_commit_subject_length_limit() -> Result<()> {
let loop_id = "ralph-20250127-143052-a3f2";
let overhead = format!("merge(ralph): (loop {})", loop_id);
let max_summary_len = 72 - overhead.len();
let long_summary =
"This is a very long summary that exceeds the maximum allowed length for commit subjects";
let truncated_summary = truncate_with_ellipsis(long_summary, max_summary_len);
let commit_message = format!("merge(ralph): {} (loop {})", truncated_summary, loop_id);
assert!(
commit_message.len() <= 72,
"Commit message should be ≤ 72 chars. Got {} chars: {}",
commit_message.len(),
commit_message
);
Ok(())
}
#[test]
fn test_merge_uses_no_ff_flag() -> Result<()> {
let preset_path =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("presets/merge-loop.yml");
if preset_path.exists() {
let content = fs::read_to_string(&preset_path)?;
assert!(
content.contains("--no-ff") || content.contains("no-ff"),
"merge-loop preset should specify --no-ff for merge.\n\
Per spec: 'Prefer git merge ralph/<id> --no-ff -m <message>'\n\
Preset content: {}",
&content[..content.len().min(500)]
);
}
Ok(())
}