use std::fs::OpenOptions;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use anyhow::Result;
use clap::Parser;
use pixtuoid::cli::{Cli, Cmd};
use pixtuoid::{config, init_pack, install, runtime, validate};
use tracing_subscriber::EnvFilter;
fn main() -> Result<()> {
install_crash_hook();
let (log_level, cli_theme, cmd) = Cli::parse().cmd_or_default();
let rust_log = std::env::var("RUST_LOG").ok();
let make_filter = || {
EnvFilter::try_new(filter_directives(rust_log.as_deref(), &log_level))
.unwrap_or_else(|_| EnvFilter::new(&log_level))
};
let tui_active = matches!(&cmd, Cmd::Run { headless, .. } if !*headless);
let wants_verbose = matches!(log_level.as_str(), "debug" | "trace");
let explicit_log_file = std::env::var("PIXTUOID_LOG").is_ok_and(|v| !v.is_empty());
if tui_active {
let rust_log_set = rust_log.as_deref().is_some_and(|v| !v.is_empty());
let filter = if wants_verbose || explicit_log_file {
make_filter()
} else if rust_log_set {
EnvFilter::try_new(filter_directives(rust_log.as_deref(), "warn"))
.unwrap_or_else(|_| EnvFilter::new("warn"))
} else {
EnvFilter::new(match log_level.as_str() {
lvl @ ("warn" | "error") => lvl,
_ => "warn",
})
};
let path = log_file_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
rotate_if_large(&path);
match OpenOptions::new().create(true).append(true).open(&path) {
Ok(f) => {
let writer = Arc::new(Mutex::new(f));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_ansi(false)
.with_writer(move || MutexFileWriter(writer.clone()))
.init();
}
Err(e) => {
eprintln!(
"⚠ pixtuoid: cannot open log file {} ({e}) — runtime warnings will not be recorded",
path.display()
);
}
}
} else {
tracing_subscriber::fmt()
.with_env_filter(make_filter())
.with_writer(std::io::stderr)
.init();
}
match cmd {
Cmd::Run {
socket,
projects_root,
codex_sessions_root,
pack_dir,
max_desks: cli_max_desks,
headless,
} => {
let cfg_path = config::config_path();
let mut cfg_warnings = Vec::new();
let cfg = config::load(&cfg_path, &mut cfg_warnings);
let theme = config::resolve_theme(&cfg, cli_theme.as_deref(), &mut cfg_warnings)?;
let desk_cap = cli_max_desks.or(cfg.max_desks);
let pack_dir = config::resolve_pack_dir(&cfg, pack_dir);
let pets = config::resolve_pets(&cfg, &mut cfg_warnings);
if !headless {
for w in &cfg_warnings {
eprintln!("⚠ pixtuoid: {w}");
}
}
runtime::run(runtime::RunConfig {
socket,
projects_root,
codex_sessions_root,
pack_dir,
desk_cap,
headless,
config_path: cfg_path,
theme,
pets,
})
}
Cmd::InstallHooks {
hook_path,
config,
target,
yes,
} => install::install(install::InstallArgs {
hook_path,
config,
target,
yes,
}),
Cmd::UninstallHooks {
config,
target,
yes,
} => install::uninstall(install::UninstallArgs {
config,
target,
yes,
}),
Cmd::ValidatePack { pack_dir } => validate::validate_pack(&pack_dir),
Cmd::InitPack { dest, force } => init_pack::init_pack(&dest, force),
}
}
fn install_crash_hook() {
std::panic::set_hook(Box::new(|info| {
let _ = crossterm::execute!(
std::io::stderr(),
crossterm::event::DisableMouseCapture,
crossterm::terminal::LeaveAlternateScreen
);
let _ = crossterm::terminal::disable_raw_mode();
let version = env!("CARGO_PKG_VERSION");
let crash_path = crash_log_path();
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let panic_msg = extract_panic_message(info);
let location = info
.location()
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
.unwrap_or_default();
let bt = std::backtrace::Backtrace::force_capture();
let bt_str = bt.to_string();
let mut report = String::new();
report.push_str(&format!("pixtuoid v{version} crashed at {timestamp}\n"));
report.push_str(&format!("{panic_msg}\n at {location}\n\n"));
report.push_str(&bt_str);
report.push('\n');
if let Some(parent) = crash_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(mut f) = OpenOptions::new()
.create(true)
.append(true)
.open(&crash_path)
{
use std::io::Write;
let _ = f.write_all(report.as_bytes());
}
let issue_url = build_issue_url(version, &panic_msg, &location, &bt_str, &crash_path);
eprintln!("\n\x1b[1;31mpixtuoid v{version} crashed — sorry about that.\x1b[0m\n");
eprintln!(" \x1b[2m{panic_msg}\x1b[0m");
eprintln!(" \x1b[2mat {location}\x1b[0m\n");
eprintln!(" \x1b[1mHelp fix it\x1b[0m — open this link to file a pre-filled bug report");
eprintln!(" (panic + backtrace already included, no typing needed):\n");
eprintln!(" \x1b[4m{issue_url}\x1b[0m\n");
eprintln!(
" Full backtrace saved to \x1b[2m{}\x1b[0m",
crash_path.display()
);
eprintln!(" \x1b[2m(attach if the reviewer asks — the link above only carries a truncated trace)\x1b[0m\n");
}));
}
#[allow(deprecated)]
fn extract_panic_message(info: &std::panic::PanicInfo<'_>) -> String {
if let Some(s) = info.payload().downcast_ref::<&str>() {
return (*s).to_string();
}
if let Some(s) = info.payload().downcast_ref::<String>() {
return s.clone();
}
"unknown panic".to_string()
}
fn build_issue_url(
version: &str,
panic_msg: &str,
location: &str,
backtrace: &str,
crash_path: &std::path::Path,
) -> String {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
let title_msg = if panic_msg.len() > 80 {
let cut = truncate_to_char_boundary(panic_msg, 80);
format!("{}…", &panic_msg[..cut])
} else {
panic_msg.to_string()
};
let title = format!("Crash: {title_msg}");
const MAX_BT: usize = 1500;
let bt_body = if backtrace.len() > MAX_BT {
let cut = truncate_to_char_boundary(backtrace, MAX_BT);
format!(
"{}\n\n... truncated — see {} for full trace",
&backtrace[..cut],
crash_path.display()
)
} else {
backtrace.to_string()
};
let body = format!(
"## Environment\n\
- **Version:** {version}\n\
- **OS:** {os}/{arch}\n\n\
## Panic\n\
```\n{panic_msg}\n at {location}\n```\n\n\
## Backtrace\n\
```\n{bt_body}\n```\n"
);
format!(
"https://github.com/IvanWng97/pixtuoid/issues/new?labels=crash-report&title={}&body={}",
percent_encode(&title),
percent_encode(&body),
)
}
fn percent_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 2);
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
use std::fmt::Write;
let _ = write!(out, "%{b:02X}");
}
}
}
out
}
fn truncate_to_char_boundary(s: &str, max_bytes: usize) -> usize {
if max_bytes >= s.len() {
return s.len();
}
let mut cut = max_bytes;
while cut > 0 && !s.is_char_boundary(cut) {
cut -= 1;
}
cut
}
fn crash_log_path() -> PathBuf {
if let Ok(state) = std::env::var("XDG_STATE_HOME") {
return PathBuf::from(format!("{state}/pixtuoid/crash.log"));
}
if let Some(home) = pixtuoid::install::io::user_home() {
return PathBuf::from(home)
.join(".cache")
.join("pixtuoid")
.join("crash.log");
}
std::env::temp_dir().join("pixtuoid-crash.log")
}
fn filter_directives<'a>(rust_log: Option<&'a str>, log_level: &'a str) -> &'a str {
match rust_log {
Some(v) if !v.is_empty() => v,
_ => log_level,
}
}
fn log_file_path() -> PathBuf {
if let Ok(p) = std::env::var("PIXTUOID_LOG") {
if !p.is_empty() {
return PathBuf::from(p);
}
}
if let Ok(state) = std::env::var("XDG_STATE_HOME") {
return PathBuf::from(format!("{state}/pixtuoid/log"));
}
if let Some(home) = pixtuoid::install::io::user_home() {
return PathBuf::from(home)
.join(".cache")
.join("pixtuoid")
.join("log");
}
std::env::temp_dir().join("pixtuoid.log")
}
const LOG_ROTATE_BYTES: u64 = 5 * 1024 * 1024;
fn rotate_if_large(path: &Path) {
let too_large = std::fs::metadata(path).is_ok_and(|m| m.len() > LOG_ROTATE_BYTES);
if too_large {
let mut old = path.as_os_str().to_os_string();
old.push(".old");
let _ = std::fs::rename(path, &old);
}
}
struct MutexFileWriter(Arc<Mutex<std::fs::File>>);
impl std::io::Write for MutexFileWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0
.lock()
.map_err(|_| std::io::Error::other("poisoned"))?
.write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
self.0
.lock()
.map_err(|_| std::io::Error::other("poisoned"))?
.flush()
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
#[test]
fn empty_rust_log_falls_back_to_requested_level() {
assert_eq!(filter_directives(Some(""), "debug"), "debug");
assert_eq!(filter_directives(None, "debug"), "debug");
assert_eq!(filter_directives(Some("trace"), "warn"), "trace");
assert_eq!(
filter_directives(Some("info,pixtuoid=debug"), "warn"),
"info,pixtuoid=debug"
);
}
#[test]
fn rotate_if_large_rotates_once_past_the_cap() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("log");
std::fs::write(&log, b"recent").unwrap();
rotate_if_large(&log);
assert!(log.exists(), "under-cap log must not rotate");
let f = std::fs::OpenOptions::new().write(true).open(&log).unwrap();
f.set_len(LOG_ROTATE_BYTES + 1).unwrap();
drop(f);
rotate_if_large(&log);
assert!(!log.exists(), "over-cap log rotates away");
assert!(
dir.path().join("log.old").exists(),
"one prior generation is kept"
);
}
#[test]
fn rotate_if_large_appends_old_to_dotted_custom_paths() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("app.log");
let f = std::fs::File::create(&log).unwrap();
f.set_len(LOG_ROTATE_BYTES + 1).unwrap();
drop(f);
rotate_if_large(&log);
assert!(!log.exists());
assert!(
dir.path().join("app.log.old").exists(),
".old is appended, not substituted"
);
}
#[test]
fn truncate_ascii() {
assert_eq!(truncate_to_char_boundary("hello world", 5), 5);
assert_eq!(
&"hello world"[..truncate_to_char_boundary("hello world", 5)],
"hello"
);
}
#[test]
fn truncate_multibyte_boundary() {
let s = "café";
assert_eq!(s.len(), 5);
let cut = truncate_to_char_boundary(s, 4);
assert_eq!(cut, 3);
assert_eq!(&s[..cut], "caf");
}
#[test]
fn truncate_beyond_length() {
assert_eq!(truncate_to_char_boundary("short", 100), 5);
}
#[test]
fn percent_encode_ascii() {
assert_eq!(percent_encode("hello"), "hello");
assert_eq!(percent_encode("a b"), "a%20b");
}
#[test]
fn percent_encode_special_chars() {
assert_eq!(percent_encode("#&="), "%23%26%3D");
assert_eq!(percent_encode("a\nb"), "a%0Ab");
}
#[test]
fn build_issue_url_starts_with_github() {
let url = build_issue_url(
"0.4.0",
"test panic",
"file.rs:1:1",
"bt",
Path::new("/tmp/x"),
);
assert!(url.starts_with("https://github.com/IvanWng97/pixtuoid/issues/new?"));
assert!(url.contains("labels=crash-report"));
assert!(url.contains("title="));
assert!(url.contains("body="));
}
#[test]
fn build_issue_url_truncates_long_backtrace() {
let long_bt = "x".repeat(2000);
let url = build_issue_url("0.4.0", "msg", "loc", &long_bt, Path::new("/tmp/x"));
assert!(url.len() < 8191);
}
}