inklog 0.2.0

Enterprise-grade Rust logging infrastructure
Documentation
// Copyright (c) 2026 Kirky.X
// SPDX-License-Identifier: MIT
use anyhow::{Context, Result};
use std::fs::File;
use std::io::Write;
use std::path::Path;

/// Default log format string shared across all config templates.
const DEFAULT_FORMAT: &str = "{timestamp} [{level}] {target} - {message}";

/// Validate that an output path is safe (no traversal, no null bytes).
fn validate_output_path_safety(path: &Path) -> Result<()> {
    let path_str = path.to_string_lossy();

    // Reject null bytes and Unicode dot variants
    let suspicious = ['\0', '\u{2024}', '\u{2025}', '\u{FE52}'];
    for c in path_str.chars() {
        if suspicious.contains(&c) {
            let mut args = fluent_bundle::FluentArgs::new();
            args.set("path", path.display().to_string());
            return Err(anyhow::anyhow!(
                "{}",
                inklog::i18n::tr_args("cli-generate-err-path-traversal", args)
            ));
        }
    }

    // Reject path traversal patterns (.. components)
    for component in path.components() {
        if matches!(component, std::path::Component::ParentDir) {
            let mut args = fluent_bundle::FluentArgs::new();
            args.set("path", path.display().to_string());
            return Err(anyhow::anyhow!(
                "{}",
                inklog::i18n::tr_args("cli-generate-err-path-traversal", args)
            ));
        }
    }

    Ok(())
}

/// Default global section preamble shared across config templates.
fn default_global_section() -> String {
    format!(
        "[global]\nlevel = \"info\"\nformat = \"{}\"",
        DEFAULT_FORMAT
    )
}

/// Default console section (minimal) shared across config templates.
fn default_console_section() -> &'static str {
    "[console]\nenabled = true\ncolored = true"
}

/// Generate configuration template
///
/// Generates configuration templates with four levels: minimal, full, database, and file.
/// Templates are hardcoded TOML strings.
pub fn generate_config(output_path: &Path, config_type: &str) -> Result<()> {
    // Validate output path safety
    validate_output_path_safety(output_path)?;

    // Determine output path
    let output_file = if output_path.is_dir() {
        output_path.join("inklog_config.toml")
    } else {
        output_path.to_path_buf()
    };

    let config_content = match config_type {
        "minimal" => generate_minimal_config(),
        "full" => generate_full_config(),
        "database" => generate_database_config(),
        "file" => generate_file_config(),
        _ => {
            let mut args = fluent_bundle::FluentArgs::new();
            args.set("type", config_type.to_string());
            return Err(anyhow::anyhow!(
                "{}",
                inklog::i18n::tr_args("cli-generate-unknown-type", args)
            ));
        }
    };

    let mut file = File::create(&output_file).with_context(|| {
        let mut args = fluent_bundle::FluentArgs::new();
        args.set("path", output_file.display().to_string());
        inklog::i18n::tr_args("config-create_config_failed", args)
    })?;

    file.write_all(config_content.as_bytes())
        .with_context(|| inklog::i18n::tr("config-write_config_failed"))?;

    let mut args = fluent_bundle::FluentArgs::new();
    args.set("path", output_file.display().to_string());
    println!("{}", inklog::i18n::tr_args("cli-generate-config", args));
    Ok(())
}

/// Generate minimal configuration template
fn generate_minimal_config() -> String {
    format!(
        r#"# inklog minimal configuration
{}

{}
"#,
        default_global_section(),
        default_console_section(),
    )
}

/// Generate full configuration template
fn generate_full_config() -> String {
    format!(
        r#"# inklog configuration
# Generated by inklog-cli generate full

{}

{}
stderr_levels = ["error", "warn"]

[file]
enabled = true
path = "logs/app.log"
max_size = "100MB"
rotation_time = "daily"
keep_files = 30
compress = true
compression_level = 3
encrypt = false
encryption_key_env = "INKLOG_ENCRYPTION_KEY"
retention_days = 30
max_total_size = "1GB"
cleanup_interval_minutes = 60

[performance]
channel_capacity = 10000
worker_threads = 3

[http]
enabled = false
host = "127.0.0.1"
port = 9090
metrics_path = "/metrics"
health_path = "/health"

# Database sink (optional)
# [database]
# enabled = false
# driver = "postgres"
# url = "postgres://localhost/logs"
# pool_size = 10
# batch_size = 100
# flush_interval_ms = 500
# table_name = "logs"

"#,
        default_global_section(),
        default_console_section(),
    )
}

/// Generate database configuration template
fn generate_database_config() -> String {
    format!(
        r#"# inklog database configuration
# Generated by inklog-cli generate database

{}

{}

[performance]
channel_capacity = 10000
worker_threads = 4

[database]
enabled = true
driver = "postgres"
url = "postgres://localhost/logs"
pool_size = 10
batch_size = 100
flush_interval_ms = 500
table_name = "logs"

# For MySQL:
# driver = "mysql"
# url = "mysql://user:password@localhost/logs"

# For SQLite:
# driver = "sqlite"
# url = "sqlite://logs.db"
# pool_size = 5

"#,
        default_global_section(),
        default_console_section(),
    )
}

/// Generate file configuration template
fn generate_file_config() -> String {
    format!(
        r#"# inklog file configuration
# Generated by inklog-cli generate file

{}

{}

[file]
enabled = true
path = "logs/app.log"
max_size = "100MB"
rotation_time = "daily"
keep_files = 30
compress = true
compression_level = 3
encrypt = false
encryption_key_env = "INKLOG_ENCRYPTION_KEY"
retention_days = 30
max_total_size = "1GB"
cleanup_interval_minutes = 60

[performance]
channel_capacity = 10000
worker_threads = 2
"#,
        default_global_section(),
        default_console_section(),
    )
}

