a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
243 lines
9.0 KiB
Rust
243 lines
9.0 KiB
Rust
//! `/support` command: gather diagnostics and open a prefilled support email.
|
|
//!
|
|
//! The diagnostics builder is a pure function over [`SupportDiagnostics`] so it
|
|
//! can be unit-tested without an `App`. The command opens a `mailto:` URL via
|
|
//! the shared detached opener (which honors NO_BROWSER and test suppression)
|
|
//! and always prints the full email text in the TUI as a fallback for
|
|
//! terminals that cannot open mailto links.
|
|
|
|
use super::{App, DisplayMessage};
|
|
|
|
pub(super) const SUPPORT_EMAIL: &str = "support@jcode.sh";
|
|
pub(super) const SUPPORT_SUBJECT: &str = "jcode support";
|
|
|
|
/// Everything the support email body needs. Pure data so the body builder is
|
|
/// trivially testable.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub(super) struct SupportDiagnostics {
|
|
pub version: String,
|
|
pub git_hash: String,
|
|
pub build_channel: String,
|
|
pub os: String,
|
|
pub arch: String,
|
|
pub telemetry_id: Option<String>,
|
|
pub account_id: Option<String>,
|
|
pub account_email: Option<String>,
|
|
pub tier: Option<String>,
|
|
pub provider: String,
|
|
pub model: String,
|
|
pub last_error: Option<String>,
|
|
}
|
|
|
|
/// Build the plain-text email body from diagnostics. Pure function.
|
|
pub(super) fn build_support_body(d: &SupportDiagnostics) -> String {
|
|
let mut body = String::from("Describe your issue:\n\n\n");
|
|
body.push_str("--- Diagnostics (auto-generated) ---\n");
|
|
body.push_str(&format!("Version: {}\n", d.version));
|
|
body.push_str(&format!("Git hash: {}\n", d.git_hash));
|
|
body.push_str(&format!("Build channel: {}\n", d.build_channel));
|
|
body.push_str(&format!("OS/Arch: {}/{}\n", d.os, d.arch));
|
|
if let Some(id) = &d.telemetry_id {
|
|
body.push_str(&format!("Telemetry ID: {}\n", id));
|
|
}
|
|
if let Some(id) = &d.account_id {
|
|
body.push_str(&format!("Account ID: {}\n", id));
|
|
}
|
|
if let Some(email) = &d.account_email {
|
|
body.push_str(&format!("Account email: {}\n", email));
|
|
}
|
|
if let Some(tier) = &d.tier {
|
|
body.push_str(&format!("Tier: {}\n", tier));
|
|
}
|
|
body.push_str(&format!("Provider: {}\n", d.provider));
|
|
body.push_str(&format!("Model: {}\n", d.model));
|
|
if let Some(err) = &d.last_error {
|
|
body.push_str(&format!("Last error: {}\n", err));
|
|
}
|
|
body
|
|
}
|
|
|
|
/// Build the `mailto:` URL with subject and body URL-encoded.
|
|
pub(super) fn build_mailto_url(body: &str) -> String {
|
|
format!(
|
|
"mailto:{}?subject={}&body={}",
|
|
SUPPORT_EMAIL,
|
|
urlencoding::encode(SUPPORT_SUBJECT),
|
|
urlencoding::encode(body)
|
|
)
|
|
}
|
|
|
|
fn build_channel() -> String {
|
|
if std::env::var(jcode_selfdev_types::CLIENT_SELFDEV_ENV).is_ok() {
|
|
return "selfdev".to_string();
|
|
}
|
|
if jcode_build_meta::is_release_build() {
|
|
return "release".to_string();
|
|
}
|
|
if let Ok(exe) = std::env::current_exe() {
|
|
let path = exe.to_string_lossy();
|
|
if path.contains("/target/debug/") || path.contains("\\target\\debug\\") {
|
|
return "debug".to_string();
|
|
}
|
|
if path.contains("/target/release/") || path.contains("\\target\\release\\") {
|
|
return "local_build".to_string();
|
|
}
|
|
}
|
|
"dev".to_string()
|
|
}
|
|
|
|
/// Read the persisted telemetry id without creating one (read-only, so
|
|
/// `/support` never mutates telemetry state).
|
|
fn read_telemetry_id() -> Option<String> {
|
|
let path = crate::storage::jcode_dir().ok()?.join("telemetry_id");
|
|
let id = std::fs::read_to_string(path).ok()?;
|
|
let id = id.trim().to_string();
|
|
if id.is_empty() { None } else { Some(id) }
|
|
}
|
|
|
|
fn gather_diagnostics(app: &App) -> SupportDiagnostics {
|
|
use crate::provider_catalog::load_env_value_from_env_or_config;
|
|
use crate::subscription_catalog as cat;
|
|
|
|
// Account identity is only present when a jcode subscription is
|
|
// configured; skip gracefully otherwise.
|
|
let has_subscription = cat::has_credentials();
|
|
let (account_id, account_email, tier) = if has_subscription {
|
|
(
|
|
load_env_value_from_env_or_config(cat::JCODE_ACCOUNT_ID_ENV, cat::JCODE_ENV_FILE),
|
|
load_env_value_from_env_or_config(cat::JCODE_ACCOUNT_EMAIL_ENV, cat::JCODE_ENV_FILE),
|
|
cat::cached_tier().map(|t| t.display_name().to_string()),
|
|
)
|
|
} else {
|
|
(None, None, None)
|
|
};
|
|
|
|
let last_error = app
|
|
.display_messages
|
|
.iter()
|
|
.rev()
|
|
.find(|message| message.role == "error")
|
|
.map(|message| {
|
|
let mut content = message.content.trim().to_string();
|
|
const MAX_ERROR_LEN: usize = 500;
|
|
if content.chars().count() > MAX_ERROR_LEN {
|
|
content = content.chars().take(MAX_ERROR_LEN).collect::<String>() + "…";
|
|
}
|
|
content
|
|
});
|
|
|
|
SupportDiagnostics {
|
|
version: jcode_build_meta::VERSION.to_string(),
|
|
git_hash: jcode_build_meta::GIT_HASH.to_string(),
|
|
build_channel: build_channel(),
|
|
os: std::env::consts::OS.to_string(),
|
|
arch: std::env::consts::ARCH.to_string(),
|
|
telemetry_id: read_telemetry_id(),
|
|
account_id,
|
|
account_email,
|
|
tier,
|
|
provider: app.provider_name().to_string(),
|
|
model: app.provider_model(),
|
|
last_error,
|
|
}
|
|
}
|
|
|
|
pub(super) fn handle_support_command(app: &mut App, trimmed: &str) -> bool {
|
|
if trimmed != "/support" && !trimmed.starts_with("/support ") {
|
|
return false;
|
|
}
|
|
|
|
let diagnostics = gather_diagnostics(app);
|
|
let body = build_support_body(&diagnostics);
|
|
let mailto = build_mailto_url(&body);
|
|
let opened = super::helpers::open_path_or_url_detached(&mailto).is_ok();
|
|
|
|
let mut message = String::new();
|
|
if opened {
|
|
message.push_str("Opened a support email draft in your mail client.\n\n");
|
|
} else {
|
|
message
|
|
.push_str("Could not open your mail client automatically. Copy the email below.\n\n");
|
|
}
|
|
message.push_str(&format!("To: {}\n", SUPPORT_EMAIL));
|
|
message.push_str(&format!("Subject: {}\n\n", SUPPORT_SUBJECT));
|
|
message.push_str(&body);
|
|
|
|
app.push_display_message(DisplayMessage::system(message).with_title("Support"));
|
|
app.set_status_notice(if opened {
|
|
"Support email draft opened"
|
|
} else {
|
|
"Support email printed below"
|
|
});
|
|
true
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn sample_diagnostics() -> SupportDiagnostics {
|
|
SupportDiagnostics {
|
|
version: "v0.35.72-dev (abc1234)".to_string(),
|
|
git_hash: "abc1234".to_string(),
|
|
build_channel: "release".to_string(),
|
|
os: "linux".to_string(),
|
|
arch: "x86_64".to_string(),
|
|
telemetry_id: Some("11111111-2222-3333-4444-555555555555".to_string()),
|
|
account_id: Some("acct_42".to_string()),
|
|
account_email: Some("user@example.com".to_string()),
|
|
tier: Some("$100 Pro".to_string()),
|
|
provider: "anthropic".to_string(),
|
|
model: "claude-opus-4-8".to_string(),
|
|
last_error: Some("request timed out".to_string()),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn support_body_includes_all_diagnostics() {
|
|
let body = build_support_body(&sample_diagnostics());
|
|
assert!(body.starts_with("Describe your issue:\n"));
|
|
assert!(body.contains("Version: v0.35.72-dev (abc1234)"));
|
|
assert!(body.contains("Git hash: abc1234"));
|
|
assert!(body.contains("Build channel: release"));
|
|
assert!(body.contains("OS/Arch: linux/x86_64"));
|
|
assert!(body.contains("Telemetry ID: 11111111-2222-3333-4444-555555555555"));
|
|
assert!(body.contains("Account ID: acct_42"));
|
|
assert!(body.contains("Account email: user@example.com"));
|
|
assert!(body.contains("Tier: $100 Pro"));
|
|
assert!(body.contains("Provider: anthropic"));
|
|
assert!(body.contains("Model: claude-opus-4-8"));
|
|
assert!(body.contains("Last error: request timed out"));
|
|
}
|
|
|
|
#[test]
|
|
fn support_body_skips_optional_fields_when_absent() {
|
|
let body = build_support_body(&SupportDiagnostics {
|
|
version: "v1".to_string(),
|
|
git_hash: "deadbee".to_string(),
|
|
build_channel: "dev".to_string(),
|
|
os: "linux".to_string(),
|
|
arch: "x86_64".to_string(),
|
|
provider: "openai".to_string(),
|
|
model: "gpt-5.5".to_string(),
|
|
..Default::default()
|
|
});
|
|
assert!(!body.contains("Telemetry ID:"));
|
|
assert!(!body.contains("Account ID:"));
|
|
assert!(!body.contains("Account email:"));
|
|
assert!(!body.contains("Tier:"));
|
|
assert!(!body.contains("Last error:"));
|
|
assert!(body.contains("Provider: openai"));
|
|
}
|
|
|
|
#[test]
|
|
fn support_mailto_url_is_correctly_encoded() {
|
|
let url = build_mailto_url("Describe your issue:\n\nVersion: v1 (abc)");
|
|
assert!(url.starts_with("mailto:support@jcode.sh?subject=jcode%20support&body="));
|
|
// Newlines, spaces, colons, and parens must be percent-encoded.
|
|
assert!(url.contains("Describe%20your%20issue%3A%0A%0AVersion%3A%20v1%20%28abc%29"));
|
|
assert!(!url.contains(' '));
|
|
assert!(!url.contains('\n'));
|
|
}
|
|
}
|