use std::collections::HashMap;
use std::fs;
use std::io::IsTerminal;
use std::path::Path;
use ignore::WalkBuilder;
use serde::Serialize;
use void_core::{cid, config, refs, support::configure_walker};
use crate::context::{find_void_dir, open_repo, void_err_to_cli};
use crate::output::{run_command, CliError, CliOptions};
use crate::registry;
#[derive(Debug, Clone, Serialize)]
pub struct ExtensionStats {
pub extension: String,
pub lines: u64,
pub files: u64,
pub bytes: u64,
pub percent: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct TotalStats {
pub lines: u64,
pub files: u64,
pub bytes: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct StatOutput {
pub stats: Vec<ExtensionStats>,
pub total: TotalStats,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VoidDirSize {
pub total: u64,
pub objects: u64,
pub index: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct LargeFile {
pub path: String,
pub bytes: u64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SizeOutput {
pub void_dir: VoidDirSize,
pub working_tree: u64,
pub total: u64,
pub largest_files: Vec<LargeFile>,
}
#[derive(Debug, Clone, Serialize)]
pub struct HeadInfo {
#[serde(rename = "ref")]
pub ref_name: Option<String>,
pub cid: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct InfoOutput {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub root: String,
pub head: HeadInfo,
pub commits: u64,
pub remotes: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RepoListEntry {
pub id: String,
pub name: String,
pub origin: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub branches: Vec<String>,
pub local_paths: Vec<String>,
pub created: String,
pub updated: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct RepoListOutput {
pub repos: Vec<RepoListEntry>,
pub count: usize,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RepoRegistryOutput {
pub id: String,
pub name: String,
pub origin: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub head: HashMap<String, String>,
pub trusted_sources: Vec<String>,
pub local_paths: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub key_ref: Option<String>,
pub created: String,
pub updated: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct RepoUnregisterOutput {
pub id: String,
pub name: String,
pub removed: bool,
}
pub struct StatArgs {
pub all: bool,
pub top: usize,
pub sort: String,
pub path: Option<String>,
pub exclude: Vec<String>,
}
pub struct SizeArgs {
pub top: usize,
}
const CODE_EXTENSIONS: &[&str] = &[
"rs", "js", "jsx", "ts", "tsx", "mjs", "cjs", "py", "pyi", "go", "c", "h", "cpp", "cc", "cxx", "hpp", "hh", "hxx", "java", "kt", "kts", "rb", "php", "swift", "sh", "bash", "zsh", "fish", "html", "htm", "css", "scss", "sass", "less", "json", "yaml", "yml", "toml", "xml", "md", "markdown", "rst", "txt", "sql", "zig", "lua", "hs", "ml", "mli", "ex", "exs", "erl", "clj", "cljs", "cljc", "scala", "jl", "r", "R", "vim", "mk", "makefile", "Makefile", "nix", "lock", "void",
];
fn format_number(n: u64) -> String {
let s = n.to_string();
let mut result = String::new();
for (i, c) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
result.insert(0, ',');
}
result.insert(0, c);
}
result
}
fn format_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
format!("{} B", bytes)
}
}
fn is_code_extension(ext: &str) -> bool {
CODE_EXTENSIONS.contains(&ext)
}
pub fn run_stat(cwd: &Path, args: StatArgs, opts: &CliOptions) -> Result<(), CliError> {
run_command("repo stat", opts, |ctx| {
ctx.progress("Scanning files...");
let void_dir = find_void_dir(cwd)?;
let root = void_dir
.parent()
.ok_or_else(|| CliError::internal("void_dir has no parent"))?;
let scan_root = if let Some(ref path) = args.path {
root.join(path)
} else {
root.to_path_buf()
};
if !scan_root.exists() {
return Err(CliError::not_found(format!(
"path does not exist: {}",
scan_root.display()
)));
}
let void_dir_name = void_dir
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(".void")
.to_string();
let exclude_extensions: Vec<String> = args
.exclude
.iter()
.flat_map(|entry| entry.split(','))
.map(|s| {
s.trim()
.trim_start_matches("*.")
.trim_start_matches('.')
.to_lowercase()
})
.filter(|s| !s.is_empty())
.collect();
let mut stats_map: HashMap<String, (u64, u64, u64)> = HashMap::new(); let mut total_lines: u64 = 0;
let mut total_files: u64 = 0;
let mut total_bytes: u64 = 0;
let mut builder = WalkBuilder::new(&scan_root);
configure_walker(&mut builder).filter_entry(move |entry| {
let name = entry.file_name().to_string_lossy();
name != void_dir_name && name != ".git" && name != "node_modules" && name != ".DS_Store"
});
for entry in builder.build().flatten() {
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
let path = entry.path();
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.unwrap_or_default();
if !args.all && !ext.is_empty() && !is_code_extension(&ext) {
continue;
}
if !exclude_extensions.is_empty() && exclude_extensions.contains(&ext) {
continue;
}
let metadata = match fs::metadata(path) {
Ok(m) => m,
Err(_) => continue,
};
let bytes = metadata.len();
let lines = match fs::read(path) {
Ok(content) => void_core::support::count_lines(&content) as u64,
Err(_) => 0,
};
let display_ext = if ext.is_empty() {
"(none)".to_string()
} else {
ext.clone()
};
let entry = stats_map.entry(display_ext).or_insert((0, 0, 0));
entry.0 += lines;
entry.1 += 1;
entry.2 += bytes;
total_lines += lines;
total_files += 1;
total_bytes += bytes;
}
let mut stats: Vec<ExtensionStats> = stats_map
.into_iter()
.map(|(ext, (lines, files, bytes))| {
let percent = if total_lines > 0 {
match args.sort.as_str() {
"files" => (files as f64 / total_files as f64) * 100.0,
"bytes" => (bytes as f64 / total_bytes as f64) * 100.0,
_ => (lines as f64 / total_lines as f64) * 100.0,
}
} else {
0.0
};
ExtensionStats {
extension: ext,
lines,
files,
bytes,
percent: (percent * 10.0).round() / 10.0, }
})
.collect();
match args.sort.as_str() {
"files" => stats.sort_by(|a, b| b.files.cmp(&a.files)),
"bytes" => stats.sort_by(|a, b| b.bytes.cmp(&a.bytes)),
_ => stats.sort_by(|a, b| b.lines.cmp(&a.lines)),
}
stats.truncate(args.top);
if !ctx.use_json() {
let use_colors = std::io::stderr().is_terminal();
let dim = if use_colors { "\x1b[2m" } else { "" };
let reset = if use_colors { "\x1b[0m" } else { "" };
let bold = if use_colors { "\x1b[1m" } else { "" };
ctx.info(format!(
"{}{:<6} {:>8} {:>3} {:>5} {:>10}{}",
bold, "EXT", "LINES", "%", "FILES", "SIZE", reset
));
ctx.info(format!("{}{}{}", dim, "─".repeat(40), reset));
for stat in &stats {
ctx.info(format!(
"{:<6} {:>8} {:>2}% {:>5} {:>10}",
stat.extension,
format_number(stat.lines),
stat.percent as u64,
format_number(stat.files),
format_bytes(stat.bytes)
));
}
ctx.info("");
ctx.info(format!(
"{}Total:{} {} lines, {} files, {}",
bold,
reset,
format_number(total_lines),
format_number(total_files),
format_bytes(total_bytes)
));
}
Ok(StatOutput {
stats,
total: TotalStats {
lines: total_lines,
files: total_files,
bytes: total_bytes,
},
})
})
}
pub fn run_size(cwd: &Path, args: SizeArgs, opts: &CliOptions) -> Result<(), CliError> {
run_command("repo size", opts, |ctx| {
ctx.progress("Calculating sizes...");
let void_dir = find_void_dir(cwd)?;
let root = void_dir
.parent()
.ok_or_else(|| CliError::internal("void_dir has no parent"))?;
let void_dir_name = void_dir
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(".void")
.to_string();
let objects_dir = void_dir.join("objects");
let index_path = void_dir.join("index");
let objects_size = dir_size(&objects_dir);
let index_size = if index_path.exists() {
fs::metadata(&index_path).map(|m| m.len()).unwrap_or(0)
} else {
0
};
let void_total = dir_size(&void_dir);
let mut working_tree_size: u64 = 0;
let mut file_sizes: Vec<(String, u64)> = Vec::new();
let mut builder = WalkBuilder::new(root);
configure_walker(&mut builder).filter_entry({
let void_dir_name = void_dir_name.clone();
move |entry| {
let name = entry.file_name().to_string_lossy();
name != void_dir_name
&& name != ".git"
&& name != "node_modules"
&& name != ".DS_Store"
}
});
for entry in builder.build().flatten() {
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
let path = entry.path();
let size = fs::metadata(path).map(|m| m.len()).unwrap_or(0);
working_tree_size += size;
let rel_path = path
.strip_prefix(root)
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|_| path.to_string_lossy().to_string());
file_sizes.push((rel_path, size));
}
file_sizes.sort_by(|a, b| b.1.cmp(&a.1));
file_sizes.truncate(args.top);
let largest_files: Vec<LargeFile> = file_sizes
.into_iter()
.map(|(path, bytes)| LargeFile { path, bytes })
.collect();
let total = void_total + working_tree_size;
if !ctx.use_json() {
ctx.info("Repository size:");
ctx.info(format!(" .void directory: {} bytes", void_total));
ctx.info(format!(" objects: {} bytes", objects_size));
ctx.info(format!(" index: {} bytes", index_size));
ctx.info(format!(" Working tree: {} bytes", working_tree_size));
ctx.info(format!(" Total: {} bytes", total));
ctx.info("");
ctx.info(format!("Top {} largest files:", args.top));
for file in &largest_files {
ctx.info(format!(" {:>12} bytes {}", file.bytes, file.path));
}
}
Ok(SizeOutput {
void_dir: VoidDirSize {
total: void_total,
objects: objects_size,
index: index_size,
},
working_tree: working_tree_size,
total,
largest_files,
})
})
}
pub fn run_info(cwd: &Path, opts: &CliOptions) -> Result<(), CliError> {
run_command("repo info", opts, |ctx| {
ctx.progress("Loading repository info...");
let repo = open_repo(cwd)?;
let void_dir = repo.void_dir();
let root = repo.root().to_string();
let head_ref = refs::read_head(void_dir).map_err(void_err_to_cli)?;
let (ref_name, head_cid_opt) = match head_ref {
Some(refs::HeadRef::Symbolic(branch)) => {
let commit_cid = refs::read_branch(void_dir, &branch)
.map_err(void_err_to_cli)?;
(Some(branch), commit_cid)
}
Some(refs::HeadRef::Detached(commit_cid)) => (None, Some(commit_cid)),
None => (None, None),
};
let has_head = head_cid_opt.is_some();
let head_cid = head_cid_opt.and_then(|c| {
cid::from_bytes(c.as_bytes())
.map(|v| v.to_string())
.ok()
});
let mut commits: u64 = 0;
if has_head {
commits = count_commits(repo.context())?;
}
let cfg =
config::load(void_dir.as_std_path()).map_err(|e| CliError::internal(e.to_string()))?;
let mut remotes: Vec<String> = cfg.remote.keys().cloned().collect();
remotes.sort();
let repo_name = cfg.repo_name.clone();
if !ctx.use_json() {
if let Some(name) = &repo_name {
ctx.info(format!("Repository: {}", name));
}
ctx.info(format!("Repository root: {}", root));
ctx.info(format!(
"HEAD: {} ({})",
ref_name.as_deref().unwrap_or("(detached)"),
head_cid.as_deref().unwrap_or("(no commits)")
));
ctx.info(format!("Commits: {}", commits));
if remotes.is_empty() {
ctx.info("Remotes: (none)");
} else {
ctx.info(format!("Remotes: {}", remotes.join(", ")));
}
}
Ok(InfoOutput {
name: repo_name,
root,
head: HeadInfo {
ref_name,
cid: head_cid,
},
commits,
remotes,
})
})
}
pub fn run_list(verbose: bool, opts: &CliOptions) -> Result<(), CliError> {
run_command("repo list", opts, |ctx| {
ctx.progress("Loading registry...");
let records = registry::list_records()
.map_err(|e| CliError::internal(format!("failed to load registry: {}", e)))?;
if records.is_empty() {
if !ctx.use_json() {
ctx.info("No repositories registered.");
ctx.info("Run 'void init' in a directory to register a repo.");
}
return Ok(RepoListOutput {
repos: Vec::new(),
count: 0,
});
}
let entries: Vec<RepoListEntry> = records
.iter()
.map(|r| RepoListEntry {
id: r.id.clone(),
name: r.name.clone(),
origin: r.origin.clone(),
description: r.description.clone(),
branches: r.head.keys().cloned().collect(),
local_paths: r
.local_paths
.iter()
.map(|p| p.display().to_string())
.collect(),
created: r.created.clone(),
updated: r.updated.clone(),
})
.collect();
let count = entries.len();
if !ctx.use_json() {
let use_colors = std::io::stderr().is_terminal();
let bold = if use_colors { "\x1b[1m" } else { "" };
let dim = if use_colors { "\x1b[2m" } else { "" };
let reset = if use_colors { "\x1b[0m" } else { "" };
for entry in &entries {
let short_id = if entry.id.len() > 8 {
&entry.id[..8]
} else {
&entry.id
};
ctx.info(format!(
"{}{}{} {}{}{}",
bold, entry.name, reset, dim, short_id, reset
));
if verbose {
ctx.info(format!(" Origin: {}", entry.origin));
if !entry.branches.is_empty() {
ctx.info(format!(" Branches: {}", entry.branches.join(", ")));
}
for path in &entry.local_paths {
ctx.info(format!(" Path: {}", path));
}
if let Some(ref desc) = entry.description {
ctx.info(format!(" Description: {}", desc));
}
ctx.info("");
}
}
ctx.info(format!("{}{} repo(s) registered{}", dim, count, reset));
}
Ok(RepoListOutput {
repos: entries,
count,
})
})
}
pub fn run_registry(target: &str, opts: &CliOptions) -> Result<(), CliError> {
run_command("repo registry", opts, |ctx| {
ctx.progress(format!("Looking up '{}'...", target));
let record = registry::resolve_target_interactive(target).map_err(|e| CliError::not_found(e))?;
if !ctx.use_json() {
ctx.info(format!("Name: {}", record.name));
ctx.info(format!("ID: {}", record.id));
ctx.info(format!("Origin: {}", record.origin));
if let Some(ref desc) = record.description {
ctx.info(format!("Desc: {}", desc));
}
ctx.info(format!("Created: {}", record.created));
ctx.info(format!("Updated: {}", record.updated));
if !record.head.is_empty() {
ctx.info("");
ctx.info("Branches:");
for (branch, cid_val) in &record.head {
let short_cid = if cid_val.len() > 16 {
&cid_val[..16]
} else {
cid_val
};
ctx.info(format!(" {} -> {}...", branch, short_cid));
}
}
if !record.trusted_sources.is_empty() {
ctx.info("");
ctx.info("Trusted sources:");
for src in &record.trusted_sources {
let short = if src.len() > 16 { &src[..16] } else { src };
ctx.info(format!(" {}...", short));
}
}
if !record.local_paths.is_empty() {
ctx.info("");
ctx.info("Local paths:");
for p in &record.local_paths {
let exists = p.join(".void").exists();
let marker = if exists { "" } else { " (missing)" };
ctx.info(format!(" {}{}", p.display(), marker));
}
}
}
Ok(RepoRegistryOutput {
id: record.id,
name: record.name,
origin: record.origin,
description: record.description,
head: record.head,
trusted_sources: record.trusted_sources,
local_paths: record
.local_paths
.iter()
.map(|p| p.display().to_string())
.collect(),
key_ref: record.key_ref,
created: record.created,
updated: record.updated,
})
})
}
pub fn run_unregister(target: &str, opts: &CliOptions) -> Result<(), CliError> {
run_command("repo unregister", opts, |ctx| {
ctx.progress(format!("Looking up '{}'...", target));
let record = registry::resolve_target_interactive(target).map_err(|e| CliError::not_found(e))?;
let id = record.id.clone();
let name = record.name.clone();
ctx.progress(format!("Removing '{}' from registry...", name));
registry::delete_record(&id)
.map_err(|e| CliError::internal(format!("failed to delete registry record: {}", e)))?;
if !ctx.use_json() {
ctx.info(format!(
"Unregistered repo '{}' ({})",
name,
&id[..8.min(id.len())]
));
}
Ok(RepoUnregisterOutput {
id,
name,
removed: true,
})
})
}
fn dir_size(path: &Path) -> u64 {
if !path.exists() {
return 0;
}
let mut size: u64 = 0;
if path.is_file() {
return fs::metadata(path).map(|m| m.len()).unwrap_or(0);
}
for entry in fs::read_dir(path).into_iter().flatten().flatten() {
let entry_path = entry.path();
if entry_path.is_dir() {
size += dir_size(&entry_path);
} else {
size += fs::metadata(&entry_path).map(|m| m.len()).unwrap_or(0);
}
}
size
}
fn count_commits(ctx: &void_core::VoidContext) -> Result<u64, CliError> {
use void_core::{
crypto::{CommitReader, EncryptedCommit},
metadata::Commit,
store::ObjectStoreExt,
};
let head_cid = refs::resolve_head(&ctx.paths.void_dir).map_err(void_err_to_cli)?;
let head_cid = match head_cid {
Some(cid) => cid,
None => return Ok(0),
};
let store = ctx.open_store().map_err(void_err_to_cli)?;
let mut count: u64 = 0;
let mut current_cid: Option<Vec<u8>> = Some(head_cid.into_bytes());
while let Some(cid_bytes) = current_cid.take() {
count += 1;
let cid_obj = cid::from_bytes(&cid_bytes)
.map_err(|e| CliError::internal(format!("invalid CID: {e}")))?;
let encrypted: EncryptedCommit = match store.get_blob(&cid_obj) {
Ok(data) => data,
Err(_) => break, };
let (commit_bytes, _reader) = CommitReader::open_with_vault(&ctx.crypto.vault, &encrypted)
.map_err(|e| CliError::internal(format!("failed to open commit: {e}")))?;
let commit: Commit = commit_bytes.parse()
.map_err(|e| CliError::internal(format!("failed to parse commit: {e}")))?;
current_cid = commit.first_parent().map(|p| p.as_bytes().to_vec());
}
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_code_extensions() {
assert!(is_code_extension("rs"));
assert!(is_code_extension("ts"));
assert!(is_code_extension("py"));
assert!(is_code_extension("go"));
assert!(!is_code_extension("exe"));
assert!(!is_code_extension("dll"));
assert!(!is_code_extension("bin"));
}
#[test]
fn test_extension_stats_serialization() {
let stats = ExtensionStats {
extension: ".rs".to_string(),
lines: 5000,
files: 50,
bytes: 150000,
percent: 45.5,
};
let json = serde_json::to_string(&stats).unwrap();
assert!(json.contains("\"extension\":\".rs\""));
assert!(json.contains("\"lines\":5000"));
assert!(json.contains("\"files\":50"));
assert!(json.contains("\"bytes\":150000"));
assert!(json.contains("\"percent\":45.5"));
}
#[test]
fn test_stat_output_serialization() {
let output = StatOutput {
stats: vec![ExtensionStats {
extension: ".rs".to_string(),
lines: 1000,
files: 10,
bytes: 50000,
percent: 50.0,
}],
total: TotalStats {
lines: 2000,
files: 20,
bytes: 100000,
},
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"stats\""));
assert!(json.contains("\"total\""));
}
#[test]
fn test_size_output_serialization() {
let output = SizeOutput {
void_dir: VoidDirSize {
total: 1048576,
objects: 1000000,
index: 48576,
},
working_tree: 500000,
total: 1548576,
largest_files: vec![LargeFile {
path: "big.bin".to_string(),
bytes: 100000,
}],
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"voidDir\""));
assert!(json.contains("\"workingTree\":500000"));
assert!(json.contains("\"largestFiles\""));
}
#[test]
fn test_info_output_serialization() {
let output = InfoOutput {
name: Some("my-repo".to_string()),
root: "/path/to/repo".to_string(),
head: HeadInfo {
ref_name: Some("trunk".to_string()),
cid: Some("bafy123".to_string()),
},
commits: 42,
remotes: vec!["origin".to_string()],
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"name\":\"my-repo\""));
assert!(json.contains("\"root\":\"/path/to/repo\""));
assert!(json.contains("\"ref\":\"trunk\""));
assert!(json.contains("\"cid\":\"bafy123\""));
assert!(json.contains("\"commits\":42"));
assert!(json.contains("\"remotes\":[\"origin\"]"));
}
}