pub fn generate_env_example(output_path: &Path) -> Result<()> {
    // Validate output path safety
    validate_output_path_safety(output_path)?;

    let env_content = r#"# inklog environment variables example
# Copy this file to .env and customize values

# Global settings
INKLOG_LEVEL=info
INKLOG_FORMAT={timestamp} [{level}] {target} - {message}

# Console sink
INKLOG_CONSOLE_ENABLED=true

# File sink
INKLOG_FILE_ENABLED=true
INKLOG_FILE_PATH=logs/app.log
INKLOG_FILE_MAX_SIZE=100MB
INKLOG_FILE_ROTATION_TIME=daily
INKLOG_FILE_KEEP_FILES=30
INKLOG_FILE_COMPRESS=true
INKLOG_FILE_ENCRYPT=false
INKLOG_FILE_ENCRYPTION_KEY=your-encryption-key-here

# Database sink
INKLOG_DB_ENABLED=false
INKLOG_DB_DRIVER=postgres
INKLOG_DB_URL=postgres://localhost/logs
INKLOG_DB_POOL_SIZE=10
INKLOG_DB_TABLE_NAME=logs
INKLOG_DB_BATCH_SIZE=100
INKLOG_DB_FLUSH_INTERVAL_MS=500

# Performance
INKLOG_CHANNEL_CAPACITY=10000
INKLOG_WORKER_THREADS=3

# HTTP server
INKLOG_HTTP_ENABLED=false
INKLOG_HTTP_PORT=9090

# Decryption
INKLOG_DECRYPT_KEY=your-decryption-key-here
"#;

    let output_file = if output_path.is_dir() {
        output_path.join(".env.example")
    } else {
        output_path.to_path_buf()
    };

    let mut file = File::create(&output_file).with_context(|| {
        let mut args = fluent_bundle::FluentArgs::new();
        args.set("path", output_file.display().to_string());
        inklog::i18n::tr_args("config-create_env_failed", args)
    })?;

    file.write_all(env_content.as_bytes())
        .with_context(|| inklog::i18n::tr("config-write_env_failed"))?;

    let mut args = fluent_bundle::FluentArgs::new();
    args.set("path", output_file.display().to_string());
    println!("{}", inklog::i18n::tr_args("cli-generate-env", args));
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    #[test]
    fn test_generate_config_minimal() {
        let dir = tempdir().unwrap();
        let output_path = dir.path().join("config.toml");
        let result = generate_config(&output_path, "minimal");
        assert!(result.is_ok());
        let content = std::fs::read_to_string(&output_path).unwrap();
        assert!(content.contains("inklog minimal configuration"));
        assert!(content.contains("[global]"));
    }

    #[test]
    fn test_generate_config_to_directory() {
        // 覆盖 L17-18: output_path.is_dir() 为 true 的分支
        let dir = tempdir().unwrap();
        let result = generate_config(dir.path(), "file");
        assert!(result.is_ok());
        let expected = dir.path().join("inklog_config.toml");
        let content = std::fs::read_to_string(&expected).unwrap();
        assert!(content.contains("inklog file configuration"));
    }

    #[test]
    fn test_generate_config_unknown_type() {
        // 覆盖 L28-33: unknown config type 错误分支
        let dir = tempdir().unwrap();
        let output_path = dir.path().join("config.toml");
        let result = generate_config(&output_path, "unknown");
        assert!(result.is_err());
        let err = result.err().unwrap().to_string();
        assert!(err.contains("Unknown config type"));
    }

    #[test]
    fn test_generate_env_example() {
        // 覆盖 generate_env_example 成功路径(L247-258)
        let dir = tempdir().unwrap();
        let output_path = dir.path().join(".env.example");
        let result = generate_env_example(&output_path);
        assert!(result.is_ok());
        let content = std::fs::read_to_string(&output_path).unwrap();
        assert!(content.contains("INKLOG_LEVEL"));
        assert!(content.contains("INKLOG_DECRYPT_KEY"));
    }

    #[test]
    fn test_generate_env_example_to_directory() {
        // 覆盖 L241-243: output_path.is_dir() 为 true 的分支
        let dir = tempdir().unwrap();
        let result = generate_env_example(dir.path());
        assert!(result.is_ok());
        let expected = dir.path().join(".env.example");
        let content = std::fs::read_to_string(&expected).unwrap();
        assert!(content.contains("inklog environment variables"));
    }

    #[test]
    fn test_generate_config_rejects_path_traversal() {
        let result = generate_config(Path::new("../etc/config.toml"), "minimal");
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("traversal"));
    }

    #[test]
    fn test_generate_env_example_rejects_path_traversal() {
        let result = generate_env_example(Path::new("../../etc/.env.example"));
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("traversal"));
    }

    #[test]
    fn test_validate_output_path_safety_rejects_null_bytes() {
        let result = validate_output_path_safety(Path::new("file\0.toml"));
        assert!(result.is_err());
    }

    #[test]
    fn test_validate_output_path_safety_accepts_normal_paths() {
        assert!(validate_output_path_safety(Path::new("config.toml")).is_ok());
        assert!(validate_output_path_safety(Path::new("/tmp/config.toml")).is_ok());
        assert!(validate_output_path_safety(Path::new("subdir/config.toml")).is_ok());
    }
}