Skip to main content

data_gov/
client.rs

1use futures::StreamExt;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4use tokio::fs::File;
5use tokio::io::AsyncWriteExt;
6use url::Url;
7
8use crate::config::DataGovConfig;
9use crate::error::{DataGovError, Result};
10use crate::ui::{
11    DownloadBatch, DownloadFailed, DownloadFinished, DownloadProgress, DownloadStarted,
12    StatusReporter,
13};
14use data_gov_catalog::{
15    CatalogClient, SearchParams,
16    models::{Dataset, Distribution, Organization, SearchHit, SearchResponse},
17};
18
19/// Async client for exploring data.gov datasets.
20///
21/// `DataGovClient` layers ergonomic helpers on top of
22/// [`data_gov_catalog::CatalogClient`]. In addition to search and metadata
23/// lookups it handles download destinations, progress reporting, and
24/// status-reporter integration used by the `data-gov` CLI.
25#[derive(Debug)]
26pub struct DataGovClient {
27    catalog: CatalogClient,
28    config: DataGovConfig,
29    http_client: reqwest::Client,
30}
31
32impl DataGovClient {
33    /// Create a new DataGov client with default configuration.
34    pub fn new() -> Result<Self> {
35        Self::with_config(DataGovConfig::new())
36    }
37
38    /// Access the current configuration.
39    pub fn config(&self) -> &DataGovConfig {
40        &self.config
41    }
42
43    /// Create a new DataGov client with custom configuration.
44    pub fn with_config(config: DataGovConfig) -> Result<Self> {
45        let catalog = CatalogClient::new(config.catalog_config.clone());
46
47        let http_client = reqwest::Client::builder()
48            .timeout(std::time::Duration::from_secs(config.download_timeout_secs))
49            .user_agent(&config.user_agent)
50            .build()?;
51
52        Ok(Self {
53            catalog,
54            config,
55            http_client,
56        })
57    }
58
59    // === Search and Discovery ===
60
61    /// Search for datasets on data.gov.
62    ///
63    /// # Arguments
64    /// * `query` - Full-text query (searches titles, descriptions, keywords).
65    ///   Pass an empty string to search without a text query.
66    /// * `per_page` - Page size. Server default is 10.
67    /// * `after` - Opaque cursor returned by a previous page's
68    ///   [`SearchResponse::after`]. Pass `None` for the first page.
69    /// * `organization` - Organization slug (e.g. `nasa`) to filter by.
70    ///
71    /// Pagination is cursor-based; there is no random-access offset.
72    ///
73    /// # Examples
74    ///
75    /// ```rust,no_run
76    /// # use data_gov::DataGovClient;
77    /// # #[tokio::main]
78    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
79    /// let client = DataGovClient::new()?;
80    /// let page = client.search("climate", Some(20), None, None).await?;
81    /// let next = client.search("climate", Some(20), page.after.as_deref(), None).await?;
82    /// # Ok(()) }
83    /// ```
84    pub async fn search(
85        &self,
86        query: &str,
87        per_page: Option<i32>,
88        after: Option<&str>,
89        organization: Option<&str>,
90    ) -> Result<SearchResponse> {
91        let mut params = SearchParams::new();
92        if !query.is_empty() {
93            params = params.q(query);
94        }
95        if let Some(n) = per_page {
96            params = params.per_page(n);
97        }
98        if let Some(cursor) = after {
99            params = params.after(cursor);
100        }
101        if let Some(org) = organization {
102            params = params.org_slug(org);
103        }
104        Ok(self.catalog.search(params).await?)
105    }
106
107    /// Fetch a single dataset by its data.gov slug.
108    ///
109    /// Returns `Err(ResourceNotFound)` if no dataset matches.
110    pub async fn get_dataset(&self, slug: &str) -> Result<SearchHit> {
111        self.catalog
112            .dataset_by_slug(slug)
113            .await?
114            .ok_or_else(|| DataGovError::resource_not_found(format!("slug {slug} not found")))
115    }
116
117    /// Fetch the DCAT-US 3 record for a harvest-record UUID.
118    pub async fn get_dataset_by_harvest_record(&self, id: &str) -> Result<Dataset> {
119        Ok(self.catalog.harvest_record_transformed(id).await?)
120    }
121
122    /// Fetch dataset title suggestions for interactive prompts.
123    ///
124    /// Implemented as a capped full-text search; the new API does not offer a
125    /// dedicated dataset-autocomplete endpoint.
126    pub async fn autocomplete_datasets(
127        &self,
128        partial: &str,
129        limit: Option<i32>,
130    ) -> Result<Vec<String>> {
131        let page = self.search(partial, limit.or(Some(10)), None, None).await?;
132        Ok(page
133            .results
134            .into_iter()
135            .filter_map(|hit| hit.title)
136            .collect())
137    }
138
139    /// List the publisher slugs for government organizations, capped to `limit`.
140    pub async fn list_organizations(&self, limit: Option<i32>) -> Result<Vec<String>> {
141        let orgs = self.catalog.organizations().await?;
142        let iter = orgs.organizations.into_iter().filter_map(|o| o.slug);
143        Ok(match limit {
144            Some(n) if n >= 0 => iter.take(n as usize).collect(),
145            _ => iter.collect(),
146        })
147    }
148
149    /// Fetch full organization records for the catalog.
150    pub async fn list_organization_records(&self) -> Result<Vec<Organization>> {
151        Ok(self.catalog.organizations().await?.organizations)
152    }
153
154    /// Fetch organization name suggestions matching `partial`.
155    ///
156    /// Implemented as a client-side case-insensitive filter over
157    /// [`CatalogClient::organizations`](data_gov_catalog::CatalogClient::organizations).
158    pub async fn autocomplete_organizations(
159        &self,
160        partial: &str,
161        limit: Option<i32>,
162    ) -> Result<Vec<String>> {
163        let needle = partial.to_lowercase();
164        let orgs = self.catalog.organizations().await?;
165        let matches = orgs.organizations.into_iter().filter(|o| {
166            let name_hit = o
167                .name
168                .as_deref()
169                .is_some_and(|n| n.to_lowercase().contains(&needle));
170            let slug_hit = o
171                .slug
172                .as_deref()
173                .is_some_and(|s| s.to_lowercase().contains(&needle));
174            name_hit || slug_hit
175        });
176        let names = matches.filter_map(|o| o.name.or(o.slug));
177        Ok(match limit {
178            Some(n) if n >= 0 => names.take(n as usize).collect(),
179            _ => names.collect(),
180        })
181    }
182
183    // === Distribution Management ===
184
185    /// Return distributions that look like downloadable files.
186    ///
187    /// A distribution qualifies when it carries a `downloadURL` (as opposed to
188    /// API-only `accessURL` entries).
189    pub fn get_downloadable_distributions(dataset: &Dataset) -> Vec<Distribution> {
190        dataset
191            .distribution
192            .iter()
193            .filter(|d| d.download_url.is_some())
194            .cloned()
195            .collect()
196    }
197
198    /// Pick a filesystem-friendly filename for a distribution.
199    ///
200    /// # Arguments
201    /// * `distribution` - The distribution to generate a filename for.
202    /// * `fallback_name` - Used when the distribution has no title and no URL
203    ///   segment we can derive a name from.
204    /// * `index` - Appended before the extension to disambiguate multi-file
205    ///   batches with duplicate titles.
206    pub fn get_distribution_filename(
207        distribution: &Distribution,
208        fallback_name: Option<&str>,
209        index: Option<usize>,
210    ) -> String {
211        let (base, has_ext) = Self::base_filename(distribution, fallback_name);
212        match index {
213            Some(i) if has_ext => {
214                if let Some(dot) = base.rfind('.') {
215                    let (stem, ext) = base.split_at(dot);
216                    format!("{stem}-{i}{ext}")
217                } else {
218                    format!("{base}-{i}")
219                }
220            }
221            Some(i) => format!("{base}-{i}"),
222            None => base,
223        }
224    }
225
226    fn base_filename(distribution: &Distribution, fallback_name: Option<&str>) -> (String, bool) {
227        if let Some(title) = &distribution.title {
228            return Self::apply_format_extension(title, distribution.format.as_deref());
229        }
230        if let Some(url) = distribution
231            .download_url
232            .as_deref()
233            .or(distribution.access_url.as_deref())
234            && let Ok(parsed) = Url::parse(url)
235            && let Some(mut segments) = parsed.path_segments()
236            && let Some(last) = segments.next_back()
237            && !last.is_empty()
238            && last.contains('.')
239        {
240            return (last.to_string(), true);
241        }
242        let stem = fallback_name.unwrap_or("data");
243        if let Some(fmt) = &distribution.format {
244            (format!("{stem}.{}", fmt.to_lowercase()), true)
245        } else {
246            (format!("{stem}.dat"), true)
247        }
248    }
249
250    fn apply_format_extension(name: &str, format: Option<&str>) -> (String, bool) {
251        match format {
252            Some(fmt) => {
253                let lower = fmt.to_lowercase();
254                if name.to_lowercase().ends_with(&format!(".{lower}")) {
255                    (name.to_string(), true)
256                } else {
257                    (format!("{name}.{lower}"), true)
258                }
259            }
260            None => (name.to_string(), name.contains('.')),
261        }
262    }
263
264    // === File Downloads ===
265
266    /// Download a single distribution to the specified directory.
267    ///
268    /// # Arguments
269    /// * `distribution` - The distribution to download.
270    /// * `output_dir` - Directory where the file will be saved. If `None`,
271    ///   uses the configured base download directory.
272    ///
273    /// Returns the path where the file was written.
274    pub async fn download_distribution(
275        &self,
276        distribution: &Distribution,
277        output_dir: Option<&Path>,
278    ) -> Result<PathBuf> {
279        let url = match distribution.download_url.as_deref() {
280            Some(url) => url,
281            None => {
282                if let Some(reporter) = self.config.status_reporter.as_ref() {
283                    let event = DownloadFailed {
284                        resource_name: distribution.title.clone(),
285                        dataset_name: None,
286                        output_path: None,
287                        error: "Distribution has no downloadURL".to_string(),
288                    };
289                    reporter.on_download_failed(&event);
290                }
291                return Err(DataGovError::resource_not_found(
292                    "Distribution has no downloadURL",
293                ));
294            }
295        };
296
297        let output_dir = output_dir
298            .map(|p| p.to_path_buf())
299            .unwrap_or_else(|| self.config.get_base_download_dir());
300        let filename = Self::get_distribution_filename(distribution, None, None);
301        let output_path = output_dir.join(filename);
302
303        Self::perform_download(
304            &self.http_client,
305            url,
306            &output_path,
307            distribution.title.clone(),
308            None,
309            self.reporter(),
310        )
311        .await?;
312
313        Ok(output_path)
314    }
315
316    /// Download multiple distributions concurrently.
317    ///
318    /// Returns one [`Result`] per distribution so callers can inspect partial
319    /// failures.
320    pub async fn download_distributions(
321        &self,
322        distributions: &[Distribution],
323        output_dir: Option<&Path>,
324    ) -> Vec<Result<PathBuf>> {
325        if distributions.is_empty() {
326            return vec![];
327        }
328
329        if distributions.len() == 1 {
330            return vec![
331                self.download_distribution(&distributions[0], output_dir)
332                    .await,
333            ];
334        }
335
336        if let Some(reporter) = self.config.status_reporter.as_ref() {
337            let event = DownloadBatch {
338                resource_count: distributions.len(),
339                dataset_name: None,
340            };
341            reporter.on_download_batch(&event);
342        }
343
344        let output_dir = output_dir
345            .map(|p| p.to_path_buf())
346            .unwrap_or_else(|| self.config.get_base_download_dir());
347
348        let semaphore = Arc::new(tokio::sync::Semaphore::new(
349            self.config.max_concurrent_downloads,
350        ));
351
352        let status_reporter = self.reporter();
353        let mut futures = Vec::with_capacity(distributions.len());
354
355        for (index, distribution) in distributions.iter().enumerate() {
356            let distribution = distribution.clone();
357            let output_dir = output_dir.clone();
358            let semaphore = semaphore.clone();
359            let http_client = self.http_client.clone();
360            let status_reporter = status_reporter.clone();
361
362            let future = async move {
363                let _permit = match semaphore.acquire().await {
364                    Ok(permit) => permit,
365                    Err(e) => {
366                        if let Some(reporter) = status_reporter.as_ref() {
367                            let event = DownloadFailed {
368                                resource_name: distribution.title.clone(),
369                                dataset_name: None,
370                                output_path: None,
371                                error: format!("Failed to acquire download slot: {e}"),
372                            };
373                            reporter.on_download_failed(&event);
374                        }
375                        return Err(DataGovError::download_error(format!(
376                            "Semaphore error: {e}"
377                        )));
378                    }
379                };
380
381                let url = match distribution.download_url.as_deref() {
382                    Some(url) => url,
383                    None => {
384                        if let Some(reporter) = status_reporter.as_ref() {
385                            let event = DownloadFailed {
386                                resource_name: distribution.title.clone(),
387                                dataset_name: None,
388                                output_path: None,
389                                error: "Distribution has no downloadURL".to_string(),
390                            };
391                            reporter.on_download_failed(&event);
392                        }
393                        return Err(DataGovError::resource_not_found(
394                            "Distribution has no downloadURL",
395                        ));
396                    }
397                };
398
399                let filename =
400                    DataGovClient::get_distribution_filename(&distribution, None, Some(index));
401                let output_path = output_dir.join(&filename);
402
403                DataGovClient::perform_download(
404                    &http_client,
405                    url,
406                    &output_path,
407                    distribution.title.clone(),
408                    None,
409                    status_reporter,
410                )
411                .await?;
412
413                Ok(output_path)
414            };
415
416            futures.push(future);
417        }
418
419        futures::future::join_all(futures).await
420    }
421
422    fn reporter(&self) -> Option<Arc<dyn StatusReporter + Send + Sync>> {
423        self.config.status_reporter.clone()
424    }
425
426    async fn perform_download(
427        http_client: &reqwest::Client,
428        url: &str,
429        output_path: &Path,
430        resource_name: Option<String>,
431        dataset_name: Option<String>,
432        status_reporter: Option<Arc<dyn StatusReporter + Send + Sync>>,
433    ) -> Result<()> {
434        let notify_failure =
435            |message: String, status_reporter: &Option<Arc<dyn StatusReporter + Send + Sync>>| {
436                if let Some(reporter) = status_reporter.as_ref() {
437                    let event = DownloadFailed {
438                        resource_name: resource_name.clone(),
439                        dataset_name: dataset_name.clone(),
440                        output_path: Some(output_path.to_path_buf()),
441                        error: message.clone(),
442                    };
443                    reporter.on_download_failed(&event);
444                }
445            };
446
447        if let Some(parent) = output_path.parent()
448            && let Err(err) = tokio::fs::create_dir_all(parent).await
449        {
450            notify_failure(err.to_string(), &status_reporter);
451            return Err(err.into());
452        }
453
454        let response = match http_client.get(url).send().await {
455            Ok(resp) => resp,
456            Err(err) => {
457                notify_failure(err.to_string(), &status_reporter);
458                return Err(err.into());
459            }
460        };
461
462        if !response.status().is_success() {
463            let message = format!("HTTP {} while downloading {}", response.status(), url);
464            notify_failure(message.clone(), &status_reporter);
465            return Err(DataGovError::download_error(message));
466        }
467
468        let total_size = response.content_length();
469
470        if let Some(reporter) = status_reporter.as_ref() {
471            let event = DownloadStarted {
472                resource_name: resource_name.clone(),
473                dataset_name: dataset_name.clone(),
474                url: url.to_string(),
475                output_path: output_path.to_path_buf(),
476                total_bytes: total_size,
477            };
478            reporter.on_download_started(&event);
479        }
480
481        let mut file = match File::create(output_path).await {
482            Ok(file) => file,
483            Err(err) => {
484                notify_failure(err.to_string(), &status_reporter);
485                return Err(err.into());
486            }
487        };
488
489        let mut stream = response.bytes_stream();
490        let mut progress = DownloadProgress {
491            resource_name: resource_name.clone(),
492            dataset_name: dataset_name.clone(),
493            output_path: output_path.to_path_buf(),
494            downloaded_bytes: 0,
495            total_bytes: total_size,
496        };
497
498        while let Some(chunk_result) = stream.next().await {
499            let chunk = match chunk_result {
500                Ok(chunk) => chunk,
501                Err(err) => {
502                    notify_failure(err.to_string(), &status_reporter);
503                    return Err(err.into());
504                }
505            };
506
507            if let Err(err) = file.write_all(&chunk).await {
508                notify_failure(err.to_string(), &status_reporter);
509                return Err(err.into());
510            }
511
512            progress.downloaded_bytes += chunk.len() as u64;
513
514            if let Some(reporter) = status_reporter.as_ref() {
515                reporter.on_download_progress(&progress);
516            }
517        }
518
519        if let Some(reporter) = status_reporter.as_ref() {
520            let event = DownloadFinished {
521                resource_name,
522                dataset_name,
523                output_path: output_path.to_path_buf(),
524            };
525            reporter.on_download_finished(&event);
526        }
527
528        Ok(())
529    }
530
531    /// Check that the base download directory exists and is writable.
532    pub async fn validate_download_dir(&self) -> Result<()> {
533        let base_dir = self.config.get_base_download_dir();
534
535        if !base_dir.exists() {
536            tokio::fs::create_dir_all(&base_dir).await?;
537        }
538
539        if !base_dir.is_dir() {
540            return Err(DataGovError::config_error(format!(
541                "Download path is not a directory: {base_dir:?}"
542            )));
543        }
544
545        let test_file = base_dir.join(".write_test");
546        tokio::fs::write(&test_file, b"test").await?;
547        tokio::fs::remove_file(&test_file).await?;
548
549        Ok(())
550    }
551
552    /// Get the current base download directory.
553    pub fn download_dir(&self) -> PathBuf {
554        self.config.get_base_download_dir()
555    }
556
557    /// Get the underlying Catalog API client for advanced operations.
558    pub fn catalog_client(&self) -> &CatalogClient {
559        &self.catalog
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    fn dist(title: Option<&str>, format: Option<&str>, url: Option<&str>) -> Distribution {
568        Distribution {
569            type_hint: None,
570            title: title.map(str::to_string),
571            description: None,
572            download_url: url.map(str::to_string),
573            access_url: None,
574            media_type: None,
575            format: format.map(str::to_string),
576            license: None,
577            described_by: None,
578            described_by_type: None,
579        }
580    }
581
582    #[test]
583    fn distribution_filename_no_index() {
584        let d = dist(
585            Some("data"),
586            Some("CSV"),
587            Some("https://example.com/data.csv"),
588        );
589        let name = DataGovClient::get_distribution_filename(&d, None, None);
590        assert_eq!(name, "data.csv");
591    }
592
593    #[test]
594    fn distribution_filename_with_index() {
595        let d = dist(
596            Some("data"),
597            Some("CSV"),
598            Some("https://example.com/data.csv"),
599        );
600        assert_eq!(
601            DataGovClient::get_distribution_filename(&d, None, Some(0)),
602            "data-0.csv"
603        );
604        assert_eq!(
605            DataGovClient::get_distribution_filename(&d, None, Some(2)),
606            "data-2.csv"
607        );
608    }
609
610    #[test]
611    fn distribution_filename_already_has_extension() {
612        let d = dist(
613            Some("report.csv"),
614            Some("CSV"),
615            Some("https://example.com/report.csv"),
616        );
617        assert_eq!(
618            DataGovClient::get_distribution_filename(&d, None, Some(3)),
619            "report-3.csv"
620        );
621    }
622
623    #[test]
624    fn distribution_filename_falls_back_to_url_when_title_missing() {
625        let d = dist(None, None, Some("https://example.com/downloads/report.csv"));
626        assert_eq!(
627            DataGovClient::get_distribution_filename(&d, None, None),
628            "report.csv"
629        );
630    }
631
632    #[test]
633    fn distribution_filename_url_without_extension_uses_format_fallback() {
634        let d = dist(None, Some("JSON"), Some("https://example.com/api/records"));
635        assert_eq!(
636            DataGovClient::get_distribution_filename(&d, None, None),
637            "data.json"
638        );
639    }
640
641    #[test]
642    fn distribution_filename_no_title_no_url_returns_data_dat() {
643        let d = dist(None, None, None);
644        assert_eq!(
645            DataGovClient::get_distribution_filename(&d, None, None),
646            "data.dat"
647        );
648    }
649
650    #[test]
651    fn distribution_filename_uses_fallback_name() {
652        let d = dist(None, Some("CSV"), None);
653        assert_eq!(
654            DataGovClient::get_distribution_filename(&d, Some("climate-dataset"), None),
655            "climate-dataset.csv"
656        );
657    }
658
659    #[test]
660    fn distribution_filename_fallback_with_index_inserts_before_extension() {
661        let d = dist(None, None, None);
662        assert_eq!(
663            DataGovClient::get_distribution_filename(&d, None, Some(2)),
664            "data-2.dat"
665        );
666    }
667
668    #[test]
669    fn downloadable_distributions_excludes_access_only_entries() {
670        let mut ds = Dataset {
671            type_hint: None,
672            title: None,
673            description: None,
674            identifier: None,
675            access_level: None,
676            modified: None,
677            issued: None,
678            publisher: None,
679            contact_point: None,
680            keyword: vec![],
681            theme: vec![],
682            distribution: vec![],
683            landing_page: None,
684            license: None,
685            rights: None,
686            spatial: None,
687            temporal: None,
688            accrual_periodicity: None,
689            language: vec![],
690            bureau_code: None,
691            program_code: None,
692            described_by: None,
693            described_by_type: None,
694            references: vec![],
695            data_quality: None,
696            system_of_records: None,
697        };
698        ds.distribution.push(dist(
699            Some("csv"),
700            Some("CSV"),
701            Some("https://example.com/file.csv"),
702        ));
703        // API-only distribution — no downloadURL.
704        let mut api_only = dist(Some("api"), Some("JSON"), None);
705        api_only.access_url = Some("https://example.com/api".to_string());
706        ds.distribution.push(api_only);
707
708        let out = DataGovClient::get_downloadable_distributions(&ds);
709        assert_eq!(out.len(), 1);
710        assert_eq!(out[0].title.as_deref(), Some("csv"));
711    }
712
713    fn client_with_download_dir(dir: std::path::PathBuf) -> DataGovClient {
714        let config = crate::config::DataGovConfig::default()
715            .with_mode(crate::config::OperatingMode::Interactive)
716            .with_download_dir(dir);
717        DataGovClient::with_config(config).expect("test client must build")
718    }
719
720    #[tokio::test]
721    async fn validate_download_dir_accepts_existing_writable_directory() {
722        let tmp = tempfile::tempdir().expect("tempdir");
723        let client = client_with_download_dir(tmp.path().to_path_buf());
724        client
725            .validate_download_dir()
726            .await
727            .expect("should succeed");
728    }
729
730    #[tokio::test]
731    async fn validate_download_dir_creates_missing_directory() {
732        let tmp = tempfile::tempdir().expect("tempdir");
733        let nested = tmp.path().join("a").join("b").join("c");
734        let client = client_with_download_dir(nested.clone());
735        client
736            .validate_download_dir()
737            .await
738            .expect("should succeed");
739        assert!(nested.is_dir());
740    }
741
742    #[tokio::test]
743    async fn validate_download_dir_rejects_path_that_is_a_file() {
744        let tmp = tempfile::tempdir().expect("tempdir");
745        let file_path = tmp.path().join("not-a-dir.txt");
746        tokio::fs::write(&file_path, b"hello").await.expect("setup");
747
748        let client = client_with_download_dir(file_path);
749        let err = client.validate_download_dir().await.unwrap_err();
750        match err {
751            DataGovError::ConfigError { message } => {
752                assert!(message.contains("not a directory"), "got: {message}");
753            }
754            other => panic!("expected ConfigError, got {other:?}"),
755        }
756    }
757
758    #[tokio::test]
759    async fn validate_download_dir_leaves_no_probe_file_behind() {
760        let tmp = tempfile::tempdir().expect("tempdir");
761        let client = client_with_download_dir(tmp.path().to_path_buf());
762        client
763            .validate_download_dir()
764            .await
765            .expect("should succeed");
766        assert!(!tmp.path().join(".write_test").exists());
767    }
768}