1use crate::contracts::{
23 ClaudePermissionMode, Model, ReasoningEffort, Runner, RunnerCliOptionsPatch,
24};
25use crate::{config, runner};
26use anyhow::{Context, Result, bail};
27use std::io::{IsTerminal, Read};
28use std::path::PathBuf;
29
30mod build;
31mod refactor;
32mod update;
33
34#[derive(Clone, Copy, Debug)]
36pub enum BatchMode {
37 Auto,
39 Never,
41 Aggressive,
43}
44
45impl From<crate::cli::task::BatchMode> for BatchMode {
46 fn from(mode: crate::cli::task::BatchMode) -> Self {
47 match mode {
48 crate::cli::task::BatchMode::Auto => BatchMode::Auto,
49 crate::cli::task::BatchMode::Never => BatchMode::Never,
50 crate::cli::task::BatchMode::Aggressive => BatchMode::Aggressive,
51 }
52 }
53}
54
55pub struct TaskBuildRefactorOptions {
57 pub threshold: usize,
58 pub path: Option<PathBuf>,
59 pub dry_run: bool,
60 pub batch: BatchMode,
61 pub extra_tags: String,
62 pub runner_override: Option<Runner>,
63 pub model_override: Option<Model>,
64 pub reasoning_effort_override: Option<ReasoningEffort>,
65 pub runner_cli_overrides: RunnerCliOptionsPatch,
66 pub force: bool,
67 pub repoprompt_tool_injection: bool,
68}
69
70pub struct TaskBuildOptions {
72 pub request: String,
73 pub hint_tags: String,
74 pub hint_scope: String,
75 pub runner_override: Option<Runner>,
76 pub model_override: Option<Model>,
77 pub reasoning_effort_override: Option<ReasoningEffort>,
78 pub runner_cli_overrides: RunnerCliOptionsPatch,
79 pub force: bool,
80 pub repoprompt_tool_injection: bool,
81 pub template_hint: Option<String>,
83 pub template_target: Option<String>,
85 pub strict_templates: bool,
87 pub estimated_minutes: Option<u32>,
89}
90
91pub struct TaskUpdateSettings {
93 pub fields: String,
94 pub runner_override: Option<Runner>,
95 pub model_override: Option<Model>,
96 pub reasoning_effort_override: Option<ReasoningEffort>,
97 pub runner_cli_overrides: RunnerCliOptionsPatch,
98 pub force: bool,
99 pub repoprompt_tool_injection: bool,
100 pub dry_run: bool,
101}
102
103#[derive(Debug, Clone)]
104pub(crate) struct TaskRunnerSettings {
105 pub(crate) runner: Runner,
106 pub(crate) model: Model,
107 pub(crate) reasoning_effort: Option<ReasoningEffort>,
108 pub(crate) runner_cli: runner::ResolvedRunnerCliOptions,
109 pub(crate) permission_mode: Option<ClaudePermissionMode>,
110}
111
112pub(crate) fn resolve_task_runner_settings(
113 resolved: &config::Resolved,
114 runner_override: Option<Runner>,
115 model_override: Option<Model>,
116 reasoning_effort_override: Option<ReasoningEffort>,
117 runner_cli_overrides: &RunnerCliOptionsPatch,
118) -> Result<TaskRunnerSettings> {
119 let settings = runner::resolve_agent_settings(
120 runner_override,
121 model_override,
122 reasoning_effort_override,
123 runner_cli_overrides,
124 None,
125 &resolved.config.agent,
126 )?;
127
128 Ok(TaskRunnerSettings {
129 runner: settings.runner,
130 model: settings.model,
131 reasoning_effort: settings.reasoning_effort,
132 runner_cli: settings.runner_cli,
133 permission_mode: resolved.config.agent.claude_permission_mode,
134 })
135}
136
137pub(crate) fn resolve_task_build_settings(
138 resolved: &config::Resolved,
139 opts: &TaskBuildOptions,
140) -> Result<TaskRunnerSettings> {
141 resolve_task_runner_settings(
142 resolved,
143 opts.runner_override.clone(),
144 opts.model_override.clone(),
145 opts.reasoning_effort_override,
146 &opts.runner_cli_overrides,
147 )
148}
149
150pub(crate) fn resolve_task_update_settings(
151 resolved: &config::Resolved,
152 settings: &TaskUpdateSettings,
153) -> Result<TaskRunnerSettings> {
154 resolve_task_runner_settings(
155 resolved,
156 settings.runner_override.clone(),
157 settings.model_override.clone(),
158 settings.reasoning_effort_override,
159 &settings.runner_cli_overrides,
160 )
161}
162
163pub fn read_request_from_args_or_reader(
164 args: &[String],
165 stdin_is_terminal: bool,
166 mut reader: impl Read,
167) -> Result<String> {
168 if !args.is_empty() {
169 let joined = args.join(" ");
170 let trimmed = joined.trim();
171 if trimmed.is_empty() {
172 bail!(
173 "Missing request: task requires a request description. Pass arguments or pipe input to the command."
174 );
175 }
176 return Ok(trimmed.to_string());
177 }
178
179 if stdin_is_terminal {
180 bail!(
181 "Missing request: task requires a request description. Pass arguments or pipe input to the command."
182 );
183 }
184
185 let mut buf = String::new();
186 reader.read_to_string(&mut buf).context("read stdin")?;
187 let trimmed = buf.trim();
188 if trimmed.is_empty() {
189 bail!(
190 "Missing request: task requires a request description (pass arguments or pipe input to the command)."
191 );
192 }
193 Ok(trimmed.to_string())
194}
195
196pub fn read_request_from_args_or_stdin(args: &[String]) -> Result<String> {
198 let stdin = std::io::stdin();
199 let stdin_is_terminal = stdin.is_terminal();
200 let handle = stdin.lock();
201 read_request_from_args_or_reader(args, stdin_is_terminal, handle)
202}
203
204pub fn compare_task_fields(before: &str, after: &str) -> Result<Vec<String>> {
205 let before_value: serde_json::Value = serde_json::from_str(before)?;
206 let after_value: serde_json::Value = serde_json::from_str(after)?;
207
208 if let (Some(before_obj), Some(after_obj)) = (before_value.as_object(), after_value.as_object())
209 {
210 let mut changed = Vec::new();
211 for (key, after_val) in after_obj {
212 if let Some(before_val) = before_obj.get(key) {
213 if before_val != after_val {
214 changed.push(key.clone());
215 }
216 } else {
217 changed.push(key.clone());
218 }
219 }
220 Ok(changed)
221 } else {
222 Ok(vec!["task".to_string()])
223 }
224}
225
226pub use build::{build_task, build_task_without_lock};
228pub use refactor::build_refactor_tasks;
229pub use update::{update_all_tasks, update_task, update_task_without_lock};
230
231#[cfg(test)]
232mod tests {
233 use super::{
234 TaskBuildOptions, TaskUpdateSettings, read_request_from_args_or_reader,
235 resolve_task_build_settings, resolve_task_update_settings,
236 };
237 use crate::config;
238 use crate::contracts::{
239 ClaudePermissionMode, Config, RunnerApprovalMode, RunnerCliConfigRoot,
240 RunnerCliOptionsPatch, RunnerOutputFormat, RunnerPlanMode, RunnerSandboxMode,
241 RunnerVerbosity, UnsupportedOptionPolicy,
242 };
243 use std::collections::BTreeMap;
244 use std::io::Cursor;
245 use std::path::PathBuf;
246 use tempfile::TempDir;
247
248 fn resolved_with_config(config: Config) -> (config::Resolved, TempDir) {
249 let dir = TempDir::new().expect("temp dir");
250 let repo_root = dir.path().to_path_buf();
251 let queue_rel = config
252 .queue
253 .file
254 .clone()
255 .unwrap_or_else(|| PathBuf::from(".ralph/queue.json"));
256 let done_rel = config
257 .queue
258 .done_file
259 .clone()
260 .unwrap_or_else(|| PathBuf::from(".ralph/done.json"));
261 let id_prefix = config
262 .queue
263 .id_prefix
264 .clone()
265 .unwrap_or_else(|| "RQ".to_string());
266 let id_width = config.queue.id_width.unwrap_or(4) as usize;
267
268 (
269 config::Resolved {
270 config,
271 repo_root: repo_root.clone(),
272 queue_path: repo_root.join(queue_rel),
273 done_path: repo_root.join(done_rel),
274 id_prefix,
275 id_width,
276 global_config_path: None,
277 project_config_path: Some(repo_root.join(".ralph/config.json")),
278 },
279 dir,
280 )
281 }
282
283 fn build_opts() -> TaskBuildOptions {
284 TaskBuildOptions {
285 request: "request".to_string(),
286 hint_tags: String::new(),
287 hint_scope: String::new(),
288 runner_override: None,
289 model_override: None,
290 reasoning_effort_override: None,
291 runner_cli_overrides: RunnerCliOptionsPatch::default(),
292 force: false,
293 repoprompt_tool_injection: false,
294 template_hint: None,
295 template_target: None,
296 strict_templates: false,
297 estimated_minutes: None,
298 }
299 }
300
301 fn update_settings() -> TaskUpdateSettings {
302 TaskUpdateSettings {
303 fields: "scope".to_string(),
304 runner_override: None,
305 model_override: None,
306 reasoning_effort_override: None,
307 runner_cli_overrides: RunnerCliOptionsPatch::default(),
308 force: false,
309 repoprompt_tool_injection: false,
310 dry_run: false,
311 }
312 }
313
314 #[test]
315 fn read_request_from_args_or_reader_rejects_empty_args_on_terminal() {
316 let args: Vec<String> = vec![];
317 let reader = Cursor::new("");
318 let err = read_request_from_args_or_reader(&args, true, reader).unwrap_err();
319 let message = err.to_string();
320 assert!(message.contains("Missing request"));
321 assert!(message.contains("Pass arguments"));
322 }
323
324 #[test]
325 fn read_request_from_args_or_reader_reads_piped_input() {
326 let args: Vec<String> = vec![];
327 let reader = Cursor::new(" hello world ");
328 let value = read_request_from_args_or_reader(&args, false, reader).unwrap();
329 assert_eq!(value, "hello world");
330 }
331
332 #[test]
333 fn read_request_from_args_or_reader_rejects_empty_piped_input() {
334 let args: Vec<String> = vec![];
335 let reader = Cursor::new(" ");
336 let err = read_request_from_args_or_reader(&args, false, reader).unwrap_err();
337 assert!(err.to_string().contains("Missing request"));
338 }
339
340 #[test]
341 fn task_build_respects_config_permission_mode_when_approval_default() {
342 let mut config = Config::default();
343 config.agent.claude_permission_mode = Some(ClaudePermissionMode::AcceptEdits);
344 config.agent.runner_cli = Some(RunnerCliConfigRoot {
345 defaults: RunnerCliOptionsPatch {
346 output_format: Some(RunnerOutputFormat::StreamJson),
347 verbosity: Some(RunnerVerbosity::Normal),
348 approval_mode: Some(RunnerApprovalMode::Default),
349 sandbox: Some(RunnerSandboxMode::Default),
350 plan_mode: Some(RunnerPlanMode::Default),
351 unsupported_option_policy: Some(UnsupportedOptionPolicy::Warn),
352 },
353 runners: BTreeMap::new(),
354 });
355
356 let (resolved, _dir) = resolved_with_config(config);
357 let settings = resolve_task_build_settings(&resolved, &build_opts()).expect("settings");
358 let effective = settings
359 .runner_cli
360 .effective_claude_permission_mode(settings.permission_mode);
361 assert_eq!(effective, Some(ClaudePermissionMode::AcceptEdits));
362 }
363
364 #[test]
365 fn task_update_cli_override_yolo_bypasses_permission_mode() {
366 let mut config = Config::default();
367 config.agent.claude_permission_mode = Some(ClaudePermissionMode::AcceptEdits);
368 config.agent.runner_cli = Some(RunnerCliConfigRoot {
369 defaults: RunnerCliOptionsPatch {
370 output_format: Some(RunnerOutputFormat::StreamJson),
371 verbosity: Some(RunnerVerbosity::Normal),
372 approval_mode: Some(RunnerApprovalMode::Default),
373 sandbox: Some(RunnerSandboxMode::Default),
374 plan_mode: Some(RunnerPlanMode::Default),
375 unsupported_option_policy: Some(UnsupportedOptionPolicy::Warn),
376 },
377 runners: BTreeMap::new(),
378 });
379
380 let mut settings = update_settings();
381 settings.runner_cli_overrides = RunnerCliOptionsPatch {
382 approval_mode: Some(RunnerApprovalMode::Yolo),
383 ..RunnerCliOptionsPatch::default()
384 };
385
386 let (resolved, _dir) = resolved_with_config(config);
387 let runner_settings = resolve_task_update_settings(&resolved, &settings).expect("settings");
388 let effective = runner_settings
389 .runner_cli
390 .effective_claude_permission_mode(runner_settings.permission_mode);
391 assert_eq!(effective, Some(ClaudePermissionMode::BypassPermissions));
392 }
393
394 #[test]
395 fn task_build_fails_fast_when_safe_approval_requires_prompt() {
396 let mut config = Config::default();
397 config.agent.runner_cli = Some(RunnerCliConfigRoot {
398 defaults: RunnerCliOptionsPatch {
399 output_format: Some(RunnerOutputFormat::StreamJson),
400 approval_mode: Some(RunnerApprovalMode::Safe),
401 unsupported_option_policy: Some(UnsupportedOptionPolicy::Error),
402 ..RunnerCliOptionsPatch::default()
403 },
404 runners: BTreeMap::new(),
405 });
406
407 let (resolved, _dir) = resolved_with_config(config);
408 let err = resolve_task_build_settings(&resolved, &build_opts()).expect_err("error");
409 assert!(err.to_string().contains("approval_mode=safe"));
410 }
411
412 #[test]
413 fn task_update_fails_fast_when_safe_approval_requires_prompt() {
414 let mut config = Config::default();
415 config.agent.runner_cli = Some(RunnerCliConfigRoot {
416 defaults: RunnerCliOptionsPatch {
417 output_format: Some(RunnerOutputFormat::StreamJson),
418 approval_mode: Some(RunnerApprovalMode::Safe),
419 unsupported_option_policy: Some(UnsupportedOptionPolicy::Error),
420 ..RunnerCliOptionsPatch::default()
421 },
422 runners: BTreeMap::new(),
423 });
424
425 let (resolved, _dir) = resolved_with_config(config);
426 let err = resolve_task_update_settings(&resolved, &update_settings()).expect_err("error");
427 assert!(err.to_string().contains("approval_mode=safe"));
428 }
429}