Simpl-Resultat/src-tauri/src/commands/fs_commands.rs
le king fu 7d3a616312 feat(import): warn about the CSV files placed directly in the import folder
scan_import_folder now returns ImportFolderScan { sources, root_files }:
the CSV/TXT files at the root, which no source reads, are named in a
warning on the Import page (above the source list, or under the "no
source" empty state). One action sets sources and root files, RESET keeps
both. Names stay on screen, never in the Journal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:59:20 -04:00

553 lines
19 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>,
}
/// Result of a scan of the import folder: the sources (one per direct
/// subfolder) and the names of the statement files placed directly at the
/// root, which no source reads and the Import page names in a warning (#427).
#[derive(Debug, Serialize, Clone)]
pub struct ImportFolderScan {
pub sources: Vec<ScannedSource>,
pub root_files: Vec<String>,
}
/// True when the file name has a statement extension (`csv` or `txt`, any
/// case), the same rule for the root and for the subfolders.
fn has_statement_extension(path: &Path) -> bool {
let ext = path
.extension()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
ext == "csv" || ext == "txt"
}
/// Scans the import folder: each non-hidden direct subfolder is a source with
/// its direct CSV/TXT files, sorted by name. CSV/TXT files placed directly at
/// the root are read by no source; their names (non-hidden, sorted) are
/// returned in `root_files` so the Import page can warn about them (#427).
#[tauri::command]
pub fn scan_import_folder(folder_path: String) -> Result<ImportFolderScan, 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 mut root_files: Vec<String> = 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() {
if path.is_file() && has_statement_extension(&path) {
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if !name.starts_with('.') {
root_files.push(name);
}
}
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;
}
if !has_statement_extension(file_path) {
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));
root_files.sort();
Ok(ImportFolderScan {
sources,
root_files,
})
}
#[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"));
}
}
#[cfg(test)]
mod scan_import_folder_tests {
use super::*;
use std::path::PathBuf;
/// One scratch directory per case: the cases run in parallel in one process.
fn sandbox(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"simpl-resultat-scan-{}-{}",
std::process::id(),
name
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("sandbox created");
dir
}
fn touch(path: &Path) {
fs::write(path, "Date;Montant\n").expect("file written");
}
fn scan(dir: &Path) -> ImportFolderScan {
scan_import_folder(dir.to_string_lossy().to_string()).expect("scan succeeds")
}
#[test]
fn lists_the_root_statement_files_sorted_by_name() {
let dir = sandbox("root-sorted");
touch(&dir.join("b.csv"));
touch(&dir.join("a.csv"));
touch(&dir.join("c.TXT"));
fs::create_dir_all(dir.join("visa")).unwrap();
touch(&dir.join("visa").join("releve.csv"));
let result = scan(&dir);
assert_eq!(result.root_files, vec!["a.csv", "b.csv", "c.TXT"]);
assert_eq!(result.sources.len(), 1);
assert_eq!(result.sources[0].folder_name, "visa");
assert_eq!(result.sources[0].files.len(), 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn keeps_subfolder_files_out_of_root_files() {
let dir = sandbox("subfolder-only");
fs::create_dir_all(dir.join("cheques")).unwrap();
touch(&dir.join("cheques").join("a.csv"));
let result = scan(&dir);
assert!(result.root_files.is_empty());
assert_eq!(result.sources[0].files[0].filename, "a.csv");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn ignores_other_extensions_and_hidden_files_at_the_root() {
let dir = sandbox("ignored");
touch(&dir.join("notes.pdf"));
touch(&dir.join("noextension"));
touch(&dir.join(".hidden.csv"));
touch(&dir.join("kept.Csv"));
let result = scan(&dir);
assert_eq!(result.root_files, vec!["kept.Csv"]);
assert!(result.sources.is_empty());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn returns_no_root_file_for_a_root_without_files() {
let dir = sandbox("empty-root");
fs::create_dir_all(dir.join("visa")).unwrap();
let result = scan(&dir);
assert!(result.root_files.is_empty());
assert_eq!(result.sources.len(), 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn serializes_root_files_in_snake_case() {
let dir = sandbox("serialize");
touch(&dir.join("a.csv"));
let json = serde_json::to_value(scan(&dir)).unwrap();
assert_eq!(json["root_files"], serde_json::json!(["a.csv"]));
assert!(json["sources"].is_array());
let _ = fs::remove_dir_all(&dir);
}
}