Skip to main content

ralph/cli/task/
mod.rs

1//! `ralph task ...` command group: Clap types and handler facade.
2//!
3//! Responsibilities:
4//! - Define clap structures for task-related commands (re-exported from submodules).
5//! - Route task subcommands to their specific handlers.
6//! - Re-export argument types used by task commands.
7//!
8//! Not handled here:
9//! - Queue persistence and locking semantics (see `crate::queue` and `crate::lock`).
10//! - Task execution or runner behavior.
11//!
12//! Invariants/assumptions:
13//! - Configuration is resolved from the current working directory.
14//! - Task state changes occur within the subcommand handlers.
15
16mod args;
17mod batch;
18mod build;
19mod children;
20mod clone;
21mod decompose;
22mod edit;
23mod from_template;
24mod mutate;
25mod parent;
26mod refactor;
27mod relations;
28mod schedule;
29mod show;
30mod split;
31mod start;
32mod status;
33mod template;
34
35use anyhow::Result;
36
37use crate::config;
38
39// Re-export all argument types for backward compatibility
40pub use args::{
41    BatchEditArgs, BatchFieldArgs, BatchMode, BatchOperation, BatchStatusArgs, TaskArgs,
42    TaskBatchArgs, TaskBlocksArgs, TaskBuildArgs, TaskBuildRefactorArgs, TaskChildrenArgs,
43    TaskCloneArgs, TaskCommand, TaskDecomposeArgs, TaskDecomposeChildPolicyArg,
44    TaskDecomposeFormatArg, TaskDoneArgs, TaskEditArgs, TaskEditFieldArg, TaskFieldArgs,
45    TaskFromArgs, TaskFromCommand, TaskFromTemplateArgs, TaskMarkDuplicateArgs, TaskMutateArgs,
46    TaskParentArgs, TaskReadyArgs, TaskRejectArgs, TaskRelateArgs, TaskRelationFormat,
47    TaskScheduleArgs, TaskShowArgs, TaskSplitArgs, TaskStartArgs, TaskStatusArg, TaskStatusArgs,
48    TaskTemplateArgs, TaskTemplateBuildArgs, TaskTemplateCommand, TaskTemplateShowArgs,
49    TaskUpdateArgs,
50};
51
52/// Main entry point for task commands.
53pub fn handle_task(args: TaskArgs, force: bool) -> Result<()> {
54    let resolved = config::resolve_from_cwd()?;
55
56    match args.command {
57        Some(TaskCommand::Ready(args)) => status::handle_ready(&args, force, &resolved),
58        Some(TaskCommand::Status(args)) => status::handle_status(&args, force, &resolved),
59        Some(TaskCommand::Done(args)) => status::handle_done(&args, force, &resolved),
60        Some(TaskCommand::Reject(args)) => status::handle_reject(&args, force, &resolved),
61        Some(TaskCommand::Field(args)) => edit::handle_field(&args, force, &resolved),
62        Some(TaskCommand::Edit(args)) => edit::handle_edit(&args, force, &resolved),
63        Some(TaskCommand::Mutate(args)) => mutate::handle(&args, force, &resolved),
64        Some(TaskCommand::Update(args)) => edit::handle_update(&args, &resolved, force),
65        Some(TaskCommand::Build(args)) => build::handle(&args, force, &resolved),
66        Some(TaskCommand::Decompose(args)) => decompose::handle(&args, force, &resolved),
67        Some(TaskCommand::Template(template_args)) => template::handle(&resolved, &template_args),
68        Some(TaskCommand::BuildRefactor(args)) | Some(TaskCommand::Refactor(args)) => {
69            refactor::handle(&args, force, &resolved)
70        }
71        Some(TaskCommand::Show(args)) => show::handle(&args, &resolved),
72        Some(TaskCommand::Clone(args)) => clone::handle(&args, force, &resolved),
73        Some(TaskCommand::Batch(args)) => batch::handle(&args, force, &resolved),
74        Some(TaskCommand::Schedule(args)) => schedule::handle(&args, force, &resolved),
75        Some(TaskCommand::Relate(args)) => relations::handle_relate(&args, force, &resolved),
76        Some(TaskCommand::Blocks(args)) => relations::handle_blocks(&args, force, &resolved),
77        Some(TaskCommand::MarkDuplicate(args)) => {
78            relations::handle_mark_duplicate(&args, force, &resolved)
79        }
80        Some(TaskCommand::Split(args)) => split::handle(&args, force, &resolved),
81        Some(TaskCommand::Start(args)) => start::handle(&args, force, &resolved),
82        Some(TaskCommand::Children(args)) => children::handle(&args, &resolved),
83        Some(TaskCommand::Parent(args)) => parent::handle(&args, &resolved),
84        Some(TaskCommand::From(args)) => match args.command {
85            TaskFromCommand::Template(template_args) => {
86                from_template::handle(&resolved, &template_args, force)
87            }
88        },
89        None => {
90            // Default command: build from request
91            build::handle(&args.build, force, &resolved)
92        }
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use clap::{CommandFactory, Parser};
99
100    use crate::cli::Cli;
101    use crate::cli::queue::QueueShowFormat;
102    use crate::cli::task::args::{BatchOperation, TaskEditFieldArg, TaskStatusArg};
103
104    #[test]
105    fn task_update_help_mentions_rp_examples() {
106        let mut cmd = Cli::command();
107        let task = cmd.find_subcommand_mut("task").expect("task subcommand");
108        let update = task
109            .find_subcommand_mut("update")
110            .expect("task update subcommand");
111        let help = update.render_long_help().to_string();
112
113        assert!(
114            help.contains("ralph task update --repo-prompt plan RQ-0001"),
115            "missing repo-prompt plan example: {help}"
116        );
117        assert!(
118            help.contains("ralph task update --repo-prompt off --fields scope,evidence RQ-0001"),
119            "missing repo-prompt off example: {help}"
120        );
121        assert!(
122            help.contains("ralph task update --approval-mode auto-edits --runner claude RQ-0001"),
123            "missing approval-mode example: {help}"
124        );
125    }
126
127    #[test]
128    fn task_show_help_mentions_examples() {
129        let mut cmd = Cli::command();
130        let task = cmd.find_subcommand_mut("task").expect("task subcommand");
131        let show = task
132            .find_subcommand_mut("show")
133            .expect("task show subcommand");
134        let help = show.render_long_help().to_string();
135
136        assert!(
137            help.contains("ralph task show RQ-0001"),
138            "missing show example: {help}"
139        );
140        assert!(
141            help.contains("--format compact"),
142            "missing format example: {help}"
143        );
144    }
145
146    #[test]
147    fn task_details_alias_parses() {
148        let cli =
149            Cli::try_parse_from(["ralph", "task", "details", "RQ-0001", "--format", "compact"])
150                .expect("parse");
151
152        match cli.command {
153            crate::cli::Command::Task(args) => match args.command {
154                Some(crate::cli::task::TaskCommand::Show(args)) => {
155                    assert_eq!(args.task_id, "RQ-0001");
156                    assert_eq!(args.format, QueueShowFormat::Compact);
157                }
158                _ => panic!("expected task show command"),
159            },
160            _ => panic!("expected task command"),
161        }
162    }
163
164    #[test]
165    fn task_build_parses_repo_prompt_and_effort_alias() {
166        let cli = Cli::try_parse_from([
167            "ralph",
168            "task",
169            "build",
170            "--repo-prompt",
171            "plan",
172            "-e",
173            "high",
174            "Add tests",
175        ])
176        .expect("parse");
177
178        match cli.command {
179            crate::cli::Command::Task(args) => match args.command {
180                Some(crate::cli::task::TaskCommand::Build(args)) => {
181                    assert_eq!(args.repo_prompt, Some(crate::agent::RepoPromptMode::Plan));
182                    assert_eq!(args.effort.as_deref(), Some("high"));
183                }
184                _ => panic!("expected task build command"),
185            },
186            _ => panic!("expected task command"),
187        }
188    }
189
190    #[test]
191    fn task_build_parses_runner_cli_overrides() {
192        let cli = Cli::try_parse_from([
193            "ralph",
194            "task",
195            "build",
196            "--approval-mode",
197            "yolo",
198            "--sandbox",
199            "disabled",
200            "Add tests",
201        ])
202        .expect("parse");
203
204        match cli.command {
205            crate::cli::Command::Task(args) => match args.command {
206                Some(crate::cli::task::TaskCommand::Build(args)) => {
207                    assert_eq!(args.runner_cli.approval_mode.as_deref(), Some("yolo"));
208                    assert_eq!(args.runner_cli.sandbox.as_deref(), Some("disabled"));
209                }
210                _ => panic!("expected task build command"),
211            },
212            _ => panic!("expected task command"),
213        }
214    }
215
216    #[test]
217    fn task_decompose_parses_preview_and_limits() {
218        let cli = Cli::try_parse_from([
219            "ralph",
220            "task",
221            "decompose",
222            "--preview",
223            "--attach-to",
224            "RQ-0042",
225            "--child-policy",
226            "append",
227            "--with-dependencies",
228            "--format",
229            "json",
230            "--max-depth",
231            "4",
232            "--max-children",
233            "6",
234            "--max-nodes",
235            "24",
236            "RQ-0001",
237        ])
238        .expect("parse");
239
240        match cli.command {
241            crate::cli::Command::Task(args) => match args.command {
242                Some(crate::cli::task::TaskCommand::Decompose(args)) => {
243                    assert!(args.preview);
244                    assert!(!args.write);
245                    assert_eq!(args.attach_to.as_deref(), Some("RQ-0042"));
246                    assert_eq!(
247                        args.child_policy,
248                        crate::cli::task::TaskDecomposeChildPolicyArg::Append
249                    );
250                    assert!(args.with_dependencies);
251                    assert_eq!(args.format, crate::cli::task::TaskDecomposeFormatArg::Json);
252                    assert_eq!(args.max_depth, 4);
253                    assert_eq!(args.max_children, 6);
254                    assert_eq!(args.max_nodes, 24);
255                    assert_eq!(args.source, vec!["RQ-0001"]);
256                }
257                _ => panic!("expected task decompose command"),
258            },
259            _ => panic!("expected task command"),
260        }
261    }
262
263    #[test]
264    fn task_decompose_parses_runner_overrides() {
265        let cli = Cli::try_parse_from([
266            "ralph",
267            "task",
268            "decompose",
269            "--runner",
270            "codex",
271            "--model",
272            "gpt-5.4",
273            "-e",
274            "high",
275            "--repo-prompt",
276            "tools",
277            "--approval-mode",
278            "auto-edits",
279            "Plan queue migration",
280        ])
281        .expect("parse");
282
283        match cli.command {
284            crate::cli::Command::Task(args) => match args.command {
285                Some(crate::cli::task::TaskCommand::Decompose(args)) => {
286                    assert_eq!(args.runner.as_deref(), Some("codex"));
287                    assert_eq!(args.model.as_deref(), Some("gpt-5.4"));
288                    assert_eq!(args.effort.as_deref(), Some("high"));
289                    assert_eq!(args.repo_prompt, Some(crate::agent::RepoPromptMode::Tools));
290                    assert_eq!(args.runner_cli.approval_mode.as_deref(), Some("auto-edits"));
291                }
292                _ => panic!("expected task decompose command"),
293            },
294            _ => panic!("expected task command"),
295        }
296    }
297
298    #[test]
299    fn task_decompose_help_mentions_write_example() {
300        let mut cmd = Cli::command();
301        let task = cmd.find_subcommand_mut("task").expect("task subcommand");
302        let decompose = task
303            .find_subcommand_mut("decompose")
304            .expect("task decompose subcommand");
305        let help = decompose.render_long_help().to_string();
306
307        assert!(
308            help.contains("Improve webhook reliability\" --write"),
309            "missing write example: {help}"
310        );
311        assert!(
312            help.contains("--attach-to RQ-0042"),
313            "missing attach example: {help}"
314        );
315        assert!(
316            help.contains("--format json"),
317            "missing json output example: {help}"
318        );
319    }
320
321    #[test]
322    fn task_update_parses_repo_prompt_and_effort_alias() {
323        let cli = Cli::try_parse_from([
324            "ralph",
325            "task",
326            "update",
327            "--repo-prompt",
328            "off",
329            "-e",
330            "low",
331            "RQ-0001",
332        ])
333        .expect("parse");
334
335        match cli.command {
336            crate::cli::Command::Task(args) => match args.command {
337                Some(crate::cli::task::TaskCommand::Update(args)) => {
338                    assert_eq!(args.repo_prompt, Some(crate::agent::RepoPromptMode::Off));
339                    assert_eq!(args.effort.as_deref(), Some("low"));
340                    assert_eq!(args.task_id.as_deref(), Some("RQ-0001"));
341                }
342                _ => panic!("expected task update command"),
343            },
344            _ => panic!("expected task command"),
345        }
346    }
347
348    #[test]
349    fn task_update_parses_runner_cli_overrides() {
350        let cli = Cli::try_parse_from([
351            "ralph",
352            "task",
353            "update",
354            "--approval-mode",
355            "auto-edits",
356            "--sandbox",
357            "disabled",
358            "RQ-0001",
359        ])
360        .expect("parse");
361
362        match cli.command {
363            crate::cli::Command::Task(args) => match args.command {
364                Some(crate::cli::task::TaskCommand::Update(args)) => {
365                    assert_eq!(args.runner_cli.approval_mode.as_deref(), Some("auto-edits"));
366                    assert_eq!(args.runner_cli.sandbox.as_deref(), Some("disabled"));
367                    assert_eq!(args.task_id.as_deref(), Some("RQ-0001"));
368                }
369                _ => panic!("expected task update command"),
370            },
371            _ => panic!("expected task command"),
372        }
373    }
374
375    #[test]
376    fn task_edit_parses_dry_run_flag() {
377        let cli = Cli::try_parse_from([
378            "ralph",
379            "task",
380            "edit",
381            "--dry-run",
382            "title",
383            "New title",
384            "RQ-0001",
385        ])
386        .expect("parse");
387
388        match cli.command {
389            crate::cli::Command::Task(args) => match args.command {
390                Some(crate::cli::task::TaskCommand::Edit(args)) => {
391                    assert!(args.dry_run);
392                    assert_eq!(args.task_ids, vec!["RQ-0001"]);
393                    assert_eq!(args.value, "New title");
394                }
395                _ => panic!("expected task edit command"),
396            },
397            _ => panic!("expected task command"),
398        }
399    }
400
401    #[test]
402    fn task_edit_without_dry_run_defaults_to_false() {
403        let cli = Cli::try_parse_from(["ralph", "task", "edit", "title", "New title", "RQ-0001"])
404            .expect("parse");
405
406        match cli.command {
407            crate::cli::Command::Task(args) => match args.command {
408                Some(crate::cli::task::TaskCommand::Edit(args)) => {
409                    assert!(!args.dry_run);
410                }
411                _ => panic!("expected task edit command"),
412            },
413            _ => panic!("expected task command"),
414        }
415    }
416
417    #[test]
418    fn task_update_parses_dry_run_flag() {
419        let cli = Cli::try_parse_from(["ralph", "task", "update", "--dry-run", "RQ-0001"])
420            .expect("parse");
421
422        match cli.command {
423            crate::cli::Command::Task(args) => match args.command {
424                Some(crate::cli::task::TaskCommand::Update(args)) => {
425                    assert!(args.dry_run);
426                    assert_eq!(args.task_id.as_deref(), Some("RQ-0001"));
427                }
428                _ => panic!("expected task update command"),
429            },
430            _ => panic!("expected task command"),
431        }
432    }
433
434    #[test]
435    fn task_update_without_dry_run_defaults_to_false() {
436        let cli = Cli::try_parse_from(["ralph", "task", "update", "RQ-0001"]).expect("parse");
437
438        match cli.command {
439            crate::cli::Command::Task(args) => match args.command {
440                Some(crate::cli::task::TaskCommand::Update(args)) => {
441                    assert!(!args.dry_run);
442                }
443                _ => panic!("expected task update command"),
444            },
445            _ => panic!("expected task command"),
446        }
447    }
448
449    #[test]
450    fn task_refactor_parses() {
451        let cli = Cli::try_parse_from(["ralph", "task", "refactor"]).expect("parse");
452        match cli.command {
453            crate::cli::Command::Task(args) => match args.command {
454                Some(crate::cli::task::TaskCommand::Refactor(_)) => {}
455                _ => panic!("expected task refactor command"),
456            },
457            _ => panic!("expected task command"),
458        }
459    }
460
461    #[test]
462    fn task_ref_alias_parses() {
463        let cli =
464            Cli::try_parse_from(["ralph", "task", "ref", "--threshold", "800"]).expect("parse");
465        match cli.command {
466            crate::cli::Command::Task(args) => match args.command {
467                Some(crate::cli::task::TaskCommand::Refactor(args)) => {
468                    assert_eq!(args.threshold, 800);
469                }
470                _ => panic!("expected task refactor command via alias"),
471            },
472            _ => panic!("expected task command"),
473        }
474    }
475
476    #[test]
477    fn task_build_refactor_parses() {
478        let cli = Cli::try_parse_from(["ralph", "task", "build-refactor", "--threshold", "700"])
479            .expect("parse");
480        match cli.command {
481            crate::cli::Command::Task(args) => match args.command {
482                Some(crate::cli::task::TaskCommand::BuildRefactor(args)) => {
483                    assert_eq!(args.threshold, 700);
484                }
485                _ => panic!("expected task build-refactor command"),
486            },
487            _ => panic!("expected task command"),
488        }
489    }
490
491    #[test]
492    fn task_clone_parses() {
493        let cli = Cli::try_parse_from(["ralph", "task", "clone", "RQ-0001"]).expect("parse");
494        match cli.command {
495            crate::cli::Command::Task(args) => match args.command {
496                Some(crate::cli::task::TaskCommand::Clone(args)) => {
497                    assert_eq!(args.task_id, "RQ-0001");
498                    assert!(!args.dry_run);
499                }
500                _ => panic!("expected task clone command"),
501            },
502            _ => panic!("expected task command"),
503        }
504    }
505
506    #[test]
507    fn task_duplicate_alias_parses() {
508        let cli = Cli::try_parse_from(["ralph", "task", "duplicate", "RQ-0001"]).expect("parse");
509        match cli.command {
510            crate::cli::Command::Task(args) => match args.command {
511                Some(crate::cli::task::TaskCommand::Clone(args)) => {
512                    assert_eq!(args.task_id, "RQ-0001");
513                }
514                _ => panic!("expected task clone command via duplicate alias"),
515            },
516            _ => panic!("expected task command"),
517        }
518    }
519
520    #[test]
521    fn task_clone_parses_status_flag() {
522        let cli = Cli::try_parse_from(["ralph", "task", "clone", "--status", "todo", "RQ-0001"])
523            .expect("parse");
524        match cli.command {
525            crate::cli::Command::Task(args) => match args.command {
526                Some(crate::cli::task::TaskCommand::Clone(args)) => {
527                    assert_eq!(args.task_id, "RQ-0001");
528                    assert_eq!(args.status, Some(TaskStatusArg::Todo));
529                }
530                _ => panic!("expected task clone command"),
531            },
532            _ => panic!("expected task command"),
533        }
534    }
535
536    #[test]
537    fn task_clone_parses_title_prefix() {
538        let cli = Cli::try_parse_from([
539            "ralph",
540            "task",
541            "clone",
542            "--title-prefix",
543            "[Follow-up] ",
544            "RQ-0001",
545        ])
546        .expect("parse");
547        match cli.command {
548            crate::cli::Command::Task(args) => match args.command {
549                Some(crate::cli::task::TaskCommand::Clone(args)) => {
550                    assert_eq!(args.task_id, "RQ-0001");
551                    assert_eq!(args.title_prefix, Some("[Follow-up] ".to_string()));
552                }
553                _ => panic!("expected task clone command"),
554            },
555            _ => panic!("expected task command"),
556        }
557    }
558
559    #[test]
560    fn task_clone_parses_dry_run_flag() {
561        let cli =
562            Cli::try_parse_from(["ralph", "task", "clone", "--dry-run", "RQ-0001"]).expect("parse");
563        match cli.command {
564            crate::cli::Command::Task(args) => match args.command {
565                Some(crate::cli::task::TaskCommand::Clone(args)) => {
566                    assert_eq!(args.task_id, "RQ-0001");
567                    assert!(args.dry_run);
568                }
569                _ => panic!("expected task clone command"),
570            },
571            _ => panic!("expected task command"),
572        }
573    }
574
575    #[test]
576    fn task_clone_help_mentions_examples() {
577        let mut cmd = Cli::command();
578        let task = cmd.find_subcommand_mut("task").expect("task subcommand");
579        let clone = task
580            .find_subcommand_mut("clone")
581            .expect("task clone subcommand");
582        let help = clone.render_long_help().to_string();
583
584        assert!(
585            help.contains("ralph task clone RQ-0001"),
586            "missing clone example: {help}"
587        );
588        assert!(
589            help.contains("--status"),
590            "missing --status example: {help}"
591        );
592        assert!(
593            help.contains("--title-prefix"),
594            "missing --title-prefix example: {help}"
595        );
596        assert!(
597            help.contains("ralph task duplicate"),
598            "missing duplicate alias example: {help}"
599        );
600    }
601
602    #[test]
603    fn task_batch_status_parses_multiple_ids() {
604        let cli = Cli::try_parse_from([
605            "ralph", "task", "batch", "status", "doing", "RQ-0001", "RQ-0002", "RQ-0003",
606        ])
607        .expect("parse");
608        match cli.command {
609            crate::cli::Command::Task(args) => match args.command {
610                Some(crate::cli::task::TaskCommand::Batch(args)) => match args.operation {
611                    BatchOperation::Status(status_args) => {
612                        assert_eq!(status_args.status, TaskStatusArg::Doing);
613                        assert_eq!(
614                            status_args.select.task_ids,
615                            vec!["RQ-0001", "RQ-0002", "RQ-0003"]
616                        );
617                        assert!(!args.dry_run);
618                        assert!(!args.continue_on_error);
619                    }
620                    _ => panic!("expected batch status operation"),
621                },
622                _ => panic!("expected task batch command"),
623            },
624            _ => panic!("expected task command"),
625        }
626    }
627
628    #[test]
629    fn task_batch_status_parses_tag_filter() {
630        let cli = Cli::try_parse_from([
631            "ralph",
632            "task",
633            "batch",
634            "status",
635            "doing",
636            "--tag-filter",
637            "rust",
638            "--tag-filter",
639            "cli",
640        ])
641        .expect("parse");
642        match cli.command {
643            crate::cli::Command::Task(args) => match args.command {
644                Some(crate::cli::task::TaskCommand::Batch(args)) => match args.operation {
645                    BatchOperation::Status(status_args) => {
646                        assert_eq!(status_args.status, TaskStatusArg::Doing);
647                        assert!(status_args.select.task_ids.is_empty());
648                        assert_eq!(status_args.select.tag_filter, vec!["rust", "cli"]);
649                    }
650                    _ => panic!("expected batch status operation"),
651                },
652                _ => panic!("expected task batch command"),
653            },
654            _ => panic!("expected task command"),
655        }
656    }
657
658    #[test]
659    fn task_batch_field_parses_multiple_ids() {
660        let cli = Cli::try_parse_from([
661            "ralph", "task", "batch", "field", "severity", "high", "RQ-0001", "RQ-0002",
662        ])
663        .expect("parse");
664        match cli.command {
665            crate::cli::Command::Task(args) => match args.command {
666                Some(crate::cli::task::TaskCommand::Batch(args)) => match args.operation {
667                    BatchOperation::Field(field_args) => {
668                        assert_eq!(field_args.key, "severity");
669                        assert_eq!(field_args.value, "high");
670                        assert_eq!(field_args.select.task_ids, vec!["RQ-0001", "RQ-0002"]);
671                    }
672                    _ => panic!("expected batch field operation"),
673                },
674                _ => panic!("expected task batch command"),
675            },
676            _ => panic!("expected task command"),
677        }
678    }
679
680    #[test]
681    fn task_batch_edit_parses_dry_run() {
682        let cli = Cli::try_parse_from([
683            "ralph",
684            "task",
685            "batch",
686            "--dry-run",
687            "edit",
688            "priority",
689            "high",
690            "RQ-0001",
691            "RQ-0002",
692        ])
693        .expect("parse");
694        match cli.command {
695            crate::cli::Command::Task(args) => match args.command {
696                Some(crate::cli::task::TaskCommand::Batch(args)) => {
697                    assert!(args.dry_run);
698                    assert!(!args.continue_on_error);
699                    match args.operation {
700                        BatchOperation::Edit(edit_args) => {
701                            assert_eq!(edit_args.field, TaskEditFieldArg::Priority);
702                            assert_eq!(edit_args.value, "high");
703                            assert_eq!(edit_args.select.task_ids, vec!["RQ-0001", "RQ-0002"]);
704                        }
705                        _ => panic!("expected batch edit operation"),
706                    }
707                }
708                _ => panic!("expected task batch command"),
709            },
710            _ => panic!("expected task command"),
711        }
712    }
713
714    #[test]
715    fn task_batch_parses_continue_on_error() {
716        let cli = Cli::try_parse_from([
717            "ralph",
718            "task",
719            "batch",
720            "--continue-on-error",
721            "status",
722            "doing",
723            "RQ-0001",
724            "RQ-0002",
725        ])
726        .expect("parse");
727        match cli.command {
728            crate::cli::Command::Task(args) => match args.command {
729                Some(crate::cli::task::TaskCommand::Batch(args)) => {
730                    assert!(!args.dry_run);
731                    assert!(args.continue_on_error);
732                    match args.operation {
733                        BatchOperation::Status(status_args) => {
734                            assert_eq!(status_args.status, TaskStatusArg::Doing);
735                        }
736                        _ => panic!("expected batch status operation"),
737                    }
738                }
739                _ => panic!("expected task batch command"),
740            },
741            _ => panic!("expected task command"),
742        }
743    }
744
745    #[test]
746    fn task_batch_help_mentions_examples() {
747        let mut cmd = Cli::command();
748        let task = cmd.find_subcommand_mut("task").expect("task subcommand");
749        let batch = task
750            .find_subcommand_mut("batch")
751            .expect("task batch subcommand");
752        let help = batch.render_long_help().to_string();
753
754        assert!(
755            help.contains("ralph task batch status doing"),
756            "missing batch status example: {help}"
757        );
758        assert!(
759            help.contains("--tag-filter"),
760            "missing --tag-filter example: {help}"
761        );
762        assert!(
763            help.contains("--dry-run"),
764            "missing --dry-run example: {help}"
765        );
766        assert!(
767            help.contains("--continue-on-error"),
768            "missing --continue-on-error example: {help}"
769        );
770    }
771
772    #[test]
773    fn task_status_parses_multiple_ids() {
774        let cli = Cli::try_parse_from([
775            "ralph", "task", "status", "doing", "RQ-0001", "RQ-0002", "RQ-0003",
776        ])
777        .expect("parse");
778        match cli.command {
779            crate::cli::Command::Task(args) => match args.command {
780                Some(crate::cli::task::TaskCommand::Status(args)) => {
781                    assert_eq!(args.status, TaskStatusArg::Doing);
782                    assert_eq!(args.task_ids, vec!["RQ-0001", "RQ-0002", "RQ-0003"]);
783                }
784                _ => panic!("expected task status command"),
785            },
786            _ => panic!("expected task command"),
787        }
788    }
789
790    #[test]
791    fn task_status_parses_tag_filter() {
792        let cli = Cli::try_parse_from([
793            "ralph",
794            "task",
795            "status",
796            "doing",
797            "--tag-filter",
798            "rust",
799            "--tag-filter",
800            "cli",
801        ])
802        .expect("parse");
803        match cli.command {
804            crate::cli::Command::Task(args) => match args.command {
805                Some(crate::cli::task::TaskCommand::Status(args)) => {
806                    assert_eq!(args.status, TaskStatusArg::Doing);
807                    assert!(args.task_ids.is_empty());
808                    assert_eq!(args.tag_filter, vec!["rust", "cli"]);
809                }
810                _ => panic!("expected task status command"),
811            },
812            _ => panic!("expected task command"),
813        }
814    }
815
816    #[test]
817    fn task_field_parses_multiple_ids() {
818        let cli = Cli::try_parse_from([
819            "ralph", "task", "field", "severity", "high", "RQ-0001", "RQ-0002",
820        ])
821        .expect("parse");
822        match cli.command {
823            crate::cli::Command::Task(args) => match args.command {
824                Some(crate::cli::task::TaskCommand::Field(args)) => {
825                    assert_eq!(args.key, "severity");
826                    assert_eq!(args.value, "high");
827                    assert_eq!(args.task_ids, vec!["RQ-0001", "RQ-0002"]);
828                }
829                _ => panic!("expected task field command"),
830            },
831            _ => panic!("expected task command"),
832        }
833    }
834
835    #[test]
836    fn task_field_parses_dry_run_flag() {
837        let cli = Cli::try_parse_from([
838            "ralph",
839            "task",
840            "field",
841            "--dry-run",
842            "severity",
843            "high",
844            "RQ-0001",
845        ])
846        .expect("parse");
847        match cli.command {
848            crate::cli::Command::Task(args) => match args.command {
849                Some(crate::cli::task::TaskCommand::Field(args)) => {
850                    assert!(args.dry_run);
851                    assert_eq!(args.key, "severity");
852                    assert_eq!(args.value, "high");
853                    assert_eq!(args.task_ids, vec!["RQ-0001"]);
854                }
855                _ => panic!("expected task field command"),
856            },
857            _ => panic!("expected task command"),
858        }
859    }
860
861    #[test]
862    fn task_field_without_dry_run_defaults_to_false() {
863        let cli = Cli::try_parse_from(["ralph", "task", "field", "severity", "high", "RQ-0001"])
864            .expect("parse");
865        match cli.command {
866            crate::cli::Command::Task(args) => match args.command {
867                Some(crate::cli::task::TaskCommand::Field(args)) => {
868                    assert!(!args.dry_run);
869                }
870                _ => panic!("expected task field command"),
871            },
872            _ => panic!("expected task command"),
873        }
874    }
875
876    #[test]
877    fn task_edit_parses_multiple_ids() {
878        let cli = Cli::try_parse_from([
879            "ralph", "task", "edit", "priority", "high", "RQ-0001", "RQ-0002",
880        ])
881        .expect("parse");
882        match cli.command {
883            crate::cli::Command::Task(args) => match args.command {
884                Some(crate::cli::task::TaskCommand::Edit(args)) => {
885                    assert_eq!(args.field, TaskEditFieldArg::Priority);
886                    assert_eq!(args.value, "high");
887                    assert_eq!(args.task_ids, vec!["RQ-0001", "RQ-0002"]);
888                }
889                _ => panic!("expected task edit command"),
890            },
891            _ => panic!("expected task command"),
892        }
893    }
894}