use compact_str::CompactString;
use crate::session::{MessageRole, Session, TreeNode};
pub fn short_id(id: &CompactString) -> String {
crate::text::short_id(id)
}
pub fn resolve_id_prefix(session: &Session, prefix: &str) -> Result<CompactString, String> {
if prefix.is_empty() {
return Err("missing id prefix".to_string());
}
let matches: Vec<&CompactString> = session
.tree
.entries
.keys()
.filter(|id| id.starts_with(prefix))
.collect();
match matches.as_slice() {
[] => Err(format!("no entry id starts with '{}'", prefix)),
[only] => Ok((*only).clone()),
many => Err(format!(
"ambiguous prefix '{}' matches {} entries (try a longer prefix)",
prefix,
many.len()
)),
}
}
pub fn render_tree(session: &Session) -> Vec<String> {
let mut out = Vec::new();
if session.tree.entries.is_empty() {
return out;
}
let leaf = session.tree.leaf_id.as_ref();
let mut roots: Vec<&TreeNode> = session
.tree
.entries
.values()
.filter(|n| n.parent.is_none())
.collect();
roots.sort_by_key(|n| n.timestamp);
for root in roots {
render_subtree(session, root, 0, leaf, &mut out);
}
if !session.branch_summaries.is_empty() {
out.push(String::new());
out.push(format!(
"Summarized branches ({}): pruned during compress/rewind",
session.branch_summaries.len(),
));
for bs in &session.branch_summaries {
out.push(format!(
" └─ parent {} · {} msg{} · {}",
short_id(&bs.parent_id),
bs.message_count,
if bs.message_count == 1 { "" } else { "s" },
bs.preview,
));
}
}
out
}
fn render_subtree(
session: &Session,
node: &TreeNode,
depth: usize,
leaf: Option<&CompactString>,
out: &mut Vec<String>,
) {
let indent = " ".repeat(depth);
let marker = if Some(&node.id) == leaf { "*" } else { " " };
let role = session
.message_store
.get(&node.id)
.map(|m| role_label(m.role))
.unwrap_or("???");
let preview: String = session
.message_store
.get(&node.id)
.map(|m| {
let s: String = m.content.chars().take(50).collect();
if m.content.chars().count() > 50 {
format!("{}…", s)
} else {
s
}
})
.unwrap_or_else(|| "(content missing)".to_string());
let preview_one_line = preview.replace('\n', " ").trim().to_string();
let label_suffix = node
.label
.as_deref()
.map(|l| format!(" [{}]", l))
.unwrap_or_default();
out.push(format!(
"{}{} {} {} {:?}{}",
indent,
marker,
short_id(&node.id),
role,
preview_one_line,
label_suffix,
));
let mut children: Vec<&TreeNode> = session
.tree
.entries
.values()
.filter(|n| n.parent.as_ref() == Some(&node.id))
.collect();
children.sort_by_key(|n| n.timestamp);
for child in children {
render_subtree(session, child, depth + 1, leaf, out);
}
}
fn role_label(role: MessageRole) -> &'static str {
match role {
MessageRole::User => "user",
MessageRole::Assistant => "asst",
MessageRole::System => "sys ",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::Session;
#[test]
fn resolve_unique_prefix_returns_full_id() {
let mut s = Session::new("p", "m", 0);
s.add_message(MessageRole::User, "msg");
let full_id = s.messages[0].id.clone();
let prefix = crate::text::short_id(&full_id);
let resolved = resolve_id_prefix(&s, &prefix).unwrap();
assert_eq!(resolved, full_id);
}
#[test]
fn resolve_empty_prefix_errors() {
let s = Session::new("p", "m", 0);
let err = resolve_id_prefix(&s, "").unwrap_err();
assert!(err.contains("missing id prefix"));
}
#[test]
fn resolve_no_match_errors() {
let s = Session::new("p", "m", 0);
let err = resolve_id_prefix(&s, "00000000").unwrap_err();
assert!(err.contains("no entry id"), "got: {err}");
}
#[test]
fn render_tree_empty_session_is_empty() {
let s = Session::new("p", "m", 0);
assert!(render_tree(&s).is_empty());
}
#[test]
fn render_tree_linear_session() {
let mut s = Session::new("p", "m", 0);
s.add_message(MessageRole::User, "first");
s.add_message(MessageRole::Assistant, "reply");
let lines = render_tree(&s);
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("user"));
assert!(!lines[0].contains(" *"));
assert!(lines[1].starts_with(" *"));
assert!(lines[1].contains("asst"));
}
#[test]
fn render_tree_branched_session() {
let mut s = Session::new("p", "m", 0);
s.add_message(MessageRole::User, "Q");
s.add_message(MessageRole::Assistant, "original");
let original_assistant_id = s.messages[1].id.clone();
s.fork_at(&original_assistant_id).unwrap();
s.add_message(MessageRole::Assistant, "alternate");
let alt_id = s.tree.leaf_id.clone().unwrap();
let lines = render_tree(&s);
assert_eq!(lines.len(), 3);
let assistant_lines: Vec<_> = lines.iter().filter(|l| l.contains("asst")).collect();
assert_eq!(assistant_lines.len(), 2);
let starred: Vec<_> = lines.iter().filter(|l| l.contains(" * ")).collect();
assert_eq!(starred.len(), 1);
let alt_prefix = short_id(&alt_id);
assert!(starred[0].contains(&alt_prefix), "got: {starred:?}");
}
#[test]
fn render_tree_includes_labels() {
let mut s = Session::new("p", "m", 0);
s.add_message(MessageRole::User, "milestone");
let id = s.messages[0].id.clone();
s.set_label(&id, Some("checkpoint".to_string())).unwrap();
let lines = render_tree(&s);
assert!(lines[0].contains("[checkpoint]"), "got: {:?}", lines[0]);
}
}