use bevy_ecs::prelude::Resource;
use egui::Ui;
use std::path::Path;
use super::gui_elements::{kit, EditorTheme};
#[derive(Clone, Debug)]
pub(super) struct JournalEntry {
pub(super) path: String,
pub(super) summary: String,
pub(super) ids: Vec<uuid::Uuid>,
pub(super) revision: String,
}
#[derive(Clone, Debug, PartialEq)]
pub(super) struct ResultRow {
pub(super) file: String,
pub(super) ok: bool,
pub(super) message: String,
pub(super) perf: String,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) enum AgentTab {
#[default]
Journal,
Results,
}
#[derive(Resource, Clone, Debug, Default)]
pub struct AgentJournal {
pub(super) entries: Vec<JournalEntry>,
pub(super) tab: AgentTab,
pub(super) pending: Option<JournalEntry>,
pub(super) paused: bool,
pub(super) accept_requested: bool,
pub(super) reject_requested: bool,
pub(super) pending_scene: Option<crate::runtime::SceneDocument>,
pub(super) pending_fields: Vec<(uuid::Uuid, String)>,
pub(super) pending_removed: Vec<(uuid::Uuid, String)>,
pub(super) accept_entity: Option<uuid::Uuid>,
results: Option<(std::time::SystemTime, Vec<ResultRow>)>,
}
impl AgentJournal {
const CAPACITY: usize = 200;
pub(super) fn record(&mut self, entry: JournalEntry) {
self.entries.push(entry);
if self.entries.len() > Self::CAPACITY {
self.entries.remove(0);
}
}
}
pub(super) fn read_test_results(path: &Path) -> Option<Vec<ResultRow>> {
let data: serde_json::Value =
serde_json::from_slice(&std::fs::read(path).ok()?).ok()?;
let rows = data["scenarios"].as_array()?.iter().map(|run| ResultRow {
file: run["file"]
.as_str()
.map(|file| {
Path::new(file).file_name().map_or(file.to_owned(), |name| {
name.to_string_lossy().into_owned()
})
})
.unwrap_or_default(),
ok: run["ok"].as_bool().unwrap_or(false),
message: run["message"].as_str().unwrap_or_default().to_owned(),
perf: perf_line(&run["perf"]),
});
Some(rows.collect())
}
fn perf_line(perf: &serde_json::Value) -> String {
let Some(mean) = perf["tick_ms_mean"].as_f64() else {
return String::new();
};
let ms = |key: &str| perf[key].as_f64().unwrap_or_default();
let mut line = format!(
"{mean:.2} ms mean, {:.2} p95, {:.2} max per tick",
ms("tick_ms_p95"),
ms("tick_ms_max")
);
if let Some(draws) = perf["draws"].as_u64() {
let triangles = perf["triangles"].as_u64().unwrap_or_default();
line += &format!(", {draws} draws, {triangles} triangles");
}
line
}
pub(super) fn draw_agent_area(
ui: &mut Ui,
journal: &mut AgentJournal,
project_root: &str,
) -> Option<Vec<uuid::Uuid>> {
kit::segmented(
ui,
"agent_tab",
&mut journal.tab,
&[
(AgentTab::Journal, "Journal"),
(AgentTab::Results, "Results"),
],
);
if journal.tab == AgentTab::Results {
draw_results(ui, journal, project_root);
return None;
}
let mut clicked = None;
ui.horizontal(|ui| {
kit::toggle(ui, &mut journal.paused);
ui.label("Pause agent edits");
});
if let Some(pending) = &journal.pending {
ui.colored_label(
EditorTheme::WARNING,
format!("Pending: {} ({})", pending.path, pending.summary),
);
let ids = pending.ids.clone();
ui.horizontal(|ui| {
if ui.button("Accept").clicked() {
journal.accept_requested = true;
}
if ui.button("Reject").clicked() {
journal.reject_requested = true;
}
if ui.button("Select").clicked() {
clicked = Some(ids.clone());
}
});
for id in ids {
let name = journal
.pending_scene
.iter()
.flat_map(|scene| &scene.entities)
.find(|entity| entity.id == id)
.and_then(|entity| entity.name.clone())
.unwrap_or_else(|| id.to_string());
ui.horizontal(|ui| {
if ui.small_button("Accept").clicked() {
journal.accept_entity = Some(id);
}
ui.label(name);
});
let fields = journal.pending_fields.iter().filter(|f| f.0 == id);
for (_, field) in fields.clone().take(8) {
ui.colored_label(EditorTheme::TEXT_MUTED, format!(" {field}"));
}
let more = fields.count().saturating_sub(8);
if more > 0 {
ui.colored_label(
EditorTheme::TEXT_MUTED,
format!(" +{more} more fields"),
);
}
}
for (id, name) in &journal.pending_removed {
ui.horizontal(|ui| {
if ui.small_button("Accept").clicked() {
journal.accept_entity = Some(*id);
}
ui.label(name);
ui.colored_label(EditorTheme::TEXT_MUTED, "removed");
});
}
}
ui.horizontal(|ui| {
ui.label(format!("Journal ({})", journal.entries.len()));
if ui.button("Clear").clicked() {
journal.entries.clear();
}
});
if journal.entries.is_empty() {
ui.colored_label(
EditorTheme::TEXT_MUTED,
"No outside changes yet. Scene writes from tools such as \
`rusting scene patch` appear here.",
);
return None;
}
egui::ScrollArea::vertical()
.id_salt("agent_area")
.show(ui, |ui| {
for (index, entry) in journal.entries.iter().enumerate().rev() {
let label = format!(
"#{} {} ({})",
index + 1,
entry.path,
entry.summary
);
if ui.selectable_label(false, label).clicked() {
clicked = Some(entry.ids.clone());
}
}
});
clicked
}
fn draw_results(ui: &mut Ui, journal: &mut AgentJournal, project_root: &str) {
let path = Path::new(project_root).join(crate::project::TEST_RESULTS_FILE);
let modified = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
if modified != journal.results.as_ref().map(|(time, _)| *time) {
journal.results = modified.zip(read_test_results(&path));
}
let Some((_, rows)) = &journal.results else {
ui.colored_label(
EditorTheme::TEXT_MUTED,
"No test results yet. Run `rusting test` on this project.",
);
return;
};
let failed = rows.iter().filter(|row| !row.ok).count();
ui.label(format!("{} scenarios, {failed} failed", rows.len()));
egui::ScrollArea::vertical()
.id_salt("agent_results")
.show(ui, |ui| {
for row in rows {
ui.horizontal(|ui| {
if row.ok {
kit::pill(ui, "pass", EditorTheme::ACCENT);
} else {
kit::pill(ui, "fail", EditorTheme::ERROR);
}
ui.label(&row.file);
});
if !row.perf.is_empty() {
ui.colored_label(EditorTheme::TEXT_MUTED, &row.perf);
}
if !row.ok {
ui.colored_label(EditorTheme::ERROR, &row.message);
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_results_are_read_from_the_file_rusting_test_writes() {
let root = std::env::temp_dir()
.join(format!("rusting-agent-results-{}", std::process::id()));
let path = root.join(crate::project::TEST_RESULTS_FILE);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
assert_eq!(read_test_results(&path), None);
std::fs::write(
&path,
serde_json::json!({"root": "/games/demo", "scenarios": [
{"file": "/games/demo/tests/jump.json", "ok": true,
"message": "\"jump\" after 60 ticks", "logs": [],
"perf": {"tick_ms_mean": 0.5, "tick_ms_p95": 0.75,
"tick_ms_max": 1.0, "draws": 12,
"triangles": 3400}},
{"file": "/games/demo/tests/coin.json", "ok": false,
"message": "tick 30: expected 1, got 0", "logs": []},
]})
.to_string(),
)
.unwrap();
let rows = read_test_results(&path).unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].file, "jump.json");
assert!(rows[0].ok);
assert_eq!(rows[1].file, "coin.json");
assert!(!rows[1].ok);
assert_eq!(rows[1].message, "tick 30: expected 1, got 0");
assert_eq!(
rows[0].perf,
"0.50 ms mean, 0.75 p95, 1.00 max per tick, 12 draws, 3400 triangles"
);
assert_eq!(rows[1].perf, "", "an older file has no perf");
let mut journal = AgentJournal {
tab: AgentTab::Results,
..AgentJournal::default()
};
let context = egui::Context::default();
let _ = context.run(egui::RawInput::default(), |context| {
egui::CentralPanel::default().show(context, |ui| {
draw_agent_area(ui, &mut journal, root.to_str().unwrap());
});
});
assert_eq!(
journal.results.as_ref().map(|(_, rows)| rows.len()),
Some(2)
);
std::fs::remove_dir_all(&root).unwrap();
}
}