From ba470077671c94f6172ec65de7d52dbb14718ed6 Mon Sep 17 00:00:00 2001 From: Alessandro Maestri Date: Thu, 16 Oct 2025 17:24:20 +0200 Subject: [PATCH 1/5] ### Refactor (v0.3.0) - Replaced manual list formatting with `tabled` (v0.20.0) for modern, aligned tabular output. - Added `--short` flag to display compact view (ID, Title, Author, Editor, Year). - Implemented reusable `build_table()` helper under `utils::table` for consistent CLI formatting. - Standardized module structure: - Added `mod.rs` to all submodules (commands, models, utils, db, config, i18n). - Simplified imports using `pub use` re-exports in `lib.rs`. - Improved code organization and idiomatic Rust layout. --- Cargo.lock | 81 ++++++++++++++++- Cargo.toml | 5 +- src/cli.rs | 30 ++++--- src/commands/backup.rs | 2 +- src/commands/list.rs | 64 +++++--------- src/commands/mod.rs | 9 ++ src/{config.rs => config/load_config.rs} | 13 --- src/config/{migrate.rs => migrate_config.rs} | 0 src/config/mod.rs | 16 ++++ src/{db.rs => db/load_db.rs} | 19 +--- src/db/{migrate.rs => migrate_db.rs} | 0 src/db/mod.rs | 15 ++++ src/i18n/loader.rs | 46 ++++++++++ src/i18n/locales/README.md | 2 +- src/i18n/locales/en.json | 13 ++- src/i18n/locales/it.json | 13 ++- src/i18n/mod.rs | 50 +---------- src/lib.rs | 50 +++++++++-- src/main.rs | 4 +- src/models.rs | 52 ----------- src/models/book.rs | 93 ++++++++++++++++++++ src/models/mod.rs | 3 + src/{utils.rs => utils/mod.rs} | 5 +- src/utils/table.rs | 37 ++++++++ 24 files changed, 428 insertions(+), 194 deletions(-) rename src/{config.rs => config/load_config.rs} (88%) rename src/config/{migrate.rs => migrate_config.rs} (100%) create mode 100644 src/config/mod.rs rename src/{db.rs => db/load_db.rs} (85%) rename src/db/{migrate.rs => migrate_db.rs} (100%) create mode 100644 src/db/mod.rs delete mode 100644 src/models.rs create mode 100644 src/models/book.rs create mode 100644 src/models/mod.rs rename src/{utils.rs => utils/mod.rs} (97%) create mode 100644 src/utils/table.rs diff --git a/Cargo.lock b/Cargo.lock index b42e9f7..4e1b99b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -166,6 +166,12 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "byteorder" version = "1.5.0" @@ -744,7 +750,7 @@ dependencies = [ [[package]] name = "librius" -version = "0.2.5" +version = "0.3.0" dependencies = [ "chrono", "clap", @@ -757,6 +763,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "tabled", "tar", "umya-spreadsheet", "zip 6.0.0", @@ -863,6 +870,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "papergrid" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6978128c8b51d8f4080631ceb2302ab51e32cc6e8615f735ee2f83fd269ae3f1" +dependencies = [ + "bytecount", + "fnv", + "unicode-width", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -934,6 +952,28 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c834641d8ad1b348c9ee86dec3b9840d805acd5f24daa5f90c788951a52ff59b" +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.101" @@ -1175,6 +1215,30 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tabled" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e39a2ee1fbcd360805a771e1b300f78cc88fec7b8d3e2f71cd37bbf23e725c7d" +dependencies = [ + "papergrid", + "tabled_derive", + "testing_table", +] + +[[package]] +name = "tabled_derive" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea5d1b13ca6cff1f9231ffd62f15eefd72543dab5e468735f1a456728a02846" +dependencies = [ + "heck", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tar" version = "0.4.44" @@ -1186,6 +1250,15 @@ dependencies = [ "xattr", ] +[[package]] +name = "testing_table" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f8daae29995a24f65619e19d8d31dea5b389f3d853d8bf297bbf607cd0014cc" +dependencies = [ + "unicode-width", +] + [[package]] name = "thin-vec" version = "0.2.14" @@ -1304,6 +1377,12 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/Cargo.toml b/Cargo.toml index d6d52a9..99a3223 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "librius" -version = "0.2.5" +version = "0.3.0" edition = "2024" -authors = ["Alessandro Maestri "] +authors = ["Alessandro Maestri "] description = "A personal library manager CLI written in Rust." license = "MIT" readme = "README.md" @@ -35,6 +35,7 @@ tar = { version = "0.4.44", optional = true } dirs = "6.0.0" umya-spreadsheet = "2.3.3" csv = "1.3.1" +tabled = "0.20.0" [target.'cfg(windows)'.dependencies] zip = "6.0.0" diff --git a/src/cli.rs b/src/cli.rs index 6c5f365..663b3ed 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,4 @@ -use crate::commands::{config::handle_config, list::handle_list}; +use crate::commands::{handle_config, handle_list}; use crate::i18n::{tr, tr_s}; use crate::utils::print_err; use clap::{Arg, Command, Subcommand}; @@ -37,7 +37,15 @@ pub fn build_cli() -> Command { .num_args(1) .help(tr_s("help_lang")), ) - .subcommand(Command::new("list").about(tr_s("list_about"))) + .subcommand( + Command::new("list").about(tr_s("list_about")).arg( + Arg::new("short") + .long("short") + .help(tr_s("help.list.short")) + .action(clap::ArgAction::SetTrue) + .num_args(0), + ), + ) .subcommand( Command::new("config") .about(tr_s("config_about")) @@ -153,8 +161,10 @@ pub fn run_cli( matches: &clap::ArgMatches, conn: &mut Connection, ) -> Result<(), Box> { - if let Some(("list", _)) = matches.subcommand() { - handle_list(conn).unwrap_or_else(|e| { + if let Some(("list", sub_matches)) = matches.subcommand() { + let short = sub_matches.get_flag("short"); + + handle_list(conn, short).unwrap_or_else(|e| { eprintln!("{} {}", "Error listing books:".red(), e); }); Ok(()) @@ -174,7 +184,7 @@ pub fn run_cli( } else if let Some(("backup", sub_m)) = matches.subcommand() { let compress = sub_m.get_flag("compress"); // esegue backup plain o compresso (zip su Windows, tar.gz su Unix) - crate::commands::backup::handle_backup(conn, compress)?; + crate::commands::handle_backup(conn, compress)?; Ok(()) } else if let Some(("export", sub_m)) = matches.subcommand() { let output_path = sub_m.get_one::("output").cloned(); @@ -184,11 +194,11 @@ pub fn run_cli( let export_json = sub_m.get_flag("json"); if export_csv || (!export_xlsx && !export_json) { - crate::commands::export::handle_export_csv(conn, output_path)?; + crate::commands::handle_export_csv(conn, output_path)?; } else if export_xlsx { - crate::commands::export::handle_export_xlsx(conn, output_path)?; + crate::commands::handle_export_xlsx(conn, output_path)?; } else if export_json { - crate::commands::export::handle_export_json(conn, output_path)?; + crate::commands::handle_export_json(conn, output_path)?; } Ok(()) } else if let Some(("import", sub_m)) = matches.subcommand() { @@ -207,10 +217,10 @@ pub fn run_cli( // πŸ”Ή Esegui l'import nel formato scelto if import_json { - crate::commands::import::handle_import_json(conn, &file) + crate::commands::handle_import_json(conn, &file) .map_err(|e| Box::new(e) as Box)?; } else { - crate::commands::import::handle_import_csv(conn, &file) + crate::commands::handle_import_csv(conn, &file) .map_err(|e| Box::new(e) as Box)?; } Ok(()) diff --git a/src/commands/backup.rs b/src/commands/backup.rs index d7c085d..8782bc5 100644 --- a/src/commands/backup.rs +++ b/src/commands/backup.rs @@ -5,7 +5,7 @@ use chrono::Local; use std::fs; use std::fs::File; use std::io::{self}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; #[cfg(target_os = "windows")] use std::io::Write; diff --git a/src/commands/list.rs b/src/commands/list.rs index e9796e7..b93b8db 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -1,48 +1,36 @@ -//! `list` command implementation. -//! -//! This module contains the handler used by the CLI to list all books stored -//! in the database. The function performs a simple SELECT query and prints -//! a human-readable list to standard output using colored formatting. - +use crate::book::{Book, BookFull, BookShort}; use crate::i18n::tr; -use crate::models::Book; +use crate::utils::build_table; use chrono::{DateTime, NaiveDateTime, Utc}; -use colored::*; use rusqlite::Connection; use std::error::Error; fn parse_added_at(s: &str) -> Option> { - // Try RFC3339 first if let Ok(dt) = DateTime::parse_from_rfc3339(s) { return Some(dt.with_timezone(&Utc)); } - // Try SQLite default format: "YYYY-MM-DD HH:MM:SS" if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { return Some(DateTime::from_naive_utc_and_offset(naive, Utc)); } - // Try with fractional seconds if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") { return Some(DateTime::from_naive_utc_and_offset(naive, Utc)); } - None } /// Handle the `list` subcommand. /// -/// The handler queries the `books` table and prints each record to stdout in -/// a compact, colored format. Errors from SQL preparation or iteration are -/// returned to the caller so the binary can decide how to report them. -pub fn handle_list(conn: &Connection) -> Result<(), Box> { - let mut stmt = conn - .prepare("SELECT id, title, author, editor, year, isbn, language, pages, genre, summary, room, shelf, row, position, added_at FROM books ORDER BY id;")?; +/// Lists all books from the database using localized tabular output. +/// Supports the `--short` flag for compact view. +pub fn handle_list(conn: &Connection, short: bool) -> Result<(), Box> { + let mut stmt = conn.prepare( + "SELECT id, title, author, editor, year, isbn, language, pages, genre, summary, \ + room, shelf, row, position, added_at FROM books ORDER BY id;", + )?; + let rows = stmt.query_map([], |row| { - // Read added_at as an optional string, then parse to DateTime let added_at_str: Option = row.get("added_at")?; - let parsed_added_at = match added_at_str { - Some(ref s) => parse_added_at(s), - None => None, - }; + let parsed_added_at = added_at_str.as_ref().and_then(|s| parse_added_at(s)); Ok(Book { id: row.get("id")?, @@ -63,25 +51,21 @@ pub fn handle_list(conn: &Connection) -> Result<(), Box> { }) })?; - println!("\n{}", &tr("app.library.info").bold().green()); - for b in rows { - let b = b?; - // Format added_at as YYYY-MM-DD when present - let added_date = b - .added_at - .as_ref() - .map(|d| d.format("%Y-%m-%d").to_string()) - .unwrap_or_else(|| "-".to_string()); + let books: Vec = rows.filter_map(|r| r.ok()).collect(); - println!( - "{}. {} ({:?}) [{}] [{}]", - b.id.to_string().blue(), - b.title.bold(), - b.author, - b.year, - added_date - ); + if books.is_empty() { + println!("πŸ“š {}", tr("list.no_books_found")); + return Ok(()); } + println!("\n{}\n", tr("app.library.info")); + + let table = if short { + build_table(books.iter().map(BookShort)) + } else { + build_table(books.iter().map(BookFull)) + }; + + println!("{table}"); Ok(()) } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index b12232b..e953749 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -9,3 +9,12 @@ pub mod config; pub mod export; pub mod import; pub mod list; + +pub use backup::handle_backup; +pub use config::handle_config; +pub use export::handle_export_csv; +pub use export::handle_export_json; +pub use export::handle_export_xlsx; +pub use import::handle_import_csv; +pub use import::handle_import_json; +pub use list::handle_list; diff --git a/src/config.rs b/src/config/load_config.rs similarity index 88% rename from src/config.rs rename to src/config/load_config.rs index 76009b0..093ed00 100644 --- a/src/config.rs +++ b/src/config/load_config.rs @@ -1,16 +1,3 @@ -//! Configuration utilities for Librius. -//! -//! This module contains types and helper functions to locate, create and load -//! the YAML configuration file used by the application. The configuration is -//! intentionally small: it currently only stores the path to the SQLite -//! database file used by the CLI. -//! -//! The functions here are used by the binary at startup to ensure a -//! deterministic configuration directory and to persist a default -//! configuration when none exists. - -pub mod migrate; - use serde::{Deserialize, Serialize}; use serde_yaml::Value; use std::io::Write; diff --git a/src/config/migrate.rs b/src/config/migrate_config.rs similarity index 100% rename from src/config/migrate.rs rename to src/config/migrate_config.rs diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..0bb7734 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,16 @@ +//! Configuration utilities for Librius. +//! +//! This module contains types and helper functions to locate, create and load +//! the YAML configuration file used by the application. The configuration is +//! intentionally small: it currently only stores the path to the SQLite +//! database file used by the CLI. +//! +//! The functions here are used by the binary at startup to ensure a +//! deterministic configuration directory and to persist a default +//! configuration when none exists. + +pub mod load_config; +pub mod migrate_config; + +pub use load_config::{AppConfig, config_file_path, load_or_init}; +pub use migrate_config::migrate_config; diff --git a/src/db.rs b/src/db/load_db.rs similarity index 85% rename from src/db.rs rename to src/db/load_db.rs index 08a4e68..09a5df4 100644 --- a/src/db.rs +++ b/src/db/load_db.rs @@ -1,16 +1,5 @@ -//! Database initialization utilities for Librius. -//! -//! This module provides a small helper to initialize (or open) the SQLite -//! database used by the application. The `init_db` function ensures the -//! required `books` table exists and returns an active `rusqlite::Connection`. -//! -//! The schema is intentionally simple and stores basic metadata for each -//! book (title, author, year, isbn and a timestamp when the record was -//! added). - -pub mod migrate; - use crate::config::AppConfig; +use crate::db::migrate_db; use crate::i18n::{tr, tr_with}; use crate::utils::{is_verbose, print_err, print_info, print_ok, write_log}; use rusqlite::{Connection, Result}; @@ -75,13 +64,13 @@ pub fn start_db(config: &AppConfig) -> Result { } // Apply migrations - match migrate::run_migrations(&conn) { + match migrate_db::run_migrations(&conn) { Err(e) => { print_err(&tr_with("db.migrate.failed", &[("error", &e.to_string())])); let _ = write_log(&conn, "DB_MIGRATION_FAIL", "DB", &e.to_string()); } Ok(result) => match result { - migrate::MigrationResult::Applied(patches) => { + migrate_db::MigrationResult::Applied(patches) => { print_ok(&tr("db.migrate.applied"), is_verbose()); let msg = &tr_with( "log.db.patch_applied", @@ -89,7 +78,7 @@ pub fn start_db(config: &AppConfig) -> Result { ); let _ = write_log(&conn, "DB_MIGRATION_OK", "DB", msg); } - migrate::MigrationResult::None => { + migrate_db::MigrationResult::None => { print_ok(&tr("db.schema.already_update"), is_verbose()); } }, diff --git a/src/db/migrate.rs b/src/db/migrate_db.rs similarity index 100% rename from src/db/migrate.rs rename to src/db/migrate_db.rs diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..695ccb9 --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,15 @@ +//! Database initialization utilities for Librius. +//! +//! This module provides a small helper to initialize (or open) the SQLite +//! database used by the application. The `init_db` function ensures the +//! required `books` table exists and returns an active `rusqlite::Connection`. +//! +//! The schema is intentionally simple and stores basic metadata for each +//! models (title, author, year, isbn and a timestamp when the record was +//! added). + +pub mod load_db; +pub mod migrate_db; + +pub use load_db::{ensure_schema, init_db, start_db}; +pub use migrate_db::{MigrationResult, run_migrations}; diff --git a/src/i18n/loader.rs b/src/i18n/loader.rs index 367849b..d80843d 100644 --- a/src/i18n/loader.rs +++ b/src/i18n/loader.rs @@ -1,6 +1,8 @@ +use once_cell::sync::Lazy; use serde_json::Value; use std::collections::HashMap; use std::io; +use std::sync::RwLock; /// Parses a JSON string and returns a HashMap of key β†’ text pairs. /// Used for both embedded and (future) external locale loading. @@ -30,3 +32,47 @@ pub fn parse_json_to_map(content: &str) -> io::Result> { Ok(map) } + +/// Global translation map +static TRANSLATIONS: Lazy>> = + Lazy::new(|| RwLock::new(HashMap::new())); + +/// Loads translations for the selected language (embedded JSON) +pub fn load_language(lang_code: &str) { + let mut map = TRANSLATIONS.write().unwrap(); + map.clear(); + + // Select embedded JSON string + let content = match lang_code { + "it" => include_str!("locales/it.json"), + "en" => include_str!("locales/en.json"), + _ => include_str!("locales/en.json"), + }; + + // Parse JSON using shared helper + *map = parse_json_to_map(content).expect("Invalid embedded locale JSON"); +} + +/// Returns the translation for the given key. +pub fn tr(key: &str) -> String { + TRANSLATIONS + .read() + .unwrap() + .get(key) + .cloned() + .unwrap_or_else(|| key.to_string()) +} + +/// Same as `tr`, but with runtime placeholder substitution. +pub fn tr_with(key: &str, vars: &[(&str, &str)]) -> String { + let mut s = tr(key); + for (k, v) in vars { + let placeholder = format!("{{{}}}", k); + s = s.replace(&placeholder, v); + } + s +} + +pub fn tr_s(key: &str) -> &'static str { + Box::leak(tr(key).into_boxed_str()) +} diff --git a/src/i18n/locales/README.md b/src/i18n/locales/README.md index 4cf8600..f4bab61 100644 --- a/src/i18n/locales/README.md +++ b/src/i18n/locales/README.md @@ -20,7 +20,7 @@ Example: { "app.config.loading": "Loading configuration...", "db.init.ok": "Database created successfully.", - "book.add.fail": "Error adding book '{title}': {error}" + "book.add.fail": "Error adding models '{title}': {error}" } ``` diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 75412ac..ee90990 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -86,5 +86,16 @@ "db.migrate.isbn_index_created": "Created unique index on 'isbn' column.", "db.migrate.isbn_index_failed": "Failed to create unique ISBN index: {error}", "import.db.skipped_isbn": "Skipped duplicate ISBN: {isbn}", - "log.import.completed": "Import completed, total records: {count}" + "log.import.completed": "Import completed, total records: {count}", + "help.list.short": "Show only ID, Title, Author, Editor, and Year", + "list.header.id": "ID", + "list.header.title": "Title", + "list.header.author": "Author", + "list.header.editor": "Editor", + "list.header.year": "Year", + "list.header.language": "Language", + "list.header.room": "Room", + "list.header.shelf": "Shelf", + "list.header.position": "Position", + "list.header.added": "Added" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index a0a95f8..9c3d941 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -86,5 +86,16 @@ "db.migrate.isbn_index_created": "Creato indice univoco sulla colonna 'isbn'.", "db.migrate.isbn_index_failed": "Errore nella creazione dell'indice univoco su ISBN: {error}", "import.db.skipped_isbn": "ISBN duplicato saltato: {isbn}", - "log.import.completed": "Importazione completata, record totali: {count}" + "log.import.completed": "Importazione completata, record totali: {count}", + "help.list.short": "Mostra solo ID, Titolo, Autore, Editore e Anno", + "list.header.id": "ID", + "list.header.title": "Titolo", + "list.header.author": "Autore", + "list.header.editor": "Editore", + "list.header.year": "Anno", + "list.header.language": "Lingua", + "list.header.room": "Stanza", + "list.header.shelf": "Scaffale", + "list.header.position": "Posizione", + "list.header.added": "Aggiunto" } diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs index 347b04d..4d3f930 100644 --- a/src/i18n/mod.rs +++ b/src/i18n/mod.rs @@ -1,52 +1,4 @@ //! i18n - Embedded Internationalization module for Librius - -use once_cell::sync::Lazy; -use std::collections::HashMap; -use std::sync::RwLock; - mod loader; -use loader::parse_json_to_map; - -/// Global translation map -static TRANSLATIONS: Lazy>> = - Lazy::new(|| RwLock::new(HashMap::new())); - -/// Loads translations for the selected language (embedded JSON) -pub fn load_language(lang_code: &str) { - let mut map = TRANSLATIONS.write().unwrap(); - map.clear(); - - // Select embedded JSON string - let content = match lang_code { - "it" => include_str!("locales/it.json"), - "en" => include_str!("locales/en.json"), - _ => include_str!("locales/en.json"), - }; - - // Parse JSON using shared helper - *map = parse_json_to_map(content).expect("Invalid embedded locale JSON"); -} - -/// Returns the translation for the given key. -pub fn tr(key: &str) -> String { - TRANSLATIONS - .read() - .unwrap() - .get(key) - .cloned() - .unwrap_or_else(|| key.to_string()) -} - -/// Same as `tr`, but with runtime placeholder substitution. -pub fn tr_with(key: &str, vars: &[(&str, &str)]) -> String { - let mut s = tr(key); - for (k, v) in vars { - let placeholder = format!("{{{}}}", k); - s = s.replace(&placeholder, v); - } - s -} -pub fn tr_s(key: &str) -> &'static str { - Box::leak(tr(key).into_boxed_str()) -} +pub use loader::{load_language, parse_json_to_map, tr, tr_s, tr_with}; diff --git a/src/lib.rs b/src/lib.rs index 4572d65..adce4b0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,10 +27,13 @@ pub mod i18n; pub mod models; pub mod utils; -pub use commands::list::handle_list; -pub use config::AppConfig; -pub use db::init_db; -pub use models::Book; +pub use cli::build_cli; +pub use commands::*; +pub use config::*; +pub use db::*; +pub use i18n::*; +pub use models::*; +pub use utils::*; #[cfg(test)] mod tests { @@ -69,7 +72,44 @@ mod tests { )?; // Chiama la funzione handle_list per esercitare la logica di mapping e formattazione - handle_list(&conn)?; + // default view in tests: non-short (full) + handle_list(&conn, false)?; + + Ok(()) + } + + #[test] + fn exercise_list_handler_short() -> Result<(), Box> { + // stessa preparazione DB, ma verifichiamo la vista corta (short=true) + let conn = Connection::open_in_memory()?; + conn.execute( + "CREATE TABLE books ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + author TEXT NOT NULL, + editor TEXT NOT NULL, + year INTEGER NOT NULL, + isbn TEXT NOT NULL, + language TEXT, + pages INTEGER, + genre TEXT, + summary TEXT, + room TEXT, + shelf TEXT, + row TEXT, + position TEXT, + added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + );", + [], + )?; + + conn.execute( + "INSERT INTO books (title, author, editor, year, isbn, added_at) VALUES (?1, ?2, ?3,?4, ?5, ?6);", + ["Short Test", "Author", "Editor", "2022", "978-0000000000", "2020-01-01 12:00:00"], + )?; + + // Chiama la funzione handle_list per verificare la vista corta (non deve panicare) + handle_list(&conn, true)?; Ok(()) } diff --git a/src/main.rs b/src/main.rs index 684fa36..ae44638 100644 --- a/src/main.rs +++ b/src/main.rs @@ -64,13 +64,13 @@ fn main() { // ------------------------------------------------------------ // 5️⃣ Esegue migrazioni DB e config // ------------------------------------------------------------ - if let Err(e) = db::migrate::run_migrations(&conn) { + if let Err(e) = db::migrate_db::run_migrations(&conn) { print_err(&tr_with("db.migrate.failed", &[("error", &e.to_string())])); } else { print_ok(&tr("db.schema.verified"), is_verbose()); } - if let Err(e) = config::migrate::migrate_config(&conn, &config::config_file_path()) { + if let Err(e) = config::migrate_config(&conn, &config::config_file_path()) { print_err(&tr_with( "config.migrate.failed", &[("error", &e.to_string())], diff --git a/src/models.rs b/src/models.rs deleted file mode 100644 index 16de76d..0000000 --- a/src/models.rs +++ /dev/null @@ -1,52 +0,0 @@ -//! Domain models used by Librius. -//! -//! This module contains simple data structures that represent the persistent -//! entities stored in the SQLite database. Models are `serde` serializable -//! so they can be easily printed, logged, or converted to JSON if needed. - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -/// Represents a single book record in the library. -/// -/// Fields correspond to the columns of the `books` table in the SQLite -/// database. Many fields are optional to reflect incomplete metadata for -/// some records. The cataloging/location fields (`room`, `shelf`, `row` and -/// `position`) describe where the physical item is stored and are optional -/// because not all records may have precise shelving information. -#[derive(Debug, Serialize, Deserialize)] -pub struct Book { - /// Primary key identifier for the book. - pub id: i64, - /// Title of the book (required). - pub title: String, - /// Author name, when available. - pub author: String, - /// Editor / publisher, when available. - pub editor: String, - /// Publication year, when available. - pub year: i32, - /// ISBN code, when available. - pub isbn: String, - /// Language of the book (e.g., "en", "it"), when available. - pub language: Option, - /// Number of pages, when available. - pub pages: Option, - /// Genre or category, when available. - pub genre: Option, - /// Short textual summary or notes about the book. - pub summary: Option, - /// Room where the item is stored (e.g. "Main", "Annex"). - pub room: Option, - /// Shelf identifier inside the room, when available. - pub shelf: Option, - /// Row identifier on the shelf, when available. - pub row: Option, - /// Position within the row/shelf, when available. - pub position: Option, - /// Timestamp when the record was added. Represented as `DateTime`. - /// - /// This field maps the SQLite `CURRENT_TIMESTAMP` value (stored as - /// text like `YYYY-MM-DD HH:MM:SS`) into a `chrono::DateTime`. - pub added_at: Option>, -} diff --git a/src/models/book.rs b/src/models/book.rs new file mode 100644 index 0000000..71439d1 --- /dev/null +++ b/src/models/book.rs @@ -0,0 +1,93 @@ +use crate::i18n::tr; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::borrow::Cow; +use tabled::Tabled; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Book { + pub id: i64, + pub title: String, + pub author: String, + pub editor: String, + pub year: i32, + pub isbn: String, + pub language: Option, + pub pages: Option, + pub genre: Option, + pub summary: Option, + pub room: Option, + pub shelf: Option, + pub row: Option, + pub position: Option, + pub added_at: Option>, +} + +pub struct BookFull<'a>(pub &'a Book); +pub struct BookShort<'a>(pub &'a Book); + +impl<'a> Tabled for BookFull<'a> { + const LENGTH: usize = 10; + + fn fields(&self) -> Vec> { + let b = self.0; + let added_date = b + .added_at + .as_ref() + .map(|d| d.format("%Y-%m-%d").to_string()) + .unwrap_or_else(|| "-".into()); + + vec![ + Cow::from(b.id.to_string()), + Cow::from(&b.title), + Cow::from(&b.author), + Cow::from(&b.editor), + Cow::from(b.year.to_string()), + Cow::from(b.language.as_deref().unwrap_or("-")), + Cow::from(b.room.as_deref().unwrap_or("-")), + Cow::from(b.shelf.as_deref().unwrap_or("-")), + Cow::from(b.position.as_deref().unwrap_or("-")), + Cow::from(added_date), + ] + } + + fn headers() -> Vec> { + vec![ + Cow::from(tr("list.header.id")), + Cow::from(tr("list.header.title")), + Cow::from(tr("list.header.author")), + Cow::from(tr("list.header.editor")), + Cow::from(tr("list.header.year")), + Cow::from(tr("list.header.language")), + Cow::from(tr("list.header.room")), + Cow::from(tr("list.header.shelf")), + Cow::from(tr("list.header.position")), + Cow::from(tr("list.header.added")), + ] + } +} + +impl<'a> Tabled for BookShort<'a> { + const LENGTH: usize = 5; + + fn fields(&self) -> Vec> { + let b = self.0; + vec![ + Cow::from(b.id.to_string()), + Cow::from(&b.title), + Cow::from(&b.author), + Cow::from(&b.editor), + Cow::from(b.year.to_string()), + ] + } + + fn headers() -> Vec> { + vec![ + Cow::from(tr("list.header.id")), + Cow::from(tr("list.header.title")), + Cow::from(tr("list.header.author")), + Cow::from(tr("list.header.editor")), + Cow::from(tr("list.header.year")), + ] + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..3df16c5 --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,3 @@ +pub mod book; + +pub use book::{Book, BookFull, BookShort}; diff --git a/src/utils.rs b/src/utils/mod.rs similarity index 97% rename from src/utils.rs rename to src/utils/mod.rs index aa4bed3..b8a6606 100644 --- a/src/utils.rs +++ b/src/utils/mod.rs @@ -1,9 +1,12 @@ // ===================================================== -// Librius - Utilities module +// Librius - Utilities module (directory layout) // ----------------------------------------------------- // Contiene funzioni di supporto generali e costanti // grafiche per output CLI. // ===================================================== +pub mod table; + +pub use table::build_table; use chrono::Local; use colored::*; diff --git a/src/utils/table.rs b/src/utils/table.rs new file mode 100644 index 0000000..a1cde66 --- /dev/null +++ b/src/utils/table.rs @@ -0,0 +1,37 @@ +//! Utilities for building and formatting tables in Librius CLI. +//! +//! Provides a unified interface for rendering tabular data using the `tabled` crate, +//! ensuring consistent visual style and alignment across commands. + +use tabled::settings::{Alignment, Modify, Style, object::Rows}; +use tabled::{Table, Tabled}; + +/// Build and format a table with a consistent Librius style. +/// +/// # Parameters +/// * `rows` β€” Any iterator of types implementing [`Tabled`]. +/// +/// # Example +/// ```no_run +/// use librius::utils::table::build_table; +/// // Esempio illustrativo: crea una struttura che implementi `Tabled` e +/// // passa una collezione a `build_table`. +/// // #[derive(tabled::Tabled)] +/// // struct MyTableRow { col: &'static str } +/// // let data = vec![MyTableRow { col: "value" }]; +/// // println!("{}", build_table(data)); +/// ``` +pub fn build_table(rows: I) -> String +where + T: Tabled, + I: IntoIterator, +{ + let s = Style::modern(); + + Table::new(rows) + // stile tabellare coerente + .with(s) + // allineamento a sinistra per le righe di dati + .with(Modify::new(Rows::new(1..)).with(Alignment::left())) + .to_string() +} From 218d0a5df2da56cbf00f93f1a36b48c5bc72b85e Mon Sep 17 00:00:00 2001 From: Alessandro Maestri Date: Thu, 16 Oct 2025 17:49:02 +0200 Subject: [PATCH 2/5] docs(updated): updated CHANGELOG.md and README.md --- CHANGELOG.md | 22 ++++++++ README.md | 140 +++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 129 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93e323c..43019e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ All notable changes to this project will be documented in this file. +## [0.3.0] - 2025-10-16 + +### Added + +- Introduced the `tabled` crate (`v0.20.0`) for tabular output. +- New `--short` flag for `librius list` showing only key columns (ID, Title, Author, Editor, Year). +- New utility `build_table()` in `utils/table.rs` to render tables with consistent style and alignment. + +### Changed + +- Refactored `list` command to use `BookFull` and `BookShort` wrappers implementing `Tabled`. +- Standardized module structure across the project: + - Each main directory (`commands`, `db`, `config`, `i18n`, `models`, `utils`) now includes a `mod.rs`. + - Unified import/export logic in `lib.rs` for cleaner module access. +- Improved code readability, organization, and adherence to Rust idioms. + +### Removed + +- Legacy manual `println!` formatting for book listings. + +--- + ## [0.2.5] - 2025-10-15 ### Added diff --git a/README.md b/README.md index 9ede732..73bad77 100644 --- a/README.md +++ b/README.md @@ -20,35 +20,24 @@ and import/export support. --- -## ✨ New in v0.2.5 - -- **Backup command** (`librius backup`) - - Creates plain `.sqlite` backups in the `backups/` directory - - Optional `--compress` flag for compressed backups - - `.zip` format on Windows - - `.tar.gz` format on macOS and Linux - - Localized help and messages via i18n (English and Italian) - - Timestamp-based file naming for safe sequential backups - -- **Export command** (`librius export`) - - Added support for exporting library data in multiple formats: - - `--csv` (default): plain text export with semicolon delimiter - - `--json`: structured JSON array output - - `--xlsx`: formatted Excel file using umya-spreadsheet - - Localized CLI help and status messages (English/Italian) - - Automatic export directory and timestamped filenames - - Uses `dirs` crate for cross-platform export path handling - -- **Import command** (`librius import`) - - Supports importing book data from external sources - - Available formats: - - `--csv` (default): semicolon-delimited CSV - - `--json`: JSON array of objects - - Unified parsing via `serde` and shared `BookRecord` struct - - Duplicate detection through unique index on `isbn` - - Uses `INSERT OR IGNORE` for idempotent imports (no duplication) - - Verbose mode logs skipped records (e.g., β€œSkipped duplicate ISBN: …”) - - Non-blocking import completion logging +## ✨ New in v0.3.0 + +**πŸ†• Modern tabular output** + +- Replaced the old println! list format with the tabled crate. +- Tables now feature aligned, styled columns for improved readability. +- Added a --short flag for compact view (ID, Title, Author, Editor, Year). + +**🧩 Modular architecture** + +- Standardized all modules using the mod.rs structure. +- Each subsystem (commands, models, utils, db, config, i18n) now has a clean namespace. +- Simplified imports using pub use re-exports in lib.rs. + +**🧱 Utility improvements** + +- Added a reusable build_table() helper in utils/table.rs to ensure consistent table rendering. +- Introduced BookFull and BookShort structs implementing Tabled for full and compact listings. --- @@ -94,12 +83,36 @@ cargo install rtimelogger | βœ… | **Import** | Import data from CSV or JSON files (duplicate-safe via ISBN) | | βœ… | **Database migrations** | Automatic schema upgrades at startup | | βœ… | **Logging system** | Records operations and migrations in log table | -| βœ… | **Verbose mode** | Optional `--verbose` flag for detailed debug output | -| βœ… | **Safe patch system** | Each migration is idempotent and logged for traceability | | βœ… | **Multilanguage (i18n)** | Localized CLI (commands, help); embedded JSON; `--lang` + YAML `language` | | 🚧 | **Add / Remove** | Add or delete books via CLI commands | | 🚧 | **Search** | Search by title, author, or ISBN | -| 🚧 | **Export / Import** | Export and import data (JSON, CSV) | + +--- + +## πŸ’» Usage + +```bash +librius list # Full detailed list +librius list --short # Compact list (ID, Title, Author, Editor, Year) +``` + +--- + +## 🧱 Example output + +```bash +$ librius list --short + +πŸ“š Personal Library +════════════════════════════════════════════════════════════════════════════════ + +β”Œβ”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β” +β”‚ ID β”‚ Title β”‚ Author β”‚ Editor β”‚ Year β”‚ +β”œβ”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€ +β”‚ 1 β”‚ The Rust Programming Languageβ”‚ Steve Klabnik β”‚ No Starch β”‚ 2018 β”‚ +β”‚ 2 β”‚ Clean Code β”‚ Robert C. Martin β”‚ Pearson β”‚ 2008 β”‚ +β””β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”˜ +``` --- @@ -122,7 +135,7 @@ Librius now supports a multilingual interface. - All user-visible messages (print_info, print_err, etc.) are translated dynamically. - Missing keys automatically fall back to their key name or English equivalent. -### Example usage +### Example Usage ```bash # Default (English) @@ -171,6 +184,51 @@ tr_with("db.path.open_existing", & [("path", & db_path)]); --- +## 🧱 Project structure + +``` +src/ +β”œβ”€ main.rs +β”œβ”€ lib.rs +β”œβ”€ cli.rs +β”‚ +β”œβ”€ commands/ +β”‚ β”œβ”€ mod.rs +β”‚ β”œβ”€ list.rs +β”‚ β”œβ”€ backup.rs +β”‚ β”œβ”€ config.rs +β”‚ β”œβ”€ export.rs +β”‚ └─ import.rs +β”‚ +β”œβ”€ config/ +β”‚ β”œβ”€ mod.rs +β”‚ β”œβ”€ load_config.rs +β”‚ └─ migrate_config.rs +β”‚ +β”œβ”€ db/ +β”‚ β”œβ”€ mod.rs +β”‚ β”œβ”€ load_db.rs +β”‚ └─ migrate_db.rs +β”‚ +β”œβ”€ i18n/ +β”‚ β”œβ”€ mod.rs +β”‚ β”œβ”€ loader.rs +β”‚ └─ locales/ +β”‚ β”œβ”€ en.json +β”‚ β”œβ”€ it.json +β”‚ └─ README.md +β”‚ +β”œβ”€ models/ +β”‚ β”œβ”€ mod.rs +β”‚ └─ book.rs +β”‚ +└─ utils/ + β”œβ”€ mod.rs + └─ table.rs +``` + +--- + ## 🧾 Changelog reference See [CHANGELOG.md](CHANGELOG.md) for a detailed list of changes and updates. @@ -269,6 +327,22 @@ language: "en" --- +## 🧩 Development notes + +Librius now follows a standard Rust modular structure: + +- Each domain (commands, db, config, models, utils, i18n) exposes its API via mod.rs. +- Common utilities like build_table() are reused across commands for consistent output. +- The lib.rs re-exports all major modules for cleaner imports in main.rs. + +### Example import + +```rust +use librius::{build_cli, handle_list; tr}; +``` + +--- + ## πŸ“š Documentation The API and user-facing documentation for Librius is available on docs.rs: From b7f374c4ca1c090f0096fc3be7250ff916a0999a Mon Sep 17 00:00:00 2001 From: Alessandro Maestri Date: Thu, 16 Oct 2025 23:38:42 +0200 Subject: [PATCH 3/5] feat(import): add --delimiter/-d option, refactor import logic, and make Book.id optional - Added CLI parameter --delimiter / -d to specify custom CSV field separator (default ',') - Refactored import logic for CSV and JSON: - utils::open_import_file() now handles file opening with localized error handling - utils::handle_import_result() manages DB insert results and counters - Made Book.id optional (Option) to fix CSV/JSON import deserialization errors - Unified error reporting, localization, and counters across import commands - Improved code maintainability and removed duplication --- CHANGELOG.md | 17 +++ src/cli.rs | 34 +++++- src/commands/backup.rs | 2 + src/commands/import.rs | 251 +++++++++++++++++++++------------------ src/commands/list.rs | 2 +- src/i18n/locales/en.json | 12 +- src/i18n/locales/it.json | 12 +- src/models/book.rs | 6 +- src/utils/mod.rs | 38 ++++++ 9 files changed, 248 insertions(+), 126 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43019e2..b4272fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ All notable changes to this project will be documented in this file. - Introduced the `tabled` crate (`v0.20.0`) for tabular output. - New `--short` flag for `librius list` showing only key columns (ID, Title, Author, Editor, Year). - New utility `build_table()` in `utils/table.rs` to render tables with consistent style and alignment. +- CLI option `--delimiter` / `-d` for `import` command. + Allows specifying a custom CSV field separator (default: `,`). ### Changed @@ -18,6 +20,21 @@ All notable changes to this project will be documented in this file. - Unified import/export logic in `lib.rs` for cleaner module access. - Improved code readability, organization, and adherence to Rust idioms. +### Refactored + +- Extracted duplicated import logic into reusable helper functions: + - `utils::open_import_file()` now handles file opening with localized error reporting. + - `utils::handle_import_result()` manages database insert results and counters. +- Unified behavior between `handle_import_csv()` and `handle_import_json()`. +- Simplified error handling and improved localization consistency across import operations. +- Reduced code duplication and improved maintainability throughout the import module. + +### Fixed + +- **CSV/JSON import deserialization error**: + The `id` field in the `Book` struct is now optional (`Option`), + preventing missing-field errors during import when the ID column is not present. + ### Removed - Legacy manual `println!` formatting for book listings. diff --git a/src/cli.rs b/src/cli.rs index 663b3ed..afe2adf 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,5 +1,6 @@ use crate::commands::{handle_config, handle_list}; use crate::i18n::{tr, tr_s}; +use crate::tr_with; use crate::utils::print_err; use clap::{Arg, Command, Subcommand}; use colored::Colorize; @@ -138,6 +139,16 @@ pub fn build_cli() -> Command { .help(tr_s("import_json_help")) .conflicts_with("csv") .action(clap::ArgAction::SetTrue), + ) + .arg( + Arg::new("delimiter") + .short('d') + .long("delimiter") + .help(tr_s("import_delimiter_help")) + .num_args(1) + .value_name("CHAR") + .required(false) + .value_parser(clap::builder::NonEmptyStringValueParser::new()), ), ) // help come subcommand dedicato (es: `librius help config`) @@ -215,14 +226,27 @@ pub fn run_cli( let _import_csv = sub_m.get_flag("csv"); let import_json = sub_m.get_flag("json"); - // πŸ”Ή Esegui l'import nel formato scelto - if import_json { + // πŸ”Ή Recupera delimitatore opzionale (solo CSV) + let delimiter_char = if let Some(delim_str) = sub_m.get_one::("delimiter") { + delim_str.chars().next().unwrap_or(',') + } else { + ',' + }; + + // πŸ”Ή Esegui l’import nel formato corretto + let result = if import_json { crate::commands::handle_import_json(conn, &file) - .map_err(|e| Box::new(e) as Box)?; } else { - crate::commands::handle_import_csv(conn, &file) - .map_err(|e| Box::new(e) as Box)?; + crate::commands::handle_import_csv(conn, &file, delimiter_char) + }; + + if let Err(e) = result { + print_err(&tr_with( + "import.error.unexpected", + &[("error", &e.to_string())], + )); } + Ok(()) } else if let Some(("help", sub_m)) = matches.subcommand() { if let Some(cmd_name) = sub_m.get_one::("command") { diff --git a/src/commands/backup.rs b/src/commands/backup.rs index 8782bc5..b077c66 100644 --- a/src/commands/backup.rs +++ b/src/commands/backup.rs @@ -16,6 +16,8 @@ use crate::utils::icons::ERR; #[cfg(not(target_os = "windows"))] use flate2::{Compression, write::GzEncoder}; #[cfg(not(target_os = "windows"))] +use std::path::Path; +#[cfg(not(target_os = "windows"))] use tar::Builder; pub fn handle_backup(_conn: &rusqlite::Connection, compress: bool) -> io::Result<()> { diff --git a/src/commands/import.rs b/src/commands/import.rs index d35ddfb..87192aa 100644 --- a/src/commands/import.rs +++ b/src/commands/import.rs @@ -1,134 +1,155 @@ use crate::i18n::tr_with; -use crate::utils::{is_verbose, print_info, print_ok, write_log}; -use rusqlite::{Connection, Transaction}; -use serde::Deserialize; -use std::fs::File; -use std::io; - +use crate::utils::{is_verbose, print_ok}; +use crate::{Book, print_err}; use csv::ReaderBuilder; +use rusqlite::Connection; +use std::io::BufReader; -/// Struttura dati comune per importazione (CSV/JSON) -#[derive(Debug, Deserialize)] -pub struct BookRecord { - pub title: String, - pub author: String, - pub editor: String, - pub year: i64, - pub isbn: String, - pub language: Option, - pub pages: Option, - pub genre: Option, - pub summary: Option, - pub room: Option, - pub shelf: Option, - pub row: Option, - pub position: Option, -} +/// 🧩 Importa dati da file CSV (usa `csv` + `serde`) +pub fn handle_import_csv( + conn: &mut Connection, + file: &str, + delimiter: char, +) -> Result<(), Box> { + let file_display = file.to_string(); -/// Funzione helper per convertire errori in `io::Error` -fn to_io(err: E) -> io::Error { - io::Error::other(err.to_string()) -} + // βœ… Attempt to open the file + let file_handle = crate::utils::open_import_file(file)?; -/// 🧩 Helper: inserisce un record nella tabella `books`, gestendo i duplicati via OR IGNORE. -/// Ritorna `true` se il record Γ¨ stato inserito, `false` se saltato (duplicato). -fn insert_book_record(tx: &Transaction, rec: &BookRecord) -> io::Result { - let affected = tx - .execute( - "INSERT OR IGNORE INTO books (title, author, editor, year, isbn, language, pages, genre, summary, room, shelf, row, position) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", - ( - &rec.title, - &rec.author, - &rec.editor, - &rec.year, - &rec.isbn, - &rec.language, - &rec.pages, - &rec.genre, - &rec.summary, - &rec.room, - &rec.shelf, - &rec.row, - &rec.position, - ), - ) - .map_err(to_io)?; + // βœ… Build CSV reader + let mut reader = ReaderBuilder::new() + .delimiter(delimiter as u8) + .from_reader(file_handle); - Ok(affected > 0) -} + let mut imported = 0; + let mut failed = 0; -/// 🧩 Importa dati da file CSV (usa `csv` + `serde`) -pub fn handle_import_csv(conn: &mut Connection, file_path: &str) -> io::Result<()> { - let mut reader = ReaderBuilder::new() - .delimiter(b';') - .from_path(file_path) - .map_err(to_io)?; - - let tx = conn.transaction().map_err(to_io)?; - let mut inserted = 0; - - for result in reader.deserialize() { - let rec: BookRecord = result.map_err(to_io)?; - let inserted_ok = insert_book_record(&tx, &rec)?; - if inserted_ok { - inserted += 1; - } else if is_verbose() { - print_info( - &tr_with("import.db.skipped_isbn", &[("isbn", &rec.isbn)]), - is_verbose(), - ); + // βœ… Read and process each record + for (index, record) in reader.deserialize::().enumerate() { + match record { + Ok(book) => { + let result = conn.execute( + "INSERT INTO books (title, author, editor, year, isbn, genre, language, pages, summary) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + ( + &book.title, + &book.author, + &book.editor, + &book.year, + &book.isbn, + &book.genre, + &book.language, + &book.pages, + &book.summary, + ), + ); + + crate::utils::handle_import_result( + &result, + &mut imported, + &mut failed, + &book.title, + ); + } + Err(e) => { + failed += 1; + print_err(&tr_with( + "import.error.parse_failed", + &[("line", &index.to_string()), ("error", &e.to_string())], + )); + } } } - tx.commit().map_err(to_io)?; - print_ok( - &tr_with("import.csv.ok", &[("count", &inserted.to_string())]), - true, - ); - write_log( - conn, - "IMPORT_CSV_COMPLETED", - "DB", - &tr_with("log.import.completed", &[("count", &inserted.to_string())]), - ) - .map_err(to_io)?; + // βœ… Summary message + if imported > 0 { + print_ok( + &tr_with( + "import.summary.ok", + &[ + ("count", &imported.to_string()), + ("file", &file_display), + ("delimiter", &delimiter.to_string()), + ], + ), + is_verbose(), + ); + } + + if failed > 0 { + print_err(&tr_with( + "import.summary.failed", + &[("count", &failed.to_string()), ("file", &file_display)], + )); + } + Ok(()) } -/// 🧩 Importa dati da file JSON (usa `serde_json`) -pub fn handle_import_json(conn: &mut Connection, file_path: &str) -> io::Result<()> { - let file = File::open(file_path).map_err(to_io)?; - let data: Vec = serde_json::from_reader(file) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; - - let tx = conn.transaction().map_err(to_io)?; - let mut inserted = 0; - - for rec in data { - let inserted_ok = insert_book_record(&tx, &rec)?; - if inserted_ok { - inserted += 1; - } else if is_verbose() { - print_info( - &tr_with("import.db.skipped_isbn", &[("isbn", &rec.isbn)]), - is_verbose(), - ); +/// Handles importing a JSON file into the database. +/// Expects a top-level array of book objects. +pub fn handle_import_json( + conn: &mut Connection, + file: &str, +) -> Result<(), Box> { + let file_display = file.to_string(); + + // βœ… Open JSON file + let file_handle = crate::utils::open_import_file(file)?; + + let reader = BufReader::new(file_handle); + let books: Vec = match serde_json::from_reader(reader) { + Ok(data) => data, + Err(e) => { + print_err(&tr_with( + "import.error.json_invalid", + &[("file", &file_display), ("error", &e.to_string())], + )); + return Ok(()); } + }; + + let mut imported = 0; + let mut failed = 0; + + // βœ… Iterate through records + for book in books { + let result = conn.execute( + "INSERT INTO books (title, author, editor, year, isbn, genre, language, pages, summary) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + ( + &book.title, + &book.author, + &book.editor, + &book.year, + &book.isbn, + &book.genre, + &book.language, + &book.pages, + &book.summary, + ), + ); + + crate::utils::handle_import_result(&result, &mut imported, &mut failed, &book.title); + } + + // βœ… Summary output + if imported > 0 { + print_ok( + &tr_with( + "import.summary.ok_json", + &[("count", &imported.to_string()), ("file", &file_display)], + ), + is_verbose(), + ); } - tx.commit().map_err(to_io)?; - print_ok( - &tr_with("import.json.ok", &[("count", &inserted.to_string())]), - true, - ); - write_log( - conn, - "IMPORT_JSON_COMPLETED", - "DB", - &tr_with("log.import.completed", &[("count", &inserted.to_string())]), - ) - .map_err(to_io)?; + if failed > 0 { + print_err(&tr_with( + "import.summary.failed", + &[("count", &failed.to_string()), ("file", &file_display)], + )); + } Ok(()) } diff --git a/src/commands/list.rs b/src/commands/list.rs index b93b8db..f49e2ee 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -54,7 +54,7 @@ pub fn handle_list(conn: &Connection, short: bool) -> Result<(), Box> let books: Vec = rows.filter_map(|r| r.ok()).collect(); if books.is_empty() { - println!("πŸ“š {}", tr("list.no_books_found")); + println!("\nπŸ“š {}", tr("list.no_books_found")); return Ok(()); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ee90990..d3b5723 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -97,5 +97,15 @@ "list.header.room": "Room", "list.header.shelf": "Shelf", "list.header.position": "Position", - "list.header.added": "Added" + "list.header.added": "Added", + "list.no_books_found": "No books found in your library.", + "import_delimiter_help": "Specify CSV delimiter character (default ',')", + "import.error.open_failed": "Failed to open file '{file}': {error}", + "import.error.parse_failed": "Failed to parse record at line {line}: {error}", + "import.error.insert_failed": "Failed to insert '{title}' into the database: {error}", + "import.summary.ok": "Imported {count} books from '{file}' (delimiter '{delimiter}')", + "import.summary.failed": "Skipped {count} invalid records in '{file}'", + "import.error.json_invalid": "Invalid JSON structure in '{file}': {error}", + "import.summary.ok_json": "Imported {count} books from JSON file '{file}'", + "import.error.unexpected": "Unexpected error during import: {error}" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 9c3d941..003c0f0 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -97,5 +97,15 @@ "list.header.room": "Stanza", "list.header.shelf": "Scaffale", "list.header.position": "Posizione", - "list.header.added": "Aggiunto" + "list.header.added": "Aggiunto", + "list.no_books_found": "Nessun libro trovato.", + "import_delimiter_help": "Specifica il carattere delimitatore del file CSV (predefinito ',')", + "import.error.open_failed": "Impossibile aprire il file '{file}': {error}", + "import.error.parse_failed": "Errore di lettura alla riga {line}: {error}", + "import.error.insert_failed": "Impossibile inserire '{title}' nel database: {error}", + "import.summary.ok": "Importati {count} libri da '{file}' (delimitatore '{delimiter}')", + "import.summary.failed": "Saltati {count} record non validi in '{file}'", + "import.error.json_invalid": "Struttura JSON non valida in '{file}': {error}", + "import.summary.ok_json": "Importati {count} libri dal file JSON '{file}'", + "import.error.unexpected": "Errore imprevisto durante l'importazione: {error}" } diff --git a/src/models/book.rs b/src/models/book.rs index 71439d1..28e0278 100644 --- a/src/models/book.rs +++ b/src/models/book.rs @@ -6,7 +6,7 @@ use tabled::Tabled; #[derive(Debug, Serialize, Deserialize)] pub struct Book { - pub id: i64, + pub id: Option, pub title: String, pub author: String, pub editor: String, @@ -38,7 +38,7 @@ impl<'a> Tabled for BookFull<'a> { .unwrap_or_else(|| "-".into()); vec![ - Cow::from(b.id.to_string()), + Cow::from(b.id.map(|v| v.to_string()).unwrap_or_default()), Cow::from(&b.title), Cow::from(&b.author), Cow::from(&b.editor), @@ -73,7 +73,7 @@ impl<'a> Tabled for BookShort<'a> { fn fields(&self) -> Vec> { let b = self.0; vec![ - Cow::from(b.id.to_string()), + Cow::from(b.id.map(|v| v.to_string()).unwrap_or_default()), Cow::from(&b.title), Cow::from(&b.author), Cow::from(&b.editor), diff --git a/src/utils/mod.rs b/src/utils/mod.rs index b8a6606..ebeb78b 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -8,9 +8,13 @@ pub mod table; pub use table::build_table; +use crate::i18n::tr_with; use chrono::Local; use colored::*; +use rusqlite::Result as SqlResult; use rusqlite::{Connection, Result}; +use std::fs::File; +use std::io; use std::sync::OnceLock; static VERBOSE: OnceLock = OnceLock::new(); @@ -110,3 +114,37 @@ pub fn print_info(msg: &str, verbose: bool) { } println!("{}{}", icons::INFO, msg.blue().bold()); } + +/// Opens a file for import operations and prints a localized error message on failure. +pub fn open_import_file(file: &str) -> Result { + let file_display = file.to_string(); + + File::open(file).inspect_err(|e| { + print_err(&tr_with( + "import.error.open_failed", + &[("file", &file_display), ("error", &e.to_string())], + )); + }) +} + +/// Handles the result of a database insert operation for book import. +/// Increments counters and prints localized error messages if necessary. +pub fn handle_import_result( + result: &SqlResult, + imported: &mut u32, + failed: &mut u32, + title: &str, +) { + match result { + Ok(_) => { + *imported += 1; + } + Err(e) => { + *failed += 1; + print_err(&tr_with( + "import.error.insert_failed", + &[("title", title), ("error", &e.to_string())], + )); + } + } +} From 55def499f100280318c0021e03e1b117fc907132 Mon Sep 17 00:00:00 2001 From: Alessandro Maestri Date: Fri, 17 Oct 2025 00:48:18 +0200 Subject: [PATCH 4/5] feat(list): add --id and --details options for single-record view - Introduced two new CLI options to the `list` command: - `--id `: display a specific book record by its ID. - `--details`: show all fields of the specified record in a vertical table. - If only `--id` is provided, details are shown automatically. - If `--details` is used without `--id`, an error message is displayed. - Implemented dynamic `build_vertical_table()` using `serde_json` + `tabled` to automatically render all struct fields without manual updates. - Updated command builder and `handle_list()` logic accordingly. refactor(list): unify query_map and own SQL params - Extract a dedicated row_to_book helper to centralize mapping from rusqlite::Row to Book. - Replace duplicated query_map closures with a single parameterized call that uses owned SQL parameters (Vec>) to ensure parameter lifetimes. - Remove redundant closures and needless borrows (pass functions directly where appropriate). - Add validation so --details must be used together with --id, and add localized messages for this case (EN/IT). - Fix Clippy warnings and verify build & tests. --- src/cli.rs | 43 +++++++++----- src/commands/list.rs | 123 +++++++++++++++++++++++++++------------ src/i18n/locales/en.json | 5 +- src/i18n/locales/it.json | 5 +- src/lib.rs | 4 +- src/utils/mod.rs | 2 +- src/utils/table.rs | 36 ++++++++++++ 7 files changed, 162 insertions(+), 56 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index afe2adf..9f4b7fd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,7 +3,6 @@ use crate::i18n::{tr, tr_s}; use crate::tr_with; use crate::utils::print_err; use clap::{Arg, Command, Subcommand}; -use colored::Colorize; use rusqlite::Connection; /// Costruisce la CLI localizzata usando le stringhe giΓ  caricate in memoria. @@ -39,13 +38,30 @@ pub fn build_cli() -> Command { .help(tr_s("help_lang")), ) .subcommand( - Command::new("list").about(tr_s("list_about")).arg( - Arg::new("short") - .long("short") - .help(tr_s("help.list.short")) - .action(clap::ArgAction::SetTrue) - .num_args(0), - ), + Command::new("list") + .about(tr_s("list_about")) + .arg( + Arg::new("short") + .long("short") + .help(tr_s("help.list.short")) + .action(clap::ArgAction::SetTrue) + .num_args(0), + ) + .arg( + Arg::new("id") + .long("id") + .help(tr_s("help.list.id")) // es: "Specify the record ID to show" + .value_name("ID") + .num_args(1) + .value_parser(clap::value_parser!(i32)), + ) + .arg( + Arg::new("details") + .long("details") + .help(tr_s("help.list.details")) // es: "Show all fields of the specified record (requires --id)" + .action(clap::ArgAction::SetTrue) + .num_args(0), + ), ) .subcommand( Command::new("config") @@ -172,12 +188,11 @@ pub fn run_cli( matches: &clap::ArgMatches, conn: &mut Connection, ) -> Result<(), Box> { - if let Some(("list", sub_matches)) = matches.subcommand() { - let short = sub_matches.get_flag("short"); - - handle_list(conn, short).unwrap_or_else(|e| { - eprintln!("{} {}", "Error listing books:".red(), e); - }); + if let Some(matches) = matches.subcommand_matches("list") { + let short = matches.get_flag("short"); + let id = matches.get_one::("id").copied(); + let details = matches.get_flag("details"); + handle_list(conn, short, id, details)?; Ok(()) } else if let Some(("config", sub_m)) = matches.subcommand() { let init = sub_m.get_flag("init"); diff --git a/src/commands/list.rs b/src/commands/list.rs index f49e2ee..ad0536f 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -1,8 +1,9 @@ use crate::book::{Book, BookFull, BookShort}; use crate::i18n::tr; -use crate::utils::build_table; +use crate::utils::{build_table, build_vertical_table, print_err}; use chrono::{DateTime, NaiveDateTime, Utc}; -use rusqlite::Connection; +use rusqlite::types::ToSql; +use rusqlite::{Connection, Row}; use std::error::Error; fn parse_added_at(s: &str) -> Option> { @@ -18,54 +19,102 @@ fn parse_added_at(s: &str) -> Option> { None } +// Helper to map a rusqlite::Row into a Book instance. +fn row_to_book(row: &Row) -> rusqlite::Result { + let added_at_str: Option = row.get("added_at")?; + let parsed_added_at = added_at_str.as_deref().and_then(parse_added_at); + + Ok(Book { + id: row.get("id")?, + title: row.get("title")?, + author: row.get("author")?, + editor: row.get("editor")?, + year: row.get("year")?, + isbn: row.get("isbn")?, + language: row.get("language")?, + pages: row.get("pages")?, + genre: row.get("genre")?, + summary: row.get("summary")?, + room: row.get("room")?, + shelf: row.get("shelf")?, + row: row.get("row")?, + position: row.get("position")?, + added_at: parsed_added_at, + }) +} + /// Handle the `list` subcommand. /// /// Lists all books from the database using localized tabular output. /// Supports the `--short` flag for compact view. -pub fn handle_list(conn: &Connection, short: bool) -> Result<(), Box> { - let mut stmt = conn.prepare( - "SELECT id, title, author, editor, year, isbn, language, pages, genre, summary, \ - room, shelf, row, position, added_at FROM books ORDER BY id;", - )?; +pub fn handle_list( + conn: &Connection, + _short: bool, + id: Option, + _details: bool, +) -> Result<(), Box> { + // If user asked for details without specifying an id, show a localized + // error message and do not display the list. + if _details && id.is_none() { + println!(); + print_err(&tr("list.error.details_requires_id")); + return Ok(()); + } - let rows = stmt.query_map([], |row| { - let added_at_str: Option = row.get("added_at")?; - let parsed_added_at = added_at_str.as_ref().and_then(|s| parse_added_at(s)); + // Build base query and optionally filter by id if provided + let base_query = "SELECT id, title, author, editor, year, isbn, language, pages, genre, summary, room, shelf, row, position, added_at FROM books"; + let query = if id.is_some() { + format!("{} WHERE id = ?1 ORDER BY id;", base_query) + } else { + format!("{} ORDER BY id;", base_query) + }; - Ok(Book { - id: row.get("id")?, - title: row.get("title")?, - author: row.get("author")?, - editor: row.get("editor")?, - year: row.get("year")?, - isbn: row.get("isbn")?, - language: row.get("language")?, - pages: row.get("pages")?, - genre: row.get("genre")?, - summary: row.get("summary")?, - room: row.get("room")?, - shelf: row.get("shelf")?, - row: row.get("row")?, - position: row.get("position")?, - added_at: parsed_added_at, - }) - })?; + let mut stmt = conn.prepare(&query)?; + let mut books: Vec = Vec::new(); - let books: Vec = rows.filter_map(|r| r.ok()).collect(); + // Build owned params: store boxed ToSql trait objects so ownership is + // guaranteed and we can build a slice of `&dyn ToSql` for the query. + let mut params_owned: Vec> = Vec::new(); + if let Some(v) = id { + params_owned.push(Box::new(v)); + } + // Create a slice of references to pass to rusqlite + let params_refs: Vec<&dyn ToSql> = params_owned + .iter() + .map(|b| b.as_ref() as &dyn ToSql) + .collect(); + + let mapped = stmt.query_map(params_refs.as_slice(), row_to_book)?; + for r in mapped { + books.push(r?); + } if books.is_empty() { - println!("\nπŸ“š {}", tr("list.no_books_found")); + if let Some(book_id) = id { + println!("⚠️ No book found with ID {book_id}"); + } else { + println!("\nπŸ“š {}", tr("list.no_books_found")); + } return Ok(()); } - println!("\n{}\n", tr("app.library.info")); - - let table = if short { - build_table(books.iter().map(BookShort)) + // If an ID was requested, show detailed vertical view for that specific record. + if id.is_some() { + let book = &books[0]; + println!("\nπŸ“– {} {:?}\n", tr("list.book_details_for_id"), book.id); + build_vertical_table(book); } else { - build_table(books.iter().map(BookFull)) - }; + // Otherwise show the list (short or full) + println!("\n{}\n", tr("app.library.info")); + + let table = if _short { + build_table(books.iter().map(BookShort)) + } else { + build_table(books.iter().map(BookFull)) + }; + + println!("{}", table); + } - println!("{table}"); Ok(()) } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d3b5723..0590c58 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -107,5 +107,8 @@ "import.summary.failed": "Skipped {count} invalid records in '{file}'", "import.error.json_invalid": "Invalid JSON structure in '{file}': {error}", "import.summary.ok_json": "Imported {count} books from JSON file '{file}'", - "import.error.unexpected": "Unexpected error during import: {error}" + "import.error.unexpected": "Unexpected error during import: {error}", + "help.list.details": "Show all fields of the specified record (requires --id)", + "list.error.details_requires_id": "The --details flag can only be used together with --id .", + "help.list.id": "Specify the record ID to show" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 003c0f0..22dfe7a 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -107,5 +107,8 @@ "import.summary.failed": "Saltati {count} record non validi in '{file}'", "import.error.json_invalid": "Struttura JSON non valida in '{file}': {error}", "import.summary.ok_json": "Importati {count} libri dal file JSON '{file}'", - "import.error.unexpected": "Errore imprevisto durante l'importazione: {error}" + "import.error.unexpected": "Errore imprevisto durante l'importazione: {error}", + "help.list.id": "Specifica l'ID del record da visualizzare", + "help.list.details": "Mostra tutti i campi del record specificato (richiede --id)", + "list.error.details_requires_id": "Il flag --details puΓ² essere usato solo insieme a --id ." } diff --git a/src/lib.rs b/src/lib.rs index adce4b0..c003dc6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -73,7 +73,7 @@ mod tests { // Chiama la funzione handle_list per esercitare la logica di mapping e formattazione // default view in tests: non-short (full) - handle_list(&conn, false)?; + handle_list(&conn, false, None, false)?; Ok(()) } @@ -109,7 +109,7 @@ mod tests { )?; // Chiama la funzione handle_list per verificare la vista corta (non deve panicare) - handle_list(&conn, true)?; + handle_list(&conn, true, None, false)?; Ok(()) } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index ebeb78b..3ed1fb1 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -6,7 +6,7 @@ // ===================================================== pub mod table; -pub use table::build_table; +pub use table::{build_table, build_vertical_table}; use crate::i18n::tr_with; use chrono::Local; diff --git a/src/utils/table.rs b/src/utils/table.rs index a1cde66..5c77a03 100644 --- a/src/utils/table.rs +++ b/src/utils/table.rs @@ -3,6 +3,8 @@ //! Provides a unified interface for rendering tabular data using the `tabled` crate, //! ensuring consistent visual style and alignment across commands. +use serde::Serialize; +use serde_json::Value; use tabled::settings::{Alignment, Modify, Style, object::Rows}; use tabled::{Table, Tabled}; @@ -35,3 +37,37 @@ where .with(Modify::new(Rows::new(1..)).with(Alignment::left())) .to_string() } + +pub fn build_vertical_table(record: &T) { + // Converte la struct in una mappa JSON dinamica + let value = serde_json::to_value(record).expect("Failed to serialize record"); + + if let Value::Object(map) = value { + // Costruiamo un vettore di tuple (campo, valore) + let mut rows: Vec<(String, String)> = map + .into_iter() + .map(|(key, val)| { + let val_str = match val { + Value::Null => String::from("β€”"), + Value::String(s) => s, + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + other => other.to_string(), + }; + (key, val_str) + }) + .collect(); + + // Ordina alfabeticamente per nome del campo + rows.sort_by(|a, b| a.0.cmp(&b.0)); + + // Crea tabella verticale + let style = Style::rounded(); + // Build an owned string representation to avoid borrowing temporaries + let table_str = Table::new(rows).with(style).to_string(); + + println!("{}", table_str); + } else { + println!("⚠️ Unable to display record: not an object"); + } +} From fa444aeb4147f9cb17ca9c7ec3b098ab639f3fe5 Mon Sep 17 00:00:00 2001 From: Alessandro Maestri Date: Fri, 17 Oct 2025 00:52:25 +0200 Subject: [PATCH 5/5] chore(release): bump version to 0.3.0 ## [0.3.0] - 2025-10-16 ### Added - Introduced the `tabled` crate (`v0.20.0`) for consistent tabular output. - New `--short` flag for `librius list` showing only key columns (ID, Title, Author, Editor, Year). - Added utility `build_table()` in `utils/table.rs` to render tables with unified style and alignment. - Added `--delimiter` / `-d` option for the `import` command to specify custom CSV field separators (default: `,`). ### Changed - Refactored the `list` command to use `BookFull` and `BookShort` wrappers implementing `Tabled`. - Standardized the internal module structure: - Each main directory (`commands`, `db`, `config`, `i18n`, `models`, `utils`) now includes a `mod.rs`. - Unified import/export logic in `lib.rs` for cleaner module access. - Improved code readability and CLI consistency. --- README.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 73bad77..b6620c2 100644 --- a/README.md +++ b/README.md @@ -24,20 +24,24 @@ and import/export support. **πŸ†• Modern tabular output** -- Replaced the old println! list format with the tabled crate. +- Replaced the old `println!` list format with the [`tabled`](https://crates.io/crates/tabled) crate. - Tables now feature aligned, styled columns for improved readability. -- Added a --short flag for compact view (ID, Title, Author, Editor, Year). +- Added a `--short` flag for compact view (`ID`, `Title`, `Author`, `Editor`, `Year`). +- Added `--id` and `--details` options to view a single record: + - `--id ` shows a specific book by its ID. + - `--details` displays all fields of the selected record in a vertical table. **🧩 Modular architecture** -- Standardized all modules using the mod.rs structure. -- Each subsystem (commands, models, utils, db, config, i18n) now has a clean namespace. -- Simplified imports using pub use re-exports in lib.rs. +- Standardized all modules using the `mod.rs` structure. +- Each subsystem (`commands`, `models`, `utils`, `db`, `config`, `i18n`) now has a clean, isolated namespace. +- Simplified imports using `pub use` re-exports in `lib.rs`. **🧱 Utility improvements** -- Added a reusable build_table() helper in utils/table.rs to ensure consistent table rendering. -- Introduced BookFull and BookShort structs implementing Tabled for full and compact listings. +- Added a reusable `build_table()` helper in `utils/table.rs` for consistent table rendering. +- Introduced a dynamic `build_vertical_table()` helper for full record details using `serde_json` + `tabled`. +- Implemented `BookFull` and `BookShort` structs implementing `Tabled` for both full and compact listings. ---