codex-switch 0.1.21

Multi-account runtime switcher for Codex
use std::env::VarError;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};

use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::fmt;
use tracing_subscriber::fmt::MakeWriter;
use tracing_subscriber::prelude::*;
use uuid::Uuid;

use crate::store;

const RUNTIME_LOG_ENV: &str = "CODEX_SWITCH_LOG";
const RUN_LOG_CREATE_ATTEMPTS: usize = 16;
const MAX_LOG_COMPONENT_LEN: usize = 64;

pub(crate) struct RuntimeLogGuard {
    stderr_enabled: Arc<AtomicBool>,
    log_path: PathBuf,
    _file_guard: WorkerGuard,
}

impl RuntimeLogGuard {
    pub(crate) fn path(&self) -> &Path {
        &self.log_path
    }

    pub(crate) fn disable_stderr(&self) {
        self.stderr_enabled.store(false, Ordering::Release);
    }

    pub(crate) fn enable_stderr(&self) {
        self.stderr_enabled.store(true, Ordering::Release);
    }
}

pub(crate) fn init_runtime_tracing() -> Result<RuntimeLogGuard> {
    let (log_file, log_path) = create_run_log_file()?;
    let (non_blocking, file_guard) = tracing_appender::non_blocking(log_file);
    let stderr_enabled = Arc::new(AtomicBool::new(true));
    let filter_spec = runtime_tracing_filter_spec(std::env::var(RUNTIME_LOG_ENV));
    let file_filter = runtime_tracing_filter(&filter_spec);
    let stderr_filter = runtime_tracing_filter(&filter_spec);

    let file_layer = fmt::layer()
        .with_writer(non_blocking)
        .with_ansi(false)
        .with_filter(file_filter);
    let stderr_layer = fmt::layer()
        .with_writer(ConditionalStderr::new(stderr_enabled.clone()))
        .with_ansi(false)
        .with_filter(stderr_filter);

    tracing_subscriber::registry()
        .with(file_layer)
        .with(stderr_layer)
        .try_init()
        .context("Failed to initialize runtime logging")?;

    Ok(RuntimeLogGuard {
        stderr_enabled,
        log_path,
        _file_guard: file_guard,
    })
}

fn runtime_tracing_filter(filter_spec: &str) -> EnvFilter {
    EnvFilter::try_new(filter_spec).unwrap_or_else(|_| EnvFilter::new("codex_switch=info"))
}

pub(crate) fn runtime_tracing_filter_spec(value: Result<String, VarError>) -> String {
    let Ok(value) = value else {
        return "codex_switch=info".to_string();
    };
    let value = value.trim();
    if value.is_empty() {
        return "codex_switch=info".to_string();
    }

    if is_plain_tracing_level(value) {
        return format!("codex_switch={}", value.to_ascii_lowercase());
    }

    value.to_string()
}

fn is_plain_tracing_level(value: &str) -> bool {
    matches!(
        value.to_ascii_lowercase().as_str(),
        "trace" | "debug" | "info" | "warn" | "error" | "off"
    )
}

fn create_run_log_file() -> Result<(File, PathBuf)> {
    let log_dir = runtime_log_dir()?;
    create_private_log_dir(&log_dir)?;

    for _ in 0..RUN_LOG_CREATE_ATTEMPTS {
        let path = log_dir.join(run_log_file_name(
            Utc::now(),
            &host_component(),
            std::process::id(),
            Uuid::new_v4(),
        ));
        match create_private_log_file(&path) {
            Ok(file) => return Ok((file, path)),
            Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
            Err(err) => {
                return Err(err).with_context(|| {
                    format!("Failed to create runtime log file: {}", path.display())
                });
            }
        }
    }

    anyhow::bail!(
        "Failed to create a unique runtime log file in {}",
        log_dir.display()
    )
}

fn runtime_log_dir() -> Result<PathBuf> {
    Ok(store::config_dir()?.join("logs"))
}

fn create_private_log_dir(path: &Path) -> Result<()> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::DirBuilderExt;

        let mut builder = fs::DirBuilder::new();
        builder.recursive(true).mode(0o700);
        builder.create(path).with_context(|| {
            format!("Failed to create runtime log directory: {}", path.display())
        })?;
        set_private_dir_permissions(path)
    }

    #[cfg(not(unix))]
    {
        fs::create_dir_all(path)
            .with_context(|| format!("Failed to create runtime log directory: {}", path.display()))
    }
}

fn create_private_log_file(path: &Path) -> io::Result<File> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;

        OpenOptions::new()
            .append(true)
            .create_new(true)
            .mode(0o600)
            .open(path)
    }

    #[cfg(not(unix))]
    {
        OpenOptions::new().append(true).create_new(true).open(path)
    }
}

#[cfg(unix)]
fn set_private_dir_permissions(path: &Path) -> Result<()> {
    use std::os::unix::fs::PermissionsExt;

    fs::set_permissions(path, fs::Permissions::from_mode(0o700)).with_context(|| {
        format!(
            "Failed to set runtime log directory permissions: {}",
            path.display()
        )
    })
}

#[cfg(not(unix))]
fn set_private_dir_permissions(_path: &Path) -> Result<()> {
    Ok(())
}

fn run_log_file_name(now: DateTime<Utc>, host: &str, pid: u32, unique: Uuid) -> String {
    format!(
        "codex-switch-run-{}-{}-{}-{}.log",
        now.format("%Y%m%d-%H%M%S"),
        sanitize_log_component(host),
        pid,
        unique.simple()
    )
}

