///|
pub(all) struct StorageBudget {
id : String
path : String
maximum_bytes : Int64
warning_percent : Int
description : String
} derive(Eq, Debug, ToJson, FromJson)
///|
pub(all) struct BudgetConfig {
format_version : Int
budgets : Array[StorageBudget]
} derive(Eq, Debug, ToJson, FromJson)
///|
pub(all) enum BudgetStatus {
WithinBudget
NearLimit
OverBudget
BudgetPathMissing
} derive(Eq, Debug, ToJson, FromJson)
///|
/// Controls when a budget evaluation should fail an automated check.
pub(all) enum BudgetFailureThreshold {
NeverFail
FailOnWarning
FailOnExceeded
} derive(Eq, Debug)
///|
pub(all) struct BudgetResult {
id : String
path : String
maximum_bytes : Int64
actual_bytes : Int64
remaining_bytes : Int64
usage_tenths : Int
status : BudgetStatus
description : String
} derive(Eq, Debug, ToJson, FromJson)
///|
pub(all) enum BudgetConfigIssueKind {
EmptyBudgetId
DuplicateBudgetId
InvalidBudgetPath
InvalidMaximum
InvalidWarningPercent
} derive(Eq, Debug, ToJson, FromJson)
///|
pub(all) struct BudgetConfigIssue {
kind : BudgetConfigIssueKind
budget_id : String
message : String
} derive(Eq, Debug, ToJson, FromJson)
///|
pub fn default_budget_config() -> BudgetConfig {
BudgetConfig::{
format_version: 1,
budgets: [
StorageBudget::{
id: "moonbit-build",
path: "_build",
maximum_bytes: 1024L * 1024L * 1024L,
warning_percent: 80,
description: "Keep generated MoonBit build output below one GiB.",
},
StorageBudget::{
id: "repository-total",
path: "",
maximum_bytes: 2L * 1024L * 1024L * 1024L,
warning_percent: 80,
description: "Track the total size of the scanned repository.",
},
],
}
}
///|
fn valid_budget_path(path : String) -> Bool {
path == "" ||
(
normalize_relative_path(path) == path &&
!path.has_prefix("/") &&
!path.has_prefix("\\") &&
!path.contains(":")
)
}
///|
pub fn validate_budget_config(
config : BudgetConfig,
) -> Array[BudgetConfigIssue] {
let issues : Array[BudgetConfigIssue] = []
let ids : Map[String, Bool] = Map([])
for budget in config.budgets {
if budget.id.trim().is_empty() {
issues.push(BudgetConfigIssue::{
kind: EmptyBudgetId,
budget_id: budget.id,
message: "Every storage budget needs a non-empty id.",
})
} else if ids.get(budget.id) is Some(_) {
issues.push(BudgetConfigIssue::{
kind: DuplicateBudgetId,
budget_id: budget.id,
message: "Storage budget ids must be unique.",
})
} else {
ids[budget.id] = true
}
if !valid_budget_path(budget.path) {
issues.push(BudgetConfigIssue::{
kind: InvalidBudgetPath,
budget_id: budget.id,
message: "Budget paths must be normalized and relative to the scan root.",
})
}
if budget.maximum_bytes <= 0L {
issues.push(BudgetConfigIssue::{
kind: InvalidMaximum,
budget_id: budget.id,
message: "Maximum bytes must be greater than zero.",
})
}
if budget.warning_percent < 1 || budget.warning_percent > 100 {
issues.push(BudgetConfigIssue::{
kind: InvalidWarningPercent,
budget_id: budget.id,
message: "Warning percent must be between 1 and 100.",
})
}
}
issues
}
///|
fn budget_observed_size(snapshot : Snapshot, path : String) -> Int64? {
if path == "" {
return Some(snapshot.total_size())
}
match snapshot.find(path) {
Some(entry) if entry.is_file() => Some(entry.size)
Some(entry) if entry.is_dir() =>
match stat_for_directory(snapshot, entry.path) {
Some(stat) => Some(stat.recursive_size)
None => Some(0L)
}
_ => None
}
}
///|
pub fn evaluate_budget(
snapshot : Snapshot,
budget : StorageBudget,
) -> BudgetResult {
match budget_observed_size(snapshot, budget.path) {
None =>
BudgetResult::{
id: budget.id,
path: budget.path,
maximum_bytes: budget.maximum_bytes,
actual_bytes: 0L,
remaining_bytes: budget.maximum_bytes,
usage_tenths: 0,
status: BudgetPathMissing,
description: budget.description,
}
Some(actual) => {
let tenths = (actual * 1000L / budget.maximum_bytes).to_int()
let status = if actual > budget.maximum_bytes {
OverBudget
} else if tenths >= budget.warning_percent * 10 {
NearLimit
} else {
WithinBudget
}
BudgetResult::{
id: budget.id,
path: budget.path,
maximum_bytes: budget.maximum_bytes,
actual_bytes: actual,
remaining_bytes: budget.maximum_bytes - actual,
usage_tenths: tenths,
status,
description: budget.description,
}
}
}
}
///|
pub fn evaluate_budgets(
snapshot : Snapshot,
config : BudgetConfig,
) -> Array[BudgetResult] {
let results = config.budgets.map(fn(budget) {
evaluate_budget(snapshot, budget)
})
results.sort_by(fn(a, b) {
let a_rank = match a.status {
OverBudget => 0
NearLimit => 1
BudgetPathMissing => 2
WithinBudget => 3
}
let b_rank = match b.status {
OverBudget => 0
NearLimit => 1
BudgetPathMissing => 2
WithinBudget => 3
}
if a_rank < b_rank {
-1
} else if a_rank > b_rank {
1
} else if a.usage_tenths > b.usage_tenths {
-1
} else if a.usage_tenths < b.usage_tenths {
1
} else if a.id < b.id {
-1
} else {
1
}
})
results
}
///|
pub fn budget_status_name(status : BudgetStatus) -> String {
match status {
WithinBudget => "within"
NearLimit => "warning"
OverBudget => "exceeded"
BudgetPathMissing => "missing"
}
}
///|
pub fn budget_exit_code(results : Array[BudgetResult]) -> Int {
if results.any(fn(result) { result.status == OverBudget }) {
2
} else if results.any(fn(result) { result.status == NearLimit }) {
1
} else {
0
}
}
///|
pub fn budget_failure_threshold_from_name(
name : String,
) -> BudgetFailureThreshold raise {
match name.to_lower() {
"never" => NeverFail
"warning" => FailOnWarning
"exceeded" => FailOnExceeded
value =>
raise Failure(
"Unknown budget failure threshold: " +
value +
". Expected never, warning, or exceeded.",
)
}
}
///|
pub fn budgets_should_fail(
results : Array[BudgetResult],
threshold : BudgetFailureThreshold,
) -> Bool {
match threshold {
NeverFail => false
FailOnWarning =>
results.any(fn(result) {
result.status == NearLimit || result.status == OverBudget
})
FailOnExceeded => results.any(fn(result) { result.status == OverBudget })
}
}
///|
pub fn render_budgets(results : Array[BudgetResult]) -> String {
let lines : Array[String] = ["MoonDisk storage budgets"]
for result in results {
let path = if result.path == "" { "." } else { result.path }
lines.push(
"[" +
budget_status_name(result.status) +
"] " +
result.id +
" (" +
path +
")",
)
if result.status == BudgetPathMissing {
lines.push(" The configured path was not present in the snapshot.")
} else {
lines.push(
" " +
format_bytes(result.actual_bytes) +
" used of " +
format_bytes(result.maximum_bytes) +
" (" +
format_percent(result.actual_bytes, result.maximum_bytes) +
").",
)
}
}
lines.join("\n")
}
///|
pub fn budget_config_to_json(config : BudgetConfig) -> String {
ToJson::to_json(config).stringify(indent=2)
}
///|
pub fn budget_config_from_json(source : String) -> BudgetConfig raise {
let config : BudgetConfig = @json.from_json(@json.parse(source))
if config.format_version != 1 {
raise Failure("Unsupported storage budget version")
}
let issues = validate_budget_config(config)
if !issues.is_empty() {
raise Failure("Invalid storage budget: " + issues[0].message)
}
config
}
///|
pub async fn write_budget_config(path : String, config : BudgetConfig) -> Unit {
@fs.write_file(path, budget_config_to_json(config))
}
///|
pub async fn read_budget_config(path : String) -> BudgetConfig {
budget_config_from_json(@fs.read_file(path).text())
}