use std::collections::{HashMap, HashSet};
use crate::session::SessionEntry;
#[derive(Debug, Clone)]
pub struct BranchInfo {
pub tip_id: String,
pub depth: usize,
pub entry_count: usize,
pub timestamp: String,
pub summary: Option<String>,
pub fork_point: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SessionTree {
branches: Vec<BranchInfo>,
leaf_tip: Option<String>,
entries_by_id: HashMap<String, EntryMeta>,
}
#[derive(Debug, Clone)]
struct EntryMeta {
parent_id: Option<String>,
timestamp: String,
summary: Option<String>,
}
impl SessionTree {
pub fn from_entries(entries: &[SessionEntry]) -> Self {
let mut meta_by_id: HashMap<String, EntryMeta> = HashMap::new();
let mut children: HashMap<String, Vec<String>> = HashMap::new();
let mut last_leaf_tip: Option<String> = None;
for entry in entries {
match entry {
SessionEntry::Message(m) => {
let summary = extract_message_summary(&m.message);
let meta = EntryMeta {
parent_id: m.parent_id.clone(),
timestamp: m.timestamp.clone(),
summary,
};
if let Some(ref pid) = m.parent_id {
children.entry(pid.clone()).or_default().push(m.id.clone());
}
meta_by_id.insert(m.id.clone(), meta);
}
SessionEntry::Compaction(c) => {
let meta = EntryMeta {
parent_id: c.parent_id.clone(),
timestamp: c.timestamp.clone(),
summary: Some(c.summary.clone()),
};
if let Some(ref pid) = c.parent_id {
children.entry(pid.clone()).or_default().push(c.id.clone());
}
meta_by_id.insert(c.id.clone(), meta);
}
SessionEntry::Leaf(l) => {
last_leaf_tip = Some(l.entry_id.clone());
}
#[allow(unreachable_patterns)]
_ => {}
}
}
for child_ids in children.values_mut() {
child_ids.sort();
}
let all_ids: HashSet<&str> = meta_by_id.keys().map(|s| s.as_str()).collect();
let has_valid_parent: HashSet<&str> = meta_by_id
.iter()
.filter(|(_, meta)| {
meta.parent_id
.as_deref()
.is_some_and(|pid| all_ids.contains(pid))
})
.map(|(id, _)| id.as_str())
.collect();
let mut roots: Vec<&str> = all_ids
.iter()
.filter(|id| !has_valid_parent.contains(*id))
.copied()
.collect();
roots.sort_unstable();
let mut branches: Vec<BranchInfo> = Vec::new();
let mut visited: HashSet<String> = HashSet::new();
for &root_id in &roots {
discover_branches(
root_id,
None,
0,
&meta_by_id,
&children,
&mut branches,
&mut visited,
);
}
if roots.is_empty() && !meta_by_id.is_empty() {
let mut ids = meta_by_id.keys().collect::<Vec<_>>();
ids.sort();
for id in ids {
let meta = meta_by_id.get(id).expect("sorted id exists");
if visited.insert(id.clone()) {
branches.push(BranchInfo {
tip_id: id.clone(),
depth: 0,
entry_count: 1,
timestamp: meta.timestamp.clone(),
summary: meta.summary.clone(),
fork_point: None,
});
}
}
}
Self {
branches,
leaf_tip: last_leaf_tip,
entries_by_id: meta_by_id,
}
}
pub fn branches(&self) -> &[BranchInfo] {
&self.branches
}
pub fn active_tip(&self) -> Option<&str> {
if let Some(ref tip) = self.leaf_tip
&& self.entries_by_id.contains_key(tip.as_str())
{
return Some(tip.as_str());
}
self.branches.first().map(|b| b.tip_id.as_str())
}
pub fn active_branch_index(&self) -> Option<usize> {
match &self.leaf_tip {
Some(tip) if self.entries_by_id.contains_key(tip.as_str()) => self
.branches
.iter()
.position(|b| b.tip_id == *tip)
.or_else(|| {
self.branches
.iter()
.position(|b| self.tip_is_on_branch(tip, &b.tip_id))
}),
_ => {
if self.branches.is_empty() {
None
} else {
Some(0)
}
}
}
}
pub fn branch_at(&self, index: usize) -> Option<&BranchInfo> {
self.branches.get(index)
}
fn tip_is_on_branch(&self, candidate_id: &str, tip_id: &str) -> bool {
let mut visited = HashSet::new();
let mut cursor = Some(tip_id);
while let Some(id) = cursor {
if id == candidate_id {
return true;
}
if !visited.insert(id) {
break;
}
cursor = self
.entries_by_id
.get(id)
.and_then(|m| m.parent_id.as_deref());
}
false
}
}
fn discover_branches(
start_id: &str,
fork_point: Option<&str>,
base_depth: usize,
meta_by_id: &HashMap<String, EntryMeta>,
children: &HashMap<String, Vec<String>>,
branches: &mut Vec<BranchInfo>,
visited: &mut HashSet<String>,
) {
let mut cursor = start_id.to_owned();
let mut depth = base_depth;
let mut entry_count = 0;
loop {
entry_count += 1;
let child_count = children.get(&cursor).map(|v| v.len()).unwrap_or(0);
if child_count == 0 {
if visited.insert(cursor.clone()) {
let m = meta_by_id.get(&cursor);
branches.push(BranchInfo {
tip_id: cursor,
depth,
entry_count,
timestamp: m.map(|m| m.timestamp.clone()).unwrap_or_default(),
summary: m.and_then(|m| m.summary.clone()),
fork_point: fork_point.map(|s| s.to_owned()),
});
}
return;
}
if child_count == 1 {
depth += 1;
cursor = children.get(&cursor).unwrap()[0].clone();
} else {
if visited.insert(cursor.clone()) {
let m = meta_by_id.get(&cursor);
branches.push(BranchInfo {
tip_id: cursor.clone(),
depth,
entry_count,
timestamp: m.map(|m| m.timestamp.clone()).unwrap_or_default(),
summary: m.and_then(|m| m.summary.clone()),
fork_point: fork_point.map(|s| s.to_owned()),
});
}
for child in children.get(&cursor).unwrap() {
discover_branches(
child,
Some(&cursor),
depth + 1,
meta_by_id,
children,
branches,
visited,
);
}
return;
}
}
}
fn extract_message_summary(message: &opi_ai::message::Message) -> Option<String> {
use opi_ai::message::{AssistantContent, InputContent, Message};
match message {
Message::User(u) => u.content.iter().find_map(|c| match c {
InputContent::Text { text } => {
let summary = if text.len() > 80 {
format!("{}...", &text[..text.floor_char_boundary(80)])
} else {
text.clone()
};
Some(summary)
}
_ => None,
}),
Message::Assistant(a) => a.content.iter().find_map(|c| match c {
AssistantContent::Text { text } => {
let summary = if text.len() > 80 {
format!("{}...", &text[..text.floor_char_boundary(80)])
} else {
text.clone()
};
Some(summary)
}
_ => None,
}),
_ => None,
}
}