///|
/// Dimension used when explaining how a directory's contents changed.
pub(all) enum CompositionDimension {
Category
Extension
} derive(Eq, Debug, ToJson, FromJson)
///|
/// Change in one file category or extension between two snapshots.
pub(all) struct CompositionChange {
name : String
before_bytes : Int64
after_bytes : Int64
byte_delta : Int64
before_files : Int
after_files : Int
file_delta : Int
share_before_per_mille : Int
share_after_per_mille : Int
} derive(Eq, Debug, ToJson, FromJson)
///|
/// Complete, serializable description of storage composition drift.
pub(all) struct CompositionDrift {
dimension : CompositionDimension
before_total_bytes : Int64
after_total_bytes : Int64
net_bytes : Int64
changes : Array[CompositionChange]
} derive(Eq, Debug, ToJson, FromJson)
///|
pub fn composition_dimension_from_name(
name : String,
) -> CompositionDimension raise {
match name.trim().to_lower() {
"category" | "categories" | "type" | "types" => Category
"extension" | "extensions" | "ext" => Extension
value => raise Failure("Unknown composition dimension: " + value.to_owned())
}
}
///|
pub fn composition_dimension_name(dimension : CompositionDimension) -> String {
match dimension {
Category => "category"
Extension => "extension"
}
}
///|
fn composition_stats(
snapshot : Snapshot,
dimension : CompositionDimension,
) -> Array[CategoryStat] {
match dimension {
Category => category_stats(snapshot)
Extension => extension_stats(snapshot)
}
}
///|
fn stats_by_name(stats : Array[CategoryStat]) -> Map[String, CategoryStat] {
let result : Map[String, CategoryStat] = Map([])
for stat in stats {
result[stat.name] = stat
}
result
}
///|
fn per_mille(value : Int64, total : Int64) -> Int {
if total <= 0L {
0
} else {
(value * 1000L / total).to_int()
}
}
///|
fn absolute_bytes(value : Int64) -> Int64 {
if value < 0L {
-value
} else {
value
}
}
///|
/// Compare two snapshots by category or file extension. Entries that disappear
/// remain visible with an after-value of zero, so reports never hide removals.
pub fn compare_composition(
before : Snapshot,
after : Snapshot,
dimension : CompositionDimension,
) -> CompositionDrift {
let before_total = before.total_size()
let after_total = after.total_size()
let before_map = stats_by_name(composition_stats(before, dimension))
let after_map = stats_by_name(composition_stats(after, dimension))
let names : Map[String, Bool] = Map([])
for name, _ in before_map {
names[name] = true
}
for name, _ in after_map {
names[name] = true
}
let changes : Array[CompositionChange] = []
for name, _ in names {
let old = before_map
.get(name)
.unwrap_or(CategoryStat::{ name, size: 0L, files: 0, })
let current = after_map
.get(name)
.unwrap_or(CategoryStat::{ name, size: 0L, files: 0, })
changes.push(CompositionChange::{
name,
before_bytes: old.size,
after_bytes: current.size,
byte_delta: current.size - old.size,
before_files: old.files,
after_files: current.files,
file_delta: current.files - old.files,
share_before_per_mille: per_mille(old.size, before_total),
share_after_per_mille: per_mille(current.size, after_total),
})
}
changes.sort_by(fn(a, b) {
let left = absolute_bytes(a.byte_delta)
let right = absolute_bytes(b.byte_delta)
if left > right {
-1
} else if left < right {
1
} else if a.name < b.name {
-1
} else if a.name > b.name {
1
} else {
0
}
})
CompositionDrift::{
dimension,
before_total_bytes: before_total,
after_total_bytes: after_total,
net_bytes: after_total - before_total,
changes,
}
}
///|
fn signed_count(value : Int) -> String {
if value > 0 {
"+" + value.to_string()
} else {
value.to_string()
}
}
///|
fn percentage_text(per_mille_value : Int) -> String {
let whole = per_mille_value / 10
let fraction = per_mille_value % 10
whole.to_string() + "." + fraction.to_string() + "%"
}
///|
pub fn render_composition_drift(
drift : CompositionDrift,
limit? : Int = 20,
) -> String {
let lines : Array[String] = [
"MoonDisk composition drift",
"Dimension: " + composition_dimension_name(drift.dimension),
"Before: " + format_bytes(drift.before_total_bytes),
"After: " + format_bytes(drift.after_total_bytes),
"Net change: " + format_bytes(drift.net_bytes),
"",
pad_right("Bucket", 28) +
pad_left("Before", 12) +
pad_left("After", 12) +
pad_left("Delta", 12) +
pad_left("Files", 9) +
pad_left("Share", 16),
]
let count = if limit < drift.changes.length() {
limit
} else {
drift.changes.length()
}
for index = 0; index < count; index = index + 1 {
let item = drift.changes[index]
let share = percentage_text(item.share_before_per_mille) +
"→" +
percentage_text(item.share_after_per_mille)
lines.push(
pad_right(item.name, 28) +
pad_left(format_bytes(item.before_bytes), 12) +
pad_left(format_bytes(item.after_bytes), 12) +
pad_left(format_bytes(item.byte_delta), 12) +
pad_left(signed_count(item.file_delta), 9) +
pad_left(share, 16),
)
}
if count == 0 {
lines.push("No file composition data is available.")
}
lines.join("\n")
}
///|
pub fn composition_drift_to_json(drift : CompositionDrift) -> String {
ToJson::to_json(drift).stringify(indent=2)
}