dm-database-sqllog2db 1.16.0

高性能 CLI 工具:流式解析达梦数据库 SQL 日志并导出到 CSV 或 SQLite
Documentation
// 整个模块仅在 binary crate (main.rs) 中使用;lib crate 生产代码不调用。
// 其 `#[cfg(test)]` 单元测试直接引用当前模块中的 items,编译不受影响。

use crate::config::Config;
use crate::parser::SqllogParser;
use std::path::Path;

/// 在 run 命令执行前检查基础条件。
/// 返回所有警告/错误,调用方决定是否中止。
#[must_use]
pub(crate) fn check(cfg: &Config) -> PreflightResult {
    let mut result = PreflightResult::default();
    for input in &cfg.sqllog.inputs {
        check_log_path(input, &mut result);
    }
    check_output_writable(cfg, &mut result);
    result
}

fn check_log_path(path_str: &str, result: &mut PreflightResult) {
    let has_glob = path_str.contains('*') || path_str.contains('?') || path_str.contains('[');

    // For non-glob paths, check existence before trying to scan
    if !has_glob {
        let path = Path::new(path_str);
        if !path.exists() {
            result.errors.push(format!(
                "日志路径不存在: {path_str}  (检查 [sqllog].inputs 或 --input 标志)"
            ));
            return;
        }
    }

    match SqllogParser::new(vec![path_str.to_string()]).log_files() {
        Ok(files) if files.is_empty() => {
            result
                .warnings
                .push(format!("路径 {path_str} 中未找到 .log 文件"));
        }
        Ok(_) => {}
        Err(e) => {
            result.errors.push(format!("扫描日志路径失败: {e}"));
        }
    }
}

fn check_output_writable(cfg: &Config, result: &mut PreflightResult) {
    if let Some(csv) = &cfg.exporter.csv {
        check_path_writable(&csv.file, result);
        return;
    }
    if let Some(sqlite) = &cfg.exporter.sqlite {
        check_path_writable(&sqlite.database_url, result);
    }
}

fn check_path_writable(file_path: &str, result: &mut PreflightResult) {
    let path = Path::new(file_path);

    // 若父目录不存在,先尝试创建;创建失败则直接报错,无需继续检查文件。
    if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
        if !parent.exists() {
            if std::fs::create_dir_all(parent).is_err() {
                result
                    .errors
                    .push(format!("无法创建输出目录: {}", parent.display()));
            }
            return;
        }
    }

    // 用单次 open(create + write)镜像导出器实际行为,消除 exists() → open() 的 TOCTOU 竞争。
    // truncate(false):preflight 仅验证可写性,不截断已有文件。
    if std::fs::OpenOptions::new()
        .create(true)
        .write(true)
        .truncate(false)
        .open(path)
        .is_err()
    {
        result.errors.push(format!("输出文件不可写: {file_path}"));
    }
}

#[derive(Debug, Default)]
pub(crate) struct PreflightResult {
    pub(crate) errors: Vec<String>,
    pub(crate) warnings: Vec<String>,
}

impl PreflightResult {
    #[must_use]
    pub(crate) fn has_errors(&self) -> bool {
        !self.errors.is_empty()
    }

