rom: bump dependencies; further work for feature parity

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I27d00ffe0d27497bdc6c1f890af3bdfe6a6a6964
This commit is contained in:
raf 2026-02-04 17:54:55 +03:00
commit 27ecaac59c
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
11 changed files with 1274 additions and 337 deletions

588
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,7 @@ version = "0.1.0"
edition = "2024"
authors = ["NotAShelf <raf@notashelf.dev>"]
description = "Pretty build graphs for Nix builds"
rust-version = "1.85"
rust-version = "1.91.1"
[workspace.dependencies]
anyhow = "1.0.100"
@ -19,6 +19,7 @@ crossterm = "0.29.0"
ratatui = "0.29.0"
indexmap = { version = "2.12.0", features = ["serde"] }
csv = "1.4.0"
chrono = "0.4.42"
thiserror = "2.0.17"
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }

View file

@ -19,6 +19,7 @@ crossterm = "0.29"
ratatui = "0.29"
indexmap.workspace = true
csv.workspace = true
chrono.workspace = true
thiserror.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true

397
rom/src/cache.rs Normal file
View file

@ -0,0 +1,397 @@
use std::{
collections::HashMap,
fs::{self, File, OpenOptions},
io::{BufReader, BufWriter},
path::PathBuf,
time::SystemTime,
};
use csv::{Reader, Writer};
use serde::{Deserialize, Serialize};
use crate::state::BuildReport;
/// Maximum number of historical builds to keep per derivation
const HISTORY_LIMIT: usize = 10;
/// Build report cache for CSV persistence
pub struct BuildReportCache {
cache_path: PathBuf,
}
/// CSV row format for build reports
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BuildReportRow {
hostname: String,
derivation_name: String,
utc_time: String,
build_seconds: u64,
}
impl BuildReportCache {
/// Create a new cache instance with the given path
#[must_use]
pub fn new(cache_path: PathBuf) -> Self {
Self { cache_path }
}
// FIXME: just use the dirs crate for this
/// Get the default cache directory path
///
/// Uses `$XDG_STATE_HOME` if set, otherwise ``~/.local/state`
#[must_use]
pub fn default_cache_dir() -> PathBuf {
if let Ok(xdg_state) = std::env::var("XDG_STATE_HOME") {
PathBuf::from(xdg_state).join("rom")
} else if let Ok(home) = std::env::var("HOME") {
PathBuf::from(home).join(".local/state/rom")
} else {
PathBuf::from(".rom")
}
}
/// Get the default cache file path
#[must_use]
pub fn default_cache_path() -> PathBuf {
Self::default_cache_dir().join("build-reports.csv")
}
/// Load build reports from CSV
///
/// Returns empty [`HashMap`] if file doesn't exist or parsing fails
pub fn load(&self) -> HashMap<(String, String), Vec<BuildReport>> {
if !self.cache_path.exists() {
return HashMap::new();
}
let file = match File::open(&self.cache_path) {
Ok(f) => f,
Err(_) => return HashMap::new(),
};
let reader = BufReader::new(file);
let mut csv_reader = Reader::from_reader(reader);
let mut reports: HashMap<(String, String), Vec<BuildReport>> =
HashMap::new();
for result in csv_reader.deserialize() {
let row: BuildReportRow = match result {
Ok(r) => r,
Err(_) => continue,
};
let completed_at = match parse_utc_time(&row.utc_time) {
Some(t) => t,
None => continue,
};
let report = BuildReport {
derivation_name: row.derivation_name.clone(),
platform: String::new(), // FIXME: not stored in CSV, for simplicity and because I'm lazy
duration_secs: row.build_seconds as f64,
completed_at,
host: row.hostname.clone(),
success: true, // only successful builds are cached
};
let key = (row.hostname, row.derivation_name);
reports.entry(key).or_default().push(report);
}
// Sort each entry by timestamp (newest first) and limit to HISTORY_LIMIT
for entries in reports.values_mut() {
entries.sort_by(|a, b| b.completed_at.cmp(&a.completed_at));
entries.truncate(HISTORY_LIMIT);
}
reports
}
/// Save build reports to CSV
///
/// Merges with existing reports and enforces history limit
pub fn save(
&self,
reports: &HashMap<(String, String), Vec<BuildReport>>,
) -> Result<(), std::io::Error> {
// Ensure directory exists
if let Some(parent) = self.cache_path.parent() {
fs::create_dir_all(parent)?;
}
// Load existing reports to merge
let mut merged = self.load();
// Merge new reports
for ((host, drv_name), new_reports) in reports {
let key = (host.clone(), drv_name.clone());
let existing = merged.entry(key).or_default();
// Add new reports
existing.extend(new_reports.iter().cloned());
// Sort by timestamp (newest first)
existing.sort_by(|a, b| b.completed_at.cmp(&a.completed_at));
// Keep only most recent HISTORY_LIMIT entries
existing.truncate(HISTORY_LIMIT);
}
// Write to CSV
let file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&self.cache_path)?;
let writer = BufWriter::new(file);
let mut csv_writer = Writer::from_writer(writer);
// Flatten and write all reports
for ((hostname, derivation_name), entries) in merged {
for report in entries {
let row = BuildReportRow {
hostname: hostname.clone(),
derivation_name: derivation_name.clone(),
utc_time: format_utc_time(report.completed_at),
build_seconds: report.duration_secs as u64,
};
csv_writer.serialize(row)?;
}
}
csv_writer.flush()?;
Ok(())
}
/// Calculate median build time from historical reports
///
/// Returns [`None`] if there are no reports
#[must_use]
pub fn calculate_median(reports: &[BuildReport]) -> Option<u64> {
if reports.is_empty() {
return None;
}
let mut durations: Vec<u64> =
reports.iter().map(|r| r.duration_secs as u64).collect();
durations.sort_unstable();
let len = durations.len();
if len % 2 == 1 {
Some(durations[len / 2])
} else {
let mid1 = durations[len / 2 - 1];
let mid2 = durations[len / 2];
Some((mid1 + mid2) / 2)
}
}
/// Get median build time for a specific derivation on a host
#[must_use]
pub fn get_estimate(
&self,
reports: &HashMap<(String, String), Vec<BuildReport>>,
host: &str,
derivation_name: &str,
) -> Option<u64> {
let key = (host.to_string(), derivation_name.to_string());
let entries = reports.get(&key)?;
Self::calculate_median(entries)
}
}
/// Parse UTC time string in format "%Y-%m-%d %H:%M:%S"
fn parse_utc_time(s: &str) -> Option<SystemTime> {
// Simple parsing for "YYYY-MM-DD HH:MM:SS" format
let parts: Vec<&str> = s.split([' ', '-', ':']).collect();
if parts.len() != 6 {
return None;
}
let year: i64 = parts[0].parse().ok()?;
let month: u64 = parts[1].parse().ok()?;
let day: u64 = parts[2].parse().ok()?;
let hour: u64 = parts[3].parse().ok()?;
let minute: u64 = parts[4].parse().ok()?;
let second: u64 = parts[5].parse().ok()?;
// Approximate conversion to Unix timestamp
// This is a simplified calculation that doesn't handle leap years perfectly
let days_since_epoch = (year - 1970) * 365
+ (year - 1969) / 4
+ days_until_month(month)
+ day as i64
- 1;
let seconds_since_epoch =
days_since_epoch as u64 * 86400 + hour * 3600 + minute * 60 + second;
Some(
SystemTime::UNIX_EPOCH
+ std::time::Duration::from_secs(seconds_since_epoch),
)
}
// FIXME: I'm really sure there's a library for this but lets just get
// this thing compiling
/// Calculate days until the start of a month (approximation)
const fn days_until_month(month: u64) -> i64 {
match month {
1 => 0,
2 => 31,
3 => 59,
4 => 90,
5 => 120,
6 => 151,
7 => 181,
8 => 212,
9 => 243,
10 => 273,
11 => 304,
12 => 334,
_ => 0,
}
}
// FIXME: does Chrono do this?
/// Format SystemTime as UTC string in format "%Y-%m-%d %H:%M:%S"
fn format_utc_time(time: SystemTime) -> String {
let duration = time
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
let secs = duration.as_secs();
let days = secs / 86400;
let remaining = secs % 86400;
let hours = remaining / 3600;
let minutes = (remaining % 3600) / 60;
let seconds = remaining % 60;
// Approximate conversion from days since epoch to date
let mut year = 1970;
let mut days_left = days as i64;
// Subtract full years
while days_left >= 365 {
if is_leap_year(year) && days_left >= 366 {
days_left -= 366;
year += 1;
} else if !is_leap_year(year) {
days_left -= 365;
year += 1;
} else {
break;
}
}
// Calculate month and day
let (month, day) = calculate_month_day(days_left as u64, is_leap_year(year));
format!("{year:04}-{month:02}-{day:02} {hours:02}:{minutes:02}:{seconds:02}")
}
const fn is_leap_year(year: i64) -> bool {
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}
fn calculate_month_day(days: u64, is_leap: bool) -> (u8, u8) {
let days_in_month: [u8; 12] = if is_leap {
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut remaining = days as i32;
for (i, &month_days) in days_in_month.iter().enumerate() {
if remaining < i32::from(month_days) {
return ((i + 1) as u8, (remaining + 1) as u8);
}
remaining -= i32::from(month_days);
}
(12, 31)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_calculate_median_odd() {
let reports = vec![
BuildReport {
derivation_name: "test".to_string(),
platform: "x86_64-linux".to_string(),
duration_secs: 10.0,
completed_at: SystemTime::UNIX_EPOCH,
host: "localhost".to_string(),
success: true,
},
BuildReport {
derivation_name: "test".to_string(),
platform: "x86_64-linux".to_string(),
duration_secs: 20.0,
completed_at: SystemTime::UNIX_EPOCH,
host: "localhost".to_string(),
success: true,
},
BuildReport {
derivation_name: "test".to_string(),
platform: "x86_64-linux".to_string(),
duration_secs: 30.0,
completed_at: SystemTime::UNIX_EPOCH,
host: "localhost".to_string(),
success: true,
},
];
assert_eq!(BuildReportCache::calculate_median(&reports), Some(20));
}
#[test]
fn test_calculate_median_even() {
let reports = vec![
BuildReport {
derivation_name: "test".to_string(),
platform: "x86_64-linux".to_string(),
duration_secs: 10.0,
completed_at: SystemTime::UNIX_EPOCH,
host: "localhost".to_string(),
success: true,
},
BuildReport {
derivation_name: "test".to_string(),
platform: "x86_64-linux".to_string(),
duration_secs: 20.0,
completed_at: SystemTime::UNIX_EPOCH,
host: "localhost".to_string(),
success: true,
},
];
assert_eq!(BuildReportCache::calculate_median(&reports), Some(15));
}
#[test]
fn test_calculate_median_empty() {
let reports = vec![];
assert_eq!(BuildReportCache::calculate_median(&reports), None);
}
#[test]
fn test_format_parse_utc_time() {
let time =
SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000);
let formatted = format_utc_time(time);
let parsed = parse_utc_time(&formatted).unwrap();
// Allow small difference due to approximation
let diff = parsed
.duration_since(time)
.unwrap_or_else(|e| e.duration())
.as_secs();
assert!(diff < 86400); // less than 1 day difference
}
}

