Simpl-Resultat/src-tauri/src/commands/fs_commands.rs
le king fu f27bdc4afa test(rust): a file named like a sample folder is refused, nothing created (#384)
Review 25592, point 1: the pre-check reads exists(), not is_dir(); under
is_dir() a file named "Exemple Tangerine" let "Exemple Desjardins" be
created before create_dir failed with a raw "File exists". The
reviewer's test pins it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:09:01 -04:00

632 lines
24 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");
/// Stable refusal codes of `create_sample_import_folders` (#384), translated by
/// the front (`sampleFolderError` in `src/hooks/useImportWizard.ts`, whose
/// tests read these three constants). `SAMPLE_ERROR_FOLDER_EXISTS` is followed
/// by `:` and the name of the sample folder found. Any other error keeps its
/// English text, shown raw.
const SAMPLE_ERROR_FOLDER_EXISTS: &str = "sample_folder_exists";
const SAMPLE_ERROR_IMPORT_FOLDER_MISSING: &str = "import_folder_missing";
const SAMPLE_ERROR_INVALID_DATE: &str = "invalid_date";
/// 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 `today` is not a date (`invalid_date`), when
/// `import_folder` is not a directory (`import_folder_missing`, without the
/// path), and when either sample folder already exists
/// (`sample_folder_exists:<name>`) — checked BEFORE anything is written, so an
/// existing folder is never touched. A write error keeps its English text: the
/// sample folder's name (with the file's for a write) and the OS error, never
/// the import folder's path. 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(|_| SAMPLE_ERROR_INVALID_DATE.to_string())?;
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(SAMPLE_ERROR_IMPORT_FOLDER_MISSING.to_string());
}
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!("{}:{}", SAMPLE_ERROR_FOLDER_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
);
}
}
/// `{{M1}}` is the most recent month (#384): line by line, a template line
/// that carries `{{M1}}` comes out in `m1`, one that carries `{{M2}}` in
/// `m2`. `assert_dates_in` accepts either month on any line, so the two
/// tokens swapped would pass it.
fn assert_months_follow_tokens(content: &str, template: &str, m1: &str, m2: &str) {
let rendered: Vec<&str> = content.lines().collect();
let source: Vec<&str> = template.lines().collect();
assert_eq!(rendered.len(), source.len());
let (mut m1_lines, mut m2_lines) = (0, 0);
for (out, tpl) in rendered.iter().zip(&source) {
let expected = if tpl.contains("{{M1}}") {
m1_lines += 1;
m1
} else if tpl.contains("{{M2}}") {
m2_lines += 1;
m2
} else {
continue;
};
let date = out.split([';', ',']).next().unwrap();
assert_eq!(&date[3..], expected, "template line {:?} came out as {:?}", tpl, out);
}
assert!(m1_lines > 0 && m2_lines > 0, "each template carries both tokens");
}
#[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);
// The code alone, translated by the front: never the folder's path.
assert_eq!(result.unwrap_err(), "import_folder_missing");
}
#[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);
assert_eq!(result.unwrap_err(), "sample_folder_exists:Exemple Desjardins");
assert!(!tangerine_created, "Exemple Tangerine must not be created");
assert!(desjardins_empty, "the existing folder must stay untouched");
}
/// The second folder of the loop (#384): without the checks that run
/// before the first write, Exemple Desjardins would be written before the
/// loop reached Exemple Tangerine.
#[test]
fn refuses_when_tangerine_folder_exists_and_writes_nothing() {
let dir = sandbox("existing-tangerine");
fs::create_dir(dir.join(SAMPLE_TANGERINE_FOLDER)).unwrap();
let result = create_sample_import_folders(as_arg(&dir), "2026-09-25".to_string());
let desjardins_created = dir.join(SAMPLE_DESJARDINS_FOLDER).exists();
let tangerine_empty = fs::read_dir(dir.join(SAMPLE_TANGERINE_FOLDER))
.unwrap()
.next()
.is_none();
let entries = fs::read_dir(&dir).unwrap().count();
cleanup(&dir);
assert_eq!(result.unwrap_err(), "sample_folder_exists:Exemple Tangerine");
assert!(!desjardins_created, "Exemple Desjardins must not be created");
assert!(tangerine_empty, "the existing folder must stay untouched");
assert_eq!(entries, 1, "nothing but the existing folder");
}
/// A FILE named like a sample folder blocks it as well (#384 review): the
/// check reads `exists()`, not `is_dir()`, so nothing is half-created and
/// the refusal keeps its code instead of a raw "File exists" from
/// `create_dir`.
#[test]
fn refuses_a_file_named_like_a_sample_folder_and_writes_nothing() {
let dir = sandbox("file-tangerine");
fs::write(dir.join(SAMPLE_TANGERINE_FOLDER), "").unwrap();
let result = create_sample_import_folders(as_arg(&dir), "2026-09-25".to_string());
let desjardins_created = dir.join(SAMPLE_DESJARDINS_FOLDER).exists();
let entries = fs::read_dir(&dir).unwrap().count();
cleanup(&dir);
assert_eq!(result.unwrap_err(), "sample_folder_exists:Exemple Tangerine");
assert!(!desjardins_created, "Exemple Desjardins must not be created");
assert_eq!(entries, 1, "nothing but the existing file");
}
#[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"));
// August, the month before the click, is `{{M1}}`; July is `{{M2}}`.
assert_months_follow_tokens(&desjardins, SAMPLE_DESJARDINS_TEMPLATE, "08/2026", "07/2026");
assert_months_follow_tokens(&tangerine, SAMPLE_TANGERINE_TEMPLATE, "08/2026", "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);
assert_eq!(result.unwrap_err(), "invalid_date");
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"));
assert_months_follow_tokens(&desjardins, SAMPLE_DESJARDINS_TEMPLATE, "12/2026", "11/2026");
assert_months_follow_tokens(&tangerine, SAMPLE_TANGERINE_TEMPLATE, "12/2026", "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);
}
}