mod board;
mod capture;
mod render;
mod terminal;
use std::path::Path;
use std::sync::mpsc::{self, RecvTimeoutError};
use std::sync::{Arc, Mutex, MutexGuard};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use concinnity_cook::build_only::LoadedWorld;
use concinnity_cook::{BuildProgress, BuildReport, BuildStage};
use concinnity_core::platform::Platform;
use board::{Board, NoteLevel, Outcome, RowState, Step};
use terminal::Surface;
const FRAME: Duration = Duration::from_millis(80);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Verbosity {
#[default]
Normal,
Verbose,
}
struct Shared {
board: Mutex<Board>,
surface: Mutex<Surface>,
}
impl Shared {
fn board(&self) -> MutexGuard<'_, Board> {
self.board.lock().unwrap_or_else(|e| e.into_inner())
}
fn note(&self, level: NoteLevel, text: String) {
self.board().note(level, text);
}
fn draw(&self, tick: usize, last: bool) {
let mut surface = self.surface.lock().unwrap_or_else(|e| e.into_inner());
let frame = {
let mut board = self.board();
surface.frame(&mut board, Instant::now(), tick, terminal::width(), last)
};
if !frame.is_empty() {
eprint!("{frame}");
}
}
}
pub(crate) struct BuildStatus {
shared: Arc<Shared>,
redraw: Option<(mpsc::Sender<()>, JoinHandle<()>)>,
ended: bool,
}
impl BuildStatus {
pub(crate) fn start(world: &str, platform: Platform, verbosity: Verbosity) -> Self {
let now = Instant::now();
let mut board = Board::new(now);
board.begin(Step::Load, 0, now);
let surface = Surface::for_stderr();
let header = render::header(&shown_path(Path::new(world)), &format!("{platform:?}"));
eprintln!("{}", header.paint(surface.colored()));
let live = matches!(surface, Surface::Live { .. });
let shared = Arc::new(Shared {
board: Mutex::new(board),
surface: Mutex::new(surface),
});
capture::install();
let shown = match verbosity {
Verbosity::Normal => tracing::Level::WARN,
Verbosity::Verbose => tracing::Level::INFO,
};
capture::attach(shared.clone(), shown);
let period = if live { FRAME } else { FRAME * 4 };
let (stop, stopped) = mpsc::channel::<()>();
let thread_shared = shared.clone();
let redraw = std::thread::Builder::new()
.name("build-status".into())
.spawn(move || {
let mut tick = 0;
while let Err(RecvTimeoutError::Timeout) = stopped.recv_timeout(period) {
tick += 1;
thread_shared.draw(tick, false);
}
})
.ok()
.map(|handle| (stop, handle));
Self {
shared,
redraw,
ended: false,
}
}
pub(crate) fn loaded(&self, loaded: &LoadedWorld) {
let summary = load_summary(
loaded.authored.len(),
loaded.assets.len(),
loaded.injected.len(),
);
self.shared.board().close(Some(summary), Instant::now());
}
pub(crate) fn progress(&self, progress: BuildProgress<'_>) {
self.shared.board().apply(progress, Instant::now());
}
pub(crate) fn finish(mut self, report: &BuildReport) {
let data_dir = report
.blobs
.first()
.and_then(|(path, _)| path.parent())
.map(shown_path)
.unwrap_or_default();
let outcome = Outcome::Built {
written: report.blobs.iter().map(|(_, size)| size).sum(),
data_dir,
};
let thumbnails = thumbnail_summary(&self.shared.board(), report);
self.end(outcome, thumbnails);
}
pub(crate) fn fail(mut self) {
self.end(Outcome::Failed, None);
}
pub(crate) fn fail_validation(mut self, errors: &[String]) {
for error in errors {
self.shared.note(NoteLevel::Error, error.clone());
}
let detail = render::plural(errors.len() as u32, "validation error", "validation errors");
self.end(Outcome::Failed, Some(detail));
}
fn end(&mut self, outcome: Outcome, summary: Option<String>) {
if std::mem::replace(&mut self.ended, true) {
return;
}
if let Some((stop, handle)) = self.redraw.take() {
let _ = stop.send(());
let _ = handle.join();
}
capture::release();
self.shared.board().end(outcome, summary, Instant::now());
self.shared.draw(0, true);
}
}
impl Drop for BuildStatus {
fn drop(&mut self) {
self.end(Outcome::Failed, None);
}
}
fn load_summary(authored: usize, assets: usize, injected: usize) -> String {
let counted = render::counted(assets as u32, "asset");
let mut summary = if authored == assets {
counted
} else {
let entries = render::plural(authored as u32, "entry", "entries");
format!("{entries} → {counted}")
};
if injected > 0 {
let added = render::counted(injected as u32, "default");
summary.push_str(&format!(" · {added} added"));
}
summary
}
fn thumbnail_summary(board: &Board, report: &BuildReport) -> Option<String> {
let running = board.rows.last().filter(|r| r.state == RowState::Running)?;
(running.step == Step::Cook(BuildStage::Thumbnails)).then(|| {
board::thumbnail_summary(
report.thumbnails.baked as u32,
report.thumbnails.reused as u32,
)
})
}
fn shown_path(path: &Path) -> String {
let relative = std::env::current_dir()
.ok()
.and_then(|cwd| path.strip_prefix(cwd).ok().map(Path::to_path_buf));
relative.as_deref().unwrap_or(path).display().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_load_summary_shows_expansion_and_added_defaults() {
assert_eq!(load_summary(3, 3, 0), "3 assets");
assert_eq!(load_summary(1, 40, 0), "1 entry → 40 assets");
assert_eq!(
load_summary(2, 1_204, 2),
"2 entries → 1,204 assets · 2 defaults added"
);
}
#[test]
fn a_path_under_the_working_directory_is_shown_relative() {
let cwd = std::env::current_dir().unwrap();
assert_eq!(
shown_path(&cwd.join("worlds").join("a.jsonl")),
Path::new("worlds").join("a.jsonl").display().to_string()
);
assert_eq!(shown_path(Path::new("x/y.jsonl")), "x/y.jsonl");
}
}