use std::io::Write;
use std::path::PathBuf;
fn support_dir() -> Option<PathBuf> {
let home = std::env::var_os("HOME")?;
Some(PathBuf::from(home).join("Library/Application Support/ai.cyber.erga"))
}
pub fn log_path() -> Option<PathBuf> {
let home = std::env::var_os("HOME")?;
Some(PathBuf::from(home).join("Library/Logs/erga/erga.log"))
}
pub fn log(line: &str) {
let Some(p) = log_path() else { return };
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
}
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {
let _ = writeln!(f, "{secs} {line}");
}
}
pub fn reveal_log() {
if let Some(p) = log_path() {
let _ = std::process::Command::new("open").arg("-R").arg(p).spawn();
}
}
pub fn log_tail(n: usize) -> String {
let Some(p) = log_path() else { return String::new() };
let Ok(text) = std::fs::read_to_string(p) else { return String::new() };
let lines: Vec<&str> = text.lines().collect();
lines[lines.len().saturating_sub(n)..].join("\n")
}
fn shell(cmd: &str, args: &[&str]) -> String {
std::process::Command::new(cmd)
.args(args)
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_default()
}
fn encode(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 2);
for b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(*b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
fn report_body(os: &str, chip: &str, mem_gb: &str, state: &str, log: &str) -> String {
format!(
"**What happened?**\n\n\n\
**What did you expect?**\n\n\n\
---\n\n\
| | |\n\
|---|---|\n\
| erga | {} |\n\
| macOS | {os} |\n\
| chip | {chip} |\n\
| memory | {mem_gb} |\n\n\
**State**\n\n```\n{state}\n```\n\n\
**Recent log**\n\n```\n{log}\n```\n",
env!("CARGO_PKG_VERSION"),
)
}
pub fn report_bug(state: &str) {
let os = shell("sw_vers", &["-productVersion"]);
let chip = shell("sysctl", &["-n", "machdep.cpu.brand_string"]);
let mem_gb = shell("sysctl", &["-n", "hw.memsize"])
.parse::<u64>()
.map(|b| format!("{:.0} GB", b as f64 / (1u64 << 30) as f64))
.unwrap_or_default();
let body = report_body(&os, &chip, &mem_gb, state, &log_tail(40));
let body: String = body.chars().take(5000).collect();
let url = format!(
"https://github.com/cyberia-to/erga/issues/new?title={}&body={}",
encode(&format!("[{}] ", env!("CARGO_PKG_VERSION"))),
encode(&body)
);
let _ = std::process::Command::new("open").arg(url).spawn();
reveal_log();
}
pub fn write_intensity(mode: &str) {
let Some(d) = support_dir() else { return };
let _ = std::fs::create_dir_all(&d);
let _ = std::fs::write(d.join("intensity"), mode);
}
#[derive(Clone, Default)]
pub struct Store {
pub accepted: u64,
pub rejected: u64,
pub donated: u64,
pub hashed: u64,
pub seen_intro: bool,
pub solo: bool,
pub intensity: String,
pub payout: Option<String>,
}
fn path() -> Option<PathBuf> {
Some(support_dir()?.join("history.json"))
}
impl Store {
pub fn load() -> Self {
let Some(p) = path() else { return Self::default() };
let Ok(text) = std::fs::read_to_string(p) else { return Self::default() };
let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else {
return Self::default();
};
let n = |k: &str| v.get(k).and_then(|x| x.as_u64()).unwrap_or(0);
let b = |k: &str| v.get(k).and_then(|x| x.as_bool()).unwrap_or(false);
Store {
accepted: n("accepted"),
rejected: n("rejected"),
donated: n("donated"),
hashed: n("hashed"),
seen_intro: b("seen_intro"),
solo: b("solo"),
intensity: v
.get("intensity")
.and_then(|x| x.as_str())
.unwrap_or("max")
.to_string(),
payout: v.get("payout").and_then(|x| x.as_str()).map(str::to_string),
}
}
pub fn save_with(&self, s_acc: u64, s_rej: u64, s_don: u64, s_hashed: u64) {
let Some(p) = path() else { return };
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
}
let v = serde_json::json!({
"accepted": self.accepted + s_acc,
"rejected": self.rejected + s_rej,
"donated": self.donated + s_don,
"hashed": self.hashed + s_hashed,
"seen_intro": self.seen_intro,
"solo": self.solo,
"intensity": self.intensity,
"payout": self.payout,
});
let _ = std::fs::write(p, v.to_string());
}
pub fn save(&self) {
self.save_with(0, 0, 0, 0);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn report_body_has_no_indented_lines() {
let body = report_body("15.0", "Apple M4 Max", "48 GB", "mining", "a log line");
for line in body.lines() {
assert!(
!line.starts_with(" "),
"indented line would render as a code block: {line:?}"
);
}
}
#[test]
fn report_body_keeps_the_table() {
let body = report_body("15.0", "Apple M4 Max", "48 GB", "mining", "log");
assert!(body.contains("\n|---|---|\n"), "delimiter row missing");
for row in ["| macOS | 15.0 |", "| chip | Apple M4 Max |", "| memory | 48 GB |"] {
assert!(body.contains(&format!("\n{row}\n")), "row missing: {row}");
}
}
}