1use crate::config::{Config, EffectiveConfigDiagnostic};
7use crate::doctor::{run_doctor, DoctorReport};
8use crate::error::{Result, UserError};
9use crate::observability::{process_metrics, MetricsSnapshot};
10use serde::{Deserialize, Serialize};
11use std::env;
12use std::fs;
13use std::path::{Path, PathBuf};
14use std::time::{SystemTime, UNIX_EPOCH};
15
16pub const SUPPORT_BUNDLE_VERSION: u32 = 1;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct SupportBundle {
22 pub schema_version: u32,
23 pub created_at_unix: u64,
24 pub aurum_version: String,
25 pub os: String,
26 pub arch: String,
27 pub family: String,
28 pub doctor: DoctorReport,
29 pub metrics: MetricsSnapshot,
30 pub config: EffectiveConfigDiagnostic,
31 pub redaction_notes: Vec<String>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub user_notes: Option<String>,
36}
37
38impl SupportBundle {
39 pub fn to_json_pretty(&self) -> Result<String> {
40 serde_json::to_string_pretty(self).map_err(|e| {
41 UserError::Other {
42 message: format!("support bundle json: {e}"),
43 }
44 .into()
45 })
46 }
47
48 pub fn write_to_path(&self, path: &Path) -> Result<()> {
49 if let Some(parent) = path.parent() {
50 fs::create_dir_all(parent).map_err(|e| UserError::Other {
51 message: format!("create support bundle dir: {e}"),
52 })?;
53 }
54 let json = self.to_json_pretty()?;
55 let tmp = path.with_extension("json.tmp");
56 fs::write(&tmp, json.as_bytes()).map_err(|e| UserError::Other {
57 message: format!("write support bundle temp: {e}"),
58 })?;
59 fs::rename(&tmp, path).map_err(|e| UserError::Other {
60 message: format!("publish support bundle: {e}"),
61 })?;
62 Ok(())
63 }
64}
65
66pub fn build_support_bundle(cfg: &Config, user_notes: Option<String>) -> SupportBundle {
68 let mut doctor = run_doctor(cfg);
69 for c in &mut doctor.checks {
71 c.summary = redact_path_str(&c.summary);
72 if let Some(d) = c.detail.as_mut() {
73 *d = redact_path_str(d);
74 }
75 if let Some(h) = c.hint.as_mut() {
76 *h = redact_path_str(h);
77 }
78 }
79 let mut diag = cfg.effective_diagnostic();
80 diag.cache_dir = redact_path_str(&diag.cache_dir);
82 if let Some(p) = diag.config_path.as_mut() {
83 *p = redact_path_str(p);
84 }
85 if let Some(p) = diag.tts_pack_dir.as_mut() {
86 *p = redact_path_str(p);
87 }
88 if diag.openrouter_api_key.is_some() {
90 diag.openrouter_api_key = Some("***".into());
91 }
92
93 let mut user_notes = user_notes;
94 if let Some(n) = user_notes.as_mut() {
95 *n = redact_path_str(n);
96 }
97
98 SupportBundle {
99 schema_version: SUPPORT_BUNDLE_VERSION,
100 created_at_unix: SystemTime::now()
101 .duration_since(UNIX_EPOCH)
102 .map(|d| d.as_secs())
103 .unwrap_or(0),
104 aurum_version: env!("CARGO_PKG_VERSION").into(),
105 os: env::consts::OS.into(),
106 arch: env::consts::ARCH.into(),
107 family: env::consts::FAMILY.into(),
108 doctor,
109 metrics: process_metrics().snapshot(),
110 config: diag,
111 redaction_notes: vec![
112 "API keys redacted".into(),
113 "source audio and transcripts never included".into(),
114 "absolute home/cache paths tokenised".into(),
115 "no network requests performed while building this bundle".into(),
116 ],
117 user_notes,
118 }
119}
120
121pub fn default_bundle_path() -> PathBuf {
123 let stamp = SystemTime::now()
124 .duration_since(UNIX_EPOCH)
125 .map(|d| d.as_secs())
126 .unwrap_or(0);
127 PathBuf::from(format!("aurum-support-{stamp}.json"))
128}
129
130fn redact_path_str(s: &str) -> String {
131 let mut out = s.to_string();
132 if let Ok(home) = env::var("HOME") {
133 if !home.is_empty() {
134 out = out.replace(&home, "$HOME");
135 }
136 }
137 if let Ok(userprofile) = env::var("USERPROFILE") {
138 if !userprofile.is_empty() {
139 out = out.replace(&userprofile, "$HOME");
140 }
141 }
142 out
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
150 fn bundle_has_no_home_leak_when_redacted() {
151 let cfg = Config::load().unwrap();
152 let b = build_support_bundle(&cfg, Some("repro steps".into()));
153 let json = b.to_json_pretty().unwrap();
154 assert!(json.contains("schema_version"));
155 assert!(json.contains("redaction_notes"));
156 assert!(!json.contains("OPENROUTER_API_KEY="));
157 if let Ok(home) = env::var("HOME") {
159 if cfg.cache_dir.starts_with(&home) {
160 assert!(!json.contains(&home));
161 assert!(json.contains("$HOME") || !json.contains("cache_dir"));
162 }
163 }
164 }
165
166 #[test]
167 fn write_roundtrip() {
168 let dir = tempfile::tempdir().unwrap();
169 let path = dir.path().join("bundle.json");
170 let cfg = Config::load().unwrap();
171 let b = build_support_bundle(&cfg, None);
172 b.write_to_path(&path).unwrap();
173 let loaded: SupportBundle =
174 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
175 assert_eq!(loaded.schema_version, SUPPORT_BUNDLE_VERSION);
176 }
177}