Skip to main content

mnemo/
importer.rs

1use anyhow::{Context, Result};
2use rusqlite::Connection;
3use std::collections::HashSet;
4use std::path::Path;
5
6use crate::config::Config;
7use crate::db::{self, NewCommand};
8use crate::filter;
9
10/// Statistiques renvoyées après un import.
11#[derive(Debug, Default, Clone)]
12pub struct ImportStats {
13    pub total: usize,
14    pub imported: usize,
15    pub skipped_sensitive: usize,
16    pub skipped_duplicate: usize,
17}
18
19/// Importe un fichier d'historique Bash dans la base.
20pub fn import_bash_history(conn: &Connection, path: &Path, config: &Config) -> Result<ImportStats> {
21    let content = std::fs::read_to_string(path)
22        .with_context(|| format!("lecture de l'historique {}", path.display()))?;
23    import_from_str(conn, &content, config)
24}
25
26/// Importe le contenu d'un historique (séparé en testable).
27pub fn import_from_str(conn: &Connection, content: &str, config: &Config) -> Result<ImportStats> {
28    let mut stats = ImportStats::default();
29    // Déduplication intra-fichier (en plus de la contrainte UNIQUE en base).
30    let mut seen: HashSet<String> = HashSet::new();
31    let created_at = db::now_timestamp();
32
33    for raw in content.lines() {
34        let line = raw.trim();
35        if line.is_empty() {
36            continue;
37        }
38        // Lignes de timestamp bash (HISTTIMEFORMAT) : `#1700000000`.
39        if is_history_timestamp(line) {
40            continue;
41        }
42
43        stats.total += 1;
44
45        if filter::is_sensitive(line, &config.sensitive_keywords) {
46            stats.skipped_sensitive += 1;
47            continue;
48        }
49
50        let hash = db::compute_hash(line, None);
51        if !seen.insert(hash) {
52            stats.skipped_duplicate += 1;
53            continue;
54        }
55
56        let cmd = NewCommand {
57            command: line.to_string(),
58            cwd: None,
59            shell: Some("bash".to_string()),
60            hostname: None,
61            exit_code: None,
62            created_at: created_at.clone(),
63            ..Default::default()
64        };
65
66        if db::insert_command(conn, &cmd)? {
67            stats.imported += 1;
68        } else {
69            stats.skipped_duplicate += 1;
70        }
71    }
72
73    Ok(stats)
74}
75
76fn is_history_timestamp(line: &str) -> bool {
77    line.len() > 1 && line.starts_with('#') && line[1..].chars().all(|c| c.is_ascii_digit())
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use crate::db;
84
85    #[test]
86    fn import_filtre_et_dedoublonne() {
87        let conn = db::open_in_memory().unwrap();
88        let cfg = Config::default();
89        let content = "ls -la\n#1700000000\nls -la\nexport TOKEN=abc\n\ngit status\n";
90
91        let stats = import_from_str(&conn, content, &cfg).unwrap();
92
93        assert_eq!(stats.total, 4);
94        assert_eq!(stats.imported, 2); // "ls -la" et "git status"
95        assert_eq!(stats.skipped_sensitive, 1); // "export TOKEN=abc"
96        assert_eq!(stats.skipped_duplicate, 1); // "ls -la" répété
97        assert_eq!(db::count(&conn).unwrap(), 2);
98    }
99
100    #[test]
101    fn detecte_les_lignes_timestamp() {
102        assert!(is_history_timestamp("#1700000000"));
103        assert!(!is_history_timestamp("# un commentaire"));
104        assert!(!is_history_timestamp("echo #1234"));
105    }
106}