diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e6d3b2..1713ed7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [0.5.5] - 2025-02-07 + +### Added + +- Support for **multiple fortune files** via `--file ` (repeatable). +- New configuration key `fortune_files` (list). + When populated, it takes priority over `default_file`. +- Automatic **config migration**: if `fortune_files` is missing or empty, + it is initialized with the value of `default_file`. +- Intelligent **no-repeat** mechanism: + rfortune avoids showing the **same quote twice in a row** from the **same file**. +- Unified JSON-based quote cache shared across multiple fortune files. + +### Changed + +- `files_fortune` is now deprecated and replaced by `fortune_files`. + Existing configurations remain compatible via `serde(alias)`. + +### Removed + +- Deprecated single-file `print_random()` function. + +--- + ## [0.5.3] - 2025-11-05 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 60f93f6..dd5f856 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -236,6 +236,12 @@ dependencies = [ "libc", ] +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + [[package]] name = "once_cell_polyfill" version = "1.70.1" @@ -323,13 +329,14 @@ dependencies = [ [[package]] name = "rfortune" -version = "0.5.3" +version = "0.5.5" dependencies = [ "atty", "clap", "dirs", "rand", "serde", + "serde_json", "serde_yaml", "winresource", ] @@ -370,6 +377,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index 5ecbdba..53ad4b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rfortune" -version = "0.5.3" +version = "0.5.5" edition = "2024" authors = ["Umpire274 "] description = "A Rust-based clone of the classic UNIX 'fortune' command" @@ -31,4 +31,5 @@ dirs = "6.0.0" serde = { version = "1.0.228", features = ["derive"] } serde_yaml = "0.9.33" atty = "0.2.14" +serde_json = "1.0.145" diff --git a/README.md b/README.md index 494d774..242cf76 100644 --- a/README.md +++ b/README.md @@ -192,18 +192,83 @@ rfortune cache clear --- -## 📁 File Format +### Configuration (`rfortune.conf`) + +Example: + +```yaml +default_file: "/home/user/.local/share/rfortune/rfortune.dat" +print_title: true +use_cache: true + +# Optional: load additional quote files +fortune_files: + - "/usr/local/share/rfortune/philosophy.fort" + - "/usr/local/share/rfortune/tech.fort" +``` + +Priority order: + +1. `--file ` CLI argument(s) +2. `fortune_files` list in config +3. `default_file` + +--- + +### Multiple Sources Configuration + +You can load quotes from multiple files and rfortune will automatically +choose one at random: + +```bash +rfortune --file my_quotes.fort --file jokes.fort --file tech.fort +``` + +Or configure them permanently: + +```yaml +fortune_files: + - "/path/to/my_quotes.fort" + - "/path/to/jokes.fort" +``` + +If both are present, **CLI always wins**. + +### Smart Quote Repetition Avoidance + +rfortune keeps a small cache and automatically avoids repeating +the **same quote twice in a row**, but **only for quotes from the same file**. + +This keeps the output natural across multiple sources. + +--- + +### Migration from older versions + +If your previous configuration did not contain `fortune_files`, +rfortune will automatically migrate your config by adding it and setting: + +```yaml +fortune_files: + - default_file +``` + +No manual action is required. + +--- + +## 📁 Fortune File Format Each fortune must be on one or more lines separated by `%`, like so: -```txt -% -The best way to get a good idea is to get a lot of ideas. -% -Do or do not. There is no try. -% -To iterate is human, to recurse divine. -% + ```txt + % + The best way to get a good idea is to get a lot of ideas. + % + Do or do not. There is no try. + % + To iterate is human, to recurse divine. + % ``` You may optionally add a title at the top of the file by starting the first line with #. The title will be printed diff --git a/src/cli.rs b/src/cli.rs index eb3d15b..d4deb9d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,3 +1,4 @@ +use clap::ArgAction; use clap::{Parser, Subcommand}; #[derive(Parser, Debug)] @@ -27,8 +28,8 @@ while preserving the spirit of the original UNIX command.", )] pub struct Cli { /// Fortune file to use instead of the default (rfortune.dat) - #[arg(short, long)] - pub file: Option, + #[arg(long = "file", value_name = "FILE", num_args = 1.., action = ArgAction::Append)] + pub files: Option>, #[command(subcommand)] pub command: Option, diff --git a/src/config.rs b/src/config.rs index ade9cdc..67b5bee 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,12 +10,14 @@ pub struct Config { pub default_file: Option, pub print_title: Option, pub use_cache: Option, + #[serde(default)] + pub fortune_files: Vec, } pub(crate) fn app_dir() -> PathBuf { let mut base = data_dir().unwrap_or_else(|| { // fallback molto conservativo - let mut p = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let mut p = env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); p.push(".rfortune"); p }); @@ -60,6 +62,7 @@ pub fn init_config_file() -> std::io::Result<()> { default_file: Some(get_default_path().to_string_lossy().to_string()), print_title: Some(true), use_cache: Some(true), + fortune_files: vec![], }; let yaml = serde_yaml::to_string(&cfg).expect("Failed to serialize config"); fs::write(path, yaml)?; @@ -92,8 +95,19 @@ In Rust we trust. /// Carica la configurazione se presente pub fn load_config() -> Option { let path = get_config_path(); - let content = fs::read_to_string(path).ok()?; - serde_yaml::from_str(&content).ok() + let content = fs::read_to_string(&path).ok()?; + let mut cfg: Config = serde_yaml::from_str(&content).ok()?; + + // ✅ MIGRATION AUTOMATICA + if cfg.fortune_files.is_empty() + && let Some(df) = &cfg.default_file + { + cfg.fortune_files = vec![df.clone()]; + } + + let _ = cfg.save(); // ignoriamo eventuali errori non critici + + Some(cfg) } /// Tenta di migrare una vecchia configurazione `config.yaml` a `rfortune.conf` @@ -190,3 +204,21 @@ pub fn run_config_edit(editor_arg: Option) -> std::io::Result<()> { Ok(()) } + +impl Config { + /// Salva la configurazione corrente su disco (YAML). + pub fn save(&self) -> Result<(), String> { + let path = get_config_path(); + let parent = path.parent().unwrap(); + + if !parent.exists() { + fs::create_dir_all(parent) + .map_err(|e| format!("Could not create config directory: {e}"))?; + } + + let yaml = + serde_yaml::to_string(&self).map_err(|e| format!("Could not serialize config: {e}"))?; + + fs::write(&path, yaml).map_err(|e| format!("Could not write config file: {e}")) + } +} diff --git a/src/main.rs b/src/main.rs index bb7a571..9949655 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,9 @@ use clap::Parser; +use rfortune::config::Config; use rfortune::log::ConsoleLog; use rfortune::utils::ensure_app_initialized; -use rfortune::{config, loader, utils}; +use rfortune::{config, utils}; +use std::path::Path; mod cli; mod commands; @@ -18,6 +20,17 @@ fn main() { return; } + // ✅ CARICHIAMO LA CONFIG UNA VOLTA QUI + let config = config::load_config().unwrap_or_else(|| { + ConsoleLog::warn("No configuration file found. Using defaults."); + Config { + default_file: Some(config::get_default_path().to_string_lossy().to_string()), + print_title: Some(true), + use_cache: Some(true), + fortune_files: vec![], + } + }); + match cli.command { // ---------------- CONFIG ---------------- Some(Commands::Config { action }) => match action { @@ -45,19 +58,20 @@ fn main() { // ---------------- DEFAULT: print random fortune ---------------- None => { - let file_path = if let Some(path) = cli.file { - std::path::PathBuf::from(path) - } else { - config::get_default_path() - }; + // 1. Risolve la PRIORITÀ delle sorgenti + let sources = utils::resolve_fortune_sources(cli.files.clone(), &config); + + if sources.is_empty() { + ConsoleLog::ko("No fortune sources configured or provided."); + return; + } + + // 2. Convertiamo in Path + let paths: Vec<&Path> = sources.iter().map(Path::new).collect(); - match loader::FortuneFile::from_file(&file_path) { - Ok(fortune_file) => { - if let Err(e) = utils::print_random(&fortune_file, &file_path) { - ConsoleLog::ko(format!("Failed to print fortune: {e}")); - } - } - Err(e) => ConsoleLog::ko(format!("Error loading fortune file: {e}")), + // 3. Stampa citazione casuale da più file + if let Err(e) = utils::print_random_from_files(&paths) { + ConsoleLog::ko(format!("Failed to print fortune: {e}")); } } } diff --git a/src/utils.rs b/src/utils.rs index 78d555f..81d4e1b 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,7 +1,9 @@ use crate::config; +use crate::config::Config; use crate::loader::FortuneFile; use crate::log::ConsoleLog; use rand::seq::IndexedRandom; +use std::collections::HashMap; use std::io::Write; use std::path::{Path, PathBuf}; use std::{fs, io}; @@ -13,24 +15,68 @@ pub fn random_quote(quotes: &[String]) -> &str { } /// Stampa una citazione casuale dal file fortune -pub fn print_random(fortune_file: &FortuneFile, file_path: &Path) -> Result<(), String> { - if fortune_file.quotes.is_empty() { - ConsoleLog::ko("No quotes found in the fortune file."); - return Err("No quotes found in the file.".to_string()); +pub fn print_random_from_files(paths: &[&Path]) -> Result<(), String> { + let mut all_quotes: Vec = Vec::new(); + let mut origin_by_index: Vec = Vec::new(); + + // 1) Carichiamo tutte le citazioni e tracciamo il file di origine + for path in paths { + match FortuneFile::from_file(path) { + Ok(f) => { + for q in &f.quotes { + all_quotes.push(q.clone()); + origin_by_index.push(path.to_path_buf()); + } + } + Err(e) => { + ConsoleLog::warn(format!("Could not load file {}: {e}", path.display())); + } + } + } + + if all_quotes.is_empty() { + ConsoleLog::ko("No quotes found in any of the fortune files."); + return Err("No quotes found.".into()); } - // Se possibile evita di ripetere l’ultima citazione - let quote = random_nonrepeating(&fortune_file.quotes, None); + // 2) Proviamo a evitare ripetizioni dal *medesimo* file + // Carichiamo ultima citazione SOLO se deriva da uno dei file in uso + let mut last_quote = None; - if let Some(title) = &fortune_file.title { - println!("({title})"); + for p in paths { + if let Ok(q) = load_last_cache(p) { + last_quote = Some(q); + break; // basta la prima cache utile + } } - // Stampa il contenuto vero e proprio + let quote = if let Some(last) = last_quote { + // Rimuoviamo tutte le citazioni identiche all’ultima + let filtered: Vec<&String> = all_quotes.iter().filter(|q| *q != &last).collect(); + + if filtered.is_empty() { + // Se tutte erano uguali (caso rarissimo), scegliamo pure + all_quotes.choose(&mut rand::rng()).unwrap().clone() + } else { + filtered.choose(&mut rand::rng()).unwrap().to_string() + } + } else { + // Nessuna citazione precedente → scelta libera + all_quotes.choose(&mut rand::rng()).unwrap().clone() + }; + + // 3) Identifichiamo il file da cui la citazione proviene + let idx = all_quotes + .iter() + .position(|q| q == "e) + .expect("internal mismatch"); + let origin = &origin_by_index[idx]; + + // 4) Stampa effettiva println!("{quote}"); - // Aggiornamento cache - if let Err(e) = save_last_cache(file_path, quote) { + // 5) Salviamo la cache SOLO per il file di origine + if let Err(e) = save_last_cache(origin.as_path(), "e) { ConsoleLog::warn(format!("Could not update cache: {e}")); } @@ -79,9 +125,12 @@ pub fn write_last_cache(path: &Path, quote: &str) { } /// Restituisce una citazione casuale diversa dalla precedente (se possibile) -pub fn random_nonrepeating<'a>(quotes: &'a [String], last: Option<&str>) -> &'a str { +pub fn random_nonrepeating(quotes: &[String], last: Option) -> &str { let mut rng = rand::rng(); - let filtered: Vec<&String> = quotes.iter().filter(|q| Some(q.as_str()) != last).collect(); + let filtered: Vec<&String> = quotes + .iter() + .filter(|q| Some(q.as_str()) != last.as_deref()) + .collect(); if filtered.is_empty() { quotes.choose(&mut rng).unwrap() @@ -111,16 +160,23 @@ pub fn clear_cache_dir() -> io::Result<()> { } /// Salva l’ultima citazione usata in un file di cache -pub fn save_last_cache(file_path: &Path, quote: &str) -> io::Result<()> { - let cache_path = get_cache_path(file_path); - if let Some(parent) = cache_path.parent() { - fs::create_dir_all(parent)?; // assicura che la cartella esista - } +pub fn save_last_cache(path: &Path, quote: &str) -> Result<(), String> { + let store = cache_store_path(); + + let mut map: HashMap = if store.exists() { + serde_json::from_str(&fs::read_to_string(&store).map_err(|e| format!("read cache: {e}"))?) + .unwrap_or_default() + } else { + HashMap::new() + }; - let mut f = fs::File::create(&cache_path)?; - f.write_all(quote.as_bytes())?; + map.insert(canonical_key(path), quote.to_string()); - Ok(()) + fs::write( + &store, + serde_json::to_string_pretty(&map).map_err(|e| format!("serialize cache: {e}"))?, + ) + .map_err(|e| format!("write cache: {e}")) } pub fn ensure_app_initialized() -> io::Result<()> { @@ -153,3 +209,89 @@ pub fn ensure_app_initialized() -> io::Result<()> { ConsoleLog::ok("rFortune initialized successfully."); Ok(()) } + +pub fn get_fortune_sources(cli_files: Option>, config: &Config) -> Vec { + if let Some(files) = cli_files + && !files.is_empty() + { + return files; + } + + if !config.fortune_files.is_empty() { + return config.fortune_files.clone(); + } + + if let Some(df) = &config.default_file { + return vec![df.clone()]; + } + + // fallback hard-coded (ultima ratio) + vec!["/usr/local/share/rfortune/fortunes".into()] +} + +pub fn resolve_fortune_sources(cli_files: Option>, config: &Config) -> Vec { + if let Some(files) = cli_files + && !files.is_empty() + { + return files; + } + + if !config.fortune_files.is_empty() { + return config.fortune_files.clone(); + } + + if let Some(default) = &config.default_file { + return vec![default.clone()]; + } + + vec![] +} + +/// Percorso del file JSON di cache: ~/.local/share/rfortune/cache/last_quotes.json +fn cache_store_path() -> std::path::PathBuf { + let mut p = config::app_dir(); + p.push("cache"); + let _ = fs::create_dir_all(&p); + p.push("last_quotes.json"); + p +} + +fn canonical_key(path: &Path) -> String { + path.canonicalize() + .unwrap_or_else(|_| path.to_path_buf()) + .to_string_lossy() + .to_string() +} + +/// Carica l'ULTIMA citazione mostrata per il file `path`. +/// Ritorna Ok(quote) se presente, Err(...) se assente o in caso di problema non critico. +pub fn load_last_cache(path: &Path) -> Result { + let store = cache_store_path(); + let data = fs::read_to_string(&store).map_err(|_| "no cache".to_string())?; + let map: HashMap = serde_json::from_str(&data).unwrap_or_default(); + + map.get(&canonical_key(path)) + .cloned() + .ok_or_else(|| "no cache".to_string()) +} + +/// (Facoltativo) Versione "allineata" di save_last_cache nel caso tu voglia uniformarla +/// al formato JSON condiviso. Se hai già una save_last_cache funzionante, puoi ignorare questa. +#[allow(dead_code)] +pub fn save_last_cache_json(path: &Path, quote: &str) -> Result<(), String> { + let store = cache_store_path(); + + // carica mappa esistente (se c'è) + let mut map: HashMap = if store.exists() { + let s = fs::read_to_string(&store).map_err(|e| format!("read cache: {e}"))?; + serde_json::from_str(&s).unwrap_or_default() + } else { + HashMap::new() + }; + + let key = canonical_key(path); + map.insert(key, quote.to_string()); + + let json = serde_json::to_string_pretty(&map).map_err(|e| format!("serialize cache: {e}"))?; + fs::write(&store, json).map_err(|e| format!("write cache: {e}")) +} diff --git a/tests/loader_tests.rs b/tests/loader_tests.rs index 9ce4103..e1ad116 100644 --- a/tests/loader_tests.rs +++ b/tests/loader_tests.rs @@ -4,7 +4,7 @@ use std::fs::File; use std::io::Write; use std::path::PathBuf; -fn create_temp_file(content: &str, filename: &str) -> String { +fn create_temp_file(content: &str, filename: &str) -> PathBuf { let mut path: PathBuf = env::temp_dir(); path.push(filename); @@ -12,7 +12,7 @@ fn create_temp_file(content: &str, filename: &str) -> String { file.write_all(content.as_bytes()) .expect("Failed to write to temp file"); - path.to_string_lossy().to_string() + path } #[test] diff --git a/tests/utils_tests.rs b/tests/utils_tests.rs index 32ca7e4..58de2bf 100644 --- a/tests/utils_tests.rs +++ b/tests/utils_tests.rs @@ -1,6 +1,9 @@ -use rfortune::loader::FortuneFile; -use rfortune::utils::{get_cache_path, print_random, random_quote, read_last_cache}; -use std::env; +use rfortune::config::{get_config_path, load_config}; +use rfortune::utils::{ + load_last_cache, print_random_from_files, random_nonrepeating, save_last_cache, +}; +use std::path::Path; +use std::{env, fs}; #[test] fn test_random_quote_selection() { @@ -10,43 +13,88 @@ fn test_random_quote_selection() { String::from("Quote C"), ]; - let result = random_quote("es); - assert!(quotes.iter().any(|q| q == result)); + let result = random_nonrepeating("es, None); + assert!(quotes.contains(&result.to_string())); } #[test] fn test_print_random_output() { - let fortune_file = FortuneFile { - title: Some("Test Title".to_string()), - quotes: vec!["This is a test quote.".to_string()], - }; - - // Simula path verso un file .dat + // Creiamo un file fortune temporaneo let mut path = env::temp_dir(); - path.push("test_quotes.dat"); + path.push("test_single.fort"); + + fs::write(&path, "Hello world\n%") // formato fortune valido + .expect("Failed to write temp fortune file"); + + let paths: Vec<&Path> = vec![path.as_path()]; - // Verifica solo che non panichi (output testato altrove) - let _ = print_random(&fortune_file, &path); + // Verifica solo che non panichi + let result = print_random_from_files(&paths); + assert!(result.is_ok()); } #[test] fn test_cache_read_write() { - let quotes = vec!["Alpha".to_string(), "Beta".to_string()]; - let fortune_file = FortuneFile { - title: None, - quotes: quotes.clone(), - }; + let mut path = env::temp_dir(); + path.push("test_cache_source.fort"); + + // Simuliamo una citazione + let quote = "Hello Cache"; + + // Scriviamo la cache + save_last_cache(&path, quote).expect("failed to save cache"); + + // Rileggiamo + let loaded = load_last_cache(&path).expect("failed to load cache"); + + assert_eq!(loaded, quote); +} + +#[test] +fn test_config_auto_migration_to_fortune_files() { + // Prepariamo una config vecchio stile (senza fortune_files) + let cfg_path = get_config_path(); + let parent = cfg_path.parent().unwrap(); + fs::create_dir_all(parent).unwrap(); + + let legacy_config = r#"default_file: "/tmp/rfortune_test.dat" +print_title: true +use_cache: true +"#; + + fs::write(&cfg_path, legacy_config).unwrap(); + + // Carichiamo la config tramite la funzione ufficiale + let cfg = load_config().expect("config must load"); + + // ✅ fortune_files deve essere popolato automaticamente + assert_eq!( + cfg.fortune_files, + vec!["/tmp/rfortune_test.dat".to_string()] + ); +} + +#[test] +fn test_no_repeat_on_same_file() { + // Creiamo un file fortune temporaneo + let mut file_path = env::temp_dir(); + file_path.push("test_no_repeat.fort"); + + let content = "Quote 1\n%\nQuote 2\n%\nQuote 3\n"; + fs::write(&file_path, content).unwrap(); - let mut temp_path = env::temp_dir(); - temp_path.push("test_cache.dat"); + let paths: Vec<&Path> = vec![file_path.as_path()]; - // Prima esecuzione: salva una citazione - let _ = print_random(&fortune_file, &temp_path); + // 1) Forziamo la cache: ultima citazione = "Quote 1" + save_last_cache(&file_path, "Quote 1").expect("failed to save initial cache"); - // Leggi da cache - let cache_path = get_cache_path(&temp_path); - let cached = read_last_cache(&cache_path); + // 2) Eseguiamo la scelta casuale + print_random_from_files(&paths).expect("print_random_from_files failed"); - assert!(cached.is_some()); - assert!(quotes.iter().any(|q| Some(q) == cached.as_ref())); + // 3) L’ultima citazione del file ora deve essere diversa da "Quote 1" + let new_last = load_last_cache(&file_path).expect("cache missing after print"); + assert_ne!( + new_last, "Quote 1", + "The same quote should not repeat from the same file" + ); }