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#[derive(Debug)]
26pub struct DataGovClient {
27 catalog: CatalogClient,
28 config: DataGovConfig,
29 http_client: reqwest::Client,
30}
31
32impl DataGovClient {
33 pub fn new() -> Result<Self> {
35 Self::with_config(DataGovConfig::new())
36 }
37
38 pub fn config(&self) -> &DataGovConfig {
40 &self.config
41 }
42
43 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 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 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 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 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 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 pub async fn list_organization_records(&self) -> Result<Vec<Organization>> {
151 Ok(self.catalog.organizations().await?.organizations)
152 }
153
154 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 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 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 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 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 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 pub fn download_dir(&self) -> PathBuf {
554 self.config.get_base_download_dir()
555 }
556
557 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 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}