1use rskit_errors::{AppError, AppResult, ErrorCode};
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct PublishResult {
10 pub target_name: String,
12 pub location: String,
14 pub files_published: usize,
16 pub message: String,
18}
19
20#[async_trait::async_trait]
22pub trait Target: Send + Sync {
23 fn name(&self) -> &str;
25
26 async fn publish(
28 &self,
29 directory: &Path,
30 metadata: Option<&std::collections::HashMap<String, String>>,
31 ) -> AppResult<PublishResult>;
32}
33
34pub struct LocalTarget;
36
37#[async_trait::async_trait]
38impl Target for LocalTarget {
39 fn name(&self) -> &str {
40 "local"
41 }
42
43 async fn publish(
44 &self,
45 directory: &Path,
46 _metadata: Option<&std::collections::HashMap<String, String>>,
47 ) -> AppResult<PublishResult> {
48 let directory = directory.to_path_buf();
49 let file_count = tokio::task::spawn_blocking({
50 let directory = directory.clone();
51 move || {
52 let mut file_count = 0usize;
53 if directory.exists() {
54 for entry in walkdir(&directory)? {
55 if entry.is_file() {
56 file_count += 1;
57 }
58 }
59 }
60 Ok::<usize, AppError>(file_count)
61 }
62 })
63 .await
64 .map_err(AppError::internal)??;
65 Ok(PublishResult {
66 target_name: self.name().to_string(),
67 location: directory.display().to_string(),
68 files_published: file_count,
69 message: format!("Data saved to {}", directory.display()),
70 })
71 }
72}
73
74fn walkdir(dir: &Path) -> AppResult<Vec<std::path::PathBuf>> {
75 let mut files = Vec::new();
76 if dir.is_dir() {
77 for entry in std::fs::read_dir(dir)
78 .map_err(|e| AppError::new(ErrorCode::Internal, format!("read dir failed: {e}")))?
79 {
80 let entry = entry
81 .map_err(|e| AppError::new(ErrorCode::Internal, format!("dir entry error: {e}")))?;
82 let path = entry.path();
83 if path.is_dir() {
84 files.extend(walkdir(&path)?);
85 } else {
86 files.push(path);
87 }
88 }
89 }
90 Ok(files)
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96
97 #[tokio::test]
98 async fn local_target_counts_nested_files_and_missing_directory_as_empty() {
99 let dir = tempfile::tempdir().unwrap();
100 std::fs::create_dir(dir.path().join("nested")).unwrap();
101 std::fs::write(dir.path().join("a.txt"), b"a").unwrap();
102 std::fs::write(dir.path().join("nested").join("b.txt"), b"b").unwrap();
103
104 let target = LocalTarget;
105 let result = target.publish(dir.path(), None).await.unwrap();
106
107 assert_eq!(target.name(), "local");
108 assert_eq!(result.target_name, "local");
109 assert_eq!(result.files_published, 2);
110 assert!(result.message.contains("Data saved"));
111
112 let missing = dir.path().join("missing");
113 let result = target.publish(&missing, None).await.unwrap();
114 assert_eq!(result.files_published, 0);
115 }
116
117 #[test]
118 fn walkdir_rejects_file_as_directory_with_empty_listing() {
119 let dir = tempfile::tempdir().unwrap();
120 let file = dir.path().join("file.txt");
121 std::fs::write(&file, b"x").unwrap();
122
123 assert!(walkdir(&file).unwrap().is_empty());
124 }
125}