use std::collections::HashMap;
use rusqlite::Connection;
#[derive(Debug, Clone)]
pub struct TreeOpts {
pub max_depth: u8,
pub min_files: u32,
pub max_nodes: usize,
}
impl Default for TreeOpts {
fn default() -> Self {
Self { max_depth: 6, min_files: 3, max_nodes: 30 }
}
}
#[derive(Debug)]
pub struct TreeNode {
pub depth: u8,
pub label: String,
pub path: String,
pub file_count: u32,
pub memory_title: Option<String>,
}
#[derive(Debug)]
pub struct DirTree {
pub nodes: Vec<TreeNode>,
pub root_memory_title: Option<String>,
pub truncated: u32,
}
pub fn dir_tree(conn: &Connection, opts: &TreeOpts) -> anyhow::Result<DirTree> {
let dir_counts = file_counts_by_dir(conn)?;
let mem_titles = dir_memory_titles(conn)?;
let root_memory_title = mem_titles.get("").cloned();
let mut included: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for (dir, &count) in &dir_counts {
let depth = dir_depth(dir);
if depth > opts.max_depth as usize {
continue;
}
if count >= opts.min_files || mem_titles.contains_key(dir.as_str()) {
included.insert(dir.clone());
}
}
for dir in mem_titles.keys() {
if dir.is_empty() {
continue;
}
let depth = dir_depth(dir);
if depth <= opts.max_depth as usize {
included.insert(dir.clone());
}
}
let qualified: Vec<String> = included.iter().cloned().collect();
for dir in &qualified {
for ancestor in ancestors(dir) {
if dir_depth(&ancestor) <= opts.max_depth as usize {
included.insert(ancestor);
}
}
}
let included_vec: Vec<String> = {
let mut v: Vec<String> = included.iter().cloned().collect();
v.sort();
v
};
let child_counts = included_child_counts(&included_vec);
let is_collapsible = |dir: &str| -> bool {
*child_counts.get(dir).unwrap_or(&0) == 1
&& !mem_titles.contains_key(dir)
&& *dir_counts.get(dir).unwrap_or(&0) == 0
};
let mut absorbed: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut label_map: HashMap<String, String> = HashMap::new();
let mut display_parent_map: HashMap<String, String> = HashMap::new();
let mut chain_end_map: HashMap<String, String> = HashMap::new();
for dir in &included_vec {
if absorbed.contains(dir) {
continue;
}
let mut chain_end = dir.clone();
loop {
if !is_collapsible(&chain_end) {
break;
}
let child = included_vec
.iter()
.find(|d| immediate_parent(d).as_deref() == Some(chain_end.as_str()));
let Some(child) = child else { break };
absorbed.insert(child.clone());
chain_end = child.clone();
}
let display_parent_anchor = displayed_parent(dir, &included_vec, &absorbed);
display_parent_map.insert(dir.clone(), display_parent_anchor.clone());
chain_end_map.insert(dir.clone(), chain_end.clone());
let display_parent_chain_end = chain_end_map
.get(&display_parent_anchor)
.cloned()
.unwrap_or_else(|| display_parent_anchor.clone());
let label = relative_label(&chain_end, &display_parent_chain_end);
label_map.insert(dir.clone(), label);
}
let display_depth_map = compute_display_depths(&included_vec, &absorbed, &display_parent_map);
let mut nodes: Vec<TreeNode> = included_vec
.iter()
.filter(|dir| !absorbed.contains(*dir))
.map(|dir| {
let label = label_map.get(dir).cloned().unwrap_or_else(|| dir.clone());
let depth = *display_depth_map.get(dir).unwrap_or(&0);
let file_count = *dir_counts.get(dir.as_str()).unwrap_or(&0);
let memory_title =
if dir.is_empty() { None } else { mem_titles.get(dir.as_str()).cloned() };
TreeNode { depth, label, path: dir.clone(), file_count, memory_title }
})
.collect();
nodes.sort_by(|a, b| a.path.cmp(&b.path));
let total = nodes.len();
let truncated = if total > opts.max_nodes {
let dropped = (total - opts.max_nodes) as u32;
nodes.truncate(opts.max_nodes);
dropped
} else {
0
};
Ok(DirTree { nodes, root_memory_title, truncated })
}
fn file_counts_by_dir(conn: &Connection) -> anyhow::Result<HashMap<String, u32>> {
let mut stmt = conn.prepare("SELECT path FROM files WHERE generated = 0")?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
let mut counts: HashMap<String, u32> = HashMap::new();
for row in rows {
let path = row?;
let dir = immediate_parent(&path).unwrap_or_default();
*counts.entry(dir).or_insert(0) += 1;
}
Ok(counts)
}
fn dir_memory_titles(conn: &Connection) -> anyhow::Result<HashMap<String, String>> {
let mut stmt = conn.prepare(
"SELECT b.binding_id, m.title
FROM repo_memory_bindings b
JOIN repo_memories m ON m.id = b.memory_id
WHERE b.binding_kind = 'dir' AND m.status = 'active'",
)?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
rows.collect::<Result<_, _>>().map_err(Into::into)
}
fn immediate_parent(path: &str) -> Option<String> {
let idx = path.rfind('/')?;
Some(path[..idx].to_string())
}
fn dir_depth(dir: &str) -> usize {
if dir.is_empty() { 0 } else { dir.chars().filter(|&c| c == '/').count() + 1 }
}
fn ancestors(dir: &str) -> Vec<String> {
let mut result = Vec::new();
let mut cur = dir;
while let Some(idx) = cur.rfind('/') {
cur = &cur[..idx];
result.push(cur.to_string());
}
result
}
fn included_child_counts(sorted: &[String]) -> HashMap<String, usize> {
let mut counts: HashMap<String, usize> = HashMap::new();
for dir in sorted {
if let Some(parent) = immediate_parent(dir)
&& sorted.binary_search(&parent).is_ok()
{
*counts.entry(parent).or_insert(0) += 1;
}
}
counts
}
fn displayed_parent(
dir: &str,
included: &[String],
absorbed: &std::collections::HashSet<String>,
) -> String {
let mut cur = dir;
while let Some(idx) = cur.rfind('/') {
cur = &cur[..idx];
let cur_str = cur.to_string();
if included.binary_search(&cur_str).is_ok() && !absorbed.contains(cur) {
return cur_str;
}
}
String::new()
}
fn relative_label(chain_end_path: &str, display_parent_path: &str) -> String {
if display_parent_path.is_empty() {
chain_end_path.to_string()
} else {
chain_end_path
.strip_prefix(&format!("{display_parent_path}/"))
.unwrap_or(chain_end_path)
.to_string()
}
}
fn compute_display_depths(
included: &[String],
absorbed: &std::collections::HashSet<String>,
display_parent_map: &HashMap<String, String>,
) -> HashMap<String, u8> {
let mut depths: HashMap<String, u8> = HashMap::new();
for dir in included {
if absorbed.contains(dir) {
continue;
}
let parent_path = display_parent_map.get(dir).map(|s| s.as_str()).unwrap_or("");
let depth = if parent_path.is_empty() {
0u8
} else {
depths.get(parent_path).copied().unwrap_or(0).saturating_add(1)
};
depths.insert(dir.clone(), depth);
}
depths
}