From cb7e3a2711918b10d1b7b0ae0a16e519a04d86f1 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:16:49 -0400 Subject: [PATCH] CLI: fieldcraft-cli crashes [list|show|replay|dir|clear] (#107) Lists, shows, replays and deletes the crash reports the desktop app wrote. A replay opens the report's model, runs its commands with writes confined to a scratch folder, and says whether the same internal error happens again. Adapted from SolidCraft's solidcraft-cli crashes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- apps/fieldcraft-cli/src/crashes.rs | 80 ++++++++++++++++++++++++++++ apps/fieldcraft-cli/src/main.rs | 9 ++++ apps/fieldcraft-cli/tests/crashes.rs | 75 ++++++++++++++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 apps/fieldcraft-cli/src/crashes.rs create mode 100644 apps/fieldcraft-cli/tests/crashes.rs diff --git a/apps/fieldcraft-cli/src/crashes.rs b/apps/fieldcraft-cli/src/crashes.rs new file mode 100644 index 0000000..24a5e64 --- /dev/null +++ b/apps/fieldcraft-cli/src/crashes.rs @@ -0,0 +1,80 @@ +//! `fieldcraft-cli crashes`: the crash reports the desktop app wrote on this computer +//! (docs/crash-reports.md): list them, show one, replay one, delete them. + +use std::path::Path; + +use fieldcraft_engine::crash; +use serde_json::json; + +pub const USAGE: &str = "usage: fieldcraft-cli crashes [list [--json] | show | replay | dir | clear []]"; + +pub fn run(args: &[String]) -> Result<(), String> { + let dir = crash::default_dir().ok_or("no crash folder on this system (set FIELDCRAFT_CRASH_DIR)")?; + let id = || args.get(1).map(String::as_str).ok_or_else(|| USAGE.to_string()); + match args.first().map(String::as_str) { + None | Some("list") => { + let reports = crash::list(&dir); + if args.iter().any(|a| a == "--json") { + return print_json(&json!({ "dir": dir.to_string_lossy(), "reports": reports })); + } + println!("crash reports in {}", dir.display()); + if reports.is_empty() { + println!(" (none)"); + } + for r in reports { + let times = if r.count > 1 { format!(" ×{}", r.count) } else { String::new() }; + let cmd = r.command.map(|c| format!(" in {c}")).unwrap_or_default(); + let replay = if r.replayable { " [replayable]" } else { "" }; + println!(" {} {}{times}{cmd} v{}{replay}\n {}", r.id, r.kind, r.version, r.message); + } + Ok(()) + } + Some("show") => print_json(&crash::read(&dir, id()?)?), + Some("replay") => replay(&dir, id()?), + Some("dir") => { + println!("{}", dir.display()); + Ok(()) + } + Some("clear") => { + println!("deleted {} report(s)", crash::clear(&dir, args.get(1).map(String::as_str))); + Ok(()) + } + _ => Err(USAGE.to_string()), + } +} + +fn print_json(v: &serde_json::Value) -> Result<(), String> { + println!("{}", serde_json::to_string_pretty(v).map_err(|e| e.to_string())?); + Ok(()) +} + +/// Run a report's commands on its model and say whether the crash happens again. Files the +/// commands write go to a scratch folder, never over the user's files. +fn replay(dir: &Path, id: &str) -> Result<(), String> { + let report = crash::read(dir, id)?; + let scratch = std::env::temp_dir().join(format!("fieldcraft-replay-{}", std::process::id())); + std::fs::create_dir_all(&scratch).map_err(|e| format!("{}: {e}", scratch.display()))?; + if report["kind"] == "unclean_exit" { + println!("this crash ended the app without a report: if this process ends abruptly, it reproduced"); + } + println!("replaying on {}", report["model_path"].as_str().unwrap_or("a new model")); + let r = crash::replay(&report, &scratch); + let _ = std::fs::remove_dir_all(&scratch); + let r = r?; + for (i, (cmd, outcome)) in r.steps.iter().enumerate() { + match outcome { + Ok(()) => println!(" {:>3}. {cmd}: ok", i + 1), + Err(e) => println!(" {:>3}. {cmd}: {e}", i + 1), + } + } + let said = |k: &str| report[k].as_str().unwrap_or_default().to_string(); + if r.reproduced { + println!("reproduced: the same internal error ({}) at {}", said("message"), said("location")); + } else if let Some((l, m)) = r.panics.first() { + println!("an internal error happened, but not the reported one: {m} at {l}"); + println!("(the report says: {} at {})", said("message"), said("location")); + } else { + println!("did not reproduce: no internal error (fixed, or it depends on something the replay does not have)"); + } + Ok(()) +} diff --git a/apps/fieldcraft-cli/src/main.rs b/apps/fieldcraft-cli/src/main.rs index eaa5902..d8d1519 100644 --- a/apps/fieldcraft-cli/src/main.rs +++ b/apps/fieldcraft-cli/src/main.rs @@ -2,10 +2,13 @@ //! fieldcraft-cli run //! fieldcraft-cli features //! fieldcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR] [--automation-write-root DIR] +//! fieldcraft-cli crashes [list [--json] | show | replay | dir | clear []] //! fieldcraft-cli --version #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod crashes; + use std::path::Path; use std::process::ExitCode; @@ -28,6 +31,11 @@ usage: --automation-read-root DIR files may be read only inside DIR --automation-write-root DIR files may be written only inside DIR (with either root, the access a root left out is not granted) + fieldcraft-cli crashes [list|show |replay |dir|clear []] + the crash reports the desktop app wrote on this + computer (docs/crash-reports.md): list them (--json), + show one, replay one (does the crash happen again?), + print their folder, delete them fieldcraft-cli --version print the version "; @@ -52,6 +60,7 @@ fn main() -> ExitCode { Ok(()) } Some("mcp") => mcp(args.get(1..).unwrap_or(&[])), + Some("crashes") => crashes::run(args.get(1..).unwrap_or(&[])), Some("run") => args.get(1).ok_or_else(|| format!("run needs a script\n\n{USAGE}")).and_then(|p| run(p)), Some("-V" | "--version") => { println!("fieldcraft-cli {}", env!("CARGO_PKG_VERSION")); diff --git a/apps/fieldcraft-cli/tests/crashes.rs b/apps/fieldcraft-cli/tests/crashes.rs new file mode 100644 index 0000000..4bb348e --- /dev/null +++ b/apps/fieldcraft-cli/tests/crashes.rs @@ -0,0 +1,75 @@ +//! `fieldcraft-cli crashes` on a crash folder of its own (`FIELDCRAFT_CRASH_DIR`). + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use fieldcraft_engine::Session; +use serde_json::{Value, json}; + +const ID: &str = "20261014-101500-00000000deadbeef"; + +/// A replayable report: a model with two nodes and a replay that adds a third. +fn folder() -> PathBuf { + let d = std::env::temp_dir().join(format!("fieldcraft-cli-crashes-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + let r = d.join(ID); + std::fs::create_dir_all(&r).unwrap(); + let mut s = Session::default(); + for l in 1..=2 { + s.execute("mesh.node.add", &json!({"label": l, "xyz": [l, 0, 0]})).unwrap(); + } + std::fs::write(r.join("model.fieldcraft"), s.file_bytes(None).unwrap()).unwrap(); + let replay = json!({"commands": [ + {"command": "mesh.node.add", "params": {"label": 3, "xyz": [3, 0, 0]}}, + {"command": "file.save", "params": {"path": d.join("overwritten.fieldcraft")}}, + ]}); + std::fs::write(r.join("replay.json"), replay.to_string()).unwrap(); + let report = json!({ + "format": "fieldcraft-crash/1", "id": ID, "kind": "unclean_exit", "signature": "00000000deadbeef", "count": 2, + "first_seen": "2026-10-14T10:15:00Z", "message": "FieldCraft ended without a clean exit while running `mesh.node.add`", + "location": "unclean exit in mesh.node.add", "command": {"command": "mesh.node.add", "params": {}}, + "app": {"version": "9.9.9"}, "backtrace": [], + }); + std::fs::write(r.join("report.json"), report.to_string()).unwrap(); + d +} + +fn cli(dir: &Path, args: &[&str]) -> (String, bool) { + let out = Command::new(env!("CARGO_BIN_EXE_fieldcraft-cli")).args(args).env("FIELDCRAFT_CRASH_DIR", dir).output().unwrap(); + (format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)), out.status.success()) +} + +#[test] +fn crashes_list_show_replay_and_clear() { + let d = folder(); + let (out, ok) = cli(&d, &["crashes"]); + assert!(ok, "{out}"); + assert!(out.contains(ID) && out.contains("unclean_exit ×2 in mesh.node.add") && out.contains("[replayable]"), "{out}"); + let (out, ok) = cli(&d, &["crashes", "list", "--json"]); + assert!(ok); + let v: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["reports"][0]["id"], ID); + let (out, ok) = cli(&d, &["crashes", "show", ID]); + assert!(ok); + let v: Value = serde_json::from_str(&out).unwrap(); + assert!(v["model_path"].as_str().unwrap().ends_with("model.fieldcraft")); + let (out, ok) = cli(&d, &["crashes", "replay", ID]); + assert!(ok, "{out}"); + assert!(out.contains("1. mesh.node.add: ok"), "{out}"); + // A command that writes a file is refused outside the scratch folder: the replay never + // writes over the user's files. + assert!(out.contains("2. file.save: ") && out.contains("outside the write root"), "{out}"); + assert!(!d.join("overwritten.fieldcraft").exists()); + assert!(out.contains("did not reproduce"), "{out}"); + let (out, ok) = cli(&d, &["crashes", "show", "../etc"]); + assert!(!ok && out.contains("no crash report"), "{out}"); + let (out, ok) = cli(&d, &["crashes", "dir"]); + assert!(ok && out.trim() == d.to_string_lossy(), "{out}"); + let (out, ok) = cli(&d, &["crashes", "clear"]); + assert!(ok && out.contains("deleted 1 report(s)"), "{out}"); + let (out, _) = cli(&d, &["crashes"]); + assert!(out.contains("(none)"), "{out}"); + let (out, ok) = cli(&d, &["crashes", "nope"]); + assert!(!ok && out.contains("usage"), "{out}"); + let _ = std::fs::remove_dir_all(&d); +}