Skip to main content

gtars_bbcache/
client.rs

1//! BEDbase caching client implementation.
2//!
3//! This module provides the core [`BBClient`] type and its builder for managing
4//! cached BED files and BED sets from the BEDbase API.
5
6use anyhow::{Context, Ok, Result, anyhow};
7use biocrs::biocache::BioCache;
8use biocrs::models::{NewResource, Resource};
9
10use std::fs::{File, create_dir_all, read_dir, remove_dir, remove_file};
11use std::io::{BufRead, BufReader, Error, ErrorKind, Write};
12use std::path::{Path, PathBuf};
13use ureq::get;
14
15use super::consts::{
16    DEFAULT_BEDFILE_EXT, DEFAULT_BEDFILE_SUBFOLDER, DEFAULT_BEDSET_EXT, DEFAULT_BEDSET_SUBFOLDER,
17};
18use super::utils::{get_default_bedbase_api, get_default_cache_folder};
19use gtars_core::models::bed_set::BedSet;
20use gtars_core::models::region_set::RegionSet;
21
22/// Builder for constructing a [`BBClient`] with custom configuration.
23///
24/// Use this builder to configure cache location and BEDbase API endpoint
25/// before creating a client instance.
26///
27/// # Examples
28///
29/// ```rust,no_run
30/// use gtars_bbcache::client::BBClient;
31/// use std::path::PathBuf;
32///
33/// # fn main() -> anyhow::Result<()> {
34/// let client = BBClient::builder()
35///     .with_cache_folder(PathBuf::from("/custom/cache"))
36///     .with_bedbase_api("https://api.bedbase.org".to_string())
37///     .finish()?;
38/// # Ok(())
39/// # }
40/// ```
41#[derive(Default)]
42pub struct BBClientBuilder {
43    cache_folder: Option<PathBuf>,
44    bedbase_api: Option<String>,
45}
46
47impl BBClientBuilder {
48    /// Creates a new, empty BBClientBuilder.
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Sets the cache folder for the BBClient.
54    pub fn with_cache_folder(mut self, path: PathBuf) -> Self {
55        self.cache_folder = Some(path);
56        self
57    }
58
59    /// Sets the BEDbase API URL for the BBClient.
60    pub fn with_bedbase_api(mut self, api: String) -> Self {
61        self.bedbase_api = Some(api);
62        self
63    }
64
65    /// Consumes the builder and creates a BBClient.
66    pub fn finish(self) -> Result<BBClient> {
67        // handle the cache dir
68        let raw_path_to_cache_folder = self.cache_folder.unwrap_or_else(get_default_cache_folder);
69        let raw_str_to_cache_folder = raw_path_to_cache_folder.to_string_lossy().into_owned();
70        let expanded_str = shellexpand::env(&raw_str_to_cache_folder)
71            .unwrap_or_else(|_| raw_str_to_cache_folder.clone().into())
72            .into_owned();
73        let abs_path_to_cache_folder = PathBuf::from(expanded_str);
74        create_dir_all(&abs_path_to_cache_folder)?;
75
76        // handle the bedbase api
77        let bedbase_api = self.bedbase_api.unwrap_or_else(get_default_bedbase_api);
78
79        // create sub folders
80        let bedfile_subfolder = &abs_path_to_cache_folder.join(DEFAULT_BEDFILE_SUBFOLDER);
81        create_dir_all(bedfile_subfolder)?;
82        let bedfile_cache = BioCache::new(bedfile_subfolder);
83
84        let bedset_subfolder = &abs_path_to_cache_folder.join(DEFAULT_BEDSET_SUBFOLDER);
85        create_dir_all(bedset_subfolder)?;
86        let bedset_cache = BioCache::new(bedset_subfolder);
87
88        Ok(BBClient {
89            cache_folder: abs_path_to_cache_folder,
90            bedbase_api,
91            bedfile_cache,
92            bedset_cache,
93        })
94    }
95}
96
97/// Client for managing BED file and BED set caching from BEDbase.
98///
99/// `BBClient` provides a high-level interface for:
100/// - Downloading and caching BED files from the BEDbase API
101/// - Managing local BED file collections
102/// - Organizing BED sets (collections of related BED files)
103/// - Querying and removing cached resources
104///
105/// The client maintains two separate caches:
106/// - **bedfile_cache**: Individual BED files (stored as `.bed.gz`)
107/// - **bedset_cache**: BED set metadata files (stored as `.txt` with lists of BED IDs)
108///
109///
110/// # Examples
111///
112/// ```rust,no_run
113/// use gtars_bbcache::client::BBClient;
114///
115/// # fn main() -> anyhow::Result<()> {
116/// // Create a client with default settings
117/// let mut client = BBClient::builder().finish()?;
118///
119/// // Download and cache a BED file from BEDbase
120/// let region_set = client.load_bed("6b2e163a1d4319d99bd465c6c78a9741")?;
121///
122/// // Check if file exists in cache
123/// let path = client.seek("6b2e163a1d4319d99bd465c6c78a9741")?;
124/// println!("Cached at: {:?}", path);
125///
126/// // List all cached BED files
127/// let beds = client.list_beds()?;
128/// println!("Found {} cached BED files", beds.len());
129///
130/// // Remove from cache
131/// client.remove("6b2e163a1d4319d99bd465c6c78a9741")?;
132/// # Ok(())
133/// # }
134/// ```
135pub struct BBClient {
136    /// Path to the root cache directory
137    pub cache_folder: PathBuf,
138    /// BEDbase API endpoint URL
139    pub bedbase_api: String,
140    /// Internal cache manager for BED files
141    bedfile_cache: BioCache,
142    /// Internal cache manager for BED sets
143    bedset_cache: BioCache,
144}
145
146impl BBClient {
147    /// Creates a new builder for constructing a [`BBClient`].
148    ///
149    /// The builder pattern allows you to configure the cache folder and API endpoint
150    /// before creating the client instance.
151    ///
152    /// # Examples
153    ///
154    /// ```rust,no_run
155    /// use gtars_bbcache::client::BBClient;
156    /// use std::path::PathBuf;
157    ///
158    /// # fn main() -> anyhow::Result<()> {
159    /// let client = BBClient::builder()
160    ///     .with_cache_folder(PathBuf::from("/tmp/bbcache"))
161    ///     .finish()?;
162    /// # Ok(())
163    /// # }
164    /// ```
165    pub fn builder() -> BBClientBuilder {
166        BBClientBuilder::default()
167    }
168
169    /// Add id and path of cached bed file or bed set into file cacher (SQLite).
170    /// # Arguments
171    /// - cache_id: id of bed file or bed set
172    /// - cache_path: path to the cached bed file or bed set
173    /// - bedfile: if the the id and file is a bed file
174    ///
175    fn add_resource_to_cache(&mut self, cache_id: &str, cache_path: &str, bedfile: bool) {
176        let resource_to_add = NewResource::new(cache_id, cache_path).set_fpath(cache_path);
177        if bedfile {
178            self.bedfile_cache.add(&resource_to_add);
179        } else {
180            self.bedset_cache.add(&resource_to_add);
181        }
182    }
183
184    /// Loads a BED file from cache, or downloads and caches it if it doesn't exist
185    /// # Arguments
186    /// - bed_id: unique identifier of a BED file
187    ///
188    /// # Returns
189    /// - the RegionSet object of the loaded bed file
190    pub fn load_bed(&mut self, bed_id: &str) -> Result<RegionSet> {
191        let bedfile_path = self.bedfile_path(bed_id, Some(false));
192
193        if bedfile_path.exists() {
194            println!("Loading cached BED file from {:?}", bedfile_path.display());
195            return Ok(RegionSet::try_from(bedfile_path)?);
196        }
197
198        let region_set = RegionSet::try_from(bed_id)
199            .with_context(|| format!("Failed to create RegionSet from BEDbase id {}", bed_id))?;
200
201        self.add_resource_to_cache(
202            bed_id,
203            bedfile_path.to_str().expect("Invalid BED file path"),
204            true,
205        );
206
207        region_set.to_bed_gz(bedfile_path.clone())?;
208        println!(
209            "Downloaded BED file {} from BEDbase to path: {}",
210            bed_id,
211            bedfile_path.display()
212        );
213        Ok(region_set)
214    }
215
216    /// Load a BEDset from cache, or download and add it to the cache with its BED files
217    /// # Arguments
218    /// - bedset_id: unique identifier of a BED set
219    ///
220    /// # Returns
221    /// - the BedSet object of the loaded bed set
222    pub fn load_bedset(&mut self, bedset_id: &str) -> Result<BedSet> {
223        let bedset_path = self.bedset_path(bedset_id, Some(true));
224
225        if bedset_path.exists() {
226            println!("Loading cached BED file from {:?}", bedset_path.display());
227            return BedSet::try_from(bedset_path);
228        }
229
230        let bed_data = self.download_bedset_data(bedset_id).unwrap();
231        let mut file = File::create(bedset_path.clone())?;
232        let mut region_sets = Vec::new();
233        for bbid in bed_data {
234            writeln!(file, "{}", bbid)?;
235            let rs = self.load_bed(&bbid).unwrap();
236            region_sets.push(rs);
237        }
238        self.add_resource_to_cache(
239            bedset_id,
240            bedset_path.to_str().expect("Invalid BED set path"),
241            false,
242        );
243
244        Ok(BedSet::from(region_sets))
245    }
246
247    ///  Add a BED file to the cache
248    /// # Arguments
249    /// - bedfile: a path or url to the BED file
250    /// - force: whether to overwrite the existing file in cache
251    ///
252    /// # Returns
253    /// - the RegionSet identifier
254    pub fn add_local_bed_to_cache(
255        &mut self,
256        bedfile: PathBuf,
257        force: Option<bool>,
258    ) -> Result<String> {
259        let regionset = RegionSet::try_from(bedfile.as_path())?;
260        self.add_regionset_to_cache(regionset, force)
261    }
262
263    ///  Add a RegionSet object to the cache
264    /// # Arguments
265    /// - regionset:  a RegionSet object
266    /// - force: whether to overwrite the existing file in cache
267    ///
268    /// # Returns
269    /// - the RegionSet identifier
270    pub fn add_regionset_to_cache(
271        &mut self,
272        regionset: RegionSet,
273        force: Option<bool>,
274    ) -> Result<String> {
275        let bedfile_id = regionset.identifier();
276        let cache_path = self.bedfile_path(&bedfile_id, Some(true));
277
278        let force = force.unwrap_or(false);
279        if !force && cache_path.exists() {
280            println!("{} already exists in cache", cache_path.display());
281            return Ok(bedfile_id);
282        }
283
284        regionset.to_bed_gz(cache_path.as_path())?;
285        self.add_resource_to_cache(
286            &bedfile_id,
287            cache_path
288                .to_str()
289                .expect("cache path cannot be convert to &str"),
290            true,
291        );
292        println!("BED file cached to {}", cache_path.display());
293
294        Ok(bedfile_id)
295    }
296
297    ///  Add a BED set to the cache
298    /// # Arguments
299    /// - bedset: the BED set to be added, a BedSet class
300    ///
301    /// # Returns
302    /// - the identifier if the BedSet object
303    pub fn add_bedset_to_cache(&mut self, bedset: BedSet) -> Result<String> {
304        let bedset_id = bedset.identifier();
305        let bedset_path = self.bedset_path(&bedset_id, Some(true));
306        if bedset_path.exists() {
307            println!("{} already exists in cache", bedset_path.display());
308        } else {
309            let mut file = File::create(bedset_path.clone())?;
310            for rs in bedset.region_sets {
311                let bed_id = rs.identifier();
312                let _ = self.add_regionset_to_cache(rs, Some(false));
313                writeln!(file, "{}", bed_id)?;
314            }
315        }
316
317        self.add_resource_to_cache(
318            &bedset_id,
319            bedset_path
320                .to_str()
321                .expect("cache path cannot be convert to &str"),
322            false,
323        );
324        println!("BED set cached to {}", bedset_path.display());
325
326        Ok(bedset_id)
327    }
328
329    ///  Add a folder of bed files to the cache as a bedset
330    /// # Arguments
331    /// - folder_path: path to the folder where bed files are stored
332    ///
333    /// # Returns
334    /// - the identifier if the BedSet object
335    pub fn add_local_folder_as_bedset(&mut self, folder_path: PathBuf) -> Result<String> {
336        let mut region_sets = Vec::new();
337        for entry in read_dir(&folder_path).expect("Failed to read directory") {
338            let entry = entry.expect("Failed to read directory entry");
339            let file_path = entry.path();
340
341            if file_path.is_file() {
342                let rs = RegionSet::try_from(file_path).unwrap();
343                region_sets.push(rs);
344            }
345        }
346        let bedset = BedSet::from(region_sets);
347        Ok(self.add_bedset_to_cache(bedset).unwrap())
348    }
349
350    ///  Add a local file that contains bed file paths as a bed set
351    /// # Arguments
352    /// - file_path: path to the file of bedset info
353    ///
354    /// # Returns
355    /// - the identifier if the BedSet object
356    pub fn add_local_file_as_bedset(&mut self, file_path: PathBuf) -> Result<String> {
357        let bedset = BedSet::try_from(file_path).unwrap();
358        Ok(self.add_bedset_to_cache(bedset).unwrap())
359    }
360
361    ///  Download BED set from BEDbase API and return the list of identifiers of BED files in the set
362    /// # Arguments
363    /// - bedset_id: unique identifier of a BED set
364    ///
365    /// # Returns
366    /// - the list of identifiers of BED files in the set
367    fn download_bedset_data(&self, bedset_id: &str) -> Result<Vec<String>> {
368        let bedset_url = format!("{}/v1/bedset/{}/bedfiles", self.bedbase_api, bedset_id);
369
370        let response = get(&bedset_url)
371            .call()
372            .map_err(|e| anyhow!("Failed to GET {}: {}", bedset_url, e))?
373            .body_mut()
374            .read_to_string()
375            .map_err(|e| anyhow!("Failed to read response body for {}: {}", bedset_url, e))?;
376
377        let json: serde_json::Value = serde_json::from_str(&response)?;
378
379        let results = json["results"]
380            .as_array()
381            .ok_or_else(|| anyhow!("`results` is not an array"))?;
382
383        let extracted_ids: Vec<String> = results
384            .iter()
385            .filter_map(|entry| {
386                let id_val = entry.get("id");
387                id_val?.as_str().map(|s| s.to_string())
388            })
389            .collect();
390
391        Ok(extracted_ids)
392    }
393
394    ///  Get the path of a BED file's .bed.gz file with given identifier
395    /// # Arguments
396    /// - bedfile_id: the identifier of BED file
397    /// - create: whether the cache path needs creating
398    ///
399    /// # Returns
400    /// - the path to the .bed.gz file
401    fn bedfile_path(&self, bedfile_id: &str, create: Option<bool>) -> PathBuf {
402        let subfolder_name = DEFAULT_BEDFILE_SUBFOLDER;
403        let file_extension = DEFAULT_BEDFILE_EXT;
404        self.cache_path(bedfile_id, subfolder_name, file_extension, create)
405    }
406
407    ///  Get the path of a BED set's .txt file with given identifier
408    /// # Arguments
409    /// - bedset_id: the identifier of BED set
410    /// - create: whether the cache path needs creating
411    ///
412    /// # Returns
413    /// - the path to the .txt file
414    fn bedset_path(&self, bedset_id: &str, create: Option<bool>) -> PathBuf {
415        let subfolder_name = DEFAULT_BEDSET_SUBFOLDER;
416        let file_extension = DEFAULT_BEDSET_EXT;
417        self.cache_path(bedset_id, subfolder_name, file_extension, create)
418    }
419
420    ///  Get the path of a file in cache folder
421    /// # Arguments
422    /// - identifier: the identifier of BED set or BED file
423    /// - subfolder_name: "bedsets" or "bedfiles"
424    /// - file_extension: ".txt" or ".bed.gz"
425    /// - create: whether the cache path needs creating
426    ///
427    /// # Returns
428    /// - the path to the file
429    fn cache_path(
430        &self,
431        identifier: &str,
432        subfolder_name: &str,
433        file_extension: &str,
434        create: Option<bool>,
435    ) -> PathBuf {
436        let filename = format!("{}{}", identifier, file_extension);
437        let folder_path = self
438            .cache_folder
439            .join(subfolder_name)
440            .join(&identifier[0..1])
441            .join(&identifier[1..2]);
442
443        if create.unwrap_or(true) {
444            self.create_cache_folder(Some(&folder_path));
445        }
446        folder_path.join(filename)
447    }
448
449    ///  Create cache folder if it doesn't exist
450    /// # Arguments
451    /// - subfolder_path: path to the subfolder
452    fn create_cache_folder(&self, subfolder_path: Option<&Path>) {
453        let path = match subfolder_path {
454            Some(p) => p.to_path_buf(),
455            None => self.cache_folder.clone(),
456        };
457
458        if !path.exists() {
459            create_dir_all(&path).expect("Failed to create cache folder");
460        }
461    }
462
463    /// Get local path to BED file or BED set with specific identifier
464    /// # Arguments
465    /// - identifier: the unique identifier
466    ///
467    /// # Returns
468    /// - the local path of the file
469    pub fn seek(&self, identifier: &str) -> Result<PathBuf> {
470        let file_path = self.bedfile_path(identifier, Some(false));
471        if file_path.exists() {
472            Ok(file_path)
473        } else {
474            let set_path = self.bedset_path(identifier, Some(false));
475            if set_path.exists() {
476                Ok(set_path)
477            } else {
478                Err(anyhow::anyhow!("{} does not exist in cache.", identifier))
479            }
480        }
481    }
482
483    /// Remove a BED file or BED set from the cache folder as well as biocfilcache (SQLite)
484    /// # Arguments
485    /// - identifier: the identifier of BED file / BED set to be removed
486    pub fn remove(&mut self, identifier: &str) -> Result<()> {
487        let file_path = self.bedfile_path(identifier, Some(false));
488        if file_path.exists() {
489            // remove file and check if subfolders is cleaned
490            let _ = self.local_removal(file_path.clone());
491            self.bedfile_cache.remove(identifier);
492
493            println!("{} is removed.", file_path.display());
494            Ok(())
495        } else {
496            let set_path = self.bedset_path(identifier, Some(false));
497            if set_path.exists() {
498                let bedset_file = File::open(set_path.clone())?;
499                let reader = BufReader::new(bedset_file);
500
501                let bed_ids: Vec<String> = reader.lines().collect::<Result<_, _>>()?;
502
503                for bed_id in bed_ids {
504                    let _ = self.remove(&bed_id);
505                }
506
507                let _ = self.local_removal(set_path.clone());
508
509                self.bedset_cache.remove(identifier);
510
511                println!("{} is removed.", set_path.display());
512                Ok(())
513            } else {
514                Err(Error::new(
515                    ErrorKind::NotFound,
516                    format!("{} does not exist in cache.", file_path.display()),
517                )
518                .into())
519            }
520        }
521    }
522
523    /// Remove a BED file or BED set from the cache folder, and make sure the removal won't cause empty subfolders
524    /// # Arguments
525    /// - identifier: the identifier of BED file / BED set to be removed
526    fn local_removal(&self, file_path: PathBuf) -> Result<()> {
527        let sub_folder_2 = file_path.parent().map(PathBuf::from);
528        let sub_folder_1 = sub_folder_2
529            .as_ref()
530            .and_then(|p| p.parent().map(PathBuf::from));
531
532        remove_file(&file_path)?;
533
534        // Attempt to remove empty subfolders
535        if let Some(sub2) = sub_folder_2
536            && read_dir(&sub2)?.next().is_none()
537        {
538            remove_dir(&sub2)?;
539            if let Some(sub1) = sub_folder_1
540                && read_dir(&sub1)?.next().is_none()
541            {
542                remove_dir(&sub1)?;
543            }
544        }
545
546        Ok(())
547    }
548
549    /// List identifiers and paths of all BED files in cache
550    /// # Returns
551    /// - the list of resource stored in biocfilecache (identifiers & paths)
552    pub fn list_beds(&mut self) -> Result<Vec<Resource>> {
553        let bed_resources = self.bedfile_cache.list_resources(Some(20_000));
554        Ok(bed_resources)
555    }
556
557    /// List identifiers and paths of all BED sets in cache
558    /// # Returns
559    /// - the list of resource stored in biocfilecache (identifiers & paths)
560    pub fn list_bedsets(&mut self) -> Result<Vec<Resource>> {
561        let bedset_resources = self.bedset_cache.list_resources(Some(20_000));
562        Ok(bedset_resources)
563    }
564}