Skip to main content

plan_tooling/
split_prs.rs

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}