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>
632 lines
24 KiB
Rust
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);
|
|
}
|
|
}
|