greentic-operator 0.4.43

Greentic operator CLI for local dev and demo orchestration.
Documentation
use std::{
    fs::{File, OpenOptions},
    io,
    io::Write,
    path::{Path, PathBuf},
    sync::Mutex,
};

use anyhow::Context;
use chrono::Utc;
use std::sync::OnceLock;

#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Level {
    Trace,
    Debug,
    Info,
    Warn,
    Error,
}

struct Logger {
    writer: Mutex<File>,
    min_level: Level,
}

static LOGGER: OnceLock<Logger> = OnceLock::new();

pub fn init(log_dir: PathBuf, min_level: Level) -> anyhow::Result<PathBuf> {
    let fallback = std::env::current_dir()
        .unwrap_or_else(|_| PathBuf::from("."))
        .join("logs");

    let mut candidates = vec![log_dir.clone()];
    if fallback != log_dir {
        candidates.push(fallback.clone());
    }

    let mut last_error: Option<(PathBuf, io::Error)> = None;
    for candidate in candidates {
        match try_open_operator_log(&candidate) {
            Ok(file) => {
                let logger = Logger {
                    writer: Mutex::new(file),
                    min_level,
                };
                if LOGGER.set(logger).is_err() {
                    anyhow::bail!("operator logger already initialized");
                }
                if candidate != log_dir {
                    eprintln!(
                        "unable to write operator.log at {}; falling back to {}",
                        log_dir.display(),
                        candidate.display()
                    );
                }
                return Ok(candidate);
            }
            Err(err) => {
                last_error = Some((candidate, err));
            }
        }
    }

    if let Some((path, err)) = last_error {
        Err(anyhow::anyhow!(
            "unable to open operator log at {}: {}",
            path.display(),
            err
        ))
    } else {
        anyhow::bail!("unable to initialize operator log")
    }
}

fn try_open_operator_log(log_dir: &Path) -> io::Result<File> {
    std::fs::create_dir_all(log_dir)?;
    let operator_path = log_dir.join("operator.log");
    OpenOptions::new()
        .create(true)
        .append(true)
        .open(&operator_path)
}

pub fn log(level: Level, target: &str, message: String) {
    let logger = match LOGGER.get() {
        Some(logger) => logger,
        None => return,
    };
    if level < logger.min_level {
        return;
    }
    let mut writer = match logger.writer.lock() {
        Ok(writer) => writer,
        Err(_) => return,
    };
    let timestamp = Utc::now().to_rfc3339();
    if writeln!(
        *writer,
        "{timestamp} [{level:?}] {target} - {message}",
        level = level,
        target = target,
        message = message
    )
    .is_err()
    {
        let _ = writer.flush();
    }
}

pub fn service_log_path(log_dir: &Path, service: &str) -> PathBuf {
    log_dir.join(format!("{service}.log"))
}

pub fn reserve_service_log(log_dir: &Path, service: &str) -> anyhow::Result<PathBuf> {
    let path = service_log_path(log_dir, service);
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    OpenOptions::new()
        .create(true)
        .append(true)
        .open(&path)
        .with_context(|| format!("unable to open {} log file at {}", service, path.display()))?;
    Ok(path)
}

pub fn trace(target: &str, message: impl AsRef<str>) {
    log(Level::Trace, target, message.as_ref().to_string());
}

pub fn debug(target: &str, message: impl AsRef<str>) {
    log(Level::Debug, target, message.as_ref().to_string());
}

pub fn info(target: &str, message: impl AsRef<str>) {
    log(Level::Info, target, message.as_ref().to_string());
}

pub fn warn(target: &str, message: impl AsRef<str>) {
    log(Level::Warn, target, message.as_ref().to_string());
}

pub fn error(target: &str, message: impl AsRef<str>) {
    log(Level::Error, target, message.as_ref().to_string());
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::tempdir;

    #[test]
    fn writes_operator_log() -> anyhow::Result<()> {
        let dir = tempdir()?;
        let _ = init(dir.path().to_path_buf(), Level::Info)?;
        info("tests::writes_operator_log", "hello world");
        let contents = fs::read_to_string(dir.path().join("operator.log"))?;
        assert!(contents.contains("hello world"));
        Ok(())
    }
}