    /// 打印所有警告和错误,返回是否有致命错误。
    #[must_use]
    pub(crate) fn print_and_check(&self) -> bool {
        for warn in &self.warnings {
            eprintln!("Warning: {warn}");
        }
        for err in &self.errors {
            eprintln!("Error: {err}");
        }
        self.has_errors()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::{Config, CsvExporterConfig, ExporterConfig, SqllogConfig};

    fn config_with_log_dir(dir: &str) -> Config {
        Config {
            sqllog: SqllogConfig {
                inputs: vec![dir.to_string()],
                path_deprecated: None,
            },
            ..Default::default()
        }
    }

    // ── PreflightResult ───────────────────────────────────────────

    #[test]
    fn test_preflight_result_no_errors() {
        let result = PreflightResult::default();
        assert!(!result.has_errors());
        assert!(!result.print_and_check());
    }

    #[test]
    fn test_preflight_result_with_errors() {
        let mut result = PreflightResult::default();
        result.errors.push("some error".to_string());
        assert!(result.has_errors());
        assert!(result.print_and_check());
    }

    #[test]
    fn test_preflight_result_warnings_no_error() {
        let mut result = PreflightResult::default();
        result.warnings.push("some warning".to_string());
        assert!(!result.has_errors());
        assert!(!result.print_and_check());
    }

    // ── check: log dir ────────────────────────────────────────────

    #[test]
    fn test_check_nonexistent_log_dir_produces_error() {
        let cfg = config_with_log_dir("/this/path/definitely/does/not/exist");
        let result = check(&cfg);
        assert!(result.has_errors());
        assert!(result.errors[0].contains("不存在"));
    }

    #[test]
    fn test_check_single_log_file_is_valid() {
        let dir = tempfile::TempDir::new().unwrap();
        let file_path = dir.path().join("test.log");
        std::fs::write(&file_path, "").unwrap();
        let cfg = config_with_log_dir(file_path.to_str().unwrap());
        let result = check(&cfg);
        assert!(!result.has_errors());
    }

    #[test]
    fn test_check_log_dir_empty_produces_warning() {
        let dir = tempfile::TempDir::new().unwrap();
        let cfg = config_with_log_dir(dir.path().to_str().unwrap());
        let result = check(&cfg);
        assert!(!result.has_errors());
        assert!(!result.warnings.is_empty());
    }

    #[test]
    fn test_check_log_dir_with_log_files_no_warning() {
        let dir = tempfile::TempDir::new().unwrap();
        std::fs::write(dir.path().join("test.log"), "").unwrap();
        let cfg = config_with_log_dir(dir.path().to_str().unwrap());
        let result = check(&cfg);
        assert!(!result.has_errors());
        assert!(result.warnings.is_empty());
    }

    #[test]
    fn test_check_glob_pattern_with_matches() {
        let dir = tempfile::TempDir::new().unwrap();
        std::fs::write(dir.path().join("a.log"), "").unwrap();
        let pattern = format!("{}/*.log", dir.path().display());
        let cfg = config_with_log_dir(&pattern);
        let result = check(&cfg);
        assert!(!result.has_errors());
        assert!(result.warnings.is_empty());
    }

    #[test]
    fn test_check_glob_pattern_no_matches_produces_warning() {
        let dir = tempfile::TempDir::new().unwrap();
        let pattern = format!("{}/nomatch*.log", dir.path().display());
        let cfg = config_with_log_dir(&pattern);
        let result = check(&cfg);
        assert!(!result.has_errors());
        assert!(!result.warnings.is_empty());
    }

    // ── check: output writable ────────────────────────────────────

    #[test]
    fn test_check_csv_output_in_existing_dir() {
        let dir = tempfile::TempDir::new().unwrap();
        std::fs::write(dir.path().join("test.log"), "").unwrap();
        let out_file = dir.path().join("out.csv");
        let mut cfg = config_with_log_dir(dir.path().to_str().unwrap());
        cfg.exporter = ExporterConfig {
            csv: Some(CsvExporterConfig {
                file: out_file.to_str().unwrap().to_string(),
                overwrite: false,
                append: false,
                ..CsvExporterConfig::default()
            }),
            ..Default::default()
        };
        let result = check(&cfg);
        assert!(!result.has_errors());
    }

    #[test]
    fn test_check_csv_existing_writable_file() {
        let dir = tempfile::TempDir::new().unwrap();
        std::fs::write(dir.path().join("test.log"), "").unwrap();
        let out_file = dir.path().join("out.csv");
        std::fs::write(&out_file, "").unwrap(); // pre-create file
        let mut cfg = config_with_log_dir(dir.path().to_str().unwrap());
        cfg.exporter = ExporterConfig {
            csv: Some(CsvExporterConfig {
                file: out_file.to_str().unwrap().to_string(),
                overwrite: false,
                append: false,
                ..CsvExporterConfig::default()
            }),
            ..Default::default()
        };
        let result = check(&cfg);
        assert!(!result.has_errors());
    }
}