use crate::target::Loaded;
use serde::Serialize;
use std::collections::BTreeMap;
use std::process::ExitCode;
#[derive(Serialize)]
struct Item {
path: String,
offset: usize,
stored_len: usize,
decompressed_len: Option<usize>,
}
#[derive(Default)]
struct Node { children: BTreeMap<String, Node>, item: Option<(usize, Option<usize>)> }
fn human(n: usize) -> String {
match n {
n if n < 1024 => format!("{n} B"),
n if n < 1 << 20 => format!("{:.1} KiB", n as f64 / 1024.0),
n => format!("{:.1} MiB", n as f64 / (1 << 20) as f64),
}
}
fn render(node: &Node, prefix: &str, out: &mut String) {
let last = node.children.len().saturating_sub(1);
for (i, (name, child)) in node.children.iter().enumerate() {
let (branch, indent) = if i == last { ("└── ", " ") } else { ("├── ", "│ ") };
let size = match child.item {
Some((s, Some(d))) if s == d => format!(" ({})", human(d)),
Some((s, Some(d))) => format!(" ({}, {} stored)", human(d), human(s)),
Some((s, None)) => format!(" ({} stored, does not decode)", human(s)),
None => String::new(),
};
out.push_str(&format!("{prefix}{branch}{name}{size}\n"));
render(child, &format!("{prefix}{indent}"), out);
}
}
fn tree(items: &[Item]) -> String {
let mut root = Node::default();
for it in items {
let mut node = &mut root;
for part in it.path.split('/').filter(|p| !p.is_empty()) {
node = node.children.entry(part.to_string()).or_default();
}
node.item = Some((it.stored_len, it.decompressed_len));
}
let mut out = String::new();
render(&root, "", &mut out);
out
}
pub fn run(loaded: &Loaded, json: bool) -> ExitCode {
let Some(t) = crate::table::read(&loaded.data) else {
eprintln!("no Tauri asset table found, so there are no asset paths to list. `untauri <app>` can still scan for the files.");
return ExitCode::from(2);
};
let mut items: Vec<Item> = t.assets.iter()
.map(|a| Item { path: a.path.clone().unwrap_or_default(), offset: a.offset, stored_len: a.compressed_len, decompressed_len: Some(a.bytes.len()) })
.chain(t.undecoded.iter().map(|k| {
let (offset, stored_len) = t.spans.get(k).copied().unwrap_or_default();
Item { path: k.clone(), offset, stored_len, decompressed_len: None }
}))
.collect();
items.sort_by(|a, b| a.path.cmp(&b.path));
let text = if json { serde_json::to_string_pretty(&items).unwrap() + "\n" } else {
let spans: std::collections::HashMap<usize, usize> = items.iter().map(|i| (i.offset, i.stored_len)).collect();
let stored: usize = spans.values().sum();
let full: usize = items.iter().filter_map(|i| i.decompressed_len).sum();
let how = if t.compressed { format!("{} brotli, {} decompressed", human(stored), human(full)) } else { format!("{} stored, uncompressed", human(stored)) };
format!("{} assets ({how})\n{}", items.len(), tree(&items))
};
use std::io::Write;
let _ = std::io::stdout().write_all(text.as_bytes());
ExitCode::SUCCESS
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renders_nested_paths_as_a_tree() {
let it = |p: &str, s, d| Item { path: p.into(), offset: 0, stored_len: s, decompressed_len: d };
let out = tree(&[it("/assets/a.js", 10, Some(40)), it("/assets/b.css", 5, None), it("/index.html", 7, Some(7))]);
assert_eq!(out, "├── assets\n│ ├── a.js (40 B, 10 B stored)\n│ └── b.css (5 B stored, does not decode)\n└── index.html (7 B)\n");
}
}