1use anyhow::{Context, Result};
24use chrono::{DateTime, Utc};
25use serde::{Deserialize, Serialize};
26use std::fs::{self, OpenOptions};
27use std::io::Write;
28use std::path::{Path, PathBuf};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
32pub enum LogLevel {
33 Debug,
35 Info,
37 Warn,
39 Error,
41}
42
43impl LogLevel {
44 pub fn parse(s: &str) -> Result<Self> {
46 match s.to_lowercase().as_str() {
47 "debug" => Ok(LogLevel::Debug),
48 "info" => Ok(LogLevel::Info),
49 "warn" | "warning" => Ok(LogLevel::Warn),
50 "error" => Ok(LogLevel::Error),
51 _ => Err(anyhow::anyhow!("不明なログレベル: {s}")),
52 }
53 }
54
55 #[must_use]
57 pub fn as_str(&self) -> &'static str {
58 match self {
59 LogLevel::Debug => "DEBUG",
60 LogLevel::Info => "INFO",
61 LogLevel::Warn => "WARN",
62 LogLevel::Error => "ERROR",
63 }
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69pub enum LogFormat {
70 Text,
72 Json,
74}
75
76impl LogFormat {
77 pub fn parse(s: &str) -> Result<Self> {
79 match s.to_lowercase().as_str() {
80 "text" | "plain" => Ok(LogFormat::Text),
81 "json" => Ok(LogFormat::Json),
82 _ => Err(anyhow::anyhow!("不明なログフォーマット: {s}")),
83 }
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct LogEntry {
90 pub timestamp: DateTime<Utc>,
92 pub level: LogLevel,
94 pub message: String,
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub metadata: Option<serde_json::Value>,
99}
100
101impl LogEntry {
102 #[must_use]
104 pub fn new(level: LogLevel, message: impl Into<String>) -> Self {
105 Self {
106 timestamp: Utc::now(),
107 level,
108 message: message.into(),
109 metadata: None,
110 }
111 }
112
113 #[must_use]
115 pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
116 self.metadata = Some(metadata);
117 self
118 }
119
120 #[must_use]
122 pub fn format_text(&self) -> String {
123 let timestamp = self.timestamp.format("%Y-%m-%d %H:%M:%S%.3f");
124 if let Some(ref meta) = self.metadata {
125 format!(
126 "[{}] {} {} | {}",
127 timestamp,
128 self.level.as_str(),
129 self.message,
130 serde_json::to_string(meta).unwrap_or_default()
131 )
132 } else {
133 format!("[{}] {} {}", timestamp, self.level.as_str(), self.message)
134 }
135 }
136
137 pub fn format_json(&self) -> Result<String> {
139 serde_json::to_string(self).context("JSON変換エラー")
140 }
141}
142
143pub struct Logger {
145 level: LogLevel,
147 format: LogFormat,
149 log_file: PathBuf,
151 rotation_days: u32,
153}
154
155impl Logger {
156 pub fn new(level: LogLevel, format: LogFormat) -> Result<Self> {
180 let log_dir = Self::log_dir()?;
181 fs::create_dir_all(&log_dir).context("ログディレクトリ作成エラー")?;
182
183 let log_file = log_dir.join("backup.log");
184
185 Ok(Self {
186 level,
187 format,
188 log_file,
189 rotation_days: 7,
190 })
191 }
192
193 pub fn with_config(
210 level: LogLevel,
211 format: LogFormat,
212 log_file: PathBuf,
213 rotation_days: u32,
214 ) -> Result<Self> {
215 if let Some(parent) = log_file.parent() {
216 fs::create_dir_all(parent).context("ログディレクトリ作成エラー")?;
217 }
218
219 Ok(Self {
220 level,
221 format,
222 log_file,
223 rotation_days,
224 })
225 }
226
227 fn log_dir() -> Result<PathBuf> {
229 #[cfg(target_os = "macos")]
230 {
231 let home = dirs::home_dir()
232 .ok_or_else(|| anyhow::anyhow!("ホームディレクトリが見つかりません"))?;
233 Ok(home.join("Library/Logs/backup-suite"))
234 }
235
236 #[cfg(not(target_os = "macos"))]
237 {
238 let data_dir = dirs::data_local_dir()
239 .ok_or_else(|| anyhow::anyhow!("データディレクトリが見つかりません"))?;
240 Ok(data_dir.join("backup-suite").join("logs"))
241 }
242 }
243
244 fn write_entry(&self, entry: &LogEntry) -> Result<()> {
246 if entry.level < self.level {
248 return Ok(());
249 }
250
251 self.rotate_if_needed()?;
253
254 let mut file = OpenOptions::new()
256 .create(true)
257 .append(true)
258 .open(&self.log_file)
259 .context("ログファイルオープンエラー")?;
260
261 let line = match self.format {
262 LogFormat::Text => entry.format_text(),
263 LogFormat::Json => entry.format_json()?,
264 };
265
266 writeln!(file, "{line}").context("ログ書き込みエラー")?;
267
268 Ok(())
269 }
270
271 fn rotate_if_needed(&self) -> Result<()> {
273 if !self.log_file.exists() {
274 return Ok(());
275 }
276
277 let metadata = fs::metadata(&self.log_file).context("ログファイルメタデータ取得エラー")?;
278 let modified = metadata.modified().context("最終更新日時取得エラー")?;
279
280 let modified_datetime: DateTime<Utc> = modified.into();
281 let now = Utc::now();
282 let days_old = (now - modified_datetime).num_days();
283
284 if days_old >= 1 {
286 let rotated_name = format!("backup-{}.log", modified_datetime.format("%Y%m%d"));
287 let rotated_path = self.log_file.parent().unwrap().join(rotated_name);
288
289 fs::rename(&self.log_file, &rotated_path).context("ログローテーションエラー")?;
290
291 self.cleanup_old_logs()?;
293 }
294
295 Ok(())
296 }
297
298 fn cleanup_old_logs(&self) -> Result<()> {
300 let log_dir = self.log_file.parent().unwrap();
301 let cutoff_date = Utc::now() - chrono::Duration::days(self.rotation_days as i64);
302
303 for entry in fs::read_dir(log_dir)? {
304 let entry = entry?;
305 let path = entry.path();
306
307 if !path.is_file() {
308 continue;
309 }
310
311 if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
312 if !file_name.starts_with("backup-") || !file_name.ends_with(".log") {
314 continue;
315 }
316
317 let metadata = fs::metadata(&path)?;
318 let modified = metadata.modified()?;
319 let modified_datetime: DateTime<Utc> = modified.into();
320
321 if modified_datetime < cutoff_date {
322 fs::remove_file(&path)
323 .context("古いログファイル削除エラー: path.display()".to_string())?;
324 }
325 }
326 }
327
328 Ok(())
329 }
330
331 pub fn debug(&self, message: impl Into<String>) {
333 let _ = self.write_entry(&LogEntry::new(LogLevel::Debug, message));
334 }
335
336 pub fn info(&self, message: impl Into<String>) {
338 let _ = self.write_entry(&LogEntry::new(LogLevel::Info, message));
339 }
340
341 pub fn warn(&self, message: impl Into<String>) {
343 let _ = self.write_entry(&LogEntry::new(LogLevel::Warn, message));
344 }
345
346 pub fn error(&self, message: impl Into<String>) {
348 let _ = self.write_entry(&LogEntry::new(LogLevel::Error, message));
349 }
350
351 pub fn log_with_metadata(
353 &self,
354 level: LogLevel,
355 message: impl Into<String>,
356 metadata: serde_json::Value,
357 ) {
358 let entry = LogEntry::new(level, message).with_metadata(metadata);
359 let _ = self.write_entry(&entry);
360 }
361
362 #[must_use]
364 pub fn log_file_path(&self) -> &Path {
365 &self.log_file
366 }
367
368 #[must_use]
370 pub fn level(&self) -> LogLevel {
371 self.level
372 }
373
374 #[must_use]
376 pub fn format(&self) -> LogFormat {
377 self.format
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use tempfile::TempDir;
385
386 #[test]
387 fn test_log_level_from_str() {
388 assert_eq!(LogLevel::parse("debug").unwrap(), LogLevel::Debug);
389 assert_eq!(LogLevel::parse("INFO").unwrap(), LogLevel::Info);
390 assert_eq!(LogLevel::parse("warn").unwrap(), LogLevel::Warn);
391 assert_eq!(LogLevel::parse("ERROR").unwrap(), LogLevel::Error);
392 assert!(LogLevel::parse("invalid").is_err());
393 }
394
395 #[test]
396 fn test_log_format_from_str() {
397 assert_eq!(LogFormat::parse("text").unwrap(), LogFormat::Text);
398 assert_eq!(LogFormat::parse("json").unwrap(), LogFormat::Json);
399 assert!(LogFormat::parse("invalid").is_err());
400 }
401
402 #[test]
403 fn test_log_entry_format_text() {
404 let entry = LogEntry::new(LogLevel::Info, "テストメッセージ");
405 let formatted = entry.format_text();
406 assert!(formatted.contains("INFO"));
407 assert!(formatted.contains("テストメッセージ"));
408 }
409
410 #[test]
411 fn test_log_entry_format_json() {
412 let entry = LogEntry::new(LogLevel::Warn, "警告メッセージ");
413 let formatted = entry.format_json().unwrap();
414 assert!(formatted.contains("\"level\":\"Warn\""));
415 assert!(formatted.contains("警告メッセージ"));
416 }
417
418 #[test]
419 fn test_logger_creation() {
420 let temp_dir = TempDir::new().unwrap();
421 let log_file = temp_dir.path().join("test.log");
422
423 let logger =
424 Logger::with_config(LogLevel::Info, LogFormat::Text, log_file.clone(), 7).unwrap();
425
426 assert_eq!(logger.level(), LogLevel::Info);
427 assert_eq!(logger.format(), LogFormat::Text);
428 }
429
430 #[test]
431 fn test_logger_write() {
432 let temp_dir = TempDir::new().unwrap();
433 let log_file = temp_dir.path().join("test.log");
434
435 let logger =
436 Logger::with_config(LogLevel::Info, LogFormat::Text, log_file.clone(), 7).unwrap();
437
438 logger.info("テストログ");
439 logger.debug("このログは出力されない"); let content = fs::read_to_string(&log_file).unwrap();
442 assert!(content.contains("テストログ"));
443 assert!(!content.contains("このログは出力されない"));
444 }
445
446 #[test]
447 fn test_logger_json_format() {
448 let temp_dir = TempDir::new().unwrap();
449 let log_file = temp_dir.path().join("test.log");
450
451 let logger =
452 Logger::with_config(LogLevel::Debug, LogFormat::Json, log_file.clone(), 7).unwrap();
453
454 logger.error("JSONエラーログ");
455
456 let content = fs::read_to_string(&log_file).unwrap();
457 assert!(content.contains("\"level\":\"Error\""));
458 assert!(content.contains("JSONエラーログ"));
459 }
460
461 #[test]
462 fn test_logger_with_metadata() {
463 let temp_dir = TempDir::new().unwrap();
464 let log_file = temp_dir.path().join("test.log");
465
466 let logger =
467 Logger::with_config(LogLevel::Info, LogFormat::Text, log_file.clone(), 7).unwrap();
468
469 let metadata = serde_json::json!({
470 "file_count": 42,
471 "bytes": 1_024_000
472 });
473
474 logger.log_with_metadata(LogLevel::Info, "バックアップ完了", metadata);
475
476 let content = fs::read_to_string(&log_file).unwrap();
477 assert!(content.contains("バックアップ完了"));
478 assert!(content.contains("file_count"));
479 }
480}