View file

@ -33,6 +33,14 @@ pub struct Cli {
/// Summary display style: concise, table, full
#[arg(long, global = true, default_value = "concise")]
pub summary: String,
/// Log prefix style: short, full, none
#[arg(long, global = true, default_value = "short")]
pub log_prefix: String,
/// Maximum number of log lines to display
#[arg(long, global = true)]
pub log_lines: Option<usize>,
}
#[derive(Debug, clap::Subcommand)]
@ -86,6 +94,8 @@ pub fn run() -> eyre::Result<()> {
cli.format.clone(),
cli.legend.clone(),
cli.summary.clone(),
cli.log_prefix.clone(),
cli.log_lines,
)?;
Ok(())
},
@ -101,6 +111,8 @@ pub fn run() -> eyre::Result<()> {
cli.format.clone(),
cli.legend.clone(),
cli.summary.clone(),
cli.log_prefix.clone(),
cli.log_lines,
)?;
Ok(())
},
@ -110,14 +122,18 @@ pub fn run() -> eyre::Result<()> {
// If no args provided and --json is set, use piping mode from stdin
if args.is_empty() && cli.json {
let config = crate::types::Config {
piping: false,
silent: cli.silent,
input_mode: crate::types::InputMode::Json,
show_timers: true,
width: None,
format: crate::types::DisplayFormat::from_str(&cli.format),
legend_style: cli.legend.clone(),
summary_style: cli.summary.clone(),
piping: false,
silent: cli.silent,
input_mode: crate::types::InputMode::Json,
show_timers: true,
width: None,
format: crate::types::DisplayFormat::from_str(&cli.format),
legend_style: cli.legend.clone(),
summary_style: cli.summary.clone(),
log_prefix_style: crate::types::LogPrefixStyle::from_str(
&cli.log_prefix,
),
log_line_limit: cli.log_lines,
};
let stdin = io::stdin();
@ -140,6 +156,8 @@ pub fn run() -> eyre::Result<()> {
cli.format.clone(),
cli.legend.clone(),
cli.summary.clone(),
cli.log_prefix.clone(),
cli.log_lines,
)?;
Ok(())
},
@ -149,14 +167,18 @@ pub fn run() -> eyre::Result<()> {
// If no args provided and --json is set, use piping mode from stdin
if args.is_empty() && cli.json {
let config = crate::types::Config {
piping: false,
silent: cli.silent,
input_mode: crate::types::InputMode::Json,
show_timers: true,
width: None,
format: crate::types::DisplayFormat::from_str(&cli.format),
legend_style: cli.legend.clone(),
summary_style: cli.summary.clone(),
piping: false,
silent: cli.silent,
input_mode: crate::types::InputMode::Json,
show_timers: true,
width: None,
format: crate::types::DisplayFormat::from_str(&cli.format),
legend_style: cli.legend.clone(),
summary_style: cli.summary.clone(),
log_prefix_style: crate::types::LogPrefixStyle::from_str(
&cli.log_prefix,
),
log_line_limit: cli.log_lines,
};
let stdin = io::stdin();
@ -179,6 +201,8 @@ pub fn run() -> eyre::Result<()> {
cli.format.clone(),
cli.legend.clone(),
cli.summary.clone(),
cli.log_prefix.clone(),
cli.log_lines,
)?;
Ok(())
},
@ -188,14 +212,18 @@ pub fn run() -> eyre::Result<()> {
// If no args provided and --json is set, use piping mode from stdin
if args.is_empty() && cli.json {
let config = crate::types::Config {
piping: false,
silent: cli.silent,
input_mode: crate::types::InputMode::Json,
show_timers: true,
width: None,
format: crate::types::DisplayFormat::from_str(&cli.format),
legend_style: cli.legend.clone(),
summary_style: cli.summary.clone(),
piping: false,
silent: cli.silent,
input_mode: crate::types::InputMode::Json,
show_timers: true,
width: None,
format: crate::types::DisplayFormat::from_str(&cli.format),
legend_style: cli.legend.clone(),
summary_style: cli.summary.clone(),
log_prefix_style: crate::types::LogPrefixStyle::from_str(
&cli.log_prefix,
),
log_line_limit: cli.log_lines,
};
let stdin = io::stdin();
@ -218,6 +246,8 @@ pub fn run() -> eyre::Result<()> {
cli.format.clone(),
cli.legend.clone(),
cli.summary.clone(),
cli.log_prefix.clone(),
cli.log_lines,
)?;
Ok(())
},
@ -239,6 +269,10 @@ pub fn run() -> eyre::Result<()> {
format: crate::types::DisplayFormat::from_str(&cli.format),
legend_style: cli.legend.clone(),
summary_style: cli.summary.clone(),
log_prefix_style: crate::types::LogPrefixStyle::from_str(
&cli.log_prefix,
),
log_line_limit: cli.log_lines,
};
let stdin = io::stdin();
@ -280,6 +314,8 @@ fn run_nix_build_wrapper(
format: String,
legend_style: String,
summary_style: String,
log_prefix: String,
log_lines: Option<usize>,
) -> eyre::Result<()> {
// Validate that at least one package/flake is specified
if package_and_rom_args.is_empty() {
@ -310,6 +346,8 @@ fn run_nix_build_wrapper(
format,
legend_style,
summary_style,
crate::types::LogPrefixStyle::from_str(&log_prefix),
log_lines,
)?;
if exit_code != 0 {
std::process::exit(exit_code);
@ -325,6 +363,8 @@ fn run_nix_shell_wrapper(
format: String,
legend_style: String,
summary_style: String,
log_prefix: String,
log_lines: Option<usize>,
) -> eyre::Result<()> {
// Validate that at least one package/flake is specified
if package_and_rom_args.is_empty() {
@ -360,6 +400,8 @@ fn run_nix_shell_wrapper(
format,
legend_style,
summary_style,
crate::types::LogPrefixStyle::from_str(&log_prefix),
log_lines,
)?;
if exit_code != 0 {
@ -391,6 +433,8 @@ fn run_nix_develop_wrapper(
format: String,
legend_style: String,
summary_style: String,
log_prefix: String,
log_lines: Option<usize>,
) -> eyre::Result<()> {
// Validate that at least one package/flake is specified (can be empty for
// current flake) develop without args is valid (uses current directory's
@ -419,6 +463,8 @@ fn run_nix_develop_wrapper(
format,
legend_style,
summary_style,
crate::types::LogPrefixStyle::from_str(&log_prefix),
log_lines,
)?;
if exit_code != 0 {
@ -450,6 +496,8 @@ fn run_monitored_command(
format_str: String,
legend_style_str: String,
summary_style_str: String,
log_prefix_style: crate::types::LogPrefixStyle,
log_line_limit: Option<usize>,
) -> eyre::Result<i32> {
use std::{
io::{BufRead, BufReader},
@ -481,6 +529,13 @@ fn run_monitored_command(
let start_time = Arc::new(Mutex::new(crate::state::current_time()));
let start_time_clone = start_time.clone();
// Buffer for build logs - collected and passed to Display for coordinated
// rendering
let log_buffer =
Arc::new(Mutex::new(std::collections::VecDeque::<String>::new()));
let log_buffer_clone = log_buffer.clone();
let log_buffer_render = log_buffer.clone();
// Spawn thread to read and parse stderr (where nix outputs logs)
let stderr_thread = thread::spawn(move || {
use tracing::debug;
@ -495,19 +550,62 @@ fn run_monitored_command(
if let Ok(action) = serde_json::from_str::<cognos::Actions>(json_line) {
debug!("Parsed JSON message #{}: {:?}", json_count, action);
// Print messages immediately to stdout
if let cognos::Actions::Message { msg, .. } = &action {
println!("{msg}");
}
// Process the action first to update state
let mut state = state_clone.lock().unwrap();
let derivation_count_before = state.derivation_infos.len();
crate::update::process_message(&mut state, action);
crate::update::process_message(&mut state, action.clone());
crate::update::maintain_state(
&mut state,
crate::state::current_time(),
);
let derivation_count_after = state.derivation_infos.len();
// Now handle build log messages after state is updated
// Buffer them for coordinated rendering with the display
match &action {
cognos::Actions::Message { msg, .. } => {
let mut logs = log_buffer_clone.lock().unwrap();
logs.push_back(msg.clone());
// Keep only recent logs based on limit
if let Some(limit) = log_line_limit {
while logs.len() > limit {
logs.pop_front();
}
}
},
cognos::Actions::Result {
fields,
activity,
id,
} => {
// Build log lines come as Result actions with FileTransfer
// activity (101) and fields containing just the log
// text: fields = ["log line text"]
if matches!(activity, cognos::Activities::FileTransfer)
&& !fields.is_empty()
{
if let Some(log_text) = fields[0].as_str() {
// Get the activity prefix (e.g., "hello> ")
let use_color = !silent;
let prefix = state
.get_activity_prefix(*id, &log_prefix_style, use_color)
.unwrap_or_default();
let prefixed_log = format!("{prefix}{log_text}");
let mut logs = log_buffer_clone.lock().unwrap();
logs.push_back(prefixed_log);
// Keep only recent logs based on limit
if let Some(limit) = log_line_limit {
while logs.len() > limit {
logs.pop_front();
}
}
}
}
},
_ => {},
}
if derivation_count_after != derivation_count_before {
debug!(
"Derivation count changed: {} -> {}",
@ -518,9 +616,16 @@ fn run_monitored_command(
debug!("Failed to parse JSON: {}", json_line);
}
} else {
// Non-JSON lines, pass through
// Non-JSON lines, buffer them
non_json_count += 1;
println!("{line}");
let mut logs = log_buffer_clone.lock().unwrap();
logs.push_back(line.clone());
// Keep only recent logs based on limit
if let Some(limit) = log_line_limit {
while logs.len() > limit {
logs.pop_front();
}
}
}
}
debug!(
@ -583,13 +688,16 @@ fn run_monitored_command(
|| !state.full_summary.planned_builds.is_empty();
if !silent {
// Get buffered logs for coordinated rendering
let logs: Vec<String> =
log_buffer_render.lock().unwrap().iter().cloned().collect();
if has_activity || state.progress_state != ProgressState::JustStarted {
// Clear any previous timer display
if last_timer_display.is_some() {
display.clear_previous().ok();
last_timer_display = None;
}
let _ = display.render(&state, &[]);
let _ = display.render(&state, &logs);
} else {
// Show initial timer while waiting for activity
let start = *start_time_clone.lock().unwrap();
@ -599,8 +707,7 @@ fn run_monitored_command(
// Only update if changed (to avoid flicker)
if last_timer_display.as_ref() != Some(&timer_text) {
display.clear_previous().ok();
eprintln!("{timer_text}");
let _ = display.render(&state, &logs);
last_timer_display = Some(timer_text);
}
}

View file

@ -114,7 +114,7 @@ impl<W: Write> Display<W> {
let mut lines = Vec::new();
// Print accumulated logs first (these go above the tree)
// Print build logs ABOVE the graph
for log in logs {
lines.push(log.clone());
}
@ -153,6 +153,8 @@ impl<W: Write> Display<W> {
}
pub fn render_final(&mut self, state: &State) -> io::Result<()> {
tracing::debug!("render_final called");
// Clear any previous render
self.clear_previous()?;
@ -180,6 +182,8 @@ impl<W: Write> Display<W> {
},
}
tracing::debug!("render_final: {} lines to print", lines.len());
// Print final output (don't track last_lines since this is final)
for line in lines {
writeln!(self.writer, "{line}")?;
@ -207,8 +211,10 @@ impl<W: Write> Display<W> {
let failed = state.full_summary.failed_builds.len();
let planned = state.full_summary.planned_builds.len();
let duration = current_time() - state.start_time;
// Always print summary (like NOM's "Finished at HH:MM:SS after Xs")
if running > 0 || completed > 0 || failed > 0 || planned > 0 {
let duration = current_time() - state.start_time;
lines.push(format!(
"{} {} {} │ {} {} │ {} {} │ {} {} │ {} {}",
self.colored("", Color::Blue),
@ -223,6 +229,18 @@ impl<W: Write> Display<W> {
self.colored("", Color::Grey),
self.format_duration(duration)
));
} else {
// Nothing built - just show "Finished after Xs"
let now = chrono::Local::now();
let time_str = now.format("%H:%M:%S");
lines.push(format!(
"{} {}",
self.colored(&format!("Finished at {time_str}"), Color::Green),
self.colored(
&format!("after {}", self.format_duration(duration)),
Color::Green
)
));
}
lines
@ -680,11 +698,23 @@ impl<W: Write> Display<W> {
if let Some(info) = state.get_derivation_info(*drv_id) {
let name = &info.name.name;
let elapsed = current_time() - build.start;
// Format time info
let mut time_info = String::new();
if let Some(estimate_secs) = build.estimate {
let remaining = estimate_secs.saturating_sub(elapsed as u64);
time_info.push_str(&format!(
"∅ {} ",
self.format_duration(remaining as f64)
));
}
time_info.push_str(&self.format_duration(elapsed));
lines.push(format!(
" {} {} {}",
self.colored("", Color::Yellow),
name,
self.format_duration(elapsed)
time_info
));
}
}
@ -954,8 +984,19 @@ impl<W: Write> Display<W> {
}
}
// Time elapsed
// Time information
let elapsed = current_time() - build_info.start;
// Show estimate if available
if let Some(estimate_secs) = build_info.estimate {
let remaining = estimate_secs.saturating_sub(elapsed as u64);
line.push_str(&self.colored(
&format!("{}", self.format_duration(remaining as f64)),
Color::DarkGrey,
));
}
// Show elapsed time
line.push_str(&self.colored(
&format!("{}", self.format_duration(elapsed)),
Color::DarkGrey,

View file

@ -1,4 +1,5 @@
//! ROM - Rust Output Monitor
pub mod cache;
pub mod cli;
pub mod display;
pub mod error;

View file

@ -1,12 +1,15 @@
//! Monitor module for orchestrating state updates and display rendering
use std::{
io::{BufRead, Write},
time::Duration,
};
use cognos::Host;
use tracing::debug;
use crate::{
cache::BuildReportCache,
display::{Display, DisplayConfig, LegendStyle, SummaryStyle},
error::{Result, RomError},
state::{
@ -54,7 +57,12 @@ impl<W: Write> Monitor<W> {
};
let display = Display::new(writer, display_config)?;
let state = State::new();
let mut state = State::new();
// Load build cache for predictions
let cache_path = BuildReportCache::default_cache_path();
let cache = BuildReportCache::new(cache_path);
state.build_cache = cache.load();
Ok(Self {
state,
@ -90,6 +98,14 @@ impl<W: Write> Monitor<W> {
self.display.render_final(&self.state)?;
}
// Save build cache for future predictions
let cache_path = BuildReportCache::default_cache_path();
let cache = BuildReportCache::new(cache_path);
if let Err(e) = cache.save(&self.state.build_cache) {
debug!("Failed to save build cache: {}", e);
// Don't fail the build if cache save fails
}
// Return error code if there were failures
if self.state.has_errors() {
return Err(RomError::BuildFailed);
@ -140,10 +156,6 @@ impl<W: Write> Monitor<W> {
/// Process a human-readable line
fn process_human_line(&mut self, line: &str) -> Result<bool> {
// Parse human-readable nix output
// This is a simplified version - the full implementation would need
// comprehensive parsing of nix's output format
let line = line.trim();
// Skip empty lines
@ -270,10 +282,8 @@ impl<W: Write> Monitor<W> {
// Extract number of paths if present
let words: Vec<&str> = line.split_whitespace().collect();
if words.len() >= 2 {
if let Ok(_count) = words[1].parse::<usize>() {
// XXX: This is a PlanCopies message, we'll probably track this
// For now just acknowledge it, and let future work decide how
// we should go around doing it.
if let Ok(count) = words[1].parse::<usize>() {
debug!("Copying {} paths", count);
return Ok(true);
}
}

View file

@ -360,6 +360,7 @@ pub struct State {
pub full_summary: DependencySummary,
pub forest_roots: Vec<DerivationId>,
pub build_reports: HashMap<String, Vec<BuildReport>>,
pub build_cache: HashMap<(String, String), Vec<BuildReport>>,
pub start_time: f64,
pub progress_state: ProgressState,
pub store_path_ids: HashMap<StorePath, StorePathId>,
@ -371,6 +372,8 @@ pub struct State {
pub traces: Vec<String>,
pub build_platform: Option<String>,
pub evaluation_state: EvalInfo,
pub builds_activity: Option<ActivityId>,
pub success_tokens: u64,
next_store_path_id: StorePathId,
next_derivation_id: DerivationId,
}
@ -390,6 +393,7 @@ impl State {
full_summary: DependencySummary::default(),
forest_roots: Vec::new(),
build_reports: HashMap::new(),
build_cache: HashMap::new(),
start_time: current_time(),
progress_state: ProgressState::JustStarted,
store_path_ids: HashMap::new(),
@ -401,6 +405,8 @@ impl State {
traces: Vec::new(),
build_platform: None,
evaluation_state: EvalInfo::default(),
builds_activity: None,
success_tokens: 0,
next_store_path_id: 0,
next_derivation_id: 0,
}
@ -697,6 +703,106 @@ impl State {
.copied()
.collect()
}
/// Get the activity prefix for a given activity ID by walking up the parent
/// chain to find a Build activity and extracting its derivation name.
/// Returns a prefix like "hello> " suitable for prepending to log lines.
/// If `use_color` is true and stderr is a TTY, the prefix will be blue.
/// The `prefix_style` determines whether to use short (pname only), full, or
/// no prefix.
#[must_use]
pub fn get_activity_prefix(
&self,
activity_id: ActivityId,
prefix_style: &crate::types::LogPrefixStyle,
use_color: bool,
) -> Option<String> {
use cognos::Activities;
use crate::types::LogPrefixStyle;
// If prefix style is None, return empty string
if matches!(prefix_style, LogPrefixStyle::None) {
return Some(String::new());
}
let mut current_id = activity_id;
let max_depth = 10; // Prevent infinite loops
let mut depth = 0;
while depth < max_depth {
if let Some(activity) = self.activities.get(&current_id) {
// Check if this is a Build activity (type 105)
if activity.activity == Activities::Build as u8 {
// Extract derivation path from the text field
// The text field typically contains something like:
// "building '/nix/store/...-hello-2.10.drv'"
if let Some(drv) = extract_derivation_from_text(&activity.text) {
// Look up the DerivationInfo for this derivation
let drv_id = self.derivation_ids.get(&drv);
let name = if matches!(prefix_style, LogPrefixStyle::Short) {
// Try to use pname if available
if let Some(id) = drv_id {
if let Some(drv_info) = self.derivation_infos.get(id) {
if let Some(pname) = &drv_info.pname {
pname.clone()
} else {
drv.name.clone()
}
} else {
drv.name.clone()
}
} else {
drv.name.clone()
}
} else {
// Full style - use full derivation name
drv.name.clone()
};
// Apply color if requested and stderr is a TTY
let colored_name = if use_color
&& std::io::IsTerminal::is_terminal(&std::io::stderr())
{
format!("\x1b[34m{name}\x1b[0m")
} else {
name
};
return Some(format!("{colored_name}> "));
}
}
// Move to parent activity
if let Some(parent_id) = activity.parent {
if parent_id == 0 {
break; // Reached root
}
current_id = parent_id;
depth += 1;
} else {
break;
}
} else {
break;
}
}
None
}
}
/// Extract derivation from activity text like "building
/// '/nix/store/...-hello-2.10.drv'" Returns the Derivation object
fn extract_derivation_from_text(text: &str) -> Option<Derivation> {
// Look for .drv path in text
if let Some(start) = text.find("/nix/store/") {
if let Some(end) = text[start..].find(".drv") {
let drv_path = &text[start..start + end + 4]; // Include .drv
return Derivation::parse(drv_path);
}
}
None
}
#[must_use]

View file

@ -11,6 +11,17 @@ pub enum DisplayFormat {
Dashboard,
}
/// Log prefix style for build logs
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LogPrefixStyle {
/// Just package name (pname)
Short,
/// Full derivation name with version
Full,
/// No prefix
None,
}
/// Summary display style
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SummaryStyle {
@ -34,6 +45,18 @@ impl SummaryStyle {
}
}
impl LogPrefixStyle {
#[must_use]
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"short" => Self::Short,
"full" => Self::Full,
"none" => Self::None,
_ => Self::Short,
}
}
}
impl DisplayFormat {
#[must_use]
pub fn from_str(s: &str) -> Self {
@ -50,34 +73,40 @@ impl DisplayFormat {
#[derive(Debug, Clone)]
pub struct Config {
/// Whether we're piping output through
pub piping: bool,
pub piping: bool,
/// Silent mode - minimal output
pub silent: bool,
pub silent: bool,
/// Input parsing mode
pub input_mode: InputMode,
pub input_mode: InputMode,
/// Show completion times
pub show_timers: bool,
pub show_timers: bool,
/// Terminal width override
pub width: Option<usize>,
pub width: Option<usize>,
/// Display format
pub format: DisplayFormat,
pub format: DisplayFormat,
/// Legend display style
pub legend_style: String,
pub legend_style: String,
/// Summary display style
pub summary_style: String,
pub summary_style: String,
/// Log prefix style for build logs
pub log_prefix_style: LogPrefixStyle,
/// Maximum number of log lines to display (None = unlimited)
pub log_line_limit: Option<usize>,
}
impl Default for Config {
fn default() -> Self {
Self {
piping: false,
silent: false,
input_mode: InputMode::Human,
show_timers: true,
width: None,
format: DisplayFormat::Tree,
legend_style: "table".to_string(),
summary_style: "concise".to_string(),
piping: false,
silent: false,
input_mode: InputMode::Human,
show_timers: true,
width: None,
format: DisplayFormat::Tree,
legend_style: "table".to_string(),
summary_style: "concise".to_string(),
log_prefix_style: LogPrefixStyle::Short,
log_line_limit: None,
}
}
}
@ -103,6 +132,8 @@ mod tests {
assert_eq!(config.input_mode, InputMode::Human);
assert!(config.show_timers);
assert_eq!(config.format, DisplayFormat::Tree);
assert_eq!(config.log_prefix_style, LogPrefixStyle::Short);
assert_eq!(config.log_line_limit, None);
}
#[test]

View file

@ -3,25 +3,29 @@
use cognos::{Actions, Activities, Host, Id, ProgressState, Verbosity};
use tracing::{debug, trace};
use crate::state::{
ActivityProgress,
ActivityStatus,
BuildFail,
BuildInfo,
BuildStatus,
CompletedBuildInfo,
CompletedTransferInfo,
Derivation,
DerivationId,
FailType,
FailedBuildInfo,
InputDerivation,
State,
StorePath,
StorePathId,
StorePathState,
TransferInfo,
current_time,
use crate::{
cache::BuildReportCache,
state::{
ActivityProgress,
ActivityStatus,
BuildFail,
BuildInfo,
BuildReport,
BuildStatus,
CompletedBuildInfo,
CompletedTransferInfo,
Derivation,
DerivationId,
FailType,
FailedBuildInfo,
InputDerivation,
State,
StorePath,
StorePathId,
StorePathState,
TransferInfo,
current_time,
},
};
/// Process a nix JSON message and update state
@ -96,10 +100,19 @@ fn handle_start(
109 => handle_query_path_info_start(state, id, &text, &fields, now), /* QueryPathInfo */
110 => handle_post_build_hook_start(state, id, &text, &fields, now), /* PostBuildHook */
101 => handle_file_transfer_start(state, id, &text, &fields, now), /* FileTransfer */
100 => handle_copy_path_start(state, id, &text, &fields, now), // CopyPath
102 | 103 | 104 | 106 | 107 | 111 | 112 => {
// Realise, CopyPaths, Builds, OptimiseStore, VerifyPaths, BuildWaiting,
// FetchTree These activities have no fields and are just tracked
100 => handle_copy_path_start(state, id, &text, &fields, now), /* CopyPath */
104 => {
// Builds activity - track this as the top-level builds activity
if state.builds_activity.is_none() {
state.builds_activity = Some(id);
true
} else {
false
}
},
102 | 103 | 106 | 107 | 111 | 112 => {
// Realise, CopyPaths, OptimiseStore, VerifyPaths, BuildWaiting, FetchTree
// These activities have no fields and are just tracked
true
},
_ => {
@ -281,11 +294,12 @@ fn handle_result(
match result_type {
100 => {
// FileLinked: 2 int fields
// FileLinked: 2 int fields (linked count, total count)
if fields.len() >= 2 {
let _linked = fields[0].as_u64();
let _total = fields[1].as_u64();
// TODO: Track file linking progress
let linked = fields[0].as_u64().unwrap_or(0);
let total = fields[1].as_u64().unwrap_or(0);
debug!("FileLinked: {}/{}", linked, total);
// File linking is reported but doesn't need state tracking
}
false
},
@ -300,17 +314,18 @@ fn handle_result(
102 => {
// UntrustedPath: 1 text field (store path)
if let Some(path_str) = fields.first().and_then(|f| f.as_str()) {
debug!("Untrusted path: {}", path_str);
// TODO: Track untrusted paths
debug!("Untrusted path reported: {}", path_str);
state
.nix_errors
.push(format!("Untrusted path: {}", path_str));
return true;
}
false
},
103 => {
// CorruptedPath: 1 text field (store path)
if let Some(path_str) = fields.first().and_then(|f| f.as_str()) {
state
.nix_errors
.push(format!("Corrupted path: {path_str}"));
state.nix_errors.push(format!("Corrupted path: {path_str}"));
return true;
}
false
@ -334,6 +349,19 @@ fn handle_result(
fields[2].as_u64(),
fields[3].as_u64(),
) {
// If this progress is for the Builds activity, track success tokens
if state.builds_activity == Some(id) {
if let Some(activity) = state.activities.get(&id) {
if let Some(prev_progress) = &activity.progress {
let new_done = done.saturating_sub(prev_progress.done);
if new_done > 0 {
state.success_tokens =
state.success_tokens.saturating_add(new_done);
}
}
}
}
if let Some(activity) = state.activities.get_mut(&id) {
activity.progress = Some(ActivityProgress {
done,
@ -350,9 +378,13 @@ fn handle_result(
106 => {
// SetExpected: 2 int fields (activity type, count)
if fields.len() >= 2 {
let _activity_type = fields[0].as_u64();
let _expected_count = fields[1].as_u64();
// TODO: Track expected counts
let activity_type = fields[0].as_u64().unwrap_or(0);
let expected_count = fields[1].as_u64().unwrap_or(0);
debug!(
"SetExpected: activity_type={}, count={}",
activity_type, expected_count
);
// Expected counts are informational and don't affect state tracking
}
false
},
@ -368,7 +400,7 @@ fn handle_result(
// FetchStatus: 1 text field
if let Some(status) = fields.first().and_then(|f| f.as_str()) {
debug!("Fetch status: {}", status);
// TODO: Track fetch status
// Fetch status is informational
}
false
},
@ -379,6 +411,50 @@ fn handle_result(
}
}
/// Get build time estimate from cache
fn get_build_estimate(
state: &State,
derivation_name: &str,
host: &Host,
) -> Option<u64> {
// Use pname if available, otherwise derivation name
let lookup_name = derivation_name.to_string();
let host_str = host.name();
BuildReportCache::calculate_median(
state
.build_cache
.get(&(host_str.to_string(), lookup_name))?
.as_slice(),
)
}
/// Record completed build for future predictions
fn record_build_completion(
state: &mut State,
derivation_name: String,
platform: Option<String>,
start: f64,
end: f64,
host: &Host,
) {
let duration_secs = end - start;
let completed_at = std::time::SystemTime::now();
let report = BuildReport {
derivation_name: derivation_name.clone(),
platform: platform.unwrap_or_default(),
duration_secs,
completed_at,
host: host.name().to_string(),
success: true,
};
// Store in state for later CSV persistence
let key = (host.name().to_string(), derivation_name);
state.build_cache.entry(key).or_default().push(report);
}
fn handle_build_start(
state: &mut State,
id: Id,
@ -402,13 +478,16 @@ fn handle_build_start(
if let Some(drv_path) = drv_path {
debug!("Extracted derivation path: {}", drv_path);
if let Some(drv) = Derivation::parse(&drv_path) {
let drv_id = state.get_or_create_derivation_id(drv);
let drv_id = state.get_or_create_derivation_id(drv.clone());
let host = extract_host(text);
// Get build time estimate from cache
let estimate = get_build_estimate(state, &drv.name, &host);
let build_info = BuildInfo {
start: now,
host,
estimate: None,
estimate,
activity_id: Some(id),
};
@ -444,21 +523,42 @@ fn handle_build_start(
false
}
fn handle_build_stop(state: &mut State, id: Id, _now: f64) -> bool {
// Find the derivation associated with this activity
for (drv_id, info) in &state.derivation_infos {
match &info.build_status {
BuildStatus::Building(build_info)
if build_info.activity_id == Some(id) =>
{
// Build was stopped but not marked as completed
// It might be cancelled
debug!("Build stopped for derivation {}", drv_id);
return false;
},
_ => {},
fn handle_build_stop(state: &mut State, id: Id, now: f64) -> bool {
// Check if we have success tokens to consume
if state.success_tokens > 0 {
// Find the derivation associated with this activity
for (drv_id, info) in state.derivation_infos.clone().iter() {
if let BuildStatus::Building(build_info) = &info.build_status {
if build_info.activity_id == Some(id) {
// Consume a success token and mark build as complete
state.success_tokens = state.success_tokens.saturating_sub(1);
state.update_build_status(*drv_id, BuildStatus::Built {
info: build_info.clone(),
end: now,
});
// Record build completion for future predictions
record_build_completion(
state,
info.name.name.clone(),
info.platform.clone(),
build_info.start,
now,
&build_info.host,
);
debug!(
"Build completed for derivation {} (success_tokens: {})",
drv_id, state.success_tokens
);
return true;
}
}
}
}
// No success tokens - build was stopped without completion signal
debug!("Build stopped for activity {} without success token", id);
false
}