Files
OneCAD-Simulation/crates/engine/src/bug_report.rs
T
jyvikoandClaude Opus 5.5 a079e51f9d repo: move to github.com/tangency-labs/fieldcraft (URLs, updater, Pages)
The updater's release endpoint, docs, README, release notes and the Pages
URL point at the tangency-labs org. App IDs (io.github.jyviko.fieldcraft)
stay unchanged so installed copies keep their identity and settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 10:38:13 -04:00

394 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Bug report drafts (Help › Report a Bug, and Report it after a crash): a GitHub new-issue link
//! that fills in the bug form (`.github/ISSUE_TEMPLATE/bug.yml`), and the files to attach.
//!
//! Nothing here sends anything. The user reviews the draft, then their browser opens the issue
//! form, which they submit or not.
//!
//! What a draft holds by default: the version, the OS, what went wrong (the user's words, the
//! last error or the crash's message) and, for a crash, where it happened. The user's home
//! folder is shown as `~`. Only with **Share my session** does it also carry the commands that
//! built the model (the replayable journal, which can name the user's files), and only with
//! **Include the model** as well the model file. A session too long for the link (GitHub
//! refuses long new-issue links) goes out as a file to attach and on the clipboard instead; the
//! model file is always one to attach.
use serde_json::{Value, json};
use crate::crash::without_home;
/// The issue form.
pub const ISSUE_URL: &str = "https://github.com/tangency-labs/fieldcraft/issues/new";
/// The longest link a draft opens: GitHub refuses new-issue links much past 8 KB.
pub const MAX_URL: usize = 7000;
/// The session file a draft attaches.
pub const SESSION_FILE: &str = "fieldcraft-session.json";
/// The model file a draft attaches.
pub const MODEL_FILE: &str = "model.fieldcraft";
/// Backtrace frames a crash's draft quotes.
const FRAMES: usize = 12;
/// A field's text is cut at this many bytes.
const MAX_FIELD: usize = 3000;
/// What to put in a draft.
#[derive(Clone, Debug, Default)]
pub struct Input {
pub title: String,
/// What happened: the user's words, or the last error.
pub observed: String,
/// A crash report (`crash::read`), when reporting one.
pub crash: Option<Value>,
/// Attach the replayable commands.
pub share_session: bool,
/// The commands (each `{command, params}`) that rebuild the model from `model` (or from an
/// empty model when there is none).
pub commands: Vec<Value>,
/// Attach the model file too (only with `share_session`).
pub include_model: bool,
pub model: Option<Vec<u8>>,
}
/// A file the user attaches to the issue.
#[derive(Clone, Debug, PartialEq)]
pub struct Attachment {
pub name: String,
pub bytes: Vec<u8>,
/// What it is, for the review.
pub what: String,
}
/// A draft ready for review.
#[derive(Clone, Debug, PartialEq)]
pub struct Draft {
pub title: String,
/// The bug form's fields by id (`observed`, `reproduction`, `evidence`, `version`), as the
/// link fills them.
pub fields: Vec<(&'static str, String)>,
/// The link that opens the filled form.
pub url: String,
/// Files to attach (the session when too long for the link, the model).
pub attachments: Vec<Attachment>,
/// Text to put on the clipboard (the session, when it is an attachment).
pub clipboard: Option<String>,
}
impl Draft {
/// The draft as JSON (attachments by name, size and what they are).
pub fn to_json(&self) -> Value {
json!({
"title": self.title,
"fields": self.fields.iter().map(|(k, v)| (k.to_string(), json!(v))).collect::<serde_json::Map<String, Value>>(),
"url": self.url,
"attachments": self.attachments.iter().map(|a| json!({"name": a.name, "bytes": a.bytes.len(), "what": a.what})).collect::<Vec<_>>(),
"clipboard": self.clipboard.as_ref().map(String::len),
})
}
}
/// `v0.5.8 (abc1234), macos aarch64`.
pub fn version_line(version: &str, commit: &str) -> String {
let commit = if commit.is_empty() { String::new() } else { format!(" ({commit})") };
format!("v{version}{commit}, {} {}", std::env::consts::OS, std::env::consts::ARCH)
}
/// Percent-encode `s` for a query value (RFC 3986 unreserved characters stay).
pub fn encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
out.push(char::from(b));
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
fn cut(s: &str, n: usize) -> String {
if s.len() <= n {
return s.to_string();
}
let mut end = n;
while !s.is_char_boundary(end) {
end -= 1;
}
format!("{}\n[cut: {} bytes in all]", s.get(..end).unwrap_or_default(), s.len())
}
fn link(title: &str, fields: &[(&'static str, String)]) -> String {
let mut url = format!("{ISSUE_URL}?template=bug.yml&title={}", encode(title));
for (k, v) in fields {
if !v.is_empty() {
url.push_str(&format!("&{k}={}", encode(v)));
}
}
url
}
/// What a crash report says, for the evidence field: its kind, message, place, command (its id;
/// its parameters only with the session) and the top of its backtrace.
fn crash_evidence(report: &Value, share: bool) -> String {
let s = |k: &str| report.get(k).and_then(Value::as_str).unwrap_or("?");
let mut out =
format!("Crash report `{}` ({}), seen {} time(s)\n\n", s("id"), s("kind"), report.get("count").and_then(Value::as_u64).unwrap_or(1));
out.push_str(&format!("Message: {}\nAt: {}\n", without_home(s("message")), without_home(s("location"))));
if let Some(c) = report.get("command").filter(|c| !c.is_null()) {
let id = c.get("command").and_then(Value::as_str).unwrap_or("?");
match c.get("params").filter(|_| share) {
Some(p) => out.push_str(&format!("Command: `{id}` {}\n", without_home(&p.to_string()))),
None => out.push_str(&format!("Command: `{id}`\n")),
}
}
if let Some(a) = report.get("last_activity").and_then(Value::as_str) {
out.push_str(&format!("Last activity: {a}\n"));
}
let frames: Vec<&str> = report.get("backtrace").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).take(FRAMES).collect();
if !frames.is_empty() {
out.push_str("\nBacktrace (top):\n```\n");
for f in frames {
out.push_str(&without_home(f));
out.push('\n');
}
out.push_str("```\n");
}
out
}
/// The draft for `input`, by the [`version_line`] `version`.
pub fn draft(input: &Input, version: &str) -> Draft {
let crash = input.crash.as_ref();
let mut title = input.title.trim().to_string();
if title.is_empty() {
title = match crash.and_then(|c| c.get("kind")).and_then(Value::as_str) {
Some("unclean_exit") => "FieldCraft closed unexpectedly".to_string(),
Some(_) => "Internal error".to_string(),
None => match input.observed.trim().lines().next().filter(|l| !l.is_empty()) {
Some(line) => format!("Bug: {}", line.chars().take(100).collect::<String>()),
None => "Bug report".to_string(),
},
};
if let Some(c) = crash.and_then(|c| c.get("command")).and_then(|c| c.get("command")).and_then(Value::as_str) {
title.push_str(&format!(" in {c}"));
}
}
let title = cut(&without_home(&title), 200);
let mut observed = without_home(input.observed.trim());
if observed.is_empty()
&& let Some(m) = crash.and_then(|c| c.get("message")).and_then(Value::as_str)
{
observed = without_home(m);
}
let observed = cut(&observed, MAX_FIELD);
let share = input.share_session;
let mut evidence = crash.map(|c| crash_evidence(c, share)).unwrap_or_default();
let mut attachments = Vec::new();
let mut clipboard = None;
let mut reproduction = String::new();
// The session: the replayable script, from the attached model when there is one.
let model = input.model.as_ref().filter(|_| share && input.include_model);
let script = share.then(|| {
let mut steps = Vec::new();
if model.is_some() {
steps.push(json!({"command": "file.open", "params": {"path": MODEL_FILE}}));
}
steps.extend(input.commands.iter().cloned());
Value::Array(steps)
});
if let Some(m) = model {
attachments.push(Attachment { name: MODEL_FILE.into(), bytes: m.clone(), what: format!("the model ({} KB)", m.len().div_ceil(1024)) });
}
if let Some(script) = &script {
let n = script.as_array().map_or(0, Vec::len);
reproduction = format!("Replay the attached session ({n} command(s)): `fieldcraft-cli run {SESSION_FILE}`");
if model.is_some() {
reproduction.push_str(&format!(" in a folder holding `{MODEL_FILE}` too"));
} else if crash.is_some_and(|c| c.get("files").and_then(|f| f.get("model")).is_some()) {
reproduction.push_str(" (the crash replays from the model, which is not attached)");
}
reproduction.push('.');
}
let inline = |evidence: &str, script: &Value| {
let text = serde_json::to_string_pretty(script).unwrap_or_default();
format!("{evidence}\nSession ({SESSION_FILE}):\n```json\n{text}\n```\n")
};
let fields = |evidence: String| {
vec![("observed", observed.clone()), ("reproduction", reproduction.clone()), ("evidence", evidence), ("version", version.to_string())]
};
// The session in the link when it fits, else a file to attach (also on the clipboard).
let mut url = String::new();
if let Some(script) = &script {
let candidate = fields(inline(&evidence, script));
let u = link(&title, &candidate);
if u.len() <= MAX_URL {
evidence = inline(&evidence, script);
url = u;
} else {
let text = serde_json::to_string_pretty(script).unwrap_or_default();
evidence.push_str(&format!(
"\nThe session is too long for this form: FieldCraft saved it as `{SESSION_FILE}` and copied it to the clipboard. Attach the file here (drag it in) or paste it.\n"
));
attachments.insert(0, Attachment { name: SESSION_FILE.into(), bytes: text.clone().into_bytes(), what: "the session's commands".into() });
clipboard = Some(text);
}
}
if model.is_some() {
evidence.push_str(&format!("\nThe model is attached as `{MODEL_FILE}` (drag it in from the folder FieldCraft saved it to).\n"));
}
if url.is_empty() || model.is_some() {
let mut f = fields(evidence.clone());
url = link(&title, &f);
// Still too long (a very long message): cut the longest field until it fits.
while url.len() > MAX_URL {
let Some((i, len)) = f.iter().enumerate().map(|(i, (_, v))| (i, v.len())).max_by_key(|(_, l)| *l) else { break };
if len < 200 {
break;
}
if let Some((_, v)) = f.get_mut(i) {
*v = cut(v, len.saturating_sub(url.len() - MAX_URL + 64).min(len / 2 + 1));
}
url = link(&title, &f);
}
evidence = f.get(2).map(|(_, v)| v.clone()).unwrap_or_default();
}
let fields = fields(evidence);
// The fields as the link carries them (a cut field included).
let fields = if url == link(&title, &fields) { fields } else { decode_fields(&url) };
Draft { title, fields, url, attachments, clipboard }
}
/// Write a draft's attachments to `bug-reports/<time>/` in the app's data folder (the user
/// attaches them from there); returns the folder.
pub fn save_attachments(draft: &Draft) -> Result<std::path::PathBuf, String> {
let root = fieldcraft_io::recovery::data_dir().ok_or("no data folder on this system")?;
let dir = root.join("bug-reports").join(crate::crash::stamp_now());
fieldcraft_io::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
for a in &draft.attachments {
fieldcraft_io::fs::write_atomic(&dir.join(&a.name), &a.bytes, false).map_err(|e| e.to_string())?;
}
Ok(dir)
}
/// The fields a link carries (for the review, when a field had to be cut).
fn decode_fields(url: &str) -> Vec<(&'static str, String)> {
let query = url.split_once('?').map_or("", |(_, q)| q);
["observed", "reproduction", "evidence", "version"]
.into_iter()
.map(|k| {
let v = query.split('&').find_map(|kv| kv.strip_prefix(k).and_then(|r| r.strip_prefix('='))).unwrap_or_default();
(k, decode(v))
})
.collect()
}
fn decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while let Some(&c) = b.get(i) {
let hex = b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok());
match (c, hex) {
(b'%', Some(v)) => {
out.push(v);
i += 3;
}
_ => {
out.push(c);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
fn home() -> String {
std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_else(|_| "/home/someone".into())
}
fn crash() -> Value {
json!({
"id": "20261014-101500-00000000deadbeef", "kind": "command_panic", "count": 2,
"message": format!("index out of bounds in {}/model.fieldcraft", home()),
"location": "crates/mesh/src/tet.rs:42",
"command": {"command": "mesh.tet.generate", "params": {"body": "Plate", "path": format!("{}/secret/plate.step", home())}},
"backtrace": ["fieldcraft_mesh::tet::insert (crates/mesh/src/tet.rs:42)"],
"files": {"model": "model.fieldcraft", "replay": "replay.json"},
})
}
#[test]
fn without_sharing_a_draft_names_no_paths_commands_or_model() {
let input = Input {
observed: format!("cannot open {}/parts/bracket.solidcraft", home()),
crash: Some(crash()),
commands: vec![json!({"command": "geometry.open", "params": {"path": format!("{}/parts/bracket.solidcraft", home())}})],
model: Some(b"model bytes".to_vec()),
include_model: true,
..Default::default()
};
let d = draft(&input, "v1.2.3, testos x");
assert!(d.url.starts_with("https://github.com/tangency-labs/fieldcraft/issues/new?template=bug.yml&title="), "{}", d.url);
assert!(d.attachments.is_empty() && d.clipboard.is_none(), "nothing attached without Share my session");
let all = format!("{} {:?} {}", d.title, d.fields, decode(&d.url));
assert!(!all.contains(&home()), "no home folder: {all}");
assert!(!all.contains("secret") && !all.contains("geometry.open"), "no parameters or commands: {all}");
assert!(all.contains("~/parts/bracket.solidcraft"), "the error, its home shown as ~: {all}");
assert!(all.contains("mesh.tet.generate") && all.contains("tet.rs:42") && all.contains("v1.2.3, testos x"), "{all}");
assert_eq!(d.title, "Internal error in mesh.tet.generate");
assert_eq!(decode_fields(&d.url), d.fields, "the review shows what the link carries");
}
#[test]
fn a_short_session_goes_in_the_link() {
let input = Input {
title: "Solve fails".into(),
share_session: true,
commands: vec![json!({"command": "file.sample", "params": {"name": "cantilever"}}), json!({"command": "solve.run", "params": {}})],
..Default::default()
};
let d = draft(&input, "v1");
assert!(d.attachments.is_empty() && d.clipboard.is_none());
let evidence = &d.fields.iter().find(|(k, _)| *k == "evidence").unwrap().1;
assert!(evidence.contains("\"command\": \"solve.run\""), "{evidence}");
assert!(d.url.len() <= MAX_URL);
assert!(decode(&d.url).contains("file.sample"));
}
#[test]
fn a_long_session_and_the_model_are_attachments() {
let commands: Vec<Value> = (0..400).map(|i| json!({"command": "mesh.node.add", "params": {"label": i + 1, "xyz": [i, 0, 0]}})).collect();
let input =
Input { share_session: true, include_model: true, commands, model: Some(vec![7; 3000]), crash: Some(crash()), ..Default::default() };
let d = draft(&input, "v1");
assert!(d.url.len() <= MAX_URL, "{}", d.url.len());
let names: Vec<&str> = d.attachments.iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, [SESSION_FILE, MODEL_FILE]);
let session: Value = serde_json::from_slice(&d.attachments[0].bytes).unwrap();
assert_eq!(session[0], json!({"command": "file.open", "params": {"path": MODEL_FILE}}), "the replay opens the attached model");
assert_eq!(session.as_array().unwrap().len(), 401);
assert_eq!(d.clipboard.as_deref().map(str::as_bytes), Some(d.attachments[0].bytes.as_slice()));
let text = decode(&d.url);
assert!(text.contains("too long for this form") && text.contains(MODEL_FILE) && !text.contains("\"label\": 399"), "{text}");
// With the session shared, the crash's command parameters are shown (home as ~).
assert!(text.contains("~/secret/plate.step"), "{text}");
}
#[test]
fn a_huge_message_is_cut_to_fit_the_link() {
let input = Input { observed: "é".repeat(20_000), ..Default::default() };
let d = draft(&input, "v1");
assert!(d.url.len() <= MAX_URL, "{}", d.url.len());
assert!(d.fields[0].1.contains("[cut:"));
assert_eq!(decode_fields(&d.url), d.fields);
}
#[test]
fn encoding_round_trips() {
let s = "a b&c=d/é\n```json";
assert_eq!(decode(&encode(s)), s);
assert_eq!(encode("a b&"), "a%20b%26");
}
}