use std::collections::BTreeMap;
use crate::{state::AphroditeState, struct_extract::floor_boundary};
pub fn build_catalog(state:&AphroditeState, mode:&str) -> serde_json::Value {
let items:Vec<serde_json::Value> = state
.recent_markers
.iter()
.map(|m| {
if mode == "toc" {
serde_json::json!({
"hash": &m.hash,
"type": m.ccr_type,
"size": m.size,
"preview": floor_boundary(&m.preview, 120),
})
} else {
serde_json::json!({
"hash": m.hash,
"type": m.ccr_type,
"size": m.size,
"preview": floor_boundary(&m.preview, 120),
"turn": m.turn,
})
}
})
.collect();
let mut by_type:BTreeMap<String, Vec<String>> = BTreeMap::new();
for m in &state.recent_markers {
by_type.entry(m.ccr_type.clone()).or_default().push(m.hash.clone());
}
let by_type_json:serde_json::Map<String, serde_json::Value> = by_type
.iter()
.map(|(t, hashes)| {
(
t.clone(),
serde_json::json!({
"count": hashes.len(),
"hashes": &hashes[..10.min(hashes.len())],
}),
)
})
.collect();
let total_saved:usize = state
.recent_markers
.iter()
.map(|m| m.size.saturating_sub(m.preview.len()))
.sum();
let mut result = serde_json::json!({
"total": items.len(), "total_items": items.len(),
"total_saved": total_saved,
"total_saved_human": fmt_size(total_saved),
"by_type": by_type_json,
"items": items,
"turn": state.turn_counter, "conv_turns": state.conv_index.len(),
"referenced_files": state.referenced_files.len(),
});
if mode == "toc" && !items.is_empty() {
let recommendations:Vec<String> = state
.recent_markers
.iter()
.take(10)
.map(|m| format!("{} ({}B) - {}", m.ccr_type, m.size, floor_boundary(&m.preview, 60)))
.collect();
result["recommendations"] = serde_json::json!(recommendations);
result["hint"] = serde_json::json!("Use aphrodite_retrieve(hash) to expand any entry.");
}
result
}
pub fn format_catalog_table(state:&AphroditeState) -> String {
let items = &state.recent_markers;
if items.is_empty() {
return "No compressed items yet.".to_string();
}
let mut lines = vec![
format!(
"Catalog: {} items {} saved {} turns {} files",
items.len(),
fmt_size(items.iter().map(|m| m.size).sum()),
state.conv_index.len(),
state.referenced_files.len(),
),
String::new(),
"| Hash | Type | Size | Preview |".to_string(),
"|------|------|------|---------|".to_string(),
];
let mut newest:Vec<&crate::state::MarkerEntry> = items.iter().collect();
newest.sort_by_key(|m| std::cmp::Reverse(m.turn));
for m in newest.iter().take(20) {
let hash = &m.hash[..10.min(m.hash.len())];
let size = fmt_size(m.size);
let preview = m.preview.replace('|', "\\|");
let preview = floor_boundary(&preview, 80);
lines.push(format!("| {} | {} | {} | {} |", hash, m.ccr_type, size, preview));
}
if items.len() > 20 {
lines.push(format!(
"{} more entries - use the aphrodite_catalog tool to list them all.",
items.len() - 20
));
}
lines.join("\n")
}
fn fmt_size(bytes:usize) -> String {
if bytes >= 1024 * 1024 {
format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0))
} else if bytes >= 1024 {
format!("{:.0}KB", bytes as f64 / 1024.0)
} else {
format!("{}B", bytes)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_catalog() {
let s = AphroditeState::default();
let cat = build_catalog(&s, "full");
assert_eq!(cat["total_items"], 0);
}
#[test]
fn test_catalog_with_items() {
let mut s = AphroditeState::default();
s.record_marker(crate::state::MarkerEntry {
hash:"abc123def456".into(),
ccr_type:"code_rust".into(),
size:1024,
preview:"[code:3fns 42L]".into(),
turn:1,
center:None,
meta:None,
});
s.record_marker(crate::state::MarkerEntry {
hash:"def789abc012".into(),
ccr_type:"diff".into(),
size:2048,
preview:"[diff:1F +2/-1 10L]".into(),
turn:1,
center:None,
meta:None,
});
let cat = build_catalog(&s, "full");
assert_eq!(cat["total_items"], 2);
let expected = (1024 - "[code:3fns 42L]".len()) + (2048 - "[diff:1F +2/-1 10L]".len());
assert_eq!(cat["total_saved"], expected);
}
#[test]
fn test_toc_mode() {
let mut s = AphroditeState::default();
s.record_marker(crate::state::MarkerEntry {
hash:"abc".into(),
ccr_type:"text".into(),
size:100,
preview:"[text]".into(),
turn:1,
center:None,
meta:None,
});
let cat = build_catalog(&s, "toc");
assert!(cat["hint"].as_str().unwrap().contains("retrieve"));
}
#[test]
fn test_empty_table() {
let s = AphroditeState::default();
assert_eq!(format_catalog_table(&s), "No compressed items yet.");
}
#[test]
fn test_by_type_json_deterministic() {
let mut s = AphroditeState::default();
for (i, (hash, ccr_type, preview)) in [
("h1", "text", "[text] one"),
("h2", "code_rust", "[code:1 1L]"),
("h3", "diff", "[diff:1 +1/-0]"),
("h4", "text", "[text] two"),
]
.iter()
.enumerate()
{
s.record_marker(crate::state::MarkerEntry {
hash:hash.to_string(),
ccr_type:ccr_type.to_string(),
size:100 + i * 10,
preview:preview.to_string(),
turn:1,
center:None,
meta:None,
});
}
let first = build_catalog(&s, "full");
let second = build_catalog(&s, "full");
assert_eq!(first["by_type"].to_string(), second["by_type"].to_string());
let keys:Vec<&str> = first["by_type"].as_object().unwrap().keys().map(|k| k.as_str()).collect();
assert_eq!(keys, vec!["code_rust", "diff", "text"]);
}
#[test]
fn test_table_sorts_by_turn_and_caps_twenty() {
let mut s = AphroditeState::default();
for i in 0..25 {
s.record_marker(crate::state::MarkerEntry {
hash:format!("h{:02}", i),
ccr_type:"text".into(),
size:100,
preview:format!("[text] entry {}", i),
turn:if i == 24 { 1 } else { i },
center:None,
meta:None,
});
}
let table = format_catalog_table(&s);
let rows:Vec<&str> = table.lines().collect();
assert!(rows[4].starts_with("| h23 |"), "newest turn first, got: {}", rows[4]);
assert!(!table.contains("h24"), "oldest-turn marker must not enter the 20-row window");
assert!(!table.contains("h03"), "only the 20 newest turns may be listed");
assert!(table.contains("5 more entries"), "missing overflow notice: {}", table);
assert!(table.contains("aphrodite_catalog"), "notice must point at the catalog tool");
}
}