Skip to main content

cadmus_core/dictionary/monolingual/
service.rs

1//! High-level service for monolingual dictionary management.
2//!
3//! [`MonolingualDictionaryService`] is the single public entry point for all
4//! monolingual dictionary operations: querying the remote catalogue, listing
5//! installed dictionaries, and installing a new one.
6
7use super::client::MonolingualClient;
8use super::db::Db;
9use super::errors::MonolingualError;
10use super::metadata::{DictionariesResponse, DictionaryEntry, download_url, download_url_no_etym};
11use crate::db::Database;
12use std::collections::HashSet;
13use std::fs;
14use std::io::{self};
15use std::path::{Path, PathBuf};
16use std::sync::{Arc, Mutex, MutexGuard};
17use zip::ZipArchive;
18
19/// Subdirectory inside the dictionaries root where reader-dict downloads live.
20const READER_DICT_SUBDIR: &str = "reader-dict";
21
22/// Provides monolingual dictionary management: querying available dictionaries,
23/// listing installed ones, and downloading + extracting new ones.
24///
25/// All network metadata is cached in the application SQLite database.
26/// Downloaded dictionaries are extracted to
27/// `<dict_dir>/reader-dict/<lang>/`.
28///
29/// The service is cheaply cloneable (`Arc`-backed). All clones share the same
30/// `pending_installs` set, so concurrent-download guards work correctly across
31/// the UI thread (which holds the original) and background threads (which hold
32/// clones).
33#[derive(Clone, Debug)]
34pub struct MonolingualDictionaryService {
35    client: MonolingualClient,
36    db: Db,
37    dict_dir: PathBuf,
38    pending_installs: Arc<Mutex<HashSet<String>>>,
39}
40
41impl MonolingualDictionaryService {
42    /// Creates a new service.
43    ///
44    /// # Arguments
45    ///
46    /// * `database` - Application SQLite database used for metadata caching.
47    /// * `dict_dir` - Root directory where dictionaries are stored. Downloads
48    ///   are placed in `<dict_dir>/reader-dict/<lang>/`.
49    ///
50    /// # Errors
51    ///
52    /// Returns an error if the HTTP client cannot be built.
53    #[cfg_attr(feature = "tracing", tracing::instrument(skip(database), fields(dict_dir = %dict_dir.display())))]
54    pub fn new(database: &Database, dict_dir: &Path) -> Result<Self, MonolingualError> {
55        let client = MonolingualClient::new()?;
56        let db = Db::new(database);
57        Ok(Self {
58            client,
59            db,
60            dict_dir: dict_dir.to_path_buf(),
61            pending_installs: Arc::new(Mutex::new(HashSet::new())),
62        })
63    }
64
65    /// Returns all dictionaries available for download from the remote API.
66    ///
67    /// Metadata is served from the SQLite cache when available; otherwise a
68    /// network request is made and the result is cached.
69    ///
70    /// # Errors
71    ///
72    /// Returns an error if the metadata cannot be loaded from cache or network.
73    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
74    pub fn get_available_dictionaries(
75        &self,
76    ) -> Result<Vec<(String, DictionaryEntry)>, MonolingualError> {
77        let metadata = self.load_metadata()?;
78
79        let monolingual = metadata
80            .into_iter()
81            .filter_map(|(lang, mut targets)| targets.remove(&lang).map(|entry| (lang, entry)))
82            .collect();
83
84        Ok(monolingual)
85    }
86
87    /// Returns the cached metadata entry for a single language.
88    ///
89    /// This does not make any network requests. Returns `None` if no entry for
90    /// `lang` has been cached yet.
91    ///
92    /// # Errors
93    ///
94    /// Returns an error if the database read fails.
95    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(lang = %lang)))]
96    pub fn get_entry_for_lang(
97        &self,
98        lang: &str,
99    ) -> Result<Option<DictionaryEntry>, MonolingualError> {
100        Ok(self.db.get_entry(lang)?)
101    }
102
103    /// Returns the language codes of all locally installed dictionaries.
104    ///
105    /// A dictionary is considered installed when its language directory exists
106    /// inside `<dict_dir>/reader-dict/` and contains at least one `.index`
107    /// file paired with a `.dict` or `.dict.dz` file.
108    ///
109    /// # Errors
110    ///
111    /// Returns an error if the directory cannot be read.
112    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
113    pub fn get_installed_dictionaries(&self) -> Result<Vec<String>, MonolingualError> {
114        let root = self.reader_dict_dir();
115
116        if !root.exists() {
117            return Ok(Vec::new());
118        }
119
120        let mut installed = Vec::new();
121
122        for entry in fs::read_dir(&root)? {
123            let entry = entry?;
124            let path = entry.path();
125
126            if !path.is_dir() {
127                continue;
128            }
129
130            if has_dict_pair(&path) {
131                if let Some(lang) = path.file_name().and_then(|n| n.to_str()) {
132                    installed.push(lang.to_string());
133                }
134            }
135        }
136
137        Ok(installed)
138    }
139
140    /// Returns `true` if a download is already in progress for `lang`.
141    ///
142    /// This can be used by callers to suppress duplicate install requests before
143    /// spawning a background thread.
144    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), ret(level=tracing::Level::TRACE)))]
145    pub fn is_installing(&self, lang: &str) -> bool {
146        #[cfg(feature = "tracing")]
147        let _span = tracing::info_span!("lock").entered();
148        self.pending_installs().contains(lang)
149    }
150
151    fn pending_installs(&self) -> MutexGuard<'_, HashSet<String>> {
152        self.pending_installs.lock().unwrap_or_else(|poisoned| {
153            tracing::warn!("Pending installs lock poisoned; continuing anyway");
154            poisoned.into_inner()
155        })
156    }
157
158    /// Reserves a language for installation before work moves to a background thread.
159    ///
160    /// Returns `false` when another install for the same language is already active.
161    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), ret(level=tracing::Level::TRACE)))]
162    pub(crate) fn try_begin_install(&self, lang: &str) -> bool {
163        #[cfg(feature = "tracing")]
164        let _span = tracing::info_span!("lock").entered();
165
166        let mut pending = self.pending_installs();
167
168        if pending.contains(lang) {
169            return false;
170        }
171
172        pending.insert(lang.to_string());
173        true
174    }
175
176    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
177    fn finish_install(&self, lang: &str) {
178        #[cfg(feature = "tracing")]
179        let _span = tracing::info_span!("lock").entered();
180        self.pending_installs().remove(lang);
181    }
182
183    /// Downloads and installs a dictionary for the given language.
184    ///
185    /// The archive is downloaded to a temporary file, then extracted into
186    /// `<dict_dir>/reader-dict/<lang>/` and the files are renamed to
187    /// `Reader-Dict-<lang>.index` and `Reader-Dict-<lang>.dict[.dz]`. Any
188    /// existing files in that directory are overwritten.
189    ///
190    /// Returns [`MonolingualError::InstallationInProgress`] immediately if a
191    /// download for the same language is already running. Callers that need to
192    /// update UI state before spawning a thread can reserve the language with
193    /// [`Self::try_begin_install`] and finish with [`Self::install_reserved_dictionary`].
194    ///
195    /// # Arguments
196    ///
197    /// * `entry` - Metadata entry for the dictionary to install. The language
198    ///   code and version are derived from this entry.
199    /// * `include_etymologies` - When `true`, the full archive (with
200    ///   etymologies) is downloaded; when `false`, the smaller no-etymology
201    ///   variant is used.
202    /// * `progress_callback` - Called after each downloaded chunk with
203    ///   `(bytes_downloaded_so_far, total_bytes)`.
204    ///
205    /// # Errors
206    ///
207    /// Returns an error if a download for the language is already in progress,
208    /// if the download fails, if the archive cannot be parsed, or if files
209    /// cannot be written to disk.
210    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, entry, progress_callback), fields(lang = %lang, include_etymologies)))]
211    pub fn install_dictionary<F>(
212        &self,
213        lang: &str,
214        entry: &DictionaryEntry,
215        include_etymologies: bool,
216        progress_callback: &mut F,
217    ) -> Result<(), MonolingualError>
218    where
219        F: FnMut(u64, u64),
220    {
221        if !self.try_begin_install(lang) {
222            return Err(MonolingualError::InstallationInProgress(lang.to_string()));
223        }
224
225        self.install_reserved_dictionary(lang, entry, include_etymologies, progress_callback)
226    }
227
228    /// Installs a dictionary after [`Self::try_begin_install`] reserves its language.
229    ///
230    /// This keeps UI state synchronous with background work by allowing callers to
231    /// mark a language as installing before spawning a thread.
232    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, entry, progress_callback), fields(lang = %lang, include_etymologies)))]
233    pub(crate) fn install_reserved_dictionary<F>(
234        &self,
235        lang: &str,
236        entry: &DictionaryEntry,
237        include_etymologies: bool,
238        progress_callback: &mut F,
239    ) -> Result<(), MonolingualError>
240    where
241        F: FnMut(u64, u64),
242    {
243        let result = self.do_install(lang, entry, include_etymologies, progress_callback);
244        self.finish_install(lang);
245
246        result
247    }
248
249    #[cfg_attr(
250        feature = "tracing",
251        tracing::instrument(skip(self, entry, progress_callback))
252    )]
253    fn do_install<F>(
254        &self,
255        lang: &str,
256        entry: &DictionaryEntry,
257        include_etymologies: bool,
258        progress_callback: &mut F,
259    ) -> Result<(), MonolingualError>
260    where
261        F: FnMut(u64, u64),
262    {
263        let url = if include_etymologies {
264            download_url(lang)
265        } else {
266            download_url_no_etym(lang)
267        };
268
269        tracing::info!(lang, url = %url, "Downloading dictionary");
270
271        let dest = self.lang_dir(lang);
272        fs::create_dir_all(&dest)?;
273
274        let temp_path = dest.join(".download.tmp");
275
276        self.client.download(&url, &temp_path, progress_callback)?;
277
278        tracing::debug!(lang, dest = %dest.display(), "Extracting dictionary archive");
279
280        let file = fs::File::open(&temp_path)?;
281        extract_zip_renamed(file, &dest, lang)?;
282
283        fs::remove_file(&temp_path)?;
284
285        if let Err(e) = self.db.record_install(lang, entry.updated.into()) {
286            tracing::warn!(lang, error = %e, "Failed to record dictionary install");
287        }
288
289        tracing::info!(lang, dest = %dest.display(), "Dictionary installed");
290
291        Ok(())
292    }
293
294    /// Removes the installed dictionary record for `lang` from the database.
295    ///
296    /// Logs a warning on failure rather than propagating the error, as this is
297    /// a best-effort cleanup step called from event handlers.
298    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
299    pub fn remove_installed(&self, lang: &str) {
300        if let Err(e) = self.db.remove_installed(lang) {
301            tracing::warn!(lang, error = %e, "Failed to remove installed dictionary record");
302        }
303    }
304
305    /// Returns `true` if a newer version of the dictionary for `lang` is
306    /// available on the server than the currently installed version.
307    ///
308    /// Returns `false` on any error to avoid surfacing spurious update badges.
309    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
310    pub fn is_update_available(&self, lang: &str) -> bool {
311        self.db.is_update_available(lang).unwrap_or(false)
312    }
313
314    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
315    fn load_metadata(&self) -> Result<DictionariesResponse, MonolingualError> {
316        if let Some(cached_at) = self.db.get_most_recent_cached_at()? {
317            match self.client.is_metadata_modified_since(cached_at) {
318                Ok(false) => {
319                    tracing::debug!("Cache is fresh (304), using cached metadata");
320                    if let Some(cached) = self.get_cached_metadata()? {
321                        return Ok(cached);
322                    }
323                }
324                Ok(true) => {
325                    tracing::debug!("API has newer data (200), refreshing cache");
326                }
327                Err(e) => {
328                    tracing::warn!(error = %e, "HEAD check failed, falling back to cache");
329                    if let Some(cached) = self.get_cached_metadata()? {
330                        return Ok(cached);
331                    }
332                }
333            }
334        }
335
336        self.fetch_and_cache_metadata().or_else(|_| {
337            self.get_cached_metadata()?
338                .ok_or_else(|| MonolingualError::NotFound("metadata unavailable".to_string()))
339        })
340    }
341
342    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
343    fn fetch_and_cache_metadata(&self) -> Result<DictionariesResponse, MonolingualError> {
344        let metadata = self.client.fetch_metadata()?;
345
346        for (source_lang, targets) in &metadata {
347            if let Some(entry) = targets.get(source_lang.as_str()) {
348                self.db.upsert_entry(source_lang, entry)?;
349            }
350        }
351
352        tracing::debug!("Cached monolingual metadata to database");
353        Ok(metadata)
354    }
355
356    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
357    fn get_cached_metadata(&self) -> Result<Option<DictionariesResponse>, MonolingualError> {
358        let entries = self.db.get_all_entries()?;
359
360        if entries.is_empty() {
361            tracing::debug!("No cached metadata found in database");
362            return Ok(None);
363        }
364
365        let mut response = DictionariesResponse::new();
366        for (lang, entry) in entries {
367            response
368                .entry(lang.clone())
369                .or_default()
370                .insert(lang, entry);
371        }
372
373        tracing::debug!("Loaded cached metadata from database");
374        Ok(Some(response))
375    }
376
377    fn reader_dict_dir(&self) -> PathBuf {
378        self.dict_dir.join(READER_DICT_SUBDIR)
379    }
380
381    fn lang_dir(&self, lang: &str) -> PathBuf {
382        self.reader_dict_dir().join(lang)
383    }
384}
385
386/// Returns `true` when `dir` contains at least one `.index` file that is
387/// paired with a `.dict` or `.dict.dz` file sharing the same stem.
388fn has_dict_pair(dir: &Path) -> bool {
389    let Ok(entries) = fs::read_dir(dir) else {
390        return false;
391    };
392
393    for entry in entries.flatten() {
394        let path = entry.path();
395        let name = match path.file_name().and_then(|n| n.to_str()) {
396            Some(n) => n.to_string(),
397            None => continue,
398        };
399
400        if !name.ends_with(".index") {
401            continue;
402        }
403
404        let stem = &name[..name.len() - ".index".len()];
405        let dict = dir.join(format!("{stem}.dict"));
406        let dict_dz = dir.join(format!("{stem}.dict.dz"));
407
408        if dict.exists() || dict_dz.exists() {
409            return true;
410        }
411    }
412
413    false
414}
415
416/// Extracts all entries from a ZIP archive into `dest`, renaming each
417/// file to `Reader-Dict-<lang><ext>` where `<ext>` is `.index`, `.dict`,
418/// or `.dict.dz`.
419///
420/// Files with unrecognised extensions are skipped. Directories inside the ZIP
421/// are ignored because all output files land flat in `dest`.
422#[cfg_attr(feature = "tracing", tracing::instrument(skip(reader)))]
423fn extract_zip_renamed<R: std::io::Read + std::io::Seek>(
424    reader: R,
425    dest: &Path,
426    lang: &str,
427) -> Result<(), MonolingualError> {
428    let mut archive = ZipArchive::new(reader)
429        .map_err(|e| MonolingualError::Extraction(format!("failed to open zip archive: {e}")))?;
430
431    for i in 0..archive.len() {
432        let mut file = archive.by_index(i).map_err(|e| {
433            MonolingualError::Extraction(format!("failed to read zip entry {i}: {e}"))
434        })?;
435
436        if file.is_dir() {
437            continue;
438        }
439
440        let original_name = match file.enclosed_name() {
441            Some(p) => p
442                .file_name()
443                .and_then(|n| n.to_str())
444                .unwrap_or("")
445                .to_string(),
446            None => {
447                tracing::warn!(index = i, "Skipping zip entry with unsafe path");
448                continue;
449            }
450        };
451
452        let target_name = dict_file_target_name(&original_name, lang);
453        let Some(target_name) = target_name else {
454            tracing::debug!(
455                original_name,
456                "Skipping zip entry with unrecognised extension"
457            );
458            continue;
459        };
460
461        let out_path = dest.join(&target_name);
462        let mut out_file = fs::File::create(&out_path)?;
463        io::copy(&mut file, &mut out_file)?;
464        tracing::debug!(path = %out_path.display(), "Extracted file");
465    }
466
467    Ok(())
468}
469
470/// Maps a ZIP entry filename to its renamed output filename `<lang>.<ext>`.
471///
472/// Recognised extensions (in priority order):
473/// - `.dict.dz` → `Reader-Dict-<lang>.dict.dz`
474/// - `.dict`    → `Reader-Dict-<lang>.dict`
475/// - `.index`   → `Reader-Dict-<lang>.index`
476///
477/// Returns `None` for any other extension.
478fn dict_file_target_name(original: &str, lang: &str) -> Option<String> {
479    for ext in &[".dict.dz", ".dict", ".index"] {
480        if original.ends_with(ext) {
481            return Some(format!("Reader-Dict-{lang}{ext}"));
482        }
483    }
484    None
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use crate::db::Database;
491    use crate::dictionary::monolingual::metadata::DictionaryEntry;
492    use chrono::NaiveDate;
493    use std::io::Cursor;
494    use std::io::Write;
495    use tempfile::TempDir;
496
497    fn create_test_service() -> (MonolingualDictionaryService, TempDir, Database) {
498        crate::crypto::init_crypto_provider();
499        let dir = TempDir::new().expect("failed to create temp dir");
500        let mut database = Database::new(":memory:").expect("failed to create in-memory database");
501        database.init(0).expect("failed to run migrations");
502        let service = MonolingualDictionaryService::new(&database, dir.path())
503            .expect("failed to create service");
504        (service, dir, database)
505    }
506
507    fn make_entry(year: i32, month: u32, day: u32) -> DictionaryEntry {
508        DictionaryEntry {
509            formats: "df,dic,dictorg,kobo,mobi,stardict".to_string(),
510            updated: NaiveDate::from_ymd_opt(year, month, day).unwrap(),
511            words: 1_381_375,
512        }
513    }
514
515    #[test]
516    fn test_get_installed_empty_when_no_dir() {
517        let (service, _dir, _db) = create_test_service();
518        let installed = service.get_installed_dictionaries().unwrap();
519        assert!(installed.is_empty());
520    }
521
522    #[test]
523    fn test_get_installed_empty_when_dir_exists_but_empty() {
524        let (service, dir, _db) = create_test_service();
525        fs::create_dir_all(dir.path().join(READER_DICT_SUBDIR)).unwrap();
526        let installed = service.get_installed_dictionaries().unwrap();
527        assert!(installed.is_empty());
528    }
529
530    #[test]
531    fn test_get_installed_detects_dict_pair() {
532        let (service, dir, _db) = create_test_service();
533        let lang_dir = dir.path().join(READER_DICT_SUBDIR).join("en");
534        fs::create_dir_all(&lang_dir).unwrap();
535        fs::File::create(lang_dir.join("dict.index")).unwrap();
536        fs::File::create(lang_dir.join("dict.dict")).unwrap();
537
538        let installed = service.get_installed_dictionaries().unwrap();
539        assert_eq!(installed, vec!["en".to_string()]);
540    }
541
542    #[test]
543    fn test_get_installed_detects_dict_dz_pair() {
544        let (service, dir, _db) = create_test_service();
545        let lang_dir = dir.path().join(READER_DICT_SUBDIR).join("fr");
546        fs::create_dir_all(&lang_dir).unwrap();
547        fs::File::create(lang_dir.join("dict.index")).unwrap();
548        fs::File::create(lang_dir.join("dict.dict.dz")).unwrap();
549
550        let installed = service.get_installed_dictionaries().unwrap();
551        assert_eq!(installed, vec!["fr".to_string()]);
552    }
553
554    #[test]
555    fn test_get_installed_ignores_index_without_dict() {
556        let (service, dir, _db) = create_test_service();
557        let lang_dir = dir.path().join(READER_DICT_SUBDIR).join("de");
558        fs::create_dir_all(&lang_dir).unwrap();
559        fs::File::create(lang_dir.join("dict.index")).unwrap();
560
561        let installed = service.get_installed_dictionaries().unwrap();
562        assert!(installed.is_empty());
563    }
564
565    #[test]
566    fn test_install_dictionary_extracts_zip_renamed() {
567        let (_service, dir, _db) = create_test_service();
568
569        let zip_bytes = make_test_zip(&[
570            ("dictorg-en-en.index", b"index content"),
571            ("dictorg-en-en.dict", b"dict content"),
572        ]);
573
574        let dest = dir.path().join(READER_DICT_SUBDIR).join("en");
575        fs::create_dir_all(&dest).unwrap();
576        extract_zip_renamed(Cursor::new(&zip_bytes), &dest, "en").unwrap();
577
578        assert!(dest.join("Reader-Dict-en.index").exists());
579        assert!(dest.join("Reader-Dict-en.dict").exists());
580    }
581
582    fn make_test_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
583        let mut buf = Vec::new();
584        {
585            let cursor = Cursor::new(&mut buf);
586            let mut zip = zip::ZipWriter::new(cursor);
587            let options = zip::write::SimpleFileOptions::default();
588            for (name, content) in entries {
589                zip.start_file(*name, options).unwrap();
590                zip.write_all(content).unwrap();
591            }
592            zip.finish().unwrap();
593        }
594        buf
595    }
596
597    #[test]
598    fn test_is_installing_false_initially() {
599        let (service, _dir, _db) = create_test_service();
600        assert!(!service.is_installing("en"));
601    }
602
603    #[test]
604    fn test_is_installing_true_while_pending() {
605        let (service, _dir, _db) = create_test_service();
606        service
607            .pending_installs
608            .lock()
609            .unwrap()
610            .insert("fr".to_string());
611        assert!(service.is_installing("fr"));
612        assert!(!service.is_installing("en"));
613    }
614
615    #[test]
616    fn test_try_begin_install_marks_pending_and_blocks_duplicate() {
617        let (service, _dir, _db) = create_test_service();
618
619        assert!(service.try_begin_install("en"));
620        assert!(service.is_installing("en"));
621        assert!(!service.try_begin_install("en"));
622    }
623
624    #[test]
625    fn test_pending_installs_recovers_from_poisoned_lock() {
626        let (service, _dir, _db) = create_test_service();
627        let service_clone = service.clone();
628
629        let result = std::thread::spawn(move || {
630            let _guard = service_clone.pending_installs.lock().unwrap();
631            panic!("poison pending installs lock");
632        })
633        .join();
634
635        assert!(result.is_err());
636        assert!(service.try_begin_install("en"));
637        assert!(service.is_installing("en"));
638        service.finish_install("en");
639        assert!(!service.is_installing("en"));
640    }
641
642    #[test]
643    fn test_is_installing_false_after_removal() {
644        let (service, _dir, _db) = create_test_service();
645        service
646            .pending_installs
647            .lock()
648            .unwrap()
649            .insert("en".to_string());
650        service.pending_installs.lock().unwrap().remove("en");
651        assert!(!service.is_installing("en"));
652    }
653
654    #[test]
655    fn test_concurrent_install_same_lang_returns_error() {
656        let (service, _dir, _db) = create_test_service();
657        service
658            .pending_installs
659            .lock()
660            .unwrap()
661            .insert("de".to_string());
662
663        let entry = make_entry(2026, 4, 1);
664        let err = service
665            .install_dictionary("de", &entry, false, &mut |_, _| {})
666            .expect_err("expected InstallationInProgress error");
667
668        assert!(
669            matches!(err, MonolingualError::InstallationInProgress(_)),
670            "unexpected error variant: {err}"
671        );
672    }
673
674    #[test]
675    fn test_pending_cleared_after_failed_install() {
676        let (service, _dir, _db) = create_test_service();
677
678        let entry = make_entry(2026, 4, 1);
679        let _ = service.install_dictionary("zz", &entry, false, &mut |_, _| {});
680        assert!(!service.is_installing("zz"));
681    }
682
683    #[test]
684    fn test_reserved_install_clears_after_failed_install() {
685        let (service, _dir, _db) = create_test_service();
686        let entry = make_entry(2026, 4, 1);
687
688        assert!(service.try_begin_install("zz"));
689
690        let _ = service.install_reserved_dictionary("zz", &entry, false, &mut |_, _| {});
691
692        assert!(!service.is_installing("zz"));
693    }
694
695    #[test]
696    fn test_is_installing_shared_across_clones() {
697        let (service, _dir, _db) = create_test_service();
698        let clone = service.clone();
699
700        service
701            .pending_installs
702            .lock()
703            .unwrap()
704            .insert("ja".to_string());
705
706        assert!(clone.is_installing("ja"));
707    }
708
709    #[test]
710    fn test_get_entry_for_lang_returns_none_when_not_cached() {
711        let (service, _dir, _db) = create_test_service();
712        let result = service.get_entry_for_lang("en").unwrap();
713        assert!(result.is_none());
714    }
715
716    #[test]
717    fn test_get_entry_for_lang_returns_entry_after_cache() {
718        let (service, _dir, _db) = create_test_service();
719
720        let entry = make_entry(2026, 4, 1);
721        service.db.upsert_entry("en", &entry).unwrap();
722
723        let result = service.get_entry_for_lang("en").unwrap();
724        assert!(result.is_some());
725        let fetched = result.unwrap();
726        assert_eq!(fetched.words, 1_381_375);
727        assert_eq!(
728            fetched.updated,
729            NaiveDate::from_ymd_opt(2026, 4, 1).unwrap()
730        );
731    }
732
733    /// Downloads and installs the English dictionary from the live API, then
734    /// verifies that at least one `.index` + `.dict`/`.dict.dz` pair is present.
735    ///
736    /// Run with: `cargo test -- --ignored`
737    #[test]
738    #[ignore = "requires network access to www.reader-dict.com"]
739    fn test_install_dictionary_live() {
740        let (service, dir, _db) = create_test_service();
741
742        let entry = service
743            .get_available_dictionaries()
744            .unwrap()
745            .into_iter()
746            .find(|(l, _)| l == "en")
747            .map(|(_, e)| e)
748            .expect("English dictionary should be available");
749
750        service
751            .install_dictionary("en", &entry, false, &mut |_, _| {})
752            .expect("install_dictionary failed");
753
754        let lang_dir = dir.path().join(READER_DICT_SUBDIR).join("en");
755        assert!(
756            lang_dir.exists(),
757            "language directory should exist after install"
758        );
759        assert!(
760            has_dict_pair(&lang_dir),
761            "expected .index + .dict/.dict.dz pair in {lang_dir:?}"
762        );
763
764        let installed = service
765            .get_installed_dictionaries()
766            .expect("get_installed_dictionaries failed");
767        assert!(
768            installed.contains(&"en".to_string()),
769            "expected 'en' in installed list, got {installed:?}"
770        );
771    }
772}