use std::collections::BTreeMap;
use std::ops::ControlFlow;
use wasmtime::{Caller, ExternRef};
use super::{ChargedDirIter, close_handle, effects, next_entry, stat_path};
use crate::runtime::StoreData;
use crate::runtime::filesystem::DirectoryEntry;
use crate::stdlib::shared::check_security_call;
const SHOWN_ENTRIES: usize = 100;
pub(super) async fn summarize(
caller: &mut Caller<'_, StoreData>,
path: &str,
) -> wasmtime::Result<String> {
check_list(caller, path)?;
let Some(stat) = stat_path(caller, path, true).await? else {
return Ok(format!("{path}: does not exist"));
};
if stat.kind != "directory" {
return Ok(format!(
"{path}: {}",
entry_details(&stat.kind, stat.size as u64, Some(stat.modified_ms))
));
}
let mut counts = Counts::default();
let mut shown: BTreeMap<(bool, String), DirectoryEntry> = BTreeMap::new();
let _ = for_each_entry(caller, path, |entry| {
counts.add(&entry);
shown.insert((entry.kind != "directory", entry.name.clone()), entry);
if shown.len() > SHOWN_ENTRIES {
shown.pop_last();
}
ControlFlow::Continue(())
})
.await?;
if counts.entries == 0 {
return Ok(format!("{path}: empty directory"));
}
let mut text = format!("{path}: directory, {}", counts.count_line());
for entry in shown.values() {
let modified = stat_path(caller, &entry.path, false)
.await?
.map(|stat| stat.modified_ms);
let name = if entry.kind == "directory" {
format!("{}/", entry.name)
} else {
entry.name.clone()
};
text.push_str(&format!(
"\n {name} ({})",
entry_details(entry.kind, entry.bytes, modified)
));
}
if counts.entries > shown.len() {
text.push_str(&format!(
"\n … {} more not shown; iterate `list({}, false)` for every entry",
counts.entries - shown.len(),
serde_json::Value::String(path.to_owned())
));
}
Ok(text)
}
pub(super) async fn is_empty(
caller: &mut Caller<'_, StoreData>,
path: &str,
) -> wasmtime::Result<bool> {
check_list(caller, path)?;
let Some(stat) = stat_path(caller, path, true).await? else {
wasmtime::bail!("fs.isEmpty {path}: no such file or directory");
};
match stat.kind.as_str() {
"file" => Ok(stat.size == 0.0),
"directory" => {
let walk = for_each_entry(caller, path, |_| ControlFlow::Break(())).await?;
Ok(walk.is_continue())
}
_ => wasmtime::bail!("fs.isEmpty {path}: not a file or directory"),
}
}
fn check_list(caller: &mut Caller<'_, StoreData>, path: &str) -> wasmtime::Result<()> {
check_security_call(
&mut *caller,
"fs.list",
serde_json::json!({ "path": path, "recursive": false }),
)?;
Ok(())
}
async fn for_each_entry(
caller: &mut Caller<'_, StoreData>,
path: &str,
mut visit: impl FnMut(DirectoryEntry) -> ControlFlow<()>,
) -> wasmtime::Result<ControlFlow<()>> {
let walk = effects::open_directory(caller, path.to_owned(), false).await?;
let directory = ChargedDirIter::new(walk, &caller.data().tenant_limits)
.map_err(|e| wasmtime::Error::new(e).context(format!("fs.list {path}")))?;
let handle = ExternRef::new(&mut *caller, Some(directory))?;
let walked: wasmtime::Result<ControlFlow<()>> = async {
while let Some(entry) = next_entry(caller, handle).await? {
if visit(entry).is_break() {
return Ok(ControlFlow::Break(()));
}
}
Ok(ControlFlow::Continue(()))
}
.await;
let closed = close_handle::<ChargedDirIter>(caller, handle).await;
let walk = walked?;
closed?;
Ok(walk)
}
#[derive(Default)]
struct Counts {
entries: usize,
files: usize,
directories: usize,
symlinks: usize,
others: usize,
file_bytes: u64,
}
impl Counts {
fn add(&mut self, entry: &DirectoryEntry) {
self.entries += 1;
match entry.kind {
"file" => {
self.files += 1;
self.file_bytes = self.file_bytes.saturating_add(entry.bytes);
}
"directory" => self.directories += 1,
"symlink" => self.symlinks += 1,
_ => self.others += 1,
}
}
fn count_line(&self) -> String {
let mut parts = Vec::new();
if self.files > 0 {
parts.push(format!(
"{} ({})",
plural(self.files, "file", "files"),
human_size(self.file_bytes)
));
}
if self.directories > 0 {
parts.push(plural(self.directories, "directory", "directories"));
}
if self.symlinks > 0 {
parts.push(plural(self.symlinks, "symlink", "symlinks"));
}
if self.others > 0 {
parts.push(plural(self.others, "other", "others"));
}
format!(
"{}: {}",
plural(self.entries, "entry", "entries"),
parts.join(", ")
)
}
}
fn entry_details(kind: &str, bytes: u64, modified_ms: Option<f64>) -> String {
let mut parts = vec![kind.to_string()];
if kind == "file" {
parts.push(human_size(bytes));
}
if let Some(modified) = modified_ms.and_then(timestamp) {
parts.push(format!("modified {modified}"));
}
parts.join(", ")
}
fn timestamp(modified_ms: f64) -> Option<jiff::Timestamp> {
if modified_ms <= 0.0 {
return None;
}
jiff::Timestamp::from_second((modified_ms / 1000.0) as i64).ok()
}
fn human_size(bytes: u64) -> String {
const UNITS: [&str; 4] = ["KB", "MB", "GB", "TB"];
if bytes < 1024 {
return format!("{bytes} B");
}
let mut size = bytes as f64 / 1024.0;
let mut unit = 0;
while size >= 1024.0 && unit + 1 < UNITS.len() {
size /= 1024.0;
unit += 1;
}
format!("{size:.1} {}", UNITS[unit])
}
fn plural(count: usize, one: &str, many: &str) -> String {
format!("{count} {}", if count == 1 { one } else { many })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sizes_read_in_the_largest_whole_unit() {
assert_eq!(human_size(0), "0 B");
assert_eq!(human_size(1023), "1023 B");
assert_eq!(human_size(12 * 1024 + 205), "12.2 KB");
assert_eq!(human_size(3 * 1024 * 1024), "3.0 MB");
}
#[test]
fn counts_name_each_kind_present() {
let entry = |kind: &'static str, bytes| DirectoryEntry {
name: String::new(),
path: String::new(),
kind,
bytes,
};
let mut counts = Counts::default();
counts.add(&entry("file", 2048));
counts.add(&entry("file", 0));
counts.add(&entry("directory", 0));
counts.add(&entry("symlink", 0));
assert_eq!(
counts.count_line(),
"4 entries: 2 files (2.0 KB), 1 directory, 1 symlink"
);
}
}