1use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
2use std::io::Write;
3use std::path::{Path, PathBuf};
4
5use serde::Serialize;
6
7use crate::parse::{Plan, Sprint, parse_plan_with_display};
8
9const USAGE: &str = r#"Usage:
10 plan-tooling split-prs --file <plan.md> [options]
11
12Purpose:
13 Build task-to-PR split records from a Plan Format v1 file.
14
15Required:
16 --file <path> Plan file to parse
17
18Options:
19 --scope <plan|sprint> Scope to split (default: sprint)
20 --sprint <n> Sprint number when --scope sprint
21 --pr-group <task=group> Group pin; repeatable (group mode only)
22 deterministic/group: required for every task
23 auto/group lanes: optional pins + auto assignment for remaining tasks
24 --pr-grouping <mode> deterministic only: per-sprint | group
25 --default-pr-grouping <mode> auto fallback when sprint metadata omits grouping intent
26 --strategy <deterministic|auto> Split strategy (default: deterministic)
27 --explain Include grouping rationale in JSON output
28 --owner-prefix <text> Owner prefix (default: subagent)
29 --branch-prefix <text> Branch prefix (default: issue)
30 --worktree-prefix <text> Worktree prefix (default: issue__)
31 --format <json|tsv> Output format (default: json)
32 -h, --help Show help
33
34Argument style:
35 --key value and --key=value are both accepted for value options.
36
37Exit:
38 0: success
39 1: runtime or validation error
40 2: usage error
41"#;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum SplitScope {
45 Plan,
46 Sprint(i32),
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum SplitPrGrouping {
51 PerSprint,
52 Group,
53}
54
55impl SplitPrGrouping {
56 pub fn as_str(self) -> &'static str {
57 match self {
58 Self::PerSprint => "per-sprint",
59 Self::Group => "group",
60 }
61 }
62
63 fn from_cli(value: &str) -> Option<Self> {
64 match value {
65 "per-sprint" | "per-spring" => Some(Self::PerSprint),
66 "group" => Some(Self::Group),
67 _ => None,
68 }
69 }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum SplitPrStrategy {
74 Deterministic,
75 Auto,
76}
77
78impl SplitPrStrategy {
79 pub fn as_str(self) -> &'static str {
80 match self {
81 Self::Deterministic => "deterministic",
82 Self::Auto => "auto",
83 }
84 }
85
86 fn from_cli(value: &str) -> Option<Self> {
87 match value {
88 "deterministic" => Some(Self::Deterministic),
89 "auto" => Some(Self::Auto),
90 _ => None,
91 }
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct SplitPlanOptions {
97 pub pr_grouping: Option<SplitPrGrouping>,
98 pub default_pr_grouping: Option<SplitPrGrouping>,
99 pub strategy: SplitPrStrategy,
100 pub pr_group_entries: Vec<String>,
101 pub owner_prefix: String,
102 pub branch_prefix: String,
103 pub worktree_prefix: String,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct SplitPlanRecord {
108 pub task_id: String,
109 pub sprint: i32,
110 pub summary: String,
111 pub pr_group: String,
112}
113
114#[derive(Debug, Clone)]
115struct Record {
116 task_id: String,
117 plan_task_id: String,
118 sprint: i32,
119 summary: String,
120 complexity: i32,
121 location_paths: Vec<String>,
122 dependency_keys: Vec<String>,
123 pr_group: String,
124}
125
126#[derive(Debug, Clone, Default)]
127struct AutoSprintHint {
128 pr_grouping_intent: Option<SplitPrGrouping>,
129 execution_profile: Option<String>,
130 target_parallel_width: Option<usize>,
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum ResolvedPrGroupingSource {
135 CommandPrGrouping,
136 PlanMetadata,
137 DefaultPrGrouping,
138}
139
140impl ResolvedPrGroupingSource {
141 fn as_str(self) -> &'static str {
142 match self {
143 Self::CommandPrGrouping => "command-pr-grouping",
144 Self::PlanMetadata => "plan-metadata",
145 Self::DefaultPrGrouping => "default-pr-grouping",
146 }
147 }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub struct ResolvedPrGrouping {
152 pub grouping: SplitPrGrouping,
153 pub source: ResolvedPrGroupingSource,
154}
155
156#[derive(Debug, Serialize)]
157struct Output {
158 file: String,
159 scope: String,
160 sprint: Option<i32>,
161 pr_grouping: String,
162 strategy: String,
163 records: Vec<OutputRecord>,
164 #[serde(skip_serializing_if = "Option::is_none")]
165 explain: Option<Vec<ExplainSprint>>,
166}
167
168#[derive(Debug, Serialize, PartialEq, Eq)]
169struct OutputRecord {
170 task_id: String,
171 summary: String,
172 pr_group: String,
173}
174
175#[derive(Debug, Serialize, PartialEq, Eq)]
176struct ExplainSprint {
177 sprint: i32,
178 #[serde(skip_serializing_if = "Option::is_none")]
179 target_parallel_width: Option<usize>,
180 #[serde(skip_serializing_if = "Option::is_none")]
181 execution_profile: Option<String>,
182 #[serde(skip_serializing_if = "Option::is_none")]
183 pr_grouping_intent: Option<String>,
184 #[serde(skip_serializing_if = "Option::is_none")]
185 pr_grouping_intent_source: Option<String>,
186 groups: Vec<ExplainGroup>,
187}
188
189#[derive(Debug, Serialize, PartialEq, Eq)]
190struct ExplainGroup {
191 pr_group: String,
192 task_ids: Vec<String>,
193 anchor: String,
194}
195
196pub fn run(args: &[String]) -> i32 {
197 let mut file: Option<String> = None;
198 let mut scope = String::from("sprint");
199 let mut sprint: Option<String> = None;
200 let mut pr_grouping: Option<String> = None;
201 let mut default_pr_grouping: Option<String> = None;
202 let mut pr_group_entries: Vec<String> = Vec::new();
203 let mut strategy = String::from("deterministic");
204 let mut explain = false;
205 let mut owner_prefix = String::from("subagent");
206 let mut branch_prefix = String::from("issue");
207 let mut worktree_prefix = String::from("issue__");
208 let mut format = String::from("json");
209
210 let mut i = 0usize;
211 while i < args.len() {
212 let raw_arg = args[i].as_str();
213 let (flag, inline_value) = split_value_arg(raw_arg);
214 match flag {
215 "--file" => {
216 let Ok((v, next_i)) = consume_option_value(args, i, inline_value, "--file") else {
217 return die("missing value for --file");
218 };
219 file = Some(v);
220 i = next_i;
221 }
222 "--scope" => {
223 let Ok((v, next_i)) = consume_option_value(args, i, inline_value, "--scope") else {
224 return die("missing value for --scope");
225 };
226 scope = v;
227 i = next_i;
228 }
229 "--sprint" => {
230 let Ok((v, next_i)) = consume_option_value(args, i, inline_value, "--sprint")
231 else {
232 return die("missing value for --sprint");
233 };
234 sprint = Some(v);
235 i = next_i;
236 }
237 "--pr-grouping" => {
238 let Ok((v, next_i)) = consume_option_value(args, i, inline_value, "--pr-grouping")
239 else {
240 return die("missing value for --pr-grouping");
241 };
242 pr_grouping = Some(v);
243 i = next_i;
244 }
245 "--default-pr-grouping" => {
246 let Ok((v, next_i)) =
247 consume_option_value(args, i, inline_value, "--default-pr-grouping")
248 else {
249 return die("missing value for --default-pr-grouping");
250 };
251 default_pr_grouping = Some(v);
252 i = next_i;
253 }
254 "--pr-group" => {
255 let Ok((v, next_i)) = consume_option_value(args, i, inline_value, "--pr-group")
256 else {
257 return die("missing value for --pr-group");
258 };
259 pr_group_entries.push(v);
260 i = next_i;
261 }
262 "--strategy" => {
263 let Ok((v, next_i)) = consume_option_value(args, i, inline_value, "--strategy")
264 else {
265 return die("missing value for --strategy");
266 };
267 strategy = v;
268 i = next_i;
269 }
270 "--explain" => {
271 if inline_value.is_some() {
272 return die("unexpected value for --explain");
273 }
274 explain = true;
275 i += 1;
276 }
277 "--owner-prefix" => {
278 let Ok((v, next_i)) = consume_option_value(args, i, inline_value, "--owner-prefix")
279 else {
280 return die("missing value for --owner-prefix");
281 };
282 owner_prefix = v;
283 i = next_i;
284 }
285 "--branch-prefix" => {
286 let Ok((v, next_i)) =
287 consume_option_value(args, i, inline_value, "--branch-prefix")
288 else {
289 return die("missing value for --branch-prefix");
290 };
291 branch_prefix = v;
292 i = next_i;
293 }
294 "--worktree-prefix" => {
295 let Ok((v, next_i)) =
296 consume_option_value(args, i, inline_value, "--worktree-prefix")
297 else {
298 return die("missing value for --worktree-prefix");
299 };
300 worktree_prefix = v;
301 i = next_i;
302 }
303 "--format" => {
304 let Ok((v, next_i)) = consume_option_value(args, i, inline_value, "--format")
305 else {
306 return die("missing value for --format");
307 };
308 format = v;
309 i = next_i;
310 }
311 "-h" | "--help" => {
312 if inline_value.is_some() {
313 return die(&format!("unknown argument: {raw_arg}"));
314 }
315 print_usage();
316 return 0;
317 }
318 _ => {
319 return die(&format!("unknown argument: {raw_arg}"));
320 }
321 }
322 }
323
324 let Some(file_arg) = file else {
325 print_usage();
326 return 2;
327 };
328 if scope != "plan" && scope != "sprint" {
329 return die(&format!(
330 "invalid --scope (expected plan|sprint): {}",
331 crate::repr::py_repr(&scope)
332 ));
333 }
334 if strategy != "deterministic" && strategy != "auto" {
335 return die(&format!(
336 "invalid --strategy (expected deterministic|auto): {}",
337 crate::repr::py_repr(&strategy)
338 ));
339 }
340 if format != "json" && format != "tsv" {
341 return die(&format!(
342 "invalid --format (expected json|tsv): {}",
343 crate::repr::py_repr(&format)
344 ));
345 }
346
347 let sprint_num = if scope == "sprint" {
348 let Some(raw) = sprint.as_deref() else {
349 return die("--sprint is required when --scope sprint");
350 };
351 match raw.parse::<i32>() {
352 Ok(v) if v > 0 => Some(v),
353 _ => {
354 eprintln!(
355 "error: invalid --sprint (expected positive int): {}",
356 crate::repr::py_repr(raw)
357 );
358 return 2;
359 }
360 }
361 } else {
362 None
363 };
364
365 if let Some(value) = pr_grouping.as_deref()
366 && SplitPrGrouping::from_cli(value).is_none()
367 {
368 return die(&format!(
369 "invalid --pr-grouping (expected per-sprint|group): {}",
370 crate::repr::py_repr(value)
371 ));
372 }
373 if let Some(value) = default_pr_grouping.as_deref()
374 && SplitPrGrouping::from_cli(value).is_none()
375 {
376 return die(&format!(
377 "invalid --default-pr-grouping (expected per-sprint|group): {}",
378 crate::repr::py_repr(value)
379 ));
380 }
381
382 if strategy == "deterministic" {
383 let Some(grouping) = pr_grouping.as_deref() else {
384 return die("--strategy deterministic requires --pr-grouping <per-sprint|group>");
385 };
386 if default_pr_grouping.is_some() {
387 return die("--default-pr-grouping is only valid when --strategy auto");
388 }
389 if grouping == "group" && pr_group_entries.is_empty() {
390 return die(
391 "--pr-grouping group requires at least one --pr-group <task-or-plan-id>=<group> entry",
392 );
393 }
394 if grouping != "group" && !pr_group_entries.is_empty() {
395 return die("--pr-group can only be used when --pr-grouping group");
396 }
397 } else if pr_grouping.is_some() {
398 return die(
399 "--pr-grouping cannot be used with --strategy auto; use sprint metadata or --default-pr-grouping",
400 );
401 }
402
403 let repo_root = crate::repo_root::detect();
404 let display_path = file_arg.clone();
405 let read_path = resolve_repo_relative(&repo_root, Path::new(&file_arg));
406 if !read_path.is_file() {
407 eprintln!("error: plan file not found: {display_path}");
408 return 1;
409 }
410
411 let plan: Plan;
412 let parse_errors: Vec<String>;
413 match parse_plan_with_display(&read_path, &display_path) {
414 Ok((p, errs)) => {
415 plan = p;
416 parse_errors = errs;
417 }
418 Err(err) => {
419 eprintln!("error: {display_path}: {err}");
420 return 1;
421 }
422 }
423 if !parse_errors.is_empty() {
424 for err in parse_errors {
425 eprintln!("error: {display_path}: error: {err}");
426 }
427 return 1;
428 }
429
430 let split_scope = match scope.as_str() {
431 "plan" => SplitScope::Plan,
432 "sprint" => {
433 let Some(want) = sprint_num else {
434 return die("internal error: missing sprint number");
435 };
436 SplitScope::Sprint(want)
437 }
438 _ => return die("internal error: invalid scope"),
439 };
440 let Some(strategy_mode) = SplitPrStrategy::from_cli(&strategy) else {
441 return die("internal error: invalid strategy");
442 };
443
444 let selected_sprints = match select_sprints_for_scope(&plan, split_scope) {
445 Ok(sprints) => sprints,
446 Err(err) => {
447 eprintln!("error: {display_path}: {err}");
448 return 1;
449 }
450 };
451 let sprint_hints = sprint_hints(&selected_sprints);
452
453 let options = SplitPlanOptions {
454 pr_grouping: pr_grouping.as_deref().and_then(SplitPrGrouping::from_cli),
455 default_pr_grouping: default_pr_grouping
456 .as_deref()
457 .and_then(SplitPrGrouping::from_cli),
458 strategy: strategy_mode,
459 pr_group_entries,
460 owner_prefix,
461 branch_prefix,
462 worktree_prefix,
463 };
464 let resolved_grouping = match resolve_pr_grouping_by_sprint(&selected_sprints, &options) {
465 Ok(value) => value,
466 Err(err) => {
467 eprintln!("error: {err}");
468 return 1;
469 }
470 };
471 let split_records = match build_split_plan_records(&selected_sprints, &options) {
472 Ok(records) => records,
473 Err(err) => {
474 eprintln!("error: {err}");
475 return 1;
476 }
477 };
478 let explain_payload = if explain {
479 Some(build_explain_payload(
480 &split_records,
481 &sprint_hints,
482 &resolved_grouping,
483 ))
484 } else {
485 None
486 };
487
488 let out_records: Vec<OutputRecord> = split_records
489 .iter()
490 .map(OutputRecord::from_split_record)
491 .collect();
492
493 if format == "tsv" {
494 print_tsv(&out_records);
495 return 0;
496 }
497
498 let output = Output {
499 file: path_to_posix(&maybe_relativize(&read_path, &repo_root)),
500 scope: scope.clone(),
501 sprint: sprint_num,
502 pr_grouping: summarize_resolved_grouping(&resolved_grouping),
503 strategy,
504 records: out_records,
505 explain: explain_payload,
506 };
507 match serde_json::to_string(&output) {
508 Ok(json) => {
509 println!("{json}");
510 0
511 }
512 Err(err) => {
513 eprintln!("error: failed to encode JSON: {err}");
514 1
515 }
516 }
517}
518
519impl OutputRecord {
520 fn from_split_record(record: &SplitPlanRecord) -> Self {
521 Self {
522 task_id: record.task_id.clone(),
523 summary: record.summary.clone(),
524 pr_group: record.pr_group.clone(),
525 }
526 }
527}
528
529pub fn select_sprints_for_scope(plan: &Plan, scope: SplitScope) -> Result<Vec<Sprint>, String> {
530 let selected = match scope {
531 SplitScope::Plan => plan
532 .sprints
533 .iter()
534 .filter(|s| !s.tasks.is_empty())
535 .cloned()
536 .collect::<Vec<_>>(),
537 SplitScope::Sprint(want) => match plan.sprints.iter().find(|s| s.number == want) {
538 Some(sprint) if !sprint.tasks.is_empty() => vec![sprint.clone()],
539 Some(_) => return Err(format!("sprint {want} has no tasks")),
540 None => return Err(format!("sprint not found: {want}")),
541 },
542 };
543 if selected.is_empty() {
544 return Err("selected scope has no tasks".to_string());
545 }
546 Ok(selected)
547}
548
549pub fn build_split_plan_records(
550 selected_sprints: &[Sprint],
551 options: &SplitPlanOptions,
552) -> Result<Vec<SplitPlanRecord>, String> {
553 if selected_sprints.is_empty() {
554 return Err("selected scope has no tasks".to_string());
555 }
556
557 let sprint_hints = sprint_hints(selected_sprints);
558 let resolved_grouping = resolve_pr_grouping_by_sprint(selected_sprints, options)?;
559
560 let mut records: Vec<Record> = Vec::new();
561 for sprint in selected_sprints {
562 for (idx, task) in sprint.tasks.iter().enumerate() {
563 let ordinal = idx + 1;
564 let task_id = format!("S{}T{ordinal}", sprint.number);
565 let plan_task_id = task.id.trim().to_string();
566 let summary = normalize_spaces(if task.name.trim().is_empty() {
567 if plan_task_id.is_empty() {
568 format!("sprint-{}-task-{ordinal}", sprint.number)
569 } else {
570 plan_task_id.clone()
571 }
572 } else {
573 task.name.trim().to_string()
574 });
575 let deps: Vec<String> = task
576 .dependencies
577 .clone()
578 .unwrap_or_default()
579 .into_iter()
580 .map(|d| d.trim().to_string())
581 .filter(|d| !d.is_empty())
582 .filter(|d| !is_placeholder(d))
583 .collect();
584 let location_paths: Vec<String> = task
585 .location
586 .iter()
587 .map(|p| p.trim().to_string())
588 .filter(|p| !p.is_empty())
589 .filter(|p| !is_placeholder(p))
590 .collect();
591 let complexity = match task.complexity {
592 Some(value) if value > 0 => value,
593 _ => 5,
594 };
595
596 records.push(Record {
597 task_id,
598 plan_task_id,
599 sprint: sprint.number,
600 summary,
601 complexity,
602 location_paths,
603 dependency_keys: deps,
604 pr_group: String::new(),
605 });
606 }
607 }
608
609 if records.is_empty() {
610 return Err("selected scope has no tasks".to_string());
611 }
612
613 let mut group_assignments: HashMap<String, String> = HashMap::new();
614 let mut assignment_sources: Vec<String> = Vec::new();
615 for entry in &options.pr_group_entries {
616 let trimmed = entry.trim();
617 if trimmed.is_empty() {
618 continue;
619 }
620 let Some((raw_key, raw_group)) = trimmed.split_once('=') else {
621 return Err("--pr-group must use <task-or-plan-id>=<group> format".to_string());
622 };
623 let key = raw_key.trim();
624 let group = normalize_token(raw_group.trim(), "", 48);
625 if key.is_empty() || group.is_empty() {
626 return Err("--pr-group must include both task key and group".to_string());
627 }
628 assignment_sources.push(key.to_string());
629 group_assignments.insert(key.to_ascii_lowercase(), group);
630 }
631
632 if !assignment_sources.is_empty() {
633 let mut known: HashMap<String, bool> = HashMap::new();
634 for rec in &records {
635 known.insert(rec.task_id.to_ascii_lowercase(), true);
636 if !rec.plan_task_id.is_empty() {
637 known.insert(rec.plan_task_id.to_ascii_lowercase(), true);
638 }
639 }
640
641 let unknown: Vec<String> = assignment_sources
642 .iter()
643 .filter(|key| !known.contains_key(&key.to_ascii_lowercase()))
644 .cloned()
645 .collect();
646 if !unknown.is_empty() {
647 return Err(format!(
648 "--pr-group references unknown task keys: {}",
649 unknown
650 .iter()
651 .take(5)
652 .cloned()
653 .collect::<Vec<_>>()
654 .join(", ")
655 ));
656 }
657 }
658
659 let mut missing: Vec<String> = Vec::new();
660 let mut invalid_pin_targets: Vec<String> = Vec::new();
661 for rec in &mut records {
662 let grouping = resolved_grouping
663 .get(&rec.sprint)
664 .map(|value| value.grouping)
665 .ok_or_else(|| format!("missing resolved grouping for sprint {}", rec.sprint))?;
666
667 let mut pinned_group: Option<String> = None;
668 for key in [&rec.task_id, &rec.plan_task_id] {
669 if key.is_empty() {
670 continue;
671 }
672 if let Some(v) = group_assignments.get(&key.to_ascii_lowercase()) {
673 pinned_group = Some(v.to_string());
674 break;
675 }
676 }
677
678 match grouping {
679 SplitPrGrouping::PerSprint => {
680 if pinned_group.is_some() {
681 invalid_pin_targets.push(rec.task_id.clone());
682 }
683 rec.pr_group =
684 normalize_token(&format!("s{}", rec.sprint), &format!("s{}", rec.sprint), 48);
685 }
686 SplitPrGrouping::Group => {
687 rec.pr_group = pinned_group.unwrap_or_default();
688 if rec.pr_group.is_empty() {
689 missing.push(rec.task_id.clone());
690 }
691 }
692 }
693 }
694
695 if !invalid_pin_targets.is_empty() {
696 return Err(format!(
697 "--pr-group cannot target auto lanes resolved as per-sprint; offending tasks: {}",
698 invalid_pin_targets
699 .iter()
700 .take(8)
701 .cloned()
702 .collect::<Vec<_>>()
703 .join(", ")
704 ));
705 }
706
707 if options.strategy == SplitPrStrategy::Deterministic {
708 if !missing.is_empty() {
709 return Err(format!(
710 "--pr-grouping group requires explicit mapping for every task; missing: {}",
711 missing
712 .iter()
713 .take(8)
714 .cloned()
715 .collect::<Vec<_>>()
716 .join(", ")
717 ));
718 }
719 } else if !missing.is_empty() {
720 assign_auto_groups(&mut records, &sprint_hints);
721 }
722
723 let mut out: Vec<SplitPlanRecord> = Vec::new();
724 for rec in records {
725 out.push(SplitPlanRecord {
726 task_id: rec.task_id,
727 sprint: rec.sprint,
728 summary: rec.summary,
729 pr_group: rec.pr_group,
730 });
731 }
732
733 Ok(out)
734}
735
736pub fn resolve_pr_grouping_by_sprint(
737 selected_sprints: &[Sprint],
738 options: &SplitPlanOptions,
739) -> Result<HashMap<i32, ResolvedPrGrouping>, String> {
740 if selected_sprints.is_empty() {
741 return Err("selected scope has no tasks".to_string());
742 }
743
744 match options.strategy {
745 SplitPrStrategy::Deterministic => {
746 let Some(grouping) = options.pr_grouping else {
747 return Err(
748 "--strategy deterministic requires --pr-grouping <per-sprint|group>"
749 .to_string(),
750 );
751 };
752 if options.default_pr_grouping.is_some() {
753 return Err("--default-pr-grouping is only valid when --strategy auto".to_string());
754 }
755
756 let mut mismatches: Vec<String> = Vec::new();
757 let mut out: HashMap<i32, ResolvedPrGrouping> = HashMap::new();
758 for sprint in selected_sprints {
759 if let Some(intent) = sprint.metadata.pr_grouping_intent.as_deref()
760 && intent != grouping.as_str()
761 {
762 mismatches.push(format!(
763 "S{} metadata `PR grouping intent={intent}` conflicts with `--pr-grouping {}`",
764 sprint.number,
765 grouping.as_str()
766 ));
767 }
768 out.insert(
769 sprint.number,
770 ResolvedPrGrouping {
771 grouping,
772 source: ResolvedPrGroupingSource::CommandPrGrouping,
773 },
774 );
775 }
776
777 if mismatches.is_empty() {
778 Ok(out)
779 } else {
780 Err(format!(
781 "plan metadata/CLI grouping mismatch: {}",
782 mismatches.join(" | ")
783 ))
784 }
785 }
786 SplitPrStrategy::Auto => {
787 if options.pr_grouping.is_some() {
788 return Err(
789 "--pr-grouping cannot be used with --strategy auto; use sprint metadata or --default-pr-grouping"
790 .to_string(),
791 );
792 }
793
794 let mut out: HashMap<i32, ResolvedPrGrouping> = HashMap::new();
795 let mut missing: Vec<String> = Vec::new();
796 for sprint in selected_sprints {
797 let resolved = if let Some(intent) = sprint
798 .metadata
799 .pr_grouping_intent
800 .as_deref()
801 .and_then(SplitPrGrouping::from_cli)
802 {
803 Some(ResolvedPrGrouping {
804 grouping: intent,
805 source: ResolvedPrGroupingSource::PlanMetadata,
806 })
807 } else {
808 options
809 .default_pr_grouping
810 .map(|grouping| ResolvedPrGrouping {
811 grouping,
812 source: ResolvedPrGroupingSource::DefaultPrGrouping,
813 })
814 };
815
816 if let Some(value) = resolved {
817 out.insert(sprint.number, value);
818 } else {
819 missing.push(format!("S{}", sprint.number));
820 }
821 }
822
823 if missing.is_empty() {
824 Ok(out)
825 } else {
826 Err(format!(
827 "auto grouping requires `PR grouping intent` metadata for every selected sprint or --default-pr-grouping <per-sprint|group>; missing: {}",
828 missing.join(", ")
829 ))
830 }
831 }
832 }
833}
834
835#[derive(Debug)]
836struct AutoMergeCandidate {
837 i: usize,
838 j: usize,
839 score_key: i64,
840 key_a: String,
841 key_b: String,
842}
843
844#[derive(Debug)]
845struct ForcedMergeCandidate {
846 i: usize,
847 j: usize,
848 span: usize,
849 complexity: i32,
850 key_a: String,
851 key_b: String,
852}
853
854fn assign_auto_groups(records: &mut [Record], hints: &HashMap<i32, AutoSprintHint>) {
855 let mut sprint_to_indices: BTreeMap<i32, Vec<usize>> = BTreeMap::new();
856 for (idx, rec) in records.iter().enumerate() {
857 if rec.pr_group.is_empty() {
858 sprint_to_indices.entry(rec.sprint).or_default().push(idx);
859 }
860 }
861
862 for (sprint, indices) in sprint_to_indices {
863 let hint = hints.get(&sprint).cloned().unwrap_or_default();
864 let assignments = auto_groups_for_sprint(records, sprint, &indices, &hint);
865 for (idx, group) in assignments {
866 if let Some(rec) = records.get_mut(idx)
867 && rec.pr_group.is_empty()
868 {
869 rec.pr_group = group;
870 }
871 }
872 }
873}
874
875fn auto_groups_for_sprint(
876 records: &[Record],
877 sprint: i32,
878 indices: &[usize],
879 hint: &AutoSprintHint,
880) -> BTreeMap<usize, String> {
881 let mut lookup: HashMap<String, usize> = HashMap::new();
882 for idx in indices {
883 let rec = &records[*idx];
884 lookup.insert(rec.task_id.to_ascii_lowercase(), *idx);
885 if !rec.plan_task_id.is_empty() {
886 lookup.insert(rec.plan_task_id.to_ascii_lowercase(), *idx);
887 }
888 }
889
890 let mut deps: BTreeMap<usize, BTreeSet<usize>> = BTreeMap::new();
891 let mut paths: BTreeMap<usize, BTreeSet<String>> = BTreeMap::new();
892 for idx in indices {
893 let rec = &records[*idx];
894 let mut resolved_deps: BTreeSet<usize> = BTreeSet::new();
895 for dep in &rec.dependency_keys {
896 let dep_key = dep.trim().to_ascii_lowercase();
897 if dep_key.is_empty() {
898 continue;
899 }
900 if let Some(dep_idx) = lookup.get(&dep_key)
901 && dep_idx != idx
902 {
903 resolved_deps.insert(*dep_idx);
904 }
905 }
906 deps.insert(*idx, resolved_deps);
907
908 let normalized_paths: BTreeSet<String> = rec
909 .location_paths
910 .iter()
911 .map(|path| normalize_location_path(path))
912 .filter(|path| !path.is_empty())
913 .collect();
914 paths.insert(*idx, normalized_paths);
915 }
916
917 let batch_by_idx = compute_batch_index(records, indices, &deps);
918 let mut parent: HashMap<usize, usize> = indices.iter().copied().map(|idx| (idx, idx)).collect();
919
920 let mut by_batch: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
921 for idx in indices {
922 let batch = batch_by_idx.get(idx).copied().unwrap_or(0);
923 by_batch.entry(batch).or_default().push(*idx);
924 }
925
926 for members in by_batch.values_mut() {
927 members.sort_by_key(|idx| task_sort_key(records, *idx));
928
929 let mut path_to_members: BTreeMap<String, Vec<usize>> = BTreeMap::new();
930 for idx in members {
931 for path in paths.get(idx).into_iter().flatten() {
932 path_to_members.entry(path.clone()).or_default().push(*idx);
933 }
934 }
935 for overlap_members in path_to_members.values() {
936 if overlap_members.len() < 2 {
937 continue;
938 }
939 let first = overlap_members[0];
940 for other in overlap_members.iter().skip(1) {
941 uf_union(&mut parent, first, *other);
942 }
943 }
944 }
945
946 let mut grouped: BTreeMap<usize, BTreeSet<usize>> = BTreeMap::new();
947 for idx in indices {
948 let root = uf_find(&mut parent, *idx);
949 grouped.entry(root).or_default().insert(*idx);
950 }
951 let mut groups: Vec<BTreeSet<usize>> = grouped.into_values().collect();
952 let target_group_count = desired_auto_group_count(indices.len(), hint);
953
954 loop {
955 if let Some(target) = target_group_count
956 && groups.len() <= target
957 {
958 break;
959 }
960
961 let mut candidates: Vec<AutoMergeCandidate> = Vec::new();
962 for i in 0..groups.len() {
963 for j in (i + 1)..groups.len() {
964 let merged_complexity =
965 group_complexity(records, &groups[i]) + group_complexity(records, &groups[j]);
966 if merged_complexity > 20 {
967 continue;
968 }
969
970 let dep_cross = dependency_cross_edges(&deps, &groups[i], &groups[j]);
971 let overlap_paths = overlap_path_count(&paths, &groups[i], &groups[j]);
972 let min_group_size = groups[i].len().min(groups[j].len()).max(1) as f64;
973 let dep_affinity = ((dep_cross as f64) / min_group_size).min(1.0);
974 let ovl_affinity = ((overlap_paths as f64) / 2.0).min(1.0);
975 let size_fit = (1.0 - ((merged_complexity as f64 - 12.0).abs() / 12.0)).max(0.0);
976 let span = group_span(&batch_by_idx, &groups[i], &groups[j]);
977 let serial_penalty = ((span as f64 - 1.0).max(0.0)) / 3.0;
978 let oversize_penalty = ((merged_complexity as f64 - 20.0).max(0.0)) / 20.0;
979
980 let score = (0.45 * dep_affinity) + (0.35 * ovl_affinity) + (0.20 * size_fit)
981 - (0.25 * serial_penalty)
982 - (0.45 * oversize_penalty);
983 if score < 0.30 {
984 continue;
985 }
986
987 let mut key_a = group_min_task_key(records, &groups[i]);
988 let mut key_b = group_min_task_key(records, &groups[j]);
989 if key_b < key_a {
990 std::mem::swap(&mut key_a, &mut key_b);
991 }
992 candidates.push(AutoMergeCandidate {
993 i,
994 j,
995 score_key: (score * 1_000_000.0).round() as i64,
996 key_a,
997 key_b,
998 });
999 }
1000 }
1001
1002 if candidates.is_empty() {
1003 if let Some(target) = target_group_count
1004 && groups.len() > target
1005 && let Some(chosen) = pick_forced_merge(records, &batch_by_idx, &groups)
1006 {
1007 let mut merged = groups[chosen.i].clone();
1008 merged.extend(groups[chosen.j].iter().copied());
1009 groups[chosen.i] = merged;
1010 groups.remove(chosen.j);
1011 continue;
1012 }
1013 break;
1014 }
1015
1016 candidates.sort_by(|a, b| {
1017 b.score_key
1018 .cmp(&a.score_key)
1019 .then_with(|| a.key_a.cmp(&b.key_a))
1020 .then_with(|| a.key_b.cmp(&b.key_b))
1021 .then_with(|| a.i.cmp(&b.i))
1022 .then_with(|| a.j.cmp(&b.j))
1023 });
1024 let chosen = &candidates[0];
1025
1026 let mut merged = groups[chosen.i].clone();
1027 merged.extend(groups[chosen.j].iter().copied());
1028 groups[chosen.i] = merged;
1029 groups.remove(chosen.j);
1030 }
1031
1032 groups.sort_by(|a, b| {
1033 group_min_batch(&batch_by_idx, a)
1034 .cmp(&group_min_batch(&batch_by_idx, b))
1035 .then_with(|| group_min_task_key(records, a).cmp(&group_min_task_key(records, b)))
1036 });
1037
1038 let mut out: BTreeMap<usize, String> = BTreeMap::new();
1039 for (idx, group) in groups.iter().enumerate() {
1040 let fallback = format!("s{sprint}-auto-g{}", idx + 1);
1041 let group_key = normalize_token(&fallback, &fallback, 48);
1042 for member in group {
1043 out.insert(*member, group_key.clone());
1044 }
1045 }
1046 out
1047}
1048
1049fn compute_batch_index(
1050 records: &[Record],
1051 indices: &[usize],
1052 deps: &BTreeMap<usize, BTreeSet<usize>>,
1053) -> BTreeMap<usize, usize> {
1054 let mut in_deg: HashMap<usize, usize> = indices.iter().copied().map(|idx| (idx, 0)).collect();
1055 let mut reverse: HashMap<usize, BTreeSet<usize>> = indices
1056 .iter()
1057 .copied()
1058 .map(|idx| (idx, BTreeSet::new()))
1059 .collect();
1060
1061 for idx in indices {
1062 for dep in deps.get(idx).cloned().unwrap_or_default() {
1063 if !in_deg.contains_key(&dep) {
1064 continue;
1065 }
1066 if let Some(value) = in_deg.get_mut(idx) {
1067 *value += 1;
1068 }
1069 if let Some(children) = reverse.get_mut(&dep) {
1070 children.insert(*idx);
1071 }
1072 }
1073 }
1074
1075 let mut remaining: BTreeSet<usize> = indices.iter().copied().collect();
1076 let mut batch_by_idx: BTreeMap<usize, usize> = BTreeMap::new();
1077 let mut layer = 0usize;
1078 let mut ready: VecDeque<usize> = {
1079 let mut start: Vec<usize> = indices
1080 .iter()
1081 .copied()
1082 .filter(|idx| in_deg.get(idx).copied().unwrap_or(0) == 0)
1083 .collect();
1084 start.sort_by_key(|idx| task_sort_key(records, *idx));
1085 start.into_iter().collect()
1086 };
1087
1088 while !remaining.is_empty() {
1089 let mut batch_members: Vec<usize> = ready.drain(..).collect();
1090 batch_members.sort_by_key(|idx| task_sort_key(records, *idx));
1091
1092 if batch_members.is_empty() {
1093 let mut cycle_members: Vec<usize> = remaining.iter().copied().collect();
1094 cycle_members.sort_by_key(|idx| task_sort_key(records, *idx));
1095 for idx in cycle_members {
1096 remaining.remove(&idx);
1097 batch_by_idx.insert(idx, layer);
1098 }
1099 break;
1100 }
1101
1102 for idx in &batch_members {
1103 remaining.remove(idx);
1104 batch_by_idx.insert(*idx, layer);
1105 }
1106
1107 let mut next: Vec<usize> = Vec::new();
1108 for idx in batch_members {
1109 for child in reverse.get(&idx).cloned().unwrap_or_default() {
1110 if let Some(value) = in_deg.get_mut(&child) {
1111 *value = value.saturating_sub(1);
1112 if *value == 0 && remaining.contains(&child) {
1113 next.push(child);
1114 }
1115 }
1116 }
1117 }
1118 next.sort_by_key(|idx| task_sort_key(records, *idx));
1119 next.dedup();
1120 ready.extend(next);
1121 layer += 1;
1122 }
1123
1124 for idx in indices {
1125 batch_by_idx.entry(*idx).or_insert(0);
1126 }
1127 batch_by_idx
1128}
1129
1130fn task_sort_key(records: &[Record], idx: usize) -> (String, String) {
1131 let rec = &records[idx];
1132 let primary = if rec.plan_task_id.trim().is_empty() {
1133 rec.task_id.to_ascii_lowercase()
1134 } else {
1135 rec.plan_task_id.to_ascii_lowercase()
1136 };
1137 (primary, rec.task_id.to_ascii_lowercase())
1138}
1139
1140fn normalize_location_path(path: &str) -> String {
1141 path.split_whitespace()
1142 .collect::<Vec<_>>()
1143 .join(" ")
1144 .to_ascii_lowercase()
1145}
1146
1147fn group_complexity(records: &[Record], group: &BTreeSet<usize>) -> i32 {
1148 group
1149 .iter()
1150 .map(|idx| records[*idx].complexity.max(1))
1151 .sum::<i32>()
1152}
1153
1154fn group_min_task_key(records: &[Record], group: &BTreeSet<usize>) -> String {
1155 group
1156 .iter()
1157 .map(|idx| task_sort_key(records, *idx).0)
1158 .min()
1159 .unwrap_or_default()
1160}
1161
1162fn group_min_batch(batch_by_idx: &BTreeMap<usize, usize>, group: &BTreeSet<usize>) -> usize {
1163 group
1164 .iter()
1165 .filter_map(|idx| batch_by_idx.get(idx).copied())
1166 .min()
1167 .unwrap_or(0)
1168}
1169
1170fn group_span(
1171 batch_by_idx: &BTreeMap<usize, usize>,
1172 left: &BTreeSet<usize>,
1173 right: &BTreeSet<usize>,
1174) -> usize {
1175 let mut min_batch = usize::MAX;
1176 let mut max_batch = 0usize;
1177 for idx in left.union(right) {
1178 let batch = batch_by_idx.get(idx).copied().unwrap_or(0);
1179 min_batch = min_batch.min(batch);
1180 max_batch = max_batch.max(batch);
1181 }
1182 if min_batch == usize::MAX {
1183 0
1184 } else {
1185 max_batch.saturating_sub(min_batch)
1186 }
1187}
1188
1189fn dependency_cross_edges(
1190 deps: &BTreeMap<usize, BTreeSet<usize>>,
1191 left: &BTreeSet<usize>,
1192 right: &BTreeSet<usize>,
1193) -> usize {
1194 let mut count = 0usize;
1195 for src in left {
1196 if let Some(edges) = deps.get(src) {
1197 count += edges.iter().filter(|dep| right.contains(dep)).count();
1198 }
1199 }
1200 for src in right {
1201 if let Some(edges) = deps.get(src) {
1202 count += edges.iter().filter(|dep| left.contains(dep)).count();
1203 }
1204 }
1205 count
1206}
1207
1208fn overlap_path_count(
1209 paths: &BTreeMap<usize, BTreeSet<String>>,
1210 left: &BTreeSet<usize>,
1211 right: &BTreeSet<usize>,
1212) -> usize {
1213 let mut left_paths: BTreeSet<String> = BTreeSet::new();
1214 let mut right_paths: BTreeSet<String> = BTreeSet::new();
1215 for idx in left {
1216 for path in paths.get(idx).into_iter().flatten() {
1217 left_paths.insert(path.clone());
1218 }
1219 }
1220 for idx in right {
1221 for path in paths.get(idx).into_iter().flatten() {
1222 right_paths.insert(path.clone());
1223 }
1224 }
1225 left_paths.intersection(&right_paths).count()
1226}
1227
1228fn desired_auto_group_count(max_groups: usize, hint: &AutoSprintHint) -> Option<usize> {
1229 if max_groups == 0 {
1230 return None;
1231 }
1232 let preferred = hint
1233 .target_parallel_width
1234 .or_else(|| {
1235 if hint.execution_profile.as_deref() == Some("serial") {
1236 Some(1usize)
1237 } else {
1238 None
1239 }
1240 })
1241 .or_else(|| {
1242 if hint.pr_grouping_intent == Some(SplitPrGrouping::PerSprint) {
1243 Some(1usize)
1244 } else {
1245 None
1246 }
1247 })?;
1248 Some(preferred.clamp(1, max_groups))
1249}
1250
1251fn pick_forced_merge(
1252 records: &[Record],
1253 batch_by_idx: &BTreeMap<usize, usize>,
1254 groups: &[BTreeSet<usize>],
1255) -> Option<ForcedMergeCandidate> {
1256 let mut chosen: Option<ForcedMergeCandidate> = None;
1257 for i in 0..groups.len() {
1258 for j in (i + 1)..groups.len() {
1259 let mut key_a = group_min_task_key(records, &groups[i]);
1260 let mut key_b = group_min_task_key(records, &groups[j]);
1261 if key_b < key_a {
1262 std::mem::swap(&mut key_a, &mut key_b);
1263 }
1264 let candidate = ForcedMergeCandidate {
1265 i,
1266 j,
1267 span: group_span(batch_by_idx, &groups[i], &groups[j]),
1268 complexity: group_complexity(records, &groups[i])
1269 + group_complexity(records, &groups[j]),
1270 key_a,
1271 key_b,
1272 };
1273 let replace = match &chosen {
1274 None => true,
1275 Some(best) => {
1276 (
1277 candidate.span,
1278 candidate.complexity,
1279 &candidate.key_a,
1280 &candidate.key_b,
1281 candidate.i,
1282 candidate.j,
1283 ) < (
1284 best.span,
1285 best.complexity,
1286 &best.key_a,
1287 &best.key_b,
1288 best.i,
1289 best.j,
1290 )
1291 }
1292 };
1293 if replace {
1294 chosen = Some(candidate);
1295 }
1296 }
1297 }
1298 chosen
1299}
1300
1301fn sprint_hints(selected_sprints: &[Sprint]) -> HashMap<i32, AutoSprintHint> {
1302 let mut hints: HashMap<i32, AutoSprintHint> = HashMap::new();
1303 for sprint in selected_sprints {
1304 let pr_grouping_intent = sprint
1305 .metadata
1306 .pr_grouping_intent
1307 .as_deref()
1308 .and_then(SplitPrGrouping::from_cli);
1309 let execution_profile = sprint.metadata.execution_profile.clone();
1310 let target_parallel_width = sprint.metadata.parallel_width;
1311 hints.insert(
1312 sprint.number,
1313 AutoSprintHint {
1314 pr_grouping_intent,
1315 execution_profile,
1316 target_parallel_width,
1317 },
1318 );
1319 }
1320 hints
1321}
1322
1323fn build_explain_payload(
1324 records: &[SplitPlanRecord],
1325 hints: &HashMap<i32, AutoSprintHint>,
1326 resolved_grouping: &HashMap<i32, ResolvedPrGrouping>,
1327) -> Vec<ExplainSprint> {
1328 let mut grouped: BTreeMap<i32, BTreeMap<String, Vec<String>>> = BTreeMap::new();
1329 for record in records {
1330 grouped
1331 .entry(record.sprint)
1332 .or_default()
1333 .entry(record.pr_group.clone())
1334 .or_default()
1335 .push(record.task_id.clone());
1336 }
1337
1338 let mut out: Vec<ExplainSprint> = Vec::new();
1339 for (sprint, per_group) in grouped {
1340 let hint = hints.get(&sprint).cloned().unwrap_or_default();
1341 let groups = per_group
1342 .into_iter()
1343 .map(|(pr_group, task_ids)| {
1344 let anchor = task_ids.first().cloned().unwrap_or_default();
1345 ExplainGroup {
1346 pr_group,
1347 task_ids,
1348 anchor,
1349 }
1350 })
1351 .collect::<Vec<_>>();
1352 out.push(ExplainSprint {
1353 sprint,
1354 target_parallel_width: hint.target_parallel_width,
1355 execution_profile: hint.execution_profile,
1356 pr_grouping_intent: resolved_grouping
1357 .get(&sprint)
1358 .map(|value| value.grouping.as_str().to_string()),
1359 pr_grouping_intent_source: resolved_grouping
1360 .get(&sprint)
1361 .map(|value| value.source.as_str().to_string()),
1362 groups,
1363 });
1364 }
1365 out
1366}
1367
1368fn summarize_resolved_grouping(resolved_grouping: &HashMap<i32, ResolvedPrGrouping>) -> String {
1369 let unique = resolved_grouping
1370 .values()
1371 .map(|value| value.grouping.as_str())
1372 .collect::<BTreeSet<_>>();
1373 if unique.len() == 1 {
1374 unique
1375 .iter()
1376 .next()
1377 .map(|value| (*value).to_string())
1378 .unwrap_or_else(|| "mixed".to_string())
1379 } else {
1380 "mixed".to_string()
1381 }
1382}
1383
1384fn split_value_arg(raw: &str) -> (&str, Option<&str>) {
1385 if raw.starts_with("--")
1386 && let Some((flag, value)) = raw.split_once('=')
1387 && !flag.is_empty()
1388 {
1389 return (flag, Some(value));
1390 }
1391 (raw, None)
1392}
1393
1394fn consume_option_value(
1395 args: &[String],
1396 idx: usize,
1397 inline_value: Option<&str>,
1398 _flag: &str,
1399) -> Result<(String, usize), ()> {
1400 match inline_value {
1401 Some(value) => {
1402 if value.is_empty() {
1403 Err(())
1404 } else {
1405 Ok((value.to_string(), idx + 1))
1406 }
1407 }
1408 None => {
1409 let Some(value) = args.get(idx + 1) else {
1410 return Err(());
1411 };
1412 if value.is_empty() {
1413 Err(())
1414 } else {
1415 Ok((value.to_string(), idx + 2))
1416 }
1417 }
1418 }
1419}
1420
1421fn uf_find(parent: &mut HashMap<usize, usize>, node: usize) -> usize {
1422 let parent_node = parent.get(&node).copied().unwrap_or(node);
1423 if parent_node == node {
1424 return node;
1425 }
1426 let root = uf_find(parent, parent_node);
1427 parent.insert(node, root);
1428 root
1429}
1430
1431fn uf_union(parent: &mut HashMap<usize, usize>, left: usize, right: usize) {
1432 let left_root = uf_find(parent, left);
1433 let right_root = uf_find(parent, right);
1434 if left_root == right_root {
1435 return;
1436 }
1437 if left_root < right_root {
1438 parent.insert(right_root, left_root);
1439 } else {
1440 parent.insert(left_root, right_root);
1441 }
1442}
1443
1444fn print_tsv(records: &[OutputRecord]) {
1445 println!("# task_id\tsummary\tpr_group");
1446 for rec in records {
1447 println!(
1448 "{}\t{}\t{}",
1449 rec.task_id.replace('\t', " "),
1450 rec.summary.replace('\t', " "),
1451 rec.pr_group.replace('\t', " "),
1452 );
1453 }
1454}
1455
1456fn print_usage() {
1457 let _ = std::io::stderr().write_all(USAGE.as_bytes());
1458}
1459
1460fn die(msg: &str) -> i32 {
1461 eprintln!("split-prs: {msg}");
1462 2
1463}
1464
1465fn resolve_repo_relative(repo_root: &Path, path: &Path) -> PathBuf {
1466 if path.is_absolute() {
1467 return path.to_path_buf();
1468 }
1469 repo_root.join(path)
1470}
1471
1472fn maybe_relativize(path: &Path, repo_root: &Path) -> PathBuf {
1473 let Ok(path_abs) = path.canonicalize() else {
1474 return path.to_path_buf();
1475 };
1476 let Ok(root_abs) = repo_root.canonicalize() else {
1477 return path_abs;
1478 };
1479 match path_abs.strip_prefix(&root_abs) {
1480 Ok(rel) => rel.to_path_buf(),
1481 Err(_) => path_abs,
1482 }
1483}
1484
1485fn path_to_posix(path: &Path) -> String {
1486 path.to_string_lossy()
1487 .replace(std::path::MAIN_SEPARATOR, "/")
1488}
1489
1490fn normalize_spaces(value: String) -> String {
1491 let joined = value.split_whitespace().collect::<Vec<_>>().join(" ");
1492 if joined.is_empty() {
1493 String::from("task")
1494 } else {
1495 joined
1496 }
1497}
1498
1499fn normalize_token(value: &str, fallback: &str, max_len: usize) -> String {
1500 let mut out = String::new();
1501 let mut last_dash = false;
1502 for ch in value.chars().flat_map(char::to_lowercase) {
1503 if ch.is_ascii_alphanumeric() {
1504 out.push(ch);
1505 last_dash = false;
1506 } else if !last_dash {
1507 out.push('-');
1508 last_dash = true;
1509 }
1510 }
1511 let normalized = out.trim_matches('-').to_string();
1512 let mut final_token = if normalized.is_empty() {
1513 fallback.to_string()
1514 } else {
1515 normalized
1516 };
1517 if final_token.len() > max_len {
1518 final_token.truncate(max_len);
1519 final_token = final_token.trim_matches('-').to_string();
1520 }
1521 final_token
1522}
1523
1524fn is_placeholder(value: &str) -> bool {
1525 let token = value.trim().to_ascii_lowercase();
1526 if matches!(token.as_str(), "" | "-" | "none" | "n/a" | "na" | "...") {
1527 return true;
1528 }
1529 if token.starts_with('<') && token.ends_with('>') {
1530 return true;
1531 }
1532 token.contains("task ids")
1533}
1534
1535#[cfg(test)]
1536mod tests {
1537 use super::{is_placeholder, normalize_token};
1538 use pretty_assertions::assert_eq;
1539
1540 #[test]
1541 fn normalize_token_collapses_non_alnum_and_limits_length() {
1542 assert_eq!(
1543 normalize_token("Sprint 2 :: Shared Pair", "fallback", 20),
1544 "sprint-2-shared-pair"
1545 );
1546 assert_eq!(normalize_token("!!!!", "fallback-value", 8), "fallback");
1547 }
1548
1549 #[test]
1550 fn placeholder_rules_cover_common_plan_values() {
1551 assert!(is_placeholder("none"));
1552 assert!(is_placeholder("<task ids>"));
1553 assert!(is_placeholder("Task IDs here"));
1554 assert!(!is_placeholder("Task 1.1"));
1555 }
1556}