backup_suite/core/
logging.rs

1//! ログファイル管理モジュール
2//!
3//! バックアップツールのログ機能を提供します。
4//!
5//! # 機能
6//!
7//! - 構造化ログ(TEXT/JSON形式)
8//! - ログローテーション(日次)
9//! - ログレベル制御(DEBUG/INFO/WARN/ERROR)
10//! - 自動クリーンアップ(保持日数設定)
11//!
12//! # 使用例
13//!
14//! ```no_run
15//! use backup_suite::core::logging::{Logger, LogLevel, LogFormat};
16//!
17//! let logger = Logger::new(LogLevel::Info, LogFormat::Text)?;
18//! logger.info("バックアップ開始");
19//! logger.error("エラーが発生しました");
20//! # Ok::<(), anyhow::Error>(())
21//! ```
22
23use 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/// ログレベル
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
32pub enum LogLevel {
33    /// デバッグレベル(詳細な診断情報)
34    Debug,
35    /// 情報レベル(一般的な情報メッセージ)
36    Info,
37    /// 警告レベル(警告メッセージ)
38    Warn,
39    /// エラーレベル(エラーメッセージ)
40    Error,
41}
42
43impl LogLevel {
44    /// 文字列から変換
45    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    /// 文字列表現を取得
56    #[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/// ログフォーマット
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69pub enum LogFormat {
70    /// プレーンテキスト形式
71    Text,
72    /// JSON形式
73    Json,
74}
75
76impl LogFormat {
77    /// 文字列から変換
78    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/// ログエントリ
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct LogEntry {
90    /// タイムスタンプ
91    pub timestamp: DateTime<Utc>,
92    /// ログレベル
93    pub level: LogLevel,
94    /// メッセージ
95    pub message: String,
96    /// 追加メタデータ(オプション)
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub metadata: Option<serde_json::Value>,
99}
100
101impl LogEntry {
102    /// 新しいログエントリを作成
103    #[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    /// メタデータを追加
114    #[must_use]
115    pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
116        self.metadata = Some(metadata);
117        self
118    }
119
120    /// テキスト形式でフォーマット
121    #[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    /// JSON形式でフォーマット
138    pub fn format_json(&self) -> Result<String> {
139        serde_json::to_string(self).context("JSON変換エラー")
140    }
141}
142
143/// ロガー
144pub struct Logger {
145    /// ログレベル閾値
146    level: LogLevel,
147    /// ログフォーマット
148    format: LogFormat,
149    /// ログファイルパス
150    log_file: PathBuf,
151    /// ローテーション保持日数
152    rotation_days: u32,
153}
154
155impl Logger {
156    /// 新しいロガーを作成
157    ///
158    /// # 引数
159    ///
160    /// * `level` - ログレベル閾値
161    /// * `format` - ログフォーマット
162    ///
163    /// # 戻り値
164    ///
165    /// Loggerインスタンス
166    ///
167    /// # エラー
168    ///
169    /// - ログディレクトリの作成に失敗した場合
170    ///
171    /// # 使用例
172    ///
173    /// ```no_run
174    /// use backup_suite::core::logging::{Logger, LogLevel, LogFormat};
175    ///
176    /// let logger = Logger::new(LogLevel::Info, LogFormat::Text)?;
177    /// # Ok::<(), anyhow::Error>(())
178    /// ```
179    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    /// カスタム設定でロガーを作成
194    ///
195    /// # 引数
196    ///
197    /// * `level` - ログレベル閾値
198    /// * `format` - ログフォーマット
199    /// * `log_file` - ログファイルパス
200    /// * `rotation_days` - ローテーション保持日数
201    ///
202    /// # 戻り値
203    ///
204    /// Loggerインスタンス
205    ///
206    /// # エラー
207    ///
208    /// - ログディレクトリの作成に失敗した場合
209    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    /// ログディレクトリのパスを取得
228    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    /// ログエントリを書き込み
245    fn write_entry(&self, entry: &LogEntry) -> Result<()> {
246        // ログレベルフィルタリング
247        if entry.level < self.level {
248            return Ok(());
249        }
250
251        // ローテーションチェック
252        self.rotate_if_needed()?;
253
254        // ログファイルに書き込み
255        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    /// ログローテーションを実行(必要に応じて)
272    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        // 1日以上経過していたらローテーション
285        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            // 古いログファイルを削除
292            self.cleanup_old_logs()?;
293        }
294
295        Ok(())
296    }
297
298    /// 古いログファイルをクリーンアップ
299    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                // backup-YYYYMMDD.log 形式のファイルのみ処理
313                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    /// DEBUGレベルのログを出力
332    pub fn debug(&self, message: impl Into<String>) {
333        let _ = self.write_entry(&LogEntry::new(LogLevel::Debug, message));
334    }
335
336    /// INFOレベルのログを出力
337    pub fn info(&self, message: impl Into<String>) {
338        let _ = self.write_entry(&LogEntry::new(LogLevel::Info, message));
339    }
340
341    /// WARNレベルのログを出力
342    pub fn warn(&self, message: impl Into<String>) {
343        let _ = self.write_entry(&LogEntry::new(LogLevel::Warn, message));
344    }
345
346    /// ERRORレベルのログを出力
347    pub fn error(&self, message: impl Into<String>) {
348        let _ = self.write_entry(&LogEntry::new(LogLevel::Error, message));
349    }
350
351    /// メタデータ付きでログを出力
352    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    /// ログファイルパスを取得
363    #[must_use]
364    pub fn log_file_path(&self) -> &Path {
365        &self.log_file
366    }
367
368    /// ログレベルを取得
369    #[must_use]
370    pub fn level(&self) -> LogLevel {
371        self.level
372    }
373
374    /// ログフォーマットを取得
375    #[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("このログは出力されない"); // レベルがInfoなので
440
441        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}