Skip to main content

kithara_file/
config.rs

1use std::{fmt, path::PathBuf};
2
3use bon::Builder;
4use kithara_assets::StoreOptions;
5use kithara_events::EventBus;
6use kithara_net::Headers;
7use kithara_stream::dl::Downloader;
8use tokio_util::sync::CancellationToken;
9use url::Url;
10
11/// Source of a file stream: either a remote URL or a local path.
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub enum FileSrc {
14    /// Remote file accessed via HTTP(S).
15    Remote(Url),
16    /// Local file accessed directly from disk.
17    Local(PathBuf),
18}
19
20impl From<Url> for FileSrc {
21    fn from(url: Url) -> Self {
22        Self::Remote(url)
23    }
24}
25
26impl From<PathBuf> for FileSrc {
27    fn from(path: PathBuf) -> Self {
28        Self::Local(path)
29    }
30}
31
32/// Configuration for file streaming.
33///
34/// Used with `Stream::<File>::new(config)`.
35#[derive(Clone, Builder)]
36#[builder(state_mod(vis = "pub"))]
37#[non_exhaustive]
38pub struct FileConfig {
39    /// File source (remote URL or local path).
40    pub src: FileSrc,
41    /// Event bus (optional - if not provided, one is created internally).
42    #[builder(name = events)]
43    pub bus: Option<EventBus>,
44    /// Cancellation token for graceful shutdown.
45    pub cancel: Option<CancellationToken>,
46    /// Shared downloader (created lazily if not provided).
47    pub downloader: Option<Downloader>,
48    /// Additional HTTP headers to include in all requests.
49    pub headers: Option<Headers>,
50    /// Max bytes the downloader may be ahead of the reader before it pauses.
51    pub look_ahead_bytes: Option<u64>,
52    /// Optional name for cache disambiguation.
53    pub name: Option<String>,
54    /// Storage configuration.
55    #[builder(default)]
56    pub store: StoreOptions,
57    /// Event bus channel capacity (used when `bus` is not provided).
58    #[builder(default = kithara_events::DEFAULT_EVENT_BUS_CAPACITY)]
59    pub event_channel_capacity: usize,
60}
61
62impl fmt::Debug for FileConfig {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        f.debug_struct("FileConfig")
65            .field("src", &self.src)
66            .field("bus", &self.bus)
67            .field("cancel", &self.cancel)
68            .field("headers", &self.headers)
69            .field("look_ahead_bytes", &self.look_ahead_bytes)
70            .field("name", &self.name)
71            .field("store", &self.store)
72            .field("event_channel_capacity", &self.event_channel_capacity)
73            .finish_non_exhaustive()
74    }
75}
76
77impl Default for FileConfig {
78    fn default() -> Self {
79        let url = Url::parse("http://localhost/audio.mp3").expect("valid default URL");
80        Self::for_src(FileSrc::Remote(url)).build()
81    }
82}
83
84impl FileConfig {
85    /// Create new file config with source.
86    #[must_use]
87    pub fn new(src: FileSrc) -> Self {
88        Self::for_src(src).build()
89    }
90
91    /// Chainable counterpart to [`FileConfig::new`].
92    pub fn for_src(src: FileSrc) -> FileConfigBuilder<file_config_builder::SetSrc> {
93        Self::builder().src(src)
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use std::path::Path;
100
101    use kithara_test_utils::kithara;
102
103    use super::*;
104
105    fn test_src() -> FileSrc {
106        FileSrc::Remote(Url::parse("http://example.com/audio.mp3").unwrap())
107    }
108
109    #[kithara::test]
110    #[case(test_src())]
111    #[case(FileSrc::Local(PathBuf::from("/tmp/song.mp3")))]
112    fn test_file_config_new_preserves_source(#[case] src: FileSrc) {
113        let config = FileConfig::new(src.clone());
114
115        assert_eq!(config.src, src);
116        assert!(config.bus.is_none());
117        assert!(config.cancel.is_none());
118        if let FileSrc::Local(path) = &config.src {
119            assert_eq!(path, Path::new("/tmp/song.mp3"));
120        }
121    }
122
123    #[kithara::test]
124    fn test_with_store() {
125        let store = StoreOptions::default();
126        let config = FileConfig::for_src(test_src()).store(store).build();
127
128        assert!(config.bus.is_none());
129    }
130
131    fn apply_cancel(mut config: FileConfig) -> FileConfig {
132        config.cancel = Some(CancellationToken::new());
133        config
134    }
135
136    fn apply_events(mut config: FileConfig) -> FileConfig {
137        config.bus = Some(EventBus::new(32));
138        config
139    }
140
141    fn apply_headers(mut config: FileConfig) -> FileConfig {
142        let mut headers = Headers::new();
143        headers.insert("Authorization", "Bearer token123");
144        config.headers = Some(headers);
145        config
146    }
147
148    fn has_cancel(config: &FileConfig) -> bool {
149        config.cancel.is_some()
150    }
151
152    fn has_bus(config: &FileConfig) -> bool {
153        config.bus.is_some()
154    }
155
156    fn has_auth_header(config: &FileConfig) -> bool {
157        config.headers.as_ref().and_then(|h| h.get("Authorization")) == Some("Bearer token123")
158    }
159
160    #[kithara::test]
161    #[case(apply_cancel, has_cancel)]
162    #[case(apply_events, has_bus)]
163    #[case(apply_headers, has_auth_header)]
164    fn test_optional_setters_update_expected_field(
165        #[case] apply: fn(FileConfig) -> FileConfig,
166        #[case] check: fn(&FileConfig) -> bool,
167    ) {
168        let config = apply(FileConfig::new(test_src()));
169        assert!(check(&config));
170    }
171
172    #[kithara::test]
173    fn test_builder_chain() {
174        let store = StoreOptions::default();
175        let cancel = CancellationToken::new();
176        let bus = EventBus::new(32);
177
178        let config = FileConfig::for_src(test_src())
179            .store(store)
180            .cancel(cancel.clone())
181            .events(bus)
182            .build();
183
184        assert!(config.cancel.is_some());
185        assert!(config.bus.is_some());
186    }
187
188    #[kithara::test]
189    #[case("stream-a")]
190    #[case("stream-b")]
191    fn test_with_name_sets_name(#[case] name: &str) {
192        let config = FileConfig::for_src(test_src())
193            .name(name.to_string())
194            .build();
195        assert_eq!(config.name.as_deref(), Some(name));
196    }
197
198    #[kithara::test]
199    fn test_debug_impl() {
200        let config = FileConfig::new(test_src());
201        let debug_str = format!("{:?}", config);
202
203        assert!(debug_str.contains("FileConfig"));
204    }
205
206    #[kithara::test]
207    fn test_clone() {
208        let bus = EventBus::new(32);
209        let config = FileConfig::for_src(test_src()).events(bus).build();
210
211        let cloned = config.clone();
212
213        assert!(cloned.bus.is_some());
214    }
215}