use std::collections::BTreeSet;
use std::path::Path;
use serde::Serialize;
use void_core::{
cid,
crypto::{CommitReader, EncryptedCommit},
store::ObjectStoreExt,
};
use crate::context::{build_void_context, resolve_ref, void_err_to_cli};
use crate::output::{run_command, CliError, CliOptions};
#[derive(Debug, Clone, Serialize)]
pub struct TreeEntry {
pub mode: String,
#[serde(rename = "type")]
pub entry_type: String,
pub path: String,
pub size: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct LsTreeOutput {
pub commit: String,
pub path: String,
pub entries: Vec<TreeEntry>,
}
fn collect_all_entries(
store: &impl ObjectStoreExt,
commit: &void_core::metadata::Commit,
reader: &CommitReader,
) -> Result<Vec<(String, u64)>, CliError> {
let manifest = void_core::metadata::manifest_tree::TreeManifest::from_commit(store, commit, reader)
.map_err(void_err_to_cli)?
.ok_or_else(|| CliError::internal("commit has no manifest_cid"))?;
let mut entries: Vec<(String, u64)> = manifest
.iter()
.map(|me| {
let me = me.map_err(void_err_to_cli)?;
Ok((me.path.clone(), me.length))
})
.collect::<Result<_, CliError>>()?;
entries.sort_by(|a, b| a.0.cmp(&b.0));
Ok(entries)
}
fn filter_by_path(entries: Vec<(String, u64)>, path_prefix: &str) -> Vec<(String, u64)> {
if path_prefix.is_empty() {
return entries;
}
let prefix = if path_prefix.ends_with('/') {
path_prefix.to_string()
} else {
format!("{}/", path_prefix)
};
entries
.into_iter()
.filter(|(p, _)| p.starts_with(&prefix) || p == path_prefix.trim_end_matches('/'))
.collect()
}
fn parent_dir(path: &str) -> Option<&str> {
path.rfind('/').map(|idx| &path[..idx])
}
fn collapse_to_immediate_children(
entries: Vec<(String, u64)>,
path_prefix: &str,
) -> Vec<TreeEntry> {
let prefix = if path_prefix.is_empty() {
String::new()
} else if path_prefix.ends_with('/') {
path_prefix.to_string()
} else {
format!("{}/", path_prefix)
};
let mut seen_dirs: BTreeSet<String> = BTreeSet::new();
let mut result: Vec<TreeEntry> = Vec::new();
for (path, size) in entries {
if !prefix.is_empty() && !path.starts_with(&prefix) {
continue;
}
let relative = if prefix.is_empty() {
path.as_str()
} else {
&path[prefix.len()..]
};
if let Some(slash_idx) = relative.find('/') {
let dir_name = &relative[..slash_idx];
let full_dir_path = if prefix.is_empty() {
format!("{}/", dir_name)
} else {
format!("{}{}/", prefix, dir_name)
};
if !seen_dirs.contains(&full_dir_path) {
seen_dirs.insert(full_dir_path.clone());
result.push(TreeEntry {
mode: "040000".to_string(),
entry_type: "tree".to_string(),
path: full_dir_path,
size: 0,
});
}
} else {
result.push(TreeEntry {
mode: "100644".to_string(),
entry_type: "blob".to_string(),
path,
size,
});
}
}
result.sort_by(|a, b| a.path.cmp(&b.path));
result
}
fn expand_recursive(entries: Vec<(String, u64)>, path_prefix: &str) -> Vec<TreeEntry> {
let prefix = if path_prefix.is_empty() {
String::new()
} else if path_prefix.ends_with('/') {
path_prefix.to_string()
} else {
format!("{}/", path_prefix)
};
let mut seen_dirs: BTreeSet<String> = BTreeSet::new();
let mut result: Vec<TreeEntry> = Vec::new();
for (path, size) in entries {
if !prefix.is_empty() && !path.starts_with(&prefix) {
continue;
}
let mut current = path.as_str();
while let Some(parent) = parent_dir(current) {
if !parent.is_empty() {
let dir_path = format!("{}/", parent);
if prefix.is_empty()
|| dir_path.starts_with(&prefix)
|| prefix.starts_with(&dir_path)
{
if !seen_dirs.contains(&dir_path) {
seen_dirs.insert(dir_path.clone());
}
}
}
current = parent;
}
result.push(TreeEntry {
mode: "100644".to_string(),
entry_type: "blob".to_string(),
path,
size,
});
}
for dir in seen_dirs {
if prefix.is_empty() || dir.starts_with(&prefix) {
result.push(TreeEntry {
mode: "040000".to_string(),
entry_type: "tree".to_string(),
path: dir,
size: 0,
});
}
}
result.sort_by(|a, b| a.path.cmp(&b.path));
result
}
pub fn run(
cwd: &Path,
commit_ref: &str,
path_filter: Option<&str>,
name_only: bool,
recursive: bool,
opts: &CliOptions,
) -> Result<(), CliError> {
run_command("ls-tree", opts, |ctx| {
ctx.progress("Loading tree...");
ctx.verbose("Reading repository context...");
let void_ctx = build_void_context(cwd)?;
ctx.verbose(format!("Resolving ref: {}", commit_ref));
let commit_cid_typed = resolve_ref(&void_ctx.paths.void_dir, commit_ref)?;
let commit_cid = cid::from_bytes(commit_cid_typed.as_bytes())
.map_err(|e| CliError::internal(format!("invalid commit CID: {e}")))?;
let commit_cid_str = commit_cid.to_string();
ctx.verbose(format!(
"Commit: {}",
&commit_cid_str[..12.min(commit_cid_str.len())]
));
let store = void_ctx.open_store().map_err(void_err_to_cli)?;
let commit_encrypted: EncryptedCommit = store
.get_blob(&commit_cid)
.map_err(|e| CliError::not_found(format!("commit not found: {e}")))?;
let (commit_bytes, reader) = CommitReader::open_with_vault(&void_ctx.crypto.vault, &commit_encrypted)
.map_err(|e| CliError::internal(format!("commit decryption failed: {e}")))?;
let commit = commit_bytes.parse()
.map_err(|e| CliError::internal(format!("failed to parse commit: {e}")))?;
ctx.verbose("Collecting file entries...");
let all_entries = collect_all_entries(&store, &commit, &reader)?;
let path_prefix = path_filter.unwrap_or("");
let filtered_entries = filter_by_path(all_entries, path_prefix);
let tree_entries = if recursive {
expand_recursive(filtered_entries, path_prefix)
} else {
collapse_to_immediate_children(filtered_entries, path_prefix)
};
ctx.progress(format!("Found {} entries", tree_entries.len()));
if !ctx.use_json() {
for entry in &tree_entries {
if name_only {
ctx.info(&entry.path);
} else {
ctx.info(format!(
"{} {} -\t{}",
entry.mode, entry.entry_type, entry.path
));
}
}
}
Ok(LsTreeOutput {
commit: commit_cid_str,
path: path_prefix.to_string(),
entries: tree_entries,
})
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parent_dir() {
assert_eq!(parent_dir("file.txt"), None);
assert_eq!(parent_dir("src/main.rs"), Some("src"));
assert_eq!(parent_dir("a/b/c.txt"), Some("a/b"));
}
#[test]
fn test_filter_by_path_empty() {
let entries = vec![
("src/main.rs".to_string(), 100),
("README.md".to_string(), 50),
];
let filtered = filter_by_path(entries.clone(), "");
assert_eq!(filtered.len(), 2);
}
#[test]
fn test_filter_by_path_prefix() {
let entries = vec![
("src/main.rs".to_string(), 100),
("src/lib.rs".to_string(), 80),
("README.md".to_string(), 50),
];
let filtered = filter_by_path(entries, "src");
assert_eq!(filtered.len(), 2);
assert!(filtered.iter().all(|(p, _)| p.starts_with("src/")));
}
#[test]
fn test_collapse_to_immediate_children_root() {
let entries = vec![
("README.md".to_string(), 50),
("src/main.rs".to_string(), 100),
("src/lib.rs".to_string(), 80),
("tests/test.rs".to_string(), 60),
];
let result = collapse_to_immediate_children(entries, "");
assert_eq!(result.len(), 3);
let readme = result.iter().find(|e| e.path == "README.md").unwrap();
assert_eq!(readme.entry_type, "blob");
let src = result.iter().find(|e| e.path == "src/").unwrap();
assert_eq!(src.entry_type, "tree");
}
#[test]
fn test_collapse_to_immediate_children_subdir() {
let entries = vec![
("src/main.rs".to_string(), 100),
("src/lib.rs".to_string(), 80),
("src/utils/helper.rs".to_string(), 40),
];
let result = collapse_to_immediate_children(entries, "src");
assert_eq!(result.len(), 3);
let main_rs = result.iter().find(|e| e.path == "src/main.rs").unwrap();
assert_eq!(main_rs.entry_type, "blob");
let utils = result.iter().find(|e| e.path == "src/utils/").unwrap();
assert_eq!(utils.entry_type, "tree");
}
#[test]
fn test_expand_recursive() {
let entries = vec![
("src/main.rs".to_string(), 100),
("src/utils/helper.rs".to_string(), 40),
];
let result = expand_recursive(entries, "");
assert_eq!(result.len(), 4);
let trees: Vec<_> = result.iter().filter(|e| e.entry_type == "tree").collect();
let blobs: Vec<_> = result.iter().filter(|e| e.entry_type == "blob").collect();
assert_eq!(trees.len(), 2);
assert_eq!(blobs.len(), 2);
}
#[test]
fn test_tree_entry_serialization() {
let entry = TreeEntry {
mode: "100644".to_string(),
entry_type: "blob".to_string(),
path: "src/main.rs".to_string(),
size: 1234,
};
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("\"mode\":\"100644\""));
assert!(json.contains("\"type\":\"blob\""));
assert!(json.contains("\"path\":\"src/main.rs\""));
assert!(json.contains("\"size\":1234"));
}
#[test]
fn test_ls_tree_output_serialization() {
let output = LsTreeOutput {
commit: "bafytest123".to_string(),
path: "src/".to_string(),
entries: vec![TreeEntry {
mode: "100644".to_string(),
entry_type: "blob".to_string(),
path: "src/main.rs".to_string(),
size: 100,
}],
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"commit\":\"bafytest123\""));
assert!(json.contains("\"path\":\"src/\""));
assert!(json.contains("\"entries\""));
}
}