use ralph_adapters::{CliBackend, CustomBackendError, NoBackendError, detect_backend_default};
use ralph_core::RalphConfig;
use crate::backend_support;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use thiserror::Error;
pub mod sops {
pub const PDD: &str = include_str!("../sops/pdd.md");
pub const CODE_TASK_GENERATOR: &str = include_str!("../sops/code-task-generator.md");
pub const PDD_TEAM_ADDENDUM: &str = include_str!("../sops/pdd-team-addendum.md");
}
#[derive(Debug, Clone, Copy)]
pub enum Sop {
Pdd,
CodeTaskGenerator,
}
impl Sop {
pub fn content(self) -> &'static str {
match self {
Sop::Pdd => sops::PDD,
Sop::CodeTaskGenerator => sops::CODE_TASK_GENERATOR,
}
}
pub fn name(self) -> &'static str {
match self {
Sop::Pdd => "Prompt-Driven Development",
Sop::CodeTaskGenerator => "Code Task Generator",
}
}
}
pub struct SopRunConfig {
pub sop: Sop,
pub user_input: Option<String>,
pub backend_override: Option<String>,
pub config: Option<RalphConfig>,
pub config_path: Option<PathBuf>,
pub custom_args: Option<Vec<String>>,
pub agent_teams: bool,
}
#[derive(Debug, Error)]
pub enum SopRunError {
#[error("No supported backend found.\n\n{0}")]
NoBackend(#[from] NoBackendError),
#[error("{0}")]
UnknownBackend(String),
#[error("Failed to spawn backend: {0}")]
SpawnError(#[from] std::io::Error),
}
impl From<CustomBackendError> for SopRunError {
fn from(_: CustomBackendError) -> Self {
SopRunError::UnknownBackend(backend_support::unknown_backend_message("custom"))
}
}
pub fn run_sop(config: SopRunConfig) -> Result<(), SopRunError> {
let backend_name = resolve_backend(
config.backend_override.as_deref(),
config.config.as_ref(),
config.config_path.as_ref(),
)?;
let is_claude = backend_name == "claude";
let mut addendums: Vec<(&str, &str)> = Vec::new();
if config.agent_teams {
if is_claude {
addendums.push(("team-instructions", sops::PDD_TEAM_ADDENDUM));
} else {
tracing::warn!("--teams is only supported with the Claude backend, ignoring");
}
}
let prompt = build_prompt(config.sop, config.user_input.as_deref(), &addendums);
let cli_backend = if backend_name == "custom" {
if let Some(args) = &config.custom_args {
if args.is_empty() {
return Err(SopRunError::UnknownBackend(
"custom (no command specified in args)".to_string(),
));
}
let command = args[0].clone();
let cli_args = args[1..].to_vec();
CliBackend {
command,
args: cli_args,
prompt_mode: ralph_adapters::PromptMode::Arg,
prompt_flag: None, output_format: ralph_adapters::OutputFormat::Text,
env_vars: vec![],
}
} else {
if let Some(config_obj) = &config.config {
CliBackend::custom(&config_obj.cli)?
} else {
let config_path = config
.config_path
.as_deref()
.unwrap_or_else(|| Path::new("ralph.yml"));
if config_path.exists() {
let ralph_config = RalphConfig::from_file(config_path).map_err(|e| {
SopRunError::SpawnError(std::io::Error::new(
std::io::ErrorKind::Other,
e.to_string(),
))
})?;
CliBackend::custom(&ralph_config.cli)?
} else {
return Err(SopRunError::UnknownBackend(
backend_support::unknown_backend_message(
"custom (configuration file not found and no CLI args provided)",
),
));
}
}
}
} else if config.agent_teams && is_claude {
CliBackend::claude_interactive_teams()
} else {
CliBackend::for_interactive_prompt(&backend_name)?
};
spawn_interactive(&cli_backend, &prompt)?;
Ok(())
}
fn resolve_backend(
flag_override: Option<&str>,
config: Option<&RalphConfig>,
config_path: Option<&PathBuf>,
) -> Result<String, SopRunError> {
if let Some(backend) = flag_override {
validate_backend_name(backend)?;
return Ok(backend.to_string());
}
if let Some(config) = config
&& config.cli.backend != "auto"
{
return Ok(config.cli.backend.clone());
}
if let Some(path) = config_path
&& path.exists()
&& let Ok(config) = RalphConfig::from_file(path)
&& config.cli.backend != "auto"
{
return Ok(config.cli.backend);
}
detect_backend_default().map_err(SopRunError::NoBackend)
}
fn validate_backend_name(name: &str) -> Result<(), SopRunError> {
if backend_support::is_known_backend(name) {
Ok(())
} else {
Err(SopRunError::UnknownBackend(
backend_support::unknown_backend_message(name),
))
}
}
fn build_prompt(sop: Sop, user_input: Option<&str>, addendums: &[(&str, &str)]) -> String {
std::iter::once(format!("<sop>\n{}\n</sop>", sop.content()))
.chain(
addendums
.iter()
.map(|(tag, content)| format!("<{}>\n{}\n</{}>", tag, content, tag)),
)
.chain(
user_input
.filter(|s| !s.is_empty())
.map(|input| format!("<user-content>\n{}\n</user-content>", input)),
)
.collect::<Vec<_>>()
.join("\n")
}
fn spawn_interactive(backend: &CliBackend, prompt: &str) -> Result<(), SopRunError> {
let (command, args, _stdin_input, _temp_file) = backend.build_command(prompt, true);
let mut cmd = Command::new(&command);
cmd.args(&args)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
cmd.envs(backend.env_vars.iter().map(|(k, v)| (k, v)));
let mut child = cmd.spawn()?;
child.wait()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::CwdGuard;
#[test]
fn test_sop_content_pdd() {
let content = Sop::Pdd.content();
assert!(content.contains("Prompt-Driven Development"));
assert!(content.contains("rough idea"));
}
#[test]
fn test_sop_content_code_task_generator() {
let content = Sop::CodeTaskGenerator.content();
assert!(content.contains("Code Task Generator"));
assert!(content.contains(".code-task.md"));
}
#[test]
fn test_sop_name() {
assert_eq!(Sop::Pdd.name(), "Prompt-Driven Development");
assert_eq!(Sop::CodeTaskGenerator.name(), "Code Task Generator");
}
#[test]
fn test_build_prompt_with_user_input() {
let prompt = build_prompt(Sop::Pdd, Some("Build a REST API"), &[]);
assert!(prompt.starts_with("<sop>\n"));
assert!(prompt.contains("</sop>"));
assert!(prompt.contains("<user-content>\nBuild a REST API\n</user-content>"));
}
#[test]
fn test_build_prompt_without_user_input() {
let prompt = build_prompt(Sop::CodeTaskGenerator, None, &[]);
assert!(prompt.starts_with("<sop>\n"));
assert!(prompt.ends_with("</sop>"));
assert!(!prompt.contains("<user-content>"));
}
#[test]
fn test_build_prompt_with_empty_user_input() {
let prompt = build_prompt(Sop::Pdd, Some(""), &[]);
assert!(!prompt.contains("<user-content>"));
}
#[test]
fn test_validate_backend_name_valid() {
assert!(validate_backend_name("claude").is_ok());
assert!(validate_backend_name("kiro").is_ok());
assert!(validate_backend_name("gemini").is_ok());
assert!(validate_backend_name("codex").is_ok());
assert!(validate_backend_name("amp").is_ok());
assert!(validate_backend_name("copilot").is_ok());
assert!(validate_backend_name("opencode").is_ok());
assert!(validate_backend_name("custom").is_ok());
}
#[test]
fn test_validate_backend_name_invalid() {
let result = validate_backend_name("invalid_backend");
assert!(result.is_err());
if let Err(SopRunError::UnknownBackend(msg)) = result {
assert!(msg.contains("invalid_backend"));
} else {
panic!("Expected UnknownBackend error");
}
}
#[test]
fn test_resolve_backend_from_flag() {
let backend = resolve_backend(Some("claude"), None, None).expect("backend");
assert_eq!(backend, "claude");
}
#[test]
fn test_resolve_backend_invalid_flag() {
let err = resolve_backend(Some("unknown"), None, None).expect_err("invalid backend");
if let SopRunError::UnknownBackend(msg) = err {
assert!(msg.contains("Unknown backend: unknown"));
} else {
panic!("expected UnknownBackend");
}
}
#[test]
fn test_resolve_backend_from_config_file() {
let config = RalphConfig::parse_yaml("cli:\n backend: gemini\n").expect("parse config");
let config_path = std::path::PathBuf::from("ralph.yml");
let backend = resolve_backend(None, Some(&config), Some(&config_path)).expect("backend");
assert_eq!(backend, "gemini");
}
#[test]
fn test_run_sop_custom_args_missing_errors() {
let temp_dir = tempfile::tempdir().expect("temp dir");
let _cwd = CwdGuard::set(temp_dir.path());
let config = SopRunConfig {
sop: Sop::Pdd,
user_input: None,
backend_override: Some("custom".to_string()),
config: None,
config_path: None,
custom_args: None,
agent_teams: false,
};
let err = run_sop(config).expect_err("expected error");
if let SopRunError::UnknownBackend(msg) = err {
assert!(msg.contains("configuration file not found"));
} else {
panic!("expected UnknownBackend");
}
}
#[cfg(unix)]
#[test]
fn test_run_sop_custom_args_executes() {
let temp_dir = tempfile::tempdir().expect("temp dir");
let _cwd = CwdGuard::set(temp_dir.path());
let config = SopRunConfig {
sop: Sop::Pdd,
user_input: Some("Build a REST API".to_string()),
backend_override: Some("custom".to_string()),
config: None,
config_path: None,
custom_args: Some(vec![
"sh".to_string(),
"-c".to_string(),
"exit 0".to_string(),
]),
agent_teams: false,
};
run_sop(config).expect("run sop");
}
#[test]
fn test_build_prompt_with_addendums() {
let prompt = build_prompt(
Sop::Pdd,
Some("my idea"),
&[("team-instructions", "Use teams wisely")],
);
assert!(prompt.starts_with("<sop>\n"));
assert!(prompt.contains("</sop>"));
assert!(prompt.contains("<team-instructions>\nUse teams wisely\n</team-instructions>"));
assert!(prompt.contains("<user-content>\nmy idea\n</user-content>"));
let sop_end = prompt.find("</sop>").unwrap();
let addendum_start = prompt.find("<team-instructions>").unwrap();
let user_start = prompt.find("<user-content>").unwrap();
assert!(sop_end < addendum_start);
assert!(addendum_start < user_start);
}
#[test]
fn test_build_prompt_with_multiple_addendums() {
let prompt = build_prompt(
Sop::Pdd,
Some("input"),
&[("a", "content-a"), ("b", "content-b")],
);
assert!(prompt.contains("<a>\ncontent-a\n</a>"));
assert!(prompt.contains("<b>\ncontent-b\n</b>"));
let a_pos = prompt.find("<a>").unwrap();
let b_pos = prompt.find("<b>").unwrap();
let user_pos = prompt.find("<user-content>").unwrap();
assert!(a_pos < b_pos);
assert!(b_pos < user_pos);
}
#[test]
fn test_build_prompt_with_addendums_and_user_input() {
let prompt = build_prompt(
Sop::Pdd,
Some("my input"),
&[("instructions", "do something")],
);
let expected_pattern = "</sop>\n<instructions>\ndo something\n</instructions>\n<user-content>\nmy input\n</user-content>";
assert!(
prompt.contains(expected_pattern),
"Expected pattern not found in prompt: {}",
prompt
);
}
#[test]
fn test_build_prompt_no_addendums_unchanged() {
let prompt_with_input = build_prompt(Sop::Pdd, Some("test"), &[]);
assert!(prompt_with_input.contains("<sop>"));
assert!(prompt_with_input.contains("</sop>"));
assert!(prompt_with_input.contains("<user-content>\ntest\n</user-content>"));
let between = &prompt_with_input[prompt_with_input.find("</sop>").unwrap()
..prompt_with_input.find("<user-content>").unwrap()];
assert_eq!(between, "</sop>\n");
let prompt_no_input = build_prompt(Sop::Pdd, None, &[]);
assert!(prompt_no_input.ends_with("</sop>"));
assert!(!prompt_no_input.contains("<user-content>"));
}
#[test]
fn test_sop_content_pdd_team_addendum() {
let content = sops::PDD_TEAM_ADDENDUM;
assert!(content.contains("Agent Teams"));
assert!(content.contains("teammate"));
}
}