1use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
22
23use objects::object::ThreadName;
24
25use crate::{Repository, Result, thread_model::ThreadRecord, thread_storage::ThreadManager};
26
27#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
31pub struct StackNode {
32 pub name: String,
33 pub children: Vec<StackNode>,
34}
35
36impl StackNode {
37 pub fn size(&self) -> usize {
39 1 + self.children.iter().map(StackNode::size).sum::<usize>()
40 }
41
42 pub fn depth(&self) -> usize {
45 self.children
46 .iter()
47 .map(|c| 1 + c.depth())
48 .max()
49 .unwrap_or(0)
50 }
51
52 pub fn iter_names(&self) -> impl Iterator<Item = &str> {
54 let mut stack: Vec<&StackNode> = vec![self];
55 std::iter::from_fn(move || {
56 let next = stack.pop()?;
57 for child in next.children.iter().rev() {
60 stack.push(child);
61 }
62 Some(next.name.as_str())
63 })
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
69pub struct ThreadStack {
70 pub root: StackNode,
71}
72
73impl ThreadStack {
74 pub fn member_count(&self) -> usize {
75 self.root.size()
76 }
77
78 pub fn depth(&self) -> usize {
79 self.root.depth()
80 }
81
82 pub fn root_name(&self) -> &str {
83 &self.root.name
84 }
85
86 pub fn member_names(&self) -> Vec<&str> {
88 self.root.iter_names().collect()
89 }
90
91 pub fn contains(&self, thread_name: &str) -> bool {
93 self.root.iter_names().any(|name| name == thread_name)
94 }
95}
96
97pub fn compute_stacks(records: &[ThreadRecord]) -> Vec<ThreadStack> {
103 let by_name: BTreeMap<&str, &ThreadRecord> =
104 records.iter().map(|r| (r.thread.as_str(), r)).collect();
105 let children_of = children_index(records, &by_name);
106
107 let roots: Vec<&str> = by_name
108 .keys()
109 .filter(|name| {
110 let record = by_name[*name];
111 match record.parent_thread.as_deref() {
112 None => true,
113 Some(parent) => !by_name.contains_key(parent),
114 }
115 })
116 .copied()
117 .collect();
118
119 let mut stacks = Vec::with_capacity(roots.len());
120 for root in roots {
121 let mut visited = HashSet::new();
122 if let Some(node) = build_node(root, &children_of, &mut visited) {
123 stacks.push(ThreadStack { root: node });
124 }
125 }
126 stacks
127}
128
129pub fn stack_for(records: &[ThreadRecord], thread_name: &str) -> Option<ThreadStack> {
135 let by_name: BTreeMap<&str, &ThreadRecord> =
136 records.iter().map(|r| (r.thread.as_str(), r)).collect();
137 by_name.get(thread_name)?;
138
139 let mut cursor: &str = thread_name;
140 let mut seen: HashSet<&str> = HashSet::new();
141 loop {
142 if !seen.insert(cursor) {
143 return None;
145 }
146 let record = match by_name.get(cursor) {
147 Some(r) => *r,
148 None => break,
149 };
150 match record.parent_thread.as_deref() {
151 Some(name) if by_name.contains_key(name) => {
152 cursor = name;
153 }
154 _ => break,
155 }
156 }
157 let root_name = cursor;
158
159 let children_of = children_index(records, &by_name);
160 let mut visited = HashSet::new();
161 let node = build_node(root_name, &children_of, &mut visited)?;
162 Some(ThreadStack { root: node })
163}
164
165fn children_index<'a>(
166 records: &'a [ThreadRecord],
167 by_name: &BTreeMap<&'a str, &'a ThreadRecord>,
168) -> HashMap<&'a str, Vec<&'a str>> {
169 let mut children_of: HashMap<&str, Vec<&str>> = HashMap::new();
170 for record in records {
171 if let Some(parent) = record.parent_thread.as_deref()
172 && by_name.contains_key(parent)
173 {
174 children_of
175 .entry(parent)
176 .or_default()
177 .push(record.thread.as_str());
178 }
179 }
180 for kids in children_of.values_mut() {
181 kids.sort();
182 }
183 children_of
184}
185
186fn build_node<'a>(
187 name: &'a str,
188 children_of: &HashMap<&'a str, Vec<&'a str>>,
189 visited: &mut HashSet<&'a str>,
190) -> Option<StackNode> {
191 if !visited.insert(name) {
192 return None;
194 }
195 let children = children_of
196 .get(name)
197 .map(|kids| {
198 kids.iter()
199 .filter_map(|child| build_node(child, children_of, visited))
200 .collect()
201 })
202 .unwrap_or_default();
203 Some(StackNode {
204 name: name.to_string(),
205 children,
206 })
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
213pub struct StackRebaseStep {
214 pub thread: String,
216 pub current_state: String,
218 pub old_base: String,
221 pub new_base: String,
225 pub parent_thread: Option<String>,
227 pub depth: usize,
229}
230
231#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
233pub struct StackRebasePlan {
234 pub root_thread: String,
235 pub onto: String,
236 pub steps: Vec<StackRebaseStep>,
237}
238
239impl StackRebasePlan {
240 pub fn step_count(&self) -> usize {
241 self.steps.len()
242 }
243
244 pub fn is_no_op(&self) -> bool {
247 self.steps
248 .iter()
249 .all(|step| step.current_state == step.new_base)
250 }
251}
252
253#[derive(Debug, thiserror::Error, PartialEq, Eq)]
254pub enum PlanRebaseError {
255 #[error("thread '{0}' not found")]
256 ThreadNotFound(String),
257 #[error("thread '{0}' is not a stack root (has parent '{1}'); rebase the root instead")]
258 NotARoot(String, String),
259}
260
261pub fn plan_stack_rebase<F>(
272 records: &[ThreadRecord],
273 root_thread: &str,
274 onto: &str,
275 mut current_state_for: F,
276) -> std::result::Result<StackRebasePlan, PlanRebaseError>
277where
278 F: FnMut(&str) -> String,
279{
280 let by_name: BTreeMap<&str, &ThreadRecord> =
281 records.iter().map(|r| (r.thread.as_str(), r)).collect();
282 let root_record = by_name
283 .get(root_thread)
284 .ok_or_else(|| PlanRebaseError::ThreadNotFound(root_thread.to_string()))?;
285
286 if let Some(parent) = root_record.parent_thread.as_deref()
287 && by_name.contains_key(parent)
288 {
289 return Err(PlanRebaseError::NotARoot(
290 root_thread.to_string(),
291 parent.to_string(),
292 ));
293 }
294
295 let children_of = children_index(records, &by_name);
296
297 let mut steps = Vec::new();
299 let mut queue: VecDeque<(&str, usize, String)> = VecDeque::new();
300 queue.push_back((root_thread, 0, onto.to_string()));
301
302 while let Some((thread_name, depth, new_base)) = queue.pop_front() {
303 let record = by_name[thread_name];
304 let current = current_state_for(thread_name);
305 let projected_tip = if current == record.base_state {
311 new_base.clone()
312 } else {
313 format!("{thread_name}@projected")
314 };
315
316 let parent_thread = if depth == 0 {
323 None
324 } else {
325 record.parent_thread.clone()
326 };
327
328 steps.push(StackRebaseStep {
329 thread: thread_name.to_string(),
330 current_state: current,
331 old_base: record.base_state.clone(),
332 new_base: new_base.clone(),
333 parent_thread,
334 depth,
335 });
336
337 if let Some(kids) = children_of.get(thread_name) {
338 for child in kids {
339 queue.push_back((child, depth + 1, projected_tip.clone()));
340 }
341 }
342 }
343
344 Ok(StackRebasePlan {
345 root_thread: root_thread.to_string(),
346 onto: onto.to_string(),
347 steps,
348 })
349}
350
351impl Repository {
354 pub fn compute_thread_stacks(&self) -> Result<Vec<ThreadStack>> {
358 let records = ThreadManager::new(self.heddle_dir()).list_records()?;
359 Ok(compute_stacks(&records))
360 }
361
362 pub fn thread_stack_for(&self, thread_name: &str) -> Result<Option<ThreadStack>> {
366 let records = ThreadManager::new(self.heddle_dir()).list_records()?;
367 Ok(stack_for(&records, thread_name))
368 }
369
370 pub fn plan_thread_stack_rebase(
379 &self,
380 root_thread: &str,
381 onto: &str,
382 ) -> Result<std::result::Result<StackRebasePlan, PlanRebaseError>> {
383 let records = ThreadManager::new(self.heddle_dir()).list_records()?;
384 let refs = self.refs();
391 let mut tips: HashMap<String, String> = HashMap::new();
392 for record in &records {
393 if let Some(id) = refs.get_thread(&ThreadName::from(record.thread.as_str()))? {
394 tips.insert(record.thread.clone(), id.to_string());
395 }
396 }
397 let plan = plan_stack_rebase(&records, root_thread, onto, |name| {
398 tips.get(name).cloned().unwrap_or_else(|| name.to_string())
399 });
400 Ok(plan)
401 }
402}
403
404#[cfg(test)]
405mod tests {
406 use chrono::Utc;
407
408 use super::*;
409 use crate::thread_model::{ThreadFreshness, ThreadMode, ThreadState};
410
411 fn record(name: &str, parent: Option<&str>) -> ThreadRecord {
412 ThreadRecord {
413 id: format!("id-{name}"),
414 thread: name.to_string(),
415 target_thread: None,
416 parent_thread: parent.map(str::to_string),
417 mode: ThreadMode::Materialized,
418 state: ThreadState::Active,
419 base_state: "base".to_string(),
420 base_root: "root".to_string(),
421 current_state: None,
422 merged_state: None,
423 task: None,
424 changed_paths: Vec::new(),
425 impact_categories: Vec::new(),
426 heavy_impact_paths: Vec::new(),
427 promotion_suggested: false,
428 freshness: ThreadFreshness::Unknown,
429 verification_summary: Default::default(),
430 confidence_summary: Default::default(),
431 integration_policy_result: Default::default(),
432 created_at: Utc::now(),
433 updated_at: Utc::now(),
434 ephemeral: None,
435 auto: false,
436 shared_target_dir: None,
437 }
438 }
439
440 fn record_at(name: &str, parent: Option<&str>, base: &str) -> ThreadRecord {
441 let mut r = record(name, parent);
442 r.base_state = base.to_string();
443 r
444 }
445
446 fn current_states<'a>(map: &'a HashMap<&'a str, &'a str>) -> impl FnMut(&str) -> String + 'a {
447 move |name: &str| map.get(name).copied().unwrap_or(name).to_string()
448 }
449
450 #[test]
451 fn empty_input_yields_no_stacks() {
452 assert!(compute_stacks(&[]).is_empty());
453 }
454
455 #[test]
456 fn single_orphan_thread_is_its_own_stack() {
457 let records = vec![record("feature-a", None)];
458 let stacks = compute_stacks(&records);
459 assert_eq!(stacks.len(), 1);
460 assert_eq!(stacks[0].root_name(), "feature-a");
461 assert_eq!(stacks[0].member_count(), 1);
462 assert_eq!(stacks[0].depth(), 0);
463 }
464
465 #[test]
466 fn linear_three_deep_stack() {
467 let records = vec![
468 record("feature-a", None),
469 record("feature-b", Some("feature-a")),
470 record("feature-c", Some("feature-b")),
471 ];
472 let stacks = compute_stacks(&records);
473 assert_eq!(stacks.len(), 1);
474 let stack = &stacks[0];
475 assert_eq!(stack.root_name(), "feature-a");
476 assert_eq!(stack.member_count(), 3);
477 assert_eq!(stack.depth(), 2);
478 assert_eq!(
479 stack.member_names(),
480 vec!["feature-a", "feature-b", "feature-c"]
481 );
482 }
483
484 #[test]
485 fn parent_outside_list_promotes_child_to_root() {
486 let records = vec![
487 record("feature-a", Some("main")),
488 record("feature-b", Some("feature-a")),
489 ];
490 let stacks = compute_stacks(&records);
491 assert_eq!(stacks.len(), 1);
492 assert_eq!(stacks[0].root_name(), "feature-a");
493 assert_eq!(stacks[0].member_count(), 2);
494 }
495
496 #[test]
497 fn cycle_does_not_panic_or_loop_forever() {
498 let records = vec![record("a", Some("b")), record("b", Some("a"))];
499 let stacks = compute_stacks(&records);
500 assert!(stacks.is_empty());
501 }
502
503 #[test]
504 fn stack_for_walks_up_to_root_then_returns_full_tree() {
505 let records = vec![
506 record("feature-a", None),
507 record("feature-b", Some("feature-a")),
508 record("feature-c", Some("feature-b")),
509 record("feature-d", Some("feature-a")),
510 ];
511 let from_root = stack_for(&records, "feature-a").unwrap();
512 let from_leaf = stack_for(&records, "feature-c").unwrap();
513 let from_sibling = stack_for(&records, "feature-d").unwrap();
514 assert_eq!(from_root, from_leaf);
515 assert_eq!(from_root, from_sibling);
516 assert_eq!(from_root.member_count(), 4);
517 }
518
519 #[test]
520 fn stack_for_returns_none_for_unknown_thread() {
521 assert!(stack_for(&[record("feature-a", None)], "missing").is_none());
522 }
523
524 #[test]
525 fn plan_rejects_unknown_root() {
526 let records: Vec<ThreadRecord> = Vec::new();
527 let err =
528 plan_stack_rebase(&records, "missing", "new-base", |_| String::new()).unwrap_err();
529 assert_eq!(err, PlanRebaseError::ThreadNotFound("missing".into()));
530 }
531
532 #[test]
533 fn plan_rejects_non_root_when_parent_present() {
534 let records = vec![
535 record_at("feature-a", None, "main-1"),
536 record_at("feature-b", Some("feature-a"), "feature-a-tip"),
537 ];
538 let err =
539 plan_stack_rebase(&records, "feature-b", "main-2", |n| n.to_string()).unwrap_err();
540 assert_eq!(
541 err,
542 PlanRebaseError::NotARoot("feature-b".into(), "feature-a".into())
543 );
544 }
545
546 #[test]
547 fn plan_for_single_orphan_yields_one_step() {
548 let records = vec![record_at("feature-a", None, "main-1")];
549 let mut current = HashMap::new();
550 current.insert("feature-a", "feature-a-tip");
551 let plan =
552 plan_stack_rebase(&records, "feature-a", "main-2", current_states(¤t)).unwrap();
553 assert_eq!(plan.step_count(), 1);
554 let step = &plan.steps[0];
555 assert_eq!(step.thread, "feature-a");
556 assert_eq!(step.current_state, "feature-a-tip");
557 assert_eq!(step.old_base, "main-1");
558 assert_eq!(step.new_base, "main-2");
559 assert_eq!(step.depth, 0);
560 assert_eq!(step.parent_thread, None);
561 assert!(!plan.is_no_op());
562 }
563
564 #[test]
565 fn plan_orders_descendants_after_parents_bfs() {
566 let records = vec![
568 record_at("a", None, "main-1"),
569 record_at("b", Some("a"), "a-tip"),
570 record_at("c", Some("b"), "b-tip"),
571 record_at("d", Some("a"), "a-tip"),
572 ];
573 let mut current = HashMap::new();
574 current.insert("a", "a-tip");
575 current.insert("b", "b-tip");
576 current.insert("c", "c-tip");
577 current.insert("d", "d-tip");
578 let plan = plan_stack_rebase(&records, "a", "main-2", current_states(¤t)).unwrap();
579 let order: Vec<&str> = plan.steps.iter().map(|s| s.thread.as_str()).collect();
580 assert_eq!(order, vec!["a", "b", "d", "c"]);
581
582 let by_thread: HashMap<&str, &StackRebaseStep> =
583 plan.steps.iter().map(|s| (s.thread.as_str(), s)).collect();
584 assert_eq!(by_thread["a"].new_base, "main-2");
585 assert_eq!(by_thread["b"].new_base, "a@projected");
586 assert_eq!(by_thread["d"].new_base, "a@projected");
587 assert_eq!(by_thread["c"].new_base, "b@projected");
588 }
589
590 #[test]
591 fn plan_root_step_parent_thread_is_none_even_when_record_names_external_parent() {
592 let records = vec![
598 record_at("feat-a", Some("main"), "main-1"),
599 record_at("feat-b", Some("feat-a"), "feat-a-tip"),
600 ];
601 let mut current = HashMap::new();
602 current.insert("feat-a", "feat-a-tip");
603 current.insert("feat-b", "feat-b-tip");
604 let plan =
605 plan_stack_rebase(&records, "feat-a", "main-2", current_states(¤t)).unwrap();
606 let by_thread: HashMap<&str, &StackRebaseStep> =
607 plan.steps.iter().map(|s| (s.thread.as_str(), s)).collect();
608 assert_eq!(
609 by_thread["feat-a"].parent_thread, None,
610 "root step must erase the record's external parent"
611 );
612 assert_eq!(
613 by_thread["feat-b"].parent_thread,
614 Some("feat-a".to_string()),
615 "descendant step must retain its in-stack parent"
616 );
617 }
618
619 #[test]
620 fn plan_reports_no_op_when_everything_already_aligned() {
621 let records = vec![record_at("a", None, "main-2")];
622 let mut current = HashMap::new();
623 current.insert("a", "main-2");
624 let plan = plan_stack_rebase(&records, "a", "main-2", current_states(¤t)).unwrap();
625 assert!(plan.is_no_op());
626 }
627}