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