Simpl-Resultat/src-tauri/src/commands/fs_commands.rs
le king fu e4a0f1042e
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
PR Check — Rust / rust (pull_request) Successful in 3m38s
feat(onboarding): create_sample_import_folders command, sample CSV templates and detection test (#354)
Add a Tauri command that writes two synthetic bank statements (Desjardins
chequing account, Tangerine credit card) into the import folder, one
sub-folder each, so a new user has sources to import on the first run.

The command takes the import folder and the webview's local date only
(chrono is compiled without `clock`), renders the `{{M1}}` / `{{M2}}`
tokens as the two complete months before that date, names each file
`releve-YYYY-MM.csv` after the most recent one, and refuses to run when
the folder is missing or when either sample folder already exists -
checked before anything is written. It returns the two folder names.

The templates live under `src/__fixtures__/samples/` so the Rust command
(`include_str!`) and vitest read the same bytes. The integration test
proves detection recognises each file as its bank with a signed single
amount and reads 40/40 rows, that the production `mapRow` parses every
row and sums to +740.00 / -368.46, and that the 180 seed keywords
categorise exactly 33 / 36 lines while leaving the hand-classified ones
untouched. The frozen detection corpus (`src/__fixtures__/csv`) is not
modified.

Five Rust tests cover the missing folder, the existing folder, the
success path, an unparseable date and the year boundary, each in its own
sandbox so they run in parallel.

CHANGELOG and architecture docs are centralised in #357.

Resolves #354

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 19:50:41 -04:00

429 lines
15 KiB
Rust

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<ScannedFile>,
}
#[tauri::command]
pub fn scan_import_folder(folder_path: String) -> Result<Vec<ScannedSource>, String> {
let root = Path::new(&folder_path);
if !root.is_dir() {
return Err(format!("Folder does not exist: {}", folder_path));
}
let mut sources: Vec<ScannedSource> = 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<ScannedFile> = 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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<Option<String>, 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<Vec<String>, 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<String> = 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<String, String> {
// 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"));
}
}