From b6cf46b883db8d8eb1b65a33ee8a8830f9b7788a Mon Sep 17 00:00:00 2001 From: soubarna Date: Mon, 5 Oct 2026 17:19:13 +0530 Subject: [PATCH 1/5] style(src-tauri): cargo fmt Pure mechanical rustfmt reformat (default config, no rustfmt.toml). --- src-tauri/src/commands/archive.rs | 90 +- src-tauri/src/commands/assets.rs | 34 +- src-tauri/src/commands/backup.rs | 649 ++++++++++--- src-tauri/src/commands/clients.rs | 41 +- src-tauri/src/commands/erpnext.rs | 64 +- src-tauri/src/commands/files.rs | 61 +- src-tauri/src/commands/fonts.rs | 458 ++++++++-- src-tauri/src/commands/invoice.rs | 247 +++-- src-tauri/src/commands/logo.rs | 86 +- src-tauri/src/commands/payments.rs | 104 ++- src-tauri/src/commands/presets.rs | 5 +- src-tauri/src/commands/series.rs | 24 +- src-tauri/src/commands/settings.rs | 52 +- src-tauri/src/db.rs | 239 ++++- src-tauri/src/gst.rs | 67 +- src-tauri/src/integrations/erpnext/client.rs | 276 ++++-- src-tauri/src/integrations/erpnext/config.rs | 74 +- .../src/integrations/erpnext/discovery.rs | 569 +++++++++--- src-tauri/src/integrations/erpnext/errors.rs | 163 +++- .../src/integrations/erpnext/live_tests.rs | 548 +++++++++-- src-tauri/src/integrations/erpnext/mapping.rs | 228 ++++- src-tauri/src/integrations/erpnext/push.rs | 387 ++++++-- .../src/integrations/erpnext/push/tests.rs | 863 +++++++++++++++--- .../src/integrations/erpnext/testutil.rs | 33 +- src-tauri/src/lib.rs | 45 +- src-tauri/src/logo.rs | 55 +- src-tauri/src/selftest.rs | 10 +- 27 files changed, 4430 insertions(+), 1042 deletions(-) diff --git a/src-tauri/src/commands/archive.rs b/src-tauri/src/commands/archive.rs index 7448949..a9418a4 100644 --- a/src-tauri/src/commands/archive.rs +++ b/src-tauri/src/commands/archive.rs @@ -30,7 +30,10 @@ fn sha256_hex(bytes: &[u8]) -> String { /// The hash comes from the database, but it ends up in a file path, so check its shape. fn archive_path(local_dir: &Path, sha256: &str) -> Result { - let valid = sha256.len() == 64 && sha256.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')); + let valid = sha256.len() == 64 + && sha256 + .bytes() + .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')); if !valid { return Err("The stored archive hash is malformed".to_string()); } @@ -72,7 +75,9 @@ pub fn archive_pdf_impl( .map_err(|e| e.to_string())?; let (status, existing) = row.ok_or_else(|| "Invoice not found".to_string())?; if status != "issued" && status != "cancelled" { - return Err(format!("Only an issued invoice can be archived (this one is {status})")); + return Err(format!( + "Only an issued invoice can be archived (this one is {status})" + )); } let sha = sha256_hex(bytes); @@ -89,7 +94,9 @@ pub fn archive_pdf_impl( std::fs::create_dir_all(path.parent().expect("archive path has a parent")) .map_err(|e| format!("Could not create the archive folder: {e}"))?; // Same content, same name; a matching length means the file is already in place. - let present = std::fs::metadata(&path).map(|m| m.len() == bytes.len() as u64).unwrap_or(false); + let present = std::fs::metadata(&path) + .map(|m| m.len() == bytes.len() as u64) + .unwrap_or(false); if !present { write_atomic(&path, bytes)?; } @@ -107,7 +114,8 @@ pub fn archive_pdf_impl( } pub fn read_archive_impl(conn: &Connection, local_dir: &Path, id: i64) -> Result, String> { - let sha = stored_sha(conn, id)?.ok_or_else(|| "This invoice has no archived PDF".to_string())?; + let sha = + stored_sha(conn, id)?.ok_or_else(|| "This invoice has no archived PDF".to_string())?; let path = archive_path(local_dir, &sha)?; let bytes = std::fs::read(&path).map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { @@ -124,7 +132,11 @@ pub fn read_archive_impl(conn: &Connection, local_dir: &Path, id: i64) -> Result fn stored_sha(conn: &Connection, id: i64) -> Result, String> { let row: Option> = conn - .query_row("SELECT archived_pdf_sha256 FROM invoices WHERE id = ?1", params![id], |r| r.get(0)) + .query_row( + "SELECT archived_pdf_sha256 FROM invoices WHERE id = ?1", + params![id], + |r| r.get(0), + ) .optional() .map_err(|e| e.to_string())?; row.ok_or_else(|| "Invoice not found".to_string()) @@ -140,12 +152,20 @@ pub fn archive_status_impl(conn: &Connection, id: i64) -> Result, state: State<'_, AppState>) -> Result { +pub async fn archive_pdf( + request: Request<'_>, + state: State<'_, AppState>, +) -> Result { let bytes = raw_body(&request)?; let headers = header_map(&request); let mut conn = state.db.lock().map_err(|e| e.to_string())?; @@ -159,7 +179,10 @@ pub async fn read_archive(invoice_id: i64, state: State<'_, AppState>) -> Result } #[tauri::command] -pub async fn archive_status(invoice_id: i64, state: State<'_, AppState>) -> Result { +pub async fn archive_status( + invoice_id: i64, + state: State<'_, AppState>, +) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; archive_status_impl(&conn, invoice_id) } @@ -195,7 +218,10 @@ mod tests { let mut conn = conn_with_invoice("issued"); let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap(); assert_eq!(sha.len(), 64); - assert_eq!(std::fs::read(dir.path().join("archive").join(format!("{sha}.pdf"))).unwrap(), PDF); + assert_eq!( + std::fs::read(dir.path().join("archive").join(format!("{sha}.pdf"))).unwrap(), + PDF + ); let status = archive_status_impl(&conn, 1).unwrap(); assert!(status.archived); @@ -205,7 +231,10 @@ mod tests { let again = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap(); assert_eq!(again, sha); - assert_eq!(archive_status_impl(&conn, 1).unwrap().archived_at.unwrap(), first_at); + assert_eq!( + archive_status_impl(&conn, 1).unwrap().archived_at.unwrap(), + first_at + ); assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF); } @@ -221,19 +250,27 @@ mod tests { let dir = tempdir().unwrap(); let mut conn = conn_with_invoice("issued"); let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap(); - let err = archive_pdf_impl(&mut conn, dir.path(), b"%PDF-1.7\nother", &headers("1")).unwrap_err(); + let err = + archive_pdf_impl(&mut conn, dir.path(), b"%PDF-1.7\nother", &headers("1")).unwrap_err(); assert!(err.contains("already has an archived original"), "{err}"); assert_eq!(archive_status_impl(&conn, 1).unwrap().sha256.unwrap(), sha); - assert_eq!(std::fs::read_dir(dir.path().join("archive")).unwrap().count(), 1); + assert_eq!( + std::fs::read_dir(dir.path().join("archive")) + .unwrap() + .count(), + 1 + ); } #[test] fn non_pdf_and_oversize_bodies_are_refused() { let dir = tempdir().unwrap(); let mut conn = conn_with_invoice("issued"); - assert!(archive_pdf_impl(&mut conn, dir.path(), b"", &headers("1")) - .unwrap_err() - .contains("not a PDF")); + assert!( + archive_pdf_impl(&mut conn, dir.path(), b"", &headers("1")) + .unwrap_err() + .contains("not a PDF") + ); let mut big = b"%PDF-".to_vec(); big.resize(MAX_ARCHIVE_BYTES + 1, 0); assert!(archive_pdf_impl(&mut conn, dir.path(), &big, &headers("1")) @@ -264,15 +301,21 @@ mod tests { fn read_archive_detects_corruption_and_missing_files() { let dir = tempdir().unwrap(); let mut conn = conn_with_invoice("issued"); - assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("no archived PDF")); + assert!(read_archive_impl(&conn, dir.path(), 1) + .unwrap_err() + .contains("no archived PDF")); let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap(); let file = dir.path().join("archive").join(format!("{sha}.pdf")); std::fs::write(&file, b"%PDF-1.7\ntampered").unwrap(); - assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("corrupted")); + assert!(read_archive_impl(&conn, dir.path(), 1) + .unwrap_err() + .contains("corrupted")); std::fs::remove_file(&file).unwrap(); - assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("missing")); + assert!(read_archive_impl(&conn, dir.path(), 1) + .unwrap_err() + .contains("missing")); // Archiving the same bytes again restores the file. archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap(); assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF); @@ -282,8 +325,13 @@ mod tests { fn a_malformed_stored_hash_never_becomes_a_path() { let dir = tempdir().unwrap(); let conn = conn_with_invoice("issued"); - conn.execute("UPDATE invoices SET archived_pdf_sha256 = '../../etc/passwd' WHERE id = 1", []) - .unwrap(); - assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("malformed")); + conn.execute( + "UPDATE invoices SET archived_pdf_sha256 = '../../etc/passwd' WHERE id = 1", + [], + ) + .unwrap(); + assert!(read_archive_impl(&conn, dir.path(), 1) + .unwrap_err() + .contains("malformed")); } } diff --git a/src-tauri/src/commands/assets.rs b/src-tauri/src/commands/assets.rs index 06d5c65..e032a74 100644 --- a/src-tauri/src/commands/assets.rs +++ b/src-tauri/src/commands/assets.rs @@ -42,7 +42,11 @@ pub fn resolve_asset(data_dir: &Path, path: &str) -> Result { if raw.components().any(|c| matches!(c, Component::ParentDir)) { return Err("Asset path must not contain '..'".into()); } - let full = if raw.is_absolute() { raw.to_path_buf() } else { data_dir.join(raw) }; + let full = if raw.is_absolute() { + raw.to_path_buf() + } else { + data_dir.join(raw) + }; let root = data_dir .join("assets") .canonicalize() @@ -73,7 +77,12 @@ pub fn relative_asset_path(data_dir: &Path, path: &str) -> Result Result { let (ext, _) = sniff_image(bytes)?; let dir = assets_dir(data_dir)?; - let file = format!("{}-{}.{}", safe_kind(kind), uuid::Uuid::new_v4().simple(), ext); + let file = format!( + "{}-{}.{}", + safe_kind(kind), + uuid::Uuid::new_v4().simple(), + ext + ); std::fs::write(dir.join(&file), bytes).map_err(|e| e.to_string())?; Ok(format!("assets/{file}")) } @@ -105,7 +114,11 @@ fn asset_data_uri(data_dir: &Path, path: &str) -> Result { fn remove_asset_file(data_dir: &Path, path: &str) -> Result<(), String> { let raw = Path::new(path); - let full = if raw.is_absolute() { raw.to_path_buf() } else { data_dir.join(raw) }; + let full = if raw.is_absolute() { + raw.to_path_buf() + } else { + data_dir.join(raw) + }; // Removing something that is already gone is fine, but an escape attempt is not. if std::fs::symlink_metadata(&full).is_err() { return Ok(()); @@ -163,7 +176,10 @@ mod tests { fn sniffing_accepts_only_png_and_jpeg() { assert_eq!(sniff_image(PNG).unwrap(), ("png", "image/png")); assert_eq!(sniff_image(JPEG).unwrap(), ("jpg", "image/jpeg")); - assert_eq!(sniff_image(b"GIF89a....").unwrap_err(), "Use a PNG or JPEG image"); + assert_eq!( + sniff_image(b"GIF89a....").unwrap_err(), + "Use a PNG or JPEG image" + ); assert!(sniff_image(b"RIFF\x00\x00\x00\x00WEBPVP8 ").is_err()); assert!(sniff_image(b"").is_err()); assert!(sniff_image(b"").is_err()); @@ -173,7 +189,10 @@ mod tests { fn write_ignores_the_name_and_stores_a_relative_path() { let dir = tempdir().unwrap(); let stored = write_asset(dir.path(), "Logo", JPEG).unwrap(); - assert!(stored.starts_with("assets/logo-") && stored.ends_with(".jpg"), "{stored}"); + assert!( + stored.starts_with("assets/logo-") && stored.ends_with(".jpg"), + "{stored}" + ); assert!(dir.path().join(&stored).is_file()); assert!(write_asset(dir.path(), "logo", b"GIF89a").is_err()); let uri = asset_data_uri(dir.path(), &stored).unwrap(); @@ -210,7 +229,10 @@ mod tests { let absolute = dir.path().join(&stored); let by_absolute = resolve_asset(dir.path(), absolute.to_str().unwrap()).unwrap(); assert_eq!(by_relative, by_absolute); - assert_eq!(relative_asset_path(dir.path(), absolute.to_str().unwrap()).unwrap(), stored); + assert_eq!( + relative_asset_path(dir.path(), absolute.to_str().unwrap()).unwrap(), + stored + ); } #[test] diff --git a/src-tauri/src/commands/backup.rs b/src-tauri/src/commands/backup.rs index ea52fdd..ac3d3e1 100644 --- a/src-tauri/src/commands/backup.rs +++ b/src-tauri/src/commands/backup.rs @@ -76,7 +76,10 @@ pub struct DataDirs { impl DataDirs { pub fn new(data: &Path, local: &Path) -> Self { - Self { data: data.to_path_buf(), local: local.to_path_buf() } + Self { + data: data.to_path_buf(), + local: local.to_path_buf(), + } } fn from_state(state: &AppState) -> Self { Self::new(&state.data_dir, &state.local_data_dir) @@ -218,7 +221,11 @@ fn validate_entry_path(name: &str) -> Result<(), String> { if name.is_empty() || name.len() > MAX_NAME_LEN { return bad("empty or too long"); } - if name.starts_with('/') || name.contains('\\') || name.contains(':') || name.chars().any(|c| c.is_control()) { + if name.starts_with('/') + || name.contains('\\') + || name.contains(':') + || name.chars().any(|c| c.is_control()) + { return bad("absolute, backslash, colon or control character"); } let parts: Vec<&str> = name.split('/').collect(); @@ -239,7 +246,11 @@ fn is_symlink_mode(mode: u32) -> bool { } /// Every regular file under `root`, as (zip entry name, path). Symlinks and other special files are skipped. -fn collect_files(root_name: &str, root: &Path, out: &mut Vec<(String, PathBuf)>) -> Result<(), String> { +fn collect_files( + root_name: &str, + root: &Path, + out: &mut Vec<(String, PathBuf)>, +) -> Result<(), String> { fn walk(prefix: &str, dir: &Path, out: &mut Vec<(String, PathBuf)>) -> Result<(), String> { let entries = match fs::read_dir(dir) { Ok(e) => e, @@ -273,7 +284,9 @@ fn zip_options(entry: &str) -> zip::write::SimpleFileOptions { } else { zip::CompressionMethod::Stored }; - zip::write::SimpleFileOptions::default().compression_method(method).large_file(true) + zip::write::SimpleFileOptions::default() + .compression_method(method) + .large_file(true) } /// Stream `src` into the zip as `entry`, hashing as it goes. Returns None if the file vanished. @@ -287,20 +300,28 @@ fn add_file( Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(e) => return Err(io_err("Could not read", src, e)), }; - zip.start_file(entry, zip_options(entry)).map_err(|e| e.to_string())?; + zip.start_file(entry, zip_options(entry)) + .map_err(|e| e.to_string())?; let mut hasher = Sha256::new(); let mut size = 0u64; let mut buf = vec![0u8; 64 * 1024]; loop { - let n = file.read(&mut buf).map_err(|e| io_err("Could not read", src, e))?; + let n = file + .read(&mut buf) + .map_err(|e| io_err("Could not read", src, e))?; if n == 0 { break; } hasher.update(&buf[..n]); - zip.write_all(&buf[..n]).map_err(|e| format!("Could not write the backup: {e}"))?; + zip.write_all(&buf[..n]) + .map_err(|e| format!("Could not write the backup: {e}"))?; size += n as u64; } - Ok(Some(ManifestFile { path: entry.to_string(), sha256: hex(&hasher.finalize()), size })) + Ok(Some(ManifestFile { + path: entry.to_string(), + sha256: hex(&hasher.finalize()), + size, + })) } // ---------------------------------------------------------------- create @@ -308,28 +329,45 @@ fn add_file( fn is_inside(path: &Path, dir: &Path) -> bool { // Compare canonical parents when possible so a relative or symlinked spelling cannot slip through. let canon = |p: &Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf()); - let parent = path.parent().map(canon).unwrap_or_else(|| path.to_path_buf()); + let parent = path + .parent() + .map(canon) + .unwrap_or_else(|| path.to_path_buf()); parent.starts_with(canon(dir)) } -pub fn create_backup_impl(dirs: &DataDirs, dest: &Path, now: DateTime) -> Result { +pub fn create_backup_impl( + dirs: &DataDirs, + dest: &Path, + now: DateTime, +) -> Result { if !dest.is_absolute() { return Err("The backup path must be absolute".to_string()); } for root in ROOTS { if is_inside(dest, &dirs.root_dir(root)) { - return Err(format!("Choose a folder outside the {root} folder for the backup")); + return Err(format!( + "Choose a folder outside the {root} folder for the backup" + )); } } - let parent = dest.parent().ok_or_else(|| "The backup path has no folder".to_string())?; + let parent = dest + .parent() + .ok_or_else(|| "The backup path has no folder".to_string())?; fs::create_dir_all(parent).map_err(|e| io_err("Could not create", parent, e))?; // 1. Consistent DB snapshot through a second connection (WAL lets it read while the app writes). - let snapshot = TempFile(sibling_with_suffix(dest, &format!(".{}.db.tmp", uuid::Uuid::new_v4().simple()))?); + let snapshot = TempFile(sibling_with_suffix( + dest, + &format!(".{}.db.tmp", uuid::Uuid::new_v4().simple()), + )?); let schema_version = { - let conn = Connection::open(dirs.db()).map_err(|e| format!("Could not open the database: {e}"))?; - conn.busy_timeout(std::time::Duration::from_secs(10)).map_err(|e| e.to_string())?; - vacuum_into(&conn, &snapshot.0).map_err(|e| format!("Could not snapshot the database: {e}"))?; + let conn = + Connection::open(dirs.db()).map_err(|e| format!("Could not open the database: {e}"))?; + conn.busy_timeout(std::time::Duration::from_secs(10)) + .map_err(|e| e.to_string())?; + vacuum_into(&conn, &snapshot.0) + .map_err(|e| format!("Could not snapshot the database: {e}"))?; user_version(&conn).map_err(|e| e.to_string())? }; @@ -362,10 +400,15 @@ pub fn create_backup_impl(dirs: &DataDirs, dest: &Path, now: DateTime) -> files: files.clone(), }; let text = serde_json::to_vec_pretty(&manifest).map_err(|e| e.to_string())?; - zip.start_file(MANIFEST_NAME, zip_options(DB_ENTRY)).map_err(|e| e.to_string())?; - zip.write_all(&text).map_err(|e| format!("Could not write the backup: {e}"))?; - let out = zip.finish().map_err(|e| format!("Could not finish the backup: {e}"))?; - out.sync_all().map_err(|e| io_err("Could not flush", &part, e))?; + zip.start_file(MANIFEST_NAME, zip_options(DB_ENTRY)) + .map_err(|e| e.to_string())?; + zip.write_all(&text) + .map_err(|e| format!("Could not write the backup: {e}"))?; + let out = zip + .finish() + .map_err(|e| format!("Could not finish the backup: {e}"))?; + out.sync_all() + .map_err(|e| io_err("Could not flush", &part, e))?; } fs::rename(&part, dest).map_err(|e| io_err("Could not write", dest, e))?; drop(part_guard); // the part file was renamed away, so this is a no-op @@ -425,7 +468,10 @@ fn validate_manifest(m: &Manifest) -> Result<(), String> { return Err(format!("{:?} is larger than the allowed size", f.path)); } if f.sha256.len() != 64 || !f.sha256.bytes().all(|b| b.is_ascii_hexdigit()) { - return Err(format!("{:?} has an invalid checksum in the manifest", f.path)); + return Err(format!( + "{:?} has an invalid checksum in the manifest", + f.path + )); } total = total.saturating_add(f.size); } @@ -479,7 +525,10 @@ fn extract_entry( ) -> Result<(), String> { let entry = archive.by_index(index).map_err(|e| e.to_string())?; if entry.size() != expected.size { - return Err(format!("{:?} does not match the size in the manifest", expected.path)); + return Err(format!( + "{:?} does not match the size in the manifest", + expected.path + )); } if let Some(parent) = target.parent() { fs::create_dir_all(parent).map_err(|e| io_err("Could not create", parent, e))?; @@ -495,34 +544,52 @@ fn extract_entry( let mut written = 0u64; let mut buf = vec![0u8; 64 * 1024]; loop { - let n = reader.read(&mut buf).map_err(|e| format!("Could not read {:?} from the backup: {e}", expected.path))?; + let n = reader + .read(&mut buf) + .map_err(|e| format!("Could not read {:?} from the backup: {e}", expected.path))?; if n == 0 { break; } written += n as u64; if written > expected.size { - return Err(format!("{:?} is larger than the manifest says", expected.path)); + return Err(format!( + "{:?} is larger than the manifest says", + expected.path + )); } hasher.update(&buf[..n]); - out.write_all(&buf[..n]).map_err(|e| io_err("Could not write", target, e))?; + out.write_all(&buf[..n]) + .map_err(|e| io_err("Could not write", target, e))?; } - out.sync_all().map_err(|e| io_err("Could not flush", target, e))?; + out.sync_all() + .map_err(|e| io_err("Could not flush", target, e))?; if written != expected.size || hex(&hasher.finalize()) != expected.sha256 { - return Err(format!("{:?} is damaged: its checksum does not match the manifest", expected.path)); + return Err(format!( + "{:?} is damaged: its checksum does not match the manifest", + expected.path + )); } Ok(()) } -fn stage_inner(dirs: &DataDirs, zip_path: &Path, now: DateTime) -> Result { +fn stage_inner( + dirs: &DataDirs, + zip_path: &Path, + now: DateTime, +) -> Result { let file = File::open(zip_path).map_err(|e| io_err("Could not open", zip_path, e))?; - let mut archive = - zip::ZipArchive::new(file).map_err(|_| "This file is not a valid backup (it is not a zip archive)".to_string())?; + let mut archive = zip::ZipArchive::new(file) + .map_err(|_| "This file is not a valid backup (it is not a zip archive)".to_string())?; if archive.len() > MAX_ENTRIES + 1 { return Err("The backup contains too many entries".to_string()); } let manifest = read_manifest(&mut archive)?; validate_manifest(&manifest)?; - let expected: BTreeMap<&str, &ManifestFile> = manifest.files.iter().map(|f| (f.path.as_str(), f)).collect(); + let expected: BTreeMap<&str, &ManifestFile> = manifest + .files + .iter() + .map(|f| (f.path.as_str(), f)) + .collect(); // The zip must hold exactly the manifest's files: no extras, no symlinks, no duplicates. let mut index_of: BTreeMap = BTreeMap::new(); @@ -533,14 +600,18 @@ fn stage_inner(dirs: &DataDirs, zip_path: &Path, now: DateTime) -> Result continue; } if entry.unix_mode().is_some_and(is_symlink_mode) { - return Err(format!("The backup contains a symbolic link ({name:?}), which is not allowed")); + return Err(format!( + "The backup contains a symbolic link ({name:?}), which is not allowed" + )); } if name == MANIFEST_NAME { continue; } validate_entry_path(&name)?; if !expected.contains_key(name.as_str()) { - return Err(format!("The backup contains a file that is not in its manifest: {name:?}")); + return Err(format!( + "The backup contains a file that is not in its manifest: {name:?}" + )); } if index_of.insert(name.clone(), i).is_some() { return Err(format!("The backup contains {name:?} twice")); @@ -554,13 +625,22 @@ fn stage_inner(dirs: &DataDirs, zip_path: &Path, now: DateTime) -> Result let result = (|| -> Result<(), String> { for f in &manifest.files { let first = f.path.split('/').next().unwrap_or(""); - let target = f.path.split('/').fold(dirs.staging_for(first), |p, c| p.join(c)); + let target = f + .path + .split('/') + .fold(dirs.staging_for(first), |p, c| p.join(c)); extract_entry(&mut archive, index_of[&f.path], f, &target)?; } - verify_staged_db(&dirs.staging_for(DB_ENTRY).join(DB_ENTRY), manifest.schema_version)?; + verify_staged_db( + &dirs.staging_for(DB_ENTRY).join(DB_ENTRY), + manifest.schema_version, + )?; // Keep the manifest beside the staged files so the apply step can re-check them. let manifest_bytes = serde_json::to_vec_pretty(&manifest).map_err(|e| e.to_string())?; - write_atomic(&dirs.staging_for(DB_ENTRY).join(MANIFEST_NAME), &manifest_bytes) + write_atomic( + &dirs.staging_for(DB_ENTRY).join(MANIFEST_NAME), + &manifest_bytes, + ) })(); if let Err(e) = result { dirs.clear_staging(); @@ -568,7 +648,10 @@ fn stage_inner(dirs: &DataDirs, zip_path: &Path, now: DateTime) -> Result } let pending = PendingRestore { - backup_name: zip_path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), + backup_name: zip_path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(), backup_created_at: manifest.created_at.clone(), app_version: manifest.app_version.clone(), schema_version: manifest.schema_version, @@ -585,7 +668,11 @@ fn stage_inner(dirs: &DataDirs, zip_path: &Path, now: DateTime) -> Result } /// Validate and stage a backup; the swap happens on the next start (`apply_pending_restore`). -pub fn stage_restore_impl(dirs: &DataDirs, zip_path: &Path, now: DateTime) -> Result { +pub fn stage_restore_impl( + dirs: &DataDirs, + zip_path: &Path, + now: DateTime, +) -> Result { // A previous staged restore is superseded. let _ = fs::remove_file(dirs.marker()); stage_inner(dirs, zip_path, now) @@ -605,9 +692,13 @@ pub fn cancel_pending_restore_impl(dirs: &DataDirs) -> Result<(), String> { #[derive(Debug, PartialEq, Eq)] pub enum ApplyOutcome { NothingPending, - Applied { safety_dir: PathBuf }, + Applied { + safety_dir: PathBuf, + }, /// The old data is back in place. - Failed { message: String }, + Failed { + message: String, + }, } fn copy_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { @@ -634,7 +725,11 @@ fn move_path(src: &Path, dst: &Path) -> std::io::Result<()> { return Ok(()); } if let Err(e) = copy_recursive(src, dst) { - let _ = if dst.is_dir() { fs::remove_dir_all(dst) } else { fs::remove_file(dst) }; + let _ = if dst.is_dir() { + fs::remove_dir_all(dst) + } else { + fs::remove_file(dst) + }; return Err(e); } if src.is_dir() { @@ -652,7 +747,9 @@ fn move_path(src: &Path, dst: &Path) -> std::io::Result<()> { } fn unique_safety_dir(dirs: &DataDirs, now: DateTime) -> PathBuf { - let base = dirs.backups().join(format!("pre-restore-{}", now.format("%Y%m%d-%H%M%S"))); + let base = dirs + .backups() + .join(format!("pre-restore-{}", now.format("%Y%m%d-%H%M%S"))); let mut candidate = base.clone(); let mut n = 1; while candidate.exists() { @@ -674,13 +771,20 @@ pub fn apply_pending_restore(dirs: &DataDirs, now: DateTime) -> ApplyOutc } /// `fault_at` makes the n-th file move fail (tests only; production passes None) to exercise the rollback. -fn apply_with_fault(dirs: &DataDirs, now: DateTime, fault_at: Option) -> ApplyOutcome { +fn apply_with_fault( + dirs: &DataDirs, + now: DateTime, + fault_at: Option, +) -> ApplyOutcome { let marker_bytes = match fs::read(dirs.marker()) { Ok(b) => b, Err(_) => return ApplyOutcome::NothingPending, }; let pending: Result = serde_json::from_slice(&marker_bytes); - let backup_name = pending.as_ref().map(|p| p.backup_name.clone()).unwrap_or_default(); + let backup_name = pending + .as_ref() + .map(|p| p.backup_name.clone()) + .unwrap_or_default(); let finish_failed = |message: String, safety: Option<&Path>| { let _ = fs::remove_file(dirs.marker()); @@ -699,7 +803,10 @@ fn apply_with_fault(dirs: &DataDirs, now: DateTime, fault_at: Option, fault_at: Option m, - Err(e) => return finish_failed(format!("The staged restore is incomplete ({e}); the restore was skipped."), None), + Err(e) => { + return finish_failed( + format!("The staged restore is incomplete ({e}); the restore was skipped."), + None, + ) + } }; if let Err(e) = validate_manifest(&manifest) { return finish_failed(format!("The staged restore is invalid: {e}"), None); } for f in &manifest.files { let first = f.path.split('/').next().unwrap_or(""); - let path = f.path.split('/').fold(dirs.staging_for(first), |p, c| p.join(c)); + let path = f + .path + .split('/') + .fold(dirs.staging_for(first), |p, c| p.join(c)); match fs::metadata(&path) { Ok(m) if m.is_file() && m.len() == f.size => {} - _ => return finish_failed(format!("The staged file {:?} is missing or changed; the restore was skipped.", f.path), None), + _ => { + return finish_failed( + format!( + "The staged file {:?} is missing or changed; the restore was skipped.", + f.path + ), + None, + ) + } } } - if let Err(e) = verify_staged_db(&dirs.staging_for(DB_ENTRY).join(DB_ENTRY), manifest.schema_version) { + if let Err(e) = verify_staged_db( + &dirs.staging_for(DB_ENTRY).join(DB_ENTRY), + manifest.schema_version, + ) { return finish_failed(e, None); } @@ -730,8 +856,14 @@ fn apply_with_fault(dirs: &DataDirs, now: DateTime, fault_at: Option = { let mut v = vec![ (dirs.data.join("voiced.db"), safety.join("voiced.db")), - (dirs.data.join("voiced.db-wal"), safety.join("voiced.db-wal")), - (dirs.data.join("voiced.db-shm"), safety.join("voiced.db-shm")), + ( + dirs.data.join("voiced.db-wal"), + safety.join("voiced.db-wal"), + ), + ( + dirs.data.join("voiced.db-shm"), + safety.join("voiced.db-shm"), + ), ]; for root in ROOTS { v.push((dirs.root_dir(root), safety.join(root))); @@ -754,13 +886,15 @@ fn apply_with_fault(dirs: &DataDirs, now: DateTime, fault_at: Option Vec<(String, PathBuf)> { if let Ok(entries) = fs::read_dir(dir) { for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().into_owned(); - let stamp = name.strip_prefix(AUTO_PREFIX).and_then(|n| n.strip_suffix(AUTO_SUFFIX)); + let stamp = name + .strip_prefix(AUTO_PREFIX) + .and_then(|n| n.strip_suffix(AUTO_SUFFIX)); if let Some(stamp) = stamp { if stamp.len() == 8 && stamp.bytes().all(|b| b.is_ascii_digit()) { found.push((stamp.to_string(), entry.path())); @@ -847,7 +983,10 @@ fn auto_backups(dir: &Path) -> Vec<(String, PathBuf)> { pub fn prune_auto_backups(dir: &Path, keep: usize) -> usize { let all = auto_backups(dir); let excess = all.len().saturating_sub(keep); - all.into_iter().take(excess).filter(|(_, p)| fs::remove_file(p).is_ok()).count() + all.into_iter() + .take(excess) + .filter(|(_, p)| fs::remove_file(p).is_ok()) + .count() } /// Write today's automatic backup unless it already exists, then prune. Returns the new file, if any. @@ -876,7 +1015,13 @@ pub fn run_auto_backup(dirs: &DataDirs, now: DateTime) -> Result bool { Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY) - .and_then(|c| c.query_row("SELECT auto_backup FROM app_settings WHERE id = 1", [], |r| r.get::<_, i64>(0))) + .and_then(|c| { + c.query_row( + "SELECT auto_backup FROM app_settings WHERE id = 1", + [], + |r| r.get::<_, i64>(0), + ) + }) .map(|v| v != 0) .unwrap_or(true) } @@ -887,22 +1032,27 @@ pub fn spawn_auto_backup(dirs: DataDirs) { if std::env::var_os("VOICED_SELFTEST_OUT").is_some() { return; } - let spawned = std::thread::Builder::new().name("auto-backup".into()).spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(8)); - loop { - if read_auto_backup_flag(&dirs.db()) { - let dirs = dirs.clone(); - let outcome = std::panic::catch_unwind(move || run_auto_backup(&dirs, Local::now())); - match outcome { - Ok(Ok(Some(path))) => eprintln!("Automatic backup written to {}", path.display()), - Ok(Ok(None)) => {} - Ok(Err(e)) => eprintln!("Automatic backup failed: {e}"), - Err(_) => eprintln!("Automatic backup panicked"), + let spawned = std::thread::Builder::new() + .name("auto-backup".into()) + .spawn(move || { + std::thread::sleep(std::time::Duration::from_secs(8)); + loop { + if read_auto_backup_flag(&dirs.db()) { + let dirs = dirs.clone(); + let outcome = + std::panic::catch_unwind(move || run_auto_backup(&dirs, Local::now())); + match outcome { + Ok(Ok(Some(path))) => { + eprintln!("Automatic backup written to {}", path.display()) + } + Ok(Ok(None)) => {} + Ok(Err(e)) => eprintln!("Automatic backup failed: {e}"), + Err(_) => eprintln!("Automatic backup panicked"), + } } + std::thread::sleep(std::time::Duration::from_secs(3600)); } - std::thread::sleep(std::time::Duration::from_secs(3600)); - } - }); + }); if let Err(e) = spawned { eprintln!("Could not start the automatic backup thread: {e}"); } @@ -923,14 +1073,20 @@ pub fn backup_status_impl(dirs: &DataDirs, auto_backup: bool) -> BackupStatus { auto_dir: auto_dir.to_string_lossy().into_owned(), last_auto_backup, auto_backup_count: all.len(), - pending_restore: fs::read(dirs.marker()).ok().and_then(|b| serde_json::from_slice(&b).ok()), - last_restore: fs::read(dirs.last_restore()).ok().and_then(|b| serde_json::from_slice(&b).ok()), + pending_restore: fs::read(dirs.marker()) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()), + last_restore: fs::read(dirs.last_restore()) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()), } } /// Milliseconds a relaunched process waits before starting (set by `restart_app`), capped at 10 s. pub fn restart_delay_from_env(value: Option<&str>) -> Option { - value.and_then(|v| v.trim().parse::().ok()).map(|ms| ms.min(10_000)) + value + .and_then(|v| v.trim().parse::().ok()) + .map(|ms| ms.min(10_000)) } /// Call first thing in `run()`: honour and clear the relaunch delay. @@ -946,26 +1102,40 @@ pub fn wait_if_relaunched() { // ---------------------------------------------------------------- Tauri commands fn auto_backup_flag(conn: &Connection) -> Result { - conn.query_row("SELECT auto_backup FROM app_settings WHERE id = 1", [], |r| r.get::<_, i64>(0)) - .map(|v| v != 0) - .map_err(|e| e.to_string()) + conn.query_row( + "SELECT auto_backup FROM app_settings WHERE id = 1", + [], + |r| r.get::<_, i64>(0), + ) + .map(|v| v != 0) + .map_err(|e| e.to_string()) } #[tauri::command] -pub async fn create_backup(state: State<'_, AppState>, dest: String) -> Result { +pub async fn create_backup( + state: State<'_, AppState>, + dest: String, +) -> Result { let dirs = DataDirs::from_state(&state); - tauri::async_runtime::spawn_blocking(move || create_backup_impl(&dirs, Path::new(&dest), Local::now())) - .await - .map_err(|e| e.to_string())? + tauri::async_runtime::spawn_blocking(move || { + create_backup_impl(&dirs, Path::new(&dest), Local::now()) + }) + .await + .map_err(|e| e.to_string())? } /// Phase one of a restore: validate and stage. The app must be restarted to apply it. #[tauri::command] -pub async fn restore_backup(state: State<'_, AppState>, path: String) -> Result { +pub async fn restore_backup( + state: State<'_, AppState>, + path: String, +) -> Result { let dirs = DataDirs::from_state(&state); - tauri::async_runtime::spawn_blocking(move || stage_restore_impl(&dirs, Path::new(&path), Local::now())) - .await - .map_err(|e| e.to_string())? + tauri::async_runtime::spawn_blocking(move || { + stage_restore_impl(&dirs, Path::new(&path), Local::now()) + }) + .await + .map_err(|e| e.to_string())? } #[tauri::command] @@ -985,8 +1155,11 @@ pub fn get_backup_status(state: State) -> Result #[tauri::command] pub fn set_auto_backup(state: State, enabled: bool) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; - conn.execute("UPDATE app_settings SET auto_backup = ?1 WHERE id = 1", [enabled as i64]) - .map_err(|e| e.to_string())?; + conn.execute( + "UPDATE app_settings SET auto_backup = ?1 WHERE id = 1", + [enabled as i64], + ) + .map_err(|e| e.to_string())?; auto_backup_flag(&conn) } @@ -994,7 +1167,8 @@ pub fn set_auto_backup(state: State, enabled: bool) -> Result Result<(), String> { - let exe = std::env::current_exe().map_err(|e| format!("Could not find the application: {e}"))?; + let exe = + std::env::current_exe().map_err(|e| format!("Could not find the application: {e}"))?; std::process::Command::new(exe) .args(std::env::args_os().skip(1)) .env("VOICED_RESTART_DELAY_MS", "1500") @@ -1064,11 +1238,23 @@ mod tests { conn.pragma_update(None, "wal_checkpoint", "TRUNCATE").ok(); drop(conn); fs::create_dir_all(dirs.root_dir("assets")).unwrap(); - fs::write(dirs.root_dir("assets").join(format!("logo-{tag}.png")), LOGO).unwrap(); + fs::write( + dirs.root_dir("assets").join(format!("logo-{tag}.png")), + LOGO, + ) + .unwrap(); fs::create_dir_all(dirs.root_dir("archive")).unwrap(); - fs::write(dirs.root_dir("archive").join(format!("{}.pdf", sha(PDF))), PDF).unwrap(); + fs::write( + dirs.root_dir("archive").join(format!("{}.pdf", sha(PDF))), + PDF, + ) + .unwrap(); fs::create_dir_all(dirs.root_dir("fonts")).unwrap(); - fs::write(dirs.root_dir("fonts").join(format!("{}.ttf", sha(FONT))), FONT).unwrap(); + fs::write( + dirs.root_dir("fonts").join(format!("{}.ttf", sha(FONT))), + FONT, + ) + .unwrap(); } type Snapshot = (Vec, Vec, Vec, Vec, i64); @@ -1088,7 +1274,8 @@ mod tests { } fn snapshot(dirs: &DataDirs) -> Snapshot { - let conn = Connection::open_with_flags(dirs.db(), OpenFlags::SQLITE_OPEN_READ_ONLY).unwrap(); + let conn = + Connection::open_with_flags(dirs.db(), OpenFlags::SQLITE_OPEN_READ_ONLY).unwrap(); ( rows(&conn, "SELECT * FROM clients ORDER BY id"), rows(&conn, "SELECT * FROM invoices ORDER BY id"), @@ -1112,7 +1299,8 @@ mod tests { let mut bytes = Vec::new(); entry.read_to_end(&mut bytes).unwrap(); if let Some(bytes) = edit(&name, bytes) { - out.start_file(&name, zip::write::SimpleFileOptions::default()).unwrap(); + out.start_file(&name, zip::write::SimpleFileOptions::default()) + .unwrap(); out.write_all(&bytes).unwrap(); } } @@ -1128,16 +1316,23 @@ mod tests { created_at: "now".into(), files: listed .iter() - .map(|(p, b)| ManifestFile { path: p.to_string(), sha256: sha(b), size: b.len() as u64 }) + .map(|(p, b)| ManifestFile { + path: p.to_string(), + sha256: sha(b), + size: b.len() as u64, + }) .collect(), }; let mut out = zip::ZipWriter::new(File::create(dst).unwrap()); for (name, bytes) in entries { - out.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); + out.start_file(*name, zip::write::SimpleFileOptions::default()) + .unwrap(); out.write_all(bytes).unwrap(); } - out.start_file(MANIFEST_NAME, zip::write::SimpleFileOptions::default()).unwrap(); - out.write_all(&serde_json::to_vec(&manifest).unwrap()).unwrap(); + out.start_file(MANIFEST_NAME, zip::write::SimpleFileOptions::default()) + .unwrap(); + out.write_all(&serde_json::to_vec(&manifest).unwrap()) + .unwrap(); out.finish().unwrap(); } @@ -1171,7 +1366,10 @@ mod tests { assert_eq!(manifest.schema_version, LATEST_VERSION); let pdf_entry = format!("archive/{}.pdf", sha(PDF)); let listed = manifest.files.iter().find(|f| f.path == pdf_entry).unwrap(); - assert_eq!((listed.sha256.as_str(), listed.size), (sha(PDF).as_str(), PDF.len() as u64)); + assert_eq!( + (listed.sha256.as_str(), listed.size), + (sha(PDF).as_str(), PDF.len() as u64) + ); // A different machine state to restore over: other rows and files, to be set aside. let dst = env(); @@ -1182,29 +1380,73 @@ mod tests { let staged = stage_restore_impl(&dst.dirs, &zip_path, now()).unwrap(); assert_eq!(staged.file_count, 4); - assert_eq!(snapshot(&dst.dirs), before_b, "staging must not touch live data"); - assert_eq!(backup_status_impl(&dst.dirs, true).pending_restore.unwrap().file_count, 4); + assert_eq!( + snapshot(&dst.dirs), + before_b, + "staging must not touch live data" + ); + assert_eq!( + backup_status_impl(&dst.dirs, true) + .pending_restore + .unwrap() + .file_count, + 4 + ); let outcome = apply_pending_restore(&dst.dirs, now()); - let ApplyOutcome::Applied { safety_dir } = outcome else { panic!("{outcome:?}") }; + let ApplyOutcome::Applied { safety_dir } = outcome else { + panic!("{outcome:?}") + }; assert_clean(&dst.dirs); assert_eq!(snapshot(&dst.dirs), before); - assert_eq!(fs::read(dst.dirs.root_dir("assets").join("logo-A.png")).unwrap(), LOGO); + assert_eq!( + fs::read(dst.dirs.root_dir("assets").join("logo-A.png")).unwrap(), + LOGO + ); assert!(!dst.dirs.root_dir("assets").join("only-in-b.png").exists()); - assert_eq!(fs::read(dst.dirs.root_dir("archive").join(format!("{}.pdf", sha(PDF)))).unwrap(), PDF); - assert_eq!(fs::read(dst.dirs.root_dir("fonts").join(format!("{}.ttf", sha(FONT)))).unwrap(), FONT); + assert_eq!( + fs::read( + dst.dirs + .root_dir("archive") + .join(format!("{}.pdf", sha(PDF))) + ) + .unwrap(), + PDF + ); + assert_eq!( + fs::read( + dst.dirs + .root_dir("fonts") + .join(format!("{}.ttf", sha(FONT))) + ) + .unwrap(), + FONT + ); // The old state is intact in the safety copy, not deleted. assert!(safety_dir.starts_with(dst.dirs.backups())); - assert_eq!(fs::read(safety_dir.join("assets").join("only-in-b.png")).unwrap(), b"b"); - let aside = Connection::open_with_flags(safety_dir.join("voiced.db"), OpenFlags::SQLITE_OPEN_READ_ONLY).unwrap(); - assert_eq!(rows(&aside, "SELECT * FROM clients ORDER BY id"), before_b.0); + assert_eq!( + fs::read(safety_dir.join("assets").join("only-in-b.png")).unwrap(), + b"b" + ); + let aside = Connection::open_with_flags( + safety_dir.join("voiced.db"), + OpenFlags::SQLITE_OPEN_READ_ONLY, + ) + .unwrap(); + assert_eq!( + rows(&aside, "SELECT * FROM clients ORDER BY id"), + before_b.0 + ); let last = backup_status_impl(&dst.dirs, true).last_restore.unwrap(); assert!(last.ok); // The restored DB opens normally through the app's own open path. drop(crate::db::open(&dst.dirs.db(), &dst.dirs.backups()).unwrap()); - assert_eq!(apply_pending_restore(&dst.dirs, now()), ApplyOutcome::NothingPending); + assert_eq!( + apply_pending_restore(&dst.dirs, now()), + ApplyOutcome::NothingPending + ); } #[test] @@ -1213,16 +1455,23 @@ mod tests { populate(&src.dirs, "A"); // Keep a connection open (as the running app does) while backing up. let live = crate::db::open(&src.dirs.db(), &src.dirs.backups()).unwrap(); - live.execute("UPDATE clients SET name = 'Live edit'", []).unwrap(); + live.execute("UPDATE clients SET name = 'Live edit'", []) + .unwrap(); let zip_path = src.dirs.backups().join("live.zip"); create_backup_impl(&src.dirs, &zip_path, now()).unwrap(); drop(live); let dst = env(); stage_restore_impl(&dst.dirs, &zip_path, now()).unwrap(); - assert!(matches!(apply_pending_restore(&dst.dirs, now()), ApplyOutcome::Applied { .. })); - let conn = Connection::open_with_flags(dst.dirs.db(), OpenFlags::SQLITE_OPEN_READ_ONLY).unwrap(); - let name: String = conn.query_row("SELECT name FROM clients", [], |r| r.get(0)).unwrap(); + assert!(matches!( + apply_pending_restore(&dst.dirs, now()), + ApplyOutcome::Applied { .. } + )); + let conn = + Connection::open_with_flags(dst.dirs.db(), OpenFlags::SQLITE_OPEN_READ_ONLY).unwrap(); + let name: String = conn + .query_row("SELECT name FROM clients", [], |r| r.get(0)) + .unwrap(); assert_eq!(name, "Live edit"); } @@ -1251,8 +1500,12 @@ mod tests { // A listed file that is missing, and an extra file that is not listed, are both refused. let missing = src.dirs.backups().join("missing.zip"); - rewrite_zip(&good, &missing, |name, bytes| (!name.starts_with("fonts/")).then_some(bytes)); - assert!(stage_restore_impl(&dst.dirs, &missing, now()).unwrap_err().contains("incomplete")); + rewrite_zip(&good, &missing, |name, bytes| { + (!name.starts_with("fonts/")).then_some(bytes) + }); + assert!(stage_restore_impl(&dst.dirs, &missing, now()) + .unwrap_err() + .contains("incomplete")); let extra = src.dirs.backups().join("extra.zip"); let mut input = zip::ZipArchive::new(File::open(&good).unwrap()).unwrap(); let mut out = zip::ZipWriter::new(File::create(&extra).unwrap()); @@ -1260,10 +1513,16 @@ mod tests { let entry = input.by_index_raw(i).unwrap(); out.raw_copy_file(entry).unwrap(); } - out.start_file("assets/sneaky.png", zip::write::SimpleFileOptions::default()).unwrap(); + out.start_file( + "assets/sneaky.png", + zip::write::SimpleFileOptions::default(), + ) + .unwrap(); out.write_all(b"x").unwrap(); out.finish().unwrap(); - assert!(stage_restore_impl(&dst.dirs, &extra, now()).unwrap_err().contains("not in its manifest")); + assert!(stage_restore_impl(&dst.dirs, &extra, now()) + .unwrap_err() + .contains("not in its manifest")); assert_clean(&dst.dirs); } @@ -1271,9 +1530,23 @@ mod tests { fn path_traversal_and_foreign_names_are_rejected() { let dst = env(); let outside = dst.dirs.data.parent().unwrap().join("evil.txt"); - for name in ["../evil.txt", "assets/../../evil.txt", "/tmp/evil.txt", "assets\\..\\evil.txt", "C:/evil.txt", "other/x", "assets/a:b", "assets//x"] { + for name in [ + "../evil.txt", + "assets/../../evil.txt", + "/tmp/evil.txt", + "assets\\..\\evil.txt", + "C:/evil.txt", + "other/x", + "assets/a:b", + "assets//x", + ] { let zip_path = dst.dirs.data.parent().unwrap().join("evil.zip"); - handmade(&zip_path, &[(DB_ENTRY, b"x"), (name, b"x")], &[(DB_ENTRY, b"x"), (name, b"x")], LATEST_VERSION); + handmade( + &zip_path, + &[(DB_ENTRY, b"x"), (name, b"x")], + &[(DB_ENTRY, b"x"), (name, b"x")], + LATEST_VERSION, + ); let err = stage_restore_impl(&dst.dirs, &zip_path, now()).unwrap_err(); assert!(err.contains("Unsafe file name"), "{name}: {err}"); assert!(!outside.exists()); @@ -1281,7 +1554,12 @@ mod tests { } // Names only in the zip (not the manifest) are caught too. let zip_path = dst.dirs.data.parent().unwrap().join("evil2.zip"); - handmade(&zip_path, &[(DB_ENTRY, b"x"), ("../evil.txt", b"x")], &[(DB_ENTRY, b"x")], LATEST_VERSION); + handmade( + &zip_path, + &[(DB_ENTRY, b"x"), ("../evil.txt", b"x")], + &[(DB_ENTRY, b"x")], + LATEST_VERSION, + ); assert!(stage_restore_impl(&dst.dirs, &zip_path, now()).is_err()); assert!(!outside.exists()); } @@ -1295,14 +1573,33 @@ mod tests { app_version: "t".into(), schema_version: LATEST_VERSION, created_at: "now".into(), - files: vec![ManifestFile { path: "assets/link".into(), sha256: sha(b"/etc/passwd"), size: 11 }, ManifestFile { path: DB_ENTRY.into(), sha256: sha(b"x"), size: 1 }], + files: vec![ + ManifestFile { + path: "assets/link".into(), + sha256: sha(b"/etc/passwd"), + size: 11, + }, + ManifestFile { + path: DB_ENTRY.into(), + sha256: sha(b"x"), + size: 1, + }, + ], }; let mut out = zip::ZipWriter::new(File::create(&zip_path).unwrap()); - out.start_file(DB_ENTRY, zip::write::SimpleFileOptions::default()).unwrap(); + out.start_file(DB_ENTRY, zip::write::SimpleFileOptions::default()) + .unwrap(); out.write_all(b"x").unwrap(); - out.add_symlink("assets/link", "/etc/passwd", zip::write::SimpleFileOptions::default()).unwrap(); - out.start_file(MANIFEST_NAME, zip::write::SimpleFileOptions::default()).unwrap(); - out.write_all(&serde_json::to_vec(&manifest).unwrap()).unwrap(); + out.add_symlink( + "assets/link", + "/etc/passwd", + zip::write::SimpleFileOptions::default(), + ) + .unwrap(); + out.start_file(MANIFEST_NAME, zip::write::SimpleFileOptions::default()) + .unwrap(); + out.write_all(&serde_json::to_vec(&manifest).unwrap()) + .unwrap(); out.finish().unwrap(); let err = stage_restore_impl(&dst.dirs, &zip_path, now()).unwrap_err(); assert!(err.contains("symbolic link"), "{err}"); @@ -1314,7 +1611,12 @@ mod tests { let dst = env(); // Manifest says newer. let zip_path = dst.dirs.data.parent().unwrap().join("newer.zip"); - handmade(&zip_path, &[(DB_ENTRY, b"x")], &[(DB_ENTRY, b"x")], LATEST_VERSION + 1); + handmade( + &zip_path, + &[(DB_ENTRY, b"x")], + &[(DB_ENTRY, b"x")], + LATEST_VERSION + 1, + ); let err = stage_restore_impl(&dst.dirs, &zip_path, now()).unwrap_err(); assert!(err.contains("newer version"), "{err}"); assert_clean(&dst.dirs); @@ -1339,7 +1641,10 @@ mod tests { Some(serde_json::to_vec(&m).unwrap()) }); let err = stage_restore_impl(&dst.dirs, &lie, now()).unwrap_err(); - assert!(err.contains("newer version") || err.contains("does not match"), "{err}"); + assert!( + err.contains("newer version") || err.contains("does not match"), + "{err}" + ); assert_clean(&dst.dirs); } @@ -1347,12 +1652,19 @@ mod tests { fn corrupt_database_fails_integrity_and_garbage_zip_is_refused() { let dst = env(); let zip_path = dst.dirs.data.parent().unwrap().join("garbage.zip"); - handmade(&zip_path, &[(DB_ENTRY, b"this is not sqlite")], &[(DB_ENTRY, b"this is not sqlite")], LATEST_VERSION); + handmade( + &zip_path, + &[(DB_ENTRY, b"this is not sqlite")], + &[(DB_ENTRY, b"this is not sqlite")], + LATEST_VERSION, + ); assert!(stage_restore_impl(&dst.dirs, &zip_path, now()).is_err()); assert_clean(&dst.dirs); let not_zip = dst.dirs.data.parent().unwrap().join("x.zip"); fs::write(¬_zip, b"hello").unwrap(); - assert!(stage_restore_impl(&dst.dirs, ¬_zip, now()).unwrap_err().contains("not a valid backup")); + assert!(stage_restore_impl(&dst.dirs, ¬_zip, now()) + .unwrap_err() + .contains("not a valid backup")); } #[test] @@ -1367,10 +1679,17 @@ mod tests { let before_b = snapshot(&dst.dirs); stage_restore_impl(&dst.dirs, &zip_path, now()).unwrap(); // Sabotage a staged file after staging: the size check refuses before anything is moved. - let staged_pdf = dst.dirs.staging_for("archive").join("archive").join(format!("{}.pdf", sha(PDF))); + let staged_pdf = dst + .dirs + .staging_for("archive") + .join("archive") + .join(format!("{}.pdf", sha(PDF))); fs::write(&staged_pdf, b"short").unwrap(); let outcome = apply_pending_restore(&dst.dirs, now()); - assert!(matches!(outcome, ApplyOutcome::Failed { .. }), "{outcome:?}"); + assert!( + matches!(outcome, ApplyOutcome::Failed { .. }), + "{outcome:?}" + ); assert_eq!(snapshot(&dst.dirs), before_b); assert!(dst.dirs.root_dir("assets").join("logo-B.png").exists()); assert_clean(&dst.dirs); @@ -1399,9 +1718,28 @@ mod tests { failed_steps = fault; assert!(message.contains("rolled back"), "{message}"); assert_eq!(snapshot(&dst.dirs), before_b, "fault {fault}"); - assert_eq!(fs::read(dst.dirs.root_dir("assets").join("logo-B.png")).unwrap(), LOGO); - assert_eq!(fs::read(dst.dirs.root_dir("archive").join(format!("{}.pdf", sha(PDF)))).unwrap(), PDF); - assert_eq!(fs::read(dst.dirs.root_dir("fonts").join(format!("{}.ttf", sha(FONT)))).unwrap(), FONT); + assert_eq!( + fs::read(dst.dirs.root_dir("assets").join("logo-B.png")).unwrap(), + LOGO + ); + assert_eq!( + fs::read( + dst.dirs + .root_dir("archive") + .join(format!("{}.pdf", sha(PDF))) + ) + .unwrap(), + PDF + ); + assert_eq!( + fs::read( + dst.dirs + .root_dir("fonts") + .join(format!("{}.ttf", sha(FONT))) + ) + .unwrap(), + FONT + ); assert!(!dst.dirs.root_dir("assets").join("logo-A.png").exists()); assert_clean(&dst.dirs); assert!(!backup_status_impl(&dst.dirs, true).last_restore.unwrap().ok); @@ -1410,7 +1748,10 @@ mod tests { } } // 5 moves aside (db, assets, archive, fonts; no wal/shm after checkpoint) + 4 installs. - assert!(failed_steps >= 8, "only {failed_steps} steps were exercised"); + assert!( + failed_steps >= 8, + "only {failed_steps} steps were exercised" + ); } #[test] @@ -1423,7 +1764,10 @@ mod tests { stage_restore_impl(&dst.dirs, &zip_path, now()).unwrap(); cancel_pending_restore_impl(&dst.dirs).unwrap(); assert_clean(&dst.dirs); - assert_eq!(apply_pending_restore(&dst.dirs, now()), ApplyOutcome::NothingPending); + assert_eq!( + apply_pending_restore(&dst.dirs, now()), + ApplyOutcome::NothingPending + ); } #[test] @@ -1439,12 +1783,19 @@ mod tests { fn auto_retention_keeps_fourteen_and_prunes_the_oldest() { let dir = tempdir().unwrap(); for day in 1..=20 { - fs::write(dir.path().join(format!("voiced-auto-202601{day:02}.zip")), b"z").unwrap(); + fs::write( + dir.path().join(format!("voiced-auto-202601{day:02}.zip")), + b"z", + ) + .unwrap(); } fs::write(dir.path().join("notes.txt"), b"keep me").unwrap(); fs::write(dir.path().join("voiced-auto-bad.zip"), b"keep me too").unwrap(); assert_eq!(prune_auto_backups(dir.path(), AUTO_KEEP), 6); - let names: Vec = auto_backups(dir.path()).into_iter().map(|(s, _)| s).collect(); + let names: Vec = auto_backups(dir.path()) + .into_iter() + .map(|(s, _)| s) + .collect(); assert_eq!(names.len(), 14); assert_eq!(names.first().unwrap(), "20260107"); assert_eq!(names.last().unwrap(), "20260120"); @@ -1461,7 +1812,11 @@ mod tests { use chrono::TimeZone; let first = run_auto_backup(&e.dirs, day(1)).unwrap().unwrap(); assert!(first.ends_with("voiced-auto-20260301.zip")); - assert_eq!(run_auto_backup(&e.dirs, day(1)).unwrap(), None, "second run the same day does nothing"); + assert_eq!( + run_auto_backup(&e.dirs, day(1)).unwrap(), + None, + "second run the same day does nothing" + ); for d in 2..=16 { assert!(run_auto_backup(&e.dirs, day(d)).unwrap().is_some()); } @@ -1492,10 +1847,26 @@ mod tests { #[test] fn entry_paths_allow_only_data_files() { - for ok in ["voiced.db", "assets/logo.png", "archive/ab.pdf", "fonts/x.ttf", "assets/sub/dir/f.png"] { + for ok in [ + "voiced.db", + "assets/logo.png", + "archive/ab.pdf", + "fonts/x.ttf", + "assets/sub/dir/f.png", + ] { validate_entry_path(ok).unwrap(); } - for bad in ["", "voiced.db/", "manifest.json", "backups/x", "assets", "assets/", "assets/./x", "assets/x.", "assets/ "] { + for bad in [ + "", + "voiced.db/", + "manifest.json", + "backups/x", + "assets", + "assets/", + "assets/./x", + "assets/x.", + "assets/ ", + ] { assert!(validate_entry_path(bad).is_err(), "{bad:?}"); } } diff --git a/src-tauri/src/commands/clients.rs b/src-tauri/src/commands/clients.rs index bd79181..263940d 100644 --- a/src-tauri/src/commands/clients.rs +++ b/src-tauri/src/commands/clients.rs @@ -4,9 +4,16 @@ use crate::AppState; use rusqlite::{params, Connection, Row}; use tauri::State; -const CATEGORIES: &[&str] = &["registered_regular", "unregistered", "composition", "sez", "overseas"]; +const CATEGORIES: &[&str] = &[ + "registered_regular", + "unregistered", + "composition", + "sez", + "overseas", +]; -const CLIENT_COLS: &str = "c.id, c.name, c.address, c.gstin, c.state_code, c.po_number, c.created_at, +const CLIENT_COLS: &str = + "c.id, c.name, c.address, c.gstin, c.state_code, c.po_number, c.created_at, c.address_line1, c.address_line2, c.city, c.pincode, c.gst_category, c.default_notes, c.payment_terms_days, (SELECT COUNT(*) FROM invoices i WHERE i.client_id = c.id)"; @@ -33,7 +40,13 @@ fn map_client(row: &Row) -> rusqlite::Result { /// Composes the stored multi-line address from the structured parts: line 1, line 2, then /// "City, State - Pincode". Must stay in step with `composeAddress` in src/lib/clientAddress.ts. -pub fn compose_address(line1: &str, line2: &str, city: &str, state_code: &str, pincode: &str) -> String { +pub fn compose_address( + line1: &str, + line2: &str, + city: &str, + state_code: &str, + pincode: &str, +) -> String { let state_name = gst::STATES .iter() .find(|(c, _)| *c == state_code) @@ -246,7 +259,10 @@ mod tests { c.po_number = "PO-9".into(); let saved = save_client_impl(&conn, c).unwrap(); assert!(saved.id.is_some()); - assert_eq!(saved.address, "12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001"); + assert_eq!( + saved.address, + "12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001" + ); assert_eq!(saved.gstin, "29ABCDE1234F1ZW"); let listed = list_clients_impl(&conn).unwrap(); assert_eq!(listed.len(), 1); @@ -282,7 +298,10 @@ mod tests { edit.name = "B".into(); edit.gst_category = "sez".into(); let again = save_client_impl(&conn, edit).unwrap(); - assert_eq!((again.name.as_str(), again.gst_category.as_str()), ("B", "sez")); + assert_eq!( + (again.name.as_str(), again.gst_category.as_str()), + ("B", "sez") + ); assert_eq!(list_clients_impl(&conn).unwrap().len(), 1); let mut ghost = blank("Ghost"); ghost.id = Some(999); @@ -292,7 +311,9 @@ mod tests { #[test] fn validation_rejects_bad_input() { let conn = open_in_memory().unwrap(); - assert!(save_client_impl(&conn, blank(" ")).unwrap_err().contains("name")); + assert!(save_client_impl(&conn, blank(" ")) + .unwrap_err() + .contains("name")); let mut c = blank("X"); c.gstin = "29ABCDE1234F1Z5".into(); // wrong check digit @@ -342,9 +363,11 @@ mod tests { delete_client_impl(&conn, id).unwrap(); assert!(list_clients_impl(&conn).unwrap().is_empty()); let (name, addr): (String, String) = conn - .query_row("SELECT client_name, client_address FROM invoices WHERE number='T-1'", [], |r| { - Ok((r.get(0)?, r.get(1)?)) - }) + .query_row( + "SELECT client_name, client_address FROM invoices WHERE number='T-1'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) .unwrap(); assert_eq!((name.as_str(), addr.as_str()), ("Referenced", "Snap addr")); } diff --git a/src-tauri/src/commands/erpnext.rs b/src-tauri/src/commands/erpnext.rs index bce5492..3e340bf 100644 --- a/src-tauri/src/commands/erpnext.rs +++ b/src-tauri/src/commands/erpnext.rs @@ -5,8 +5,12 @@ //! never held across a network call. use crate::integrations::erpnext::client::ErpClient; -use crate::integrations::erpnext::config::{self, ErpnextConfig, ErpnextConfigInput, ErpnextConfigView}; -use crate::integrations::erpnext::discovery::{self, ic_number_ok, ConnectionTest, ErpnextOptions, LocalFacts}; +use crate::integrations::erpnext::config::{ + self, ErpnextConfig, ErpnextConfigInput, ErpnextConfigView, +}; +use crate::integrations::erpnext::discovery::{ + self, ic_number_ok, ConnectionTest, ErpnextOptions, LocalFacts, +}; use crate::integrations::erpnext::push::{self, PaymentPushResult, PushResult}; use crate::integrations::SyncStatus; use crate::AppState; @@ -18,12 +22,18 @@ pub fn get_config_impl(conn: &Connection) -> Result { } /// Merges the form with the stored row (blank secret keeps the stored one) without saving. -pub fn resolve_config(conn: &Connection, input: ErpnextConfigInput) -> Result { +pub fn resolve_config( + conn: &Connection, + input: ErpnextConfigInput, +) -> Result { let stored = config::load(conn)?; input.resolve(&stored) } -pub fn save_config_impl(conn: &Connection, input: ErpnextConfigInput) -> Result { +pub fn save_config_impl( + conn: &Connection, + input: ErpnextConfigInput, +) -> Result { let cfg = resolve_config(conn, input)?; config::save(conn, &cfg)?; Ok(cfg.view()) @@ -32,7 +42,11 @@ pub fn save_config_impl(conn: &Connection, input: ErpnextConfigInput) -> Result< /// What the connection test needs from the local database. pub fn local_facts(conn: &Connection) -> Result { let registration: String = conn - .query_row("SELECT gst_registration FROM app_settings WHERE id = 1", [], |r| r.get(0)) + .query_row( + "SELECT gst_registration FROM app_settings WHERE id = 1", + [], + |r| r.get(0), + ) .map_err(|e| e.to_string())?; let mut stmt = conn .prepare("SELECT number FROM invoices ORDER BY id") @@ -54,7 +68,11 @@ pub fn local_facts(conn: &Connection) -> Result { ) .ok() .map(|(prefix, padding, next)| crate::db::format_number(&prefix, padding, next)); - Ok(LocalFacts { vendor_registered: registration != "unregistered", invalid_numbers, next_number }) + Ok(LocalFacts { + vendor_registered: registration != "unregistered", + invalid_numbers, + next_number, + }) } #[tauri::command] @@ -65,7 +83,10 @@ pub fn erpnext_get_config(state: State) -> Result, config: ErpnextConfigInput) -> Result { +pub fn erpnext_save_config( + state: State, + config: ErpnextConfigInput, +) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; save_config_impl(&conn, config) } @@ -105,7 +126,10 @@ fn saved_client(state: &AppState) -> Result { config::load(&conn)? }; if cfg.base_url.trim().is_empty() || cfg.api_key.trim().is_empty() || !cfg.api_secret.is_set() { - return Err("ERPNext is not set up yet: enter the address, API key and API secret in Settings.".into()); + return Err( + "ERPNext is not set up yet: enter the address, API key and API secret in Settings." + .into(), + ); } Ok(ErpClient::from_config(&cfg)?) } @@ -134,7 +158,10 @@ pub async fn erpnext_push_invoices( } #[tauri::command] -pub async fn erpnext_push_payment(state: State<'_, AppState>, payment_id: i64) -> Result { +pub async fn erpnext_push_payment( + state: State<'_, AppState>, + payment_id: i64, +) -> Result { let http = saved_client(&state)?; Ok(push::push_payment(&state.db, &http, payment_id).await) } @@ -165,15 +192,22 @@ mod tests { use serde_json::json; fn input(extra: serde_json::Value) -> ErpnextConfigInput { - let mut base = json!({ "baseUrl": "https://erp.example.com", "apiKey": "key1", "company": "Test Co" }); - base.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + let mut base = + json!({ "baseUrl": "https://erp.example.com", "apiKey": "key1", "company": "Test Co" }); + base.as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); serde_json::from_value(base).unwrap() } #[test] fn saved_config_is_returned_without_the_secret() { let conn = crate::db::open_in_memory().unwrap(); - let view = save_config_impl(&conn, input(json!({ "apiSecret": "super-secret-value", "submitOnPush": true }))).unwrap(); + let view = save_config_impl( + &conn, + input(json!({ "apiSecret": "super-secret-value", "submitOnPush": true })), + ) + .unwrap(); assert!(view.api_secret_set); let shown = serde_json::to_string(&get_config_impl(&conn).unwrap()).unwrap(); assert!(!shown.contains("super-secret-value")); @@ -190,7 +224,8 @@ mod tests { #[test] fn test_values_are_resolved_without_saving() { let conn = crate::db::open_in_memory().unwrap(); - let resolved = resolve_config(&conn, input(json!({ "apiSecret": "typed-not-saved" }))).unwrap(); + let resolved = + resolve_config(&conn, input(json!({ "apiSecret": "typed-not-saved" }))).unwrap(); assert_eq!(resolved.api_secret.expose(), "typed-not-saved"); assert!(!get_config_impl(&conn).unwrap().api_secret_set); } @@ -203,7 +238,8 @@ mod tests { assert!(facts.invalid_numbers.is_empty()); assert!(facts.next_number.is_some()); - conn.execute("UPDATE app_settings SET gst_registration = 'regular'", []).unwrap(); + conn.execute("UPDATE app_settings SET gst_registration = 'regular'", []) + .unwrap(); conn.execute( "INSERT INTO invoices (number, invoice_date, created_at, updated_at) VALUES ('INV/2026-001', '2026-04-01', 'n', 'n'), ('INVOICE/2026/000001', '2026-04-01', 'n', 'n')", diff --git a/src-tauri/src/commands/files.rs b/src-tauri/src/commands/files.rs index 39bb9e6..a3f5df4 100644 --- a/src-tauri/src/commands/files.rs +++ b/src-tauri/src/commands/files.rs @@ -1,6 +1,8 @@ //! User-visible files: exports written to a path from the save dialog, and the //! reveal/open helpers used afterwards. -use super::raw::{decode_header_path, header_map, raw_body, required_header, write_atomic, Headers}; +use super::raw::{ + decode_header_path, header_map, raw_body, required_header, write_atomic, Headers, +}; use crate::AppState; use rusqlite::{params, Connection}; use std::path::{Path, PathBuf}; @@ -25,7 +27,9 @@ pub fn write_export_file_impl( if bytes.is_empty() { return Err("Nothing to write: the file is empty".to_string()); } - let parent = path.parent().ok_or_else(|| "The export path has no folder".to_string())?; + let parent = path + .parent() + .ok_or_else(|| "The export path has no folder".to_string())?; std::fs::create_dir_all(parent) .map_err(|e| format!("Could not create {}: {e}", parent.display()))?; write_atomic(&path, bytes)?; @@ -38,8 +42,12 @@ pub fn write_export_file_impl( } pub fn get_last_export_dir_impl(conn: &Connection) -> Result { - conn.query_row("SELECT last_export_dir FROM app_settings WHERE id = 1", [], |r| r.get(0)) - .map_err(|e| e.to_string()) + conn.query_row( + "SELECT last_export_dir FROM app_settings WHERE id = 1", + [], + |r| r.get(0), + ) + .map_err(|e| e.to_string()) } fn require_existing(path: &str) -> Result { @@ -71,7 +79,11 @@ fn reveal_fallback_command(path: &Path) -> Command { } #[cfg(not(any(target_os = "windows", target_os = "macos")))] { - let folder = if path.is_dir() { path } else { path.parent().unwrap_or(path) }; + let folder = if path.is_dir() { + path + } else { + path.parent().unwrap_or(path) + }; let mut cmd = Command::new("xdg-open"); cmd.arg(folder); cmd @@ -90,7 +102,9 @@ fn open_fallback_command(path: &Path) -> Command { } fn spawn_detached(mut cmd: Command) -> Result<(), String> { - let mut child = cmd.spawn().map_err(|e| format!("Could not start the file manager: {e}"))?; + let mut child = cmd + .spawn() + .map_err(|e| format!("Could not start the file manager: {e}"))?; // Reap the child so it does not linger as a zombie; the result is irrelevant // (explorer.exe, for one, exits non-zero even on success). std::thread::spawn(move || { @@ -100,7 +114,10 @@ fn spawn_detached(mut cmd: Command) -> Result<(), String> { } #[tauri::command] -pub async fn write_export_file(request: Request<'_>, state: State<'_, AppState>) -> Result { +pub async fn write_export_file( + request: Request<'_>, + state: State<'_, AppState>, +) -> Result { let bytes = raw_body(&request)?; let headers = header_map(&request); let conn = state.db.lock().map_err(|e| e.to_string())?; @@ -125,7 +142,11 @@ pub fn reveal_in_folder(app: tauri::AppHandle, path: String) -> Result<(), Strin #[tauri::command] pub fn open_file(app: tauri::AppHandle, path: String) -> Result<(), String> { let path = require_existing(&path)?; - if app.opener().open_path(path.to_string_lossy(), None::<&str>).is_ok() { + if app + .opener() + .open_path(path.to_string_lossy(), None::<&str>) + .is_ok() + { return Ok(()); } spawn_detached(open_fallback_command(&path)) @@ -141,8 +162,11 @@ mod tests { } fn encode(path: &Path) -> String { - percent_encoding::utf8_percent_encode(&path.to_string_lossy(), percent_encoding::NON_ALPHANUMERIC) - .to_string() + percent_encoding::utf8_percent_encode( + &path.to_string_lossy(), + percent_encoding::NON_ALPHANUMERIC, + ) + .to_string() } #[test] @@ -152,8 +176,12 @@ mod tests { assert_eq!(get_last_export_dir_impl(&conn).unwrap(), ""); // Non-ASCII, spaces and a folder that does not exist yet. - let target = dir.path().join("New folder").join("\u{9ac}\u{9be}\u{982}\u{9b2}\u{9be} 1.pdf"); - let written = write_export_file_impl(&conn, b"%PDF-1.7", &headers(&encode(&target))).unwrap(); + let target = dir + .path() + .join("New folder") + .join("\u{9ac}\u{9be}\u{982}\u{9b2}\u{9be} 1.pdf"); + let written = + write_export_file_impl(&conn, b"%PDF-1.7", &headers(&encode(&target))).unwrap(); assert_eq!(PathBuf::from(&written), target); assert_eq!(std::fs::read(&target).unwrap(), b"%PDF-1.7"); assert_eq!( @@ -170,7 +198,10 @@ mod tests { fn rejects_empty_relative_and_missing_paths() { let conn = crate::db::open_in_memory().unwrap(); for bad in ["", " ", "out.pdf", "sub%2Fout.pdf", "..%2Fout.pdf"] { - assert!(write_export_file_impl(&conn, b"x", &headers(bad)).is_err(), "{bad}"); + assert!( + write_export_file_impl(&conn, b"x", &headers(bad)).is_err(), + "{bad}" + ); } assert!(write_export_file_impl(&conn, b"x", &Headers::new()).is_err()); assert_eq!(get_last_export_dir_impl(&conn).unwrap(), ""); @@ -191,7 +222,9 @@ mod tests { let dir = tempdir().unwrap(); assert!(require_existing("").is_err()); let missing = dir.path().join("nope.pdf"); - assert!(require_existing(&missing.to_string_lossy()).unwrap_err().contains("does not exist")); + assert!(require_existing(&missing.to_string_lossy()) + .unwrap_err() + .contains("does not exist")); assert!(require_existing(&dir.path().to_string_lossy()).is_ok()); } diff --git a/src-tauri/src/commands/fonts.rs b/src-tauri/src/commands/fonts.rs index 6af13a0..fb8c046 100644 --- a/src-tauri/src/commands/fonts.rs +++ b/src-tauri/src/commands/fonts.rs @@ -9,7 +9,9 @@ //! (`fvar`, `CFF2`: only the default instance would embed), files without outlines, and fonts whose OS/2 `fsType` //! forbids embedding. The sfnt is parsed by hand (no font crate is available offline); only the tables needed //! to describe the font are read. -use super::raw::{decode_header_path, header_map, raw_body, required_header, write_atomic, Headers}; +use super::raw::{ + decode_header_path, header_map, raw_body, required_header, write_atomic, Headers, +}; use crate::AppState; use flate2::read::ZlibDecoder; use rusqlite::{params, Connection, OptionalExtension}; @@ -119,7 +121,10 @@ impl<'a> Sfnt<'a> { let off = be32(data, rec + 8).ok_or("The font file is truncated")? as usize; let len = be32(data, rec + 12).ok_or("The font file is truncated")? as usize; if off.checked_add(len).map_or(true, |end| end > data.len()) { - return Err(format!("Table '{}' lies outside the file", String::from_utf8_lossy(&tag))); + return Err(format!( + "Table '{}' lies outside the file", + String::from_utf8_lossy(&tag) + )); } tables.push((tag, off, len)); } @@ -135,7 +140,10 @@ impl<'a> Sfnt<'a> { } fn utf16be(b: &[u8]) -> String { - let units: Vec = b.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); + let units: Vec = b + .chunks_exact(2) + .map(|c| u16::from_be_bytes([c[0], c[1]])) + .collect(); String::from_utf16_lossy(&units) } @@ -201,7 +209,11 @@ impl<'a> Cmap<'a> { best = Some((rank, offset, format)); } } - best.map(|(_, offset, format)| Self { table, offset, format }) + best.map(|(_, offset, format)| Self { + table, + offset, + format, + }) } fn has(&self, cp: u32) -> bool { @@ -283,7 +295,9 @@ fn woff_to_sfnt(data: &[u8]) -> Result, String> { let comp = be32(data, rec + 8).ok_or_else(bad)? as usize; let orig = be32(data, rec + 12).ok_or_else(bad)? as usize; let checksum = be32(data, rec + 16).ok_or_else(bad)?; - let stored = data.get(off..off.checked_add(comp).ok_or_else(bad)?).ok_or_else(bad)?; + let stored = data + .get(off..off.checked_add(comp).ok_or_else(bad)?) + .ok_or_else(bad)?; total += (orig + 3) & !3; if orig > MAX_FONT_BYTES || total > MAX_FONT_BYTES { return Err("The font expands to more than the 16 MB limit".into()); @@ -293,7 +307,10 @@ fn woff_to_sfnt(data: &[u8]) -> Result, String> { } else if comp < orig { let mut out = Vec::with_capacity(orig); // One byte over the stated size detects a table that lies about it, without trusting it for allocation. - ZlibDecoder::new(stored).take(orig as u64 + 1).read_to_end(&mut out).map_err(|_| bad())?; + ZlibDecoder::new(stored) + .take(orig as u64 + 1) + .read_to_end(&mut out) + .map_err(|_| bad())?; if out.len() != orig { return Err(bad()); } @@ -301,7 +318,11 @@ fn woff_to_sfnt(data: &[u8]) -> Result, String> { } else { return Err(bad()); }; - entries.push(Entry { tag, checksum, bytes }); + entries.push(Entry { + tag, + checksum, + bytes, + }); } entries.sort_by_key(|e| e.tag); @@ -372,12 +393,18 @@ pub fn inspect_bytes(input: &[u8]) -> Result<(Vec, FontInspection), String> } let cff = font.table(b"CFF ").is_some(); if !cff && (font.table(b"glyf").is_none() || font.table(b"loca").is_none()) { - return Err("The font has no glyph outlines (no glyf or CFF table), so it cannot be embedded.".into()); + return Err( + "The font has no glyph outlines (no glyf or CFF table), so it cannot be embedded." + .into(), + ); } let head = font.table(b"head").ok_or("The font has no head table")?; let cmap_table = font.table(b"cmap").ok_or("The font has no cmap table")?; let name = font.table(b"name").ok_or("The font has no name table")?; - let num_glyphs = font.table(b"maxp").and_then(|m| be16(m, 4)).ok_or("The font has no usable maxp table")?; + let num_glyphs = font + .table(b"maxp") + .and_then(|m| be16(m, 4)) + .ok_or("The font has no usable maxp table")?; let os2 = font.table(b"OS/2"); let fs_type = os2.and_then(|t| be16(t, 8)).unwrap_or(0); @@ -413,7 +440,11 @@ pub fn inspect_bytes(input: &[u8]) -> Result<(Vec, FontInspection), String> let fs_selection = os2.and_then(|t| be16(t, 62)).unwrap_or(0); let mac_style = be16(head, 44).unwrap_or(0); // fsSelection bit 0 = italic, bit 9 = oblique; head.macStyle bit 1 = italic. - let italic = if os2.map_or(false, |t| t.len() >= 64) { fs_selection & 0x0201 != 0 } else { mac_style & 0x2 != 0 }; + let italic = if os2.map_or(false, |t| t.len() >= 64) { + fs_selection & 0x0201 != 0 + } else { + mac_style & 0x2 != 0 + }; let cmap = Cmap::new(cmap_table).ok_or("The font has no usable Unicode character map")?; let has_rupee = cmap.has(0x20B9); @@ -428,9 +459,14 @@ pub fn inspect_bytes(input: &[u8]) -> Result<(Vec, FontInspection), String> warnings.push("The font has no rupee sign (\u{20B9}); amounts fall back to IBM Plex Sans for that glyph.".to_string()); } - let family = name_string(name, 16).or_else(|| name_string(name, 1)).unwrap_or_default(); - let subfamily = name_string(name, 17).or_else(|| name_string(name, 2)).unwrap_or_default(); - let full_name = name_string(name, 4).unwrap_or_else(|| format!("{family} {subfamily}").trim().to_string()); + let family = name_string(name, 16) + .or_else(|| name_string(name, 1)) + .unwrap_or_default(); + let subfamily = name_string(name, 17) + .or_else(|| name_string(name, 2)) + .unwrap_or_default(); + let full_name = + name_string(name, 4).unwrap_or_else(|| format!("{family} {subfamily}").trim().to_string()); let postscript_name = name_string(name, 6).unwrap_or_default(); if family.is_empty() && full_name.is_empty() { return Err("The font has no readable name".into()); @@ -460,7 +496,10 @@ pub fn inspect_bytes(input: &[u8]) -> Result<(Vec, FontInspection), String> // storage fn font_path(local_dir: &Path, sha256: &str, format: &str) -> Result { - let valid = sha256.len() == 64 && sha256.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')); + let valid = sha256.len() == 64 + && sha256 + .bytes() + .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')); if !valid { return Err("The stored font hash is malformed".to_string()); } @@ -493,19 +532,31 @@ fn map_row(r: &rusqlite::Row) -> rusqlite::Result { } fn get_font(conn: &Connection, id: i64) -> Result, String> { - conn.query_row(&format!("SELECT {COLS} FROM user_fonts WHERE id = ?1"), params![id], map_row) - .optional() - .map_err(|e| e.to_string()) + conn.query_row( + &format!("SELECT {COLS} FROM user_fonts WHERE id = ?1"), + params![id], + map_row, + ) + .optional() + .map_err(|e| e.to_string()) } -pub fn list_user_fonts_impl(conn: &Connection, include_hidden: bool) -> Result, String> { +pub fn list_user_fonts_impl( + conn: &Connection, + include_hidden: bool, +) -> Result, String> { let sql = format!( "SELECT {COLS} FROM user_fonts {} ORDER BY face, weight, style, id", - if include_hidden { "" } else { "WHERE hidden = 0" } + if include_hidden { + "" + } else { + "WHERE hidden = 0" + } ); let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; let rows = stmt.query_map([], map_row).map_err(|e| e.to_string())?; - rows.collect::, _>>().map_err(|e| e.to_string()) + rows.collect::, _>>() + .map_err(|e| e.to_string()) } /// Whether any invoice's frozen render prefs name this font file. @@ -531,7 +582,11 @@ pub struct ImportRequest<'a> { fn clean_file_name(raw: &str) -> String { let base = raw.rsplit(['/', '\\']).next().unwrap_or(raw); let cleaned: String = base.chars().filter(|c| !c.is_control()).take(120).collect(); - if cleaned.trim().is_empty() { "font".to_string() } else { cleaned } + if cleaned.trim().is_empty() { + "font".to_string() + } else { + cleaned + } } pub fn import_font_impl( @@ -541,7 +596,10 @@ pub fn import_font_impl( req: &ImportRequest, ) -> Result { if !FACES.contains(&req.face) { - return Err(format!("Fonts can be imported for {} only", FACES.join(", "))); + return Err(format!( + "Fonts can be imported for {} only", + FACES.join(", ") + )); } if !req.licence_acknowledged { return Err("Confirm that you hold a licence for this font before importing it".into()); @@ -567,7 +625,9 @@ pub fn import_font_impl( let path = font_path(local_dir, &info.sha256, &info.format)?; std::fs::create_dir_all(path.parent().expect("font path has a parent")) .map_err(|e| format!("Could not create the fonts folder: {e}"))?; - let present = std::fs::metadata(&path).map(|m| m.len() == sfnt.len() as u64).unwrap_or(false); + let present = std::fs::metadata(&path) + .map(|m| m.len() == sfnt.len() as u64) + .unwrap_or(false); if !present { write_atomic(&path, &sfnt)?; } @@ -628,12 +688,21 @@ pub fn import_font_impl( fn retire(conn: &Connection, local_dir: &Path, id: i64) -> Result, String> { let font = get_font(conn, id)?.ok_or_else(|| "Font not found".to_string())?; if referenced_by_invoice(conn, &font.sha256)? { - conn.execute("UPDATE user_fonts SET hidden = 1 WHERE id = ?1", params![id]).map_err(|e| e.to_string())?; + conn.execute( + "UPDATE user_fonts SET hidden = 1 WHERE id = ?1", + params![id], + ) + .map_err(|e| e.to_string())?; return Ok(None); } - conn.execute("DELETE FROM user_fonts WHERE id = ?1", params![id]).map_err(|e| e.to_string())?; + conn.execute("DELETE FROM user_fonts WHERE id = ?1", params![id]) + .map_err(|e| e.to_string())?; let shared: i64 = conn - .query_row("SELECT COUNT(*) FROM user_fonts WHERE sha256 = ?1", params![font.sha256], |r| r.get(0)) + .query_row( + "SELECT COUNT(*) FROM user_fonts WHERE sha256 = ?1", + params![font.sha256], + |r| r.get(0), + ) .map_err(|e| e.to_string())?; if shared > 0 { return Ok(None); @@ -641,11 +710,19 @@ fn retire(conn: &Connection, local_dir: &Path, id: i64) -> Result Result { +pub fn remove_user_font_impl( + conn: &mut Connection, + local_dir: &Path, + id: i64, +) -> Result { let tx = conn.transaction().map_err(|e| e.to_string())?; let file = retire(&tx, local_dir, id)?; let still_there: bool = tx - .query_row("SELECT EXISTS(SELECT 1 FROM user_fonts WHERE id = ?1)", params![id], |r| r.get::<_, i64>(0)) + .query_row( + "SELECT EXISTS(SELECT 1 FROM user_fonts WHERE id = ?1)", + params![id], + |r| r.get::<_, i64>(0), + ) .map_err(|e| e.to_string())? != 0; tx.commit().map_err(|e| e.to_string())?; @@ -653,13 +730,24 @@ pub fn remove_user_font_impl(conn: &mut Connection, local_dir: &Path, id: i64) - // Best effort, as in import: the row is already gone. let _ = std::fs::remove_file(path); } - Ok(RemoveOutcome { deleted: !still_there, hidden: still_there }) + Ok(RemoveOutcome { + deleted: !still_there, + hidden: still_there, + }) } /// The stored sfnt for a font file, hidden or not (an issued invoice must still be able to render with it). -pub fn user_font_bytes_impl(conn: &Connection, local_dir: &Path, sha256: &str) -> Result, String> { +pub fn user_font_bytes_impl( + conn: &Connection, + local_dir: &Path, + sha256: &str, +) -> Result, String> { let format: Option = conn - .query_row("SELECT format FROM user_fonts WHERE sha256 = ?1 LIMIT 1", params![sha256], |r| r.get(0)) + .query_row( + "SELECT format FROM user_fonts WHERE sha256 = ?1 LIMIT 1", + params![sha256], + |r| r.get(0), + ) .optional() .map_err(|e| e.to_string())?; let format = format.ok_or_else(|| "This font is not in the font library".to_string())?; @@ -677,20 +765,43 @@ pub fn user_font_bytes_impl(conn: &Connection, local_dir: &Path, sha256: &str) - Ok(bytes) } -fn parse_import_headers<'a>(headers: &'a Headers, file_name: &'a str) -> Result, String> { +fn parse_import_headers<'a>( + headers: &'a Headers, + file_name: &'a str, +) -> Result, String> { let face = required_header(headers, "x-face")?; - let weight = match headers.get("x-weight").map(|v| v.trim()).filter(|v| !v.is_empty()) { + let weight = match headers + .get("x-weight") + .map(|v| v.trim()) + .filter(|v| !v.is_empty()) + { None => None, - Some(v) => Some(v.parse::().map_err(|_| "x-weight must be a number".to_string())?), + Some(v) => Some( + v.parse::() + .map_err(|_| "x-weight must be a number".to_string())?, + ), }; - let italic = match headers.get("x-style").map(|v| v.trim()).filter(|v| !v.is_empty()) { + let italic = match headers + .get("x-style") + .map(|v| v.trim()) + .filter(|v| !v.is_empty()) + { None => None, Some("normal") => Some(false), Some("italic") => Some(true), Some(_) => return Err("x-style must be normal or italic".into()), }; - let licence_acknowledged = headers.get("x-licence-ack").map(|v| v.trim() == "true").unwrap_or(false); - Ok(ImportRequest { face, file_name, weight, italic, licence_acknowledged }) + let licence_acknowledged = headers + .get("x-licence-ack") + .map(|v| v.trim() == "true") + .unwrap_or(false); + Ok(ImportRequest { + face, + file_name, + weight, + italic, + licence_acknowledged, + }) } // The commands are async so parsing, hashing and file I/O of a multi-MB font stay off the main thread. @@ -701,10 +812,18 @@ pub async fn inspect_font(request: Request<'_>) -> Result, state: State<'_, AppState>) -> Result { +pub async fn import_font( + request: Request<'_>, + state: State<'_, AppState>, +) -> Result { let bytes = raw_body(&request)?; let headers = header_map(&request); - let file_name = decode_header_path(headers.get("x-file-name").map(String::as_str).unwrap_or("font"))?; + let file_name = decode_header_path( + headers + .get("x-file-name") + .map(String::as_str) + .unwrap_or("font"), + )?; let face = decode_header_path(required_header(&headers, "x-face")?)?; let mut headers = headers; headers.insert("x-face".to_string(), face); @@ -714,19 +833,28 @@ pub async fn import_font(request: Request<'_>, state: State<'_, AppState>) -> Re } #[tauri::command] -pub async fn list_user_fonts(include_hidden: Option, state: State<'_, AppState>) -> Result, String> { +pub async fn list_user_fonts( + include_hidden: Option, + state: State<'_, AppState>, +) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; list_user_fonts_impl(&conn, include_hidden.unwrap_or(false)) } #[tauri::command] -pub async fn get_user_font_bytes(sha256: String, state: State<'_, AppState>) -> Result { +pub async fn get_user_font_bytes( + sha256: String, + state: State<'_, AppState>, +) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; user_font_bytes_impl(&conn, &state.local_data_dir, &sha256).map(Response::new) } #[tauri::command] -pub async fn remove_user_font(id: i64, state: State<'_, AppState>) -> Result { +pub async fn remove_user_font( + id: i64, + state: State<'_, AppState>, +) -> Result { let mut conn = state.db.lock().map_err(|e| e.to_string())?; remove_user_font_impl(&mut conn, &state.local_data_dir, id) } @@ -740,7 +868,9 @@ mod tests { use tempfile::tempdir; fn jost(name: &str) -> Vec { - let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../public/fonts/jost").join(name); + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../public/fonts/jost") + .join(name); std::fs::read(path).unwrap() } fn regular() -> Vec { @@ -781,7 +911,11 @@ mod tests { let mut enc = ZlibEncoder::new(Vec::new(), Compression::default()); enc.write_all(raw).unwrap(); let z = enc.finish().unwrap(); - blobs.push(if z.len() < raw.len() { (*tag, z, raw.len()) } else { (*tag, raw.to_vec(), raw.len()) }); + blobs.push(if z.len() < raw.len() { + (*tag, z, raw.len()) + } else { + (*tag, raw.to_vec(), raw.len()) + }); } let mut out = vec![0u8; 44 + 20 * n]; out[0..4].copy_from_slice(b"wOFF"); @@ -803,7 +937,13 @@ mod tests { } fn request<'a>(face: &'a str, ack: bool) -> ImportRequest<'a> { - ImportRequest { face, file_name: "C:\\fonts\\Jost-Regular.ttf", weight: None, italic: None, licence_acknowledged: ack } + ImportRequest { + face, + file_name: "C:\\fonts\\Jost-Regular.ttf", + weight: None, + italic: None, + licence_acknowledged: ack, + } } #[test] @@ -821,9 +961,17 @@ mod tests { assert_eq!(info.sha256, sha256_hex(®ular())); assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings); assert!(info.warnings[0].contains("rupee")); - let poppins = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../public/fonts/poppins/Poppins-Regular.ttf")).unwrap(); + let poppins = std::fs::read( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../public/fonts/poppins/Poppins-Regular.ttf"), + ) + .unwrap(); let (_, with_rupee) = inspect_bytes(&poppins).unwrap(); - assert!(with_rupee.has_rupee && with_rupee.warnings.is_empty(), "{:?}", with_rupee.warnings); + assert!( + with_rupee.has_rupee && with_rupee.warnings.is_empty(), + "{:?}", + with_rupee.warnings + ); assert_eq!(inspect_bytes(&bold()).unwrap().1.weight, 700); } @@ -835,7 +983,9 @@ mod tests { let mut ttc = regular(); ttc[0..4].copy_from_slice(b"ttcf"); assert!(inspect_bytes(&ttc).unwrap_err().contains("collections")); - assert!(inspect_bytes(b"").unwrap_err().contains("not a TrueType")); + assert!(inspect_bytes(b"") + .unwrap_err() + .contains("not a TrueType")); assert!(inspect_bytes(b"short").is_err()); let mut big = regular(); big.resize(MAX_FONT_BYTES + 1, 0); @@ -856,7 +1006,9 @@ mod tests { fn fs_type_rules() { let mut restricted = regular(); set_fs_type(&mut restricted, FS_RESTRICTED); - assert!(inspect_bytes(&restricted).unwrap_err().contains("restricted")); + assert!(inspect_bytes(&restricted) + .unwrap_err() + .contains("restricted")); // A more permissive bit alongside wins (least restrictive). let mut both = regular(); @@ -865,11 +1017,21 @@ mod tests { let mut preview = regular(); set_fs_type(&mut preview, FS_PREVIEW_PRINT); - assert!(inspect_bytes(&preview).unwrap().1.warnings.iter().any(|w| w.contains("preview and print"))); + assert!(inspect_bytes(&preview) + .unwrap() + .1 + .warnings + .iter() + .any(|w| w.contains("preview and print"))); let mut no_subset = regular(); set_fs_type(&mut no_subset, FS_NO_SUBSETTING); - assert!(inspect_bytes(&no_subset).unwrap().1.warnings.iter().any(|w| w.contains("subset"))); + assert!(inspect_bytes(&no_subset) + .unwrap() + .1 + .warnings + .iter() + .any(|w| w.contains("subset"))); let mut bitmap = regular(); set_fs_type(&mut bitmap, FS_BITMAP_ONLY); @@ -880,13 +1042,18 @@ mod tests { fn refuses_a_font_without_outlines() { let mut font = regular(); rename_table(&mut font, b"glyf", b"XXXX"); - assert!(inspect_bytes(&font).unwrap_err().contains("no glyph outlines")); + assert!(inspect_bytes(&font) + .unwrap_err() + .contains("no glyph outlines")); } #[test] fn unpacks_woff_to_the_same_font() { let woff = to_woff(®ular()); - assert!(woff.len() < regular().len(), "the fixture should actually compress"); + assert!( + woff.len() < regular().len(), + "the fixture should actually compress" + ); let (sfnt, info) = inspect_bytes(&woff).unwrap(); assert_eq!(info.source_format, "woff"); assert_eq!(info.family, "Jost"); @@ -897,7 +1064,12 @@ mod tests { let unpacked = Sfnt::parse(&sfnt).unwrap(); assert_eq!(original.tables.len(), unpacked.tables.len()); for (tag, _, _) in &original.tables { - assert_eq!(original.table(tag), unpacked.table(tag), "table {}", String::from_utf8_lossy(tag)); + assert_eq!( + original.table(tag), + unpacked.table(tag), + "table {}", + String::from_utf8_lossy(tag) + ); } let mut damaged = woff.clone(); let len = damaged.len(); @@ -909,17 +1081,28 @@ mod tests { fn import_stores_content_addressed_and_round_trips() { let dir = tempdir().unwrap(); let mut conn = crate::db::open_in_memory().unwrap(); - let row = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap(); + let row = + import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap(); assert_eq!(row.face, "Gotham"); - assert_eq!((row.weight, row.style.as_str(), row.format.as_str()), (400, "normal", "ttf")); - assert_eq!(row.file_name, "Jost-Regular.ttf", "only the base name is kept"); + assert_eq!( + (row.weight, row.style.as_str(), row.format.as_str()), + (400, "normal", "ttf") + ); + assert_eq!( + row.file_name, "Jost-Regular.ttf", + "only the base name is kept" + ); assert!(!row.hidden && row.licence_ack_at.len() > 10); let path = dir.path().join("fonts").join(format!("{}.ttf", row.sha256)); assert_eq!(std::fs::read(&path).unwrap(), regular()); - assert_eq!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap(), regular()); + assert_eq!( + user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap(), + regular() + ); // Importing the same file again is a no-op. - let again = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap(); + let again = + import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap(); assert_eq!(again.id, row.id); assert_eq!(list_user_fonts_impl(&conn, false).unwrap().len(), 1); @@ -933,18 +1116,32 @@ mod tests { fn import_overrides_and_guards() { let dir = tempdir().unwrap(); let mut conn = crate::db::open_in_memory().unwrap(); - assert!(import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", false)) - .unwrap_err() - .contains("licence")); - assert!(import_font_impl(&mut conn, dir.path(), ®ular(), &request("Montserrat", true)) - .unwrap_err() - .contains("Now, Gotham, Open Sauce One")); - assert!(!dir.path().join("fonts").exists(), "a refused import writes nothing"); + assert!( + import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", false)) + .unwrap_err() + .contains("licence") + ); + assert!(import_font_impl( + &mut conn, + dir.path(), + ®ular(), + &request("Montserrat", true) + ) + .unwrap_err() + .contains("Now, Gotham, Open Sauce One")); + assert!( + !dir.path().join("fonts").exists(), + "a refused import writes nothing" + ); let mut woff2 = regular(); woff2[0..4].copy_from_slice(b"wOF2"); assert!(import_font_impl(&mut conn, dir.path(), &woff2, &request("Now", true)).is_err()); - let req = ImportRequest { weight: Some(300), italic: Some(true), ..request("Open Sauce One", true) }; + let req = ImportRequest { + weight: Some(300), + italic: Some(true), + ..request("Open Sauce One", true) + }; let row = import_font_impl(&mut conn, dir.path(), ®ular(), &req).unwrap(); assert_eq!((row.weight, row.style.as_str()), (300, "italic")); // The table itself refuses other faces. @@ -962,15 +1159,23 @@ mod tests { fn replacing_a_slot_removes_the_old_unreferenced_font() { let dir = tempdir().unwrap(); let mut conn = crate::db::open_in_memory().unwrap(); - let first = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap(); + let first = + import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap(); // Same slot, different file (the bold file declared as 400). - let req = ImportRequest { weight: Some(400), ..request("Gotham", true) }; + let req = ImportRequest { + weight: Some(400), + ..request("Gotham", true) + }; let second = import_font_impl(&mut conn, dir.path(), &bold(), &req).unwrap(); assert_ne!(first.sha256, second.sha256); let rows = list_user_fonts_impl(&conn, true).unwrap(); assert_eq!(rows.len(), 1); assert_eq!(rows[0].sha256, second.sha256); - assert!(!dir.path().join("fonts").join(format!("{}.ttf", first.sha256)).exists()); + assert!(!dir + .path() + .join("fonts") + .join(format!("{}.ttf", first.sha256)) + .exists()); } fn issue_invoice_with_prefs(conn: &Connection, prefs: &str) { @@ -986,11 +1191,22 @@ mod tests { fn remove_deletes_when_unreferenced() { let dir = tempdir().unwrap(); let mut conn = crate::db::open_in_memory().unwrap(); - let row = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Now", true)).unwrap(); + let row = + import_font_impl(&mut conn, dir.path(), ®ular(), &request("Now", true)).unwrap(); let out = remove_user_font_impl(&mut conn, dir.path(), row.id).unwrap(); - assert_eq!(out, RemoveOutcome { deleted: true, hidden: false }); + assert_eq!( + out, + RemoveOutcome { + deleted: true, + hidden: false + } + ); assert!(list_user_fonts_impl(&conn, true).unwrap().is_empty()); - assert!(!dir.path().join("fonts").join(format!("{}.ttf", row.sha256)).exists()); + assert!(!dir + .path() + .join("fonts") + .join(format!("{}.ttf", row.sha256)) + .exists()); assert!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).is_err()); assert!(remove_user_font_impl(&mut conn, dir.path(), row.id).is_err()); } @@ -999,20 +1215,42 @@ mod tests { fn remove_hides_a_font_an_issued_invoice_references() { let dir = tempdir().unwrap(); let mut conn = crate::db::open_in_memory().unwrap(); - let row = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap(); + let row = + import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap(); issue_invoice_with_prefs( &conn, - &format!(r#"{{"version":1,"templateId":"purple-pop","fonts":{{"Gotham":["{}"]}}}}"#, row.sha256), + &format!( + r#"{{"version":1,"templateId":"purple-pop","fonts":{{"Gotham":["{}"]}}}}"#, + row.sha256 + ), ); let out = remove_user_font_impl(&mut conn, dir.path(), row.id).unwrap(); - assert_eq!(out, RemoveOutcome { deleted: false, hidden: true }); + assert_eq!( + out, + RemoveOutcome { + deleted: false, + hidden: true + } + ); // Gone from the visible list, but kept and still served so the invoice can re-render. assert!(list_user_fonts_impl(&conn, false).unwrap().is_empty()); assert_eq!(list_user_fonts_impl(&conn, true).unwrap().len(), 1); - assert_eq!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap(), regular()); + assert_eq!( + user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap(), + regular() + ); // The slot is free again: importing a new font for it works, and the hidden one stays. - let fresh = import_font_impl(&mut conn, dir.path(), &bold(), &ImportRequest { weight: Some(400), ..request("Gotham", true) }).unwrap(); + let fresh = import_font_impl( + &mut conn, + dir.path(), + &bold(), + &ImportRequest { + weight: Some(400), + ..request("Gotham", true) + }, + ) + .unwrap(); assert_ne!(fresh.sha256, row.sha256); assert_eq!(list_user_fonts_impl(&conn, true).unwrap().len(), 2); } @@ -1021,10 +1259,27 @@ mod tests { fn replacing_a_referenced_font_hides_it_instead_of_deleting() { let dir = tempdir().unwrap(); let mut conn = crate::db::open_in_memory().unwrap(); - let row = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap(); - issue_invoice_with_prefs(&conn, &format!(r#"{{"fonts":{{"Gotham":["{}"]}}}}"#, row.sha256)); - import_font_impl(&mut conn, dir.path(), &bold(), &ImportRequest { weight: Some(400), ..request("Gotham", true) }).unwrap(); - assert!(dir.path().join("fonts").join(format!("{}.ttf", row.sha256)).exists()); + let row = + import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap(); + issue_invoice_with_prefs( + &conn, + &format!(r#"{{"fonts":{{"Gotham":["{}"]}}}}"#, row.sha256), + ); + import_font_impl( + &mut conn, + dir.path(), + &bold(), + &ImportRequest { + weight: Some(400), + ..request("Gotham", true) + }, + ) + .unwrap(); + assert!(dir + .path() + .join("fonts") + .join(format!("{}.ttf", row.sha256)) + .exists()); let all = list_user_fonts_impl(&conn, true).unwrap(); assert_eq!(all.len(), 2); assert_eq!(all.iter().filter(|f| f.hidden).count(), 1); @@ -1034,22 +1289,38 @@ mod tests { fn a_file_shared_by_two_faces_survives_removing_one() { let dir = tempdir().unwrap(); let mut conn = crate::db::open_in_memory().unwrap(); - let a = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap(); + let a = + import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap(); let b = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Now", true)).unwrap(); assert_eq!(a.sha256, b.sha256); remove_user_font_impl(&mut conn, dir.path(), a.id).unwrap(); - assert!(dir.path().join("fonts").join(format!("{}.ttf", a.sha256)).exists()); + assert!(dir + .path() + .join("fonts") + .join(format!("{}.ttf", a.sha256)) + .exists()); remove_user_font_impl(&mut conn, dir.path(), b.id).unwrap(); - assert!(!dir.path().join("fonts").join(format!("{}.ttf", a.sha256)).exists()); + assert!(!dir + .path() + .join("fonts") + .join(format!("{}.ttf", a.sha256)) + .exists()); } #[test] fn bytes_are_verified_and_the_hash_shape_checked() { let dir = tempdir().unwrap(); let mut conn = crate::db::open_in_memory().unwrap(); - let row = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Now", true)).unwrap(); - std::fs::write(dir.path().join("fonts").join(format!("{}.ttf", row.sha256)), b"tampered").unwrap(); - assert!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap_err().contains("corrupted")); + let row = + import_font_impl(&mut conn, dir.path(), ®ular(), &request("Now", true)).unwrap(); + std::fs::write( + dir.path().join("fonts").join(format!("{}.ttf", row.sha256)), + b"tampered", + ) + .unwrap(); + assert!(user_font_bytes_impl(&conn, dir.path(), &row.sha256) + .unwrap_err() + .contains("corrupted")); assert!(font_path(dir.path(), "../../etc/passwd", "ttf").is_err()); } @@ -1061,11 +1332,18 @@ mod tests { h.insert("x-weight".into(), "700".into()); h.insert("x-style".into(), "italic".into()); let req = parse_import_headers(&h, "f.ttf").unwrap(); - assert_eq!((req.weight, req.italic, req.licence_acknowledged), (Some(700), Some(true), true)); + assert_eq!( + (req.weight, req.italic, req.licence_acknowledged), + (Some(700), Some(true), true) + ); h.insert("x-style".into(), "oblique".into()); assert!(parse_import_headers(&h, "f.ttf").is_err()); h.remove("x-licence-ack"); h.insert("x-style".into(), "".into()); - assert!(!parse_import_headers(&h, "f.ttf").unwrap().licence_acknowledged); + assert!( + !parse_import_headers(&h, "f.ttf") + .unwrap() + .licence_acknowledged + ); } } diff --git a/src-tauri/src/commands/invoice.rs b/src-tauri/src/commands/invoice.rs index 1d655d2..4df1a81 100644 --- a/src-tauri/src/commands/invoice.rs +++ b/src-tauri/src/commands/invoice.rs @@ -1,11 +1,12 @@ use super::assets::relative_asset_path; +use super::payments::{derive_summary, summary_for, today_ist}; use super::series::{validate_series_format, MAX_NUMBER_LEN}; use super::settings::{map_bank, map_settings, SETTINGS_COLS}; use crate::db::format_number; use crate::gst::{self, TaxType}; -use super::payments::{derive_summary, summary_for, today_ist}; use crate::models::{ - BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary, Settings, + BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary, + Settings, }; use crate::AppState; use chrono::NaiveDate; @@ -140,7 +141,9 @@ fn validate_items(items: &[InvoiceItem]) -> Result<(), String> { match item.mode.as_str() { "rate" => { if !finite_ok(item.rate) || !item.quantity.is_finite() || item.quantity < 0.0 { - return Err(format!("Line {n}: rate and quantity must be positive numbers")); + return Err(format!( + "Line {n}: rate and quantity must be positive numbers" + )); } if item.quantity > MAX_LINE_RUPEES { return Err(format!("Line {n}: quantity is too large")); @@ -271,8 +274,9 @@ pub fn issue_invoice_impl( .map_err(db)? .ok_or_else(|| "No active invoice series. Create one under Series.".to_string())?; - validate_series_format(&prefix, padding) - .map_err(|e| format!("The active invoice series cannot be used: {e}. Start a new series."))?; + validate_series_format(&prefix, padding).map_err(|e| { + format!("The active invoice series cannot be used: {e}. Start a new series.") + })?; let number = format_number(&prefix, padding, next_number); if number.len() > MAX_NUMBER_LEN { return Err(format!( @@ -296,10 +300,16 @@ pub fn issue_invoice_impl( }; let pos = input.place_of_supply_state_code.trim().to_string(); if !pos.is_empty() && !gst::state_exists(&pos) { - return Err(format!("Place of supply \"{pos}\" is not a valid GST state code")); + return Err(format!( + "Place of supply \"{pos}\" is not a valid GST state code" + )); } - let derived = gst::derive_tax_type(&settings.gst_registration, &settings.vendor_state_code, &pos); + let derived = gst::derive_tax_type( + &settings.gst_registration, + &settings.vendor_state_code, + &pos, + ); if input.tax_type != derived.as_str() { return Err(format!( "Tax type \"{}\" does not match \"{}\", which follows from supplier state {} and place of supply {}", @@ -309,7 +319,11 @@ pub fn issue_invoice_impl( if pos.is_empty() { &settings.vendor_state_code } else { &pos }, )); } - let tax_rate = if derived == TaxType::None { 0.0 } else { input.tax_rate }; + let tax_rate = if derived == TaxType::None { + 0.0 + } else { + input.tax_rate + }; if !tax_rate.is_finite() || !(0.0..=100.0).contains(&tax_rate) { return Err("Tax rate must be between 0 and 100".into()); } @@ -330,9 +344,9 @@ pub fn issue_invoice_impl( let snapshot = vendor_snapshot(&settings); let bank = bank_snapshot(&tx, input.bank_account_id)?; let signature_path = match input.signature_path.as_deref().map(str::trim) { - Some(p) if !p.is_empty() => Some( - relative_asset_path(data_dir, p).map_err(|e| format!("Signature image: {e}"))?, - ), + Some(p) if !p.is_empty() => { + Some(relative_asset_path(data_dir, p).map_err(|e| format!("Signature image: {e}"))?) + } _ => None, }; let prefs = serde_json::to_string(render_prefs).map_err(|e| e.to_string())?; @@ -445,8 +459,11 @@ pub fn issue_invoice_impl( .map_err(db)?; if let Some(draft_id) = input.draft_id { - tx.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![draft_id]) - .map_err(db)?; + tx.execute( + "DELETE FROM invoice_drafts WHERE id = ?1", + params![draft_id], + ) + .map_err(db)?; } tx.commit().map_err(db)?; @@ -464,9 +481,17 @@ pub fn issue_invoice( } /// Issued invoices are never deleted: cancelling keeps the row and its number. -pub fn cancel_invoice_impl(conn: &mut Connection, id: i64, reason: &str) -> Result { +pub fn cancel_invoice_impl( + conn: &mut Connection, + id: i64, + reason: &str, +) -> Result { let payments: i64 = conn - .query_row("SELECT COUNT(*) FROM payments WHERE invoice_id = ?1", params![id], |r| r.get(0)) + .query_row( + "SELECT COUNT(*) FROM payments WHERE invoice_id = ?1", + params![id], + |r| r.get(0), + ) .map_err(|e| e.to_string())?; if payments > 0 { return Err("This invoice has payments recorded. Delete them before cancelling it.".into()); @@ -482,7 +507,11 @@ pub fn cancel_invoice_impl(conn: &mut Connection, id: i64, reason: &str) -> Resu .map_err(|e| e.to_string())?; if changed == 0 { let status: Option = conn - .query_row("SELECT status FROM invoices WHERE id = ?1", params![id], |r| r.get(0)) + .query_row( + "SELECT status FROM invoices WHERE id = ?1", + params![id], + |r| r.get(0), + ) .optional() .map_err(|e| e.to_string())?; return Err(match status { @@ -525,7 +554,14 @@ pub fn list_invoices_impl(conn: &Connection) -> Result, Stri let status: String = row.get(6)?; let due_date: String = row.get(3)?; let total_paise = gst::rupees_to_paise(total); - let payment = derive_summary(&status, total_paise, row.get(9)?, row.get(10)?, &due_date, &today); + let payment = derive_summary( + &status, + total_paise, + row.get(9)?, + row.get(10)?, + &due_date, + &today, + ); Ok(InvoiceSummary { id: row.get(0)?, number: row.get(1)?, @@ -566,7 +602,8 @@ pub struct InvoiceLedgerRow { } pub fn list_invoice_ledger_impl(conn: &Connection) -> Result, String> { - let mut hsn_by_invoice: std::collections::HashMap> = std::collections::HashMap::new(); + let mut hsn_by_invoice: std::collections::HashMap> = + std::collections::HashMap::new(); { let mut stmt = conn .prepare("SELECT invoice_id, TRIM(hsn_sac) FROM invoice_items ORDER BY invoice_id, sort_order, id") @@ -666,11 +703,17 @@ pub fn save_draft_impl( pub fn list_drafts_impl(conn: &Connection) -> Result, String> { let mut stmt = conn - .prepare("SELECT id, payload, updated_at FROM invoice_drafts ORDER BY updated_at DESC, id DESC") + .prepare( + "SELECT id, payload, updated_at FROM invoice_drafts ORDER BY updated_at DESC, id DESC", + ) .map_err(|e| e.to_string())?; let rows = stmt .query_map([], |r| { - Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)) + Ok(( + r.get::<_, i64>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + )) }) .map_err(|e| e.to_string())? .collect::>>() @@ -682,7 +725,10 @@ pub fn list_drafts_impl(conn: &Connection) -> Result, String> DraftSummary { id, updated_at, - client_name: json.get("clientName").and_then(|v| v.as_str()).map(String::from), + client_name: json + .get("clientName") + .and_then(|v| v.as_str()) + .map(String::from), total: json.get("total").and_then(|v| v.as_f64()), } }) @@ -691,7 +737,11 @@ pub fn list_drafts_impl(conn: &Connection) -> Result, String> pub fn get_draft_impl(conn: &Connection, id: i64) -> Result { let text: String = conn - .query_row("SELECT payload FROM invoice_drafts WHERE id = ?1", params![id], |r| r.get(0)) + .query_row( + "SELECT payload FROM invoice_drafts WHERE id = ?1", + params![id], + |r| r.get(0), + ) .optional() .map_err(|e| e.to_string())? .ok_or_else(|| "Draft not found".to_string())?; @@ -765,19 +815,28 @@ mod tests { } fn next_number(conn: &Connection) -> i64 { - conn.query_row("SELECT next_number FROM invoice_series WHERE is_active = 1", [], |r| r.get(0)) - .unwrap() + conn.query_row( + "SELECT next_number FROM invoice_series WHERE is_active = 1", + [], + |r| r.get(0), + ) + .unwrap() } fn invoice_count(conn: &Connection) -> i64 { - conn.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0)).unwrap() + conn.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0)) + .unwrap() } #[test] fn ledger_rows_carry_the_tax_split_in_paise_and_distinct_hsn() { let mut conn = registered(); let first = issue(&mut conn, input(json!({"placeOfSupplyStateCode": "27"}))).unwrap(); - conn.execute("UPDATE invoice_items SET hsn_sac = ' 9983 ' WHERE invoice_id = ?1", params![first.id]).unwrap(); + conn.execute( + "UPDATE invoice_items SET hsn_sac = ' 9983 ' WHERE invoice_id = ?1", + params![first.id], + ) + .unwrap(); conn.execute( "INSERT INTO invoice_items (invoice_id, description, amount, sort_order, hsn_sac) VALUES (?1, 'b', 1, 5, '9983'), (?1, 'c', 1, 6, '9984'), (?1, 'd', 1, 7, '')", params![first.id], @@ -791,7 +850,10 @@ mod tests { assert_eq!(row.doc_type, "tax_invoice"); assert_eq!(row.hsn_sac, vec!["9983".to_string(), "9984".to_string()]); assert_eq!(row.taxable_paise, 731_000); - assert_eq!((row.cgst_paise, row.sgst_paise, row.igst_paise), (65_790, 65_790, 0)); + assert_eq!( + (row.cgst_paise, row.sgst_paise, row.igst_paise), + (65_790, 65_790, 0) + ); assert!(!row.reverse_charge); } @@ -862,7 +924,10 @@ mod tests { input(json!({"placeOfSupplyStateCode": "29", "taxType": "cgst_sgst"})), ) .unwrap_err(); - assert!(err.starts_with("Tax type \"cgst_sgst\" does not match \"igst\""), "{err}"); + assert!( + err.starts_with("Tax type \"cgst_sgst\" does not match \"igst\""), + "{err}" + ); let err = issue(&mut conn, input(json!({"taxType": "none"}))).unwrap_err(); assert!(err.contains("does not match"), "{err}"); assert_eq!(next_number(&conn), 1); @@ -872,7 +937,11 @@ mod tests { #[test] fn unregistered_supplier_issues_a_plain_invoice_without_tax() { let mut conn = crate::db::open_in_memory().unwrap(); - conn.execute("UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1ZV'", []).unwrap(); + conn.execute( + "UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1ZV'", + [], + ) + .unwrap(); let inv = issue( &mut conn, input(json!({"taxType": "none", "taxRate": 18.0, "placeOfSupplyStateCode": "07"})), @@ -929,7 +998,8 @@ mod tests { assert!(inv.amount_in_words.contains("Eight Thousand")); // Later settings edits do not touch the stored snapshot. - conn.execute("UPDATE app_settings SET vendor_name = 'Renamed'", []).unwrap(); + conn.execute("UPDATE app_settings SET vendor_name = 'Renamed'", []) + .unwrap(); let again = fetch_invoice(&conn, inv.id).unwrap(); assert_eq!(again.vendor_snapshot, inv.vendor_snapshot); } @@ -937,7 +1007,9 @@ mod tests { #[test] fn bank_snapshot_is_built_from_the_bank_row() { let mut conn = registered(); - let bank_id: i64 = conn.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0)).unwrap(); + let bank_id: i64 = conn + .query_row("SELECT id FROM bank_accounts", [], |r| r.get(0)) + .unwrap(); let inv = issue(&mut conn, input(json!({"bankAccountId": bank_id}))).unwrap(); let bank: serde_json::Value = serde_json::from_str(&inv.bank_snapshot).unwrap(); assert_eq!(bank["bankName"], "Test Bank"); @@ -956,15 +1028,22 @@ mod tests { params![format!("INV/{year}-001")], ) .unwrap(); - conn.execute("INSERT INTO invoice_drafts (payload, updated_at) VALUES ('{}', 'now')", []) - .unwrap(); + conn.execute( + "INSERT INTO invoice_drafts (payload, updated_at) VALUES ('{}', 'now')", + [], + ) + .unwrap(); let err = issue(&mut conn, input(json!({"saveClient": true, "draftId": 1}))).unwrap_err(); assert!(err.contains("already exists"), "{err}"); assert_eq!(next_number(&conn), 1); assert_eq!(invoice_count(&conn), 1); // The client insert and the draft delete were rolled back too. - let clients: i64 = conn.query_row("SELECT COUNT(*) FROM clients", [], |r| r.get(0)).unwrap(); - let drafts: i64 = conn.query_row("SELECT COUNT(*) FROM invoice_drafts", [], |r| r.get(0)).unwrap(); + let clients: i64 = conn + .query_row("SELECT COUNT(*) FROM clients", [], |r| r.get(0)) + .unwrap(); + let drafts: i64 = conn + .query_row("SELECT COUNT(*) FROM invoice_drafts", [], |r| r.get(0)) + .unwrap(); assert_eq!((clients, drafts), (0, 1)); } @@ -979,10 +1058,19 @@ mod tests { json!({"items": [{"mode": "rate", "rate": 0.0, "quantity": 2.0}]}), "greater than zero", ), - (json!({"items": [{"mode": "fixed", "amount": -1.0}]}), "zero or more"), - (json!({"items": [{"mode": "weird", "amount": 1.0}]}), "unknown line type"), + ( + json!({"items": [{"mode": "fixed", "amount": -1.0}]}), + "zero or more", + ), + ( + json!({"items": [{"mode": "weird", "amount": 1.0}]}), + "unknown line type", + ), (json!({"clientGstin": "29ABCDE1234F1Z5"}), "Client GSTIN"), - (json!({"placeOfSupplyStateCode": "99"}), "not a valid GST state code"), + ( + json!({"placeOfSupplyStateCode": "99"}), + "not a valid GST state code", + ), (json!({"taxRate": 150.0}), "between 0 and 100"), (json!({"signaturePath": "../voiced.db"}), "Signature image"), ]; @@ -1001,31 +1089,58 @@ mod tests { #[test] fn supplier_settings_are_validated() { let mut conn = registered(); - conn.execute("UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1Z5'", []).unwrap(); - assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("check digit")); - conn.execute("UPDATE app_settings SET vendor_gstin = ''", []).unwrap(); - assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("needs a GSTIN")); - conn.execute("UPDATE app_settings SET vendor_gstin = '29AAGCB7383J1Z4'", []).unwrap(); - assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("state code 29")); + conn.execute( + "UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1Z5'", + [], + ) + .unwrap(); + assert!(issue(&mut conn, input(json!({}))) + .unwrap_err() + .contains("check digit")); + conn.execute("UPDATE app_settings SET vendor_gstin = ''", []) + .unwrap(); + assert!(issue(&mut conn, input(json!({}))) + .unwrap_err() + .contains("needs a GSTIN")); + conn.execute( + "UPDATE app_settings SET vendor_gstin = '29AAGCB7383J1Z4'", + [], + ) + .unwrap(); + assert!(issue(&mut conn, input(json!({}))) + .unwrap_err() + .contains("state code 29")); conn.execute( "UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1ZV', vendor_pan = 'ABCDE1234F'", [], ) .unwrap(); - assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("PAN")); - conn.execute("UPDATE app_settings SET vendor_pan = 'AAPFU0939F', vendor_state_code = '99'", []) - .unwrap(); - assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("state code")); + assert!(issue(&mut conn, input(json!({}))) + .unwrap_err() + .contains("PAN")); + conn.execute( + "UPDATE app_settings SET vendor_pan = 'AAPFU0939F', vendor_state_code = '99'", + [], + ) + .unwrap(); + assert!(issue(&mut conn, input(json!({}))) + .unwrap_err() + .contains("state code")); assert_eq!(next_number(&conn), 1); } #[test] fn legacy_series_that_break_the_rules_are_refused() { let mut conn = registered(); - conn.execute("UPDATE invoice_series SET prefix = 'AP 2026 '", []).unwrap(); + conn.execute("UPDATE invoice_series SET prefix = 'AP 2026 '", []) + .unwrap(); let err = issue(&mut conn, input(json!({}))).unwrap_err(); assert!(err.contains("active invoice series"), "{err}"); - conn.execute("UPDATE invoice_series SET prefix = 'ABCDEFGHIJKLMN', padding = 3", []).unwrap(); + conn.execute( + "UPDATE invoice_series SET prefix = 'ABCDEFGHIJKLMN', padding = 3", + [], + ) + .unwrap(); assert!(issue(&mut conn, input(json!({}))).is_err()); // A counter that outgrows its padding can push the number past 16 characters. conn.execute( @@ -1033,7 +1148,9 @@ mod tests { [], ) .unwrap(); - assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("longer than 16")); + assert!(issue(&mut conn, input(json!({}))) + .unwrap_err() + .contains("longer than 16")); assert_eq!(invoice_count(&conn), 0); } @@ -1061,7 +1178,10 @@ mod tests { &json!({"theme": "plain"}), ) .unwrap(); - assert_eq!(inv.signature_path.as_deref(), Some("assets/signature-1.png")); + assert_eq!( + inv.signature_path.as_deref(), + Some("assets/signature-1.png") + ); let prefs: String = conn .query_row("SELECT render_prefs FROM invoices", [], |r| r.get(0)) .unwrap(); @@ -1082,8 +1202,14 @@ mod tests { let err = cancel_invoice_impl(&mut conn, inv.id, "again").unwrap_err(); assert!(err.contains("cancelled"), "{err}"); - assert!(cancel_invoice_impl(&mut conn, 999, "").unwrap_err().contains("not found")); - conn.execute("UPDATE invoices SET status = 'draft' WHERE id = ?1", params![inv.id]).unwrap(); + assert!(cancel_invoice_impl(&mut conn, 999, "") + .unwrap_err() + .contains("not found")); + conn.execute( + "UPDATE invoices SET status = 'draft' WHERE id = ?1", + params![inv.id], + ) + .unwrap(); assert!(cancel_invoice_impl(&mut conn, inv.id, "").is_err()); // The number is never reused. @@ -1095,10 +1221,16 @@ mod tests { #[test] fn drafts_round_trip() { let conn = crate::db::open_in_memory().unwrap(); - let a = save_draft_impl(&conn, None, &json!({"clientName": "Acme", "total": 1180.5})).unwrap(); + let a = + save_draft_impl(&conn, None, &json!({"clientName": "Acme", "total": 1180.5})).unwrap(); let b = save_draft_impl(&conn, None, &json!({"notes": "no client yet"})).unwrap(); assert_ne!(a, b); - let c = save_draft_impl(&conn, Some(a), &json!({"clientName": "Acme 2", "total": 10})).unwrap(); + let c = save_draft_impl( + &conn, + Some(a), + &json!({"clientName": "Acme 2", "total": 10}), + ) + .unwrap(); assert_eq!(c, a); assert!(save_draft_impl(&conn, Some(999), &json!({})).is_err()); @@ -1111,7 +1243,8 @@ mod tests { let blank = list.iter().find(|d| d.id == b).unwrap(); assert_eq!((blank.client_name.clone(), blank.total), (None, None)); - conn.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![a]).unwrap(); + conn.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![a]) + .unwrap(); assert!(get_draft_impl(&conn, a).is_err()); } } diff --git a/src-tauri/src/commands/logo.rs b/src-tauri/src/commands/logo.rs index 424f791..4a8a2a9 100644 --- a/src-tauri/src/commands/logo.rs +++ b/src-tauri/src/commands/logo.rs @@ -21,14 +21,22 @@ pub struct LogoAsset { } fn sha8(bytes: &[u8]) -> String { - Sha256::digest(bytes).iter().take(4).map(|b| format!("{b:02x}")).collect() + Sha256::digest(bytes) + .iter() + .take(4) + .map(|b| format!("{b:02x}")) + .collect() } /// Derive the print and knockout images from `bytes` and store them content-addressed in the asset /// store. `existing_original` is the already stored file the bytes came from (legacy logos); without /// it the bytes are stored as the original. Nothing is ever deleted: issued invoices may still point at /// older logo files. -fn store_logo(data_dir: &Path, bytes: &[u8], existing_original: Option) -> Result { +fn store_logo( + data_dir: &Path, + bytes: &[u8], + existing_original: Option, +) -> Result { let derived = process_logo(bytes).map_err(|e| e.to_string())?; let id = sha8(bytes); let original_path = match existing_original { @@ -38,17 +46,28 @@ fn store_logo(data_dir: &Path, bytes: &[u8], existing_original: Option) write_named_asset(data_dir, &format!("logo-{id}-original.{ext}"), bytes)? } }; - let print_path = write_named_asset(data_dir, &format!("logo-{id}-print.png"), &derived.print_png)?; + let print_path = write_named_asset( + data_dir, + &format!("logo-{id}-print.png"), + &derived.print_png, + )?; let knockout_path = derived .knockout_png .as_deref() .map(|png| write_named_asset(data_dir, &format!("logo-{id}-knockout.png"), png)) .transpose()?; - Ok(LogoAsset { original_path, print_path, knockout_path, metrics: derived.metrics }) + Ok(LogoAsset { + original_path, + print_path, + knockout_path, + metrics: derived.metrics, + }) } pub fn import_logo_from_path(data_dir: &Path, source_path: &str) -> Result { - let len = std::fs::metadata(source_path).map_err(|e| e.to_string())?.len(); + let len = std::fs::metadata(source_path) + .map_err(|e| e.to_string())? + .len(); if len > MAX_INPUT_BYTES as u64 { return Err(crate::logo::LogoError::TooLarge.to_string()); } @@ -61,7 +80,9 @@ pub fn import_logo_from_base64(data_dir: &Path, data_base64: &str) -> Result MAX_INPUT_BYTES / 3 * 4 + 8 { return Err(crate::logo::LogoError::TooLarge.to_string()); } - let bytes = STANDARD.decode(data_base64.as_bytes()).map_err(|e| e.to_string())?; + let bytes = STANDARD + .decode(data_base64.as_bytes()) + .map_err(|e| e.to_string())?; store_logo(data_dir, &bytes, None) } @@ -74,9 +95,11 @@ fn metrics_json(metrics: &LogoMetrics) -> Result { /// original. Returns whether anything changed. pub fn ensure_logo_derived_impl(conn: &Connection, data_dir: &Path) -> Result { let (logo_path, meta): (Option, String) = conn - .query_row("SELECT logo_path, logo_meta FROM app_settings WHERE id = 1", [], |r| { - Ok((r.get(0)?, r.get(1)?)) - }) + .query_row( + "SELECT logo_path, logo_meta FROM app_settings WHERE id = 1", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) .map_err(|e| e.to_string())?; let Some(logo_path) = logo_path.filter(|p| !p.trim().is_empty()) else { return Ok(false); @@ -86,11 +109,20 @@ pub fn ensure_logo_derived_impl(conn: &Connection, data_dir: &Path) -> Result String { let ist = FixedOffset::east_opt(5 * 3600 + 1800).expect("valid offset"); - Utc::now().with_timezone(&ist).date_naive().format("%Y-%m-%d").to_string() + Utc::now() + .with_timezone(&ist) + .date_naive() + .format("%Y-%m-%d") + .to_string() } /// Derives the payment summary. Only an issued invoice takes payments; anything else is `none` @@ -112,7 +116,14 @@ pub fn summary_for( params![invoice_id], |r| Ok((r.get(0)?, r.get(1)?)), )?; - Ok(derive_summary(status, rupees_to_paise(total), paid, tds, due_date, &today_ist())) + Ok(derive_summary( + status, + rupees_to_paise(total), + paid, + tds, + due_date, + &today_ist(), + )) } fn rupees(paise: i64) -> String { @@ -130,7 +141,9 @@ fn map_payment(r: &rusqlite::Row) -> rusqlite::Result { reference: r.get(6)?, notes: r.get(7)?, created_at: r.get(8)?, - erpnext_payment_entry: r.get::<_, Option>(9)?.filter(|e| !e.trim().is_empty()), + erpnext_payment_entry: r + .get::<_, Option>(9)? + .filter(|e| !e.trim().is_empty()), }) } @@ -165,7 +178,9 @@ pub fn record_payment_impl(conn: &mut Connection, input: PaymentInput) -> Result .map_err(db)? .ok_or_else(|| "Invoice not found".to_string())?; if status != "issued" { - return Err(format!("Payments can only be recorded on an issued invoice (this one is {status})")); + return Err(format!( + "Payments can only be recorded on an issued invoice (this one is {status})" + )); } let total_paise = rupees_to_paise(total); let settled: i64 = tx @@ -203,7 +218,11 @@ pub fn record_payment_impl(conn: &mut Connection, input: PaymentInput) -> Result .map_err(db)?; let id = tx.last_insert_rowid(); let payment = tx - .query_row(&format!("SELECT {COLS} FROM payments WHERE id = ?1"), params![id], map_payment) + .query_row( + &format!("SELECT {COLS} FROM payments WHERE id = ?1"), + params![id], + map_payment, + ) .map_err(db)?; tx.commit().map_err(db)?; Ok(payment) @@ -270,7 +289,9 @@ mod tests { "items": [{"description": "Design", "mode": "fixed", "amount": amount}], })) .unwrap(); - issue_invoice_impl(conn, Path::new("/nonexistent"), input, &json!({})).unwrap().id + issue_invoice_impl(conn, Path::new("/nonexistent"), input, &json!({})) + .unwrap() + .id } fn pay(invoice_id: i64, amount: i64, tds: i64) -> PaymentInput { @@ -287,9 +308,11 @@ mod tests { fn summary(conn: &Connection, id: i64) -> PaymentSummary { let (status, total, due): (String, f64, String) = conn - .query_row("SELECT status, total, due_date FROM invoices WHERE id = ?1", params![id], |r| { - Ok((r.get(0)?, r.get(1)?, r.get(2)?)) - }) + .query_row( + "SELECT status, total, due_date FROM invoices WHERE id = ?1", + params![id], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) .unwrap(); summary_for(conn, id, &status, total, &due).unwrap() } @@ -303,7 +326,10 @@ mod tests { record_payment_impl(&mut conn, pay(id, 40_000, 0)).unwrap(); let s = summary(&conn, id); - assert_eq!((s.payment_status.as_str(), s.paid_paise, s.balance_paise), ("partially_paid", 40_000, 60_000)); + assert_eq!( + (s.payment_status.as_str(), s.paid_paise, s.balance_paise), + ("partially_paid", 40_000, 60_000) + ); record_payment_impl(&mut conn, pay(id, 60_000, 0)).unwrap(); let s = summary(&conn, id); @@ -317,7 +343,15 @@ mod tests { // 10% TDS: the client pays 900.00 in cash and deducts 100.00. record_payment_impl(&mut conn, pay(id, 90_000, 10_000)).unwrap(); let s = summary(&conn, id); - assert_eq!((s.payment_status.as_str(), s.paid_paise, s.tds_paise, s.balance_paise), ("paid", 90_000, 10_000, 0)); + assert_eq!( + ( + s.payment_status.as_str(), + s.paid_paise, + s.tds_paise, + s.balance_paise + ), + ("paid", 90_000, 10_000, 0) + ); // TDS alone is a valid payment too. let id2 = issued(&mut conn, 500.0, "2999-01-01"); record_payment_impl(&mut conn, pay(id2, 0, 5_000)).unwrap(); @@ -329,13 +363,18 @@ mod tests { let mut conn = crate::db::open_in_memory().unwrap(); let id = issued(&mut conn, 1000.0, "2999-01-01"); let err = record_payment_impl(&mut conn, pay(id, 100_001, 0)).unwrap_err(); - assert!(err.contains("more than the outstanding balance of ₹1000.00"), "{err}"); + assert!( + err.contains("more than the outstanding balance of ₹1000.00"), + "{err}" + ); // Cash plus TDS is what counts. assert!(record_payment_impl(&mut conn, pay(id, 95_000, 6_000)).is_err()); record_payment_impl(&mut conn, pay(id, 50_000, 0)).unwrap(); let err = record_payment_impl(&mut conn, pay(id, 50_001, 0)).unwrap_err(); assert!(err.contains("₹500.00"), "{err}"); - let n: i64 = conn.query_row("SELECT COUNT(*) FROM payments", [], |r| r.get(0)).unwrap(); + let n: i64 = conn + .query_row("SELECT COUNT(*) FROM payments", [], |r| r.get(0)) + .unwrap(); assert_eq!(n, 1); } @@ -374,7 +413,11 @@ mod tests { assert_eq!((s.payment_status.as_str(), s.balance_paise), ("none", 0)); let id2 = issued(&mut conn, 1000.0, "2999-01-01"); - conn.execute("UPDATE invoices SET status = 'draft' WHERE id = ?1", params![id2]).unwrap(); + conn.execute( + "UPDATE invoices SET status = 'draft' WHERE id = ?1", + params![id2], + ) + .unwrap(); assert!(record_payment_impl(&mut conn, pay(id2, 100, 0)).is_err()); } @@ -397,27 +440,38 @@ mod tests { assert_eq!(summary(&conn, id).payment_status, "paid"); delete_payment_impl(&conn, full.id).unwrap(); let s = summary(&conn, id); - assert_eq!((s.payment_status.as_str(), s.balance_paise), ("unpaid", 100_000)); + assert_eq!( + (s.payment_status.as_str(), s.balance_paise), + ("unpaid", 100_000) + ); assert!(list_payments_impl(&conn, id).unwrap().is_empty()); assert!(delete_payment_impl(&conn, full.id).is_err()); } #[test] fn overdue_is_derived_from_due_date_and_balance() { - let d = |due: &str, paid: i64, tds: i64| derive_summary("issued", 100_000, paid, tds, due, "2026-05-10"); + let d = |due: &str, paid: i64, tds: i64| { + derive_summary("issued", 100_000, paid, tds, due, "2026-05-10") + }; // Due today is not overdue yet; the day after is. assert_eq!(d("2026-05-10", 0, 0).payment_status, "unpaid"); assert_eq!(d("2026-05-09", 0, 0).payment_status, "overdue"); // Overdue wins over partially paid; the paid amount is still reported. let s = d("2026-05-09", 10_000, 0); - assert_eq!((s.payment_status.as_str(), s.paid_paise), ("overdue", 10_000)); + assert_eq!( + (s.payment_status.as_str(), s.paid_paise), + ("overdue", 10_000) + ); // A settled invoice is never overdue. assert_eq!(d("2026-05-09", 90_000, 10_000).payment_status, "paid"); // No usable due date means never overdue. assert_eq!(d("", 0, 0).payment_status, "unpaid"); assert_eq!(d("not a date", 0, 0).payment_status, "unpaid"); // Cancelled invoices are not owed. - assert_eq!(derive_summary("cancelled", 100_000, 0, 0, "2020-01-01", "2026-05-10").balance_paise, 0); + assert_eq!( + derive_summary("cancelled", 100_000, 0, 0, "2020-01-01", "2026-05-10").balance_paise, + 0 + ); } #[test] @@ -429,7 +483,10 @@ mod tests { record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap(); record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap(); let s = summary(&conn, id); - assert_eq!((s.paid_paise, s.balance_paise, s.payment_status.as_str()), (9999, 0, "paid")); + assert_eq!( + (s.paid_paise, s.balance_paise, s.payment_status.as_str()), + (9999, 0, "paid") + ); let inv = super::super::invoice::get_invoice_impl(&conn, id).unwrap(); assert_eq!(inv.total_paise, 9999); } @@ -445,7 +502,14 @@ mod tests { let rows = super::super::invoice::list_invoices_impl(&conn).unwrap(); let get = |id: i64| rows.iter().find(|r| r.id == id).unwrap(); let ra = get(a); - assert_eq!((ra.payment.paid_paise, ra.payment.tds_paise, ra.payment.balance_paise), (40_000, 5_000, 55_000)); + assert_eq!( + ( + ra.payment.paid_paise, + ra.payment.tds_paise, + ra.payment.balance_paise + ), + (40_000, 5_000, 55_000) + ); assert_eq!(ra.payment.payment_status, "overdue"); assert_eq!(ra.total_paise, 100_000); assert_eq!(get(b).payment.payment_status, "paid"); diff --git a/src-tauri/src/commands/presets.rs b/src-tauri/src/commands/presets.rs index d1dcd1e..453e235 100644 --- a/src-tauri/src/commands/presets.rs +++ b/src-tauri/src/commands/presets.rs @@ -136,7 +136,10 @@ mod tests { let saved = save_preset_impl(&conn, p).unwrap(); assert_eq!(saved.description, "Voice-over session"); assert_eq!(saved.hsn_sac, "998397"); - assert_eq!((saved.mode.as_str(), saved.unit.as_str(), saved.rate_paise), ("rate", "hour", 150_050)); + assert_eq!( + (saved.mode.as_str(), saved.unit.as_str(), saved.rate_paise), + ("rate", "hour", 150_050) + ); let mut edit = saved.clone(); edit.rate_paise = 200_000; diff --git a/src-tauri/src/commands/series.rs b/src-tauri/src/commands/series.rs index fbb3e8f..1c97b04 100644 --- a/src-tauri/src/commands/series.rs +++ b/src-tauri/src/commands/series.rs @@ -154,7 +154,11 @@ mod tests { assert_eq!(s.prefix, "FY27/"); assert_eq!(s.next_invoice_number, "FY27/0001"); let active: i64 = c - .query_row("SELECT COUNT(*) FROM invoice_series WHERE is_active = 1", [], |r| r.get(0)) + .query_row( + "SELECT COUNT(*) FROM invoice_series WHERE is_active = 1", + [], + |r| r.get(0), + ) .unwrap(); assert_eq!(active, 1); } @@ -163,12 +167,20 @@ mod tests { fn invalid_series_leaves_the_active_one_alone() { let mut c = conn(); let before: i64 = c - .query_row("SELECT id FROM invoice_series WHERE is_active = 1", [], |r| r.get(0)) + .query_row( + "SELECT id FROM invoice_series WHERE is_active = 1", + [], + |r| r.get(0), + ) .unwrap(); assert!(start_new_series_impl(&mut c, "bad prefix", 3).is_err()); assert!(start_new_series_impl(&mut c, "ABCDEFGHIJKLMNOP", 3).is_err()); let after: i64 = c - .query_row("SELECT id FROM invoice_series WHERE is_active = 1", [], |r| r.get(0)) + .query_row( + "SELECT id FROM invoice_series WHERE is_active = 1", + [], + |r| r.get(0), + ) .unwrap(); assert_eq!(before, after); } @@ -184,7 +196,11 @@ mod tests { .unwrap(); assert!(start_new_series_impl(&mut c, "FY27", 3).is_err()); let active: i64 = c - .query_row("SELECT COUNT(*) FROM invoice_series WHERE is_active = 1", [], |r| r.get(0)) + .query_row( + "SELECT COUNT(*) FROM invoice_series WHERE is_active = 1", + [], + |r| r.get(0), + ) .unwrap(); assert_eq!(active, 1); } diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 4976570..91b840c 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -142,8 +142,14 @@ pub fn save_bank(state: State, bank: BankAccount) -> Result, bank: BankAccount) -> Result Result<(), String> { tx.execute("UPDATE bank_accounts SET is_default = 0", []) .map_err(|e| e.to_string())?; if let Some(next) = next { - tx.execute("UPDATE bank_accounts SET is_default = 1 WHERE id = ?1", params![next]) - .map_err(|e| e.to_string())?; - } - tx.execute("UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1", params![next]) + tx.execute( + "UPDATE bank_accounts SET is_default = 1 WHERE id = ?1", + params![next], + ) .map_err(|e| e.to_string())?; + } + tx.execute( + "UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1", + params![next], + ) + .map_err(|e| e.to_string())?; } tx.commit().map_err(|e| e.to_string()) } @@ -238,12 +255,20 @@ mod tests { fn default_state(conn: &Connection) -> (Option, Vec) { let setting = conn - .query_row("SELECT default_bank_id FROM app_settings WHERE id = 1", [], |r| r.get(0)) + .query_row( + "SELECT default_bank_id FROM app_settings WHERE id = 1", + [], + |r| r.get(0), + ) .unwrap(); let mut stmt = conn .prepare("SELECT id FROM bank_accounts WHERE is_default = 1 ORDER BY id") .unwrap(); - let flagged = stmt.query_map([], |r| r.get(0)).unwrap().map(Result::unwrap).collect(); + let flagged = stmt + .query_map([], |r| r.get(0)) + .unwrap() + .map(Result::unwrap) + .collect(); (setting, flagged) } @@ -259,8 +284,11 @@ mod tests { .unwrap() }; assert_eq!(read(&conn).render_prefs, ""); - conn.execute("UPDATE app_settings SET render_prefs = ?1 WHERE id = 1", params!["{\"version\":1}"]) - .unwrap(); + conn.execute( + "UPDATE app_settings SET render_prefs = ?1 WHERE id = 1", + params!["{\"version\":1}"], + ) + .unwrap(); let settings = read(&conn); assert_eq!(settings.render_prefs, "{\"version\":1}"); let json = serde_json::to_value(&settings).unwrap(); diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 0640f00..d8825d2 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -482,7 +482,10 @@ pub(crate) fn seed_test_fixture(conn: &Connection) -> rusqlite::Result<()> { [], )?; let bank_id = conn.last_insert_rowid(); - conn.execute("UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1", params![bank_id])?; + conn.execute( + "UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1", + params![bank_id], + )?; Ok(()) } @@ -542,8 +545,9 @@ pub fn relativize_asset_paths(conn: &Connection, data_dir: &Path) -> rusqlite::R } let snapshots: Vec<(i64, String)> = { - let mut stmt = - tx.prepare("SELECT id, vendor_snapshot FROM invoices WHERE vendor_snapshot IS NOT NULL")?; + let mut stmt = tx.prepare( + "SELECT id, vendor_snapshot FROM invoices WHERE vendor_snapshot IS NOT NULL", + )?; let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?; rows.collect::>()? }; @@ -574,7 +578,12 @@ pub fn relativize_asset_paths(conn: &Connection, data_dir: &Path) -> rusqlite::R /// Format an invoice number from a series definition, e.g. "INV/2026-" + 1 -> "INV/2026-001". pub fn format_number(prefix: &str, padding: i64, number: i64) -> String { - format!("{}{:0width$}", prefix, number, width = padding.max(1) as usize) + format!( + "{}{:0width$}", + prefix, + number, + width = padding.max(1) as usize + ) } #[cfg(test)] @@ -682,7 +691,8 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); "#; fn user_version(conn: &Connection) -> i64 { - conn.pragma_query_value(None, "user_version", |r| r.get(0)).unwrap() + conn.pragma_query_value(None, "user_version", |r| r.get(0)) + .unwrap() } fn backups_in(dir: &Path) -> Vec { @@ -724,7 +734,11 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); fn m4_adds_archive_and_export_dir_columns() { let conn = open_in_memory().unwrap(); let dir: String = conn - .query_row("SELECT last_export_dir FROM app_settings WHERE id = 1", [], |r| r.get(0)) + .query_row( + "SELECT last_export_dir FROM app_settings WHERE id = 1", + [], + |r| r.get(0), + ) .unwrap(); assert_eq!(dir, ""); // The columns exist and are nullable on invoices (no rows needed to prove it). @@ -745,14 +759,26 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); fn render_prefs_column_defaults_to_empty_and_round_trips() { let conn = open_in_memory().unwrap(); let initial: String = conn - .query_row("SELECT render_prefs FROM app_settings WHERE id = 1", [], |r| r.get(0)) + .query_row( + "SELECT render_prefs FROM app_settings WHERE id = 1", + [], + |r| r.get(0), + ) .unwrap(); assert_eq!(initial, ""); let json = r#"{"version":1,"templateId":"classic","pageSize":"LETTER","margins":"narrow"}"#; - conn.execute("UPDATE app_settings SET render_prefs = ?1 WHERE id = 1", [json]).unwrap(); + conn.execute( + "UPDATE app_settings SET render_prefs = ?1 WHERE id = 1", + [json], + ) + .unwrap(); let stored: String = conn - .query_row("SELECT render_prefs FROM app_settings WHERE id = 1", [], |r| r.get(0)) + .query_row( + "SELECT render_prefs FROM app_settings WHERE id = 1", + [], + |r| r.get(0), + ) .unwrap(); assert_eq!(stored, json); } @@ -779,15 +805,21 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); .to_latest(&mut old) .unwrap(); seed(&old).unwrap(); - old.execute("UPDATE app_settings SET logo_path = 'assets/logo-old.png'", []).unwrap(); + old.execute( + "UPDATE app_settings SET logo_path = 'assets/logo-old.png'", + [], + ) + .unwrap(); assert_eq!(user_version(&old), 4); } let conn = open(&path, &dir.path().join("backups")).unwrap(); assert_eq!(user_version(&conn), LATEST_VERSION); let (logo, meta): (String, String) = conn - .query_row("SELECT logo_path, logo_meta FROM app_settings WHERE id = 1", [], |r| { - Ok((r.get(0)?, r.get(1)?)) - }) + .query_row( + "SELECT logo_path, logo_meta FROM app_settings WHERE id = 1", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) .unwrap(); assert_eq!((logo.as_str(), meta.as_str()), ("assets/logo-old.png", "")); assert_eq!(backups_in(&dir.path().join("backups")).len(), 1); @@ -808,7 +840,11 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); ) .unwrap(); let (cat, terms): (String, Option) = conn - .query_row("SELECT gst_category, payment_terms_days FROM clients", [], |r| Ok((r.get(0)?, r.get(1)?))) + .query_row( + "SELECT gst_category, payment_terms_days FROM clients", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) .unwrap(); assert_eq!((cat.as_str(), terms), ("unregistered", None)); // The category CHECK rejects unknown values. @@ -820,7 +856,14 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); #[test] fn m7_upgrade_from_v6_keeps_client_data_and_backfills_category() { let mut conn = Connection::open_in_memory().unwrap(); - let all = vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6)]; + let all = vec![ + M::up(SCHEMA), + M::up(M2), + M::up(M3), + M::up(M4), + M::up(M5), + M::up(M6), + ]; Migrations::new(all).to_latest(&mut conn).unwrap(); assert_eq!(user_version(&conn), 6); conn.execute( @@ -846,7 +889,10 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); .collect::>() .unwrap(); assert_eq!(rows[0].1, "Line one\nLine two"); - assert_eq!((rows[0].2.as_str(), rows[0].3.as_str(), rows[0].4.as_str()), ("29ABCDE1234F1Z5", "29", "PO-1")); + assert_eq!( + (rows[0].2.as_str(), rows[0].3.as_str(), rows[0].4.as_str()), + ("29ABCDE1234F1Z5", "29", "PO-1") + ); assert_eq!(rows[0].5, "registered_regular"); assert_eq!(rows[0].6, ""); assert_eq!(rows[1].5, "unregistered"); @@ -864,25 +910,43 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)), ) .unwrap(); - assert_eq!((naming.as_str(), submit, attach, auto_push, create_customers), ("mirror", 0, 1, 0, 1)); + assert_eq!( + (naming.as_str(), submit, attach, auto_push, create_customers), + ("mirror", 0, 1, 0, 1) + ); assert_eq!(secret, ""); assert!(serde_json::from_str::(&uom).is_ok()); - let rows: i64 = conn.query_row("SELECT COUNT(*) FROM erpnext_config", [], |r| r.get(0)).unwrap(); + let rows: i64 = conn + .query_row("SELECT COUNT(*) FROM erpnext_config", [], |r| r.get(0)) + .unwrap(); assert_eq!(rows, 1); // Single row only. - assert!(conn.execute("INSERT INTO erpnext_config (id) VALUES (2)", []).is_err()); + assert!(conn + .execute("INSERT INTO erpnext_config (id) VALUES (2)", []) + .is_err()); conn.prepare("SELECT invoice_id, remote_name, remote_docstatus, status, last_error, payload_hash, synced_at FROM erpnext_sync") .unwrap(); - conn.prepare("SELECT erpnext_customer, erpnext_address FROM clients").unwrap(); - conn.prepare("SELECT erpnext_payment_entry FROM payments").unwrap(); - conn.prepare("SELECT erpnext_item_code FROM item_presets").unwrap(); + conn.prepare("SELECT erpnext_customer, erpnext_address FROM clients") + .unwrap(); + conn.prepare("SELECT erpnext_payment_entry FROM payments") + .unwrap(); + conn.prepare("SELECT erpnext_item_code FROM item_presets") + .unwrap(); } #[test] fn m10_upgrade_from_v9_keeps_existing_data_and_new_columns_are_null() { let mut conn = Connection::open_in_memory().unwrap(); let v9 = vec![ - M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6), M::up(M7), M::up(M8), M::up(M9), + M::up(SCHEMA), + M::up(M2), + M::up(M3), + M::up(M4), + M::up(M5), + M::up(M6), + M::up(M7), + M::up(M8), + M::up(M9), ]; Migrations::new(v9).to_latest(&mut conn).unwrap(); assert_eq!(user_version(&conn), 9); @@ -902,37 +966,70 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); migrations().to_latest(&mut conn).unwrap(); assert_eq!(user_version(&conn), LATEST_VERSION); - let (name, gstin, customer, address): (String, String, Option, Option) = conn - .query_row("SELECT name, gstin, erpnext_customer, erpnext_address FROM clients", [], |r| { - Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)) - }) + let (name, gstin, customer, address): (String, String, Option, Option) = + conn.query_row( + "SELECT name, gstin, erpnext_customer, erpnext_address FROM clients", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), + ) .unwrap(); - assert_eq!((name.as_str(), gstin.as_str(), customer, address), ("Acme", "29ABCDE1234F1Z5", None, None)); + assert_eq!( + (name.as_str(), gstin.as_str(), customer, address), + ("Acme", "29ABCDE1234F1Z5", None, None) + ); let (desc, rate, code): (String, i64, Option) = conn - .query_row("SELECT description, rate_paise, erpnext_item_code FROM item_presets", [], |r| { - Ok((r.get(0)?, r.get(1)?, r.get(2)?)) - }) + .query_row( + "SELECT description, rate_paise, erpnext_item_code FROM item_presets", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) .unwrap(); assert_eq!((desc.as_str(), rate, code), ("Design", 500000, None)); let (paid, entry): (i64, Option) = conn - .query_row("SELECT amount_paise, erpnext_payment_entry FROM payments", [], |r| Ok((r.get(0)?, r.get(1)?))) + .query_row( + "SELECT amount_paise, erpnext_payment_entry FROM payments", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) .unwrap(); assert_eq!((paid, entry), (12345, None)); - let number: String = conn.query_row("SELECT number FROM invoices", [], |r| r.get(0)).unwrap(); + let number: String = conn + .query_row("SELECT number FROM invoices", [], |r| r.get(0)) + .unwrap(); assert_eq!(number, "INV/2026-001"); // The config row exists with defaults, and a sync row can reference the invoice. - let enabled: i64 = conn.query_row("SELECT COUNT(*) FROM erpnext_config WHERE id = 1", [], |r| r.get(0)).unwrap(); + let enabled: i64 = conn + .query_row( + "SELECT COUNT(*) FROM erpnext_config WHERE id = 1", + [], + |r| r.get(0), + ) + .unwrap(); assert_eq!(enabled, 1); conn.execute("INSERT INTO erpnext_sync (invoice_id, remote_name, status) VALUES (1, 'INV/2026-001', 'synced')", []).unwrap(); - assert!(conn.execute("INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'bogus')", []).is_err()); + assert!(conn + .execute( + "INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'bogus')", + [] + ) + .is_err()); } #[test] fn m11_rebuilds_the_sync_table_keeping_rows_and_allowing_conflict() { let mut conn = Connection::open_in_memory().unwrap(); let v10 = vec![ - M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6), M::up(M7), M::up(M8), M::up(M9), M::up(M10), + M::up(SCHEMA), + M::up(M2), + M::up(M3), + M::up(M4), + M::up(M5), + M::up(M6), + M::up(M7), + M::up(M8), + M::up(M9), + M::up(M10), ]; Migrations::new(v10).to_latest(&mut conn).unwrap(); assert_eq!(user_version(&conn), 10); @@ -947,7 +1044,9 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); [], ) .unwrap(); - assert!(conn.execute("UPDATE erpnext_sync SET status = 'conflict'", []).is_err()); + assert!(conn + .execute("UPDATE erpnext_sync SET status = 'conflict'", []) + .is_err()); migrations().to_latest(&mut conn).unwrap(); assert_eq!(user_version(&conn), 11); @@ -960,12 +1059,27 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); .unwrap(); assert_eq!( row, - ("INV/2026-001".into(), 1, "synced".into(), "abc".into(), String::new(), Some("2026-04-02T00:00:00Z".into())) + ( + "INV/2026-001".into(), + 1, + "synced".into(), + "abc".into(), + String::new(), + Some("2026-04-02T00:00:00Z".into()) + ) ); - conn.execute("UPDATE erpnext_sync SET status = 'conflict'", []).unwrap(); - assert!(conn.execute("UPDATE erpnext_sync SET status = 'bogus'", []).is_err()); + conn.execute("UPDATE erpnext_sync SET status = 'conflict'", []) + .unwrap(); + assert!(conn + .execute("UPDATE erpnext_sync SET status = 'bogus'", []) + .is_err()); // The unique key still guards one row per invoice. - assert!(conn.execute("INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'error')", []).is_err()); + assert!(conn + .execute( + "INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'error')", + [] + ) + .is_err()); } fn build_v1_database(path: &Path) { @@ -1018,7 +1132,10 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); let files = backups_in(&backups); assert_eq!(files.len(), 1); let name = files[0].file_name().unwrap().to_str().unwrap(); - assert!(name.starts_with("voiced-pre-v0-") && name.ends_with(".db"), "{name}"); + assert!( + name.starts_with("voiced-pre-v0-") && name.ends_with(".db"), + "{name}" + ); let backup = Connection::open(&files[0]).unwrap(); assert_eq!(user_version(&backup), 0); let n: i64 = backup @@ -1039,7 +1156,13 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); assert_eq!(vendor, "Acme Studio"); // Invoice backfill. - let (origin, snapshot, doc_type, number, total): (String, Option, String, String, f64) = conn + let (origin, snapshot, doc_type, number, total): ( + String, + Option, + String, + String, + f64, + ) = conn .query_row( "SELECT snapshot_origin, vendor_snapshot, doc_type, number, total FROM invoices", [], @@ -1107,7 +1230,9 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); .collect(); names.sort(); assert_eq!(names.len(), MAX_BACKUPS); - assert!(names.iter().all(|n| !n.contains("20260101") && !n.contains("20260104"))); + assert!(names + .iter() + .all(|n| !n.contains("20260101") && !n.contains("20260104"))); assert!(dir.path().join("unrelated.db").exists()); } @@ -1116,8 +1241,16 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); let dir = tempdir().unwrap(); let data = dir.path(); let conn = open(&data.join("voiced.db"), &data.join("backups")).unwrap(); - let abs_logo = data.join("assets").join("logo-1.png").to_string_lossy().into_owned(); - let abs_sig = data.join("assets").join("signature-2.jpg").to_string_lossy().into_owned(); + let abs_logo = data + .join("assets") + .join("logo-1.png") + .to_string_lossy() + .into_owned(); + let abs_sig = data + .join("assets") + .join("signature-2.jpg") + .to_string_lossy() + .into_owned(); let foreign = "/somewhere/else/logo.png"; conn.execute( "UPDATE app_settings SET logo_path = ?1, signature_path = ?2", @@ -1146,9 +1279,11 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); for _ in 0..2 { relativize_asset_paths(&conn, data).unwrap(); let (logo, sig): (String, String) = conn - .query_row("SELECT logo_path, signature_path FROM app_settings", [], |r| { - Ok((r.get(0)?, r.get(1)?)) - }) + .query_row( + "SELECT logo_path, signature_path FROM app_settings", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) .unwrap(); assert_eq!(logo, "assets/logo-1.png"); assert_eq!(sig, foreign); @@ -1165,7 +1300,11 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); assert_eq!(json["signaturePath"], "assets/signature-2.jpg"); assert_eq!(json["vendorName"], "Acme"); let bad: String = conn - .query_row("SELECT vendor_snapshot FROM invoices WHERE number = 'A-2'", [], |r| r.get(0)) + .query_row( + "SELECT vendor_snapshot FROM invoices WHERE number = 'A-2'", + [], + |r| r.get(0), + ) .unwrap(); assert_eq!(bad, "not json"); } diff --git a/src-tauri/src/gst.rs b/src-tauri/src/gst.rs index 951a03f..5129b96 100644 --- a/src-tauri/src/gst.rs +++ b/src-tauri/src/gst.rs @@ -75,7 +75,11 @@ pub fn derive_tax_type(registration: &str, supplier_state: &str, pos_state: &str if registration == "unregistered" { return TaxType::None; } - let pos = if pos_state.trim().is_empty() { supplier_state } else { pos_state }; + let pos = if pos_state.trim().is_empty() { + supplier_state + } else { + pos_state + }; if pos == supplier_state { TaxType::CgstSgst } else { @@ -225,8 +229,26 @@ pub fn compute_totals( } const ONES: [&str; 20] = [ - "", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven", - "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen", + "", + "One", + "Two", + "Three", + "Four", + "Five", + "Six", + "Seven", + "Eight", + "Nine", + "Ten", + "Eleven", + "Twelve", + "Thirteen", + "Fourteen", + "Fifteen", + "Sixteen", + "Seventeen", + "Eighteen", + "Nineteen", ]; const TENS: [&str; 10] = [ @@ -353,9 +375,18 @@ mod tests { #[test] fn tax_type_serde_strings() { - assert_eq!(serde_json::to_string(&TaxType::CgstSgst).unwrap(), "\"cgst_sgst\""); - assert_eq!(serde_json::from_str::("\"igst\"").unwrap(), TaxType::Igst); - assert_eq!(serde_json::from_str::("\"none\"").unwrap(), TaxType::None); + assert_eq!( + serde_json::to_string(&TaxType::CgstSgst).unwrap(), + "\"cgst_sgst\"" + ); + assert_eq!( + serde_json::from_str::("\"igst\"").unwrap(), + TaxType::Igst + ); + assert_eq!( + serde_json::from_str::("\"none\"").unwrap(), + TaxType::None + ); } #[test] @@ -384,17 +415,29 @@ mod tests { assert_eq!(validate_gstin("29abcde1234f1zw"), Ok(())); // The algorithm gives W for this body, so a trailing 5 is a typo. assert_eq!(validate_gstin("29ABCDE1234F1ZW"), Ok(())); - assert!(validate_gstin("29ABCDE1234F1Z5").unwrap_err().contains("check digit")); - assert!(validate_gstin("27AAPFU0939F1ZX").unwrap_err().contains("check digit")); + assert!(validate_gstin("29ABCDE1234F1Z5") + .unwrap_err() + .contains("check digit")); + assert!(validate_gstin("27AAPFU0939F1ZX") + .unwrap_err() + .contains("check digit")); } #[test] fn gstin_rejects_bad_shapes() { assert!(validate_gstin("").unwrap_err().contains("15 characters")); - assert!(validate_gstin("27AAPFU0939F1Z").unwrap_err().contains("15 characters")); - assert!(validate_gstin("27AAPFU0939F1ZVV").unwrap_err().contains("15 characters")); - assert!(validate_gstin("2XAAPFU0939F1ZV").unwrap_err().contains("format")); - assert!(validate_gstin("27AAPFU0939F1AV").unwrap_err().contains("format")); + assert!(validate_gstin("27AAPFU0939F1Z") + .unwrap_err() + .contains("15 characters")); + assert!(validate_gstin("27AAPFU0939F1ZVV") + .unwrap_err() + .contains("15 characters")); + assert!(validate_gstin("2XAAPFU0939F1ZV") + .unwrap_err() + .contains("format")); + assert!(validate_gstin("27AAPFU0939F1AV") + .unwrap_err() + .contains("format")); // Valid shape and checksum but state 99 does not exist. let body = b"99AAPFU0939F1Z"; let mut g = String::from_utf8(body.to_vec()).unwrap(); diff --git a/src-tauri/src/integrations/erpnext/client.rs b/src-tauri/src/integrations/erpnext/client.rs index 41d8407..8d715ac 100644 --- a/src-tauri/src/integrations/erpnext/client.rs +++ b/src-tauri/src/integrations/erpnext/client.rs @@ -49,7 +49,10 @@ impl Default for ClientOptions { /// Plain http is only for a local development site; anything else must use https. pub fn is_local_dev_host(host: &str) -> bool { let host = host.trim_end_matches('.').to_ascii_lowercase(); - host == "localhost" || host == "127.0.0.1" || host.ends_with(".localhost") || host.ends_with(".test") + host == "localhost" + || host == "127.0.0.1" + || host.ends_with(".localhost") + || host.ends_with(".test") } /// Normalises what the user typed into `scheme://host[:port][/prefix]` without a trailing slash. @@ -63,10 +66,15 @@ pub fn normalize_base_url(raw: &str) -> Result { raw.to_string() } else { let host = raw.split(['/', ':']).next().unwrap_or(""); - let scheme = if is_local_dev_host(host) { "http" } else { "https" }; + let scheme = if is_local_dev_host(host) { + "http" + } else { + "https" + }; format!("{scheme}://{raw}") }; - let mut url = Url::parse(&with_scheme).map_err(|e| ErpError::config(format!("The ERPNext address is not valid: {e}")))?; + let mut url = Url::parse(&with_scheme) + .map_err(|e| ErpError::config(format!("The ERPNext address is not valid: {e}")))?; let host = url .host_str() .ok_or_else(|| ErpError::config("The ERPNext address has no host name."))? @@ -80,10 +88,16 @@ pub fn normalize_base_url(raw: &str) -> Result { )); } } - other => return Err(ErpError::config(format!("Unsupported address scheme \"{other}\"; use https://."))), + other => { + return Err(ErpError::config(format!( + "Unsupported address scheme \"{other}\"; use https://." + ))) + } } if !url.username().is_empty() || url.password().is_some() { - return Err(ErpError::config("Do not put credentials in the address; use the API key and secret fields.")); + return Err(ErpError::config( + "Do not put credentials in the address; use the API key and secret fields.", + )); } url.set_query(None); url.set_fragment(None); @@ -114,7 +128,9 @@ pub struct ErpClient { impl std::fmt::Debug for ErpClient { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ErpClient").field("base", &self.base.as_str()).finish_non_exhaustive() + f.debug_struct("ErpClient") + .field("base", &self.base.as_str()) + .finish_non_exhaustive() } } @@ -127,12 +143,18 @@ impl ErpClient { opts: ClientOptions, ) -> Result { ensure_crypto_provider(); - let base = Url::parse(&normalize_base_url(base_url)?).map_err(|e| ErpError::config(e.to_string()))?; + let base = Url::parse(&normalize_base_url(base_url)?) + .map_err(|e| ErpError::config(e.to_string()))?; if api_key.trim().is_empty() || !api_secret.is_set() { return Err(ErpError::config("Enter the API key and API secret.")); } - let mut auth = HeaderValue::from_str(&format!("token {}:{}", api_key.trim(), api_secret.expose())) - .map_err(|_| ErpError::config("The API key or secret contains characters that cannot be sent."))?; + let mut auth = + HeaderValue::from_str(&format!("token {}:{}", api_key.trim(), api_secret.expose())) + .map_err(|_| { + ErpError::config( + "The API key or secret contains characters that cannot be sent.", + ) + })?; auth.set_sensitive(true); let mut headers = HeaderMap::new(); @@ -152,13 +174,18 @@ impl ErpClient { let certs = reqwest::Certificate::from_pem_bundle(extra_ca_pem.trim().as_bytes()) .map_err(|_| ErpError::config("The extra CA certificate is not valid PEM."))?; if certs.is_empty() { - return Err(ErpError::config("The extra CA certificate field holds no certificate.")); + return Err(ErpError::config( + "The extra CA certificate field holds no certificate.", + )); } builder = builder.tls_certs_merge(certs); } - let http = builder - .build() - .map_err(|e| ErpError::config(format!("Could not set up the HTTP client: {}", error_chain(&e))))?; + let http = builder.build().map_err(|e| { + ErpError::config(format!( + "Could not set up the HTTP client: {}", + error_chain(&e) + )) + })?; Ok(ErpClient { http, base, @@ -168,7 +195,13 @@ impl ErpClient { } pub fn from_config(cfg: &ErpnextConfig) -> Result { - Self::new(&cfg.base_url, &cfg.api_key, &cfg.api_secret, &cfg.extra_ca_pem, ClientOptions::default()) + Self::new( + &cfg.base_url, + &cfg.api_key, + &cfg.api_secret, + &cfg.extra_ca_pem, + ClientOptions::default(), + ) } pub fn base_url(&self) -> &str { @@ -184,22 +217,38 @@ impl ErpClient { } /// GET, retried on 429, 5xx and timeouts. - pub async fn get(&self, segments: &[&str], query: &[(&str, String)]) -> Result { - self.send(Method::GET, segments, query, Payload::None, true).await + pub async fn get( + &self, + segments: &[&str], + query: &[(&str, String)], + ) -> Result { + self.send(Method::GET, segments, query, Payload::None, true) + .await } /// POST with a JSON body. 429 is always retried (the server refused before doing anything). 5xx and /// timeouts are retried only when `idempotent` is true, because the server may have processed the /// request: a mirrored-name Sales Invoice is idempotent (a repeat is a 409), a series-named one is not. - pub async fn post(&self, segments: &[&str], body: &Value, idempotent: bool) -> Result { - self.send(Method::POST, segments, &[], Payload::Json(body), idempotent).await + pub async fn post( + &self, + segments: &[&str], + body: &Value, + idempotent: bool, + ) -> Result { + self.send(Method::POST, segments, &[], Payload::Json(body), idempotent) + .await } /// Multipart POST carrying one file part named `file` (the name Frappe's `upload_file` reads; the file /// name travels in the part's Content-Disposition) plus plain text fields. Never retried after a 5xx or /// timeout, because an upload that may have landed would attach the file twice. - pub async fn post_file(&self, segments: &[&str], upload: &Upload<'_>) -> Result { - self.send(Method::POST, segments, &[], Payload::Upload(upload), false).await + pub async fn post_file( + &self, + segments: &[&str], + upload: &Upload<'_>, + ) -> Result { + self.send(Method::POST, segments, &[], Payload::Upload(upload), false) + .await } async fn send( @@ -260,7 +309,8 @@ impl ErpClient { if status.is_success() { return self.parse_success(&text); } - let retryable = status.as_u16() == 429 || (status.is_server_error() && retry_ambiguous); + let retryable = + status.as_u16() == 429 || (status.is_server_error() && retry_ambiguous); if retryable && can_retry { self.backoff(attempt, retry_after).await; attempt += 1; @@ -287,7 +337,10 @@ impl ErpClient { } async fn backoff(&self, attempt: u32, retry_after: Option) { - let exp = self.opts.backoff_base.saturating_mul(1u32 << attempt.min(10)); + let exp = self + .opts + .backoff_base + .saturating_mul(1u32 << attempt.min(10)); let delay = retry_after.unwrap_or(exp).min(self.opts.max_backoff); tokio::time::sleep(delay).await; } @@ -311,7 +364,10 @@ impl ErpClient { fn map_transport(&self, e: &reqwest::Error) -> ErpError { let chain = error_chain(e); let err = if e.is_timeout() { - ErpError::new(ErrorKind::Timeout, "The ERPNext server did not answer in time.") + ErpError::new( + ErrorKind::Timeout, + "The ERPNext server did not answer in time.", + ) } else if e.is_connect() { let lower = chain.to_ascii_lowercase(); let hint = if lower.contains("certificate") || lower.contains("unknownissuer") { @@ -319,9 +375,15 @@ impl ErpClient { } else { "" }; - ErpError::new(ErrorKind::Network, format!("Could not connect to the ERPNext server ({chain}).{hint}")) + ErpError::new( + ErrorKind::Network, + format!("Could not connect to the ERPNext server ({chain}).{hint}"), + ) } else { - ErpError::new(ErrorKind::Network, format!("The request to ERPNext failed ({chain}).")) + ErpError::new( + ErrorKind::Network, + format!("The request to ERPNext failed ({chain})."), + ) }; self.scrub(err) } @@ -352,9 +414,18 @@ mod tests { #[test] fn https_is_accepted_and_normalised() { - assert_eq!(normalize_base_url(" https://Erp.Example.com/ ").unwrap(), "https://erp.example.com"); - assert_eq!(normalize_base_url("erp.example.com").unwrap(), "https://erp.example.com"); - assert_eq!(normalize_base_url("https://erp.example.com:8443/sub/?a=1#x").unwrap(), "https://erp.example.com:8443/sub"); + assert_eq!( + normalize_base_url(" https://Erp.Example.com/ ").unwrap(), + "https://erp.example.com" + ); + assert_eq!( + normalize_base_url("erp.example.com").unwrap(), + "https://erp.example.com" + ); + assert_eq!( + normalize_base_url("https://erp.example.com:8443/sub/?a=1#x").unwrap(), + "https://erp.example.com:8443/sub" + ); } #[test] @@ -370,14 +441,26 @@ mod tests { let url = normalize_base_url(ok).unwrap_or_else(|e| panic!("{ok}: {e}")); assert!(url.starts_with("http://"), "{ok} -> {url}"); } - for bad in ["http://erp.example.com", "http://192.168.1.10:8000", "http://localhost.evil.com", "http://notlocalhost"] { + for bad in [ + "http://erp.example.com", + "http://192.168.1.10:8000", + "http://localhost.evil.com", + "http://notlocalhost", + ] { assert!(normalize_base_url(bad).is_err(), "{bad} must be refused"); } } #[test] fn other_schemes_credentials_and_blanks_are_refused() { - for bad in ["ftp://erp.example.com", "file:///etc/passwd", "https://user:pw@erp.example.com", "", " ", "https://"] { + for bad in [ + "ftp://erp.example.com", + "file:///etc/passwd", + "https://user:pw@erp.example.com", + "", + " ", + "https://", + ] { assert!(normalize_base_url(bad).is_err(), "{bad:?} must be refused"); } } @@ -391,8 +474,14 @@ mod tests { .unwrap(); assert_eq!(value["message"], "Sample@example.com"); let raw = mock.requests.lock().unwrap()[0].to_ascii_lowercase(); - assert!(raw.starts_with("get /api/method/frappe.auth.get_logged_user "), "{raw}"); - assert!(raw.contains("authorization: token thekey:thesecret"), "{raw}"); + assert!( + raw.starts_with("get /api/method/frappe.auth.get_logged_user "), + "{raw}" + ); + assert!( + raw.contains("authorization: token thekey:thesecret"), + "{raw}" + ); assert!(raw.contains("accept: application/json"), "{raw}"); } @@ -400,11 +489,19 @@ mod tests { async fn path_segments_are_percent_encoded_once() { let mock = serve(vec![reply(200, json!({ "data": [] }))]).await; client(&mock) - .get(&["api", "resource", "Sales Invoice", "INV/2026-001"], &[("limit_page_length", "0".into())]) + .get( + &["api", "resource", "Sales Invoice", "INV/2026-001"], + &[("limit_page_length", "0".into())], + ) .await .unwrap(); let raw = mock.requests.lock().unwrap()[0].clone(); - assert!(raw.starts_with("GET /api/resource/Sales%20Invoice/INV%2F2026-001?limit_page_length=0 "), "{raw}"); + assert!( + raw.starts_with( + "GET /api/resource/Sales%20Invoice/INV%2F2026-001?limit_page_length=0 " + ), + "{raw}" + ); } #[tokio::test] @@ -412,15 +509,25 @@ mod tests { let mut limited = reply(429, json!({ "message": "slow down" })); limited.headers.push(("Retry-After", "0".into())); let mock = serve(vec![limited, reply(200, json!({ "message": "ok" }))]).await; - let value = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap(); + let value = client(&mock) + .get(&["api", "method", "ping"], &[]) + .await + .unwrap(); assert_eq!(value["message"], "ok"); assert_eq!(mock.hits.load(Ordering::SeqCst), 2); } #[tokio::test] async fn persistent_500_exhausts_the_retries() { - let mock = serve(vec![reply(500, json!({ "exception": "frappe.exceptions.ValidationError: boom" }))]).await; - let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err(); + let mock = serve(vec![reply( + 500, + json!({ "exception": "frappe.exceptions.ValidationError: boom" }), + )]) + .await; + let err = client(&mock) + .get(&["api", "method", "ping"], &[]) + .await + .unwrap_err(); assert_eq!(err.kind, ErrorKind::Server); assert_eq!(err.message, "boom"); assert_eq!(mock.hits.load(Ordering::SeqCst), 4, "1 attempt + 3 retries"); @@ -428,9 +535,17 @@ mod tests { #[tokio::test] async fn validation_errors_are_not_retried() { - let mock = serve(vec![reply(417, json!({ "_error_message": "Rate is required" }))]).await; + let mock = serve(vec![reply( + 417, + json!({ "_error_message": "Rate is required" }), + )]) + .await; let err = client(&mock) - .post(&["api", "resource", "Sales Invoice"], &json!({ "a": 1 }), true) + .post( + &["api", "resource", "Sales Invoice"], + &json!({ "a": 1 }), + true, + ) .await .unwrap_err(); assert_eq!(err.kind, ErrorKind::Validation); @@ -440,25 +555,42 @@ mod tests { #[tokio::test] async fn timeouts_are_retried_for_reads_but_not_for_ambiguous_writes() { - let slow = Reply { delay_ms: 2_000, ..reply(200, json!({})) }; + let slow = Reply { + delay_ms: 2_000, + ..reply(200, json!({})) + }; let mock = serve(vec![slow]).await; - let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err(); + let err = client(&mock) + .get(&["api", "method", "ping"], &[]) + .await + .unwrap_err(); assert_eq!(err.kind, ErrorKind::Timeout); assert_eq!(mock.hits.load(Ordering::SeqCst), 4); - let slow = Reply { delay_ms: 2_000, ..reply(200, json!({})) }; + let slow = Reply { + delay_ms: 2_000, + ..reply(200, json!({})) + }; let mock = serve(vec![slow]).await; let err = client(&mock) .post(&["api", "resource", "Sales Invoice"], &json!({}), false) .await .unwrap_err(); assert_eq!(err.kind, ErrorKind::Timeout); - assert_eq!(mock.hits.load(Ordering::SeqCst), 1, "a write that may have landed is not repeated"); + assert_eq!( + mock.hits.load(Ordering::SeqCst), + 1, + "a write that may have landed is not repeated" + ); } #[tokio::test] async fn non_idempotent_write_retries_429_but_not_500() { - let mock = serve(vec![reply(429, json!({})), reply(200, json!({ "data": { "name": "X" } }))]).await; + let mock = serve(vec![ + reply(429, json!({})), + reply(200, json!({ "data": { "name": "X" } })), + ]) + .await; let value = client(&mock) .post(&["api", "resource", "Sales Invoice"], &json!({}), false) .await @@ -478,11 +610,20 @@ mod tests { #[tokio::test] async fn redirects_are_reported_not_followed() { let mut moved = reply(301, json!({})); - moved.headers.push(("Location", "https://erp.example.com/".into())); + moved + .headers + .push(("Location", "https://erp.example.com/".into())); let mock = serve(vec![moved]).await; - let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err(); + let err = client(&mock) + .get(&["api", "method", "ping"], &[]) + .await + .unwrap_err(); assert_eq!(err.kind, ErrorKind::Protocol); - assert!(err.message.contains("https://erp.example.com/"), "{}", err.message); + assert!( + err.message.contains("https://erp.example.com/"), + "{}", + err.message + ); assert_eq!(mock.hits.load(Ordering::SeqCst), 1); } @@ -491,7 +632,10 @@ mod tests { let mut page = reply(200, json!({})); page.body = "login".into(); let mock = serve(vec![page]).await; - let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err(); + let err = client(&mock) + .get(&["api", "method", "ping"], &[]) + .await + .unwrap_err(); assert_eq!(err.kind, ErrorKind::Protocol); } @@ -500,7 +644,14 @@ mod tests { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); drop(listener); - let c = ErpClient::new(&format!("http://127.0.0.1:{port}"), "thekey", &Secret::new("thesecret"), "", fast_opts()).unwrap(); + let c = ErpClient::new( + &format!("http://127.0.0.1:{port}"), + "thekey", + &Secret::new("thesecret"), + "", + fast_opts(), + ) + .unwrap(); let err = c.get(&["api", "method", "ping"], &[]).await.unwrap_err(); assert_eq!(err.kind, ErrorKind::Network); assert!(!err.to_string().contains("thesecret")); @@ -509,9 +660,30 @@ mod tests { #[test] fn credentials_are_required_and_a_bad_ca_is_refused() { - assert!(ErpClient::new("https://erp.example.com", "", &Secret::new("s"), "", fast_opts()).is_err()); - assert!(ErpClient::new("https://erp.example.com", "k", &Secret::default(), "", fast_opts()).is_err()); - let err = ErpClient::new("https://erp.example.com", "k", &Secret::new("s"), "not a certificate", fast_opts()).unwrap_err(); + assert!(ErpClient::new( + "https://erp.example.com", + "", + &Secret::new("s"), + "", + fast_opts() + ) + .is_err()); + assert!(ErpClient::new( + "https://erp.example.com", + "k", + &Secret::default(), + "", + fast_opts() + ) + .is_err()); + let err = ErpClient::new( + "https://erp.example.com", + "k", + &Secret::new("s"), + "not a certificate", + fast_opts(), + ) + .unwrap_err(); assert_eq!(err.kind, ErrorKind::Config); } } diff --git a/src-tauri/src/integrations/erpnext/config.rs b/src-tauri/src/integrations/erpnext/config.rs index c6957d2..5549566 100644 --- a/src-tauri/src/integrations/erpnext/config.rs +++ b/src-tauri/src/integrations/erpnext/config.rs @@ -29,7 +29,11 @@ impl Secret { impl std::fmt::Debug for Secret { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(if self.is_set() { "Secret([redacted])" } else { "Secret(unset)" }) + f.write_str(if self.is_set() { + "Secret([redacted])" + } else { + "Secret(unset)" + }) } } @@ -150,7 +154,9 @@ impl ErpnextConfig { if let Some(u) = self.uom_map.get(unit).filter(|u| !u.trim().is_empty()) { return u.trim().to_string(); } - default_uom_map().remove(unit).unwrap_or_else(|| "Nos".to_string()) + default_uom_map() + .remove(unit) + .unwrap_or_else(|| "Nos".to_string()) } pub fn view(&self) -> ErpnextConfigView { @@ -182,7 +188,8 @@ impl ErpnextConfig { attach_pdf: self.attach_pdf, auto_push_on_issue: self.auto_push_on_issue, create_missing_customers: self.create_missing_customers, - last_detect_result: serde_json::from_str(&self.last_detect_result).unwrap_or(Value::Null), + last_detect_result: serde_json::from_str(&self.last_detect_result) + .unwrap_or(Value::Null), } } } @@ -296,7 +303,11 @@ fn yes() -> bool { } fn origin_of(url: &str) -> String { - url.split('/').take(3).collect::>().join("/").to_ascii_lowercase() + url.split('/') + .take(3) + .collect::>() + .join("/") + .to_ascii_lowercase() } impl ErpnextConfigInput { @@ -469,8 +480,11 @@ mod tests { use super::*; fn input(extra: serde_json::Value) -> ErpnextConfigInput { - let mut base = serde_json::json!({ "baseUrl": "https://erp.example.com/", "apiKey": "key1" }); - base.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + let mut base = + serde_json::json!({ "baseUrl": "https://erp.example.com/", "apiKey": "key1" }); + base.as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); serde_json::from_value(base).unwrap() } @@ -480,7 +494,10 @@ mod tests { #[test] fn debug_never_prints_the_secret() { - let cfg = ErpnextConfig { api_secret: Secret::new("s3cr3t-value"), ..Default::default() }; + let cfg = ErpnextConfig { + api_secret: Secret::new("s3cr3t-value"), + ..Default::default() + }; assert!(!format!("{cfg:?}").contains("s3cr3t-value")); assert!(!format!("{:?}", Secret::new("s3cr3t-value")).contains("s3cr3t-value")); } @@ -489,7 +506,12 @@ mod tests { fn defaults_load_from_the_migration_row() { let cfg = load(&db()).unwrap(); assert_eq!(cfg.naming_mode, NamingMode::Mirror); - assert!(!cfg.submit_on_push && cfg.attach_pdf && !cfg.auto_push_on_issue && cfg.create_missing_customers); + assert!( + !cfg.submit_on_push + && cfg.attach_pdf + && !cfg.auto_push_on_issue + && cfg.create_missing_customers + ); assert_eq!(cfg.uom_for("hour"), "Hour"); assert_eq!(cfg.uom_for("session"), "Nos"); assert!(!cfg.api_secret.is_set()); @@ -519,34 +541,52 @@ mod tests { #[test] fn blank_secret_keeps_and_explicit_values_replace_or_clear() { let conn = db(); - let first = input(serde_json::json!({ "apiSecret": "one" })).resolve(&load(&conn).unwrap()).unwrap(); + let first = input(serde_json::json!({ "apiSecret": "one" })) + .resolve(&load(&conn).unwrap()) + .unwrap(); save(&conn, &first).unwrap(); - let keep = input(serde_json::json!({ "apiSecret": " " })).resolve(&load(&conn).unwrap()).unwrap(); + let keep = input(serde_json::json!({ "apiSecret": " " })) + .resolve(&load(&conn).unwrap()) + .unwrap(); assert_eq!(keep.api_secret.expose(), "one"); - let replace = input(serde_json::json!({ "apiSecret": "two" })).resolve(&load(&conn).unwrap()).unwrap(); + let replace = input(serde_json::json!({ "apiSecret": "two" })) + .resolve(&load(&conn).unwrap()) + .unwrap(); assert_eq!(replace.api_secret.expose(), "two"); - let clear = input(serde_json::json!({ "clearSecret": true })).resolve(&load(&conn).unwrap()).unwrap(); + let clear = input(serde_json::json!({ "clearSecret": true })) + .resolve(&load(&conn).unwrap()) + .unwrap(); assert!(!clear.api_secret.is_set()); } #[test] fn stored_secret_is_not_reused_for_another_server() { let conn = db(); - let first = input(serde_json::json!({ "apiSecret": "one" })).resolve(&load(&conn).unwrap()).unwrap(); + let first = input(serde_json::json!({ "apiSecret": "one" })) + .resolve(&load(&conn).unwrap()) + .unwrap(); save(&conn, &first).unwrap(); - let moved = input(serde_json::json!({ "baseUrl": "https://other.example.org" })).resolve(&load(&conn).unwrap()); + let moved = input(serde_json::json!({ "baseUrl": "https://other.example.org" })) + .resolve(&load(&conn).unwrap()); assert!(moved.unwrap_err().contains("API secret")); // The same server with a different path or case is still the same origin. - let same = input(serde_json::json!({ "baseUrl": "HTTPS://ERP.example.com" })).resolve(&load(&conn).unwrap()); + let same = input(serde_json::json!({ "baseUrl": "HTTPS://ERP.example.com" })) + .resolve(&load(&conn).unwrap()); assert!(same.is_ok()); } #[test] fn invalid_address_or_naming_mode_is_rejected() { let stored = ErpnextConfig::default(); - assert!(input(serde_json::json!({ "baseUrl": "http://erp.example.com" })).resolve(&stored).is_err()); - assert!(input(serde_json::json!({ "namingMode": "random" })).resolve(&stored).is_err()); + assert!( + input(serde_json::json!({ "baseUrl": "http://erp.example.com" })) + .resolve(&stored) + .is_err() + ); + assert!(input(serde_json::json!({ "namingMode": "random" })) + .resolve(&stored) + .is_err()); } #[test] diff --git a/src-tauri/src/integrations/erpnext/discovery.rs b/src-tauri/src/integrations/erpnext/discovery.rs index 399288f..6809701 100644 --- a/src-tauri/src/integrations/erpnext/discovery.rs +++ b/src-tauri/src/integrations/erpnext/discovery.rs @@ -20,12 +20,19 @@ impl Version { /// Parses `15.73.2`, `v16.0.0-dev` or `15.1`; anything after the numeric part is ignored. pub fn parse(s: &str) -> Option { let s = s.trim().trim_start_matches(['v', 'V']); - let numeric: String = s.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect(); + let numeric: String = s + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '.') + .collect(); let mut parts = numeric.split('.').filter(|p| !p.is_empty()); let major = parts.next()?.parse().ok()?; let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); - Some(Version { major, minor, patch }) + Some(Version { + major, + minor, + patch, + }) } } @@ -59,7 +66,10 @@ pub fn parse_versions(value: &Value) -> ServerVersions { let apps = value.get("message").unwrap_or(value); let version_of = |app: &str| -> Option { let entry = apps.get(app)?; - let v = entry.get("version").and_then(Value::as_str).or_else(|| entry.as_str())?; + let v = entry + .get("version") + .and_then(Value::as_str) + .or_else(|| entry.as_str())?; let v = v.trim(); (!v.is_empty()).then(|| v.to_string()) }; @@ -77,7 +87,9 @@ pub const IC_MAX_NUMBER_LEN: usize = 16; /// India Compliance: `^[^\W_][A-Za-z0-9\-\/]{0,15}$`. pub fn ic_number_ok(number: &str) -> bool { let mut chars = number.chars(); - let Some(first) = chars.next() else { return false }; + let Some(first) = chars.next() else { + return false; + }; first.is_alphanumeric() && number.chars().count() <= IC_MAX_NUMBER_LEN && chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '/') @@ -158,13 +170,22 @@ fn item(row: &Value, label_key: &str, detail: String) -> Option { let label = text(row, label_key); Some(OptionItem { name: name.to_string(), - label: if label.is_empty() { name.to_string() } else { label.to_string() }, + label: if label.is_empty() { + name.to_string() + } else { + label.to_string() + }, detail, }) } fn join(parts: &[&str]) -> String { - parts.iter().filter(|p| !p.is_empty()).cloned().collect::>().join(" · ") + parts + .iter() + .filter(|p| !p.is_empty()) + .cloned() + .collect::>() + .join(" · ") } /// Parses the `naming_series` options out of a `getdoctype` response: newline-separated, first line blank. @@ -173,23 +194,41 @@ pub fn parse_naming_series(doctype_response: &Value) -> Vec { .get("docs") .or_else(|| doctype_response.get("message").and_then(|m| m.get("docs"))) .and_then(Value::as_array); - let Some(doc) = docs.and_then(|d| d.first()) else { return Vec::new() }; - let Some(fields) = doc.get("fields").and_then(Value::as_array) else { return Vec::new() }; - let Some(field) = fields.iter().find(|f| text(f, "fieldname") == "naming_series") else { + let Some(doc) = docs.and_then(|d| d.first()) else { + return Vec::new(); + }; + let Some(fields) = doc.get("fields").and_then(Value::as_array) else { + return Vec::new(); + }; + let Some(field) = fields + .iter() + .find(|f| text(f, "fieldname") == "naming_series") + else { return Vec::new(); }; text(field, "options") .lines() .map(str::trim) .filter(|l| !l.is_empty()) - .map(|l| OptionItem { name: l.to_string(), label: l.to_string(), detail: format!("e.g. {} digits", naming_series_expanded_len(l)) }) + .map(|l| OptionItem { + name: l.to_string(), + label: l.to_string(), + detail: format!("e.g. {} digits", naming_series_expanded_len(l)), + }) .collect() } impl ErpClient { pub async fn get_logged_user(&self) -> Result { - let v = self.get(&["api", "method", "frappe.auth.get_logged_user"], &[]).await?; - let user = v.get("message").and_then(Value::as_str).unwrap_or("").trim().to_string(); + let v = self + .get(&["api", "method", "frappe.auth.get_logged_user"], &[]) + .await?; + let user = v + .get("message") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_string(); if user.is_empty() || user.eq_ignore_ascii_case("guest") { return Err(ErpError { kind: ErrorKind::Auth, @@ -202,7 +241,12 @@ impl ErpClient { } pub async fn get_versions(&self) -> Result { - let v = self.get(&["api", "method", "frappe.utils.change_log.get_versions"], &[]).await?; + let v = self + .get( + &["api", "method", "frappe.utils.change_log.get_versions"], + &[], + ) + .await?; Ok(parse_versions(&v)) } @@ -223,7 +267,10 @@ impl ErpClient { query.push(("filters", filters.to_string())); } let v = self.get(&["api", "resource", doctype], &query).await?; - Ok(v.get("data").and_then(Value::as_array).cloned().unwrap_or_default()) + Ok(v.get("data") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default()) } pub async fn load_options(&self, company: &str) -> Result { @@ -234,52 +281,119 @@ impl ErpClient { ($list:literal, $target:expr, $call:expr) => { match $call.await { Ok(rows) => $target = rows, - Err(e) if matches!(e.kind, ErrorKind::Auth | ErrorKind::Network | ErrorKind::Timeout | ErrorKind::Config | ErrorKind::Protocol) => { + Err(e) + if matches!( + e.kind, + ErrorKind::Auth + | ErrorKind::Network + | ErrorKind::Timeout + | ErrorKind::Config + | ErrorKind::Protocol + ) => + { return Err(e) } - Err(e) => out.errors.push(OptionError { list: $list.to_string(), message: e.to_string() }), + Err(e) => out.errors.push(OptionError { + list: $list.to_string(), + message: e.to_string(), + }), } }; } load!("companies", out.companies, async { - self.list_resource("Company", &["name", "company_name", "abbr", "country", "default_currency"], Value::Null, "name asc") - .await - .map(|rows| { - rows.iter() - .filter_map(|r| item(r, "company_name", join(&[text(r, "abbr"), text(r, "default_currency")]))) - .collect() - }) + self.list_resource( + "Company", + &[ + "name", + "company_name", + "abbr", + "country", + "default_currency", + ], + Value::Null, + "name asc", + ) + .await + .map(|rows| { + rows.iter() + .filter_map(|r| { + item( + r, + "company_name", + join(&[text(r, "abbr"), text(r, "default_currency")]), + ) + }) + .collect() + }) }); load!("itemGroups", out.item_groups, async { self.list_resource("Item Group", &["name"], Value::Null, "name asc") .await - .map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect()) + .map(|rows| { + rows.iter() + .filter_map(|r| item(r, "name", String::new())) + .collect() + }) }); load!("uoms", out.uoms, async { self.list_resource("UOM", &["name"], json!([["enabled", "=", 1]]), "name asc") .await - .map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect()) + .map(|rows| { + rows.iter() + .filter_map(|r| item(r, "name", String::new())) + .collect() + }) }); load!("priceLists", out.price_lists, async { - self.list_resource("Price List", &["name"], json!([["enabled", "=", 1], ["selling", "=", 1]]), "name asc") - .await - .map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect()) + self.list_resource( + "Price List", + &["name"], + json!([["enabled", "=", 1], ["selling", "=", 1]]), + "name asc", + ) + .await + .map(|rows| { + rows.iter() + .filter_map(|r| item(r, "name", String::new())) + .collect() + }) }); load!("customerGroups", out.customer_groups, async { - self.list_resource("Customer Group", &["name"], json!([["is_group", "=", 0]]), "name asc") - .await - .map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect()) + self.list_resource( + "Customer Group", + &["name"], + json!([["is_group", "=", 0]]), + "name asc", + ) + .await + .map(|rows| { + rows.iter() + .filter_map(|r| item(r, "name", String::new())) + .collect() + }) }); load!("territories", out.territories, async { - self.list_resource("Territory", &["name"], json!([["is_group", "=", 0]]), "name asc") - .await - .map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect()) + self.list_resource( + "Territory", + &["name"], + json!([["is_group", "=", 0]]), + "name asc", + ) + .await + .map(|rows| { + rows.iter() + .filter_map(|r| item(r, "name", String::new())) + .collect() + }) }); load!("namingSeries", out.naming_series, async { - self.get(&["api", "method", "frappe.desk.form.load.getdoctype"], &[("doctype", "Sales Invoice".to_string())]) - .await - .map(|v| parse_naming_series(&v)) + self.get( + &["api", "method", "frappe.desk.form.load.getdoctype"], + &[("doctype", "Sales Invoice".to_string())], + ) + .await + .map(|v| parse_naming_series(&v)) }); if company.is_empty() { @@ -291,11 +405,32 @@ impl ErpClient { ["Dynamic Link", "link_name", "=", company], ["disabled", "=", 0] ]); - let base = ["name", "address_title", "address_line1", "city", "state", "pincode"]; - let with_gstin = ["name", "address_title", "address_line1", "city", "state", "pincode", "gstin"]; + let base = [ + "name", + "address_title", + "address_line1", + "city", + "state", + "pincode", + ]; + let with_gstin = [ + "name", + "address_title", + "address_line1", + "city", + "state", + "pincode", + "gstin", + ]; // `gstin` is a custom field added by India Compliance; a plain site rejects it ("Field not permitted"). - let rows = match self.list_resource("Address", &with_gstin, filters.clone(), "name asc").await { - Err(e) if e.kind == ErrorKind::Validation => self.list_resource("Address", &base, filters, "name asc").await?, + let rows = match self + .list_resource("Address", &with_gstin, filters.clone(), "name asc") + .await + { + Err(e) if e.kind == ErrorKind::Validation => { + self.list_resource("Address", &base, filters, "name asc") + .await? + } other => other?, }; Ok::<_, ErpError>( @@ -304,7 +439,13 @@ impl ErpClient { item( r, "address_title", - join(&[text(r, "address_line1"), text(r, "city"), text(r, "state"), text(r, "pincode"), text(r, "gstin")]), + join(&[ + text(r, "address_line1"), + text(r, "city"), + text(r, "state"), + text(r, "pincode"), + text(r, "gstin"), + ]), ) }) .collect(), @@ -314,21 +455,39 @@ impl ErpClient { self.list_resource( "Account", &["name", "account_name", "account_type"], - json!([["company", "=", company], ["root_type", "=", "Income"], ["is_group", "=", 0], ["disabled", "=", 0]]), + json!([ + ["company", "=", company], + ["root_type", "=", "Income"], + ["is_group", "=", 0], + ["disabled", "=", 0] + ]), "name asc", ) .await - .map(|rows| rows.iter().filter_map(|r| item(r, "account_name", text(r, "account_type").to_string())).collect()) + .map(|rows| { + rows.iter() + .filter_map(|r| item(r, "account_name", text(r, "account_type").to_string())) + .collect() + }) }); load!("taxAccounts", out.tax_accounts, async { self.list_resource( "Account", &["name", "account_name", "account_type"], - json!([["company", "=", company], ["account_type", "=", "Tax"], ["is_group", "=", 0], ["disabled", "=", 0]]), + json!([ + ["company", "=", company], + ["account_type", "=", "Tax"], + ["is_group", "=", 0], + ["disabled", "=", 0] + ]), "name asc", ) .await - .map(|rows| rows.iter().filter_map(|r| item(r, "account_name", text(r, "account_type").to_string())).collect()) + .map(|rows| { + rows.iter() + .filter_map(|r| item(r, "account_name", text(r, "account_type").to_string())) + .collect() + }) }); load!("taxTemplates", out.tax_templates, async { self.list_resource( @@ -338,17 +497,29 @@ impl ErpClient { "name asc", ) .await - .map(|rows| rows.iter().filter_map(|r| item(r, "title", String::new())).collect()) + .map(|rows| { + rows.iter() + .filter_map(|r| item(r, "title", String::new())) + .collect() + }) }); load!("costCenters", out.cost_centers, async { self.list_resource( "Cost Center", &["name", "cost_center_name"], - json!([["company", "=", company], ["is_group", "=", 0], ["disabled", "=", 0]]), + json!([ + ["company", "=", company], + ["is_group", "=", 0], + ["disabled", "=", 0] + ]), "name asc", ) .await - .map(|rows| rows.iter().filter_map(|r| item(r, "cost_center_name", String::new())).collect()) + .map(|rows| { + rows.iter() + .filter_map(|r| item(r, "cost_center_name", String::new())) + .collect() + }) }); Ok(out) } @@ -363,7 +534,10 @@ pub struct Warning { } fn warn(code: &str, message: impl Into) -> Warning { - Warning { code: code.to_string(), message: message.into() } + Warning { + code: code.to_string(), + message: message.into(), + } } /// Facts from the local database that the warnings need. @@ -390,7 +564,9 @@ pub struct RemoteFacts { pub fn check_gst_accounts(rows: &[Value], cfg: &ErpnextConfig) -> Vec { let for_company: Vec<&Value> = rows .iter() - .filter(|r| text(r, "company") == cfg.company && matches!(text(r, "account_type"), "" | "Output")) + .filter(|r| { + text(r, "company") == cfg.company && matches!(text(r, "account_type"), "" | "Output") + }) .collect(); if for_company.is_empty() { return vec![warn( @@ -419,7 +595,11 @@ pub fn check_gst_accounts(rows: &[Value], cfg: &ErpnextConfig) -> Vec { } /// Pure: turns the configuration and the facts gathered into the list shown after a connection test. -pub fn build_warnings(cfg: &ErpnextConfig, local: &LocalFacts, remote: &RemoteFacts) -> Vec { +pub fn build_warnings( + cfg: &ErpnextConfig, + local: &LocalFacts, + remote: &RemoteFacts, +) -> Vec { let mut w = Vec::new(); let features = remote.versions.as_ref().map(feature_switches); let ic = features.is_some_and(|f| f.india_compliance); @@ -449,11 +629,15 @@ pub fn build_warnings(cfg: &ErpnextConfig, local: &LocalFacts, remote: &RemoteFa } } } else if cfg.naming_series.is_empty() { - w.push(warn("naming_series_missing", "ERPNext series naming is selected but no naming series is chosen.")); + w.push(warn( + "naming_series_missing", + "ERPNext series naming is selected but no naming series is chosen.", + )); } if local.vendor_registered { - if cfg.cgst_account.is_empty() || cfg.sgst_account.is_empty() || cfg.igst_account.is_empty() { + if cfg.cgst_account.is_empty() || cfg.sgst_account.is_empty() || cfg.igst_account.is_empty() + { w.push(warn( "tax_accounts_missing", "The supplier is GST-registered but the CGST, SGST and IGST accounts are not all set.", @@ -479,9 +663,15 @@ pub fn build_warnings(cfg: &ErpnextConfig, local: &LocalFacts, remote: &RemoteFa } } if cfg.company_address.is_empty() { - w.push(warn("company_address_missing", "India Compliance needs a company address on every invoice; none is selected.")); + w.push(warn( + "company_address_missing", + "India Compliance needs a company address on every invoice; none is selected.", + )); } else if remote.company_address_count == Some(0) { - w.push(warn("company_address_missing", "No address is linked to the company on the site.")); + w.push(warn( + "company_address_missing", + "No address is linked to the company on the site.", + )); } match cfg.naming_mode { NamingMode::Mirror => { @@ -533,14 +723,21 @@ pub struct ConnectionTest { /// Runs the connection test: identity (a failure here fails the test), versions, then the checks that only /// produce warnings. -pub async fn test_connection(client: &ErpClient, cfg: &ErpnextConfig, local: &LocalFacts) -> Result { +pub async fn test_connection( + client: &ErpClient, + cfg: &ErpnextConfig, + local: &LocalFacts, +) -> Result { let user = client.get_logged_user().await?; let versions = match client.get_versions().await { Ok(v) => Some(v), Err(e) if e.kind == ErrorKind::Auth => return Err(e), Err(_) => None, }; - let mut remote = RemoteFacts { versions, ..Default::default() }; + let mut remote = RemoteFacts { + versions, + ..Default::default() + }; let features = remote.versions.as_ref().map(feature_switches); if features.is_some_and(|f| f.india_compliance) { if !cfg.company.is_empty() { @@ -548,7 +745,11 @@ pub async fn test_connection(client: &ErpClient, cfg: &ErpnextConfig, local: &Lo .list_resource( "Address", &["name"], - json!([["Dynamic Link", "link_doctype", "=", "Company"], ["Dynamic Link", "link_name", "=", cfg.company], ["disabled", "=", 0]]), + json!([ + ["Dynamic Link", "link_doctype", "=", "Company"], + ["Dynamic Link", "link_name", "=", cfg.company], + ["disabled", "=", 0] + ]), "name asc", ) .await @@ -560,7 +761,13 @@ pub async fn test_connection(client: &ErpClient, cfg: &ErpnextConfig, local: &Lo client .get(&["api", "resource", "GST Settings", "GST Settings"], &[]) .await - .map(|v| v.get("data").and_then(|d| d.get("gst_accounts")).and_then(Value::as_array).cloned().unwrap_or_default()) + .map(|v| { + v.get("data") + .and_then(|d| d.get("gst_accounts")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + }) .map_err(|e| e.to_string()), ); } @@ -568,7 +775,13 @@ pub async fn test_connection(client: &ErpClient, cfg: &ErpnextConfig, local: &Lo let warnings = build_warnings(cfg, local, &remote); let versions = remote.versions.unwrap_or_default(); let features = feature_switches(&versions); - Ok(ConnectionTest { user, india_compliance: features.india_compliance, features, versions, warnings }) + Ok(ConnectionTest { + user, + india_compliance: features.india_compliance, + features, + versions, + warnings, + }) } #[cfg(test)] @@ -608,10 +821,38 @@ mod tests { #[test] fn versions_parse_leniently() { - assert_eq!(Version::parse("15.73.2"), Some(Version { major: 15, minor: 73, patch: 2 })); - assert_eq!(Version::parse("v16.0.0-dev"), Some(Version { major: 16, minor: 0, patch: 0 })); - assert_eq!(Version::parse("15.1"), Some(Version { major: 15, minor: 1, patch: 0 })); - assert_eq!(Version::parse("15.73.0-beta.2"), Some(Version { major: 15, minor: 73, patch: 0 })); + assert_eq!( + Version::parse("15.73.2"), + Some(Version { + major: 15, + minor: 73, + patch: 2 + }) + ); + assert_eq!( + Version::parse("v16.0.0-dev"), + Some(Version { + major: 16, + minor: 0, + patch: 0 + }) + ); + assert_eq!( + Version::parse("15.1"), + Some(Version { + major: 15, + minor: 1, + patch: 0 + }) + ); + assert_eq!( + Version::parse("15.73.0-beta.2"), + Some(Version { + major: 15, + minor: 73, + patch: 0 + }) + ); assert_eq!(Version::parse("unknown"), None); } @@ -640,7 +881,11 @@ mod tests { assert_eq!(v.frappe.as_deref(), Some("15.73.0")); assert_eq!(v.erpnext.as_deref(), Some("15.121.6")); assert_eq!(v.india_compliance.as_deref(), Some("15.9.0")); - assert!(parse_versions(&json!({ "message": { "frappe": { "version": "15.0.0" } } })).india_compliance.is_none()); + assert!( + parse_versions(&json!({ "message": { "frappe": { "version": "15.0.0" } } })) + .india_compliance + .is_none() + ); } #[test] @@ -648,7 +893,16 @@ mod tests { for ok in ["INV/2026-001", "A", "1234567890123456", "AP-24-25/0001"] { assert!(ic_number_ok(ok), "{ok}"); } - for bad in ["", "12345678901234567", "_AB", "AB 01", "AB_01", "-AB", "/AB", "AB#1"] { + for bad in [ + "", + "12345678901234567", + "_AB", + "AB 01", + "AB_01", + "-AB", + "/AB", + "AB#1", + ] { assert!(!ic_number_ok(bad), "{bad}"); } } @@ -656,9 +910,18 @@ mod tests { #[test] fn naming_series_is_expanded_before_measuring() { assert_eq!(naming_series_expanded_len("SINV-.YY.-"), 5 + 2 + 1 + 5); - assert_eq!(naming_series_expanded_len("ACC-SINV-.YYYY.-.#####"), 9 + 4 + 1 + 5); - assert_eq!(naming_series_expanded_len("INV-.MM.-.DD.-.####"), 4 + 2 + 1 + 2 + 1 + 4); - assert_eq!(naming_series_expanded_len("LONGPREFIX-ABC-.YYYY.-.#####"), 15 + 4 + 1 + 5); + assert_eq!( + naming_series_expanded_len("ACC-SINV-.YYYY.-.#####"), + 9 + 4 + 1 + 5 + ); + assert_eq!( + naming_series_expanded_len("INV-.MM.-.DD.-.####"), + 4 + 2 + 1 + 2 + 1 + 4 + ); + assert_eq!( + naming_series_expanded_len("LONGPREFIX-ABC-.YYYY.-.#####"), + 15 + 4 + 1 + 5 + ); } #[test] @@ -668,33 +931,54 @@ mod tests { { "fieldname": "naming_series", "options": "\nSINV-.YY.-\nACC-SINV-.YYYY.-" } ]}]}); let items = parse_naming_series(&resp); - assert_eq!(items.iter().map(|i| i.name.as_str()).collect::>(), ["SINV-.YY.-", "ACC-SINV-.YYYY.-"]); + assert_eq!( + items.iter().map(|i| i.name.as_str()).collect::>(), + ["SINV-.YY.-", "ACC-SINV-.YYYY.-"] + ); assert!(parse_naming_series(&json!({})).is_empty()); } #[test] fn a_healthy_ic_setup_has_no_warnings() { - let local = LocalFacts { vendor_registered: true, invalid_numbers: vec![], next_number: Some("INV/2026-001".into()) }; - assert_eq!(build_warnings(&cfg(), &local, &ic_versions("15.73.0")), vec![]); + let local = LocalFacts { + vendor_registered: true, + invalid_numbers: vec![], + next_number: Some("INV/2026-001".into()), + }; + assert_eq!( + build_warnings(&cfg(), &local, &ic_versions("15.73.0")), + vec![] + ); } #[test] fn gst_accounts_not_configured_or_mismatched_are_flagged() { - let local = LocalFacts { vendor_registered: true, ..Default::default() }; + let local = LocalFacts { + vendor_registered: true, + ..Default::default() + }; let mut remote = ic_versions("15.73.0"); remote.gst_accounts = Some(Ok(vec![])); - assert_eq!(codes(&build_warnings(&cfg(), &local, &remote)), ["gst_accounts_not_configured"]); + assert_eq!( + codes(&build_warnings(&cfg(), &local, &remote)), + ["gst_accounts_not_configured"] + ); let mut remote = ic_versions("15.73.0"); - remote.gst_accounts = Some(Ok(vec![json!({ "company": "Test Co", "account_type": "Output", - "cgst_account": "Output CGST - AC", "sgst_account": "Other SGST - AC", "igst_account": "Output IGST - AC" })])); + remote.gst_accounts = Some(Ok(vec![ + json!({ "company": "Test Co", "account_type": "Output", + "cgst_account": "Output CGST - AC", "sgst_account": "Other SGST - AC", "igst_account": "Output IGST - AC" }), + ])); let w = build_warnings(&cfg(), &local, &remote); assert_eq!(codes(&w), ["gst_account_mismatch"]); assert!(w[0].message.contains("SGST")); let mut remote = ic_versions("15.73.0"); remote.gst_accounts = Some(Err("HTTP 403".into())); - assert_eq!(codes(&build_warnings(&cfg(), &local, &remote)), ["gst_settings_unreadable"]); + assert_eq!( + codes(&build_warnings(&cfg(), &local, &remote)), + ["gst_settings_unreadable"] + ); } #[test] @@ -727,20 +1011,45 @@ mod tests { let mut remote = ic_versions("15.73.0"); remote.company_address_count = Some(0); - assert_eq!(codes(&build_warnings(&cfg(), &LocalFacts::default(), &remote)), ["company_address_missing"]); + assert_eq!( + codes(&build_warnings(&cfg(), &LocalFacts::default(), &remote)), + ["company_address_missing"] + ); } #[test] fn required_settings_and_unknown_versions_are_flagged() { - let w = build_warnings(&ErpnextConfig::default(), &LocalFacts { vendor_registered: true, ..Default::default() }, &RemoteFacts::default()); - assert_eq!(codes(&w), ["versions_unknown", "company_missing", "income_account_missing", "tax_accounts_missing"]); + let w = build_warnings( + &ErpnextConfig::default(), + &LocalFacts { + vendor_registered: true, + ..Default::default() + }, + &RemoteFacts::default(), + ); + assert_eq!( + codes(&w), + [ + "versions_unknown", + "company_missing", + "income_account_missing", + "tax_accounts_missing" + ] + ); } #[test] fn registered_vendor_without_ic_gets_a_notice() { let mut remote = ic_versions("15.73.0"); remote.versions.as_mut().unwrap().india_compliance = None; - let w = build_warnings(&cfg(), &LocalFacts { vendor_registered: true, ..Default::default() }, &remote); + let w = build_warnings( + &cfg(), + &LocalFacts { + vendor_registered: true, + ..Default::default() + }, + &remote, + ); assert_eq!(codes(&w), ["ic_not_detected"]); } @@ -753,27 +1062,51 @@ mod tests { if path.contains("get_logged_user") { reply(200, json!({ "message": "integration@example.com" })) } else if path.contains("get_versions") { - reply(200, json!({ "message": { - "frappe": { "version": "15.73.0" }, "erpnext": { "version": "15.121.6" }, "india_compliance": { "version": "15.9.0" } - }})) + reply( + 200, + json!({ "message": { + "frappe": { "version": "15.73.0" }, "erpnext": { "version": "15.121.6" }, "india_compliance": { "version": "15.9.0" } + }}), + ) } else if path.starts_with("/api/resource/GST%20Settings/GST%20Settings") { reply(200, json!({ "data": { "gst_accounts": [] } })) } else if path.starts_with("/api/resource/Address?") { - reply(200, json!({ "data": [ - { "name": "Test Co-Billing", "address_title": "Test Co", "address_line1": "1 Main Rd", "city": "Mumbai", "state": "Maharashtra", "pincode": "400001", "gstin": "27AAPFU0939F1ZV" } - ]})) + reply( + 200, + json!({ "data": [ + { "name": "Test Co-Billing", "address_title": "Test Co", "address_line1": "1 Main Rd", "city": "Mumbai", "state": "Maharashtra", "pincode": "400001", "gstin": "27AAPFU0939F1ZV" } + ]}), + ) } else if path.starts_with("/api/resource/Company?") { - reply(200, json!({ "data": [{ "name": "Test Co", "company_name": "Test Co", "abbr": "AC", "default_currency": "INR" }] })) + reply( + 200, + json!({ "data": [{ "name": "Test Co", "company_name": "Test Co", "abbr": "AC", "default_currency": "INR" }] }), + ) } else if path.starts_with("/api/resource/Account?") && path.contains("Income") { - reply(200, json!({ "data": [{ "name": "Sales - AC", "account_name": "Sales", "account_type": "" }] })) + reply( + 200, + json!({ "data": [{ "name": "Sales - AC", "account_name": "Sales", "account_type": "" }] }), + ) } else if path.starts_with("/api/resource/Account?") && path.contains("Tax") { - reply(200, json!({ "data": [{ "name": "Output CGST - AC", "account_name": "Output CGST", "account_type": "Tax" }] })) + reply( + 200, + json!({ "data": [{ "name": "Output CGST - AC", "account_name": "Output CGST", "account_type": "Tax" }] }), + ) } else if path.starts_with("/api/resource/Price%20List?") { - reply(403, json!({ "exc_type": "PermissionError", "_error_message": "No permission for Price List" })) + reply( + 403, + json!({ "exc_type": "PermissionError", "_error_message": "No permission for Price List" }), + ) } else if path.contains("getdoctype") { - reply(200, json!({ "docs": [{ "fields": [{ "fieldname": "naming_series", "options": "\nSINV-.YY.-" }] }] })) + reply( + 200, + json!({ "docs": [{ "fields": [{ "fieldname": "naming_series", "options": "\nSINV-.YY.-" }] }] }), + ) } else if path.starts_with("/api/resource/UOM?") { - reply(200, json!({ "data": [{ "name": "Nos" }, { "name": "Hour" }, { "name": "" }] })) + reply( + 200, + json!({ "data": [{ "name": "Nos" }, { "name": "Hour" }, { "name": "" }] }), + ) } else { reply(200, json!({ "data": [] })) } @@ -782,8 +1115,14 @@ mod tests { #[tokio::test] async fn connection_test_reports_user_versions_ic_and_warnings() { let mock = serve_fn(|_, line| site(line)).await; - let local = LocalFacts { vendor_registered: true, next_number: Some("INV/2026-001".into()), ..Default::default() }; - let result = test_connection(&client(&mock), &cfg(), &local).await.unwrap(); + let local = LocalFacts { + vendor_registered: true, + next_number: Some("INV/2026-001".into()), + ..Default::default() + }; + let result = test_connection(&client(&mock), &cfg(), &local) + .await + .unwrap(); assert_eq!(result.user, "integration@example.com"); assert_eq!(result.versions.frappe.as_deref(), Some("15.73.0")); assert!(result.india_compliance && result.features.v2_naming); @@ -810,17 +1149,25 @@ mod tests { let options = client(&mock).load_options("Test Co").await.unwrap(); assert_eq!(options.company_addresses.len(), 1); assert_eq!(options.company_addresses[0].name, "Test Co-Billing"); - assert!(options.errors.iter().all(|e| e.list != "companyAddresses"), "{:?}", options.errors); + assert!( + options.errors.iter().all(|e| e.list != "companyAddresses"), + "{:?}", + options.errors + ); } #[tokio::test] async fn connection_test_fails_for_the_guest_user_and_bad_credentials() { let mock = serve_fn(|_, _| reply(200, json!({ "message": "Guest" }))).await; - let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default()).await.unwrap_err(); + let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default()) + .await + .unwrap_err(); assert_eq!(err.kind, ErrorKind::Auth); let mock = serve_fn(|_, _| reply(401, json!({ "message": "Invalid Authorization" }))).await; - let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default()).await.unwrap_err(); + let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default()) + .await + .unwrap_err(); assert_eq!(err.kind, ErrorKind::Auth); assert!(!err.to_string().contains("thesecret")); } @@ -837,17 +1184,29 @@ mod tests { assert_eq!(options.income_accounts[0].name, "Sales - AC"); assert_eq!(options.tax_accounts[0].name, "Output CGST - AC"); assert_eq!(options.naming_series[0].name, "SINV-.YY.-"); - assert_eq!(options.uoms.iter().map(|u| u.name.as_str()).collect::>(), ["Nos", "Hour"]); + assert_eq!( + options + .uoms + .iter() + .map(|u| u.name.as_str()) + .collect::>(), + ["Nos", "Hour"] + ); assert!(options.price_lists.is_empty()); assert_eq!(options.errors.len(), 1); assert_eq!(options.errors[0].list, "priceLists"); - assert!(options.errors[0].message.contains("No permission for Price List")); + assert!(options.errors[0] + .message + .contains("No permission for Price List")); // Company-scoped lists are skipped until a company is chosen. let none = client(&mock).load_options(" ").await.unwrap(); assert!(none.income_accounts.is_empty() && none.company_addresses.is_empty()); let requests = mock.requests.lock().unwrap().join("\n"); - assert!(requests.contains("root_type"), "income accounts are filtered by root type"); + assert!( + requests.contains("root_type"), + "income accounts are filtered by root type" + ); assert!(requests.contains("limit_page_length=0")); } @@ -856,6 +1215,10 @@ mod tests { let mock = serve_fn(|_, _| reply(401, json!({ "message": "bad" }))).await; let err = client(&mock).load_options("Test Co").await.unwrap_err(); assert_eq!(err.kind, ErrorKind::Auth); - assert_eq!(mock.hits.load(std::sync::atomic::Ordering::SeqCst), 1, "stops at the first auth failure"); + assert_eq!( + mock.hits.load(std::sync::atomic::Ordering::SeqCst), + 1, + "stops at the first auth failure" + ); } } diff --git a/src-tauri/src/integrations/erpnext/errors.rs b/src-tauri/src/integrations/erpnext/errors.rs index 1b668ff..fa292fa 100644 --- a/src-tauri/src/integrations/erpnext/errors.rs +++ b/src-tauri/src/integrations/erpnext/errors.rs @@ -81,7 +81,12 @@ impl From for String { impl ErpError { pub fn new(kind: ErrorKind, message: impl Into) -> Self { - ErpError { kind, status: None, message: message.into(), exc_type: None } + ErpError { + kind, + status: None, + message: message.into(), + exc_type: None, + } } pub fn config(message: impl Into) -> Self { @@ -99,8 +104,15 @@ impl ErpError { if parsed.exc_type.as_deref() == Some("DuplicateEntryError") { kind = ErrorKind::Duplicate; } - let message = parsed.message.unwrap_or_else(|| default_status_text(status)); - ErpError { kind, status: Some(status), message, exc_type: parsed.exc_type } + let message = parsed + .message + .unwrap_or_else(|| default_status_text(status)); + ErpError { + kind, + status: Some(status), + message, + exc_type: parsed.exc_type, + } } /// Removes every occurrence of the given secrets from the message. @@ -150,14 +162,24 @@ const MAX_MESSAGE_CHARS: usize = 600; pub fn parse_error_body(body: &str) -> ParsedError { let Ok(json) = serde_json::from_str::(body) else { let text = strip_html(body); - let first = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim(); - return ParsedError { message: non_empty(truncate(first)), exc_type: None }; + let first = text + .lines() + .find(|l| !l.trim().is_empty()) + .unwrap_or("") + .trim(); + return ParsedError { + message: non_empty(truncate(first)), + exc_type: None, + }; }; let Some(obj) = json.as_object() else { return ParsedError::default(); }; - let mut exc_type = obj.get("exc_type").and_then(Value::as_str).map(str::to_string); + let mut exc_type = obj + .get("exc_type") + .and_then(Value::as_str) + .map(str::to_string); let mut messages: Vec = Vec::new(); if let Some(errors) = obj.get("errors").and_then(Value::as_array) { @@ -165,8 +187,15 @@ pub fn parse_error_body(body: &str) -> ParsedError { match e { Value::String(s) => push_unique(&mut messages, strip_html(s)), Value::Object(o) => { - let text = o.get("message").and_then(Value::as_str).map(strip_html).filter(|m| !m.is_empty()); - let exception = o.get("exception").and_then(Value::as_str).map(split_exception); + let text = o + .get("message") + .and_then(Value::as_str) + .map(strip_html) + .filter(|m| !m.is_empty()); + let exception = o + .get("exception") + .and_then(Value::as_str) + .map(split_exception); if exc_type.is_none() { exc_type = o .get("type") @@ -174,7 +203,9 @@ pub fn parse_error_body(body: &str) -> ParsedError { .map(str::to_string) .or_else(|| exception.as_ref().and_then(|(t, _)| t.clone())); } - if let Some(m) = text.or_else(|| exception.map(|(_, m)| m).filter(|m| !m.is_empty())) { + if let Some(m) = + text.or_else(|| exception.map(|(_, m)| m).filter(|m| !m.is_empty())) + { push_unique(&mut messages, m); } } @@ -191,7 +222,12 @@ pub fn parse_error_body(body: &str) -> ParsedError { } } if messages.is_empty() { - if let Some(m) = obj.get("_error_message").and_then(Value::as_str).map(strip_html).filter(|m| !m.is_empty()) { + if let Some(m) = obj + .get("_error_message") + .and_then(Value::as_str) + .map(strip_html) + .filter(|m| !m.is_empty()) + { messages.push(m); } } @@ -207,7 +243,12 @@ pub fn parse_error_body(body: &str) -> ParsedError { } } if messages.is_empty() { - if let Some(m) = obj.get("message").and_then(Value::as_str).map(strip_html).filter(|m| !m.is_empty()) { + if let Some(m) = obj + .get("message") + .and_then(Value::as_str) + .map(strip_html) + .filter(|m| !m.is_empty()) + { messages.push(m); } } @@ -218,10 +259,16 @@ pub fn parse_error_body(body: &str) -> ParsedError { } if exc_type.is_none() { // v1 on Frappe 14 has no `exc_type`; the class is still named in `exception`. - exc_type = obj.get("exception").and_then(Value::as_str).and_then(|e| split_exception(e).0); + exc_type = obj + .get("exception") + .and_then(Value::as_str) + .and_then(|e| split_exception(e).0); } - ParsedError { message: non_empty(truncate(&messages.join("; "))), exc_type } + ParsedError { + message: non_empty(truncate(&messages.join("; "))), + exc_type, + } } fn server_messages(raw: &str) -> Vec { @@ -233,10 +280,18 @@ fn server_messages(raw: &str) -> Vec { .filter_map(|item| { let text = match item { Value::String(s) => match serde_json::from_str::(s) { - Ok(Value::Object(o)) => o.get("message").and_then(Value::as_str).unwrap_or("").to_string(), + Ok(Value::Object(o)) => o + .get("message") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), _ => s.clone(), }, - Value::Object(o) => o.get("message").and_then(Value::as_str).unwrap_or("").to_string(), + Value::Object(o) => o + .get("message") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), _ => String::new(), }; let text = strip_html(&text); @@ -250,18 +305,28 @@ fn server_messages(raw: &str) -> Vec { fn split_exception(raw: &str) -> (Option, String) { let first_line = raw.trim().lines().next().unwrap_or("").trim(); if let Some((prefix, rest)) = first_line.split_once(": ") { - let class_like = prefix.contains('.') || prefix.ends_with("Error") || prefix.ends_with("Exception"); - if class_like && prefix.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.') { + let class_like = + prefix.contains('.') || prefix.ends_with("Error") || prefix.ends_with("Exception"); + if class_like + && prefix + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.') + { let ty = prefix.rsplit('.').next().map(str::to_string); return (ty, strip_html(rest)); } } if !first_line.is_empty() - && first_line.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.') + && first_line + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.') && first_line.contains('.') { // `frappe.exceptions.DoesNotExistError` with no message. - return (first_line.rsplit('.').next().map(str::to_string), String::new()); + return ( + first_line.rsplit('.').next().map(str::to_string), + String::new(), + ); } (None, strip_html(first_line)) } @@ -297,8 +362,16 @@ pub fn strip_html(input: &str) -> String { } '>' if in_tag => { in_tag = false; - let name = tag.trim_start_matches('/').split_whitespace().next().unwrap_or("").to_ascii_lowercase(); - if matches!(name.as_str(), "br" | "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3") { + let name = tag + .trim_start_matches('/') + .split_whitespace() + .next() + .unwrap_or("") + .to_ascii_lowercase(); + if matches!( + name.as_str(), + "br" | "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3" + ) { out.push('\n'); } } @@ -339,7 +412,10 @@ mod tests { }) .to_string(); let parsed = parse_error_body(&body); - assert_eq!(parsed.message.as_deref(), Some("Row 1: Account Sales & more; Second\nproblem")); + assert_eq!( + parsed.message.as_deref(), + Some("Row 1: Account Sales & more; Second\nproblem") + ); assert_eq!(parsed.exc_type.as_deref(), Some("ValidationError")); let err = ErpError::from_response(417, &body); assert_eq!(err.kind, ErrorKind::Validation); @@ -354,7 +430,10 @@ mod tests { let b = parse_error_body( &json!({ "exception": "frappe.exceptions.DuplicateEntryError: Sales Invoice INV/1 already exists" }).to_string(), ); - assert_eq!(b.message.as_deref(), Some("Sales Invoice INV/1 already exists")); + assert_eq!( + b.message.as_deref(), + Some("Sales Invoice INV/1 already exists") + ); assert_eq!(b.exc_type.as_deref(), Some("DuplicateEntryError")); let c = parse_error_body(&json!({ "exc_type": "PermissionError" }).to_string()); @@ -363,8 +442,13 @@ mod tests { #[test] fn exception_prefix_is_only_dropped_when_it_looks_like_a_class_path() { - let parsed = parse_error_body(&json!({ "exception": "Something: with a colon but no class" }).to_string()); - assert_eq!(parsed.message.as_deref(), Some("Something: with a colon but no class")); + let parsed = parse_error_body( + &json!({ "exception": "Something: with a colon but no class" }).to_string(), + ); + assert_eq!( + parsed.message.as_deref(), + Some("Something: with a colon but no class") + ); assert_eq!(parsed.exc_type, None); } @@ -378,7 +462,10 @@ mod tests { }) .to_string(); let parsed = parse_error_body(&body); - assert_eq!(parsed.message.as_deref(), Some("Customer X is disabled; Row 2: rate missing")); + assert_eq!( + parsed.message.as_deref(), + Some("Customer X is disabled; Row 2: rate missing") + ); assert_eq!(parsed.exc_type.as_deref(), Some("ValidationError")); } @@ -406,22 +493,36 @@ mod tests { (502, ErrorKind::Server), (418, ErrorKind::Other), ] { - assert_eq!(ErpError::from_response(status, "").kind, kind, "status {status}"); + assert_eq!( + ErpError::from_response(status, "").kind, + kind, + "status {status}" + ); } // The exception class wins when the status is generic. - let dup = ErpError::from_response(417, &json!({ "exc_type": "DuplicateEntryError" }).to_string()); + let dup = ErpError::from_response( + 417, + &json!({ "exc_type": "DuplicateEntryError" }).to_string(), + ); assert_eq!(dup.kind, ErrorKind::Duplicate); } #[test] fn secrets_are_redacted_from_messages() { - let err = ErpError::new(ErrorKind::Other, "bad token abc123:topsecret in header").redacted(&["topsecret", "abc123"]); + let err = ErpError::new(ErrorKind::Other, "bad token abc123:topsecret in header") + .redacted(&["topsecret", "abc123"]); assert_eq!(err.message, "bad token [redacted]:[redacted] in header"); } #[test] fn display_carries_the_status_and_hint() { - let err = ErpError::from_response(401, &json!({ "message": "Invalid credentials" }).to_string()); - assert_eq!(err.to_string(), "Authentication failed (HTTP 401): Invalid credentials"); + let err = ErpError::from_response( + 401, + &json!({ "message": "Invalid credentials" }).to_string(), + ); + assert_eq!( + err.to_string(), + "Authentication failed (HTTP 401): Invalid credentials" + ); } } diff --git a/src-tauri/src/integrations/erpnext/live_tests.rs b/src-tauri/src/integrations/erpnext/live_tests.rs index 7cfd6c9..243efe6 100644 --- a/src-tauri/src/integrations/erpnext/live_tests.rs +++ b/src-tauri/src/integrations/erpnext/live_tests.rs @@ -11,8 +11,8 @@ use super::client::{ClientOptions, ErpClient}; use super::config::{self, ErpnextConfig, NamingMode, Secret}; -use super::errors::ErrorKind; use super::discovery::{test_connection, ConnectionTest, LocalFacts}; +use super::errors::ErrorKind; use super::push::{push_invoice, push_payment, PushResult}; use crate::commands::invoice::{get_invoice_impl, issue_invoice_impl}; use crate::commands::payments::{record_payment_impl, PaymentInput}; @@ -78,7 +78,14 @@ impl Live { ..Default::default() }; edit(&mut cfg); - let http = ErpClient::new(&cfg.base_url, &cfg.api_key, &cfg.api_secret, "", ClientOptions::default()).unwrap(); + let http = ErpClient::new( + &cfg.base_url, + &cfg.api_key, + &cfg.api_secret, + "", + ClientOptions::default(), + ) + .unwrap(); let mut conn = crate::db::open_in_memory().unwrap(); conn.execute( @@ -88,8 +95,24 @@ impl Live { ) .unwrap(); for (name, gstin, state, line1, city, pin, category) in [ - ("Voiced Live Intra Co", "", "27", "5 FC Road", "Pune", "411004", "unregistered"), - ("Voiced Live Inter Co", KA_GSTIN, "29", "12 MG Road", "Bengaluru", "560001", "registered_regular"), + ( + "Voiced Live Intra Co", + "", + "27", + "5 FC Road", + "Pune", + "411004", + "unregistered", + ), + ( + "Voiced Live Inter Co", + KA_GSTIN, + "29", + "12 MG Road", + "Bengaluru", + "560001", + "registered_regular", + ), ] { conn.execute( "INSERT INTO clients (name, address, gstin, state_code, address_line1, city, pincode, gst_category, created_at) @@ -98,12 +121,26 @@ impl Live { ) .unwrap(); } - let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() as u64; - let prefix = format!("L{}{}/", base36(nanos / 1_000_000, 4), base36(COUNTER.fetch_add(1, Ordering::SeqCst) as u64, 1)); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() as u64; + let prefix = format!( + "L{}{}/", + base36(nanos / 1_000_000, 4), + base36(COUNTER.fetch_add(1, Ordering::SeqCst) as u64, 1) + ); start_new_series_impl(&mut conn, &prefix, 3).unwrap(); config::save(&conn, &cfg).unwrap(); - let live = Live { db: Mutex::new(conn), dir: tempfile::tempdir().unwrap(), http, abbr, ic, prefix }; + let live = Live { + db: Mutex::new(conn), + dir: tempfile::tempdir().unwrap(), + http, + abbr, + ic, + prefix, + }; // The same step the UI performs before the first push. let ct = live.connection_test(&cfg).await; let mut cfg = cfg; @@ -113,8 +150,13 @@ impl Live { } async fn connection_test(&self, cfg: &ErpnextConfig) -> ConnectionTest { - let local = LocalFacts { vendor_registered: true, ..Default::default() }; - test_connection(&self.http, cfg, &local).await.expect("connection test") + let local = LocalFacts { + vendor_registered: true, + ..Default::default() + }; + test_connection(&self.http, cfg, &local) + .await + .expect("connection test") } fn cfg(&self) -> ErpnextConfig { @@ -176,7 +218,10 @@ impl Live { self.db .lock() .unwrap() - .execute("UPDATE invoices SET archived_pdf_sha256 = ?1 WHERE id = ?2", params![sha, inv.id]) + .execute( + "UPDATE invoices SET archived_pdf_sha256 = ?1 WHERE id = ?2", + params![sha, inv.id], + ) .unwrap(); } @@ -185,13 +230,22 @@ impl Live { } async fn doc(&self, doctype: &str, name: &str) -> Value { - let v = self.http.get(&["api", "resource", doctype, name], &[]).await.unwrap_or_else(|e| panic!("read {doctype} {name}: {e}")); + let v = self + .http + .get(&["api", "resource", doctype, name], &[]) + .await + .unwrap_or_else(|e| panic!("read {doctype} {name}: {e}")); v["data"].clone() } async fn invoices_named(&self, number: &str) -> Vec { self.http - .list_resource("Sales Invoice", &["name", "docstatus", "grand_total", "remarks"], json!([["remarks", "like", format!("%{number}%")]]), "creation asc") + .list_resource( + "Sales Invoice", + &["name", "docstatus", "grand_total", "remarks"], + json!([["remarks", "like", format!("%{number}%")]]), + "creation asc", + ) .await .unwrap() } @@ -201,7 +255,10 @@ impl Live { .list_resource( "File", &["name", "file_name", "is_private", "file_url"], - json!([["attached_to_doctype", "=", doctype], ["attached_to_name", "=", name]]), + json!([ + ["attached_to_doctype", "=", doctype], + ["attached_to_name", "=", name] + ]), "creation asc", ) .await @@ -248,7 +305,13 @@ fn minimal_pdf(label: &str) -> Vec { for o in offsets { out.extend(format!("{o:010} 00000 n \n").into_bytes()); } - out.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).into_bytes()); + out.extend( + format!( + "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", + objects.len() + 1 + ) + .into_bytes(), + ); out } @@ -263,8 +326,17 @@ fn assert_pushed(r: &PushResult) { /// The invoice as ERPNext holds it: total equals Voiced's, tax rows are what Voiced computed. async fn assert_matches(live: &Live, inv: &Invoice, doc: &Value) { - assert_eq!(paise(&doc["grand_total"]), crate::gst::rupees_to_paise(inv.total), "grand_total of {}", inv.number); - assert_eq!(doc["rounded_total"].as_f64().unwrap_or(0.0), 0.0, "rounding is disabled"); + assert_eq!( + paise(&doc["grand_total"]), + crate::gst::rupees_to_paise(inv.total), + "grand_total of {}", + inv.number + ); + assert_eq!( + doc["rounded_total"].as_f64().unwrap_or(0.0), + 0.0, + "rounding is disabled" + ); assert_eq!(doc["posting_date"], inv.invoice_date.as_str()); let taxes = doc["taxes"].as_array().unwrap(); let by_head = |needle: &str| -> Option { @@ -273,10 +345,25 @@ async fn assert_matches(live: &Live, inv: &Invoice, doc: &Value) { .find(|t| t["account_head"].as_str().unwrap_or("").contains(needle)) .map(|t| paise(&t["tax_amount"])) }; - assert_eq!(by_head("CGST").unwrap_or(0), crate::gst::rupees_to_paise(inv.cgst_amount).max(0), "CGST of {}", inv.number); + assert_eq!( + by_head("CGST").unwrap_or(0), + crate::gst::rupees_to_paise(inv.cgst_amount).max(0), + "CGST of {}", + inv.number + ); // IGST rows contain "IGST"; "CGST" and "SGST" rows do not. - assert_eq!(by_head("SGST").unwrap_or(0), crate::gst::rupees_to_paise(inv.sgst_amount), "SGST of {}", inv.number); - assert_eq!(by_head("IGST").unwrap_or(0), crate::gst::rupees_to_paise(inv.igst_amount), "IGST of {}", inv.number); + assert_eq!( + by_head("SGST").unwrap_or(0), + crate::gst::rupees_to_paise(inv.sgst_amount), + "SGST of {}", + inv.number + ); + assert_eq!( + by_head("IGST").unwrap_or(0), + crate::gst::rupees_to_paise(inv.igst_amount), + "IGST of {}", + inv.number + ); let _ = live; } @@ -290,7 +377,10 @@ async fn live_connection_and_options() { let ct = live.connection_test(&cfg).await; println!("{}", serde_json::to_string_pretty(&ct).unwrap()); assert!(ct.user.contains('@')); - assert_eq!(ct.versions.frappe.as_deref().map(|v| v.starts_with("15.")), Some(true)); + assert_eq!( + ct.versions.frappe.as_deref().map(|v| v.starts_with("15.")), + Some(true) + ); assert!(ct.versions.erpnext.is_some()); assert_eq!(ct.india_compliance, live.ic); assert!(ct.features.v2_naming, "frappe >= 15.73 supports v2 naming"); @@ -298,14 +388,25 @@ async fn live_connection_and_options() { let o = live.http.load_options(&cfg.company).await.unwrap(); println!("option errors: {:?}", o.errors); assert!(o.errors.is_empty(), "{:?}", o.errors); - let has = |list: &[super::discovery::OptionItem], name: &str| list.iter().any(|i| i.name == name); + let has = + |list: &[super::discovery::OptionItem], name: &str| list.iter().any(|i| i.name == name); assert!(has(&o.companies, &cfg.company)); - assert!(has(&o.income_accounts, &cfg.income_account), "{:?}", o.income_accounts); - assert!(o.tax_accounts.iter().any(|a| a.name.contains("CGST")), "{:?}", o.tax_accounts); + assert!( + has(&o.income_accounts, &cfg.income_account), + "{:?}", + o.income_accounts + ); + assert!( + o.tax_accounts.iter().any(|a| a.name.contains("CGST")), + "{:?}", + o.tax_accounts + ); assert!(has(&o.uoms, "Hour") && has(&o.uoms, "Nos")); assert!(!o.company_addresses.is_empty()); assert!(!o.naming_series.is_empty()); - assert!(!o.cost_centers.is_empty() && !o.customer_groups.is_empty() && !o.territories.is_empty()); + assert!( + !o.cost_centers.is_empty() && !o.customer_groups.is_empty() && !o.territories.is_empty() + ); } // ---- Sales Invoice mapping ---- @@ -361,10 +462,16 @@ async fn live_push_matrix() { for (label, inv) in &cases { live.archive(inv); let r = live.push(inv, None).await; - println!("{label}: {} -> {} (warnings {:?})", inv.number, r.remote_name, r.warnings); + println!( + "{label}: {} -> {} (warnings {:?})", + inv.number, r.remote_name, r.warnings + ); assert_pushed(&r); assert!(r.created && r.attached, "{label}: {r:?}"); - assert_eq!(r.remote_name, inv.number, "mirror naming keeps the Voiced number"); + assert_eq!( + r.remote_name, inv.number, + "mirror naming keeps the Voiced number" + ); let doc = live.doc("Sales Invoice", &r.remote_name).await; assert_eq!(doc["docstatus"], 0); assert_matches(&live, inv, &doc).await; @@ -376,11 +483,18 @@ async fn live_push_matrix() { // Customer and address shape, as ERPNext stored them. let inter = &cases[1].1; let doc = live.doc("Sales Invoice", &inter.number).await; - let customer = live.doc("Customer", doc["customer"].as_str().unwrap()).await; + let customer = live + .doc("Customer", doc["customer"].as_str().unwrap()) + .await; assert_eq!(customer["customer_name"], "Voiced Live Inter Co"); assert_eq!(customer["customer_type"], "Company"); - let address = live.doc("Address", doc["customer_address"].as_str().unwrap()).await; - println!("address: {}", json!({"state": address["state"], "country": address["country"], "pincode": address["pincode"], "city": address["city"], "type": address["address_type"], "links": address["links"], "gst_category": address["gst_category"], "gstin": address["gstin"]})); + let address = live + .doc("Address", doc["customer_address"].as_str().unwrap()) + .await; + println!( + "address: {}", + json!({"state": address["state"], "country": address["country"], "pincode": address["pincode"], "city": address["city"], "type": address["address_type"], "links": address["links"], "gst_category": address["gst_category"], "gstin": address["gstin"]}) + ); assert_eq!(address["state"], "Karnataka"); assert_eq!(address["country"], "India"); assert_eq!(address["address_type"], "Billing"); @@ -393,10 +507,17 @@ async fn live_push_matrix() { } // A fresh local database (lost link) reuses the customer's address instead of creating another. let again = Live::new(|_| {}).await; - let inv = again.issue(false, json!([{"description": "Again", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]), json!({})); + let inv = again.issue( + false, + json!([{"description": "Again", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]), + json!({}), + ); assert_pushed(&again.push(&inv, None).await); let d2 = again.doc("Sales Invoice", &inv.number).await; - assert_eq!(d2["customer_address"], doc["customer_address"], "address reused"); + assert_eq!( + d2["customer_address"], doc["customer_address"], + "address reused" + ); assert_eq!(d2["customer"], doc["customer"]); } @@ -408,7 +529,13 @@ async fn live_half_paise_rounding() { // (taxable amount, discount): 9% of 10.50 is 0.945, 18% of 0.25 is 0.045 and so on. Voiced rounds half up. // Half-even and half-up agree when the digit before the tie is odd (3.50 -> 0.315 -> 0.32), so only the // first two are certain to differ under Banker's Rounding. - let ties = [(10.5, 0.0, true), (7310.5, 0.0, true), (3.5, 0.0, false), (0.25, 0.0, false), (99.99, 0.15, false)]; + let ties = [ + (10.5, 0.0, true), + (7310.5, 0.0, true), + (3.5, 0.0, false), + (0.25, 0.0, false), + (99.99, 0.15, false), + ]; for (amount, discount, tie) in ties { let inv = live.issue( true, @@ -416,16 +543,31 @@ async fn live_half_paise_rounding() { json!({"discount": discount}), ); let r = live.push(&inv, Some(true)).await; - println!("{amount}/{discount} tie={tie} banker={banker}: ok={} {:?}", r.ok, r.error); + println!( + "{amount}/{discount} tie={tie} banker={banker}: ok={} {:?}", + r.ok, r.error + ); if tie && banker { // ERPNext's Banker's Rounding disagrees by a paise: the draft is reported, kept, and never submitted. - assert!(!r.ok && r.status == "conflict" && r.error_kind == Some(ErrorKind::Conflict), "{r:?}"); + assert!( + !r.ok && r.status == "conflict" && r.error_kind == Some(ErrorKind::Conflict), + "{r:?}" + ); let msg = r.error.as_deref().unwrap(); - assert!(msg.contains("Commercial Rounding") && msg.contains("does not submit"), "{msg}"); + assert!( + msg.contains("Commercial Rounding") && msg.contains("does not submit"), + "{msg}" + ); assert_eq!(r.remote_name, inv.number); let doc = live.doc("Sales Invoice", &r.remote_name).await; - assert_eq!(doc["docstatus"], 0, "a mismatching document is never submitted"); - assert!(live.files_of("Sales Invoice", &r.remote_name).await.is_empty()); + assert_eq!( + doc["docstatus"], 0, + "a mismatching document is never submitted" + ); + assert!(live + .files_of("Sales Invoice", &r.remote_name) + .await + .is_empty()); // Pushing again finds the same draft and reports the same conflict instead of creating another. let again = live.push(&inv, Some(true)).await; assert!(!again.ok && again.status == "conflict", "{again:?}"); @@ -442,7 +584,11 @@ async fn live_half_paise_rounding() { #[ignore = "needs the live ERPNext stack"] async fn live_repush_is_idempotent() { let live = Live::new(|_| {}).await; - let inv = live.issue(true, json!([{"description": "Design", "mode": "fixed", "amount": 500.0, "hsnSac": "998314"}]), json!({})); + let inv = live.issue( + true, + json!([{"description": "Design", "mode": "fixed", "amount": 500.0, "hsnSac": "998314"}]), + json!({}), + ); live.archive(&inv); let first = live.push(&inv, None).await; assert_pushed(&first); @@ -452,7 +598,11 @@ async fn live_repush_is_idempotent() { assert_pushed(&again); assert!(again.no_op && !again.created, "{again:?}"); // Lose the local sync state: the POST gets a 409 and the document is adopted, not duplicated. - live.db.lock().unwrap().execute("DELETE FROM erpnext_sync", []).unwrap(); + live.db + .lock() + .unwrap() + .execute("DELETE FROM erpnext_sync", []) + .unwrap(); let adopted = live.push(&inv, None).await; assert_pushed(&adopted); assert!(adopted.attached, "{adopted:?}"); @@ -460,7 +610,12 @@ async fn live_repush_is_idempotent() { assert_eq!(adopted.remote_name, first.remote_name); let all = live .http - .list_resource("Sales Invoice", &["name"], json!([["name", "=", inv.number.clone()]]), "name asc") + .list_resource( + "Sales Invoice", + &["name"], + json!([["name", "=", inv.number.clone()]]), + "name asc", + ) .await .unwrap(); assert_eq!(all.len(), 1); @@ -475,13 +630,24 @@ async fn live_repush_is_idempotent() { async fn live_submit_paths() { let live = Live::new(|_| {}).await; // Submit while creating. - let a = live.issue(true, json!([{"description": "A", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]), json!({})); + let a = live.issue( + true, + json!([{"description": "A", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]), + json!({}), + ); let r = live.push(&a, Some(true)).await; assert_pushed(&r); assert_eq!(r.remote_docstatus, 1); - assert_eq!(live.doc("Sales Invoice", &r.remote_name).await["docstatus"], 1); + assert_eq!( + live.doc("Sales Invoice", &r.remote_name).await["docstatus"], + 1 + ); // Submit an existing draft in a later push (mirror: v2 method route). - let b = live.issue(true, json!([{"description": "B", "mode": "fixed", "amount": 200.0, "hsnSac": "998314"}]), json!({})); + let b = live.issue( + true, + json!([{"description": "B", "mode": "fixed", "amount": 200.0, "hsnSac": "998314"}]), + json!({}), + ); assert_pushed(&live.push(&b, Some(false)).await); let r = live.push(&b, Some(true)).await; assert_pushed(&r); @@ -494,20 +660,43 @@ async fn live_submit_paths() { async fn live_series_naming_mode() { let live = Live::new(|c| c.naming_mode = NamingMode::Series).await; let o = live.http.load_options(&live.cfg().company).await.unwrap(); - let series = o.naming_series.first().expect("a naming series").name.clone(); + let series = o + .naming_series + .first() + .expect("a naming series") + .name + .clone(); println!("series options: {:?}", o.naming_series); live.save_cfg(|c| c.naming_series = series.clone()); - let inv = live.issue(true, json!([{"description": "Series", "mode": "fixed", "amount": 700.0, "hsnSac": "998314"}]), json!({"notes": "note"})); + let inv = live.issue( + true, + json!([{"description": "Series", "mode": "fixed", "amount": 700.0, "hsnSac": "998314"}]), + json!({"notes": "note"}), + ); live.archive(&inv); let r = live.push(&inv, Some(false)).await; assert_pushed(&r); assert!(r.created); - assert_ne!(r.remote_name, inv.number, "series mode lets ERPNext name it"); + assert_ne!( + r.remote_name, inv.number, + "series mode lets ERPNext name it" + ); let doc = live.doc("Sales Invoice", &r.remote_name).await; - assert!(doc["remarks"].as_str().unwrap().starts_with(&format!("Voiced invoice {}", inv.number)), "{}", doc["remarks"]); + assert!( + doc["remarks"] + .as_str() + .unwrap() + .starts_with(&format!("Voiced invoice {}", inv.number)), + "{}", + doc["remarks"] + ); assert_matches(&live, &inv, &doc).await; // Lose the sync row: the remarks lookup finds the document instead of creating another. - live.db.lock().unwrap().execute("DELETE FROM erpnext_sync", []).unwrap(); + live.db + .lock() + .unwrap() + .execute("DELETE FROM erpnext_sync", []) + .unwrap(); let again = live.push(&inv, Some(false)).await; assert_pushed(&again); assert!(!again.created, "{again:?}"); @@ -517,7 +706,10 @@ async fn live_series_naming_mode() { let s = live.push(&inv, Some(true)).await; assert_pushed(&s); assert_eq!(s.remote_docstatus, 1); - assert_eq!(live.doc("Sales Invoice", &r.remote_name).await["docstatus"], 1); + assert_eq!( + live.doc("Sales Invoice", &r.remote_name).await["docstatus"], + 1 + ); } #[tokio::test] @@ -529,18 +721,62 @@ async fn live_uom_and_quantities() { let row = |desc: &str, unit: &str, qty: f64| json!([{"description": desc, "mode": "rate", "rate": 100.0, "unit": unit, "quantity": qty, "hsnSac": "998314"}]); let mut outcomes = Vec::new(); for (label, desc, unit, qty) in [ - ("Hour row on a Nos item, whole qty", "Nos preset", "hour", 2.0), - ("Hour row on a Nos item, fractional qty", "Nos preset", "hour", 1.5), - ("Hour row on an Hour item, fractional qty", "Hourly preset", "hour", 1.5), - ("Nos row on an Hour item, whole qty", "Hourly preset", "unit", 3.0), - ("code-less hours, fractional qty", "Code-less hours", "hour", 1.5), - ("code-less minutes, fractional qty", "Code-less minutes", "minute", 90.5), - ("code-less Nos, fractional qty", "Code-less nos", "unit", 1.5), - ("code-less Nos, whole qty", "Code-less nos whole", "unit", 4.0), + ( + "Hour row on a Nos item, whole qty", + "Nos preset", + "hour", + 2.0, + ), + ( + "Hour row on a Nos item, fractional qty", + "Nos preset", + "hour", + 1.5, + ), + ( + "Hour row on an Hour item, fractional qty", + "Hourly preset", + "hour", + 1.5, + ), + ( + "Nos row on an Hour item, whole qty", + "Hourly preset", + "unit", + 3.0, + ), + ( + "code-less hours, fractional qty", + "Code-less hours", + "hour", + 1.5, + ), + ( + "code-less minutes, fractional qty", + "Code-less minutes", + "minute", + 90.5, + ), + ( + "code-less Nos, fractional qty", + "Code-less nos", + "unit", + 1.5, + ), + ( + "code-less Nos, whole qty", + "Code-less nos whole", + "unit", + 4.0, + ), ] { let inv = live.issue(true, row(desc, unit, qty), json!({})); let r = live.push(&inv, None).await; - println!("{label}: ok={} {}", r.ok, r.error.clone().unwrap_or_default()); + println!( + "{label}: ok={} {}", + r.ok, + r.error.clone().unwrap_or_default() + ); outcomes.push((label, r.ok)); } println!("{outcomes:?}"); @@ -548,7 +784,10 @@ async fn live_uom_and_quantities() { assert!(ok("Hour row on an Hour item, fractional qty")); assert!(ok("code-less hours, fractional qty") && ok("code-less minutes, fractional qty")); assert!(ok("code-less Nos, whole qty")); - assert!(!ok("code-less Nos, fractional qty"), "ERPNext refuses a fraction of a whole-number UOM"); + assert!( + !ok("code-less Nos, fractional qty"), + "ERPNext refuses a fraction of a whole-number UOM" + ); } // ---- payments ---- @@ -569,8 +808,32 @@ async fn live_payment_entry() { assert!(r.ok, "{:?}", r.error); let entry = r.entry_name.clone().unwrap(); let pe = live.doc("Payment Entry", &entry).await; - let keys = ["payment_type", "party_type", "paid_from", "paid_to", "paid_amount", "received_amount", "base_paid_amount", "base_received_amount", "total_allocated_amount", "unallocated_amount", "difference_amount", "reference_no", "reference_date", "posting_date", "bank_account", "mode_of_payment", "status"]; - println!("PE {}", json!(keys.iter().map(|k| (k.to_string(), pe[*k].clone())).collect::>())); + let keys = [ + "payment_type", + "party_type", + "paid_from", + "paid_to", + "paid_amount", + "received_amount", + "base_paid_amount", + "base_received_amount", + "total_allocated_amount", + "unallocated_amount", + "difference_amount", + "reference_no", + "reference_date", + "posting_date", + "bank_account", + "mode_of_payment", + "status", + ]; + println!( + "PE {}", + json!(keys + .iter() + .map(|k| (k.to_string(), pe[*k].clone())) + .collect::>()) + ); println!("PE references {}", pe["references"].as_array().unwrap().iter().map(|r| json!({"ref": r["reference_name"], "allocated": r["allocated_amount"], "total": r["total_amount"], "outstanding": r["outstanding_amount"]}).to_string()).collect::>().join(",")); println!("PE deductions {}", pe["deductions"].as_array().unwrap().iter().map(|r| json!({"account": r["account"], "cost_center": r["cost_center"], "amount": r["amount"], "description": r["description"]}).to_string()).collect::>().join(",")); assert_eq!(paise(&pe["paid_amount"]), cash); @@ -580,14 +843,29 @@ async fn live_payment_entry() { let ded = pe["deductions"].as_array().unwrap(); assert_eq!(ded.len(), 1); assert_eq!(ded[0]["account"], live.cfg().tds_account.as_str()); - assert_eq!(paise(&ded[0]["amount"]), tds, "a TDS deduction row is a positive amount"); + assert_eq!( + paise(&ded[0]["amount"]), + tds, + "a TDS deduction row is a positive amount" + ); assert_eq!(pe["reference_no"], "UTR-LIVE-1"); assert_eq!(pe["posting_date"], "2026-06-20"); - assert_eq!(pe["paid_to"], live.cfg().payment_bank_account.as_str(), "money lands in the configured bank account"); + assert_eq!( + pe["paid_to"], + live.cfg().payment_bank_account.as_str(), + "money lands in the configured bank account" + ); let inv_doc = live.doc("Sales Invoice", &inv.number).await; - println!("outstanding after payment: {}", inv_doc["outstanding_amount"]); + println!( + "outstanding after payment: {}", + inv_doc["outstanding_amount"] + ); assert_eq!(pe["docstatus"], 1); - assert_eq!(paise(&inv_doc["outstanding_amount"]), 0, "cash plus TDS settles the invoice"); + assert_eq!( + paise(&inv_doc["outstanding_amount"]), + 0, + "cash plus TDS settles the invoice" + ); // Idempotent locally. let again = push_payment(&live.db, &live.http, pid).await; assert!(again.ok && again.already_synced); @@ -597,14 +875,22 @@ async fn live_payment_entry() { #[ignore = "needs the live ERPNext stack"] async fn live_payment_without_tds() { let live = Live::new(|_| {}).await; - let inv = live.issue(true, json!([{"description": "Plain", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({})); + let inv = live.issue( + true, + json!([{"description": "Plain", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), + json!({}), + ); assert_pushed(&live.push(&inv, Some(true)).await); let total = crate::gst::rupees_to_paise(inv.total); let pid = live.payment(&inv, total / 2, 0); let r = push_payment(&live.db, &live.http, pid).await; assert!(r.ok, "{:?}", r.error); let inv_doc = live.doc("Sales Invoice", &inv.number).await; - assert_eq!(paise(&inv_doc["outstanding_amount"]), total - total / 2, "partial payment leaves the rest outstanding"); + assert_eq!( + paise(&inv_doc["outstanding_amount"]), + total - total / 2, + "partial payment leaves the rest outstanding" + ); } // ---- India Compliance only (ERPNEXT_IC=1) ---- @@ -625,10 +911,18 @@ async fn live_ic_hsn_enforcement() { } let live = Live::new(|_| {}).await; let mut outcomes = Vec::new(); - for (label, hsn) in [("no HSN", ""), ("4-digit HSN", "9983"), ("6-digit HSN", "998314"), ("8-digit HSN", "99831400")] { + for (label, hsn) in [ + ("no HSN", ""), + ("4-digit HSN", "9983"), + ("6-digit HSN", "998314"), + ("8-digit HSN", "99831400"), + ] { let inv = live.issue(true, json!([{"description": format!("HSN {label}"), "mode": "fixed", "amount": 1000.0, "hsnSac": hsn}]), json!({})); let r = live.push(&inv, Some(true)).await; - println!("{label}: ok={} docstatus={} err={:?}", r.ok, r.remote_docstatus, r.error); + println!( + "{label}: ok={} docstatus={} err={:?}", + r.ok, r.remote_docstatus, r.error + ); outcomes.push((label, r)); } let get = |l: &str| &outcomes.iter().find(|(k, _)| *k == l).unwrap().1; @@ -653,7 +947,14 @@ async fn live_ic_number_longer_than_16_characters() { // Voiced never issues such a number; this is a number imported or migrated from elsewhere. let long = format!("{}-EXTRA-LONG-PART", inv.number); assert!(long.len() > 16); - live.db.lock().unwrap().execute("UPDATE invoices SET number = ?1 WHERE id = ?2", params![long, inv.id]).unwrap(); + live.db + .lock() + .unwrap() + .execute( + "UPDATE invoices SET number = ?1 WHERE id = ?2", + params![long, inv.id], + ) + .unwrap(); let r = live.push(&inv, None).await; println!("long number: ok={} err={:?}", r.ok, r.error); assert!(!r.ok); @@ -668,26 +969,55 @@ async fn live_ic_gst_account_warnings() { return; } let live = Live::new(|_| {}).await; - let local = LocalFacts { vendor_registered: true, ..Default::default() }; - let codes = |ct: &ConnectionTest| ct.warnings.iter().map(|w| w.code.clone()).collect::>(); + let local = LocalFacts { + vendor_registered: true, + ..Default::default() + }; + let codes = |ct: &ConnectionTest| { + ct.warnings + .iter() + .map(|w| w.code.clone()) + .collect::>() + }; - let clean = test_connection(&live.http, &live.cfg(), &local).await.unwrap(); + let clean = test_connection(&live.http, &live.cfg(), &local) + .await + .unwrap(); assert!(clean.warnings.is_empty(), "{:?}", clean.warnings); let mut cfg = live.cfg(); cfg.cgst_account = format!("Input Tax CGST - {}", live.abbr); let wrong = test_connection(&live.http, &cfg, &local).await.unwrap(); - assert_eq!(codes(&wrong), ["gst_account_mismatch"], "{:?}", wrong.warnings); + assert_eq!( + codes(&wrong), + ["gst_account_mismatch"], + "{:?}", + wrong.warnings + ); let mut cfg = live.cfg(); cfg.company = "No Such Co".into(); let other = test_connection(&live.http, &cfg, &local).await.unwrap(); - assert!(codes(&other).contains(&"gst_accounts_not_configured".to_string()), "{:?}", other.warnings); + assert!( + codes(&other).contains(&"gst_accounts_not_configured".to_string()), + "{:?}", + other.warnings + ); // Mirror mode with a number the site would refuse. - let local = LocalFacts { vendor_registered: true, next_number: Some("TOOLONG-INVOICE-0001".into()), ..Default::default() }; - let ct = test_connection(&live.http, &live.cfg(), &local).await.unwrap(); - assert!(codes(&ct).contains(&"number_invalid".to_string()), "{:?}", ct.warnings); + let local = LocalFacts { + vendor_registered: true, + next_number: Some("TOOLONG-INVOICE-0001".into()), + ..Default::default() + }; + let ct = test_connection(&live.http, &live.cfg(), &local) + .await + .unwrap(); + assert!( + codes(&ct).contains(&"number_invalid".to_string()), + "{:?}", + ct.warnings + ); } #[tokio::test] @@ -702,7 +1032,11 @@ async fn live_ic_reverse_charge_and_gst_fields() { // India Compliance refuses is_reverse_charge=1 without negative RCM tax rows ("Booked reverse charge is not // equal to applied tax amount"), whatever GST Settings say, so it is sent as 0 with a warning. assert_pushed(&r); - assert!(r.warnings.iter().any(|w| w.contains("reverse charge")), "{:?}", r.warnings); + assert!( + r.warnings.iter().any(|w| w.contains("reverse charge")), + "{:?}", + r.warnings + ); let doc = live.doc("Sales Invoice", &r.remote_name).await; assert_eq!(doc["is_reverse_charge"], 0); assert_matches(&live, &inv, &doc).await; @@ -718,13 +1052,34 @@ async fn live_ic_state_names_match_voiced() { return; } let live = Live::new(|_| {}).await; - let v = live.http.get(&["api", "method", "frappe.desk.form.load.getdoctype"], &[("doctype", "Address".to_string())]).await.unwrap(); + let v = live + .http + .get( + &["api", "method", "frappe.desk.form.load.getdoctype"], + &[("doctype", "Address".to_string())], + ) + .await + .unwrap(); let docs = v["docs"].as_array().unwrap(); let address = docs.iter().find(|d| d["name"] == "Address").unwrap(); - let options = address["fields"].as_array().unwrap().iter().find(|f| f["fieldname"] == "gst_state").expect("IC adds gst_state"); - let ic: Vec<&str> = options["options"].as_str().unwrap().lines().map(str::trim).filter(|l| !l.is_empty()).collect(); + let options = address["fields"] + .as_array() + .unwrap() + .iter() + .find(|f| f["fieldname"] == "gst_state") + .expect("IC adds gst_state"); + let ic: Vec<&str> = options["options"] + .as_str() + .unwrap() + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .collect(); for (code, name) in crate::gst::STATES { - assert!(ic.contains(name), "India Compliance has no state named {name:?} ({code}); it has {ic:?}"); + assert!( + ic.contains(name), + "India Compliance has no state named {name:?} ({code}); it has {ic:?}" + ); } assert_eq!(ic.len(), crate::gst::STATES.len(), "{ic:?}"); } @@ -733,23 +1088,40 @@ async fn live_ic_state_names_match_voiced() { #[ignore = "needs the live ERPNext stack"] async fn live_payment_edge_cases() { let live = Live::new(|_| {}).await; - let inv = live.issue(true, json!([{"description": "Edge", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({})); + let inv = live.issue( + true, + json!([{"description": "Edge", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), + json!({}), + ); // Not submitted yet: refused locally, nothing sent. assert_pushed(&live.push(&inv, Some(false)).await); let early = live.payment(&inv, 100_00, 0); let r = push_payment(&live.db, &live.http, early).await; - assert!(!r.ok && r.error.as_deref().unwrap().contains("not submitted"), "{r:?}"); + assert!( + !r.ok && r.error.as_deref().unwrap().contains("not submitted"), + "{r:?}" + ); assert_pushed(&live.push(&inv, Some(true)).await); let total = crate::gst::rupees_to_paise(inv.total); // TDS only (no cash): settled by deduction alone. let tds_only = live.payment(&inv, 0, total - 100_00); let r = push_payment(&live.db, &live.http, tds_only).await; - assert!(!r.ok && r.error.as_deref().unwrap().contains("TDS only"), "{r:?}"); + assert!( + !r.ok && r.error.as_deref().unwrap().contains("TDS only"), + "{r:?}" + ); // Without a configured TDS account a TDS payment is refused locally. live.save_cfg(|c| c.tds_account = String::new()); - let inv2 = live.issue(true, json!([{"description": "Edge 2", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({})); + let inv2 = live.issue( + true, + json!([{"description": "Edge 2", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), + json!({}), + ); assert_pushed(&live.push(&inv2, Some(true)).await); let t = live.payment(&inv2, 100_00, 50_00); let r = push_payment(&live.db, &live.http, t).await; - assert!(!r.ok && r.error.as_deref().unwrap().contains("TDS account"), "{r:?}"); + assert!( + !r.ok && r.error.as_deref().unwrap().contains("TDS account"), + "{r:?}" + ); } diff --git a/src-tauri/src/integrations/erpnext/mapping.rs b/src-tauri/src/integrations/erpnext/mapping.rs index 80cbbf1..659bc62 100644 --- a/src-tauri/src/integrations/erpnext/mapping.rs +++ b/src-tauri/src/integrations/erpnext/mapping.rs @@ -150,7 +150,12 @@ pub fn remarks_marker(number: &str) -> String { fn verified_totals(inv: &Invoice) -> Result<(TaxType, i64, Totals), String> { let tax_type = parse_tax_type(&inv.tax_type)?; let rate_bp = (inv.tax_rate * 100.0).round() as i64; - let totals = gst::compute_totals(&inv.items, gst::rupees_to_paise(inv.discount), tax_type, rate_bp); + let totals = gst::compute_totals( + &inv.items, + gst::rupees_to_paise(inv.discount), + tax_type, + rate_bp, + ); let stored = [ (totals.cgst, inv.cgst_amount, "CGST"), (totals.sgst, inv.sgst_amount, "SGST"), @@ -199,13 +204,21 @@ fn distinct_descriptions(items: &[InvoiceItem]) -> Vec { .enumerate() .map(|(i, item)| { let trimmed = item.description.trim(); - let full = if trimmed.is_empty() { format!("Item {}", i + 1) } else { trimmed.to_string() }; + let full = if trimmed.is_empty() { + format!("Item {}", i + 1) + } else { + trimmed.to_string() + }; let (head, tail) = first_line_and_rest(&full); let mut candidate = full.clone(); let mut n = 2; while used.contains(&candidate.to_lowercase()) { let head = format!("{head} ({n})"); - candidate = if tail.is_empty() { head } else { format!("{head}\n{tail}") }; + candidate = if tail.is_empty() { + head + } else { + format!("{head}\n{tail}") + }; n += 1; } used.insert(candidate.to_lowercase()); @@ -271,7 +284,10 @@ fn item_rows(ctx: &InvoiceContext, apply_gst: bool) -> Result, String )); } let (head, _) = first_line_and_rest(&descriptions[i]); - row.insert("item_name".into(), json!(truncate_chars(head, ITEM_NAME_MAX))); + row.insert( + "item_name".into(), + json!(truncate_chars(head, ITEM_NAME_MAX)), + ); } } row.insert("description".into(), json!(descriptions[i])); @@ -296,7 +312,9 @@ fn item_rows(ctx: &InvoiceContext, apply_gst: bool) -> Result, String fn tax_row(account: &str, head: &str, rate: &str) -> Result { if account.trim().is_empty() { - return Err(format!("The {head} account is not set in the ERPNext settings.")); + return Err(format!( + "The {head} account is not set in the ERPNext settings." + )); } Ok(json!({ "charge_type": "On Net Total", @@ -306,10 +324,19 @@ fn tax_row(account: &str, head: &str, rate: &str) -> Result { })) } -fn tax_rows(cfg: &ErpnextConfig, tax_type: TaxType, rate_bp: i64, supplier_state: &str) -> Result, String> { +fn tax_rows( + cfg: &ErpnextConfig, + tax_type: TaxType, + rate_bp: i64, + supplier_state: &str, +) -> Result, String> { match tax_type { TaxType::None => Ok(Vec::new()), - TaxType::Igst => Ok(vec![tax_row(&cfg.igst_account, "IGST", &scaled_decimal(rate_bp, 2))?]), + TaxType::Igst => Ok(vec![tax_row( + &cfg.igst_account, + "IGST", + &scaled_decimal(rate_bp, 2), + )?]), TaxType::CgstSgst => { // rate_bp is in hundredths of a percent; half of it in thousandths of a percent is rate_bp * 5. let half = scaled_decimal(rate_bp * 5, 3); @@ -319,7 +346,10 @@ fn tax_rows(cfg: &ErpnextConfig, tax_type: TaxType, rate_bp: i64, supplier_state } else { &cfg.sgst_account }; - Ok(vec![tax_row(&cfg.cgst_account, "CGST", &half)?, tax_row(second_account, second, &half)?]) + Ok(vec![ + tax_row(&cfg.cgst_account, "CGST", &half)?, + tax_row(second_account, second, &half)?, + ]) } } } @@ -328,7 +358,10 @@ pub fn build_sales_invoice(ctx: &InvoiceContext) -> Result let inv = ctx.invoice; let cfg = ctx.config; if inv.status != "issued" { - return Err(format!("Invoice {} is {}; only issued invoices are sent to ERPNext.", inv.number, inv.status)); + return Err(format!( + "Invoice {} is {}; only issued invoices are sent to ERPNext.", + inv.number, inv.status + )); } if ctx.customer.trim().is_empty() { return Err("The ERPNext customer is not resolved.".into()); @@ -346,7 +379,9 @@ pub fn build_sales_invoice(ctx: &InvoiceContext) -> Result } NamingMode::Series => { if cfg.naming_series.trim().is_empty() { - return Err("ERPNext series naming is selected but no naming series is set.".into()); + return Err( + "ERPNext series naming is selected but no naming series is set.".into(), + ); } body.insert("naming_series".into(), json!(cfg.naming_series.trim())); } @@ -396,14 +431,21 @@ pub fn build_sales_invoice(ctx: &InvoiceContext) -> Result body.insert("discount_amount".into(), money(totals.discount)); } body.insert("items".into(), Value::Array(item_rows(ctx, gst_fields)?)); - body.insert("taxes".into(), Value::Array(tax_rows(cfg, tax_type, rate_bp, &ctx.vendor.state_code)?)); + body.insert( + "taxes".into(), + Value::Array(tax_rows(cfg, tax_type, rate_bp, &ctx.vendor.state_code)?), + ); if ctx.submit { body.insert("docstatus".into(), json!(1)); } let mirror = cfg.naming_mode == NamingMode::Mirror; Ok(BuiltRequest { - path: if mirror { SALES_INVOICE_V2 } else { SALES_INVOICE_V1 }, + path: if mirror { + SALES_INVOICE_V2 + } else { + SALES_INVOICE_V1 + }, body: Value::Object(body), idempotent: mirror, }) @@ -416,7 +458,11 @@ fn has_gstin(client: &Client) -> bool { !g.is_empty() && !g.eq_ignore_ascii_case("NA") } -pub fn build_customer(client: &Client, cfg: &ErpnextConfig, india_compliance: bool) -> Result { +pub fn build_customer( + client: &Client, + cfg: &ErpnextConfig, + india_compliance: bool, +) -> Result { let name = client.name.trim(); if name.is_empty() { return Err("The client has no name.".into()); @@ -427,19 +473,33 @@ pub fn build_customer(client: &Client, cfg: &ErpnextConfig, india_compliance: bo set_if(&mut body, "customer_group", &cfg.customer_group); set_if(&mut body, "territory", &cfg.territory); if india_compliance { - body.insert("gst_category".into(), json!(gst_category_label(&client.gst_category)?)); - let taxable_registration = matches!(client.gst_category.as_str(), "registered_regular" | "composition" | "sez"); + body.insert( + "gst_category".into(), + json!(gst_category_label(&client.gst_category)?), + ); + let taxable_registration = matches!( + client.gst_category.as_str(), + "registered_regular" | "composition" | "sez" + ); if taxable_registration && has_gstin(client) { let gstin = client.gstin.trim().to_ascii_uppercase(); gst::validate_gstin(&gstin).map_err(|e| format!("Client GSTIN: {e}"))?; body.insert("gstin".into(), json!(gstin)); } } - Ok(BuiltRequest { path: CUSTOMER, body: Value::Object(body), idempotent: false }) + Ok(BuiltRequest { + path: CUSTOMER, + body: Value::Object(body), + idempotent: false, + }) } /// `customer` is the name ERPNext returned when the Customer was created (a duplicate becomes "X - 1"). -pub fn build_address(client: &Client, customer: &str, india_compliance: bool) -> Result { +pub fn build_address( + client: &Client, + customer: &str, + india_compliance: bool, +) -> Result { if customer.trim().is_empty() { return Err("The ERPNext customer is not resolved.".into()); } @@ -472,16 +532,31 @@ pub fn build_address(client: &Client, customer: &str, india_compliance: bool) -> set_if(&mut body, "pincode", &client.pincode); body.insert("country".into(), json!("India")); if india_compliance { - body.insert("gst_category".into(), json!(gst_category_label(&client.gst_category)?)); - if has_gstin(client) && matches!(client.gst_category.as_str(), "registered_regular" | "composition" | "sez") { - body.insert("gstin".into(), json!(client.gstin.trim().to_ascii_uppercase())); + body.insert( + "gst_category".into(), + json!(gst_category_label(&client.gst_category)?), + ); + if has_gstin(client) + && matches!( + client.gst_category.as_str(), + "registered_regular" | "composition" | "sez" + ) + { + body.insert( + "gstin".into(), + json!(client.gstin.trim().to_ascii_uppercase()), + ); } } body.insert( "links".into(), json!([{ "link_doctype": "Customer", "link_name": customer.trim() }]), ); - Ok(BuiltRequest { path: ADDRESS, body: Value::Object(body), idempotent: false }) + Ok(BuiltRequest { + path: ADDRESS, + body: Value::Object(body), + idempotent: false, + }) } #[cfg(test)] @@ -539,7 +614,13 @@ mod tests { Vendor::from_snapshot(&inv.vendor_snapshot).unwrap() } - fn build(inv: &Invoice, cfg: &ErpnextConfig, ic: bool, codes: &[Option], submit: bool) -> Result { + fn build( + inv: &Invoice, + cfg: &ErpnextConfig, + ic: bool, + codes: &[Option], + submit: bool, + ) -> Result { let v = vendor(inv); build_sales_invoice(&InvoiceContext { invoice: inv, @@ -597,7 +678,10 @@ mod tests { assert_eq!(scaled_decimal(1500, 3), "1.5"); assert_eq!(scaled_decimal(2625, 3), "2.625"); assert_eq!(money(731_005).to_string(), "7310.05"); - assert_eq!(money(i64::from(u32::MAX) * 100 + 7).to_string(), "4294967295.07"); + assert_eq!( + money(i64::from(u32::MAX) * 100 + 7).to_string(), + "4294967295.07" + ); } #[test] @@ -607,7 +691,10 @@ mod tests { assert_eq!(place_of_supply_label("97").unwrap(), "97-Other Territory"); assert_eq!(place_of_supply_label("96").unwrap(), "96-Other Countries"); for (code, name) in gst::STATES { - assert_eq!(place_of_supply_label(code).unwrap(), format!("{code}-{name}")); + assert_eq!( + place_of_supply_label(code).unwrap(), + format!("{code}-{name}") + ); } assert!(place_of_supply_label("28").is_err()); } @@ -750,10 +837,16 @@ mod tests { #[test] fn plain_erpnext_registered_vendor_gets_tax_rows_but_no_india_compliance_fields() { let mut conn = registered(); - let inv = issue(&mut conn, json!({ "items": [{"description": "Design", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}] })); + let inv = issue( + &mut conn, + json!({ "items": [{"description": "Design", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}] }), + ); let req = build(&inv, &cfg(), false, &[None], false).unwrap(); assert_eq!(req.body["taxes"].as_array().unwrap().len(), 2); - assert!(req.body.get("place_of_supply").is_none() && req.body.get("is_reverse_charge").is_none()); + assert!( + req.body.get("place_of_supply").is_none() + && req.body.get("is_reverse_charge").is_none() + ); assert!(req.body["items"][0].get("gst_hsn_code").is_none()); } @@ -795,8 +888,17 @@ mod tests { let req = build(&inv, &cfg(), false, &codes, false).unwrap(); let rows = req.body["items"].as_array().unwrap(); assert!(rows[0].get("stock_uom").is_none()); - assert_eq!((rows[0]["uom"].as_str(), rows[0]["conversion_factor"].as_i64()), (Some("Hour"), Some(1))); - assert_eq!((rows[1]["uom"].as_str(), rows[1]["stock_uom"].as_str()), (Some("Minute"), Some("Minute"))); + assert_eq!( + ( + rows[0]["uom"].as_str(), + rows[0]["conversion_factor"].as_i64() + ), + (Some("Hour"), Some(1)) + ); + assert_eq!( + (rows[1]["uom"].as_str(), rows[1]["stock_uom"].as_str()), + (Some("Minute"), Some("Minute")) + ); } #[test] @@ -849,7 +951,10 @@ mod tests { assert!(!req.idempotent); assert!(req.body.get("name").is_none()); assert_eq!(req.body["naming_series"], "SINV-.YY.-"); - assert_eq!(req.body["remarks"], format!("Voiced invoice {}\nNet 30", inv.number)); + assert_eq!( + req.body["remarks"], + format!("Voiced invoice {}\nNet 30", inv.number) + ); c.naming_series.clear(); assert!(build(&inv, &c, true, &[None], false).is_err()); @@ -859,8 +964,15 @@ mod tests { fn submit_adds_docstatus_and_drafts_do_not() { let mut conn = registered(); let inv = issue(&mut conn, json!({})); - assert!(build(&inv, &cfg(), true, &[None], false).unwrap().body.get("docstatus").is_none()); - assert_eq!(build(&inv, &cfg(), true, &[None], true).unwrap().body["docstatus"], 1); + assert!(build(&inv, &cfg(), true, &[None], false) + .unwrap() + .body + .get("docstatus") + .is_none()); + assert_eq!( + build(&inv, &cfg(), true, &[None], true).unwrap().body["docstatus"], + 1 + ); } #[test] @@ -874,7 +986,9 @@ mod tests { // A supplier in a union territory without a legislature charges UTGST, not SGST. let mut ut = inv.clone(); - ut.vendor_snapshot = ut.vendor_snapshot.replace("\"vendorStateCode\":\"27\"", "\"vendorStateCode\":\"04\""); + ut.vendor_snapshot = ut + .vendor_snapshot + .replace("\"vendorStateCode\":\"27\"", "\"vendorStateCode\":\"04\""); let req = build(&ut, &cfg(), true, &[None], false).unwrap(); assert_eq!(req.body["taxes"][1]["account_head"], "Output UTGST - AC"); assert_eq!(req.body["taxes"][1]["description"], "UTGST @ 2.625%"); @@ -901,11 +1015,17 @@ mod tests { let mut inv = issue(&mut conn, json!({})); let mut c = cfg(); c.cgst_account.clear(); - assert!(build(&inv, &c, true, &[None], false).unwrap_err().contains("CGST account")); + assert!(build(&inv, &c, true, &[None], false) + .unwrap_err() + .contains("CGST account")); c.company.clear(); - assert!(build(&inv, &c, true, &[None], false).unwrap_err().contains("company")); + assert!(build(&inv, &c, true, &[None], false) + .unwrap_err() + .contains("company")); inv.status = "cancelled".into(); - assert!(build(&inv, &cfg(), true, &[None], false).unwrap_err().contains("only issued")); + assert!(build(&inv, &cfg(), true, &[None], false) + .unwrap_err() + .contains("only issued")); } #[test] @@ -913,9 +1033,17 @@ mod tests { let mut conn = registered(); let mut inv = issue(&mut conn, json!({})); inv.due_date = "2026-03-01".into(); - assert!(build(&inv, &cfg(), true, &[None], false).unwrap().body.get("due_date").is_none()); + assert!(build(&inv, &cfg(), true, &[None], false) + .unwrap() + .body + .get("due_date") + .is_none()); inv.due_date.clear(); - assert!(build(&inv, &cfg(), true, &[None], false).unwrap().body.get("due_date").is_none()); + assert!(build(&inv, &cfg(), true, &[None], false) + .unwrap() + .body + .get("due_date") + .is_none()); } #[test] @@ -960,11 +1088,17 @@ mod tests { } let mut unreg = client(); unreg.gst_category = "unregistered".into(); - assert!(build_customer(&unreg, &cfg(), true).unwrap().body.get("gstin").is_none()); + assert!(build_customer(&unreg, &cfg(), true) + .unwrap() + .body + .get("gstin") + .is_none()); let mut bad = client(); bad.gstin = "29AABCU9603R1Z0".into(); - assert!(build_customer(&bad, &cfg(), true).unwrap_err().contains("GSTIN")); + assert!(build_customer(&bad, &cfg(), true) + .unwrap_err() + .contains("GSTIN")); } #[test] @@ -989,7 +1123,10 @@ mod tests { ); let mut jk = client(); jk.state_code = "01".into(); - assert_eq!(build_address(&jk, "X", true).unwrap().body["state"], "Jammu and Kashmir"); + assert_eq!( + build_address(&jk, "X", true).unwrap().body["state"], + "Jammu and Kashmir" + ); } #[test] @@ -997,7 +1134,10 @@ mod tests { let mut c = client(); c.address_line1.clear(); c.address = "5 Park Street\nKolkata, West Bengal - 700016".into(); - assert_eq!(build_address(&c, "X", false).unwrap().body["address_line1"], "5 Park Street"); + assert_eq!( + build_address(&c, "X", false).unwrap().body["address_line1"], + "5 Park Street" + ); let mut no_city = client(); no_city.city.clear(); @@ -1005,7 +1145,11 @@ mod tests { let mut no_state = client(); no_state.state_code.clear(); assert!(build_address(&no_state, "X", true).is_err()); - assert!(build_address(&no_state, "X", false).unwrap().body.get("state").is_none()); + assert!(build_address(&no_state, "X", false) + .unwrap() + .body + .get("state") + .is_none()); assert!(build_address(&client(), " ", true).is_err()); } } diff --git a/src-tauri/src/integrations/erpnext/push.rs b/src-tauri/src/integrations/erpnext/push.rs index 3986fb6..80a8568 100644 --- a/src-tauri/src/integrations/erpnext/push.rs +++ b/src-tauri/src/integrations/erpnext/push.rs @@ -9,8 +9,8 @@ use super::config::{self, ErpnextConfig, NamingMode}; use super::discovery::ic_number_ok; use super::errors::{ErpError, ErrorKind}; use super::mapping::{ - self, build_address, build_customer, build_sales_invoice, paise_to_decimal, remarks_marker, InvoiceContext, Vendor, - SALES_INVOICE_V2, + self, build_address, build_customer, build_sales_invoice, paise_to_decimal, remarks_marker, + InvoiceContext, Vendor, SALES_INVOICE_V2, }; use crate::commands::archive::read_archive_impl; use crate::commands::invoice::get_invoice_impl; @@ -31,14 +31,20 @@ use std::sync::Mutex; type Db = Mutex; const DOCTYPE_INVOICE: &str = "Sales Invoice"; -const GET_PAYMENT_ENTRY: &str = "erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry"; +const GET_PAYMENT_ENTRY: &str = + "erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry"; fn pre(message: impl Into) -> ErpError { ErpError::new(ErrorKind::Precondition, message) } -fn with_db(db: &Db, f: impl FnOnce(&mut Connection) -> Result) -> Result { - let mut conn = db.lock().map_err(|e| pre(format!("The database is busy: {e}")))?; +fn with_db( + db: &Db, + f: impl FnOnce(&mut Connection) -> Result, +) -> Result { + let mut conn = db + .lock() + .map_err(|e| pre(format!("The database is busy: {e}")))?; f(&mut conn).map_err(|e| pre(format!("Could not read or save the sync state: {e}"))) } @@ -197,7 +203,11 @@ pub fn sync_statuses(conn: &Connection) -> Result, String> { /// `/app/sales-invoice/`, with the name URL-encoded (a mirrored number contains a slash). pub fn open_url(conn: &Connection, invoice_id: i64) -> Result { - const KEEP: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'_').remove(b'.').remove(b'~'); + const KEEP: &AsciiSet = &NON_ALPHANUMERIC + .remove(b'-') + .remove(b'_') + .remove(b'.') + .remove(b'~'); let row = load_sync(conn, invoice_id)?; let name = row.map(|r| r.remote_name).unwrap_or_default(); if name.is_empty() { @@ -205,7 +215,10 @@ pub fn open_url(conn: &Connection, invoice_id: i64) -> Result { } let cfg = config::load(conn)?; let base = super::client::normalize_base_url(&cfg.base_url).map_err(|e| e.to_string())?; - Ok(format!("{base}/app/sales-invoice/{}", utf8_percent_encode(&name, KEEP))) + Ok(format!( + "{base}/app/sales-invoice/{}", + utf8_percent_encode(&name, KEEP) + )) } // ---- loading ---- @@ -294,7 +307,12 @@ fn load_client(conn: &Connection, inv: &Invoice) -> Result { address_line2: String::new(), city: String::new(), pincode: String::new(), - gst_category: if has_gstin { "registered_regular" } else { "unregistered" }.into(), + gst_category: if has_gstin { + "registered_regular" + } else { + "unregistered" + } + .into(), default_notes: String::new(), payment_terms_days: None, invoice_count: 0, @@ -321,7 +339,11 @@ fn load_item_codes(conn: &Connection, inv: &Invoice) -> Result Result Result bool { let g = client.gstin.trim(); !g.is_empty() && !g.eq_ignore_ascii_case("NA") - && matches!(client.gst_category.as_str(), "registered_regular" | "composition" | "sez") + && matches!( + client.gst_category.as_str(), + "registered_regular" | "composition" | "sez" + ) } /// The checks India Compliance would make on an Address, done here so the error is readable and local. @@ -440,7 +478,10 @@ pub fn validate_address(client: &Client) -> Result<(), String> { let pin = client.pincode.trim(); if !pin.is_empty() { if pin.len() != 6 || !pin.bytes().all(|b| b.is_ascii_digit()) || pin.starts_with('0') { - return Err(format!("{}: the PIN code \"{pin}\" is not a valid 6-digit PIN.", client.name.trim())); + return Err(format!( + "{}: the PIN code \"{pin}\" is not a valid 6-digit PIN.", + client.name.trim() + )); } if let Some(allowed) = pin_prefixes(state_code) { if !allowed.iter().any(|p| pin.starts_with(p)) { @@ -482,7 +523,9 @@ fn doc_docstatus(doc: &Value) -> i64 { fn doc_total_paise(doc: &Value) -> Option { let v = doc.get("grand_total")?; - let n = v.as_f64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))?; + let n = v + .as_f64() + .or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))?; Some(gst::rupees_to_paise(n)) } @@ -502,13 +545,26 @@ async fn ensure_customer(db: &Db, http: &ErpClient, l: &Loaded) -> Result c, @@ -521,14 +577,19 @@ async fn ensure_customer(db: &Db, http: &ErpClient, l: &Loaded) -> Result existing, None => { let resp = http.post(req.path, &req.body, req.idempotent).await?; - doc_name(&resp).ok_or_else(|| ErpError::protocol("ERPNext did not return the new address's name."))? + doc_name(&resp).ok_or_else(|| { + ErpError::protocol("ERPNext did not return the new address's name.") + })? } }; with_db(db, |c| { - c.execute("UPDATE clients SET erpnext_address = ?1 WHERE id = ?2", params![name, client_id]) - .map(|_| ()) - .map_err(|e| e.to_string()) + c.execute( + "UPDATE clients SET erpnext_address = ?1 WHERE id = ?2", + params![name, client_id], + ) + .map(|_| ()) + .map_err(|e| e.to_string()) })?; Ok((Some(name), None)) } @@ -593,7 +670,15 @@ async fn find_address(http: &ErpClient, customer: &str, body: &Value) -> Option< if let Some(pin) = body.get("pincode").and_then(Value::as_str) { filters.push(json!(["pincode", "=", pin])); } - let rows = http.list_resource("Address", &["name"], Value::Array(filters), "`tabAddress`.creation asc").await.ok()?; + let rows = http + .list_resource( + "Address", + &["name"], + Value::Array(filters), + "`tabAddress`.creation asc", + ) + .await + .ok()?; rows.first()?.get("name")?.as_str().map(str::to_string) } @@ -656,7 +741,10 @@ async fn find_by_remarks(http: &ErpClient, l: &Loaded) -> Result Result Result { +async fn create_or_find( + http: &ErpClient, + l: &Loaded, + body: &mapping::BuiltRequest, +) -> Result { let inv = &l.invoice; if l.cfg.naming_mode == NamingMode::Series { if let Some(found) = find_by_remarks(http, l).await? { @@ -688,10 +780,17 @@ async fn create_or_find(http: &ErpClient, l: &Loaded, body: &mapping::BuiltReque (Some(n), _) => n, (None, NamingMode::Mirror) => inv.number.clone(), (None, NamingMode::Series) => { - return Err(ErpError::protocol("ERPNext did not return the new Sales Invoice's name.")) + return Err(ErpError::protocol( + "ERPNext did not return the new Sales Invoice's name.", + )) } }; - Ok(RemoteDoc { name, docstatus: doc_docstatus(&doc), created: true, total_paise: doc_total_paise(&doc) }) + Ok(RemoteDoc { + name, + docstatus: doc_docstatus(&doc), + created: true, + total_paise: doc_total_paise(&doc), + }) } Err(e) if e.kind == ErrorKind::Duplicate && l.cfg.naming_mode == NamingMode::Mirror => { // The mirrored name is taken: either a repeat of an earlier push or someone else's document. @@ -719,11 +818,20 @@ async fn submit_remote(http: &ErpClient, cfg: &ErpnextConfig, name: &str) -> Res http.post(&path, &json!({}), false).await? } NamingMode::Series => { - http.post(&["api", "resource", DOCTYPE_INVOICE, name], &json!({ "run_method": "submit" }), false).await? + http.post( + &["api", "resource", DOCTYPE_INVOICE, name], + &json!({ "run_method": "submit" }), + false, + ) + .await? } }; // A 2xx without a docstatus is taken as submitted; one that says otherwise is not. - match resp.get("data").and_then(|d| d.get("docstatus")).and_then(Value::as_i64) { + match resp + .get("data") + .and_then(|d| d.get("docstatus")) + .and_then(Value::as_i64) + { Some(1) | None => Ok(1), Some(other) => Err(ErpError::protocol(format!( "ERPNext accepted the submit request but the document is still at docstatus {other}." @@ -734,15 +842,32 @@ async fn submit_remote(http: &ErpClient, cfg: &ErpnextConfig, name: &str) -> Res fn attachment_file_name(number: &str) -> String { let cleaned: String = number .chars() - .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '-' }) + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { + c + } else { + '-' + } + }) .collect(); let cleaned = cleaned.trim_matches('-'); - format!("{}.pdf", if cleaned.is_empty() { "invoice" } else { cleaned }) + format!( + "{}.pdf", + if cleaned.is_empty() { + "invoice" + } else { + cleaned + } + ) } /// True when the document already carries a file of this name. Used when the local row lost its attachment /// hash (a re-adopted document), so the same PDF is not attached twice. -async fn has_attachment(http: &ErpClient, remote_name: &str, file_name: &str) -> Result { +async fn has_attachment( + http: &ErpClient, + remote_name: &str, + file_name: &str, +) -> Result { let rows = http .list_resource( "File", @@ -759,9 +884,19 @@ async fn has_attachment(http: &ErpClient, remote_name: &str, file_name: &str) -> } /// `adopted`: the document was found, not created by this push, and nothing is recorded as attached. -async fn attach_pdf(http: &ErpClient, l: &Loaded, remote_name: &str, pdf: &Pdf, adopted: bool) -> Result<(), ErpError> { +async fn attach_pdf( + http: &ErpClient, + l: &Loaded, + remote_name: &str, + pdf: &Pdf, + adopted: bool, +) -> Result<(), ErpError> { let file_name = attachment_file_name(&l.invoice.number); - if adopted && has_attachment(http, remote_name, &file_name).await.unwrap_or(false) { + if adopted + && has_attachment(http, remote_name, &file_name) + .await + .unwrap_or(false) + { return Ok(()); } let fields = [ @@ -772,14 +907,24 @@ async fn attach_pdf(http: &ErpClient, l: &Loaded, remote_name: &str, pdf: &Pdf, let resp = http .post_file( &["api", "method", "upload_file"], - &Upload { file_name: &file_name, mime: "application/pdf", bytes: &pdf.bytes, fields: &fields }, + &Upload { + file_name: &file_name, + mime: "application/pdf", + bytes: &pdf.bytes, + fields: &fields, + }, ) .await?; - let ok = resp.get("message").map(|m| m.get("name").is_some() || m.get("file_url").is_some()).unwrap_or(false); + let ok = resp + .get("message") + .map(|m| m.get("name").is_some() || m.get("file_url").is_some()) + .unwrap_or(false); if ok { Ok(()) } else { - Err(ErpError::protocol("ERPNext did not confirm the PDF upload.")) + Err(ErpError::protocol( + "ERPNext did not confirm the PDF upload.", + )) } } @@ -797,7 +942,14 @@ struct Progress { warnings: Vec, } -fn persist(db: &Db, invoice_id: i64, prev: Option<&SyncRow>, st: &Progress, status: &str, error: &str) -> Result<(), ErpError> { +fn persist( + db: &Db, + invoice_id: i64, + prev: Option<&SyncRow>, + st: &Progress, + status: &str, + error: &str, +) -> Result<(), ErpError> { let synced_at = if status == "synced" { Some(chrono::Utc::now().to_rfc3339()) } else { @@ -818,7 +970,12 @@ fn persist(db: &Db, invoice_id: i64, prev: Option<&SyncRow>, st: &Progress, stat /// The message shown to the user: which step failed, then the error's own readable text. Local refusals and /// conflicts already say what is wrong and get no prefix. fn failure_text(step: &str, e: &ErpError) -> String { - if step.is_empty() || matches!(e.kind, ErrorKind::Config | ErrorKind::Precondition | ErrorKind::Conflict) { + if step.is_empty() + || matches!( + e.kind, + ErrorKind::Config | ErrorKind::Precondition | ErrorKind::Conflict + ) + { e.to_string() } else if e.kind == ErrorKind::Validation && e.message.contains("cannot be a fraction") { // ERPNext names the row and the UOM already; say what to change in Voiced's settings. @@ -828,7 +985,13 @@ fn failure_text(step: &str, e: &ErpError) -> String { } } -async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st: &mut Progress) -> Result<(), ErpError> { +async fn run_push( + db: &Db, + http: &ErpClient, + l: &Loaded, + want_submit: bool, + st: &mut Progress, +) -> Result<(), ErpError> { let inv = &l.invoice; let cfg = &l.cfg; let prev = l.sync.as_ref(); @@ -882,7 +1045,10 @@ async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st: } None => { st.payload_hash = hash; - if cfg.naming_mode == NamingMode::Mirror && l.india_compliance && !ic_number_ok(&inv.number) { + if cfg.naming_mode == NamingMode::Mirror + && l.india_compliance + && !ic_number_ok(&inv.number) + { return Err(pre(format!( "Invoice number {} is longer than 16 characters or has characters India Compliance refuses (letters, digits, - and / only), so ERPNext would reject it. Use the ERPNext series naming mode, or start a new Voiced series with a shorter prefix.", inv.number @@ -911,7 +1077,11 @@ async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st: if let (Some(w), None) = (&l.pdf_warning, &l.pdf) { st.warnings.push(w.clone()); } - if existing.is_some() && prev.is_some_and(|p| p.status == "synced") && !need_submit && pdf.is_none() { + if existing.is_some() + && prev.is_some_and(|p| p.status == "synced") + && !need_submit + && pdf.is_none() + { st.no_op = true; return Ok(()); } @@ -933,7 +1103,13 @@ async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st: Ok(()) } -pub async fn push_invoice(db: &Db, local_dir: &Path, http: &ErpClient, invoice_id: i64, submit: Option) -> PushResult { +pub async fn push_invoice( + db: &Db, + local_dir: &Path, + http: &ErpClient, + invoice_id: i64, + submit: Option, +) -> PushResult { let loaded = match load_for_push(db, local_dir, invoice_id) { Ok(l) => l, Err((number, e)) => return PushResult::refused(invoice_id, &number, e), @@ -946,7 +1122,11 @@ pub async fn push_invoice(db: &Db, local_dir: &Path, http: &ErpClient, invoice_i let (error, status): (Option<(ErpError, String)>, &str) = match outcome { Ok(()) => { - let write = if st.no_op { Ok(()) } else { persist(db, invoice_id, prev, &st, "synced", "") }; + let write = if st.no_op { + Ok(()) + } else { + persist(db, invoice_id, prev, &st, "synced", "") + }; match write { Ok(()) => (None, "synced"), Err(e) => { @@ -957,7 +1137,11 @@ pub async fn push_invoice(db: &Db, local_dir: &Path, http: &ErpClient, invoice_i } Err(e) => { let text = failure_text(st.step, &e); - let status = if e.kind == ErrorKind::Conflict { "conflict" } else { "error" }; + let status = if e.kind == ErrorKind::Conflict { + "conflict" + } else { + "error" + }; // Best effort: the original error is what the caller needs to see. let _ = persist(db, invoice_id, prev, &st, status, &text); (Some((e, text)), status) @@ -1022,23 +1206,44 @@ pub struct PaymentEntryInput<'a> { /// `difference_amount` is 0, `total_allocated_amount = cash + TDS` and the invoice's outstanding drops by /// cash + TDS. A partial payment without TDS leaves the rest outstanding. pub fn build_payment_entry(draft: &Value, p: &PaymentEntryInput) -> Result { - let mut doc: Map = draft.as_object().cloned().ok_or("ERPNext returned an unexpected payment draft.")?; + let mut doc: Map = draft + .as_object() + .cloned() + .ok_or("ERPNext returned an unexpected payment draft.")?; doc.retain(|k, _| !k.starts_with("__")); doc.insert("doctype".into(), json!("Payment Entry")); doc.insert("posting_date".into(), json!(p.paid_on)); - let reference = if p.reference.trim().is_empty() { format!("Voiced payment {}", p.payment_id) } else { p.reference.trim().to_string() }; + let reference = if p.reference.trim().is_empty() { + format!("Voiced payment {}", p.payment_id) + } else { + p.reference.trim().to_string() + }; doc.insert("reference_no".into(), json!(reference)); doc.insert("reference_date".into(), json!(p.paid_on)); doc.insert("paid_amount".into(), mapping::money(p.cash_paise)); doc.insert("received_amount".into(), mapping::money(p.cash_paise)); - doc.insert("remarks".into(), json!(format!("Voiced payment {} for invoice {}", p.payment_id, p.invoice_number))); + doc.insert( + "remarks".into(), + json!(format!( + "Voiced payment {} for invoice {}", + p.payment_id, p.invoice_number + )), + ); let allocated = mapping::money(p.cash_paise + p.tds_paise); - let refs = doc.get_mut("references").and_then(Value::as_array_mut).ok_or("ERPNext returned no invoice reference for this payment.")?; + let refs = doc + .get_mut("references") + .and_then(Value::as_array_mut) + .ok_or("ERPNext returned no invoice reference for this payment.")?; let target = refs .iter_mut() .find(|r| r.get("reference_name").and_then(Value::as_str) == Some(p.remote_invoice)) - .ok_or_else(|| format!("ERPNext's payment draft does not reference {}.", p.remote_invoice))?; + .ok_or_else(|| { + format!( + "ERPNext's payment draft does not reference {}.", + p.remote_invoice + ) + })?; target["allocated_amount"] = allocated; let deductions = if p.tds_paise > 0 { @@ -1100,7 +1305,11 @@ pub async fn push_payment(db: &Db, http: &ErpClient, payment_id: i64) -> Payment .map_err(|e| e.to_string())? .ok_or_else(|| "Payment not found".to_string())?; let number: String = conn - .query_row("SELECT number FROM invoices WHERE id = ?1", params![row.invoice_id], |r| r.get(0)) + .query_row( + "SELECT number FROM invoices WHERE id = ?1", + params![row.invoice_id], + |r| r.get(0), + ) .map_err(|e| e.to_string())?; let sync = load_sync(conn, row.invoice_id)?; let cfg = config::load(conn)?; @@ -1144,21 +1353,33 @@ pub async fn push_payment(db: &Db, http: &ErpClient, payment_id: i64) -> Payment )); } if cfg.payment_bank_account.trim().is_empty() { - return fail(pre("Set the payment bank account in the ERPNext settings first.")); + return fail(pre( + "Set the payment bank account in the ERPNext settings first.", + )); } if row.tds_paise > 0 && cfg.tds_account.trim().is_empty() { - return fail(pre("This payment has TDS: set the TDS account in the ERPNext settings first.")); + return fail(pre( + "This payment has TDS: set the TDS account in the ERPNext settings first.", + )); } let query = [ ("dt", DOCTYPE_INVOICE.to_string()), ("dn", remote_invoice.clone()), ("bank_account", cfg.payment_bank_account.trim().to_string()), - ("party_amount", paise_to_decimal(row.amount_paise + row.tds_paise)), + ( + "party_amount", + paise_to_decimal(row.amount_paise + row.tds_paise), + ), ]; - let draft = match http.get(&["api", "method", GET_PAYMENT_ENTRY], &query).await { + let draft = match http + .get(&["api", "method", GET_PAYMENT_ENTRY], &query) + .await + { Ok(v) => v.get("message").cloned().unwrap_or(Value::Null), - Err(e) => return payment_failure(payment_id, row.invoice_id, "prepare the Payment Entry", e), + Err(e) => { + return payment_failure(payment_id, row.invoice_id, "prepare the Payment Entry", e) + } }; let body = match build_payment_entry( &draft, @@ -1178,17 +1399,27 @@ pub async fn push_payment(db: &Db, http: &ErpClient, payment_id: i64) -> Payment Err(e) => return fail(ErpError::protocol(e)), }; // Not retried after a 5xx or timeout: it may have been created, and a second entry would double-count. - let resp = match http.post(&["api", "resource", "Payment Entry"], &body, false).await { + let resp = match http + .post(&["api", "resource", "Payment Entry"], &body, false) + .await + { Ok(v) => v, - Err(e) => return payment_failure(payment_id, row.invoice_id, "create the Payment Entry", e), + Err(e) => { + return payment_failure(payment_id, row.invoice_id, "create the Payment Entry", e) + } }; let Some(name) = doc_name(&resp) else { - return fail(ErpError::protocol("ERPNext did not return the new Payment Entry's name.")); + return fail(ErpError::protocol( + "ERPNext did not return the new Payment Entry's name.", + )); }; if let Err(e) = with_db(db, |c| { - c.execute("UPDATE payments SET erpnext_payment_entry = ?1 WHERE id = ?2", params![name, payment_id]) - .map(|_| ()) - .map_err(|e| e.to_string()) + c.execute( + "UPDATE payments SET erpnext_payment_entry = ?1 WHERE id = ?2", + params![name, payment_id], + ) + .map(|_| ()) + .map_err(|e| e.to_string()) }) { return fail(e); } @@ -1223,7 +1454,14 @@ impl InvoiceSink for ErpnextSink<'_> { request: PushRequest, ) -> impl std::future::Future> + Send + 'a { async move { - let r = push_invoice(self.db, self.local_dir, self.http, request.invoice_id, request.submit).await; + let r = push_invoice( + self.db, + self.local_dir, + self.http, + request.invoice_id, + request.submit, + ) + .await; if r.ok { Ok(PushedInvoice { remote_name: r.remote_name, @@ -1244,7 +1482,10 @@ impl InvoiceSink for ErpnextSink<'_> { async move { let r = push_payment(self.db, self.http, request.payment_id).await; match (r.ok, r.entry_name) { - (true, Some(name)) => Ok(PushedPayment { remote_name: name, created: !r.already_synced }), + (true, Some(name)) => Ok(PushedPayment { + remote_name: name, + created: !r.already_synced, + }), _ => Err(r.error.unwrap_or_else(|| "The payment push failed.".into())), } } diff --git a/src-tauri/src/integrations/erpnext/push/tests.rs b/src-tauri/src/integrations/erpnext/push/tests.rs index 70f0ce1..fe842e9 100644 --- a/src-tauri/src/integrations/erpnext/push/tests.rs +++ b/src-tauri/src/integrations/erpnext/push/tests.rs @@ -26,8 +26,24 @@ impl Fx { ) .unwrap(); for (name, gstin, state, line1, city, pin, category) in [ - ("Client Ltd", CLIENT_GSTIN, "29", "12 MG Road", "Bengaluru", "560001", "registered_regular"), - ("Other Co", "", "27", "5 FC Road", "Pune", "411004", "unregistered"), + ( + "Client Ltd", + CLIENT_GSTIN, + "29", + "12 MG Road", + "Bengaluru", + "560001", + "registered_regular", + ), + ( + "Other Co", + "", + "27", + "5 FC Road", + "Pune", + "411004", + "unregistered", + ), ] { conn.execute( "INSERT INTO clients (name, address, gstin, state_code, address_line1, city, pincode, gst_category, created_at) @@ -55,7 +71,10 @@ impl Fx { }; edit(&mut cfg); config::save(&conn, &cfg).unwrap(); - Fx { db: Mutex::new(conn), dir: tempfile::tempdir().unwrap() } + Fx { + db: Mutex::new(conn), + dir: tempfile::tempdir().unwrap(), + } } fn issue(&self, client_name: &str) -> Invoice { @@ -90,7 +109,10 @@ impl Fx { self.db .lock() .unwrap() - .execute("UPDATE invoices SET archived_pdf_sha256 = ?1 WHERE id = ?2", params![sha, inv.id]) + .execute( + "UPDATE invoices SET archived_pdf_sha256 = ?1 WHERE id = ?2", + params![sha, inv.id], + ) .unwrap(); sha } @@ -100,7 +122,11 @@ impl Fx { } fn scalar(&self, sql: &str) -> Option { - self.db.lock().unwrap().query_row(sql, [], |r| r.get::<_, Option>(0)).unwrap() + self.db + .lock() + .unwrap() + .query_row(sql, [], |r| r.get::<_, Option>(0)) + .unwrap() } async fn push(&self, mock: &Mock, id: i64, submit: Option) -> PushResult { @@ -121,12 +147,21 @@ fn route(prefix: impl Into, status: u16, body: Value) -> Route { async fn mock(routes: Vec) -> Mock { let routes = Arc::new(routes); serve_fn(move |_, line| { - match routes.iter().find(|(prefix, _, _)| line.starts_with(prefix.as_str())) { - Some((_, status, body)) => Reply { status: *status, headers: vec![], body: body.to_string(), delay_ms: 0 }, + match routes + .iter() + .find(|(prefix, _, _)| line.starts_with(prefix.as_str())) + { + Some((_, status, body)) => Reply { + status: *status, + headers: vec![], + body: body.to_string(), + delay_ms: 0, + }, None => Reply { status: 404, headers: vec![], - body: json!({ "exc_type": "DoesNotExistError", "_error_message": "no such route" }).to_string(), + body: json!({ "exc_type": "DoesNotExistError", "_error_message": "no such route" }) + .to_string(), delay_ms: 0, }, } @@ -135,7 +170,12 @@ async fn mock(routes: Vec) -> Mock { } fn hits(m: &Mock, prefix: &str) -> usize { - m.requests.lock().unwrap().iter().filter(|r| r.starts_with(prefix)).count() + m.requests + .lock() + .unwrap() + .iter() + .filter(|r| r.starts_with(prefix)) + .count() } fn first_request(m: &Mock, prefix: &str) -> String { @@ -164,14 +204,30 @@ const UPLOAD: &str = "POST /api/method/upload_file HTTP"; fn master_routes() -> Vec { vec![ route(CUSTOMER_GET, 200, json!({ "data": [] })), - route(CUSTOMER_POST, 200, json!({ "data": { "name": "Client Ltd" } })), - route(ADDRESS_POST, 200, json!({ "data": { "name": "Client Ltd-Billing" } })), - route(UPLOAD, 200, json!({ "message": { "name": "f1", "file_url": "/private/files/x.pdf" } })), + route( + CUSTOMER_POST, + 200, + json!({ "data": { "name": "Client Ltd" } }), + ), + route( + ADDRESS_POST, + 200, + json!({ "data": { "name": "Client Ltd-Billing" } }), + ), + route( + UPLOAD, + 200, + json!({ "message": { "name": "f1", "file_url": "/private/files/x.pdf" } }), + ), ] } fn mirror_create(number: &str, docstatus: i64, total: f64) -> Route { - route(SI_POST_V2, 200, json!({ "data": { "name": number, "docstatus": docstatus, "grand_total": total } })) + route( + SI_POST_V2, + 200, + json!({ "data": { "name": number, "docstatus": docstatus, "grand_total": total } }), + ) } // ---- invoices ---- @@ -187,8 +243,18 @@ async fn draft_push_creates_customer_address_invoice_and_attaches_the_pdf() { let r = fx.push(&m, inv.id, None).await; assert!(r.ok, "{r:?}"); - assert_eq!((r.status.as_str(), r.remote_name.as_str(), r.remote_docstatus), ("synced", inv.number.as_str(), 0)); - assert!(r.created && r.attached && !r.no_op && r.warnings.is_empty(), "{r:?}"); + assert_eq!( + ( + r.status.as_str(), + r.remote_name.as_str(), + r.remote_docstatus + ), + ("synced", inv.number.as_str(), 0) + ); + assert!( + r.created && r.attached && !r.no_op && r.warnings.is_empty(), + "{r:?}" + ); assert!(!serde_json::to_string(&r).unwrap().contains("thesecret")); // Customer: looked up by GSTIN, then by name, then created; the address is created and linked. @@ -196,9 +262,20 @@ async fn draft_push_creates_customer_address_invoice_and_attaches_the_pdf() { assert!(first_request(&m, CUSTOMER_POST).contains("\"gstin\":\"29AABCU9603R1ZJ\"")); let address = json_body(&first_request(&m, ADDRESS_POST)); assert_eq!(address["state"], "Karnataka"); - assert_eq!(address["links"][0], json!({ "link_doctype": "Customer", "link_name": "Client Ltd" })); - assert_eq!(fx.scalar("SELECT erpnext_customer FROM clients WHERE id = 1").as_deref(), Some("Client Ltd")); - assert_eq!(fx.scalar("SELECT erpnext_address FROM clients WHERE id = 1").as_deref(), Some("Client Ltd-Billing")); + assert_eq!( + address["links"][0], + json!({ "link_doctype": "Customer", "link_name": "Client Ltd" }) + ); + assert_eq!( + fx.scalar("SELECT erpnext_customer FROM clients WHERE id = 1") + .as_deref(), + Some("Client Ltd") + ); + assert_eq!( + fx.scalar("SELECT erpnext_address FROM clients WHERE id = 1") + .as_deref(), + Some("Client Ltd-Billing") + ); // The invoice body: draft (no docstatus), customer and address from the created docs. let body = json_body(&first_request(&m, SI_POST_V2)); @@ -209,16 +286,40 @@ async fn draft_push_creates_customer_address_invoice_and_attaches_the_pdf() { // The attachment is a multipart POST: part `file` with the file name, plus doctype/docname/is_private. let up = first_request(&m, UPLOAD); - assert!(up.to_ascii_lowercase().contains("content-type: multipart/form-data; boundary="), "{up}"); - assert!(up.contains("name=\"file\"; filename=\"INV-2026-001.pdf\"") || up.contains(&format!("filename=\"{}.pdf\"", inv.number.replace('/', "-"))), "{up}"); + assert!( + up.to_ascii_lowercase() + .contains("content-type: multipart/form-data; boundary="), + "{up}" + ); + assert!( + up.contains("name=\"file\"; filename=\"INV-2026-001.pdf\"") + || up.contains(&format!( + "filename=\"{}.pdf\"", + inv.number.replace('/', "-") + )), + "{up}" + ); assert!(up.contains("Content-Type: application/pdf"), "{up}"); assert!(up.contains("%PDF-1.4 test invoice")); - assert!(up.contains("name=\"doctype\"\r\n\r\nSales Invoice\r\n"), "{up}"); - assert!(up.contains(&format!("name=\"docname\"\r\n\r\n{}\r\n", inv.number)), "{up}"); + assert!( + up.contains("name=\"doctype\"\r\n\r\nSales Invoice\r\n"), + "{up}" + ); + assert!( + up.contains(&format!("name=\"docname\"\r\n\r\n{}\r\n", inv.number)), + "{up}" + ); assert!(up.contains("name=\"is_private\"\r\n\r\n1\r\n"), "{up}"); let row = fx.sync_row(inv.id).unwrap(); - assert_eq!((row.status.as_str(), row.remote_docstatus, row.last_error.as_str()), ("synced", 0, "")); + assert_eq!( + ( + row.status.as_str(), + row.remote_docstatus, + row.last_error.as_str() + ), + ("synced", 0, "") + ); assert_eq!(row.attachment_sha256, sha); assert_eq!(row.payload_hash.len(), 64); assert!(row.synced_at.is_some()); @@ -229,9 +330,16 @@ async fn submit_flow_and_submitting_a_synced_draft_later_without_recreating_it() let fx = Fx::new(|_| {}); let inv = fx.issue("Client Ltd"); fx.archive(&inv); - let submit = format!("POST /api/v2/document/Sales%20Invoice/{}/method/submit", enc(&inv.number)); + let submit = format!( + "POST /api/v2/document/Sales%20Invoice/{}/method/submit", + enc(&inv.number) + ); let mut routes = master_routes(); - routes.push(route(submit.clone(), 200, json!({ "data": { "docstatus": 1 } }))); + routes.push(route( + submit.clone(), + 200, + json!({ "data": { "docstatus": 1 } }), + )); routes.push(mirror_create(&inv.number, 0, inv.total)); let m = mock(routes).await; @@ -242,7 +350,10 @@ async fn submit_flow_and_submitting_a_synced_draft_later_without_recreating_it() // Pushing again with submit submits the same document: no second create, no second upload. let r = fx.push(&m, inv.id, Some(true)).await; - assert!(r.ok && r.remote_docstatus == 1 && !r.created && !r.no_op, "{r:?}"); + assert!( + r.ok && r.remote_docstatus == 1 && !r.created && !r.no_op, + "{r:?}" + ); assert_eq!(hits(&m, SI_POST_V2), 1); assert_eq!(hits(&m, &submit), 1); assert_eq!(hits(&m, UPLOAD), 1); @@ -259,18 +370,42 @@ async fn submit_flow_and_submitting_a_synced_draft_later_without_recreating_it() async fn submit_on_push_setting_is_the_default_and_a_failed_submit_keeps_the_draft_recorded() { let fx = Fx::new(|c| c.submit_on_push = true); let inv = fx.issue("Client Ltd"); - let submit = format!("POST /api/v2/document/Sales%20Invoice/{}/method/submit", enc(&inv.number)); + let submit = format!( + "POST /api/v2/document/Sales%20Invoice/{}/method/submit", + enc(&inv.number) + ); let mut routes = master_routes(); - routes.push(route(submit, 417, json!({ "_error_message": "HSN code is mandatory" }))); + routes.push(route( + submit, + 417, + json!({ "_error_message": "HSN code is mandatory" }), + )); routes.push(mirror_create(&inv.number, 0, inv.total)); let m = mock(routes).await; let r = fx.push(&m, inv.id, None).await; assert!(!r.ok && r.status == "error" && r.created, "{r:?}"); - assert!(r.error.as_deref().unwrap().contains("Could not submit the Sales Invoice"), "{r:?}"); - assert!(r.error.as_deref().unwrap().contains("HSN code is mandatory")); + assert!( + r.error + .as_deref() + .unwrap() + .contains("Could not submit the Sales Invoice"), + "{r:?}" + ); + assert!(r + .error + .as_deref() + .unwrap() + .contains("HSN code is mandatory")); let row = fx.sync_row(inv.id).unwrap(); - assert_eq!((row.status.as_str(), row.remote_name.as_str(), row.remote_docstatus), ("error", inv.number.as_str(), 0)); + assert_eq!( + ( + row.status.as_str(), + row.remote_name.as_str(), + row.remote_docstatus + ), + ("error", inv.number.as_str(), 0) + ); assert!(row.last_error.contains("HSN code is mandatory")); } @@ -280,22 +415,42 @@ async fn a_created_invoice_whose_total_differs_is_a_conflict_that_is_kept_and_ne let fx = Fx::new(|_| {}); let inv = fx.issue("Client Ltd"); fx.archive(&inv); - let submit = format!("POST /api/v2/document/Sales%20Invoice/{}/method/submit", enc(&inv.number)); + let submit = format!( + "POST /api/v2/document/Sales%20Invoice/{}/method/submit", + enc(&inv.number) + ); let mut routes = master_routes(); - routes.push(route(submit.clone(), 200, json!({ "data": { "docstatus": 1 } }))); + routes.push(route( + submit.clone(), + 200, + json!({ "data": { "docstatus": 1 } }), + )); routes.push(mirror_create(&inv.number, 0, inv.total - 0.02)); let m = mock(routes).await; let r = fx.push(&m, inv.id, Some(true)).await; - assert!(!r.ok && r.status == "conflict" && r.error_kind == Some(ErrorKind::Conflict), "{r:?}"); + assert!( + !r.ok && r.status == "conflict" && r.error_kind == Some(ErrorKind::Conflict), + "{r:?}" + ); assert!(r.created && !r.attached, "{r:?}"); let msg = r.error.as_deref().unwrap(); - assert!(msg.contains("does not submit") && msg.contains("Commercial Rounding"), "{msg}"); + assert!( + msg.contains("does not submit") && msg.contains("Commercial Rounding"), + "{msg}" + ); assert_eq!(hits(&m, &submit), 0); assert_eq!(hits(&m, UPLOAD), 0); // The row keeps the remote name, so the draft is recognised later. let row = fx.sync_row(inv.id).unwrap(); - assert_eq!((row.status.as_str(), row.remote_name.as_str(), row.remote_docstatus), ("conflict", inv.number.as_str(), 0)); + assert_eq!( + ( + row.status.as_str(), + row.remote_name.as_str(), + row.remote_docstatus + ), + ("conflict", inv.number.as_str(), 0) + ); } #[tokio::test] @@ -315,17 +470,34 @@ async fn an_existing_customer_address_is_reused_instead_of_created_again() { let fx = Fx::new(|_| {}); let inv = fx.issue("Client Ltd"); let mut routes = master_routes(); - routes.insert(0, route("GET /api/resource/Address?", 200, json!({ "data": [{ "name": "Client Ltd-Billing-3" }] }))); + routes.insert( + 0, + route( + "GET /api/resource/Address?", + 200, + json!({ "data": [{ "name": "Client Ltd-Billing-3" }] }), + ), + ); routes.push(mirror_create(&inv.number, 0, inv.total)); let m = mock(routes).await; let r = fx.push(&m, inv.id, None).await; assert!(r.ok, "{r:?}"); assert_eq!(hits(&m, ADDRESS_POST), 0); - assert_eq!(json_body(&first_request(&m, SI_POST_V2))["customer_address"], "Client Ltd-Billing-3"); - assert_eq!(fx.scalar("SELECT erpnext_address FROM clients WHERE id = 1").as_deref(), Some("Client Ltd-Billing-3")); + assert_eq!( + json_body(&first_request(&m, SI_POST_V2))["customer_address"], + "Client Ltd-Billing-3" + ); + assert_eq!( + fx.scalar("SELECT erpnext_address FROM clients WHERE id = 1") + .as_deref(), + Some("Client Ltd-Billing-3") + ); let lookup = first_request(&m, "GET /api/resource/Address?"); - assert!(lookup.contains("address_line1") && lookup.contains("Customer"), "{lookup}"); + assert!( + lookup.contains("address_line1") && lookup.contains("Customer"), + "{lookup}" + ); } #[tokio::test] @@ -334,8 +506,19 @@ async fn an_adopted_invoice_that_already_carries_the_pdf_is_not_attached_twice() let inv = fx.issue("Client Ltd"); let sha = fx.archive(&inv); let mut routes = master_routes(); - routes.insert(0, route("GET /api/resource/File?", 200, json!({ "data": [{ "name": "f1" }] }))); - routes.push(route(SI_POST_V2, 409, json!({ "exc_type": "DuplicateEntryError", "_error_message": "already exists" }))); + routes.insert( + 0, + route( + "GET /api/resource/File?", + 200, + json!({ "data": [{ "name": "f1" }] }), + ), + ); + routes.push(route( + SI_POST_V2, + 409, + json!({ "exc_type": "DuplicateEntryError", "_error_message": "already exists" }), + )); routes.push(route( format!("GET /api/v2/document/Sales%20Invoice/{}", enc(&inv.number)), 200, @@ -362,34 +545,66 @@ async fn a_fractional_quantity_refused_by_erpnext_says_what_to_change() { let m = mock(routes).await; let r = fx.push(&m, inv.id, None).await; let msg = r.error.as_deref().unwrap(); - assert!(!r.ok && msg.contains("Row 1: Quantity (1.5) cannot be a fraction") && msg.contains("unit mapping"), "{msg}"); + assert!( + !r.ok + && msg.contains("Row 1: Quantity (1.5) cannot be a fraction") + && msg.contains("unit mapping"), + "{msg}" + ); } #[tokio::test] async fn a_reverse_charge_invoice_is_sent_as_a_normal_one_with_a_warning() { let fx = Fx::new(|_| {}); let inv = fx.issue("Client Ltd"); - fx.db.lock().unwrap().execute("UPDATE invoices SET reverse_charge = 1 WHERE id = ?1", params![inv.id]).unwrap(); + fx.db + .lock() + .unwrap() + .execute( + "UPDATE invoices SET reverse_charge = 1 WHERE id = ?1", + params![inv.id], + ) + .unwrap(); let mut routes = master_routes(); routes.push(mirror_create(&inv.number, 0, inv.total)); let m = mock(routes).await; let r = fx.push(&m, inv.id, None).await; assert!(r.ok, "{r:?}"); - assert!(r.warnings.iter().any(|w| w.contains("reverse charge")), "{:?}", r.warnings); - assert_eq!(json_body(&first_request(&m, SI_POST_V2))["is_reverse_charge"], 0); + assert!( + r.warnings.iter().any(|w| w.contains("reverse charge")), + "{:?}", + r.warnings + ); + assert_eq!( + json_body(&first_request(&m, SI_POST_V2))["is_reverse_charge"], + 0 + ); } #[tokio::test] async fn a_mirrored_number_india_compliance_would_refuse_is_stopped_before_anything_is_created() { let fx = Fx::new(|_| {}); let inv = fx.issue("Client Ltd"); - fx.db.lock().unwrap().execute("UPDATE invoices SET number = 'IMPORTED/2026-INVOICE-0001' WHERE id = ?1", params![inv.id]).unwrap(); + fx.db + .lock() + .unwrap() + .execute( + "UPDATE invoices SET number = 'IMPORTED/2026-INVOICE-0001' WHERE id = ?1", + params![inv.id], + ) + .unwrap(); let mut routes = master_routes(); routes.push(mirror_create("IMPORTED/2026-INVOICE-0001", 0, inv.total)); let m = mock(routes).await; let r = fx.push(&m, inv.id, None).await; - assert!(!r.ok && r.error_kind == Some(ErrorKind::Precondition), "{r:?}"); - assert!(r.error.as_deref().unwrap().contains("16 characters"), "{r:?}"); + assert!( + !r.ok && r.error_kind == Some(ErrorKind::Precondition), + "{r:?}" + ); + assert!( + r.error.as_deref().unwrap().contains("16 characters"), + "{r:?}" + ); assert_eq!(hits(&m, SI_POST_V2), 0); // Series naming lets ERPNext pick the name, so the same number is fine there. @@ -398,10 +613,21 @@ async fn a_mirrored_number_india_compliance_would_refuse_is_stopped_before_anyth c.naming_series = "SINV-.YY.-".into(); }); let inv = fx.issue("Client Ltd"); - fx.db.lock().unwrap().execute("UPDATE invoices SET number = 'IMPORTED/2026-INVOICE-0001' WHERE id = ?1", params![inv.id]).unwrap(); + fx.db + .lock() + .unwrap() + .execute( + "UPDATE invoices SET number = 'IMPORTED/2026-INVOICE-0001' WHERE id = ?1", + params![inv.id], + ) + .unwrap(); let mut routes = master_routes(); routes.push(route(SI_LIST_V1, 200, json!({ "data": [] }))); - routes.push(route(SI_POST_V1, 200, json!({ "data": { "name": "SINV-26-00001", "docstatus": 0, "grand_total": inv.total } }))); + routes.push(route( + SI_POST_V1, + 200, + json!({ "data": { "name": "SINV-26-00001", "docstatus": 0, "grand_total": inv.total } }), + )); let m = mock(routes).await; let r = fx.push(&m, inv.id, None).await; assert!(r.ok, "{r:?}"); @@ -412,7 +638,11 @@ async fn mirrored_409_with_the_same_total_is_recorded_as_synced() { let fx = Fx::new(|_| {}); let inv = fx.issue("Client Ltd"); let mut routes = master_routes(); - routes.push(route(SI_POST_V2, 409, json!({ "exc_type": "DuplicateEntryError", "_error_message": "already exists" }))); + routes.push(route( + SI_POST_V2, + 409, + json!({ "exc_type": "DuplicateEntryError", "_error_message": "already exists" }), + )); routes.push(route( format!("GET /api/v2/document/Sales%20Invoice/{}", enc(&inv.number)), 200, @@ -421,7 +651,10 @@ async fn mirrored_409_with_the_same_total_is_recorded_as_synced() { let m = mock(routes).await; let r = fx.push(&m, inv.id, Some(false)).await; - assert!(r.ok && !r.created && r.status == "synced" && r.remote_docstatus == 1, "{r:?}"); + assert!( + r.ok && !r.created && r.status == "synced" && r.remote_docstatus == 1, + "{r:?}" + ); let row = fx.sync_row(inv.id).unwrap(); assert_eq!((row.status.as_str(), row.remote_docstatus), ("synced", 1)); } @@ -431,7 +664,11 @@ async fn mirrored_409_with_a_different_total_is_a_conflict_and_nothing_is_overwr let fx = Fx::new(|_| {}); let inv = fx.issue("Client Ltd"); let mut routes = master_routes(); - routes.push(route(SI_POST_V2, 409, json!({ "exc_type": "DuplicateEntryError", "_error_message": "already exists" }))); + routes.push(route( + SI_POST_V2, + 409, + json!({ "exc_type": "DuplicateEntryError", "_error_message": "already exists" }), + )); routes.push(route( format!("GET /api/v2/document/Sales%20Invoice/{}", enc(&inv.number)), 200, @@ -440,9 +677,15 @@ async fn mirrored_409_with_a_different_total_is_a_conflict_and_nothing_is_overwr let m = mock(routes).await; let r = fx.push(&m, inv.id, Some(true)).await; - assert!(!r.ok && r.status == "conflict" && r.error_kind == Some(ErrorKind::Conflict), "{r:?}"); + assert!( + !r.ok && r.status == "conflict" && r.error_kind == Some(ErrorKind::Conflict), + "{r:?}" + ); let msg = r.error.as_deref().unwrap(); - assert!(msg.contains("does not overwrite") && msg.contains(&inv.number), "{msg}"); + assert!( + msg.contains("does not overwrite") && msg.contains(&inv.number), + "{msg}" + ); let row = fx.sync_row(inv.id).unwrap(); assert_eq!(row.status, "conflict"); assert!(row.last_error.contains("does not overwrite")); @@ -471,7 +714,10 @@ async fn series_mode_finds_an_existing_document_by_remarks_instead_of_posting_ag let m = mock(routes).await; let r = fx.push(&m, inv.id, Some(false)).await; - assert!(r.ok && !r.created && r.remote_name == "SINV-26-00001", "{r:?}"); + assert!( + r.ok && !r.created && r.remote_name == "SINV-26-00001", + "{r:?}" + ); assert_eq!(hits(&m, SI_POST_V1), 0, "must not create a second document"); assert_eq!(fx.sync_row(inv.id).unwrap().remote_name, "SINV-26-00001"); } @@ -485,18 +731,28 @@ async fn series_mode_creates_with_the_remarks_marker_when_nothing_is_there_yet() let inv = fx.issue("Client Ltd"); let mut routes = master_routes(); routes.push(route(SI_LIST_V1, 200, json!({ "data": [] }))); - routes.push(route(SI_POST_V1, 200, json!({ "data": { "name": "SINV-26-00002", "docstatus": 0 } }))); + routes.push(route( + SI_POST_V1, + 200, + json!({ "data": { "name": "SINV-26-00002", "docstatus": 0 } }), + )); let submit = "POST /api/resource/Sales%20Invoice/SINV-26-00002 HTTP"; routes.push(route(submit, 200, json!({ "data": { "docstatus": 1 } }))); let m = mock(routes).await; let r = fx.push(&m, inv.id, Some(true)).await; - assert!(r.ok && r.created && r.remote_name == "SINV-26-00002" && r.remote_docstatus == 1, "{r:?}"); + assert!( + r.ok && r.created && r.remote_name == "SINV-26-00002" && r.remote_docstatus == 1, + "{r:?}" + ); let body = json_body(&first_request(&m, SI_POST_V1)); assert_eq!(body["naming_series"], "SINV-.YY.-"); assert_eq!(body["remarks"], format!("Voiced invoice {}", inv.number)); assert!(body.get("name").is_none()); - assert_eq!(json_body(&first_request(&m, submit)), json!({ "run_method": "submit" })); + assert_eq!( + json_body(&first_request(&m, submit)), + json!({ "run_method": "submit" }) + ); // A re-push goes by the stored remote name; no lookup, no second create. let before = hits(&m, SI_POST_V1); @@ -509,36 +765,64 @@ async fn series_mode_creates_with_the_remarks_marker_when_nothing_is_there_yet() async fn a_duplicate_customer_name_is_read_back_and_used_everywhere() { let fx = Fx::new(|_| {}); let inv = fx.issue("Client Ltd"); - let mut routes = vec![route(CUSTOMER_POST, 200, json!({ "data": { "name": "Client Ltd - 1" } }))]; + let mut routes = vec![route( + CUSTOMER_POST, + 200, + json!({ "data": { "name": "Client Ltd - 1" } }), + )]; routes.extend(master_routes()); routes.push(mirror_create(&inv.number, 0, inv.total)); let m = mock(routes).await; let r = fx.push(&m, inv.id, Some(false)).await; assert!(r.ok, "{r:?}"); - assert_eq!(json_body(&first_request(&m, SI_POST_V2))["customer"], "Client Ltd - 1"); - assert_eq!(json_body(&first_request(&m, ADDRESS_POST))["links"][0]["link_name"], "Client Ltd - 1"); - assert_eq!(fx.scalar("SELECT erpnext_customer FROM clients WHERE id = 1").as_deref(), Some("Client Ltd - 1")); + assert_eq!( + json_body(&first_request(&m, SI_POST_V2))["customer"], + "Client Ltd - 1" + ); + assert_eq!( + json_body(&first_request(&m, ADDRESS_POST))["links"][0]["link_name"], + "Client Ltd - 1" + ); + assert_eq!( + fx.scalar("SELECT erpnext_customer FROM clients WHERE id = 1") + .as_deref(), + Some("Client Ltd - 1") + ); } #[tokio::test] async fn an_existing_customer_is_found_not_created_and_creation_can_be_switched_off() { let fx = Fx::new(|_| {}); let inv = fx.issue("Client Ltd"); - let mut routes = vec![route(CUSTOMER_GET, 200, json!({ "data": [{ "name": "Client Ltd - GST" }] }))]; + let mut routes = vec![route( + CUSTOMER_GET, + 200, + json!({ "data": [{ "name": "Client Ltd - GST" }] }), + )]; routes.extend(master_routes()); routes.push(mirror_create(&inv.number, 0, inv.total)); let m = mock(routes).await; let r = fx.push(&m, inv.id, Some(false)).await; assert!(r.ok, "{r:?}"); assert_eq!(hits(&m, CUSTOMER_POST), 0); - assert_eq!(json_body(&first_request(&m, SI_POST_V2))["customer"], "Client Ltd - GST"); + assert_eq!( + json_body(&first_request(&m, SI_POST_V2))["customer"], + "Client Ltd - GST" + ); let fx = Fx::new(|c| c.create_missing_customers = false); let inv = fx.issue("Client Ltd"); let m = mock(master_routes()).await; let r = fx.push(&m, inv.id, Some(false)).await; - assert!(!r.ok && r.error.as_deref().unwrap().contains("creating customers is switched off"), "{r:?}"); + assert!( + !r.ok + && r.error + .as_deref() + .unwrap() + .contains("creating customers is switched off"), + "{r:?}" + ); assert_eq!(hits(&m, CUSTOMER_POST), 0); assert_eq!(fx.sync_row(inv.id).unwrap().status, "error"); } @@ -558,11 +842,24 @@ async fn identical_re_push_of_a_synced_invoice_sends_nothing() { let before = m.requests.lock().unwrap().len(); let second = fx.push(&m, inv.id, Some(false)).await; - assert!(second.ok && second.no_op && !second.created && second.attached, "{second:?}"); - assert_eq!((second.remote_name.as_str(), second.remote_docstatus), (inv.number.as_str(), 0)); - assert_eq!(m.requests.lock().unwrap().len(), before, "no request at all"); + assert!( + second.ok && second.no_op && !second.created && second.attached, + "{second:?}" + ); + assert_eq!( + (second.remote_name.as_str(), second.remote_docstatus), + (inv.number.as_str(), 0) + ); + assert_eq!( + m.requests.lock().unwrap().len(), + before, + "no request at all" + ); let after = fx.sync_row(inv.id).unwrap(); - assert_eq!((after.payload_hash, after.synced_at), (row.payload_hash, row.synced_at)); + assert_eq!( + (after.payload_hash, after.synced_at), + (row.payload_hash, row.synced_at) + ); } #[tokio::test] @@ -581,12 +878,24 @@ async fn a_417_is_recorded_in_the_sync_table_with_a_readable_message() { let m = mock(routes).await; let r = fx.push(&m, inv.id, Some(false)).await; - assert!(!r.ok && r.status == "error" && r.error_kind == Some(ErrorKind::Validation), "{r:?}"); + assert!( + !r.ok && r.status == "error" && r.error_kind == Some(ErrorKind::Validation), + "{r:?}" + ); let msg = r.error.as_deref().unwrap(); - assert!(msg.starts_with("Could not create the Sales Invoice: ERPNext rejected the data (HTTP 417)"), "{msg}"); - assert!(msg.contains("Row 1: Rate is required") && !msg.contains('<'), "{msg}"); + assert!( + msg.starts_with("Could not create the Sales Invoice: ERPNext rejected the data (HTTP 417)"), + "{msg}" + ); + assert!( + msg.contains("Row 1: Rate is required") && !msg.contains('<'), + "{msg}" + ); let row = fx.sync_row(inv.id).unwrap(); - assert_eq!((row.status.as_str(), row.remote_name.as_str()), ("error", "")); + assert_eq!( + (row.status.as_str(), row.remote_name.as_str()), + ("error", "") + ); assert_eq!(row.last_error, msg); assert_eq!(hits(&m, SI_POST_V2), 1, "a validation error is not retried"); } @@ -609,21 +918,43 @@ async fn bulk_push_continues_after_a_failing_or_refused_row() { // Per-invoice create responses are chosen by the request body, which the mock cannot see; the name falls back // to the invoice number when the answer carries none. routes.retain(|(p, _, _)| p != SI_POST_V2); - routes.push(route(SI_POST_V2, 200, json!({ "data": { "docstatus": 0 } }))); + routes.push(route( + SI_POST_V2, + 200, + json!({ "data": { "docstatus": 0 } }), + )); let m = mock(routes).await; - let out = push_invoices(&fx.db, fx.dir.path(), &client(&m), &[a.id, b.id, cancelled.id, 9999, d.id, a.id], Some(false)).await; + let out = push_invoices( + &fx.db, + fx.dir.path(), + &client(&m), + &[a.id, b.id, cancelled.id, 9999, d.id, a.id], + Some(false), + ) + .await; assert_eq!(out.len(), 5, "the repeated id is sent once"); let oks: Vec = out.iter().map(|r| r.ok).collect(); assert_eq!(oks, [true, false, false, false, true], "{out:#?}"); assert_eq!(out[1].status, "error"); - assert!(out[1].error.as_deref().unwrap().contains("Permission denied"), "{:?}", out[1]); + assert!( + out[1] + .error + .as_deref() + .unwrap() + .contains("Permission denied"), + "{:?}", + out[1] + ); assert_eq!(out[2].status, "refused"); assert!(out[2].error.as_deref().unwrap().contains("cancelled")); assert_eq!(out[3].status, "refused"); assert_eq!(out[4].remote_name, d.number); assert!(fx.sync_row(b.id).is_some_and(|r| r.status == "error")); - assert!(fx.sync_row(cancelled.id).is_none(), "a refusal records nothing"); + assert!( + fx.sync_row(cancelled.id).is_none(), + "a refusal records nothing" + ); } #[tokio::test] @@ -639,14 +970,26 @@ async fn cancelled_and_draft_invoices_are_refused_before_any_request() { [], ) .unwrap(); - let draft_id: i64 = fx.db.lock().unwrap().query_row("SELECT id FROM invoices WHERE number = 'DRAFT-1'", [], |r| r.get(0)).unwrap(); + let draft_id: i64 = fx + .db + .lock() + .unwrap() + .query_row( + "SELECT id FROM invoices WHERE number = 'DRAFT-1'", + [], + |r| r.get(0), + ) + .unwrap(); let m = mock(master_routes()).await; let r = fx.push(&m, inv.id, None).await; assert!(!r.ok && r.status == "refused" && r.error_kind == Some(ErrorKind::Precondition)); assert!(r.error.as_deref().unwrap().contains("cancelled"), "{r:?}"); let d = fx.push(&m, draft_id, None).await; - assert!(!d.ok && d.status == "refused" && d.error.as_deref().unwrap().contains("draft"), "{d:?}"); + assert!( + !d.ok && d.status == "refused" && d.error.as_deref().unwrap().contains("draft"), + "{d:?}" + ); assert!(m.requests.lock().unwrap().is_empty()); assert!(fx.sync_row(inv.id).is_none() && fx.sync_row(draft_id).is_none()); } @@ -660,14 +1003,30 @@ async fn a_registered_vendor_must_run_the_connection_test_before_pushing() { let m = mock(routes).await; let r = fx.push(&m, inv.id, Some(false)).await; - assert!(!r.ok && r.status == "refused" && r.error_kind == Some(ErrorKind::Precondition), "{r:?}"); - assert!(r.error.as_deref().unwrap().contains("Run Test connection first"), "{r:?}"); + assert!( + !r.ok && r.status == "refused" && r.error_kind == Some(ErrorKind::Precondition), + "{r:?}" + ); + assert!( + r.error + .as_deref() + .unwrap() + .contains("Run Test connection first"), + "{r:?}" + ); assert!(m.requests.lock().unwrap().is_empty()); assert!(fx.sync_row(inv.id).is_none()); // `{}` and `null` are as good as nothing; a stored result unlocks the push. assert!(!has_detect_result("") && !has_detect_result("{}") && !has_detect_result("null")); - fx.db.lock().unwrap().execute("UPDATE erpnext_config SET last_detect_result = '{\"indiaCompliance\":false}'", []).unwrap(); + fx.db + .lock() + .unwrap() + .execute( + "UPDATE erpnext_config SET last_detect_result = '{\"indiaCompliance\":false}'", + [], + ) + .unwrap(); let r = fx.push(&m, inv.id, Some(false)).await; assert!(r.ok, "{r:?}"); } @@ -682,7 +1041,15 @@ async fn an_unregistered_vendor_needs_no_connection_test() { .unwrap() .execute("UPDATE invoices SET vendor_snapshot = replace(vendor_snapshot, '\"gstRegistration\":\"regular\"', '\"gstRegistration\":\"unregistered\"')", []) .unwrap(); - assert!(!Vendor::from_snapshot(&get_invoice_impl(&fx.db.lock().unwrap(), inv.id).unwrap().vendor_snapshot).unwrap().registered); + assert!( + !Vendor::from_snapshot( + &get_invoice_impl(&fx.db.lock().unwrap(), inv.id) + .unwrap() + .vendor_snapshot + ) + .unwrap() + .registered + ); let mut routes = master_routes(); routes.push(mirror_create(&inv.number, 0, inv.total)); let m = mock(routes).await; @@ -700,14 +1067,21 @@ async fn an_unarchived_invoice_is_pushed_without_a_pdf_and_attached_by_a_later_p let r = fx.push(&m, inv.id, Some(false)).await; assert!(r.ok && !r.attached, "{r:?}"); - assert!(r.warnings.iter().any(|w| w.contains("not archived")), "{:?}", r.warnings); + assert!( + r.warnings.iter().any(|w| w.contains("not archived")), + "{:?}", + r.warnings + ); assert_eq!(hits(&m, UPLOAD), 0); assert_eq!(fx.sync_row(inv.id).unwrap().attachment_sha256, ""); // Once archived, the next push attaches it (and still does not create the invoice again). fx.archive(&inv); let again = fx.push(&m, inv.id, Some(false)).await; - assert!(again.ok && again.attached && !again.no_op && !again.created, "{again:?}"); + assert!( + again.ok && again.attached && !again.no_op && !again.created, + "{again:?}" + ); assert_eq!(hits(&m, UPLOAD), 1); assert_eq!(hits(&m, SI_POST_V2), 1); } @@ -727,7 +1101,11 @@ async fn pdf_attachment_can_be_switched_off_and_a_failed_upload_is_only_a_warnin let fx = Fx::new(|_| {}); let inv = fx.issue("Client Ltd"); fx.archive(&inv); - let mut routes = vec![route(UPLOAD, 403, json!({ "_error_message": "No permission for File" }))]; + let mut routes = vec![route( + UPLOAD, + 403, + json!({ "_error_message": "No permission for File" }), + )]; routes.extend(master_routes()); routes.push(mirror_create(&inv.number, 0, inv.total)); let m = mock(routes).await; @@ -735,21 +1113,37 @@ async fn pdf_attachment_can_be_switched_off_and_a_failed_upload_is_only_a_warnin assert!(r.ok && !r.attached, "{r:?}"); assert!(r.warnings.iter().any(|w| w.contains("The PDF was not attached") && w.contains("No permission for File")), "{:?}", r.warnings); let row = fx.sync_row(inv.id).unwrap(); - assert_eq!((row.status.as_str(), row.attachment_sha256.as_str()), ("synced", "")); + assert_eq!( + (row.status.as_str(), row.attachment_sha256.as_str()), + ("synced", "") + ); } #[tokio::test] async fn local_address_problems_stop_the_push_before_any_address_is_sent() { let fx = Fx::new(|_| {}); let inv = fx.issue("Client Ltd"); - fx.db.lock().unwrap().execute("UPDATE clients SET pincode = '411004' WHERE id = 1", []).unwrap(); + fx.db + .lock() + .unwrap() + .execute("UPDATE clients SET pincode = '411004' WHERE id = 1", []) + .unwrap(); let mut routes = master_routes(); routes.push(mirror_create(&inv.number, 0, inv.total)); let m = mock(routes).await; let r = fx.push(&m, inv.id, Some(false)).await; - assert!(!r.ok && r.error_kind == Some(ErrorKind::Precondition), "{r:?}"); - assert!(r.error.as_deref().unwrap().contains("PIN code 411004 does not belong to Karnataka"), "{r:?}"); + assert!( + !r.ok && r.error_kind == Some(ErrorKind::Precondition), + "{r:?}" + ); + assert!( + r.error + .as_deref() + .unwrap() + .contains("PIN code 411004 does not belong to Karnataka"), + "{r:?}" + ); assert_eq!(hits(&m, ADDRESS_POST), 0); assert_eq!(fx.sync_row(inv.id).unwrap().status, "error"); } @@ -763,13 +1157,20 @@ fn address_validation_checks_pin_state_and_gstin_prefix() { .unwrap(); assert!(validate_address(&c).is_ok()); c.pincode = "56001".into(); - assert!(validate_address(&c).unwrap_err().contains("not a valid 6-digit PIN")); + assert!(validate_address(&c) + .unwrap_err() + .contains("not a valid 6-digit PIN")); c.pincode = "411004".into(); - assert!(validate_address(&c).unwrap_err().contains("does not belong to Karnataka")); + assert!(validate_address(&c) + .unwrap_err() + .contains("does not belong to Karnataka")); c.pincode = "411004".into(); c.state_code = "27".into(); let err = validate_address(&c).unwrap_err(); - assert!(err.contains("starts with state code 29") && err.contains("Maharashtra"), "{err}"); + assert!( + err.contains("starts with state code 29") && err.contains("Maharashtra"), + "{err}" + ); // An unregistered client's GSTIN field is ignored, and an empty PIN is allowed. c.gst_category = "unregistered".into(); c.pincode.clear(); @@ -800,7 +1201,9 @@ fn sync_status_list_and_open_url() { let conn = fx.db.lock().unwrap(); assert_eq!(sync_status(&conn, inv.id).unwrap().status, "none"); assert!(sync_statuses(&conn).unwrap().is_empty()); - assert!(open_url(&conn, inv.id).unwrap_err().contains("not been sent")); + assert!(open_url(&conn, inv.id) + .unwrap_err() + .contains("not been sent")); drop(conn); let mut conn = fx.db.lock().unwrap(); @@ -818,12 +1221,30 @@ fn sync_status_list_and_open_url() { ) .unwrap(); let s = sync_status(&conn, inv.id).unwrap(); - assert_eq!((s.status.as_str(), s.remote_name.as_str(), s.remote_docstatus, s.attached), ("synced", "INV/2026-001", 1, true)); + assert_eq!( + ( + s.status.as_str(), + s.remote_name.as_str(), + s.remote_docstatus, + s.attached + ), + ("synced", "INV/2026-001", 1, true) + ); assert_eq!(sync_statuses(&conn).unwrap(), vec![s]); - assert_eq!(open_url(&conn, inv.id).unwrap(), "http://127.0.0.1:8000/app/sales-invoice/INV%2F2026-001"); + assert_eq!( + open_url(&conn, inv.id).unwrap(), + "http://127.0.0.1:8000/app/sales-invoice/INV%2F2026-001" + ); // Names with spaces and other characters stay one path segment. - conn.execute("UPDATE erpnext_sync SET remote_name = 'SINV 26/1?x' WHERE invoice_id = ?1", params![inv.id]).unwrap(); - assert_eq!(open_url(&conn, inv.id).unwrap(), "http://127.0.0.1:8000/app/sales-invoice/SINV%2026%2F1%3Fx"); + conn.execute( + "UPDATE erpnext_sync SET remote_name = 'SINV 26/1?x' WHERE invoice_id = ?1", + params![inv.id], + ) + .unwrap(); + assert_eq!( + open_url(&conn, inv.id).unwrap(), + "http://127.0.0.1:8000/app/sales-invoice/SINV%2026%2F1%3Fx" + ); } // ---- payments ---- @@ -846,14 +1267,20 @@ fn payment_draft(remote_invoice: &str) -> Value { }) } -const PE_GET: &str = "GET /api/method/erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry?"; +const PE_GET: &str = + "GET /api/method/erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry?"; const PE_POST: &str = "POST /api/resource/Payment%20Entry HTTP"; fn mark_invoice_synced(fx: &Fx, inv: &Invoice, docstatus: i64) { write_sync( &mut fx.db.lock().unwrap(), inv.id, - &SyncRow { remote_name: inv.number.clone(), remote_docstatus: docstatus, status: "synced".into(), ..Default::default() }, + &SyncRow { + remote_name: inv.number.clone(), + remote_docstatus: docstatus, + status: "synced".into(), + ..Default::default() + }, ) .unwrap(); } @@ -883,13 +1310,29 @@ async fn payments_need_a_synced_and_submitted_parent_invoice() { let m = mock(vec![]).await; let r = push_payment(&fx.db, &client(&m), pid).await; - assert!(!r.ok && r.error_kind == Some(ErrorKind::Precondition), "{r:?}"); - assert!(r.error.as_deref().unwrap().contains("has not been sent to ERPNext"), "{r:?}"); + assert!( + !r.ok && r.error_kind == Some(ErrorKind::Precondition), + "{r:?}" + ); + assert!( + r.error + .as_deref() + .unwrap() + .contains("has not been sent to ERPNext"), + "{r:?}" + ); mark_invoice_synced(&fx, &inv, 0); let r = push_payment(&fx.db, &client(&m), pid).await; assert!(!r.ok, "{r:?}"); - assert!(r.error.as_deref().unwrap().to_lowercase().contains("submit the invoice in erpnext first"), "{r:?}"); + assert!( + r.error + .as_deref() + .unwrap() + .to_lowercase() + .contains("submit the invoice in erpnext first"), + "{r:?}" + ); assert!(m.requests.lock().unwrap().is_empty()); } @@ -900,17 +1343,36 @@ async fn payment_with_tds_maps_deductions_and_is_idempotent() { mark_invoice_synced(&fx, &inv, 1); let pid = add_payment(&fx, &inv, 100_000, 10_000); let m = mock(vec![ - route(PE_GET, 200, json!({ "message": payment_draft(&inv.number) })), - route(PE_POST, 200, json!({ "data": { "name": "ACC-PAY-2026-00001" } })), + route( + PE_GET, + 200, + json!({ "message": payment_draft(&inv.number) }), + ), + route( + PE_POST, + 200, + json!({ "data": { "name": "ACC-PAY-2026-00001" } }), + ), ]) .await; let r = push_payment(&fx.db, &client(&m), pid).await; - assert!(r.ok && !r.already_synced && r.entry_name.as_deref() == Some("ACC-PAY-2026-00001"), "{r:?}"); + assert!( + r.ok && !r.already_synced && r.entry_name.as_deref() == Some("ACC-PAY-2026-00001"), + "{r:?}" + ); let get = first_request(&m, PE_GET); - for part in ["dt=Sales+Invoice", &format!("dn={}", inv.number.replace('/', "%2F")), "bank_account=HDFC+-+AC", "party_amount=1100.00"] { - assert!(get.lines().next().unwrap().contains(part), "{part} in {get}"); + for part in [ + "dt=Sales+Invoice", + &format!("dn={}", inv.number.replace('/', "%2F")), + "bank_account=HDFC+-+AC", + "party_amount=1100.00", + ] { + assert!( + get.lines().next().unwrap().contains(part), + "{part} in {get}" + ); } let body = json_body(&first_request(&m, PE_POST)); assert_eq!(body["docstatus"], 1); @@ -920,17 +1382,31 @@ async fn payment_with_tds_maps_deductions_and_is_idempotent() { assert_eq!(body["paid_amount"], json!(1000.0)); assert_eq!(body["received_amount"], json!(1000.0)); assert_eq!(body["references"][1]["allocated_amount"], json!(1100.0)); - assert_eq!(body["references"][0]["allocated_amount"], json!(5.0), "other references are left alone"); - assert_eq!(body["deductions"], json!([{ "account": "TDS Receivable - AC", "cost_center": "Main - AC", "amount": 100.0 }])); + assert_eq!( + body["references"][0]["allocated_amount"], + json!(5.0), + "other references are left alone" + ); + assert_eq!( + body["deductions"], + json!([{ "account": "TDS Receivable - AC", "cost_center": "Main - AC", "amount": 100.0 }]) + ); assert!(body.get("__onload").is_none()); assert_eq!( - fx.scalar(&format!("SELECT erpnext_payment_entry FROM payments WHERE id = {pid}")).as_deref(), + fx.scalar(&format!( + "SELECT erpnext_payment_entry FROM payments WHERE id = {pid}" + )) + .as_deref(), Some("ACC-PAY-2026-00001") ); let before = m.requests.lock().unwrap().len(); let again = push_payment(&fx.db, &client(&m), pid).await; - assert!(again.ok && again.already_synced && again.entry_name.as_deref() == Some("ACC-PAY-2026-00001")); + assert!( + again.ok + && again.already_synced + && again.entry_name.as_deref() == Some("ACC-PAY-2026-00001") + ); assert_eq!(m.requests.lock().unwrap().len(), before); } @@ -942,22 +1418,39 @@ async fn tds_account_is_required_only_for_payments_with_tds_and_the_bank_always( let with_tds = add_payment(&fx, &inv, 100_000, 10_000); let without = add_payment(&fx, &inv, 200_000, 0); let m = mock(vec![ - route(PE_GET, 200, json!({ "message": payment_draft(&inv.number) })), + route( + PE_GET, + 200, + json!({ "message": payment_draft(&inv.number) }), + ), route(PE_POST, 200, json!({ "data": { "name": "ACC-PAY-1" } })), ]) .await; let r = push_payment(&fx.db, &client(&m), with_tds).await; - assert!(!r.ok && r.error.as_deref().unwrap().contains("TDS account"), "{r:?}"); + assert!( + !r.ok && r.error.as_deref().unwrap().contains("TDS account"), + "{r:?}" + ); assert!(m.requests.lock().unwrap().is_empty()); let r = push_payment(&fx.db, &client(&m), without).await; assert!(r.ok, "{r:?}"); - assert_eq!(json_body(&first_request(&m, PE_POST))["deductions"], json!([])); + assert_eq!( + json_body(&first_request(&m, PE_POST))["deductions"], + json!([]) + ); - fx.db.lock().unwrap().execute("UPDATE erpnext_config SET payment_bank_account = ''", []).unwrap(); + fx.db + .lock() + .unwrap() + .execute("UPDATE erpnext_config SET payment_bank_account = ''", []) + .unwrap(); let r = push_payment(&fx.db, &client(&m), with_tds).await; - assert!(!r.ok && r.error.as_deref().unwrap().contains("payment bank account"), "{r:?}"); + assert!( + !r.ok && r.error.as_deref().unwrap().contains("payment bank account"), + "{r:?}" + ); } #[tokio::test] @@ -969,7 +1462,10 @@ async fn a_tds_only_payment_is_refused_locally_with_a_readable_reason() { let pid = add_payment(&fx, &inv, 0, 100_000); let m = mock(vec![]).await; let r = push_payment(&fx.db, &client(&m), pid).await; - assert!(!r.ok && r.error_kind == Some(ErrorKind::Precondition), "{r:?}"); + assert!( + !r.ok && r.error_kind == Some(ErrorKind::Precondition), + "{r:?}" + ); assert!(r.error.as_deref().unwrap().contains("TDS only"), "{r:?}"); assert!(m.requests.lock().unwrap().is_empty()); } @@ -986,10 +1482,28 @@ async fn payment_errors_come_back_readable_and_leave_the_payment_unlinked() { ]) .await; let r = push_payment(&fx.db, &client(&m), pid).await; - assert!(!r.ok && r.error_kind == Some(ErrorKind::Validation), "{r:?}"); - assert!(r.error.as_deref().unwrap().starts_with("Could not create the Payment Entry:"), "{r:?}"); - assert!(r.error.as_deref().unwrap().contains("Reference No and Reference Date")); - assert_eq!(fx.scalar(&format!("SELECT erpnext_payment_entry FROM payments WHERE id = {pid}")), None); + assert!( + !r.ok && r.error_kind == Some(ErrorKind::Validation), + "{r:?}" + ); + assert!( + r.error + .as_deref() + .unwrap() + .starts_with("Could not create the Payment Entry:"), + "{r:?}" + ); + assert!(r + .error + .as_deref() + .unwrap() + .contains("Reference No and Reference Date")); + assert_eq!( + fx.scalar(&format!( + "SELECT erpnext_payment_entry FROM payments WHERE id = {pid}" + )), + None + ); } #[test] @@ -1007,19 +1521,34 @@ fn payment_entry_mapping_is_pure_and_matches_the_shape_verified_on_a_live_site() }; let draft = payment_draft("INV/2026-001"); let doc = build_payment_entry(&draft, &input(9_950, "")).unwrap(); - assert_eq!(doc["reference_no"], "Voiced payment 7", "a blank reference gets a placeholder; ERPNext requires one"); + assert_eq!( + doc["reference_no"], "Voiced payment 7", + "a blank reference gets a placeholder; ERPNext requires one" + ); assert_eq!(doc["paid_amount"].to_string(), "900.5"); - assert_eq!(doc["references"][1]["allocated_amount"].to_string(), "1000.0", "cash plus TDS is allocated"); - assert_eq!(doc["deductions"], json!([{ "account": "TDS Receivable - AC", "amount": 99.5 }])); + assert_eq!( + doc["references"][1]["allocated_amount"].to_string(), + "1000.0", + "cash plus TDS is allocated" + ); + assert_eq!( + doc["deductions"], + json!([{ "account": "TDS Receivable - AC", "amount": 99.5 }]) + ); assert_eq!(doc["remarks"], "Voiced payment 7 for invoice INV/2026-001"); let no_tds = build_payment_entry(&draft, &input(0, "UTR9")).unwrap(); assert_eq!(no_tds["deductions"], json!([])); - assert_eq!(no_tds["references"][1]["allocated_amount"].to_string(), "900.5"); + assert_eq!( + no_tds["references"][1]["allocated_amount"].to_string(), + "900.5" + ); // A draft that does not reference the invoice, or is not an object, is refused. let other = payment_draft("SOMETHING-ELSE"); - assert!(build_payment_entry(&other, &input(0, "x")).unwrap_err().contains("does not reference")); + assert!(build_payment_entry(&other, &input(0, "x")) + .unwrap_err() + .contains("does not reference")); assert!(build_payment_entry(&json!([]), &input(0, "x")).is_err()); assert!(build_payment_entry(&json!({ "doctype": "Payment Entry" }), &input(0, "x")).is_err()); } @@ -1034,39 +1563,91 @@ async fn the_sink_wraps_the_push_and_reports_local_status() { routes.push(mirror_create(&inv.number, 0, inv.total)); let m = mock(routes).await; let http = client(&m); - let sink = ErpnextSink { db: &fx.db, local_dir: fx.dir.path(), http: &http }; + let sink = ErpnextSink { + db: &fx.db, + local_dir: fx.dir.path(), + http: &http, + }; assert_eq!(sink.id(), "erpnext"); assert_eq!(sink.status(inv.id).unwrap().status, "none"); - let pushed = sink.push_invoice(PushRequest { invoice_id: inv.id, submit: Some(false) }).await.unwrap(); - assert_eq!((pushed.remote_name.as_str(), pushed.remote_docstatus, pushed.created), (inv.number.as_str(), 0, true)); + let pushed = sink + .push_invoice(PushRequest { + invoice_id: inv.id, + submit: Some(false), + }) + .await + .unwrap(); + assert_eq!( + ( + pushed.remote_name.as_str(), + pushed.remote_docstatus, + pushed.created + ), + (inv.number.as_str(), 0, true) + ); assert_eq!(sink.status(inv.id).unwrap().status, "synced"); // A payment on a draft parent is an Err with the readable reason. let pid = add_payment(&fx, &inv, 100_000, 0); - let err = sink.push_payment(PaymentRequest { payment_id: pid }).await.unwrap_err(); - assert!(err.to_lowercase().contains("submit the invoice in erpnext first"), "{err}"); + let err = sink + .push_payment(PaymentRequest { payment_id: pid }) + .await + .unwrap_err(); + assert!( + err.to_lowercase() + .contains("submit the invoice in erpnext first"), + "{err}" + ); } #[tokio::test] -async fn a_submit_answered_with_a_draft_is_not_recorded_as_submitted_and_payments_survive_a_later_error() { +async fn a_submit_answered_with_a_draft_is_not_recorded_as_submitted_and_payments_survive_a_later_error( +) { let fx = Fx::new(|_| {}); let inv = fx.issue("Client Ltd"); - let submit = format!("POST /api/v2/document/Sales%20Invoice/{}/method/submit", enc(&inv.number)); + let submit = format!( + "POST /api/v2/document/Sales%20Invoice/{}/method/submit", + enc(&inv.number) + ); let mut routes = master_routes(); routes.push(route(submit, 200, json!({ "data": { "docstatus": 0 } }))); routes.push(mirror_create(&inv.number, 0, inv.total)); - routes.push(route(PE_GET, 200, json!({ "message": payment_draft(&inv.number) }))); - routes.push(route(PE_POST, 200, json!({ "data": { "name": "ACC-PAY-1" } }))); + routes.push(route( + PE_GET, + 200, + json!({ "message": payment_draft(&inv.number) }), + )); + routes.push(route( + PE_POST, + 200, + json!({ "data": { "name": "ACC-PAY-1" } }), + )); let m = mock(routes).await; let r = fx.push(&m, inv.id, Some(true)).await; - assert!(!r.ok && r.remote_docstatus == 0 && r.error.as_deref().unwrap().contains("still at docstatus 0"), "{r:?}"); + assert!( + !r.ok + && r.remote_docstatus == 0 + && r.error.as_deref().unwrap().contains("still at docstatus 0"), + "{r:?}" + ); assert_eq!(fx.sync_row(inv.id).unwrap().remote_docstatus, 0); // A submitted invoice whose row later flipped to `error` still accepts payments. let pid = add_payment(&fx, &inv, 100_000, 0); - fx.db.lock().unwrap().execute("UPDATE erpnext_sync SET remote_docstatus = 1, status = 'error'", []).unwrap(); + fx.db + .lock() + .unwrap() + .execute( + "UPDATE erpnext_sync SET remote_docstatus = 1, status = 'error'", + [], + ) + .unwrap(); assert!(push_payment(&fx.db, &client(&m), pid).await.ok); - fx.db.lock().unwrap().execute("UPDATE erpnext_sync SET status = 'conflict'", []).unwrap(); + fx.db + .lock() + .unwrap() + .execute("UPDATE erpnext_sync SET status = 'conflict'", []) + .unwrap(); let pid2 = add_payment(&fx, &inv, 100_000, 0); assert!(!push_payment(&fx.db, &client(&m), pid2).await.ok); } diff --git a/src-tauri/src/integrations/erpnext/testutil.rs b/src-tauri/src/integrations/erpnext/testutil.rs index 85e5304..850fe3d 100644 --- a/src-tauri/src/integrations/erpnext/testutil.rs +++ b/src-tauri/src/integrations/erpnext/testutil.rs @@ -18,7 +18,12 @@ pub struct Reply { } pub fn reply(status: u16, body: Value) -> Reply { - Reply { status, headers: vec![], body: body.to_string(), delay_ms: 0 } + Reply { + status, + headers: vec![], + body: body.to_string(), + delay_ms: 0, + } } pub struct Mock { @@ -33,7 +38,12 @@ pub async fn serve(script: Vec) -> Mock { let script = Arc::new(script); serve_fn(move |i, _| { let r = &script[i.min(script.len() - 1)]; - Reply { status: r.status, headers: r.headers.clone(), body: r.body.clone(), delay_ms: r.delay_ms } + Reply { + status: r.status, + headers: r.headers.clone(), + body: r.body.clone(), + delay_ms: r.delay_ms, + } }) .await } @@ -48,7 +58,9 @@ pub async fn serve_fn(handler: impl Fn(usize, &str) -> Reply + Send + Sync + 'st let handler = Arc::new(handler); tokio::spawn(async move { loop { - let Ok((mut sock, _)) = listener.accept().await else { return }; + let Ok((mut sock, _)) = listener.accept().await else { + return; + }; let (h, r, handler) = (h.clone(), r.clone(), handler.clone()); tokio::spawn(async move { let mut buf = Vec::new(); @@ -97,7 +109,11 @@ pub async fn serve_fn(handler: impl Fn(usize, &str) -> Reply + Send + Sync + 'st }); } }); - Mock { base: format!("http://127.0.0.1:{port}"), hits, requests } + Mock { + base: format!("http://127.0.0.1:{port}"), + hits, + requests, + } } /// Short timeouts and near-zero backoff so retry tests run in milliseconds. @@ -113,5 +129,12 @@ pub fn fast_opts() -> ClientOptions { } pub fn client(mock: &Mock) -> ErpClient { - ErpClient::new(&mock.base, "thekey", &Secret::new("thesecret"), "", fast_opts()).unwrap() + ErpClient::new( + &mock.base, + "thekey", + &Secret::new("thesecret"), + "", + fast_opts(), + ) + .unwrap() } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9f357f9..5f272c7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -38,23 +38,39 @@ fn init_state(app: &tauri::App) -> Result> let data_dir = match app.path().app_data_dir() { Ok(dir) => dir, Err(e) => { - show_startup_error(app, &format!("Could not resolve the application data directory: {e}"), None); + show_startup_error( + app, + &format!("Could not resolve the application data directory: {e}"), + None, + ); return Err(e.into()); } }; if let Err(e) = std::fs::create_dir_all(&data_dir) { - show_startup_error(app, &format!("Could not create the application data directory: {e}"), Some(&data_dir)); + show_startup_error( + app, + &format!("Could not create the application data directory: {e}"), + Some(&data_dir), + ); return Err(e.into()); } let local_data_dir = match app.path().app_local_data_dir() { Ok(dir) => dir, Err(e) => { - show_startup_error(app, &format!("Could not resolve the local data directory: {e}"), Some(&data_dir)); + show_startup_error( + app, + &format!("Could not resolve the local data directory: {e}"), + Some(&data_dir), + ); return Err(e.into()); } }; if let Err(e) = std::fs::create_dir_all(&local_data_dir) { - show_startup_error(app, &format!("Could not create the local data directory: {e}"), Some(&local_data_dir)); + show_startup_error( + app, + &format!("Could not create the local data directory: {e}"), + Some(&local_data_dir), + ); return Err(e.into()); } // A restore staged by the Data tab is swapped in now, before anything opens the database. @@ -62,19 +78,32 @@ fn init_state(app: &tauri::App) -> Result> match commands::backup::apply_pending_restore(&dirs, chrono::Local::now()) { commands::backup::ApplyOutcome::NothingPending => {} commands::backup::ApplyOutcome::Applied { safety_dir } => { - eprintln!("Backup restored; the previous data is in {}", safety_dir.display()); + eprintln!( + "Backup restored; the previous data is in {}", + safety_dir.display() + ); + } + commands::backup::ApplyOutcome::Failed { message } => { + eprintln!("Backup restore failed: {message}") } - commands::backup::ApplyOutcome::Failed { message } => eprintln!("Backup restore failed: {message}"), } let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) { Ok(conn) => conn, Err(e) => { - show_startup_error(app, &format!("Could not open the database: {e}"), Some(&data_dir)); + show_startup_error( + app, + &format!("Could not open the database: {e}"), + Some(&data_dir), + ); return Err(e.into()); } }; if let Err(e) = db::relativize_asset_paths(&conn, &data_dir) { - show_startup_error(app, &format!("Could not update stored image paths: {e}"), Some(&data_dir)); + show_startup_error( + app, + &format!("Could not update stored image paths: {e}"), + Some(&data_dir), + ); return Err(e.into()); } // Best effort: a logo that cannot be derived keeps working untrimmed, and the next start retries. diff --git a/src-tauri/src/logo.rs b/src-tauri/src/logo.rs index e04a360..1fb0ef1 100644 --- a/src-tauri/src/logo.rs +++ b/src-tauri/src/logo.rs @@ -224,7 +224,8 @@ fn quantize_colour(img: &mut RgbaImage) { fn encode_png(img: &DynamicImage) -> Result, LogoError> { let mut buf = Vec::new(); - let encoder = PngEncoder::new_with_quality(&mut buf, CompressionType::Best, FilterType::Adaptive); + let encoder = + PngEncoder::new_with_quality(&mut buf, CompressionType::Best, FilterType::Adaptive); img.write_with_encoder(encoder) .map_err(|e| LogoError::Encode(e.to_string()))?; Ok(buf) @@ -360,7 +361,9 @@ mod tests { } fn decode_png(bytes: &[u8]) -> RgbaImage { - image::load_from_memory_with_format(bytes, ImageFormat::Png).unwrap().to_rgba8() + image::load_from_memory_with_format(bytes, ImageFormat::Png) + .unwrap() + .to_rgba8() } #[test] @@ -427,7 +430,8 @@ mod tests { assert!((38..=42).contains(&m.height), "{}", m.height); assert!(out.knockout_png.is_none()); // The input pixels are not edited: the printed corners are still the logo colour, not transparent. - let printed = image::load_from_memory_with_format(&out.print_png, ImageFormat::Png).unwrap(); + let printed = + image::load_from_memory_with_format(&out.print_png, ImageFormat::Png).unwrap(); assert!(!printed.color().has_alpha()); } @@ -459,7 +463,10 @@ mod tests { // Right magic, broken body. let mut truncated = png_bytes(&alpha_logo(20, 20, (2, 2, 5, 5), [0; 3])); truncated.truncate(30); - assert!(matches!(process_logo(&truncated), Err(LogoError::Decode(_)))); + assert!(matches!( + process_logo(&truncated), + Err(LogoError::Decode(_)) + )); } #[test] @@ -470,14 +477,23 @@ mod tests { // A blank image 8193 px wide compresses to a few KB, so it passes the byte cap. let wide = RgbaImage::new(8193, 2); - assert!(matches!(process_logo(&png_bytes(&wide)), Err(LogoError::Dimensions))); + assert!(matches!( + process_logo(&png_bytes(&wide)), + Err(LogoError::Dimensions) + )); } #[test] fn blank_images_have_no_content() { - assert!(matches!(process_logo(&png_bytes(&RgbaImage::new(40, 40))), Err(LogoError::Empty))); + assert!(matches!( + process_logo(&png_bytes(&RgbaImage::new(40, 40))), + Err(LogoError::Empty) + )); let white = RgbaImage::from_pixel(40, 40, Rgba([255, 255, 255, 255])); - assert!(matches!(process_logo(&png_bytes(&white)), Err(LogoError::Empty))); + assert!(matches!( + process_logo(&png_bytes(&white)), + Err(LogoError::Empty) + )); } #[test] @@ -508,9 +524,15 @@ mod tests { assert!((m.ink_density - 0.52).abs() < 1e-9, "{}", m.ink_density); let square = alpha_logo(50, 50, (10, 10, 30, 30), [0; 3]); - assert_eq!(process_logo(&png_bytes(&square)).unwrap().metrics.kind, LogoKind::Mark); + assert_eq!( + process_logo(&png_bytes(&square)).unwrap().metrics.kind, + LogoKind::Mark + ); let tall = alpha_logo(50, 100, (10, 10, 12, 40), [0; 3]); - assert_eq!(process_logo(&png_bytes(&tall)).unwrap().metrics.kind, LogoKind::Tall); + assert_eq!( + process_logo(&png_bytes(&tall)).unwrap().metrics.kind, + LogoKind::Tall + ); assert_eq!(kind_for(2.49), LogoKind::Mark); assert_eq!(kind_for(0.6), LogoKind::Tall); } @@ -535,15 +557,24 @@ mod tests { #[test] fn sample_logo_is_trimmed_and_small() { - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/pdf/testing/fixtures/sample-wordmark-logo.png"); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../src/pdf/testing/fixtures/sample-wordmark-logo.png"); let bytes = std::fs::read(path).unwrap(); let out = process_logo(&bytes).unwrap(); let m = &out.metrics; assert_eq!((m.source_width, m.source_height), (2172, 724)); assert!((5.3..=5.6).contains(&m.aspect), "aspect {}", m.aspect); - assert!((0.50..=0.60).contains(&m.ink_density), "density {}", m.ink_density); + assert!( + (0.50..=0.60).contains(&m.ink_density), + "density {}", + m.ink_density + ); assert_eq!(m.kind, LogoKind::Wordmark); - assert!(out.print_png.len() <= 120 * 1024, "print is {} bytes", out.print_png.len()); + assert!( + out.print_png.len() <= 120 * 1024, + "print is {} bytes", + out.print_png.len() + ); assert!(out.knockout_png.is_some()); } } diff --git a/src-tauri/src/selftest.rs b/src-tauri/src/selftest.rs index d46e343..af21e8a 100644 --- a/src-tauri/src/selftest.rs +++ b/src-tauri/src/selftest.rs @@ -7,14 +7,18 @@ use std::path::{Path, PathBuf}; const ENV_VAR: &str = "VOICED_SELFTEST_OUT"; fn configured_path() -> Option { - std::env::var_os(ENV_VAR).filter(|v| !v.is_empty()).map(PathBuf::from) + std::env::var_os(ENV_VAR) + .filter(|v| !v.is_empty()) + .map(PathBuf::from) } fn write_report(path: &Path, json: &str) -> Result<(), String> { if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|e| format!("Could not create {}: {e}", parent.display()))?; + std::fs::create_dir_all(parent) + .map_err(|e| format!("Could not create {}: {e}", parent.display()))?; } - let mut file = std::fs::File::create(path).map_err(|e| format!("Could not write the report: {e}"))?; + let mut file = + std::fs::File::create(path).map_err(|e| format!("Could not write the report: {e}"))?; file.write_all(json.as_bytes()) .and_then(|()| file.sync_all()) .map_err(|e| format!("Could not write the report: {e}")) From 9ba00045c9194cba913a9c73d1f8cc6b660919aa Mon Sep 17 00:00:00 2001 From: soubarna Date: Mon, 5 Oct 2026 17:23:27 +0530 Subject: [PATCH 2/5] ci: add quality-gate workflow, ESLint and Playwright smoke test --- .gitea/workflows/ci.yml | 108 ++++ .gitignore | 4 + .jscpd.json | 21 + CI.md | 50 ++ e2e/smoke.spec.ts | 69 ++ eslint.config.js | 21 + package-lock.json | 1324 ++++++++++++++++++++++++++++++++++++++- package.json | 10 +- playwright.config.ts | 21 + 9 files changed, 1625 insertions(+), 3 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .jscpd.json create mode 100644 CI.md create mode 100644 e2e/smoke.spec.ts create mode 100644 eslint.config.js create mode 100644 playwright.config.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..bc344cc --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,108 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + schedule: + - cron: "0 3 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + # Static analysis. Also the only job that runs on the weekly schedule, so newly published + # Semgrep registry rules are applied to main even when nothing is pushed. + security: + runs-on: bongbetic-ci + timeout-minutes: 15 + container: + image: docker.io/semgrep/semgrep:1.178.0 + steps: + - name: Checkout + run: | + git init -q . && git remote add origin "${{ gitea.server_url }}/${{ gitea.repository }}.git" \ + && git -c http.extraheader="Authorization: token ${{ gitea.token }}" fetch -q --depth=1 origin "${{ gitea.sha }}" \ + && git checkout -q FETCH_HEAD + - name: Semgrep + run: semgrep scan --config p/default --config p/owasp-top-ten --metrics off --error + + lint: + if: gitea.event_name != 'schedule' + runs-on: bongbetic-ci + timeout-minutes: 20 + steps: + - name: Checkout + run: | + git init -q . && git remote add origin "${{ gitea.server_url }}/${{ gitea.repository }}.git" \ + && git -c http.extraheader="Authorization: token ${{ gitea.token }}" fetch -q --depth=1 origin "${{ gitea.sha }}" \ + && git checkout -q FETCH_HEAD + - name: Install dependencies + run: npm ci + - name: Typecheck + run: npx tsc --noEmit + - name: ESLint + run: npx eslint . + - name: Rust format check + # rustfmt only. clippy is deliberately not run: it needs the webkit2gtk/gtk system libraries + # and a full compile of the Tauri crate, which is too heavy for the shared CI host. + working-directory: src-tauri + run: | + base=https://static.rust-lang.org/rustup/dist/x86_64-unknown-linux-gnu + curl --proto '=https' --tlsv1.2 -sSfO "$base/rustup-init" -O "$base/rustup-init.sha256" + sha256sum -c rustup-init.sha256 + chmod +x rustup-init + ./rustup-init -y --no-modify-path --profile minimal --default-toolchain 1.99.0 -c rustfmt + rm -f rustup-init rustup-init.sha256 + "$HOME/.cargo/bin/cargo" fmt --check + - name: Duplicate code (jscpd) + run: npx --yes jscpd@4.3.0 + + e2e: + if: gitea.event_name != 'schedule' + runs-on: bongbetic-ci + timeout-minutes: 25 + container: + # Keep this tag in step with the @playwright/test version in package.json. + image: mcr.microsoft.com/playwright:v1.63.0-noble + env: + CI: "true" + steps: + - name: Checkout + run: | + git init -q . && git remote add origin "${{ gitea.server_url }}/${{ gitea.repository }}.git" \ + && git -c http.extraheader="Authorization: token ${{ gitea.token }}" fetch -q --depth=1 origin "${{ gitea.sha }}" \ + && git checkout -q FETCH_HEAD + - name: Install dependencies + run: npm ci + - name: Build + run: npm run build + - name: Playwright smoke tests + run: npx playwright test + + # Advisory only: never blocks a merge. + ai-review: + if: gitea.event_name == 'pull_request' + runs-on: bongbetic-ci + timeout-minutes: 10 + continue-on-error: true + container: + image: docker.io/pragent/pr-agent:0.47.0 + options: --entrypoint "" + env: + config__git_provider: gitea + gitea__url: https://git.bongbetic.com + gitea__personal_access_token: ${{ secrets.PR_AGENT_GITEA_TOKEN }} + openrouter__key: ${{ secrets.OPENROUTER_API_KEY }} + config__model: ${{ vars.PR_AGENT_MODEL || 'openrouter/anthropic/claude-sonnet-5' }} + # PR-Agent needs this for OpenRouter models it has no built-in context window for. + config__custom_model_max_tokens: "200000" + steps: + - name: PR-Agent review + run: | + if [ -z "$gitea__personal_access_token" ] || [ -z "$openrouter__key" ]; then + echo "::notice::PR-Agent secrets not configured; skipping AI review." + exit 0 + fi + pr-agent --pr_url="${{ gitea.event.pull_request.html_url }}" review diff --git a/.gitignore b/.gitignore index 8f07c13..c93f7e5 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ xwin/ CLAUDE.md handoff.md .commandcode/ + +# Playwright +test-results/ +playwright-report/ diff --git a/.jscpd.json b/.jscpd.json new file mode 100644 index 0000000..7291e2a --- /dev/null +++ b/.jscpd.json @@ -0,0 +1,21 @@ +{ + "threshold": 4.5, + "reporters": ["console"], + "absolute": false, + "gitignore": true, + "path": ["."], + "ignore": [ + "**/node_modules/**", + "**/package-lock.json", + "**/Cargo.lock", + "**/dist/**", + "**/target/**", + "public/**", + "docs/**", + "src-tauri/gen/**", + "src-tauri/icons/**", + "**/*.{woff,woff2,ttf,otf,png,jpg,jpeg,gif,ico,icns,svg,pdf}", + "**/playwright-report/**", + "**/test-results/**" + ] +} diff --git a/CI.md b/CI.md new file mode 100644 index 0000000..326aaf2 --- /dev/null +++ b/CI.md @@ -0,0 +1,50 @@ +# CI + +Workflow: `.gitea/workflows/ci.yml`, runner label `bongbetic-ci`. It runs on pull requests, pushes to `main` and manual dispatch. A weekly schedule (Monday 03:00 UTC) runs the `security` job only. + +## Jobs + +| Job | What it runs | Blocks merge? | +| --- | --- | --- | +| `security` | Semgrep (`p/default`, `p/owasp-top-ten`), fails on any finding | Yes | +| `lint` | `npm ci`, `tsc --noEmit`, ESLint, `cargo fmt --check` (src-tauri), jscpd (threshold 4.5%) | Yes | +| `e2e` | `npm run build`, then the Playwright smoke test (chromium) against `vite preview` | Yes | +| `ai-review` | PR-Agent review comment, pull requests only | No (advisory, `continue-on-error`) | + +There is no deploy job: Voiced ships release assets, not a hosted app. + +Semgrep registry rules are fetched at run time, so a new rule can fail a previously green `main`. The weekly schedule surfaces that early. Triage with a fix or a narrow `// nosemgrep: -- reason`. + +Rust clippy is deferred. It needs the webkit2gtk/gtk system libraries and a full compile of the Tauri crate, which is too heavy for the shared CI host (jobs are limited to 3 GB RAM / 2 CPU). Only `cargo fmt --check` runs, with a pinned minimal rustup toolchain (rustfmt only). + +## Run locally + +```sh +# Semgrep (same image and rules as CI) +podman run --rm -v "$PWD:/src:ro,Z" -w /src docker.io/semgrep/semgrep:1.178.0 \ + semgrep scan --config p/default --config p/owasp-top-ten --metrics off --error + +npm run lint # ESLint +npx tsc --noEmit # typecheck +(cd src-tauri && cargo fmt --check) +npx jscpd@4.3.0 # duplicate code, reads .jscpd.json + +npx playwright install chromium # once +npm run build && npm run e2e +``` + +Keep the `mcr.microsoft.com/playwright` image tag in the `e2e` job in step with the `@playwright/test` version in `package.json`. + +## PR-Agent (advisory) + +`ai-review` posts a review through PR-Agent using OpenRouter. It never pushes code and is skipped (with a notice) when the secrets are empty. + +Repository secrets and variables: + +- `OPENROUTER_API_KEY` (secret): OpenRouter API key. +- `PR_AGENT_GITEA_TOKEN` (secret): Gitea personal access token of the account that posts the review. +- `PR_AGENT_MODEL` (variable, optional): defaults to `openrouter/anthropic/claude-sonnet-5`. + +## Rollback + +Revert the workflow PR. If branch protection requires these checks (`security`, `lint`, `e2e`), relax it first, otherwise PRs will wait forever for checks that no longer run. diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 0000000..16f822d --- /dev/null +++ b/e2e/smoke.spec.ts @@ -0,0 +1,69 @@ +import { expect, test, type Page } from "@playwright/test"; + +// The app talks to its Rust backend through Tauri's `invoke`, which does not exist in a plain +// browser. These smoke tests cover the web build in both situations: without the backend (the app +// must fail gracefully) and with a minimal stubbed backend (the app shell must render). + +const SETTINGS = { + vendorName: "Smoke Test Studio", + vendorAddress: "1 Test Street", + vendorEmail: "smoke@example.com", + vendorPhone: "", + vendorPan: "", + vendorGstin: "", + vendorStateCode: "", + logoPath: null, + signaturePath: null, + defaultBankId: null, + defaultTaxRate: 0, + defaultTaxType: "none", + paymentTermsDays: 15, + currency: "INR", + onboarded: true, + theme: "g10", + gstRegistration: "unregistered", + defaultHsnSac: "", + signatoryName: "", + signatoryDesignation: "", + renderPrefs: "", + logoOriginalPath: null, + logoKnockoutPath: null, + logoMeta: "", + logoIncludesName: "auto", +}; + +function collectPageErrors(page: Page): string[] { + const errors: string[] = []; + page.on("pageerror", (err) => errors.push(err.message)); + return errors; +} + +test("web build loads without a backend and fails gracefully", async ({ page }) => { + const errors = collectPageErrors(page); + await page.goto("/"); + await expect(page).toHaveTitle("Voiced"); + await expect(page.getByText("Could not start Voiced")).toBeVisible(); + expect(errors).toEqual([]); +}); + +test("app shell renders with a stubbed backend", async ({ page }) => { + const errors = collectPageErrors(page); + await page.addInitScript((settings) => { + const w = window as unknown as { __TAURI_INTERNALS__: unknown }; + w.__TAURI_INTERNALS__ = { + transformCallback: () => 0, + invoke: async (cmd: string) => { + if (cmd === "get_settings") return settings; + if (cmd.startsWith("list_")) return []; + return null; + }, + }; + }, SETTINGS); + await page.goto("/"); + const nav = page.getByRole("navigation", { name: "Voiced navigation" }); + await expect(nav).toBeVisible(); + for (const label of ["Clients", "Settings"]) { + await expect(nav.getByText(label, { exact: true })).toBeVisible(); + } + expect(errors).toEqual([]); +}); diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..9d5000a --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,21 @@ +import js from "@eslint/js"; +import globals from "globals"; +import tseslint from "typescript-eslint"; +import reactHooks from "eslint-plugin-react-hooks"; + +export default tseslint.config( + { + ignores: ["node_modules/", "dist/", "src-tauri/", "public/", "docs/", "playwright-report/", "test-results/"], + }, + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + { + files: ["src/**/*.{ts,tsx}"], + languageOptions: { globals: globals.browser }, + }, + { + files: ["scripts/**/*.{js,mjs}", "e2e/**/*.ts", "*.config.{js,ts}"], + languageOptions: { globals: globals.node }, + }, +); diff --git a/package-lock.json b/package-lock.json index fcc978c..774fe0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "voiced", "version": "1.0.2", + "license": "Apache-2.0", "dependencies": { "@carbon/icons-react": "^11.89.0", "@carbon/react": "^1.117.0", @@ -22,15 +23,21 @@ "react-is": "^19.3.0" }, "devDependencies": { + "@eslint/js": "10.0.1", + "@playwright/test": "1.63.0", "@tauri-apps/cli": "^2.12.1", "@types/node": "^26.6.4", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^5.2.0", "clipper-lib": "^6.4.2", + "eslint": "10.12.0", + "eslint-plugin-react-hooks": "7.1.1", + "globals": "17.13.0", "pdfjs-dist": "6.4.299", "sass": "^1.105.1", "typescript": "^5.9.3", + "typescript-eslint": "8.71.0", "vite": "^7.3.6", "vitest": "^5.0.3" }, @@ -326,6 +333,30 @@ "node": ">=6.9.0" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, "node_modules/@carbon/colors": { "version": "11.59.0", "resolved": "https://registry.npmjs.org/@carbon/colors/-/colors-11.59.0.tgz", @@ -953,6 +984,134 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@floating-ui/core": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", @@ -1006,6 +1165,72 @@ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@ibm/plex": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/@ibm/plex/-/plex-6.4.1.tgz", @@ -1164,6 +1389,30 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/canvas": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.10.tgz", @@ -1730,6 +1979,22 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@react-pdf/fns": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.3.tgz", @@ -2591,6 +2856,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2598,6 +2870,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.6.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", @@ -2628,6 +2907,249 @@ "@types/react": "^19.3.0" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", + "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/type-utils": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz", + "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz", + "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", + "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@vitejs/plugin-react": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", @@ -2693,6 +3215,46 @@ "integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==", "license": "MIT" }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2703,6 +3265,16 @@ "node": ">=12" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -2745,6 +3317,19 @@ "require-from-string": "^2.0.2" } }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/brotli": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", @@ -2788,6 +3373,20 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001814", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", @@ -2937,6 +3536,21 @@ "integrity": "sha512-gklSft7IuhriZKHKpuoA1fpJSLPNgvUMWMo5BlnzAJm0zNKnznoSv23IjtNqclx8eKi6ZcdvFFzYEER/+U1LoQ==", "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2962,6 +3576,13 @@ } } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3093,6 +3714,184 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -3103,6 +3902,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -3128,6 +3937,20 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -3152,12 +3975,58 @@ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", "license": "MIT" }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, "node_modules/flatpickr": { "version": "4.6.13", "resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz", "integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==", "license": "MIT" }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/fontkit": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", @@ -3223,6 +4092,69 @@ "node": ">=6.9.0" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, "node_modules/hsl-to-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz", @@ -3244,12 +4176,32 @@ "integrity": "sha512-XtqmnT+b9n5MX+MsqluFAVTIenbtC25iskW0Z+jLd+awfhA+ZbWKWQMIvLJccGoa2bM1R6juWJ27cZxIFOmkWw==", "license": "ISC" }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/immutable": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", "license": "MIT" }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -3275,8 +4227,8 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "devOptional": true, "license": "MIT", - "optional": true, "engines": { "node": ">=0.10.0" } @@ -3285,8 +4237,8 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -3300,6 +4252,13 @@ "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/jay-peg": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz", @@ -3328,6 +4287,20 @@ "node": ">=6" } }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -3341,6 +4314,30 @@ "node": ">=6" } }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/linebreak": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", @@ -3360,6 +4357,22 @@ "node": ">= 0.4" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3398,6 +4411,22 @@ "integrity": "sha512-FqNmlXKYrp5d3g7xEOMJb+6gXZE0fTssdyIQqYR4LbkifbNbS0hDylhNDOh8r6tCgLboHd8+mwgH2njgSYDpnQ==", "license": "MIT" }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/motion": { "version": "13.5.1", "resolved": "https://registry.npmjs.org/motion/-/motion-13.5.1.tgz", @@ -3461,6 +4490,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -3510,6 +4546,56 @@ "node": ">=12.20.0" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pako": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", @@ -3522,6 +4608,26 @@ "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", "license": "MIT" }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pdfjs-dist": { "version": "6.4.299", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.4.299.tgz", @@ -3569,6 +4675,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/png-js": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz", @@ -3612,6 +4747,16 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -3629,6 +4774,36 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/queue": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", @@ -3797,6 +4972,29 @@ "semver": "bin/semver.js" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/simple-swizzle": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", @@ -3899,12 +5097,38 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3919,6 +5143,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.0.tgz", + "integrity": "sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.0", + "@typescript-eslint/parser": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici-types": { "version": "8.9.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", @@ -3977,6 +5225,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -4135,6 +5393,22 @@ } } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", @@ -4148,6 +5422,16 @@ "node": ">=20.11" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -4155,11 +5439,47 @@ "dev": true, "license": "ISC" }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yoga-layout": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", "license": "MIT" + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } } } } diff --git a/package.json b/package.json index 9063877..db90c11 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,9 @@ "decor:script-word": "node scripts/decor/script-word.mjs", "decor:icons": "node scripts/decor/icons.mjs", "thumbnails": "node scripts/thumbnails.mjs", - "templates:compare": "node scripts/templates/compare.mjs" + "templates:compare": "node scripts/templates/compare.mjs", + "lint": "eslint .", + "e2e": "playwright test" }, "dependencies": { "@carbon/icons-react": "^11.89.0", @@ -40,15 +42,21 @@ "react-is": "^19.3.0" }, "devDependencies": { + "@eslint/js": "10.0.1", + "@playwright/test": "1.63.0", "@tauri-apps/cli": "^2.12.1", "@types/node": "^26.6.4", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^5.2.0", "clipper-lib": "^6.4.2", + "eslint": "10.12.0", + "eslint-plugin-react-hooks": "7.1.1", + "globals": "17.13.0", "pdfjs-dist": "6.4.299", "sass": "^1.105.1", "typescript": "^5.9.3", + "typescript-eslint": "8.71.0", "vite": "^7.3.6", "vitest": "^5.0.3" }, diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..8189081 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig, devices } from "@playwright/test"; + +const PORT = 4173; + +export default defineConfig({ + testDir: "./e2e", + workers: 1, + retries: process.env.CI ? 1 : 0, + reporter: "list", + use: { + baseURL: `http://localhost:${PORT}`, + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + // Serves the production build (run `npm run build` first). + webServer: { + command: `npx vite preview --port ${PORT} --strictPort`, + url: `http://localhost:${PORT}`, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, +}); From 5b18db3a77e4fc0075a08b1180572925739f1922 Mon Sep 17 00:00:00 2001 From: soubarna Date: Mon, 5 Oct 2026 17:29:03 +0530 Subject: [PATCH 3/5] fix: eslint findings Remove dead code and unused imports, drop useless assignments, and tune rules in eslint.config.js (underscore-prefixed unused names, intentional BOM/NBSP, React Compiler rules from eslint-plugin-react-hooks 7 as warnings). --- eslint.config.js | 29 ++++++++++++++++++++++++ scripts/decor/script-word.mjs | 1 - src/lib/selfTestE2e.ts | 3 +-- src/pdf/model/text.ts | 1 + src/pdf/quirks.test.tsx | 2 +- src/pdf/templates/band/plan.ts | 2 -- src/pdf/templates/harness.test.tsx | 12 ++-------- src/pdf/templates/highlighter/Layout.tsx | 2 +- src/pdf/templates/marble/Layout.tsx | 2 +- src/pdf/templates/marble/plan.ts | 2 +- src/pdf/templates/neutral/plan.ts | 2 +- src/selftestBoot.ts | 2 +- src/views/AppSettings.tsx | 1 - src/views/NewInvoice.tsx | 8 +------ src/views/SeriesSettings.tsx | 2 +- 15 files changed, 41 insertions(+), 30 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 9d5000a..0f7fa9d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -10,6 +10,35 @@ export default tseslint.config( js.configs.recommended, tseslint.configs.recommended, reactHooks.configs.flat.recommended, + { + rules: { + // Leading underscore marks an intentionally unused binding (omitting a key via rest, unused + // override parameters). + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_", ignoreRestSiblings: true }, + ], + // The BOM and NBSP are written literally on purpose in the CSV exporters and the text sanitiser. + "no-irregular-whitespace": ["error", { skipStrings: true, skipTemplates: true, skipRegExps: true }], + }, + }, + { + // The React Compiler rules shipped in eslint-plugin-react-hooks 7 flag long-standing patterns + // here (load data on mount, "latest value" refs written during render). They are legitimate + // design feedback but not bugs, and rewriting them blind risks behaviour changes in an + // effect-heavy app, so they stay visible as warnings until each site is reworked. + files: ["src/**/*.{ts,tsx}"], + rules: { + "react-hooks/set-state-in-effect": "warn", + "react-hooks/refs": "warn", + "react-hooks/immutability": "warn", + }, + }, + { + // Test helper that reads react-pdf's untyped internal layout data. + files: ["src/pdf/testing/**/*.ts"], + rules: { "@typescript-eslint/no-explicit-any": "warn" }, + }, { files: ["src/**/*.{ts,tsx}"], languageOptions: { globals: globals.browser }, diff --git a/scripts/decor/script-word.mjs b/scripts/decor/script-word.mjs index 783a127..92d8a78 100644 --- a/scripts/decor/script-word.mjs +++ b/scripts/decor/script-word.mjs @@ -52,7 +52,6 @@ function wordCommands(font) { function rotate(cmds) { const pts = cmds.flatMap((c) => c.args); const xs = pts.filter((_, i) => i % 2 === 0); - const ys = pts.filter((_, i) => i % 2 === 1); const inkLen = Math.max(...xs) - Math.min(...xs); const s = INK_LENGTH / inkLen; const map = (x, y) => [-y * s, -x * s]; diff --git a/src/lib/selfTestE2e.ts b/src/lib/selfTestE2e.ts index f9954a8..5631c93 100644 --- a/src/lib/selfTestE2e.ts +++ b/src/lib/selfTestE2e.ts @@ -102,7 +102,6 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise { const outcome = await issueAndArchive(input, prefs, deps); invoice = outcome.invoice; @@ -112,7 +111,7 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise { const st = await api.archiveStatus(inv.id); diff --git a/src/pdf/model/text.ts b/src/pdf/model/text.ts index 7200fa4..42cd66c 100644 --- a/src/pdf/model/text.ts +++ b/src/pdf/model/text.ts @@ -22,6 +22,7 @@ export function sanitize(value: string | null | undefined): string { .replace(/\r\n?/g, "\n") .replace(/[\t\n\v\f]/g, " ") .replace(/ /g, " ") + // eslint-disable-next-line no-control-regex -- stripping control characters is the point .replace(/[\u0000-\u001F\u007F-\u009F​]/g, "") .replace(/ {2,}/g, " ") .trim(); diff --git a/src/pdf/quirks.test.tsx b/src/pdf/quirks.test.tsx index 64f8377..03ec85b 100644 --- a/src/pdf/quirks.test.tsx +++ b/src/pdf/quirks.test.tsx @@ -21,7 +21,7 @@ import { RenderTxt } from "./blocks/RenderTxt"; import { TableGuard, guardPresence } from "./blocks/TableGuard"; import { renderCore } from "./render/core"; import { loadCanvas, inkPixels } from "./testing/rasterize"; -import { byId, doc, FAMILY, find, frameFor, PAD, PAGE_W, rawLayout, txt } from "./testing/docs"; +import { byId, doc, FAMILY, find, frameFor, PAD, rawLayout, txt } from "./testing/docs"; import { FONTS_DIR, setupPdfTest } from "./testing/setup"; import { stack } from "./fonts/register"; diff --git a/src/pdf/templates/band/plan.ts b/src/pdf/templates/band/plan.ts index 6354a06..60f3bbf 100644 --- a/src/pdf/templates/band/plan.ts +++ b/src/pdf/templates/band/plan.ts @@ -15,8 +15,6 @@ export const REVERSE_GAP = 4; export const HEADING_AFTER = 4; /** Space between the closing block's two columns. */ export const CLOSING_COLUMN_GAP = 16; -/** Slate: space between a mark and the business name in the dark band. */ -const NAME_AFTER_LOGO = 7.8; /** Space above and below the thin rule that separates the stack from TOTAL. */ export const STACK_RULE = { before: 2, after: 8, thickness: 0.75 }; const MIN_NAME_W = 40; diff --git a/src/pdf/templates/harness.test.tsx b/src/pdf/templates/harness.test.tsx index 39a0135..b718823 100644 --- a/src/pdf/templates/harness.test.tsx +++ b/src/pdf/templates/harness.test.tsx @@ -1,17 +1,9 @@ import { mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import type { AuditIssue } from "../engine/audit"; -import { PAGE_SIZES } from "../engine/geometry"; -import { PAGE_LABEL_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX, CLOSING_ID, TOTALS_ID } from "../engine/ids"; -import { walk, type LayoutNode, type LayoutPage, type Rect } from "../engine/layoutTree"; -import { forbiddenProbe, SLOT_RULES, slotContent, type SlotName } from "../model/slots"; -import { frameForPrefs, renderInvoicePdf } from "../render/invoice"; import { loadCanvas, pagePng } from "../testing/rasterize"; -import { SLOT_ID } from "../blocks/slots"; -import { META_ID, SUPPLIER_ID, TITLE_ID } from "./classic/ids"; -import { HEADER_GAP, headerCols } from "./classic/plan"; -import { FIXTURE_GSTIN, FIXTURES, buildFixture, ensureSetup, makeItems, PAGE_SIZE_IDS, prefsFor, renderFixture, type FixtureSpec, type Rendered } from "./harness"; +import { renderInvoicePdf } from "../render/invoice"; +import { FIXTURES, buildFixture, ensureSetup, PAGE_SIZE_IDS, prefsFor, renderFixture, type FixtureSpec } from "./harness"; import { TEMPLATES } from "./registry"; /** diff --git a/src/pdf/templates/highlighter/Layout.tsx b/src/pdf/templates/highlighter/Layout.tsx index 32e1245..79ec845 100644 --- a/src/pdf/templates/highlighter/Layout.tsx +++ b/src/pdf/templates/highlighter/Layout.tsx @@ -1,6 +1,6 @@ import { Image, View } from "@react-pdf/renderer"; import type { ReactNode } from "react"; -import { colsDetailed, pinned } from "../../engine/columns"; +import { pinned } from "../../engine/columns"; import { CLOSING_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX, TOTALS_ID } from "../../engine/ids"; import { AvailableWidthProvider } from "../../blocks/AvailableWidth"; import { BottomSpacer } from "../../blocks/BottomSpacer"; diff --git a/src/pdf/templates/marble/Layout.tsx b/src/pdf/templates/marble/Layout.tsx index 67e0b72..f3663c3 100644 --- a/src/pdf/templates/marble/Layout.tsx +++ b/src/pdf/templates/marble/Layout.tsx @@ -202,7 +202,7 @@ function Parties({ ctx }: { ctx: Ctx }) { const blocks: ReactNode[] = []; let k = 1; if (plan.info.hasBank) blocks.push(); - if (plan.info.hasIds) blocks.push(); + if (plan.info.hasIds) blocks.push(); const specs: ColSpec[] = cols.map((w, i) => (i === cols.length - 1 ? { fill: true } : { fixed: w })); return ( diff --git a/src/pdf/templates/marble/plan.ts b/src/pdf/templates/marble/plan.ts index 631023c..aadeafa 100644 --- a/src/pdf/templates/marble/plan.ts +++ b/src/pdf/templates/marble/plan.ts @@ -151,7 +151,7 @@ export function planMarble(model: RenderModel, frame: PageFrame, v: MarbleVarian let k = 0; const clientW = textW(k++); const bankW = hasBank ? textW(k++) : 0; - const idsW = hasIds ? textW(k++) : 0; + const idsW = hasIds ? textW(k) : 0; const head = leading(r.heading) + v.parties.headingGap; let clientH = head + textHeight(r.body, c.name, clientW); for (const l of c.addressLines) clientH += textHeight(r.body, l, clientW); diff --git a/src/pdf/templates/neutral/plan.ts b/src/pdf/templates/neutral/plan.ts index bcd7c26..5f8870f 100644 --- a/src/pdf/templates/neutral/plan.ts +++ b/src/pdf/templates/neutral/plan.ts @@ -296,7 +296,7 @@ export function planNeutral(model: RenderModel, frame: PageFrame, v: NeutralVari for (const l of lines) payH += textHeight(r.address, l, payW); } if (v.id === "serenity") signH += leading(r.label) + 4; - let closingH = 0; + let closingH: number; if (v.payTo.where === "closing") closingH = Math.max(payH, signH); else closingH = signH; if (model.cancelled) closingH += 10 + textHeight(r.small, CANCELLED_SENTENCE, W); diff --git a/src/selftestBoot.ts b/src/selftestBoot.ts index f9cb5c6..dfa7aec 100644 --- a/src/selftestBoot.ts +++ b/src/selftestBoot.ts @@ -11,7 +11,7 @@ import type { StepResult } from "./lib/selfTestSteps"; const WATCHDOG_MS = 120_000; export async function maybeRunSelfTest(): Promise { - let reportPath: string | null = null; + let reportPath: string | null; try { reportPath = await invoke("selftest_config"); } catch { diff --git a/src/views/AppSettings.tsx b/src/views/AppSettings.tsx index 372170d..53a0a72 100644 --- a/src/views/AppSettings.tsx +++ b/src/views/AppSettings.tsx @@ -24,7 +24,6 @@ import { TabPanels, Tabs, Tile, - Toggle, } from "@carbon/react"; import { Add, Save, TrashCan } from "@carbon/icons-react"; import { confirm } from "@tauri-apps/plugin-dialog"; diff --git a/src/views/NewInvoice.tsx b/src/views/NewInvoice.tsx index 9469253..2936d79 100644 --- a/src/views/NewInvoice.tsx +++ b/src/views/NewInvoice.tsx @@ -48,8 +48,7 @@ import type { Settings, TaxType, } from "../lib/types"; -import { computeLineAmount, computeTotals } from "../lib/invoice"; -import { amountInWords } from "../lib/numberToWords"; +import { computeLineAmount } from "../lib/invoice"; import { gstinFullError } from "../lib/validators"; import { addDays, formatAmount, todayIso } from "../lib/format"; import { modelFromAssets, prefsForInvoice } from "../lib/pdf"; @@ -256,11 +255,6 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv ? "cgst_sgst" : "igst"; const clientGstinError = gstinFullError(clientGstin); - const totals = useMemo( - () => computeTotals(items, discount, effectiveTaxType, taxRate), - [items, discount, effectiveTaxType, taxRate], - ); - const words = useMemo(() => amountInWords(totals.total), [totals.total]); const bank = useMemo(() => banks.find((b) => b.id === bankId) ?? null, [banks, bankId]); const payload = useMemo( diff --git a/src/views/SeriesSettings.tsx b/src/views/SeriesSettings.tsx index b25bd98..2b9b7d0 100644 --- a/src/views/SeriesSettings.tsx +++ b/src/views/SeriesSettings.tsx @@ -29,7 +29,7 @@ export default function SeriesSettings({ embedded?: boolean; }) { const toast = useToast(); - const [active, setActive] = useState(null); + const [, setActive] = useState(null); const [all, setAll] = useState([]); const [loading, setLoading] = useState(true); From 65f3615661bcda265f71481a634b074ee17516d4 Mon Sep 17 00:00:00 2001 From: soubarna Date: Mon, 5 Oct 2026 17:29:03 +0530 Subject: [PATCH 4/5] chore: semgrep triage Pass a constant format string to console.log in compare.mjs (same output). Annotate the mutable action tags in the Windows fallback workflow with nosemgrep; workflow behaviour is unchanged. --- .gitea/workflows/windows-build.yml | 8 ++++---- scripts/templates/compare.mjs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/windows-build.yml b/.gitea/workflows/windows-build.yml index eb0edf2..ec74ec9 100644 --- a/.gitea/workflows/windows-build.yml +++ b/.gitea/workflows/windows-build.yml @@ -13,13 +13,13 @@ jobs: build: runs-on: windows steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 # nosemgrep: github-actions-mutable-action-tag -- fallback workflow, behavior unchanged - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@stable # nosemgrep: github-actions-mutable-action-tag -- fallback workflow, behavior unchanged - name: Install Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v4 # nosemgrep: github-actions-mutable-action-tag -- fallback workflow, behavior unchanged with: node-version: 20 @@ -30,7 +30,7 @@ jobs: run: npm run app:build - name: Upload installer - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v4 # nosemgrep: github-actions-mutable-action-tag -- fallback workflow, behavior unchanged with: name: voiced-windows path: src-tauri/target/release/bundle/nsis/*.exe diff --git a/scripts/templates/compare.mjs b/scripts/templates/compare.mjs index 0b81303..899fde4 100644 --- a/scripts/templates/compare.mjs +++ b/scripts/templates/compare.mjs @@ -107,7 +107,7 @@ async function main() { invoice: { clientName: TOKEN, clientAddress: LONG, notes: `Note: ${TOKEN} ${LONG}` }, }); writeFileSync(path.join(OUT, `${id}-A4-longtext.png`), (await rasterize(long.result.bytes, 1, SCALE)).toBuffer("image/png")); - console.log(`${id}: pages ${withLogo.result.pages}/${sixty.result.pages}/${letter.result.pages}/${long.result.pages}; audit issues`, [withLogo, noLogo, sixty, letter, long].map((x) => x.result.issues.length).join("/")); + console.log("%s: pages %s/%s/%s/%s; audit issues %s", id, withLogo.result.pages, sixty.result.pages, letter.result.pages, long.result.pages, [withLogo, noLogo, sixty, letter, long].map((x) => x.result.issues.length).join("/")); for (const x of [withLogo, noLogo, sixty, letter, long]) for (const i of x.result.issues) console.log(" ", i.severity, i.code, `p${i.page}`, i.message.slice(0, 160)); } } From aa83505997b2fcde1483035b92b782f7001e5aaa Mon Sep 17 00:00:00 2001 From: soubarna Date: Mon, 5 Oct 2026 17:38:49 +0530 Subject: [PATCH 5/5] fix(ci): drop --entrypoint override on the ai-review job container The runner starts job containers with entrypoint /bin/sleep 3600 and copies the workspace in. An empty --entrypoint override made the pr-agent container exit at once (RWLayer is unexpectedly nil). The image already has pr-agent on PATH. --- .gitea/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index bc344cc..5287924 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -89,7 +89,6 @@ jobs: continue-on-error: true container: image: docker.io/pragent/pr-agent:0.47.0 - options: --entrypoint "" env: config__git_provider: gitea gitea__url: https://git.bongbetic.com