Skip to main content

ralph/commands/task/
mod.rs

1//! Task-building and task-updating command helpers (request parsing, runner invocation, and queue updates).
2//!
3//! Responsibilities:
4//! - Shared types and configuration for task operations (build, update, refactor).
5//! - Parse task request inputs from CLI args or stdin.
6//! - Runner settings resolution for task operations.
7//! - JSON field comparison for task updates.
8//!
9//! Not handled here:
10//! - Actual task building logic (see build.rs).
11//! - Task update logic (see update.rs).
12//! - Refactor task generation and LOC scanning (see refactor.rs).
13//! - CLI argument definitions or command routing.
14//! - Runner process implementation details or output parsing.
15//! - Queue schema definitions or config persistence.
16//!
17//! Invariants/assumptions:
18//! - Queue/done files are the source of truth for task ordering and status.
19//! - Runner execution requires stream-json output for parsing.
20//! - Permission/approval defaults come from config unless overridden at CLI.
21
22use 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/// Batching mode for grouping related files in build-refactor.
35#[derive(Clone, Copy, Debug)]
36pub enum BatchMode {
37    /// Group files in same directory with similar names (e.g., test files with source).
38    Auto,
39    /// Create individual task per file.
40    Never,
41    /// Group all files in same module/directory.
42    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
55/// Options for the build-refactor command.
56pub 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
70// TaskBuildOptions controls runner-driven task creation via .ralph/prompts/task_builder.md.
71pub 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    /// Optional template name to use as a base for task fields
82    pub template_hint: Option<String>,
83    /// Optional target path for template variable substitution
84    pub template_target: Option<String>,
85    /// Fail on unknown template variables (default: false, warns only)
86    pub strict_templates: bool,
87    /// Estimated minutes for task completion
88    pub estimated_minutes: Option<u32>,
89}
90
91// TaskUpdateSettings controls runner-driven task updates via .ralph/prompts/task_updater.md.
92pub 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
196// read_request_from_args_or_stdin joins any positional args, otherwise reads stdin.
197pub 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
226// Re-export public functions from submodules
227pub 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}