1use crate::config::LoggingConfig;
2use crate::security::redact_secrets;
3use anyhow::{Context, Result};
4use std::fs::{self, OpenOptions};
5use std::path::{Path, PathBuf};
6use tracing_subscriber::layer::SubscriberExt;
7use tracing_subscriber::{EnvFilter, Layer, Registry};
8
9pub struct LoggingGuard {
11 path: Option<PathBuf>,
12 _guard: Option<tracing_appender::non_blocking::WorkerGuard>,
13}
14
15#[derive(Debug, Clone)]
17pub struct LoggingRuntimeConfig {
18 pub stdout_enabled: bool,
20 pub file_enabled: bool,
22 pub level: Option<String>,
24 pub include_payloads: bool,
26}
27
28impl LoggingGuard {
29 pub fn path(&self) -> Option<&Path> {
31 self.path.as_deref()
32 }
33}
34
35impl Default for LoggingRuntimeConfig {
36 fn default() -> Self {
37 Self {
38 stdout_enabled: false,
39 file_enabled: true,
40 level: None,
41 include_payloads: false,
42 }
43 }
44}
45
46pub fn log_dir(data_dir: &Path) -> PathBuf {
48 data_dir.join("logs")
49}
50
51pub fn log_path(data_dir: &Path) -> PathBuf {
53 log_dir(data_dir).join("navi.log")
54}
55
56pub fn init_logging(
59 config: &LoggingConfig,
60 data_dir: &Path,
61 runtime: LoggingRuntimeConfig,
62) -> Result<LoggingGuard> {
63 if !config.enabled {
64 return Ok(LoggingGuard {
65 path: None,
66 _guard: None,
67 });
68 }
69
70 let level = runtime.level.unwrap_or_else(|| config.level.clone());
71 let filter =
72 EnvFilter::try_new(level.clone()).unwrap_or_else(|_| EnvFilter::new("navi=info,info"));
73 let mut layers: Vec<Box<dyn Layer<Registry> + Send + Sync>> = Vec::new();
74 let mut guard = None;
75 let mut path = None;
76
77 if config.file_enabled && runtime.file_enabled {
78 let dir = log_dir(data_dir);
79 fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
80 crate::fs_util::set_private_dir_permissions(&dir)?;
81 cleanup_old_logs(&dir, config.max_files)?;
82
83 let path_for_writer = log_path(data_dir);
84 let file = OpenOptions::new()
85 .create(true)
86 .append(true)
87 .open(&path_for_writer)
88 .with_context(|| format!("failed to open {}", path_for_writer.display()))?;
89 crate::fs_util::set_private_file_permissions(&path_for_writer)?;
90 let (writer, writer_guard) = tracing_appender::non_blocking(file);
91 layers.push(
92 tracing_subscriber::fmt::layer()
93 .with_ansi(false)
94 .with_target(true)
95 .with_writer(writer)
96 .boxed(),
97 );
98 guard = Some(writer_guard);
99 path = Some(path_for_writer);
100 }
101
102 if config.stdout_enabled || runtime.stdout_enabled {
103 layers.push(
104 tracing_subscriber::fmt::layer()
105 .with_ansi(true)
106 .with_target(true)
107 .boxed(),
108 );
109 }
110
111 if layers.is_empty() {
112 return Ok(LoggingGuard {
113 path: None,
114 _guard: None,
115 });
116 }
117
118 let subscriber = Registry::default().with(layers).with(filter);
119 let _ = tracing::subscriber::set_global_default(subscriber);
120
121 tracing::info!(
122 log_path = path.as_ref().map(|p| p.display().to_string()),
123 level,
124 include_payloads = config.include_payloads || runtime.include_payloads,
125 "logging initialized"
126 );
127
128 Ok(LoggingGuard {
129 path,
130 _guard: guard,
131 })
132}
133
134pub fn redact_log_value(value: impl AsRef<str>) -> String {
136 redact_secrets(value.as_ref())
137}
138
139fn cleanup_old_logs(dir: &Path, max_files: usize) -> Result<()> {
140 if max_files == 0 {
141 return Ok(());
142 }
143 let mut logs = Vec::new();
144 for entry in fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))? {
145 let entry = entry?;
146 let path = entry.path();
147 if path.file_name().and_then(|name| name.to_str()) != Some("navi.log") {
148 continue;
149 }
150 let modified = entry.metadata()?.modified()?;
151 logs.push((modified, path));
152 }
153 logs.sort_by_key(|b| std::cmp::Reverse(b.0));
154 for (_, path) in logs.into_iter().skip(max_files) {
155 let _ = fs::remove_file(path);
156 }
157 Ok(())
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn log_path_lives_under_data_dir() {
166 let path = log_path(Path::new("/tmp/navi-data"));
167 assert_eq!(path, PathBuf::from("/tmp/navi-data/logs/navi.log"));
168 }
169
170 #[test]
171 fn redacts_secret_values_for_logs() {
172 assert_eq!(
173 redact_log_value("OPENAI_API_KEY=sk-proj-1234567890abcdef"),
174 "OPENAI_API_KEY=<redacted>"
175 );
176 }
177
178 #[cfg(unix)]
179 #[test]
180 fn init_logging_creates_private_log_file() {
181 use std::os::unix::fs::PermissionsExt;
182
183 let tempdir = tempfile::tempdir().expect("tempdir");
184 let config = LoggingConfig::default();
185 let guard = init_logging(
186 &config,
187 tempdir.path(),
188 LoggingRuntimeConfig {
189 stdout_enabled: false,
190 file_enabled: true,
191 level: Some("info".to_string()),
192 include_payloads: false,
193 },
194 )
195 .expect("init logging");
196 drop(guard);
197
198 let dir_mode = fs::metadata(log_dir(tempdir.path()))
199 .expect("dir metadata")
200 .permissions()
201 .mode()
202 & 0o777;
203 let file_mode = fs::metadata(log_path(tempdir.path()))
204 .expect("file metadata")
205 .permissions()
206 .mode()
207 & 0o777;
208
209 assert_eq!(dir_mode, 0o700);
210 assert_eq!(file_mode, 0o600);
211 }
212}