use std::path::{Path, PathBuf};
use anyhow::Context;
use crate::query::StoreSummary;
struct Row {
handle: String,
forest: String,
dir: PathBuf,
path: PathBuf,
summary: StoreSummary,
}
pub fn cmd_list(dirs: &[PathBuf], names_only: bool, json: bool) -> anyhow::Result<()> {
let forests = crate::forest::forests_for_list(dirs);
if dirs.is_empty() && forests.is_empty() {
crate::note!("timberfs: no forests configured (see /etc/timberfs/forests.d/)");
return Ok(());
}
let mut rows: Vec<Row> = Vec::new();
for forest in &forests {
if !forest.dir.is_dir() {
crate::note!(
"timberfs: forest `{}` ({}) not found; skipping",
forest.name,
forest.dir.display()
);
continue;
}
for (handle, path) in crate::forest::scan_forest(&forest.dir) {
match open_summary(&path) {
Ok((dir, summary)) => rows.push(Row {
handle,
forest: forest.name.clone(),
dir,
path,
summary,
}),
Err(e) => crate::note!("timberfs: {}: {e}", path.display()),
}
}
}
rows.sort_by(|a, b| {
(a.forest.as_str(), a.handle.as_str()).cmp(&(b.forest.as_str(), b.handle.as_str()))
});
if names_only {
for r in &rows {
println!("{}", r.handle);
}
return Ok(());
}
if json {
println!("{}", serde_json::to_string_pretty(&rows_to_json(&rows))?);
return Ok(());
}
print_table(&rows);
Ok(())
}
fn open_summary(logical: &Path) -> anyhow::Result<(PathBuf, StoreSummary)> {
let (dir, name) = crate::query::resolve_backing(logical)?;
let rings = crate::format::rings_path(&dir, &name);
let records = crate::format::read_index(&rings)
.with_context(|| format!("reading index {}", rings.display()))?;
let bark = crate::bark::load(&dir, &name);
let summary = crate::query::summarize_store(&dir, &name, &records, bark.as_ref());
Ok((dir, summary))
}
fn retain_text(s: &StoreSummary) -> String {
match (&s.retain, &s.retain_size) {
(None, None) => "-".to_string(),
(Some(r), None) => r.clone(),
(None, Some(r)) => r.clone(),
(Some(r), Some(rs)) => format!("{r}, {rs}"),
}
}
fn span_text(s: &StoreSummary) -> String {
match (s.first_write_ms, s.last_write_ms) {
(Some(f), Some(l)) => format!("{} .. {}", crate::query::fmt_ms(f), crate::query::fmt_ms(l)),
_ => "empty".to_string(),
}
}
const COLUMNS: [&str; 7] = [
"HANDLE", "FOREST", "SIZE", "SPAN", "WRITER", "INDEX", "RETAIN",
];
fn row_cells(r: &Row) -> [String; 7] {
[
r.handle.clone(),
r.forest.clone(),
crate::rotate::human_bytes(r.summary.compressed_bytes),
span_text(&r.summary),
if r.summary.writer.is_live() {
"live"
} else {
"-"
}
.to_string(),
if r.summary.indexed() { "grain" } else { "-" }.to_string(),
retain_text(&r.summary),
]
}
fn format_table(header: &[&str], rows: &[[String; 7]]) -> String {
let mut widths: Vec<usize> = header.iter().map(|h| h.len()).collect();
for row in rows {
for (i, cell) in row.iter().enumerate() {
widths[i] = widths[i].max(cell.len());
}
}
let format_row = |cells: &[&str]| -> String {
let line: Vec<String> = cells
.iter()
.enumerate()
.map(|(i, c)| format!("{c:<width$}", width = widths[i]))
.collect();
line.join(" ").trim_end().to_string()
};
let mut out = String::new();
out.push_str(&format_row(header));
for row in rows {
out.push('\n');
out.push_str(&format_row(
&row.iter().map(String::as_str).collect::<Vec<_>>(),
));
}
out
}
fn print_table(rows: &[Row]) {
let data: Vec<[String; 7]> = rows.iter().map(row_cells).collect();
println!("{}", format_table(&COLUMNS, &data));
}
fn rows_to_json(rows: &[Row]) -> serde_json::Value {
serde_json::Value::Array(
rows.iter()
.map(|r| {
let s = &r.summary;
let mut o = serde_json::Map::new();
o.insert("handle".to_string(), r.handle.clone().into());
o.insert("forest".to_string(), r.forest.clone().into());
o.insert("dir".to_string(), r.dir.display().to_string().into());
o.insert("path".to_string(), r.path.display().to_string().into());
o.insert("size_bytes".to_string(), s.compressed_bytes.into());
o.insert(
"from_ms".to_string(),
s.first_write_ms
.map(Into::into)
.unwrap_or(serde_json::Value::Null),
);
o.insert(
"to_ms".to_string(),
s.last_write_ms
.map(Into::into)
.unwrap_or(serde_json::Value::Null),
);
o.insert("writer_live".to_string(), s.writer.is_live().into());
o.insert("indexed".to_string(), s.indexed().into());
o.insert(
"retain".to_string(),
s.retain
.clone()
.map(Into::into)
.unwrap_or(serde_json::Value::Null),
);
o.insert(
"retain_size".to_string(),
s.retain_size
.clone()
.map(Into::into)
.unwrap_or(serde_json::Value::Null),
);
serde_json::Value::Object(o)
})
.collect(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::query::WriterState;
fn summary(
compressed_bytes: u64,
span: Option<(u64, u64)>,
writer: WriterState,
indexed: bool,
retain: Option<&str>,
retain_size: Option<&str>,
) -> StoreSummary {
StoreSummary {
chunks: if span.is_some() { 1 } else { 0 },
logical_bytes: compressed_bytes * 2,
compressed_bytes,
first_write_ms: span.map(|(f, _)| f),
last_write_ms: span.map(|(_, l)| l),
rings_bytes: 64,
grain: if indexed { Some((10, 1)) } else { None },
index_declared: false,
retain: retain.map(str::to_string),
retain_size: retain_size.map(str::to_string),
writer,
}
}
fn row(handle: &str, forest: &str, summary: StoreSummary) -> Row {
Row {
handle: handle.to_string(),
forest: forest.to_string(),
dir: PathBuf::from("/var/log/timberfs"),
path: PathBuf::from(format!("/var/log/timberfs/{handle}.log")),
summary,
}
}
#[test]
fn retain_text_reports_declared_policy_or_dash() {
assert_eq!(
retain_text(&summary(0, None, WriterState::Idle, false, None, None)),
"-"
);
assert_eq!(
retain_text(&summary(
0,
None,
WriterState::Idle,
false,
Some("30d"),
None
)),
"30d"
);
assert_eq!(
retain_text(&summary(
0,
None,
WriterState::Idle,
false,
None,
Some("50G")
)),
"50G"
);
assert_eq!(
retain_text(&summary(
0,
None,
WriterState::Idle,
false,
Some("30d"),
Some("50G")
)),
"30d, 50G"
);
}
#[test]
fn span_text_reports_the_write_window_or_empty() {
assert_eq!(
span_text(&summary(0, None, WriterState::Idle, false, None, None)),
"empty"
);
let s = summary(0, Some((0, 1000)), WriterState::Idle, false, None, None);
assert!(span_text(&s).contains(".."));
}
#[test]
fn row_cells_reflect_writer_and_index_state() {
let live = row(
"nginx",
"default",
summary(2048, Some((0, 1000)), WriterState::Active, true, None, None),
);
let cells = row_cells(&live);
assert_eq!(cells[0], "nginx");
assert_eq!(cells[1], "default");
assert_eq!(cells[4], "live");
assert_eq!(cells[5], "grain");
let idle = row(
"db",
"default",
summary(0, None, WriterState::Idle, false, None, None),
);
let cells = row_cells(&idle);
assert_eq!(cells[3], "empty");
assert_eq!(cells[4], "-");
assert_eq!(cells[5], "-");
}
#[test]
fn table_aligns_columns_to_the_widest_cell() {
let rows = [
row(
"nginx",
"default",
summary(
2048,
Some((0, 1000)),
WriterState::Active,
true,
Some("30d"),
None,
),
),
row(
"a-very-long-handle-name",
"default",
summary(0, None, WriterState::Idle, false, None, None),
),
];
let data: Vec<[String; 7]> = rows.iter().map(row_cells).collect();
let table = format_table(&COLUMNS, &data);
let lines: Vec<&str> = table.lines().collect();
assert_eq!(lines.len(), 3); assert!(lines[0].starts_with("HANDLE"));
assert!(lines[2].starts_with("a-very-long-handle-name"));
}
#[test]
fn json_rows_carry_the_documented_fields() {
let rows = [row(
"nginx",
"default",
summary(
2048,
Some((5, 10)),
WriterState::Active,
true,
Some("30d"),
Some("50G"),
),
)];
let v = rows_to_json(&rows);
let obj = &v[0];
assert_eq!(obj["handle"], "nginx");
assert_eq!(obj["forest"], "default");
assert_eq!(obj["size_bytes"], 2048);
assert_eq!(obj["from_ms"], 5);
assert_eq!(obj["to_ms"], 10);
assert_eq!(obj["writer_live"], true);
assert_eq!(obj["indexed"], true);
assert_eq!(obj["retain"], "30d");
assert_eq!(obj["retain_size"], "50G");
}
#[test]
fn json_span_is_null_for_an_empty_store() {
let rows = [row(
"empty",
"default",
summary(0, None, WriterState::Idle, false, None, None),
)];
let v = rows_to_json(&rows);
assert!(v[0]["from_ms"].is_null());
assert!(v[0]["to_ms"].is_null());
assert!(v[0]["retain"].is_null());
}
}