1use std::path::{
2 Path,
3 PathBuf,
4};
5use std::time::{
6 Duration,
7 SystemTime,
8};
9
10use anyhow::{
11 Context,
12 Result,
13};
14use chrono::Utc;
15use tokio::fs;
16
17pub const DEFAULT_MAX_LOG_SIZE: u64 = 10 * 1024 * 1024;
18
19pub const DEFAULT_LOG_RETENTION_DAYS: u64 = 7;
20
21pub const HTTP_LOG_EXTENSION: &str = "http";
22
23const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
24
25#[derive(Debug, Clone)]
26pub struct LogConfig {
27 log_dir: PathBuf,
28 max_log_size: u64,
29 retention_days: u64,
30 auto_cleanup: bool,
31 file_extension: String,
32}
33
34impl LogConfig {
35 pub fn new(log_dir: PathBuf) -> Self {
36 Self {
37 log_dir,
38 max_log_size: DEFAULT_MAX_LOG_SIZE,
39 retention_days: DEFAULT_LOG_RETENTION_DAYS,
40 auto_cleanup: true,
41 file_extension: HTTP_LOG_EXTENSION.to_string(),
42 }
43 }
44
45 pub fn builder(log_dir: PathBuf) -> LogConfigBuilder {
46 LogConfigBuilder::new(log_dir)
47 }
48
49 pub fn default_log_directory() -> Result<PathBuf> {
50 kftray_commons::utils::config_dir::get_log_folder_path()
51 .map_err(anyhow::Error::msg)
52 .context("Failed to resolve the HTTP log directory")
53 }
54
55 pub fn log_dir(&self) -> &Path {
56 &self.log_dir
57 }
58
59 pub fn max_log_size(&self) -> u64 {
60 self.max_log_size
61 }
62
63 pub fn retention_days(&self) -> u64 {
64 self.retention_days
65 }
66
67 pub fn auto_cleanup(&self) -> bool {
68 self.auto_cleanup
69 }
70
71 pub async fn create_log_file_path(&self, config_id: i64, local_port: u16) -> Result<PathBuf> {
72 self.ensure_log_directory().await?;
73
74 let file_path = self.log_dir.join(format!(
75 "{}_{}.{}",
76 config_id, local_port, self.file_extension
77 ));
78
79 Ok(file_path)
80 }
81
82 pub async fn ensure_log_directory(&self) -> Result<()> {
83 fs::create_dir_all(&self.log_dir)
84 .await
85 .context("Failed to create log directory")
86 }
87
88 pub async fn rotated_log_path(&self, log_file_path: &Path) -> PathBuf {
89 let stem = Self::file_stem(log_file_path);
90 let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
91 let dir = log_file_path.parent().unwrap_or(&self.log_dir);
92
93 let mut rotated = dir.join(format!("{stem}_{timestamp}.{}", self.file_extension));
94 let mut suffix = 1u32;
95 while fs::try_exists(&rotated).await.unwrap_or(false) {
96 rotated = dir.join(format!(
97 "{stem}_{timestamp}_{suffix}.{}",
98 self.file_extension
99 ));
100 suffix += 1;
101 }
102 rotated
103 }
104
105 pub async fn remove_expired_rotated_logs(&self, log_file_path: &Path) -> Result<()> {
106 let Some(dir) = log_file_path.parent() else {
107 return Ok(());
108 };
109 let prefix = format!("{}_", Self::file_stem(log_file_path));
110 let suffix = format!(".{}", self.file_extension);
111 let max_age = Duration::from_secs(self.retention_days.saturating_mul(SECONDS_PER_DAY));
112 let now = SystemTime::now();
113
114 let mut entries = fs::read_dir(dir)
115 .await
116 .context("Failed to read log directory")?;
117 while let Some(entry) = entries
118 .next_entry()
119 .await
120 .context("Failed to read log directory entry")?
121 {
122 let name = entry.file_name();
123 let Some(name) = name.to_str() else {
124 continue;
125 };
126 let is_rotated = name
127 .strip_prefix(&prefix)
128 .and_then(|rest| rest.strip_suffix(&suffix))
129 .is_some_and(Self::is_rotation_stamp);
130 if !is_rotated {
131 continue;
132 }
133
134 let metadata = entry
135 .metadata()
136 .await
137 .context("Failed to read rotated log metadata")?;
138 let age = metadata
139 .modified()
140 .ok()
141 .and_then(|modified| now.duration_since(modified).ok());
142 if metadata.is_file() && age.is_some_and(|age| age > max_age) {
143 fs::remove_file(entry.path())
144 .await
145 .with_context(|| format!("Failed to remove {}", entry.path().display()))?;
146 }
147 }
148
149 Ok(())
150 }
151
152 fn file_stem(log_file_path: &Path) -> String {
153 log_file_path
154 .file_stem()
155 .map(|stem| stem.to_string_lossy().into_owned())
156 .unwrap_or_default()
157 }
158
159 fn is_rotation_stamp(stamp: &str) -> bool {
160 let is_digits =
161 |part: &str, len: usize| part.len() == len && part.bytes().all(|b| b.is_ascii_digit());
162 let mut parts = stamp.split('_');
163 let (Some(date), Some(time)) = (parts.next(), parts.next()) else {
164 return false;
165 };
166 let suffix_ok = match parts.next() {
167 None => true,
168 Some(suffix) => !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit()),
169 };
170 is_digits(date, 8) && is_digits(time, 6) && suffix_ok && parts.next().is_none()
171 }
172}
173
174#[derive(Debug)]
175pub struct LogConfigBuilder {
176 log_dir: PathBuf,
177 max_log_size: Option<u64>,
178 retention_days: Option<u64>,
179 auto_cleanup: Option<bool>,
180 file_extension: Option<String>,
181}
182
183impl LogConfigBuilder {
184 pub fn new(log_dir: PathBuf) -> Self {
185 Self {
186 log_dir,
187 max_log_size: None,
188 retention_days: None,
189 auto_cleanup: None,
190 file_extension: None,
191 }
192 }
193
194 pub fn max_log_size(mut self, max_log_size: u64) -> Self {
195 self.max_log_size = Some(max_log_size);
196 self
197 }
198
199 pub fn retention_days(mut self, retention_days: u64) -> Self {
200 self.retention_days = Some(retention_days);
201 self
202 }
203
204 pub fn auto_cleanup(mut self, auto_cleanup: bool) -> Self {
205 self.auto_cleanup = Some(auto_cleanup);
206 self
207 }
208
209 pub fn file_extension(mut self, extension: impl Into<String>) -> Self {
210 self.file_extension = Some(extension.into());
211 self
212 }
213
214 pub fn build(self) -> LogConfig {
215 LogConfig {
216 log_dir: self.log_dir,
217 max_log_size: self.max_log_size.unwrap_or(DEFAULT_MAX_LOG_SIZE),
218 retention_days: self.retention_days.unwrap_or(DEFAULT_LOG_RETENTION_DAYS),
219 auto_cleanup: self.auto_cleanup.unwrap_or(true),
220 file_extension: self
221 .file_extension
222 .unwrap_or_else(|| HTTP_LOG_EXTENSION.to_string()),
223 }
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use tempfile::TempDir;
230
231 use super::*;
232
233 #[tokio::test]
234 async fn test_create_log_file_path() {
235 let temp_dir = TempDir::new().unwrap();
236 let config = LogConfig::new(temp_dir.path().to_path_buf());
237
238 let log_path = config.create_log_file_path(123, 8080).await.unwrap();
239 assert!(log_path.ends_with("123_8080.http"));
240
241 assert!(temp_dir.path().exists());
242 }
243
244 #[test]
245 fn test_log_config_getters() {
246 let temp_dir = TempDir::new().unwrap();
247 let log_dir = temp_dir.path().to_path_buf();
248 let config = LogConfig {
249 log_dir: log_dir.clone(),
250 max_log_size: 500,
251 retention_days: 3,
252 auto_cleanup: true,
253 file_extension: "log".to_string(),
254 };
255
256 assert_eq!(config.log_dir(), log_dir.as_path());
257 assert_eq!(config.max_log_size(), 500);
258 assert_eq!(config.retention_days(), 3);
259 }
260
261 #[test]
262 fn test_log_config_builder_defaults() {
263 let temp_dir = TempDir::new().unwrap();
264 let log_dir = temp_dir.path().to_path_buf();
265
266 let config = LogConfig::builder(log_dir.clone()).build();
267
268 assert_eq!(config.log_dir(), log_dir.as_path());
269 assert_eq!(config.max_log_size(), DEFAULT_MAX_LOG_SIZE);
270 assert_eq!(config.retention_days(), DEFAULT_LOG_RETENTION_DAYS);
271 assert_eq!(config.file_extension, HTTP_LOG_EXTENSION);
272 }
273
274 #[test]
275 fn test_log_config_builder_custom() {
276 let temp_dir = TempDir::new().unwrap();
277 let log_dir = temp_dir.path().to_path_buf();
278
279 let builder = LogConfigBuilder::new(log_dir.clone());
280 let config = builder.file_extension("testlog").build();
281
282 assert_eq!(config.log_dir(), log_dir.as_path());
283 assert_eq!(config.max_log_size(), DEFAULT_MAX_LOG_SIZE);
284 assert_eq!(config.retention_days(), DEFAULT_LOG_RETENTION_DAYS);
285 assert_eq!(config.file_extension, "testlog");
286 }
287
288 #[tokio::test]
289 async fn rotated_log_path_never_reuses_an_existing_file() {
290 let temp_dir = TempDir::new().unwrap();
291 let config = LogConfig::new(temp_dir.path().to_path_buf());
292 let log_path = config.create_log_file_path(99, 1234).await.unwrap();
293
294 let first = config.rotated_log_path(&log_path).await;
295 std::fs::write(&first, "first").unwrap();
296 let second = config.rotated_log_path(&log_path).await;
297
298 let first_name = first.file_name().unwrap().to_str().unwrap();
299 assert!(first_name.starts_with("99_1234_"), "{first_name}");
300 assert!(first_name.ends_with(".http"), "{first_name}");
301 assert_eq!(first.parent(), log_path.parent());
302 assert_ne!(second, first);
303 assert!(!second.exists());
304 }
305
306 #[tokio::test]
307 async fn expired_cleanup_removes_only_old_rotated_files_of_this_forward() {
308 let temp_dir = TempDir::new().unwrap();
309 let config = LogConfig::builder(temp_dir.path().to_path_buf())
310 .retention_days(2)
311 .build();
312 let log_path = config.create_log_file_path(7, 8080).await.unwrap();
313 let old = SystemTime::now() - Duration::from_secs(3 * SECONDS_PER_DAY);
314
315 let touch = |name: &str, modified: SystemTime| {
316 let path = temp_dir.path().join(name);
317 let file = std::fs::File::create(&path).unwrap();
318 file.set_modified(modified).unwrap();
319 path
320 };
321 let active = touch("7_8080.http", old);
322 let old_rotated = touch("7_8080_20260101_000000.http", old);
323 let old_rotated_suffix = touch("7_8080_20260101_000000_2.http", old);
324 let new_rotated = touch("7_8080_20260109_000000.http", SystemTime::now());
325 let other_forward = touch("7_80801_20260101_000000.http", old);
326 let other_extension = touch("7_8080_20260101_000000.txt", old);
327 let user_file = touch("7_8080_backup.http", old);
328
329 config.remove_expired_rotated_logs(&log_path).await.unwrap();
330
331 assert!(!old_rotated.exists());
332 assert!(!old_rotated_suffix.exists());
333 assert!(active.exists());
334 assert!(new_rotated.exists());
335 assert!(other_forward.exists());
336 assert!(other_extension.exists());
337 assert!(user_file.exists());
338 }
339
340 #[tokio::test]
341 async fn test_ensure_log_directory() {
342 let temp_dir = TempDir::new().unwrap();
343 let log_subdir = temp_dir.path().join("test_logs");
344
345 assert!(!log_subdir.exists());
346
347 let config = LogConfig::new(log_subdir.clone());
348 config.ensure_log_directory().await.unwrap();
349
350 assert!(log_subdir.exists());
351 assert!(log_subdir.is_dir());
352 }
353
354 #[test]
355 fn test_default_log_directory_uses_kftray_config() {
356 let temp_dir = TempDir::new().unwrap();
357 let _guard = kftray_commons::test_utils::EnvVarGuard::set(
358 "KFTRAY_CONFIG",
359 temp_dir.path().to_str().unwrap(),
360 );
361
362 let log_dir =
363 LogConfig::default_log_directory().expect("default_log_directory should resolve");
364 assert_eq!(log_dir, temp_dir.path().join("http_logs"));
365 }
366}