#[cfg(target_os = "macos")]
use anyhow::Result;
#[cfg(target_os = "macos")]
pub const ROTATION_LAUNCHD_LABEL: &str = "com.trusty.trusty-search.logrotate";
#[cfg(target_os = "macos")]
pub const ROTATION_SIZE_KB: u32 = 1024;
#[cfg(target_os = "macos")]
pub const ROTATION_KEEP: u32 = 7;
#[cfg(target_os = "macos")]
pub fn stderr_log_path() -> Result<std::path::PathBuf> {
let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not resolve $HOME"))?;
Ok(home
.join("Library")
.join("Logs")
.join("trusty-search")
.join("stderr.log"))
}
#[cfg(target_os = "macos")]
pub fn newsyslog_conf_path() -> Result<std::path::PathBuf> {
let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not resolve $HOME"))?;
Ok(home
.join("Library")
.join("Application Support")
.join("trusty-search")
.join("newsyslog.conf"))
}
#[cfg(target_os = "macos")]
pub fn rotation_plist_path() -> Result<std::path::PathBuf> {
let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not resolve $HOME"))?;
Ok(home
.join("Library")
.join("LaunchAgents")
.join(format!("{ROTATION_LAUNCHD_LABEL}.plist")))
}
#[cfg(target_os = "macos")]
pub fn newsyslog_conf_body(stderr_log: &std::path::Path) -> String {
format!(
"# trusty-search log rotation (issue #127) — managed by `trusty-search doctor --fix`.\n\
# Columns: logfile_name mode count size when flags\n\
# Rotates at {size} KB or daily (whichever comes first); keeps {keep} archives.\n\
{path} 644 {keep} {size} $D0 JN\n",
path = stderr_log.display(),
size = ROTATION_SIZE_KB,
keep = ROTATION_KEEP,
)
}
#[cfg(target_os = "macos")]
pub fn rotation_plist_body(newsyslog_conf: &std::path::Path) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{label}</string>
<key>ProgramArguments</key>
<array>
<string>/usr/sbin/newsyslog</string>
<string>-F</string>
<string>-f</string>
<string>{conf}</string>
</array>
<!-- Daily at 03:17. StartCalendarInterval (not StartInterval) so a Mac
that was asleep at 03:17 runs the rotation once on next wake rather
than firing repeatedly to "catch up". -->
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>3</integer>
<key>Minute</key>
<integer>17</integer>
</dict>
<key>RunAtLoad</key>
<true/>
<key>ProcessType</key>
<string>Background</string>
</dict>
</plist>
"#,
label = ROTATION_LAUNCHD_LABEL,
conf = newsyslog_conf.display(),
)
}
#[cfg(target_os = "macos")]
pub fn rotation_configured() -> bool {
let system = std::path::Path::new("/etc/newsyslog.d/trusty-search.conf");
if system.exists() {
return true;
}
newsyslog_conf_path()
.map(|p| p.exists())
.unwrap_or(false)
}
#[cfg(target_os = "macos")]
pub fn install_rotation() -> Result<()> {
let stderr_log = stderr_log_path()?;
let conf_path = newsyslog_conf_path()?;
let plist_path = rotation_plist_path()?;
if let Some(parent) = conf_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| anyhow::anyhow!("create {}: {e}", parent.display()))?;
}
std::fs::write(&conf_path, newsyslog_conf_body(&stderr_log))
.map_err(|e| anyhow::anyhow!("write {}: {e}", conf_path.display()))?;
if let Some(parent) = plist_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| anyhow::anyhow!("create {}: {e}", parent.display()))?;
}
std::fs::write(&plist_path, rotation_plist_body(&conf_path))
.map_err(|e| anyhow::anyhow!("write {}: {e}", plist_path.display()))?;
let uid = nix::unistd::getuid().as_raw();
let domain = format!("gui/{uid}");
let _ = std::process::Command::new("launchctl")
.args(["bootout", &domain])
.arg(&plist_path)
.status();
let status = std::process::Command::new("launchctl")
.args(["bootstrap", &domain])
.arg(&plist_path)
.status()
.map_err(|e| anyhow::anyhow!("launchctl bootstrap failed: {e}"))?;
if !status.success() {
anyhow::bail!("launchctl bootstrap exited with {status}");
}
Ok(())
}
#[cfg(all(test, target_os = "macos"))]
mod tests {
use super::*;
#[test]
fn stderr_log_path_ends_with_expected_components() {
let p = stderr_log_path().expect("HOME should resolve in tests");
let s = p.to_string_lossy();
assert!(s.ends_with("Library/Logs/trusty-search/stderr.log"), "{s}");
}
#[test]
fn newsyslog_conf_path_under_application_support() {
let p = newsyslog_conf_path().expect("HOME should resolve in tests");
let s = p.to_string_lossy();
assert!(
s.ends_with("Library/Application Support/trusty-search/newsyslog.conf"),
"{s}"
);
}
#[test]
fn rotation_plist_path_uses_rotation_label() {
let p = rotation_plist_path().expect("HOME should resolve in tests");
let s = p.to_string_lossy();
assert!(s.contains(ROTATION_LAUNCHD_LABEL), "{s}");
assert!(s.ends_with(".plist"), "{s}");
}
#[test]
fn newsyslog_conf_body_has_expected_columns() {
let log = std::path::Path::new("/Users/test/Library/Logs/trusty-search/stderr.log");
let body = newsyslog_conf_body(log);
assert!(body.contains("/Users/test/Library/Logs/trusty-search/stderr.log"));
assert!(body.contains(&ROTATION_SIZE_KB.to_string()));
assert!(body.contains(&format!(" {} ", ROTATION_KEEP)));
assert!(body.contains("$D0"), "should rotate daily as well: {body}");
assert!(body.trim_end().ends_with("JN"), "flags column: {body}");
}
#[test]
fn rotation_plist_body_invokes_newsyslog() {
let conf = std::path::Path::new("/Users/test/Library/Application Support/trusty-search/newsyslog.conf");
let body = rotation_plist_body(conf);
assert!(body.contains("/usr/sbin/newsyslog"));
assert!(body.contains("<string>-F</string>"));
assert!(body.contains("<string>-f</string>"));
assert!(body.contains(&conf.display().to_string()));
assert!(body.contains(ROTATION_LAUNCHD_LABEL));
assert!(body.contains("StartCalendarInterval"));
}
#[test]
fn rotation_keep_count_bounds_disk_footprint() {
assert_eq!(ROTATION_KEEP, 7);
assert_eq!(ROTATION_SIZE_KB, 1024);
}
}