tmux-workspace 0.1.0-alpha.16

Build tmux workspaces from tmuxp-style YAML using libtmux (alpha)
Documentation
use std::{fs::File, io::Write, path::Path};

use rustix::fs::{FileType, Mode, OFlags};
use serde_json::{Value, json};

use super::{CliError, Result};

pub(super) struct Logger {
    level: u8,
    file: Option<File>,
    failure: Option<String>,
    sequence: u64,
    #[cfg(unix_process_observer)]
    script_bytes: [usize; 2],
    #[cfg(unix_process_observer)]
    script_truncated: [bool; 2],
}

fn rank(level: &str) -> u8 {
    match level {
        "debug" => 0,
        "info" => 1,
        "error" => 3,
        "critical" => 4,
        _ => 2,
    }
}

fn metadata(value: &Value) -> Value {
    match value {
        Value::Object(fields) => fields
            .iter()
            .filter(|(name, _)| *name != "script_output")
            .map(|(name, value)| (name.clone(), metadata(value)))
            .collect(),
        Value::Array(values) => values.iter().map(metadata).collect(),
        _ => value.clone(),
    }
}

impl Logger {
    pub(super) fn new(level: &str) -> Self {
        Self {
            level: rank(level),
            file: None,
            failure: None,
            sequence: 0,
            #[cfg(unix_process_observer)]
            script_bytes: [0; 2],
            #[cfg(unix_process_observer)]
            script_truncated: [false; 2],
        }
    }

    pub(super) fn open(&mut self, path: &Path) -> Result<()> {
        let unavailable = |error: std::io::Error| CliError::new("log_file", error.to_string());
        match std::fs::symlink_metadata(path) {
            Ok(info) if info.file_type().is_file() => {}
            Ok(_) => {
                return Err(CliError::new(
                    "log_file",
                    "log destination must be a regular file",
                ));
            }
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
            Err(error) => return Err(unavailable(error)),
        }
        let file = rustix::fs::open(
            path,
            OFlags::WRONLY
                | OFlags::APPEND
                | OFlags::CREATE
                | OFlags::CLOEXEC
                | OFlags::NOFOLLOW
                | OFlags::NONBLOCK
                | OFlags::NOCTTY,
            Mode::RUSR | Mode::WUSR,
        )
        .map_err(|error| unavailable(error.into()))?;
        let stat = rustix::fs::fstat(&file).map_err(|error| unavailable(error.into()))?;
        if FileType::from_raw_mode(stat.st_mode) != FileType::RegularFile {
            return Err(CliError::new(
                "log_file",
                "opened log destination is not a regular file",
            ));
        }
        self.file = Some(file.into());
        Ok(())
    }

    fn enabled(&self, severity: &str) -> bool {
        self.file.is_some() && rank(severity) >= self.level
    }

    fn write(&mut self, command: &str, event: &str, severity: &str, data: Value) {
        let Some(file) = self.file.as_mut() else {
            return;
        };
        self.sequence += 1;
        let mut record = json!({"schema_version":1,"command":command,"event":event,"sequence":self.sequence,"severity":severity});
        record["data"] = data;
        let mut bytes = record.to_string().into_bytes();
        bytes.push(b'\n');
        if let Err(error) = file.write_all(&bytes) {
            self.failure = Some(error.to_string());
            self.file = None;
        }
    }

    pub(super) fn event(&mut self, command: &str, event: &str, data: &Value) {
        let severity = match event {
            "script-output" => return,
            "failed" => "error",
            "warning" => "warning",
            _ => "info",
        };
        if self.enabled(severity) {
            self.write(command, event, severity, metadata(data));
        }
    }

    pub(super) fn error(&mut self, command: &str, error: &CliError) {
        if self.enabled("error") {
            self.write(
                command,
                "diagnostic",
                "error",
                json!({"code":error.code,"message":error.message}),
            );
        }
    }

    #[cfg(unix_process_observer)]
    pub(super) fn begin_child(&mut self) {
        self.script_bytes = [0; 2];
        self.script_truncated = [false; 2];
    }

    #[cfg(unix_process_observer)]
    pub(super) fn chunk(&mut self, command: &str, stream: &str, text: &str) {
        let index = usize::from(stream == "stderr");
        if !self.enabled("debug") || self.script_truncated[index] || text.is_empty() {
            return;
        }
        let mut count = text
            .len()
            .min(super::process::CAPTURE_LIMIT - self.script_bytes[index]);
        while !text.is_char_boundary(count) {
            count -= 1;
        }
        self.script_bytes[index] += count;
        self.script_truncated[index] = count < text.len();
        self.write(command, "script-output", "debug", json!({"stream":stream,"text":&text[..count],"truncated":self.script_truncated[index],"encoding":"utf-8-with-replacement"}));
    }

    pub(super) fn warning(&mut self) -> Option<String> {
        let failure = self.failure.take()?;
        (self.level <= rank("warning")).then(|| format!("log file disabled: {failure}"))
    }
}