Skip to main content

hexz_cli/cmd/data/
ls.rs

1//! List Hexz archives in a directory and render their lineage as a tree.
2
3use anyhow::{Context, Result};
4use hexz_core::format::header::Header;
5use hexz_core::format::index::MasterIndex;
6use indicatif::HumanBytes;
7use std::collections::{HashMap, HashSet};
8use std::fs::File;
9use std::path::{Path, PathBuf};
10
11use colored::Colorize;
12use crate::ui::color::{Palette, palette};
13
14struct ArchiveInfo {
15    path: PathBuf,
16    file_size: u64,
17    /// First declared parent path (if any).
18    parent: Option<String>,
19    /// Number of data blocks (not parent-refs, not sparse).
20    data_blocks: usize,
21}
22
23fn read_archive_info(path: &Path) -> Result<ArchiveInfo> {
24    use std::io::{Read, Seek, SeekFrom};
25
26    use hexz_core::format::index::IndexPage;
27
28    let mut f = File::open(path)?;
29    let file_size = f.metadata()?.len();
30    let header = Header::read_from(&mut f)?;
31    let master = MasterIndex::read_from(&mut f, header.index_offset)?;
32
33    let parent = header.parent_paths.into_iter().next();
34
35    let mut data_blocks = 0usize;
36    for page_meta in &master.main_pages {
37        let _ = f.seek(SeekFrom::Start(page_meta.offset))?;
38        let mut buf = vec![0u8; page_meta.length as usize];
39        f.read_exact(&mut buf)?;
40        let page: IndexPage = bincode::deserialize(&buf)?;
41        for block in page.blocks {
42            if !block.is_sparse() && !block.is_parent_ref() {
43                data_blocks += 1;
44            }
45        }
46    }
47
48    Ok(ArchiveInfo {
49        path: path.to_path_buf(),
50        file_size,
51        parent,
52        data_blocks,
53    })
54}
55
56fn print_tree(
57    idx: usize,
58    entries: &[ArchiveInfo],
59    children: &HashMap<usize, Vec<usize>>,
60    external_parent: &[Option<&str>],
61    prefix: &str,
62    is_last: bool,
63    p: &'static Palette,
64) {
65    let a = &entries[idx];
66    let connector = if is_last { "└──" } else { "├──" };
67    let name = a.path.file_name().unwrap_or_default().to_string_lossy();
68
69    let (ann_text, ann_color) = if let Some(ext) = external_parent[idx] {
70        let parent_name = Path::new(ext)
71            .file_name()
72            .unwrap_or_default()
73            .to_string_lossy()
74            .into_owned();
75        (format!("← {parent_name} (external)"), p.gray)
76    } else if a.parent.is_none() {
77        ("standalone".to_string(), p.gray)
78    } else {
79        (format!("+{} new blocks", a.data_blocks), p.dim)
80    };
81
82    let name_padded = format!("{name:<32}");
83    let size_str = format!("{:>10}", HumanBytes(a.file_size));
84
85    println!(
86        "  {}{}{}{} {}{}{} {}{}{}  {}{}{}",
87        prefix,
88        p.gray,
89        connector,
90        p.reset,
91        p.bold,
92        name_padded,
93        p.reset,
94        p.green,
95        size_str,
96        p.reset,
97        ann_color,
98        ann_text,
99        p.reset,
100    );
101
102    let mut kids = children.get(&idx).cloned().unwrap_or_default();
103    kids.sort_by_key(|&i| &entries[i].path);
104
105    let segment = if is_last {
106        "    ".to_string()
107    } else {
108        format!("{}│{}   ", p.gray, p.reset)
109    };
110    let child_prefix = format!("{prefix}{segment}");
111
112    for (j, &child) in kids.iter().enumerate() {
113        let last = j == kids.len() - 1;
114        print_tree(
115            child,
116            entries,
117            children,
118            external_parent,
119            &child_prefix,
120            last,
121            p,
122        );
123    }
124}
125
126/// Execute the `hexz log` command to list archives and their lineage.
127pub fn run(dir: &Path) -> Result<()> {
128    let entries: Vec<ArchiveInfo> = std::fs::read_dir(dir)
129        .with_context(|| format!("Cannot read directory: {}", dir.display()))?
130        .filter_map(std::result::Result::ok)
131        .filter(|e| e.path().extension().is_some_and(|ext| ext == "hxz"))
132        .map(|e| {
133            let p = e.path();
134            read_archive_info(&p).with_context(|| format!("Failed to read {}", p.display()))
135        })
136        .collect::<Result<Vec<_>>>()?;
137
138    if entries.is_empty() {
139        println!("No .hxz archives found in {}", dir.display());
140        return Ok(());
141    }
142
143    let name_to_idx: HashMap<String, usize> = entries
144        .iter()
145        .enumerate()
146        .map(|(i, a)| {
147            let name = a
148                .path
149                .file_name()
150                .unwrap_or_default()
151                .to_string_lossy()
152                .into_owned();
153            (name, i)
154        })
155        .collect();
156
157    let parent_idx: Vec<Option<usize>> = entries
158        .iter()
159        .map(|a| {
160            a.parent.as_deref().and_then(|p| {
161                let parent_name = Path::new(p)
162                    .file_name()
163                    .unwrap_or_default()
164                    .to_string_lossy()
165                    .into_owned();
166                name_to_idx.get(&parent_name).copied()
167            })
168        })
169        .collect();
170
171    let external_parent: Vec<Option<&str>> = entries
172        .iter()
173        .zip(&parent_idx)
174        .map(|(a, resolved)| {
175            if resolved.is_none() {
176                a.parent.as_deref()
177            } else {
178                None
179            }
180        })
181        .collect();
182
183    let mut children: HashMap<usize, Vec<usize>> = HashMap::new();
184    let mut has_parent: HashSet<usize> = HashSet::new();
185    for (i, p) in parent_idx.iter().enumerate() {
186        if let Some(pi) = p {
187            children.entry(*pi).or_default().push(i);
188            let _ = has_parent.insert(i);
189        }
190    }
191
192    let mut roots: Vec<usize> = (0..entries.len())
193        .filter(|i| !has_parent.contains(i))
194        .collect();
195    roots.sort_by_key(|&i| &entries[i].path);
196
197    let p = palette();
198    let total_size: u64 = entries.iter().map(|a| a.file_size).sum();
199
200    let dir_str = dir.to_string_lossy();
201    let dir_base = dir_str.trim_end_matches('/');
202    println!("{} {}/", "╭".dimmed(), dir_base.cyan());
203
204    for (i, &root) in roots.iter().enumerate() {
205        let last = i == roots.len() - 1;
206        print_tree(root, &entries, &children, &external_parent, "│ ".dimmed().to_string().as_str(), last, p);
207    }
208
209    let archive_count = format!("{} archive{}", entries.len(), if entries.len() == 1 { "" } else { "s" });
210    println!(
211        "{} {}   {} on disk",
212        "╰".dimmed(),
213        archive_count.bright_black(),
214        HumanBytes(total_size).to_string().green(),
215    );
216
217    Ok(())
218}