fn host_component() -> String {
    std::env::var("HOSTNAME")
        .ok()
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
        .or_else(|| {
            fs::read_to_string("/etc/hostname")
                .ok()
                .map(|value| value.trim().to_string())
                .filter(|value| !value.is_empty())
        })
        .unwrap_or_else(|| "unknown-host".to_string())
}

fn sanitize_log_component(value: &str) -> String {
    let mut sanitized = String::new();
    for ch in value.chars() {
        if sanitized.len() >= MAX_LOG_COMPONENT_LEN {
            break;
        }
        if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
            sanitized.push(ch);
        } else if !sanitized.ends_with('-') {
            sanitized.push('-');
        }
    }

    let sanitized = sanitized.trim_matches('-');
    if sanitized.is_empty() {
        "unknown".to_string()
    } else {
        sanitized.to_string()
    }
}

#[derive(Clone)]
struct ConditionalStderr {
    enabled: Arc<AtomicBool>,
}

impl ConditionalStderr {
    fn new(enabled: Arc<AtomicBool>) -> Self {
        Self { enabled }
    }
}

impl<'writer> MakeWriter<'writer> for ConditionalStderr {
    type Writer = ConditionalStderrWriter;

    fn make_writer(&'writer self) -> Self::Writer {
        ConditionalStderrWriter {
            enabled: self.enabled.clone(),
            stderr: io::stderr(),
        }
    }
}

struct ConditionalStderrWriter {
    enabled: Arc<AtomicBool>,
    stderr: io::Stderr,
}

impl Write for ConditionalStderrWriter {
    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
        if self.enabled.load(Ordering::Acquire) {
            self.stderr.write(buf)
        } else {
            Ok(buf.len())
        }
    }

    fn flush(&mut self) -> io::Result<()> {
        if self.enabled.load(Ordering::Acquire) {
            self.stderr.flush()
        } else {
            Ok(())
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn runtime_log_component_sanitizes_path_separators_and_control_characters() {
        assert_eq!(sanitize_log_component("../host\nname"), "..-host-name");
    }

    #[test]
    fn runtime_log_component_falls_back_when_empty_after_sanitizing() {
        assert_eq!(sanitize_log_component("\n\t"), "unknown");
    }

    #[test]
    fn runtime_log_file_name_contains_host_pid_and_uuid() {
        let timestamp = DateTime::parse_from_rfc3339("2026-05-19T12:34:56Z")
            .expect("timestamp should parse")
            .with_timezone(&Utc);
        let uuid =
            Uuid::parse_str("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee").expect("uuid should parse");

        assert_eq!(
            run_log_file_name(timestamp, "dev/container", 123, uuid),
            "codex-switch-run-20260519-123456-dev-container-123-aaaaaaaabbbbccccddddeeeeeeeeeeee.log"
        );
    }

    #[cfg(unix)]
    #[test]
    fn private_log_file_is_create_new_and_user_only() {
        use std::os::unix::fs::PermissionsExt;

        let dir = std::env::temp_dir().join(format!("codex-switch-log-test-{}", Uuid::new_v4()));
        fs::create_dir(&dir).expect("test log dir should be created");
        let path = dir.join("run.log");
        let file = create_private_log_file(&path).expect("log file should be created");
        drop(file);

        let mode = fs::metadata(&path)
            .expect("log file metadata should be readable")
            .permissions()
            .mode()
            & 0o777;
        assert_eq!(mode, 0o600);
        assert_eq!(
            create_private_log_file(&path)
                .expect_err("existing log file should not be replaced")
                .kind(),
            io::ErrorKind::AlreadyExists
        );

        fs::remove_dir_all(&dir).expect("test log dir should be removed");
    }

    #[cfg(unix)]
    #[test]
    fn private_log_dir_is_user_only() {
        use std::os::unix::fs::PermissionsExt;

        let dir = std::env::temp_dir().join(format!("codex-switch-log-test-{}", Uuid::new_v4()));
        create_private_log_dir(&dir).expect("test log dir should be created");

        let mode = fs::metadata(&dir)
            .expect("log dir metadata should be readable")
            .permissions()
            .mode()
            & 0o777;
        assert_eq!(mode, 0o700);

        fs::remove_dir_all(&dir).expect("test log dir should be removed");
    }

    #[test]
    fn default_runtime_log_filter_is_codex_switch_info() {
        assert_eq!(
            runtime_tracing_filter_spec(Err(std::env::VarError::NotPresent)),
            "codex_switch=info"
        );
        assert_eq!(
            runtime_tracing_filter_spec(Ok(String::new())),
            "codex_switch=info"
        );
    }

    #[test]
    fn plain_runtime_log_level_maps_to_codex_switch_target() {
        assert_eq!(
            runtime_tracing_filter_spec(Ok("debug".to_string())),
            "codex_switch=debug"
        );
        assert_eq!(
            runtime_tracing_filter_spec(Ok("WARN".to_string())),
            "codex_switch=warn"
        );
    }

    #[test]
    fn full_runtime_log_filter_is_preserved() {
        assert_eq!(
            runtime_tracing_filter_spec(Ok("codex_switch=debug,tokio=warn".to_string())),
            "codex_switch=debug,tokio=warn"
        );
    }
}