use std::io::Write as _;
use std::path::Path;
use anyhow::Context as _;
pub fn build_osc777_json(title: &str, body: &str) -> String {
let safe_body = body.replace(';', "\\;");
let seq = format!("\x1b]777;notify;{title};{safe_body}\x07");
let payload = serde_json::json!({ "terminalSequence": seq });
serde_json::to_string(&payload).unwrap_or_default()
}
pub fn emit_osc777(message: &str) -> anyhow::Result<()> {
let json = build_osc777_json("limit detected", message);
println!("{json}");
Ok(())
}
pub fn append_log(sid: &str, message: &str, _owner_dir: &Path) -> anyhow::Result<()> {
use crate::paths;
use std::fs::OpenOptions;
let smart_dir = paths::smart_dir()?;
let log_path = smart_dir.join("limit-switch.log");
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
let hostname = get_hostname();
let line = format!("{ts} host={hostname} sid={sid} {message}\n");
let mut f = OpenOptions::new()
.append(true)
.create(true)
.open(&log_path)
.with_context(|| format!("failed to open limit-switch.log at {log_path:?}"))?;
f.write_all(line.as_bytes())
.with_context(|| format!("failed to append to limit-switch.log at {log_path:?}"))?;
Ok(())
}
fn get_hostname() -> String {
#[cfg(unix)]
{
use nix::unistd::gethostname;
if let Ok(h) = gethostname() {
let s = h.to_string_lossy().into_owned();
return s.split('.').next().unwrap_or(&s).to_string();
}
}
std::env::var("COMPUTERNAME")
.or_else(|_| std::env::var("HOSTNAME"))
.unwrap_or_else(|_| "unknown".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn osc777_payload_is_valid_json() {
let json = build_osc777_json("limit detected", "session abc-123 hit 99%");
let val: serde_json::Value = serde_json::from_str(&json).expect("should be valid JSON");
assert!(val.get("terminalSequence").is_some());
let ts_val = val["terminalSequence"].as_str().unwrap();
assert!(
ts_val.starts_with("\x1b]777;notify;"),
"sequence should start with OSC 777 prefix: {ts_val:?}"
);
assert!(
ts_val.ends_with('\x07'),
"sequence should end with BEL: {ts_val:?}"
);
}
#[test]
fn osc777_title_in_sequence() {
let json = build_osc777_json("limit detected", "body here");
let val: serde_json::Value = serde_json::from_str(&json).unwrap();
let seq = val["terminalSequence"].as_str().unwrap();
assert!(
seq.contains("limit detected"),
"title should appear in sequence: {seq}"
);
assert!(seq.contains("body here"), "body should appear: {seq}");
}
#[test]
fn osc777_semicolons_escaped_in_body() {
let json = build_osc777_json("limit detected", "switching; profile: work; hop 1");
let val: serde_json::Value = serde_json::from_str(&json).unwrap();
let seq = val["terminalSequence"].as_str().unwrap();
let after_prefix = seq.strip_prefix("\x1b]777;notify;limit detected;").unwrap();
let body = after_prefix.strip_suffix('\x07').unwrap();
for (i, ch) in body.char_indices() {
if ch == ';' {
let prev = if i > 0 {
body.as_bytes().get(i - 1).copied()
} else {
None
};
assert_eq!(
prev,
Some(b'\\'),
"unescaped ';' found at position {i} in body: {body}"
);
}
}
assert!(
body.contains("\\;"),
"escaped semicolons should use \\;: {body}"
);
}
#[test]
fn osc777_empty_message_no_panic() {
let json = build_osc777_json("limit detected", "");
assert!(!json.is_empty());
let val: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(val.get("terminalSequence").is_some());
}
#[test]
fn append_log_creates_and_writes() {
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
let hostname = "testhost";
let sid = "test-sid-0001";
let message = "action=switched to=work";
let line = format!("{ts} host={hostname} sid={sid} {message}\n");
assert!(line.contains("host=testhost"), "line: {line}");
assert!(line.contains("sid=test-sid-0001"), "line: {line}");
assert!(line.contains("action=switched"), "line: {line}");
assert!(
line.chars()
.take(10)
.all(|c| c.is_ascii_digit() || c == '-'),
"timestamp format unexpected: {line}"
);
}
#[test]
fn append_log_format_and_append() {
let dir = TempDir::new().unwrap();
let log_path = dir.path().join("limit-switch.log");
let entries = [
("sid-1", "action=switched to=work"),
("sid-2", "action=notify-only"),
];
for (sid, msg) in &entries {
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
let line = format!("{ts} host=testhostname sid={sid} {msg}\n");
let mut f = std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(&log_path)
.unwrap();
f.write_all(line.as_bytes()).unwrap();
}
let content = std::fs::read_to_string(&log_path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 2, "should have exactly 2 log lines: {content}");
assert!(lines[0].contains("sid-1"), "first line: {}", lines[0]);
assert!(lines[1].contains("sid-2"), "second line: {}", lines[1]);
for line in &lines {
assert!(line.contains("host="), "missing host= in: {line}");
assert!(line.contains("sid="), "missing sid= in: {line}");
}
}
#[test]
fn osc777_json_extractable_for_cc() {
let body = "[home] hit session 99% → switching to [work] (hop 1)";
let json = build_osc777_json("limit detected", body);
let val: serde_json::Value = serde_json::from_str(&json).unwrap();
let seq = val["terminalSequence"]
.as_str()
.expect("terminalSequence must be a string");
assert!(
seq.starts_with("\x1b]777;"),
"must start with ESC]777;: {seq:?}"
);
assert!(seq.contains("home"), "body content missing: {seq}");
assert!(seq.contains("work"), "body content missing: {seq}");
}
}