greentic_operator/
operator_log.rs1use std::{
2 fs::{File, OpenOptions},
3 io,
4 io::Write,
5 path::{Path, PathBuf},
6 sync::Mutex,
7};
8
9use anyhow::Context;
10use chrono::Utc;
11use std::sync::OnceLock;
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
14pub enum Level {
15 Trace,
16 Debug,
17 Info,
18 Warn,
19 Error,
20}
21
22struct Logger {
23 writer: Mutex<File>,
24 min_level: Level,
25}
26
27static LOGGER: OnceLock<Logger> = OnceLock::new();
28
29pub fn init(log_dir: PathBuf, min_level: Level) -> anyhow::Result<PathBuf> {
30 let fallback = std::env::current_dir()
31 .unwrap_or_else(|_| PathBuf::from("."))
32 .join("logs");
33
34 let mut candidates = vec![log_dir.clone()];
35 if fallback != log_dir {
36 candidates.push(fallback.clone());
37 }
38
39 let mut last_error: Option<(PathBuf, io::Error)> = None;
40 for candidate in candidates {
41 match try_open_operator_log(&candidate) {
42 Ok(file) => {
43 let logger = Logger {
44 writer: Mutex::new(file),
45 min_level,
46 };
47 if LOGGER.set(logger).is_err() {
48 anyhow::bail!("operator logger already initialized");
49 }
50 if candidate != log_dir {
51 eprintln!(
52 "unable to write operator.log at {}; falling back to {}",
53 log_dir.display(),
54 candidate.display()
55 );
56 }
57 return Ok(candidate);
58 }
59 Err(err) => {
60 last_error = Some((candidate, err));
61 }
62 }
63 }
64
65 if let Some((path, err)) = last_error {
66 Err(anyhow::anyhow!(
67 "unable to open operator log at {}: {}",
68 path.display(),
69 err
70 ))
71 } else {
72 anyhow::bail!("unable to initialize operator log")
73 }
74}
75
76fn try_open_operator_log(log_dir: &Path) -> io::Result<File> {
77 std::fs::create_dir_all(log_dir)?;
78 let operator_path = log_dir.join("operator.log");
79 OpenOptions::new()
80 .create(true)
81 .append(true)
82 .open(&operator_path)
83}
84
85pub fn log(level: Level, target: &str, message: String) {
86 let logger = match LOGGER.get() {
87 Some(logger) => logger,
88 None => return,
89 };
90 if level < logger.min_level {
91 return;
92 }
93 let mut writer = match logger.writer.lock() {
94 Ok(writer) => writer,
95 Err(_) => return,
96 };
97 let timestamp = Utc::now().to_rfc3339();
98 if writeln!(
99 *writer,
100 "{timestamp} [{level:?}] {target} - {message}",
101 level = level,
102 target = target,
103 message = message
104 )
105 .is_err()
106 {
107 let _ = writer.flush();
108 }
109}
110
111pub fn service_log_path(log_dir: &Path, service: &str) -> PathBuf {
112 log_dir.join(format!("{service}.log"))
113}
114
115pub fn reserve_service_log(log_dir: &Path, service: &str) -> anyhow::Result<PathBuf> {
116 let path = service_log_path(log_dir, service);
117 if let Some(parent) = path.parent() {
118 std::fs::create_dir_all(parent)?;
119 }
120 OpenOptions::new()
121 .create(true)
122 .append(true)
123 .open(&path)
124 .with_context(|| format!("unable to open {} log file at {}", service, path.display()))?;
125 Ok(path)
126}
127
128pub fn trace(target: &str, message: impl AsRef<str>) {
129 log(Level::Trace, target, message.as_ref().to_string());
130}
131
132pub fn debug(target: &str, message: impl AsRef<str>) {
133 log(Level::Debug, target, message.as_ref().to_string());
134}
135
136pub fn info(target: &str, message: impl AsRef<str>) {
137 log(Level::Info, target, message.as_ref().to_string());
138}
139
140pub fn warn(target: &str, message: impl AsRef<str>) {
141 log(Level::Warn, target, message.as_ref().to_string());
142}
143
144pub fn error(target: &str, message: impl AsRef<str>) {
145 log(Level::Error, target, message.as_ref().to_string());
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use std::fs;
152 use tempfile::tempdir;
153
154 #[test]
155 fn writes_operator_log() -> anyhow::Result<()> {
156 let dir = tempdir()?;
157 let _ = init(dir.path().to_path_buf(), Level::Info)?;
158 info("tests::writes_operator_log", "hello world");
159 let contents = fs::read_to_string(dir.path().join("operator.log"))?;
160 assert!(contents.contains("hello world"));
161 Ok(())
162 }
163}