use chrono::{Datelike, Months, NaiveDate}; use encoding_rs::{UTF_8, WINDOWS_1252, ISO_8859_15}; use serde::Serialize; use sha2::{Digest, Sha256}; use std::fs; use std::path::Path; use tauri_plugin_dialog::DialogExt; use walkdir::WalkDir; #[derive(Debug, Serialize, Clone)] pub struct ScannedFile { pub filename: String, pub file_path: String, pub size_bytes: u64, pub modified_at: String, } #[derive(Debug, Serialize, Clone)] pub struct ScannedSource { pub folder_name: String, pub folder_path: String, pub files: Vec, } #[tauri::command] pub fn scan_import_folder(folder_path: String) -> Result, String> { let root = Path::new(&folder_path); if !root.is_dir() { return Err(format!("Folder does not exist: {}", folder_path)); } let mut sources: Vec = Vec::new(); let entries = fs::read_dir(root).map_err(|e| format!("Cannot read folder: {}", e))?; for entry in entries { let entry = entry.map_err(|e| format!("Error reading entry: {}", e))?; let path = entry.path(); if !path.is_dir() { continue; } let folder_name = path .file_name() .unwrap_or_default() .to_string_lossy() .to_string(); // Skip hidden folders if folder_name.starts_with('.') { continue; } let mut files: Vec = Vec::new(); for file_entry in WalkDir::new(&path).max_depth(1).into_iter().flatten() { let file_path = file_entry.path(); if !file_path.is_file() { continue; } let ext = file_path .extension() .unwrap_or_default() .to_string_lossy() .to_lowercase(); if ext != "csv" && ext != "txt" { continue; } let metadata = fs::metadata(file_path) .map_err(|e| format!("Cannot read metadata: {}", e))?; let modified_at = metadata .modified() .map(|t| { let duration = t .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default(); duration.as_secs().to_string() }) .unwrap_or_default(); files.push(ScannedFile { filename: file_path .file_name() .unwrap_or_default() .to_string_lossy() .to_string(), file_path: file_path.to_string_lossy().to_string(), size_bytes: metadata.len(), modified_at, }); } files.sort_by(|a, b| a.filename.cmp(&b.filename)); sources.push(ScannedSource { folder_name, folder_path: path.to_string_lossy().to_string(), files, }); } sources.sort_by(|a, b| a.folder_name.cmp(&b.folder_name)); Ok(sources) } #[tauri::command] pub fn read_file_content(file_path: String, encoding: String) -> Result { let bytes = fs::read(&file_path).map_err(|e| format!("Cannot read file: {}", e))?; let content = decode_bytes(&bytes, &encoding)?; Ok(content) } #[tauri::command] pub fn hash_file(file_path: String) -> Result { let bytes = fs::read(&file_path).map_err(|e| format!("Cannot read file: {}", e))?; let mut hasher = Sha256::new(); hasher.update(&bytes); let result = hasher.finalize(); Ok(format!("{:x}", result)) } #[tauri::command] pub fn detect_encoding(file_path: String) -> Result { let bytes = fs::read(&file_path).map_err(|e| format!("Cannot read file: {}", e))?; // Check BOM if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { return Ok("utf-8".to_string()); } if bytes.starts_with(&[0xFF, 0xFE]) { return Ok("utf-16le".to_string()); } if bytes.starts_with(&[0xFE, 0xFF]) { return Ok("utf-16be".to_string()); } // Try UTF-8 first if std::str::from_utf8(&bytes).is_ok() { return Ok("utf-8".to_string()); } // Default to windows-1252 for French bank CSVs Ok("windows-1252".to_string()) } #[tauri::command] pub fn get_file_preview( file_path: String, encoding: String, max_lines: usize, ) -> Result { let bytes = fs::read(&file_path).map_err(|e| format!("Cannot read file: {}", e))?; let content = decode_bytes(&bytes, &encoding)?; let lines: Vec<&str> = content.lines().take(max_lines).collect(); Ok(lines.join("\n")) } #[tauri::command] pub async fn pick_folder(app: tauri::AppHandle) -> Result, String> { let folder = app .dialog() .file() .blocking_pick_folder(); Ok(folder.map(|f| f.to_string())) } /// Folder names the onboarding samples are written under — the same in FR and /// EN (decided in planning), so the scan shows one source per bank whatever the /// UI language. const SAMPLE_DESJARDINS_FOLDER: &str = "Exemple Desjardins"; const SAMPLE_TANGERINE_FOLDER: &str = "Exemple Tangerine"; /// Sample statements embedded at compile time. The paths are relative to /// `src-tauri/src/commands/`; the same files are read by vitest, so the Rust /// command and the detection test cannot drift apart. const SAMPLE_DESJARDINS_TEMPLATE: &str = include_str!("../../../src/__fixtures__/samples/exemple-desjardins.csv"); const SAMPLE_TANGERINE_TEMPLATE: &str = include_str!("../../../src/__fixtures__/samples/exemple-tangerine.csv"); /// Write the two onboarding sample statements (a Desjardins chequing account and /// a Tangerine credit card, both synthetic) into `import_folder`, one sub-folder /// each, so that the very next scan of the import folder shows two sources a /// new user can import right away. /// /// `today` is the webview's local date as `YYYY-MM-DD`; chrono is compiled /// without `clock`, so the command never reads the system clock and stays /// deterministic. The templates carry `{{M1}}` / `{{M2}}` tokens, rendered as /// `MM/YYYY` of the two complete months before `today` (`{{M1}}` the most /// recent), and each file is named `releve-YYYY-MM.csv` after `{{M1}}`. /// /// Refuses to run when `import_folder` is not a directory, and when either /// sample folder already exists — checked BEFORE anything is written, so an /// existing folder is never touched. The command only ever receives the import /// folder and a date: the names of what it writes are fixed here. Returns the /// two folder NAMES (not paths). #[tauri::command] pub fn create_sample_import_folders( import_folder: String, today: String, ) -> Result, String> { let today = NaiveDate::parse_from_str(&today, "%Y-%m-%d") .map_err(|e| format!("Invalid date '{}': {}", today, e))?; let first_of_month = today .with_day(1) .ok_or_else(|| "Cannot compute the first day of the month".to_string())?; let m1 = first_of_month .checked_sub_months(Months::new(1)) .ok_or_else(|| "Cannot compute the previous month".to_string())?; let m2 = first_of_month .checked_sub_months(Months::new(2)) .ok_or_else(|| "Cannot compute the month before the previous one".to_string())?; let root = Path::new(&import_folder); if !root.is_dir() { return Err(format!("Folder does not exist: {}", import_folder)); } let samples: [(&str, &str); 2] = [ (SAMPLE_DESJARDINS_FOLDER, SAMPLE_DESJARDINS_TEMPLATE), (SAMPLE_TANGERINE_FOLDER, SAMPLE_TANGERINE_TEMPLATE), ]; // Both existence checks run before the first write: a user who already has // one of the folders keeps it untouched and gets neither. for (folder_name, _) in &samples { if root.join(folder_name).exists() { return Err(format!("Folder already exists: {}", folder_name)); } } let m1_token = m1.format("%m/%Y").to_string(); let m2_token = m2.format("%m/%Y").to_string(); let filename = format!("releve-{}.csv", m1.format("%Y-%m")); let mut created: Vec = Vec::with_capacity(samples.len()); for (folder_name, template) in &samples { let folder = root.join(folder_name); fs::create_dir(&folder) .map_err(|e| format!("Cannot create folder {}: {}", folder_name, e))?; let content = template .replace("{{M1}}", &m1_token) .replace("{{M2}}", &m2_token); fs::write(folder.join(&filename), content) .map_err(|e| format!("Cannot write {}/{}: {}", folder_name, filename, e))?; created.push((*folder_name).to_string()); } Ok(created) } fn decode_bytes(bytes: &[u8], encoding: &str) -> Result { // Strip BOM if present let bytes = if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { &bytes[3..] } else { bytes }; match encoding.to_lowercase().as_str() { "utf-8" | "utf8" => { String::from_utf8(bytes.to_vec()).map_err(|e| format!("UTF-8 decode error: {}", e)) } "windows-1252" | "cp1252" => { let (cow, _, had_errors) = WINDOWS_1252.decode(bytes); if had_errors { Err("Windows-1252 decode error".to_string()) } else { Ok(cow.into_owned()) } } "iso-8859-1" | "iso-8859-15" | "latin1" | "latin9" => { let (cow, _, had_errors) = ISO_8859_15.decode(bytes); if had_errors { Err("ISO-8859-15 decode error".to_string()) } else { Ok(cow.into_owned()) } } _ => { // Fallback to UTF-8 let (cow, _, _) = UTF_8.decode(bytes); Ok(cow.into_owned()) } } } #[cfg(test)] mod sample_folder_tests { use super::*; use std::path::PathBuf; /// One scratch directory per CASE: the cases of this binary run in parallel /// inside a single process, so `process::id()` alone would make them share /// — and trip over — the same folder. fn sandbox(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!( "simpl-resultat-samples-{}-{}", std::process::id(), name )); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).expect("sandbox created"); dir } fn cleanup(dir: &Path) { let _ = fs::remove_dir_all(dir); } fn as_arg(path: &Path) -> String { path.to_string_lossy().to_string() } /// Every `DD/MM/YYYY` date of a rendered file must fall in one of the two /// expected months — no token left, no third month. fn assert_dates_in(content: &str, months: &[&str]) { assert!(!content.contains("{{"), "token left in {}", content); for line in content.lines().skip(1) { let date = line.split([';', ',']).next().unwrap(); assert_eq!(date.len(), 10, "unexpected date {}", date); let month_year = &date[3..]; assert!( months.contains(&month_year), "date {} is outside {:?}", date, months ); } } #[test] fn refuses_a_missing_import_folder() { let dir = sandbox("missing"); let missing = dir.join("nope"); let result = create_sample_import_folders(as_arg(&missing), "2026-09-25".to_string()); cleanup(&dir); let err = result.unwrap_err(); assert!(err.contains("does not exist"), "{}", err); } #[test] fn refuses_when_desjardins_folder_exists_and_writes_nothing() { let dir = sandbox("existing"); fs::create_dir(dir.join(SAMPLE_DESJARDINS_FOLDER)).unwrap(); let result = create_sample_import_folders(as_arg(&dir), "2026-09-25".to_string()); let tangerine_created = dir.join(SAMPLE_TANGERINE_FOLDER).exists(); let desjardins_empty = fs::read_dir(dir.join(SAMPLE_DESJARDINS_FOLDER)) .unwrap() .next() .is_none(); cleanup(&dir); let err = result.unwrap_err(); assert!(err.contains(SAMPLE_DESJARDINS_FOLDER), "{}", err); assert!(!tangerine_created, "Exemple Tangerine must not be created"); assert!(desjardins_empty, "the existing folder must stay untouched"); } #[test] fn writes_both_folders_for_the_two_previous_months() { let dir = sandbox("success"); let result = create_sample_import_folders(as_arg(&dir), "2026-09-25".to_string()); let read = |folder: &str| { fs::read_to_string(dir.join(folder).join("releve-2026-08.csv")) }; let desjardins = read(SAMPLE_DESJARDINS_FOLDER); let tangerine = read(SAMPLE_TANGERINE_FOLDER); let desjardins_count = fs::read_dir(dir.join(SAMPLE_DESJARDINS_FOLDER)).unwrap().count(); let tangerine_count = fs::read_dir(dir.join(SAMPLE_TANGERINE_FOLDER)).unwrap().count(); cleanup(&dir); assert_eq!( result.unwrap(), vec![ SAMPLE_DESJARDINS_FOLDER.to_string(), SAMPLE_TANGERINE_FOLDER.to_string() ] ); let desjardins = desjardins.expect("Desjardins file written"); let tangerine = tangerine.expect("Tangerine file written"); assert_eq!(desjardins_count, 1); assert_eq!(tangerine_count, 1); assert_eq!(desjardins.lines().count(), 41); assert_eq!(tangerine.lines().count(), 41); assert_dates_in(&desjardins, &["08/2026", "07/2026"]); assert_dates_in(&tangerine, &["08/2026", "07/2026"]); assert!(desjardins.contains("08/2026") && desjardins.contains("07/2026")); assert!(tangerine.contains("08/2026") && tangerine.contains("07/2026")); } #[test] fn refuses_an_unparseable_date_before_writing() { let dir = sandbox("bad-date"); let result = create_sample_import_folders(as_arg(&dir), "pas-une-date".to_string()); let anything_written = fs::read_dir(&dir).unwrap().next().is_some(); cleanup(&dir); let err = result.unwrap_err(); assert!(err.contains("Invalid date"), "{}", err); assert!(!anything_written); } #[test] fn crosses_the_year_boundary() { let dir = sandbox("year-boundary"); let result = create_sample_import_folders(as_arg(&dir), "2027-01-15".to_string()); let desjardins = fs::read_to_string(dir.join(SAMPLE_DESJARDINS_FOLDER).join("releve-2026-12.csv")); let tangerine = fs::read_to_string(dir.join(SAMPLE_TANGERINE_FOLDER).join("releve-2026-12.csv")); cleanup(&dir); assert!(result.is_ok()); let desjardins = desjardins.expect("Desjardins file named after December"); let tangerine = tangerine.expect("Tangerine file named after December"); assert_dates_in(&desjardins, &["12/2026", "11/2026"]); assert_dates_in(&tangerine, &["12/2026", "11/2026"]); assert!(desjardins.contains("12/2026") && desjardins.contains("11/2026")); assert!(tangerine.contains("12/2026") && tangerine.contains("11/2026")); } }