use std::collections::BTreeMap;
use std::fs::File;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::Local;
use walkdir::WalkDir;
use zip::write::SimpleFileOptions;
use zip::ZipWriter;
use super::{
AndroidBuildInfo, BuildDetails, BuildInfo, BuildInfoFull, BuildMetadata, EnvironmentInfo,
GitInfo, IosBuildInfo, MacosBuildInfo, PlatformBuildInfo, ProjectInfo,
};
pub const ARCHIVE_DIR_LIB: &str = "lib";
pub const ARCHIVE_DIR_STATIC: &str = "static";
pub const ARCHIVE_DIR_SHARED: &str = "shared";
pub const ARCHIVE_DIR_INCLUDE: &str = "include";
pub const ARCHIVE_DIR_FRAMEWORKS: &str = "lib"; pub const ARCHIVE_DIR_HAARS: &str = "haars";
pub const ARCHIVE_DIR_SYMBOLS: &str = "symbols";
pub const ARCHIVE_DIR_OBJ: &str = "obj";
pub const ARCHIVE_DIR_META: &str = "meta";
pub const BUILD_INFO_FILE: &str = "build_info.json";
pub const ARCHIVE_INFO_FILE: &str = "archive_info.json";
const ARCHIVE_EXCLUDE_FILES: &[&str] = &["CPPLINT.cfg", ".clang-format", ".clang-tidy"];
fn should_include_file_in_archive(filename: &str) -> bool {
!ARCHIVE_EXCLUDE_FILES.contains(&filename)
}
pub fn get_unified_include_path(project_name: &str, src_include_dir: &Path) -> String {
let project_subdir = src_include_dir.join(project_name);
if project_subdir.is_dir() {
ARCHIVE_DIR_INCLUDE.to_string()
} else {
format!("{}/{}", ARCHIVE_DIR_INCLUDE, project_name)
}
}
pub fn create_archive(source_dir: &Path, archive_path: &Path) -> Result<()> {
let file = File::create(archive_path)
.with_context(|| format!("Failed to create archive: {}", archive_path.display()))?;
let mut zip = ZipWriter::new(file);
let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
for entry in WalkDir::new(source_dir) {
let entry = entry.context("Failed to read directory entry")?;
let path = entry.path();
let relative_path = path
.strip_prefix(source_dir)
.context("Failed to get relative path")?;
if relative_path.as_os_str().is_empty() {
continue;
}
let path_str = relative_path.to_string_lossy().replace('\\', "/");
if path.is_dir() {
zip.add_directory(&path_str, options)
.with_context(|| format!("Failed to add directory to archive: {}", path_str))?;
} else {
zip.start_file(&path_str, options)
.with_context(|| format!("Failed to start file in archive: {}", path_str))?;
let mut file = File::open(path)
.with_context(|| format!("Failed to open file: {}", path.display()))?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer)
.with_context(|| format!("Failed to read file: {}", path.display()))?;
zip.write_all(&buffer)
.with_context(|| format!("Failed to write file to archive: {}", path_str))?;
}
}
zip.finish().context("Failed to finish ZIP archive")?;
Ok(())
}
pub fn collect_file_metadata(dir: &Path) -> Result<Vec<FileMetadata>> {
let mut files = Vec::new();
for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_file() {
let relative = path
.strip_prefix(dir)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/");
let metadata = std::fs::metadata(path)?;
files.push(FileMetadata {
path: relative,
size: metadata.len(),
compressed_size: 0, });
}
}
Ok(files)
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct FileMetadata {
pub path: String,
pub size: u64,
pub compressed_size: u64,
}
pub fn get_git_commit() -> Option<String> {
std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
}
pub fn get_git_branch() -> Option<String> {
std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
}
pub fn create_build_info(
name: &str,
version: &str,
platform: &str,
architectures: &[String],
link_type: &str,
toolchain: Option<&str>,
) -> BuildInfo {
BuildInfo {
project: name.to_string(),
platform: platform.to_string(),
version: version.to_string(),
link_type: link_type.to_string(),
build_time: Local::now().format("%Y-%m-%dT%H:%M:%S%.6f").to_string(),
build_host: get_build_host(),
architectures: architectures.to_vec(),
toolchain: toolchain.map(String::from),
git_commit: get_git_commit(),
git_branch: get_git_branch(),
}
}
fn get_build_host() -> String {
match std::env::consts::OS {
"macos" => "Darwin".to_string(),
"linux" => "Linux".to_string(),
"windows" => "Windows".to_string(),
other => other.to_string(),
}
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct ArchiveMetadata {
pub version: String,
pub generated_at: String,
pub archive_name: String,
pub archive_size: u64,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct ArchiveSummary {
pub total_files: usize,
pub total_size: u64,
pub library_count: usize,
pub platforms: Vec<String>,
pub architectures: Vec<String>,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct ArchiveInfo {
pub archive_metadata: ArchiveMetadata,
pub files: Vec<FileMetadata>,
pub summary: ArchiveSummary,
}
pub fn create_archive_info(
archive_name: &str,
archive_size: u64,
files: Vec<FileMetadata>,
platform: &str,
architectures: &[String],
) -> ArchiveInfo {
let total_files = files.len();
let total_size: u64 = files.iter().map(|f| f.size).sum();
let library_count = files
.iter()
.filter(|f| {
f.path.ends_with(".a")
|| f.path.ends_with(".so")
|| f.path.ends_with(".lib")
|| f.path.ends_with(".dll")
|| f.path.ends_with(".dylib")
})
.count();
ArchiveInfo {
archive_metadata: ArchiveMetadata {
version: "1.0".to_string(),
generated_at: chrono::Utc::now().to_rfc3339(),
archive_name: archive_name.to_string(),
archive_size,
},
files,
summary: ArchiveSummary {
total_files,
total_size,
library_count,
platforms: vec![platform.to_string()],
architectures: architectures.to_vec(),
},
}
}
pub fn write_archive_info(archive_info: &ArchiveInfo, path: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(archive_info).context("Failed to serialize archive_info")?;
std::fs::write(path, json)
.with_context(|| format!("Failed to write archive_info.json to {}", path.display()))?;
Ok(())
}
pub fn write_build_info(build_info: &BuildInfo, path: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(build_info).context("Failed to serialize build_info")?;
std::fs::write(path, json)
.with_context(|| format!("Failed to write build_info.json to {}", path.display()))?;
Ok(())
}
pub fn create_build_info_full(
project_name: &str,
version: &str,
platform: &str,
project_root: &Path,
) -> BuildInfoFull {
let now = Local::now();
let timestamp = now.timestamp();
let git_info = get_git_info_full(project_root);
let platform_info = get_platform_build_info(platform);
let ccgo_version = env!("CARGO_PKG_VERSION").to_string();
BuildInfoFull {
build_metadata: BuildMetadata {
version: "1.0".to_string(),
generated_at: now.format("%Y-%m-%dT%H:%M:%S%.6f").to_string(),
generator: "ccgo".to_string(),
},
project: ProjectInfo {
name: project_name.to_uppercase(),
version: version.to_string(),
},
git: git_info,
build: BuildDetails {
time: now.format("%Y-%m-%d %H:%M:%S").to_string(),
timestamp,
platform: platform.to_string(),
platform_info,
},
environment: EnvironmentInfo {
os: get_build_host(),
os_version: get_os_version(),
python_version: None, ccgo_version,
},
}
}
fn get_git_info_full(project_root: &Path) -> GitInfo {
let branch = get_git_branch_from_path(project_root).unwrap_or_default();
let revision = get_git_commit_from_path(project_root).unwrap_or_default();
let revision_full = get_git_commit_full_from_path(project_root).unwrap_or_else(|| revision.clone());
let tag = get_git_tag_from_path(project_root).unwrap_or_default();
let is_dirty = check_git_dirty(project_root);
let remote_url = get_git_remote_url(project_root).unwrap_or_default();
GitInfo {
branch,
revision,
revision_full,
tag,
is_dirty,
remote_url,
}
}
fn get_git_branch_from_path(project_root: &Path) -> Option<String> {
std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(project_root)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
}
fn get_git_commit_full_from_path(project_root: &Path) -> Option<String> {
std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(project_root)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
}
fn get_git_commit_from_path(project_root: &Path) -> Option<String> {
std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.current_dir(project_root)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
}
fn get_git_tag_from_path(project_root: &Path) -> Option<String> {
std::process::Command::new("git")
.args(["describe", "--tags", "--abbrev=0"])
.current_dir(project_root)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
}
fn check_git_dirty(project_root: &Path) -> bool {
std::process::Command::new("git")
.args(["status", "--porcelain"])
.current_dir(project_root)
.output()
.ok()
.map(|o| !o.stdout.is_empty())
.unwrap_or(false)
}
fn get_git_remote_url(project_root: &Path) -> Option<String> {
let output = std::process::Command::new("git")
.args(["config", "--get", "remote.origin.url"])
.current_dir(project_root)
.output()
.ok()?;
if output.status.success() {
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
Some(anonymize_git_url(&url))
} else {
None
}
}
fn anonymize_git_url(url: &str) -> String {
if url.contains('@') && url.contains(':') {
if let Some(colon_idx) = url.rfind(':') {
let rest = &url[colon_idx + 1..];
if let Some(slash_idx) = rest.find('/') {
let username = &rest[..slash_idx];
let suffix = &rest[slash_idx..];
if username.len() > 4 {
let masked = format!("/{}***{}", &username[..2], &username[username.len() - 1..]);
return format!("{}{}", masked, suffix);
} else if !username.is_empty() {
return format!("/{}***{}", &username[..1], suffix);
}
}
}
}
if let Some(idx) = url.find("//") {
let after_protocol = &url[idx + 2..];
if let Some(host_end) = after_protocol.find('/') {
let path = &after_protocol[host_end + 1..];
if let Some(slash_idx) = path.find('/') {
let username = &path[..slash_idx];
let suffix = &path[slash_idx..];
if username.len() > 4 {
let masked = format!("/{}***{}", &username[..2], &username[username.len() - 1..]);
return format!("{}{}", masked, suffix);
} else if !username.is_empty() {
return format!("/{}***{}", &username[..1], suffix);
}
}
}
}
url.to_string()
}
fn get_os_version() -> String {
std::process::Command::new("uname")
.arg("-v")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|| std::env::consts::OS.to_string())
}
fn get_platform_build_info(platform: &str) -> Option<PlatformBuildInfo> {
match platform.to_lowercase().as_str() {
"ios" | "watchos" | "tvos" => {
let (xcode_version, xcode_build) = get_xcode_version();
Some(PlatformBuildInfo::Ios {
ios: IosBuildInfo {
xcode_version,
xcode_build,
},
})
}
"macos" => {
let (xcode_version, xcode_build) = get_xcode_version();
Some(PlatformBuildInfo::Macos {
macos: MacosBuildInfo {
xcode_version,
xcode_build,
},
})
}
"android" => {
let (ndk_version, min_sdk) = get_android_ndk_info();
Some(PlatformBuildInfo::Android {
android: AndroidBuildInfo {
ndk_version,
stl: "c++_shared".to_string(),
min_sdk_version: min_sdk,
},
})
}
_ => None,
}
}
fn get_xcode_version() -> (String, String) {
let output = std::process::Command::new("xcodebuild")
.arg("-version")
.output()
.ok();
if let Some(output) = output {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let lines: Vec<&str> = stdout.lines().collect();
if lines.len() >= 2 {
let version = lines[0].replace("Xcode ", "");
let build = lines[1].replace("Build version ", "");
return (version, build);
}
}
}
("unknown".to_string(), "unknown".to_string())
}
fn get_android_ndk_info() -> (String, String) {
let min_sdk = "21".to_string();
let ndk_path = std::env::var("ANDROID_NDK_HOME")
.or_else(|_| std::env::var("ANDROID_NDK_ROOT"))
.or_else(|_| std::env::var("ANDROID_NDK"))
.ok();
if let Some(ndk_path) = ndk_path {
let source_props = std::path::Path::new(&ndk_path).join("source.properties");
if let Ok(content) = std::fs::read_to_string(&source_props) {
for line in content.lines() {
if line.starts_with("Pkg.Revision") {
if let Some(version) = line.split('=').nth(1) {
let version = version.trim();
if let Some(major) = version.split('.').next() {
return (format!("r{}", major), min_sdk);
}
}
}
}
}
}
("unknown".to_string(), min_sdk)
}
pub fn print_build_info_json(build_info: &BuildInfoFull) {
if let Ok(json) = serde_json::to_string_pretty(build_info) {
println!("\n[[==BUILD_INFO_JSON==]]");
println!("{}", json);
println!("[[==END_BUILD_INFO_JSON==]]");
}
}
#[derive(Debug)]
pub struct ArchiveBuilder {
name: String,
version: String,
publish_suffix: String,
is_release: bool,
platform: String,
staging_dir: PathBuf,
output_dir: PathBuf,
}
impl ArchiveBuilder {
pub fn new(
name: impl Into<String>,
version: impl Into<String>,
publish_suffix: impl Into<String>,
is_release: bool,
platform: impl Into<String>,
output_dir: PathBuf,
) -> Result<Self> {
let name = name.into();
let platform = platform.into().to_lowercase();
let staging_dir = tempfile::tempdir()?.keep();
Ok(Self {
name,
version: version.into(),
publish_suffix: publish_suffix.into(),
is_release,
platform,
staging_dir,
output_dir,
})
}
pub fn staging_dir(&self) -> &Path {
&self.staging_dir
}
pub fn add_file(&self, source: &Path, dest_relative: &str) -> Result<()> {
let dest = self.staging_dir.join(dest_relative);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(source, &dest)?;
Ok(())
}
pub fn add_directory(&self, source: &Path, dest_relative: &str) -> Result<()> {
let dest = self.staging_dir.join(dest_relative);
copy_dir_all(source, &dest)?;
Ok(())
}
pub fn add_directory_filtered(
&self,
source: &Path,
dest_relative: &str,
extensions: &[&str],
) -> Result<()> {
let dest = self.staging_dir.join(dest_relative);
copy_dir_filtered(source, &dest, extensions)?;
Ok(())
}
pub fn add_files_flat(
&self,
source: &Path,
dest_relative: &str,
extensions: &[&str],
) -> Result<()> {
let dest = self.staging_dir.join(dest_relative);
std::fs::create_dir_all(&dest)?;
for entry in std::fs::read_dir(source)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
continue;
}
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let matches = extensions.iter().any(|ext| {
if let Some(file_ext) = path.extension().and_then(|e| e.to_str()) {
if file_ext == *ext {
return true;
}
}
file_name.contains(&format!(".{}.", ext)) || file_name.contains(&format!(".{}", ext))
});
if matches {
let dest_path = dest.join(entry.file_name());
std::fs::copy(&path, &dest_path)?;
}
}
Ok(())
}
pub fn create_sdk_archive(&self, architectures: &[String], link_type: &str) -> Result<PathBuf> {
self.create_sdk_archive_with_toolchain(architectures, link_type, None)
}
pub fn create_sdk_archive_with_toolchain(
&self,
architectures: &[String],
link_type: &str,
toolchain: Option<&str>,
) -> Result<PathBuf> {
let meta_dir = self.staging_dir.join(ARCHIVE_DIR_META).join(&self.platform);
std::fs::create_dir_all(&meta_dir)?;
let file_metadata = collect_file_metadata(&self.staging_dir)?;
let build_info = create_build_info(
&self.name,
&self.version,
&self.platform,
architectures,
link_type,
toolchain,
);
let build_info_path = meta_dir.join(BUILD_INFO_FILE);
write_build_info(&build_info, &build_info_path)?;
let archive_name = if self.publish_suffix.is_empty() {
format!(
"{}_{}_SDK-{}.zip",
self.name.to_uppercase(),
self.platform.to_uppercase(),
self.version
)
} else {
format!(
"{}_{}_SDK-{}-{}.zip",
self.name.to_uppercase(),
self.platform.to_uppercase(),
self.version,
self.publish_suffix
)
};
let archive_path = self.output_dir.join(&archive_name);
std::fs::create_dir_all(&self.output_dir)?;
create_archive(&self.staging_dir, &archive_path)?;
let archive_size = std::fs::metadata(&archive_path)?.len();
let archive_info = create_archive_info(
&archive_name,
archive_size,
file_metadata,
&self.platform,
architectures,
);
let archive_info_path = meta_dir.join(ARCHIVE_INFO_FILE);
write_archive_info(&archive_info, &archive_info_path)?;
create_archive(&self.staging_dir, &archive_path)?;
Ok(archive_path)
}
pub fn lib_path(&self, link_type: &str, arch: Option<&str>, lib_name: &str) -> String {
let mut parts = vec![ARCHIVE_DIR_LIB, &self.platform, link_type];
if let Some(a) = arch {
parts.push(a);
}
parts.push(lib_name);
parts.join("/")
}
pub fn static_lib_path(&self, arch: Option<&str>, lib_name: &str) -> String {
self.lib_path(ARCHIVE_DIR_STATIC, arch, lib_name)
}
pub fn shared_lib_path(&self, arch: Option<&str>, lib_name: &str) -> String {
self.lib_path(ARCHIVE_DIR_SHARED, arch, lib_name)
}
pub fn create_symbols_archive(&self, symbols_dir: &Path) -> Result<PathBuf> {
let archive_name = if self.publish_suffix.is_empty() {
format!(
"{}_{}_SDK-{}-SYMBOLS.zip",
self.name.to_uppercase(),
self.platform.to_uppercase(),
self.version
)
} else {
format!(
"{}_{}_SDK-{}-{}-SYMBOLS.zip",
self.name.to_uppercase(),
self.platform.to_uppercase(),
self.version,
self.publish_suffix
)
};
let archive_path = self.output_dir.join(&archive_name);
std::fs::create_dir_all(&self.output_dir)?;
create_archive(symbols_dir, &archive_path)?;
Ok(archive_path)
}
pub fn cleanup(self) -> Result<()> {
std::fs::remove_dir_all(&self.staging_dir).ok();
Ok(())
}
}
fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let path = entry.path();
let file_name = entry.file_name();
let file_name_str = file_name.to_string_lossy();
let dest_path = dst.join(&file_name);
if path.is_dir() {
copy_dir_all(&path, &dest_path)?;
} else {
if should_include_file_in_archive(&file_name_str) {
std::fs::copy(&path, &dest_path)?;
}
}
}
Ok(())
}
fn copy_dir_filtered(src: &Path, dst: &Path, extensions: &[&str]) -> Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let path = entry.path();
let dest_path = dst.join(entry.file_name());
if path.is_dir() {
copy_dir_filtered(&path, &dest_path, extensions)?;
} else {
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let matches = extensions.iter().any(|ext| {
if let Some(file_ext) = path.extension().and_then(|e| e.to_str()) {
if file_ext == *ext {
return true;
}
}
file_name.contains(&format!(".{}.", ext)) || file_name.contains(&format!(".{}", ext))
});
if matches {
std::fs::copy(&path, &dest_path)?;
}
}
}
Ok(())
}
fn detect_archive_format(path: &Path) -> Result<ArchiveFormat> {
let mut file = File::open(path)
.with_context(|| format!("Failed to open archive: {}", path.display()))?;
let mut header = [0u8; 4];
file.read_exact(&mut header).ok();
if &header[..2] == b"PK" {
return Ok(ArchiveFormat::Zip);
}
if header[..2] == [0x1F, 0x8B] {
return Ok(ArchiveFormat::TarGz);
}
Ok(ArchiveFormat::Unknown)
}
#[derive(Debug, PartialEq)]
enum ArchiveFormat {
Zip,
TarGz,
Unknown,
}
pub fn print_zip_tree(archive_path: &Path, indent: &str) -> Result<()> {
let format = detect_archive_format(archive_path)?;
match format {
ArchiveFormat::Zip => print_zip_tree_impl(archive_path, indent),
ArchiveFormat::TarGz => print_targz_tree(archive_path, indent),
ArchiveFormat::Unknown => {
if print_zip_tree_impl(archive_path, indent).is_ok() {
Ok(())
} else {
print_targz_tree(archive_path, indent)
}
}
}
}
fn print_zip_tree_impl(archive_path: &Path, indent: &str) -> Result<()> {
use super::elf::{get_library_info, is_library_file};
use zip::ZipArchive;
let file = File::open(archive_path)
.with_context(|| format!("Failed to open archive: {}", archive_path.display()))?;
let mut zip = ZipArchive::new(file)
.with_context(|| format!("Failed to read ZIP archive: {}", archive_path.display()))?;
let mut file_infos: Vec<(String, u64, String)> = Vec::new();
for i in 0..zip.len() {
let mut entry = zip.by_index(i)?;
let path = entry.name().to_string();
let size = entry.size();
if path.ends_with('/') {
continue;
}
let filename = path.split('/').next_back().unwrap_or(&path);
let lib_info = if is_library_file(filename, &path) {
let mut data = Vec::with_capacity(size as usize);
if entry.read_to_end(&mut data).is_ok() {
get_library_info(&data, filename, &path).to_display_string()
} else {
String::new()
}
} else {
String::new()
};
file_infos.push((path, size, lib_info));
}
let mut tree: BTreeMap<String, TreeNode> = BTreeMap::new();
for (path, size, lib_info) in file_infos {
let parts: Vec<&str> = path.split('/').collect();
let mut current = &mut tree;
for (idx, part) in parts.iter().enumerate() {
let is_last = idx == parts.len() - 1;
if is_last {
current.insert(
part.to_string(),
TreeNode::File {
size,
lib_info: lib_info.clone(),
},
);
} else {
current = match current
.entry(part.to_string())
.or_insert_with(|| TreeNode::Dir(BTreeMap::new()))
{
TreeNode::Dir(children) => children,
_ => unreachable!("We just inserted a Dir"),
};
}
}
}
eprintln!("{}ZIP contents:", indent);
print_tree_level(&tree, indent, "");
Ok(())
}
enum TreeNode {
File { size: u64, lib_info: String },
Dir(BTreeMap<String, TreeNode>),
}
fn print_tree_level(tree: &BTreeMap<String, TreeNode>, base_indent: &str, prefix: &str) {
let items: Vec<_> = tree.iter().collect();
let len = items.len();
for (i, (name, node)) in items.iter().enumerate() {
let is_last = i == len - 1;
let connector = if is_last { "└── " } else { "├── " };
match node {
TreeNode::File { size, lib_info } => {
let size_mb = *size as f64 / (1024.0 * 1024.0);
let size_str = if size_mb >= 0.01 {
format!("({:.2} MB)", size_mb)
} else {
let size_kb = *size as f64 / 1024.0;
format!("({:.1} KB)", size_kb)
};
eprintln!(
"{}{}{}{} {}{}",
base_indent, prefix, connector, name, size_str, lib_info
);
}
TreeNode::Dir(children) => {
eprintln!("{}{}{}{}/", base_indent, prefix, connector, name);
let new_prefix = if is_last {
format!("{} ", prefix)
} else {
format!("{}│ ", prefix)
};
print_tree_level(children, base_indent, &new_prefix);
}
}
}
}
fn print_targz_tree(archive_path: &Path, indent: &str) -> Result<()> {
use flate2::read::GzDecoder;
use tar::Archive;
let file = File::open(archive_path)
.with_context(|| format!("Failed to open archive: {}", archive_path.display()))?;
let decoder = GzDecoder::new(file);
let mut archive = Archive::new(decoder);
let mut file_infos: Vec<(String, u64)> = Vec::new();
for entry in archive.entries()? {
let entry = entry?;
let path = entry.path()?.to_string_lossy().to_string();
let size = entry.size();
if entry.header().entry_type().is_dir() {
continue;
}
file_infos.push((path, size));
}
let mut tree: BTreeMap<String, TreeNode> = BTreeMap::new();
for (path, size) in file_infos {
let parts: Vec<&str> = path.split('/').collect();
let mut current = &mut tree;
for (idx, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
let is_last = idx == parts.len() - 1;
if is_last {
current.insert(
part.to_string(),
TreeNode::File {
size,
lib_info: String::new(), },
);
} else {
current = match current
.entry(part.to_string())
.or_insert_with(|| TreeNode::Dir(BTreeMap::new()))
{
TreeNode::Dir(children) => children,
_ => unreachable!("We just inserted a Dir"),
};
}
}
}
eprintln!("{}Archive contents:", indent);
print_tree_level(&tree, indent, "");
Ok(())
}