Skip to main content

dm_database_sqllog2db/cli/
init.rs

1use crate::error::{ConfigError, Error, FileError, Result};
2use log::{error, info, warn};
3use std::fs;
4use std::io::{BufRead, Write};
5use std::path::Path;
6
7/// 生成默认配置文件
8pub fn handle_init(output_path: &str, force: bool) -> Result<()> {
9    let path = Path::new(output_path);
10    let content = CONFIG_TEMPLATE_EN;
11    write_config_file(path, content, force)?;
12    info!("Next steps:");
13    info!("  1. Edit configuration file: {output_path}");
14    info!("  2. Validate configuration: sqllog2db validate -c {output_path}");
15    info!("  3. Run export: sqllog2db run -c {output_path}");
16    Ok(())
17}
18
19fn write_config_file(path: &Path, content: &str, force: bool) -> Result<()> {
20    let output_path = path.to_string_lossy();
21    info!("Preparing to generate configuration file: {output_path}");
22    let file_existed = path.exists();
23
24    if file_existed && !force {
25        error!("Configuration file already exists: {output_path}");
26        info!("Tip: use --force to overwrite");
27        return Err(Error::File(FileError::AlreadyExists {
28            path: path.to_path_buf(),
29        }));
30    }
31    if file_existed && force {
32        warn!("Will overwrite existing configuration file");
33    }
34    if let Some(parent) = path.parent().filter(|p| !p.exists()) {
35        info!("Creating directory: {}", parent.display());
36        fs::create_dir_all(parent).map_err(|e| {
37            Error::File(FileError::CreateDirectoryFailed {
38                path: parent.to_path_buf(),
39                reason: e.to_string(),
40            })
41        })?;
42    }
43    fs::write(path, content).map_err(|e| {
44        Error::File(FileError::WriteFailed {
45            path: path.to_path_buf(),
46            reason: e.to_string(),
47        })
48    })?;
49    if file_existed {
50        info!("Configuration file overwritten: {output_path}");
51    } else {
52        info!("Configuration file generated: {output_path}");
53    }
54    Ok(())
55}
56
57// ── Wizard types ─────────────────────────────────────────────────────────────
58
59#[derive(Debug, PartialEq, Eq)]
60pub enum ExporterChoice {
61    Csv,
62    Sqlite,
63}
64
65#[derive(Debug, PartialEq, Eq)]
66pub struct WizardAnswers {
67    pub inputs: String,
68    pub exporter: ExporterChoice,
69    pub csv_file: Option<String>,
70    pub sqlite_db: Option<String>,
71    pub sqlite_table: Option<String>,
72}
73
74fn prompt_line(
75    reader: &mut impl BufRead,
76    writer: &mut impl Write,
77    prompt: &str,
78    default: &str,
79    buf: &mut String,
80) -> Result<String> {
81    write!(writer, "{prompt}")?;
82    writer.flush()?;
83    buf.clear();
84    reader.read_line(buf)?;
85    Ok(if buf.trim().is_empty() {
86        default.to_owned()
87    } else {
88        buf.trim().to_owned()
89    })
90}
91
92fn ask_exporter(
93    reader: &mut impl BufRead,
94    writer: &mut impl Write,
95    buf: &mut String,
96) -> Result<ExporterChoice> {
97    write!(writer, "导出格式 (csv/sqlite) [default: csv]: ")?;
98    writer.flush()?;
99    let mut last_input = String::new();
100    for _ in 0..3 {
101        buf.clear();
102        reader.read_line(buf)?;
103        buf.trim().clone_into(&mut last_input);
104        match last_input.as_str() {
105            "" | "csv" => return Ok(ExporterChoice::Csv),
106            "sqlite" => return Ok(ExporterChoice::Sqlite),
107            _ => {
108                write!(writer, "无效格式\"{last_input}\",请输入 csv 或 sqlite: ")?;
109                writer.flush()?;
110            }
111        }
112    }
113    Err(Error::Config(ConfigError::InvalidValue {
114        field: "exporter".to_owned(),
115        value: last_input,
116        reason: "must be 'csv' or 'sqlite'".to_owned(),
117    }))
118}
119
120fn build_csv_answers(
121    reader: &mut impl BufRead,
122    writer: &mut impl Write,
123    inputs: String,
124    buf: &mut String,
125) -> Result<WizardAnswers> {
126    let csv_file = prompt_line(
127        reader,
128        writer,
129        "CSV 输出文件路径 [default: outputs/sqllog.csv]: ",
130        "outputs/sqllog.csv",
131        buf,
132    )?;
133    Ok(WizardAnswers {
134        inputs,
135        exporter: ExporterChoice::Csv,
136        csv_file: Some(csv_file),
137        sqlite_db: None,
138        sqlite_table: None,
139    })
140}
141
142fn build_sqlite_answers(
143    reader: &mut impl BufRead,
144    writer: &mut impl Write,
145    inputs: String,
146    buf: &mut String,
147) -> Result<WizardAnswers> {
148    let sqlite_db = prompt_line(
149        reader,
150        writer,
151        "SQLite 数据库路径 [default: export/sqllog2db.db]: ",
152        "export/sqllog2db.db",
153        buf,
154    )?;
155    let sqlite_table = prompt_line(
156        reader,
157        writer,
158        "表名(仅含字母/数字/下划线)[default: sqllog_records]: ",
159        "sqllog_records",
160        buf,
161    )?;
162    Ok(WizardAnswers {
163        inputs,
164        exporter: ExporterChoice::Sqlite,
165        csv_file: None,
166        sqlite_db: Some(sqlite_db),
167        sqlite_table: Some(sqlite_table),
168    })
169}
170
171pub fn run_wizard(reader: &mut impl BufRead, writer: &mut impl Write) -> Result<WizardAnswers> {
172    let mut buf = String::new();
173    let inputs = prompt_line(
174        reader,
175        writer,
176        "SQL log 输入目录(可以是目录、文件或 glob 模式)[default: sqllogs]: ",
177        "sqllogs",
178        &mut buf,
179    )?;
180    let exporter = ask_exporter(reader, writer, &mut buf)?;
181    match exporter {
182        ExporterChoice::Csv => build_csv_answers(reader, writer, inputs, &mut buf),
183        ExporterChoice::Sqlite => build_sqlite_answers(reader, writer, inputs, &mut buf),
184    }
185}
186
187/// Escape a user string for embedding inside a TOML basic string (double-quoted).
188/// In TOML basic strings, backslash and double-quote must be escaped.
189/// Forward-slash normalization also handles Windows paths.
190fn toml_escape(s: &str) -> String {
191    s.replace('\\', "/").replace('"', "\\\"")
192}
193
194fn apply_csv_substitutions(content: &str, answers: &WizardAnswers) -> String {
195    let csv_file = answers.csv_file.as_deref().unwrap_or("outputs/sqllog.csv");
196    let escaped = toml_escape(csv_file);
197    content.replace(
198        r#"file = "outputs/sqllog.csv""#,
199        &format!(r#"file = "{escaped}""#),
200    )
201}
202
203fn apply_sqlite_substitutions(content: &str, answers: &WizardAnswers) -> String {
204    let sqlite_db = answers
205        .sqlite_db
206        .as_deref()
207        .unwrap_or("export/sqllog2db.db");
208    let sqlite_table = answers.sqlite_table.as_deref().unwrap_or("sqllog_records");
209    let escaped_db = toml_escape(sqlite_db);
210    let escaped_table = toml_escape(sqlite_table);
211    // The SQLite template already has the correct exporter sections configured.
212    // Only substitute the user-provided values.
213    content
214        .replace(
215            r#"database_url = "export/sqllog2db.db""#,
216            &format!(r#"database_url = "{escaped_db}""#),
217        )
218        .replace(
219            r#"table_name = "sqllog_records""#,
220            &format!(r#"table_name = "{escaped_table}""#),
221        )
222}
223
224fn apply_wizard_answers_to_template(answers: &WizardAnswers) -> String {
225    let escaped_inputs = toml_escape(&answers.inputs);
226    let template = match answers.exporter {
227        ExporterChoice::Csv => CONFIG_TEMPLATE_EN,
228        ExporterChoice::Sqlite => CONFIG_TEMPLATE_SQLITE_EN,
229    };
230    let content = template.replace(
231        r#"inputs = ["sqllogs"]"#,
232        &format!(r#"inputs = ["{escaped_inputs}"]"#),
233    );
234    match answers.exporter {
235        ExporterChoice::Csv => apply_csv_substitutions(&content, answers),
236        ExporterChoice::Sqlite => apply_sqlite_substitutions(&content, answers),
237    }
238}
239
240/// 交互式配置向导入口
241pub fn handle_init_interactive(output_path: &str, force: bool) -> Result<()> {
242    // Early-exit check: do not run the wizard if the file already exists and --force is not set.
243    let path = std::path::Path::new(output_path);
244    if path.exists() && !force {
245        error!("Configuration file already exists: {output_path}");
246        info!("Tip: use --force to overwrite");
247        return Err(Error::File(FileError::AlreadyExists {
248            path: path.to_path_buf(),
249        }));
250    }
251    let stdin = std::io::stdin();
252    let stdout = std::io::stdout();
253    let mut reader = stdin.lock();
254    let mut writer = stdout.lock();
255    let answers = run_wizard(&mut reader, &mut writer)?;
256    let content = apply_wizard_answers_to_template(&answers);
257    write_config_file(path, &content, force)?;
258    info!("Next steps:");
259    info!("  1. Edit configuration file: {output_path}");
260    info!("  2. Validate configuration: sqllog2db validate -c {output_path}");
261    info!("  3. Run export: sqllog2db run -c {output_path}");
262    Ok(())
263}
264
265// ── Templates ────────────────────────────────────────────────────────────────
266
267const CONFIG_TEMPLATE_EN: &str = r#"# sqllog2db default configuration file (edit as needed)
268
269[sqllog]
270# SQL log path list: directories, single files, or glob patterns (e.g. "./logs/2025-*.log")
271# Multiple entries are supported.
272inputs = ["sqllogs"]
273
274[logging]
275# Application log file path
276file = "logs/sqllog2db.log"
277# Log level: trace | debug | info | warn | error
278level = "info"
279# Log retention in days (1-365)
280retention_days = 7
281
282[replace_parameters]
283# Write a normalized_sql column in export output (default: true).
284# For INS/DEL/UPD/ORA records, parameter values are substituted into SQL placeholders.
285enable = true
286
287[filter]
288# Enable the filter pipeline
289enable = false
290
291# --- Include filters (record-level, AND semantics: every configured field must match) ---
292# Metadata fields use exact string matching.
293[filter.include]
294# users      = ["SYSDBA"]                       # Exact-match list of usernames to include
295# ips        = ["127.0.0.1", "192.168.1.100"]   # Exact-match list of client IP addresses to include
296# sessions   = ["0x7f41435437a8"]               # Exact-match list of session IDs (hex strings) to include
297# threads    = ["2188515"]                       # Exact-match list of thread IDs to include
298# statements = ["INS", "UPD", "DEL"]            # Statement types to include (INS/UPD/DEL/SEL/SET/OTH/ORA)
299# apps       = ["DMSQL"]                        # Exact-match list of application names to include
300# tags       = ["[SEL]"]                        # Exact-match list of record tags to include (e.g. [SEL], [INS])
301# start_ts   = "2023-01-01 00:00:00"            # Inclusive lower bound of record timestamp (format: YYYY-MM-DD HH:MM:SS)
302# end_ts     = "2023-01-01 23:59:59"            # Inclusive upper bound of record timestamp (format: YYYY-MM-DD HH:MM:SS)
303# trxids     = ["257809109", "257809110"]        # Exact-match list of transaction IDs to include
304
305# --- Exclude filters (record-level, OR-veto: any match drops the record) ---
306# Metadata fields use exact string matching.
307[filter.exclude]
308# users      = ["guest", "anon"]                # Exact-match list of usernames to exclude
309# ips        = ["10.0.0.1", "172.16.0.1"]       # Exact-match list of client IP addresses to exclude
310# sessions   = ["0x0000000000000000"]           # Exact-match list of session IDs (hex strings) to exclude
311# threads    = ["0"]                            # Exact-match list of thread IDs to exclude
312# statements = ["SEL", "SET"]                   # Statement types to exclude (INS/UPD/DEL/SEL/SET/OTH/ORA)
313# apps       = ["monitor", "health"]            # Exact-match list of application names to exclude
314# tags       = ["[SET]", "[OTH]"]              # Exact-match list of record tags to exclude
315
316# --- Indicator filters (transaction-level: match retains the whole transaction; requires pre-scan) ---
317[filter.indicators]
318# exec_ids = [257809109, 257809110]   # Transaction-level: retain whole transaction if any record's exec_id matches
319# min_runtime_ms = 1000               # Transaction-level: retain whole transaction if any statement's runtime (ms) >= threshold
320# min_row_count = 100                 # Transaction-level: retain whole transaction if any statement's row_count >= threshold
321
322# --- SQL filters (transaction-level: match retains the whole transaction; requires pre-scan) ---
323[filter.sql]
324# includes = ["FROM USER_TABLES", "DELETE FROM"]   # Transaction-level: retain whole transaction if any SQL text contains any substring listed
325# excludes = ["SELECT 1", "DUAL"]                  # Transaction-level: drop whole transaction if any SQL text contains any substring listed
326
327# --- Stats subcommand time-range filter (optional) ---
328[stats]
329# from = "2024-01-01"   # Start of time range. Formats: "YYYY-MM-DD" or "YYYY-MM-DD HH:MM:SS"
330# to   = "2024-01-31"   # End of time range. Same formats as from.
331# top  = 20             # Default top-N. CLI --top overrides this value.
332# CLI args --from / --to / --top override the values above. When both CLI and config are absent, stats runs without time filtering (top defaults to 20).
333
334# ===================== Exporter Configuration =====================
335# Only one exporter can be active at a time. Priority: csv > sqlite
336
337# Option 1: CSV export (default)
338[exporter.csv]
339# CSV output file path
340file = "outputs/sqllog.csv"
341# Drop and recreate the file before writing (true/false)
342overwrite = true
343# Append to existing CSV file instead of overwriting (true/false)
344append = false
345
346# Option 2: SQLite database export
347# [exporter.sqlite]
348# SQLite database file path
349# database_url = "export/sqllog2db.db"
350# Table name to write records into (ASCII identifiers only: [A-Za-z_][A-Za-z0-9_]*)
351# table_name = "sqllog_records"
352# Drop and recreate the table before writing (true/false)
353# overwrite = true
354# Append rows to existing table instead of overwriting (true/false)
355# append = false
356"#;
357
358/// `SQLite`-mode template: CSV section commented out, `SQLite` section active.
359/// Used by the interactive wizard when the user selects `SQLite` output.
360/// Placeholder values are substituted by `apply_sqlite_substitutions`.
361const CONFIG_TEMPLATE_SQLITE_EN: &str = r#"# sqllog2db default configuration file (edit as needed)
362
363[sqllog]
364# SQL log path list: directories, single files, or glob patterns (e.g. "./logs/2025-*.log")
365# Multiple entries are supported.
366inputs = ["sqllogs"]
367
368[logging]
369# Application log file path
370file = "logs/sqllog2db.log"
371# Log level: trace | debug | info | warn | error
372level = "info"
373# Log retention in days (1-365)
374retention_days = 7
375
376[replace_parameters]
377# Write a normalized_sql column in export output (default: true).
378# For INS/DEL/UPD/ORA records, parameter values are substituted into SQL placeholders.
379enable = true
380
381[filter]
382# Enable the filter pipeline
383enable = false
384
385# --- Include filters (record-level, AND semantics: every configured field must match) ---
386# Metadata fields use exact string matching.
387[filter.include]
388# users      = ["SYSDBA"]                       # Exact-match list of usernames to include
389# ips        = ["127.0.0.1", "192.168.1.100"]   # Exact-match list of client IP addresses to include
390# sessions   = ["0x7f41435437a8"]               # Exact-match list of session IDs (hex strings) to include
391# threads    = ["2188515"]                       # Exact-match list of thread IDs to include
392# statements = ["INS", "UPD", "DEL"]            # Statement types to include (INS/UPD/DEL/SEL/SET/OTH/ORA)
393# apps       = ["DMSQL"]                        # Exact-match list of application names to include
394# tags       = ["[SEL]"]                        # Exact-match list of record tags to include (e.g. [SEL], [INS])
395# start_ts   = "2023-01-01 00:00:00"            # Inclusive lower bound of record timestamp (format: YYYY-MM-DD HH:MM:SS)
396# end_ts     = "2023-01-01 23:59:59"            # Inclusive upper bound of record timestamp (format: YYYY-MM-DD HH:MM:SS)
397# trxids     = ["257809109", "257809110"]        # Exact-match list of transaction IDs to include
398
399# --- Exclude filters (record-level, OR-veto: any match drops the record) ---
400# Metadata fields use exact string matching.
401[filter.exclude]
402# users      = ["guest", "anon"]                # Exact-match list of usernames to exclude
403# ips        = ["10.0.0.1", "172.16.0.1"]       # Exact-match list of client IP addresses to exclude
404# sessions   = ["0x0000000000000000"]           # Exact-match list of session IDs (hex strings) to exclude
405# threads    = ["0"]                            # Exact-match list of thread IDs to exclude
406# statements = ["SEL", "SET"]                   # Statement types to exclude (INS/UPD/DEL/SEL/SET/OTH/ORA)
407# apps       = ["monitor", "health"]            # Exact-match list of application names to exclude
408# tags       = ["[SET]", "[OTH]"]              # Exact-match list of record tags to exclude
409
410# --- Indicator filters (transaction-level: match retains the whole transaction; requires pre-scan) ---
411[filter.indicators]
412# exec_ids = [257809109, 257809110]   # Transaction-level: retain whole transaction if any record's exec_id matches
413# min_runtime_ms = 1000               # Transaction-level: retain whole transaction if any statement's runtime (ms) >= threshold
414# min_row_count = 100                 # Transaction-level: retain whole transaction if any statement's row_count >= threshold
415
416# --- SQL filters (transaction-level: match retains the whole transaction; requires pre-scan) ---
417[filter.sql]
418# includes = ["FROM USER_TABLES", "DELETE FROM"]   # Transaction-level: retain whole transaction if any SQL text contains any substring listed
419# excludes = ["SELECT 1", "DUAL"]                  # Transaction-level: drop whole transaction if any SQL text contains any substring listed
420
421# --- Stats subcommand time-range filter (optional) ---
422[stats]
423# from = "2024-01-01"   # Start of time range. Formats: "YYYY-MM-DD" or "YYYY-MM-DD HH:MM:SS"
424# to   = "2024-01-31"   # End of time range. Same formats as from.
425# top  = 20             # Default top-N. CLI --top overrides this value.
426# CLI args --from / --to / --top override the values above. When both CLI and config are absent, stats runs without time filtering (top defaults to 20).
427
428# ===================== Exporter Configuration =====================
429# Only one exporter can be active at a time. Priority: csv > sqlite
430
431# Option 1: CSV export (default)
432# [exporter.csv]
433# CSV output file path
434# file = "outputs/sqllog.csv"
435# Drop and recreate the file before writing (true/false)
436# overwrite = true
437# Append to existing CSV file instead of overwriting (true/false)
438# append = false
439
440# Option 2: SQLite database export
441[exporter.sqlite]
442# SQLite database file path
443database_url = "export/sqllog2db.db"
444# Table name to write records into (ASCII identifiers only: [A-Za-z_][A-Za-z0-9_]*)
445table_name = "sqllog_records"
446# Drop and recreate the table before writing (true/false)
447overwrite = true
448# Append rows to existing table instead of overwriting (true/false)
449append = false
450"#;
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    #[test]
457    fn test_wizard_all_defaults() {
458        let input = b"\n\n\n";
459        let mut reader = std::io::Cursor::new(input.as_ref());
460        let mut writer = Vec::<u8>::new();
461        let answers = run_wizard(&mut reader, &mut writer).unwrap();
462        assert_eq!(answers.inputs, "sqllogs");
463        assert!(matches!(answers.exporter, ExporterChoice::Csv));
464        assert_eq!(answers.csv_file.as_deref(), Some("outputs/sqllog.csv"));
465        assert!(answers.sqlite_db.is_none());
466        assert!(answers.sqlite_table.is_none());
467    }
468
469    #[test]
470    fn test_wizard_custom_csv_path() {
471        let input = b"my/logs\ncsv\nmy_out/result.csv\n";
472        let mut reader = std::io::Cursor::new(input.as_ref());
473        let mut writer = Vec::<u8>::new();
474        let answers = run_wizard(&mut reader, &mut writer).unwrap();
475        assert_eq!(answers.inputs, "my/logs");
476        assert!(matches!(answers.exporter, ExporterChoice::Csv));
477        assert_eq!(answers.csv_file.as_deref(), Some("my_out/result.csv"));
478    }
479
480    #[test]
481    fn test_wizard_sqlite_path() {
482        let input = b"\nsqlite\nmy.db\nmy_table\n";
483        let mut reader = std::io::Cursor::new(input.as_ref());
484        let mut writer = Vec::<u8>::new();
485        let answers = run_wizard(&mut reader, &mut writer).unwrap();
486        assert!(matches!(answers.exporter, ExporterChoice::Sqlite));
487        assert_eq!(answers.sqlite_db.as_deref(), Some("my.db"));
488        assert_eq!(answers.sqlite_table.as_deref(), Some("my_table"));
489        assert!(answers.csv_file.is_none());
490    }
491
492    #[test]
493    fn test_wizard_sqlite_defaults() {
494        let input = b"\nsqlite\n\n\n";
495        let mut reader = std::io::Cursor::new(input.as_ref());
496        let mut writer = Vec::<u8>::new();
497        let answers = run_wizard(&mut reader, &mut writer).unwrap();
498        assert!(matches!(answers.exporter, ExporterChoice::Sqlite));
499        assert_eq!(answers.sqlite_db.as_deref(), Some("export/sqllog2db.db"));
500        assert_eq!(answers.sqlite_table.as_deref(), Some("sqllog_records"));
501    }
502
503    #[test]
504    fn test_wizard_invalid_format_three_times_returns_err() {
505        let input = b"\nbad\nbad\nbad\n";
506        let mut reader = std::io::Cursor::new(input.as_ref());
507        let mut writer = Vec::<u8>::new();
508        let result = run_wizard(&mut reader, &mut writer);
509        assert!(result.is_err());
510        let err = result.unwrap_err();
511        assert!(
512            matches!(&err, Error::Config(ConfigError::InvalidValue { field, .. }) if field == "exporter"),
513            "expected ConfigError::InvalidValue with field='exporter', got: {err:?}"
514        );
515    }
516
517    #[test]
518    fn test_wizard_writer_receives_prompts() {
519        let input = b"\n\n\n";
520        let mut reader = std::io::Cursor::new(input.as_ref());
521        let mut writer = Vec::<u8>::new();
522        run_wizard(&mut reader, &mut writer).unwrap();
523        let output = String::from_utf8(writer).unwrap();
524        assert!(
525            output.contains("SQL log 输入目录"),
526            "prompt should contain 'SQL log 输入目录'"
527        );
528        assert!(
529            output.contains("导出格式 (csv/sqlite)"),
530            "prompt should contain '导出格式 (csv/sqlite)'"
531        );
532        assert!(
533            output.contains("CSV 输出文件路径"),
534            "prompt should contain 'CSV 输出文件路径'"
535        );
536    }
537
538    #[test]
539    fn test_apply_csv_default() {
540        let answers = WizardAnswers {
541            inputs: "sqllogs".to_owned(),
542            exporter: ExporterChoice::Csv,
543            csv_file: Some("outputs/sqllog.csv".to_owned()),
544            sqlite_db: None,
545            sqlite_table: None,
546        };
547        let output = apply_wizard_answers_to_template(&answers);
548        assert_eq!(
549            output, CONFIG_TEMPLATE_EN,
550            "default CSV path should produce identical output to template"
551        );
552    }
553
554    #[test]
555    fn test_apply_csv_custom() {
556        let answers = WizardAnswers {
557            inputs: "my/dir".to_owned(),
558            exporter: ExporterChoice::Csv,
559            csv_file: Some("out/r.csv".to_owned()),
560            sqlite_db: None,
561            sqlite_table: None,
562        };
563        let output = apply_wizard_answers_to_template(&answers);
564        assert!(
565            output.contains(r#"inputs = ["my/dir"]"#),
566            "custom inputs should appear in output"
567        );
568        assert!(
569            output.contains(r#"file = "out/r.csv""#),
570            "custom csv path should appear in output"
571        );
572        assert!(
573            output.contains("[exporter.csv]"),
574            "[exporter.csv] section should be active (not commented)"
575        );
576        assert!(
577            output.contains("# [exporter.sqlite]"),
578            "[exporter.sqlite] section should remain commented"
579        );
580    }
581
582    #[test]
583    fn test_apply_sqlite() {
584        let answers = WizardAnswers {
585            inputs: "x".to_owned(),
586            exporter: ExporterChoice::Sqlite,
587            sqlite_db: Some("d.db".to_owned()),
588            sqlite_table: Some("t".to_owned()),
589            csv_file: None,
590        };
591        let output = apply_wizard_answers_to_template(&answers);
592        assert!(
593            output.contains("[exporter.sqlite]"),
594            "[exporter.sqlite] should be activated"
595        );
596        assert!(
597            !output.contains("# [exporter.sqlite]"),
598            "[exporter.sqlite] should not be commented"
599        );
600        assert!(
601            output.contains(r#"database_url = "d.db""#),
602            "database_url should use user value"
603        );
604        assert!(
605            output.contains(r#"table_name = "t""#),
606            "table_name should use user value"
607        );
608        assert!(
609            output.contains("# [exporter.csv]"),
610            "[exporter.csv] should be commented out"
611        );
612        assert!(
613            output.contains(r#"# file = "outputs/sqllog.csv""#),
614            "csv file line should be commented out"
615        );
616    }
617
618    #[test]
619    fn test_apply_does_not_corrupt_logging_file() {
620        let answers_csv = WizardAnswers {
621            inputs: "sqllogs".to_owned(),
622            exporter: ExporterChoice::Csv,
623            csv_file: Some("outputs/sqllog.csv".to_owned()),
624            sqlite_db: None,
625            sqlite_table: None,
626        };
627        let output_csv = apply_wizard_answers_to_template(&answers_csv);
628        assert!(
629            output_csv.contains(r#"file = "logs/sqllog2db.log""#),
630            "logging.file must not be corrupted in CSV mode"
631        );
632
633        let answers_sqlite = WizardAnswers {
634            inputs: "sqllogs".to_owned(),
635            exporter: ExporterChoice::Sqlite,
636            sqlite_db: Some("export/sqllog2db.db".to_owned()),
637            sqlite_table: Some("sqllog_records".to_owned()),
638            csv_file: None,
639        };
640        let output_sqlite = apply_wizard_answers_to_template(&answers_sqlite);
641        assert!(
642            output_sqlite.contains(r#"file = "logs/sqllog2db.log""#),
643            "logging.file must not be corrupted in SQLite mode"
644        );
645    }
646
647    #[test]
648    fn test_apply_output_parses_as_config_csv() {
649        let answers = WizardAnswers {
650            inputs: "sqllogs".to_owned(),
651            exporter: ExporterChoice::Csv,
652            csv_file: Some("outputs/sqllog.csv".to_owned()),
653            sqlite_db: None,
654            sqlite_table: None,
655        };
656        let content = apply_wizard_answers_to_template(&answers);
657        let cfg: crate::config::Config =
658            toml::from_str(&content).expect("CSV output should parse as valid TOML Config");
659        cfg.validate()
660            .expect("CSV output should pass Config::validate()");
661    }
662
663    #[test]
664    fn test_apply_output_parses_as_config_sqlite() {
665        let answers = WizardAnswers {
666            inputs: "sqllogs".to_owned(),
667            exporter: ExporterChoice::Sqlite,
668            sqlite_db: Some("export/sqllog2db.db".to_owned()),
669            sqlite_table: Some("sqllog_records".to_owned()),
670            csv_file: None,
671        };
672        let content = apply_wizard_answers_to_template(&answers);
673        let cfg: crate::config::Config =
674            toml::from_str(&content).expect("SQLite output should parse as valid TOML Config");
675        cfg.validate()
676            .expect("SQLite output should pass Config::validate()");
677    }
678}