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
7pub 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#[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
187fn 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 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
240pub fn handle_init_interactive(output_path: &str, force: bool) -> Result<()> {
242 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
265const 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
358const 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}