//! 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, /// 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, /// Attach the model file too (only with `share_session`). pub include_model: bool, pub model: Option>, } /// A file the user attaches to the issue. #[derive(Clone, Debug, PartialEq)] pub struct Attachment { pub name: String, pub bytes: Vec, /// 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, /// Text to put on the clipboard (the session, when it is an attachment). pub clipboard: Option, } 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::>(), "url": self.url, "attachments": self.attachments.iter().map(|a| json!({"name": a.name, "bytes": a.bytes.len(), "what": a.what})).collect::>(), "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::()), 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/