Skip to main content

cranpose_services/
content.rs

1//! Streaming content: the single shape every byte source hands the application.
2//!
3//! File picking, incoming shares, document intents, dropped files and media
4//! sources all resolve to a [`ContentHandle`]. A handle carries
5//! [`ContentMetadata`] and opens a [`ContentReader`] that yields chunks, so a
6//! multi-gigabyte media file and a two-byte clipboard drop travel the same API
7//! and neither forces the application to buffer a whole payload.
8//!
9//! Folders resolve to a [`ContentFolderRef`]. Their trees are consumed through
10//! [`folder_files`], which returns a [`ContentStream`]: an asynchronous stream
11//! whose `next` future wakes its collector when the provider discovers another
12//! file. Nothing here polls.
13
14use std::{
15    cell::RefCell,
16    collections::VecDeque,
17    future::Future,
18    pin::Pin,
19    rc::Rc,
20    task::{Context, Poll, Waker},
21};
22
23/// Chunk size the framework's own readers hand back per [`ContentReader::read_chunk`].
24pub const DEFAULT_CHUNK_LEN: usize = 256 * 1024;
25
26/// A future produced by a content operation. It borrows the content it reads
27/// from, so no implementation has to clone a handle into every call.
28pub type ContentFuture<'a, T> = Pin<Box<dyn Future<Output = T> + 'a>>;
29
30/// Errors produced while reading or writing content.
31#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
32pub enum ContentError {
33    /// The content no longer exists at its source.
34    #[error("content not found: {0}")]
35    NotFound(String),
36    /// The provider refused the read or write.
37    #[error("content access denied: {0}")]
38    PermissionDenied(String),
39    /// Any other provider failure.
40    #[error("content i/o failed: {0}")]
41    Io(String),
42    /// The operation is not offered by this platform or build.
43    #[error("{0} is not supported on this platform")]
44    Unsupported(&'static str),
45}
46
47/// Everything a provider can state about a piece of content without reading it.
48#[derive(Clone, Debug, Default, PartialEq, Eq)]
49pub struct ContentMetadata {
50    /// Display name, usually the last path or URI component.
51    pub name: String,
52    /// MIME type when the provider reports one.
53    pub mime_type: Option<String>,
54    /// Byte length when the provider reports one.
55    pub len: Option<u64>,
56    /// Last-modified time in milliseconds since the Unix epoch, when known.
57    pub modified_millis: Option<u64>,
58    /// Provider-scoped identifier: a path, a `content://` URI, a blob name.
59    /// Not guaranteed to be openable outside its provider.
60    pub identifier: String,
61}
62
63impl ContentMetadata {
64    /// Metadata carrying only a display name.
65    pub fn named(name: impl Into<String>) -> Self {
66        let name = name.into();
67        Self {
68            identifier: name.clone(),
69            name,
70            ..Self::default()
71        }
72    }
73
74    /// Sets the MIME type.
75    pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
76        self.mime_type = Some(mime_type.into());
77        self
78    }
79
80    /// Sets the byte length.
81    pub fn with_len(mut self, len: u64) -> Self {
82        self.len = Some(len);
83        self
84    }
85
86    /// Sets the last-modified time in milliseconds since the Unix epoch.
87    pub fn with_modified_millis(mut self, modified_millis: u64) -> Self {
88        self.modified_millis = Some(modified_millis);
89        self
90    }
91
92    /// Sets the provider-scoped identifier.
93    pub fn with_identifier(mut self, identifier: impl Into<String>) -> Self {
94        self.identifier = identifier.into();
95        self
96    }
97
98    /// The lowercase extension of [`name`](Self::name), without the dot.
99    pub fn extension(&self) -> Option<String> {
100        let (_, extension) = self.name.rsplit_once('.')?;
101        if extension.is_empty() {
102            None
103        } else {
104            Some(extension.to_ascii_lowercase())
105        }
106    }
107}
108
109/// A pull reader over one piece of content.
110///
111/// Each [`read_chunk`](ContentReader::read_chunk) resolves to the next chunk of
112/// bytes, or `None` once the content is exhausted. Chunk sizes are chosen by the
113/// provider; framework readers use [`DEFAULT_CHUNK_LEN`].
114pub trait ContentReader {
115    /// Resolves to the next chunk, or `None` at end of content.
116    fn read_chunk(&self) -> ContentFuture<'_, Result<Option<Vec<u8>>, ContentError>>;
117}
118
119/// Shared handle to a [`ContentReader`].
120pub type ContentReaderRef = Rc<dyn ContentReader>;
121
122/// One readable piece of content from any source.
123pub trait Content {
124    /// What the provider states about this content without reading it.
125    fn metadata(&self) -> ContentMetadata;
126
127    /// Opens a chunked reader over the bytes.
128    fn open(&self) -> ContentFuture<'_, Result<ContentReaderRef, ContentError>>;
129
130    /// Reads every byte. The default drains [`open`](Content::open); providers
131    /// already holding the bytes override it to avoid the copy.
132    fn read_all(&self) -> ContentFuture<'_, Result<Vec<u8>, ContentError>> {
133        Box::pin(async move {
134            let reader = self.open().await?;
135            drain_reader(&reader).await
136        })
137    }
138}
139
140/// Shared handle to a [`Content`].
141pub type ContentHandle = Rc<dyn Content>;
142
143/// Reads a reader to its end.
144pub async fn drain_reader(reader: &ContentReaderRef) -> Result<Vec<u8>, ContentError> {
145    let mut bytes = Vec::new();
146    while let Some(chunk) = reader.read_chunk().await? {
147        bytes.extend_from_slice(&chunk);
148    }
149    Ok(bytes)
150}
151
152/// A writable destination for streamed content.
153pub trait ContentSink {
154    /// Appends `bytes` to the destination.
155    fn write_chunk(&self, bytes: Vec<u8>) -> ContentFuture<'_, Result<(), ContentError>>;
156
157    /// Commits the destination. Dropping a sink without finishing it discards
158    /// whatever the provider allows it to discard.
159    fn finish(&self) -> ContentFuture<'_, Result<(), ContentError>>;
160}
161
162/// Shared handle to a [`ContentSink`].
163pub type ContentSinkRef = Rc<dyn ContentSink>;
164
165/// Writes every byte of `bytes` into `sink` and commits it.
166pub async fn write_all(sink: &ContentSinkRef, bytes: Vec<u8>) -> Result<(), ContentError> {
167    sink.write_chunk(bytes).await?;
168    sink.finish().await
169}
170
171/// One child of a [`ContentFolder`].
172pub enum ContentEntry {
173    /// A readable file.
174    File(ContentHandle),
175    /// A nested folder.
176    Folder(ContentFolderRef),
177}
178
179impl ContentEntry {
180    /// The child's metadata regardless of kind.
181    pub fn metadata(&self) -> ContentMetadata {
182        match self {
183            ContentEntry::File(file) => file.metadata(),
184            ContentEntry::Folder(folder) => folder.metadata(),
185        }
186    }
187}
188
189/// A folder granted by a provider.
190pub trait ContentFolder {
191    /// What the provider states about this folder.
192    fn metadata(&self) -> ContentMetadata;
193
194    /// The immediate children.
195    fn entries(&self) -> ContentFuture<'_, Result<Vec<ContentEntry>, ContentError>>;
196
197    /// A provider-native stream over every file in the tree, when the provider
198    /// discovers files incrementally. Providers that enumerate eagerly return
199    /// `None` and [`folder_files`] walks them instead.
200    fn stream_files(&self) -> Option<ContentStreamRef> {
201        None
202    }
203}
204
205/// Shared handle to a [`ContentFolder`].
206pub type ContentFolderRef = Rc<dyn ContentFolder>;
207
208/// An asynchronous stream of content.
209///
210/// [`next`](ContentStream::next) resolves once the next item is available; the
211/// producer wakes the pending future, so collectors never poll.
212pub trait ContentStream {
213    /// Resolves to the next item, or `None` once the stream is exhausted.
214    fn next(&self) -> ContentFuture<'_, Result<Option<ContentHandle>, ContentError>>;
215
216    /// How many items the provider has produced so far, when it counts them.
217    fn produced(&self) -> Option<usize> {
218        None
219    }
220}
221
222/// Shared handle to a [`ContentStream`].
223pub type ContentStreamRef = Rc<dyn ContentStream>;
224
225/// Streams every file in `folder`'s tree, using the provider's native stream
226/// when it has one and a depth-first walk otherwise.
227pub fn folder_files(folder: ContentFolderRef) -> ContentStreamRef {
228    folder
229        .stream_files()
230        .unwrap_or_else(|| Rc::new(WalkStream::new(folder)))
231}
232
233/// Collects an entire stream. Convenient for tests and small trees; production
234/// collectors consume [`ContentStream::next`] incrementally.
235pub async fn collect_stream(stream: &ContentStreamRef) -> Result<Vec<ContentHandle>, ContentError> {
236    let mut items = Vec::new();
237    while let Some(item) = stream.next().await? {
238        items.push(item);
239    }
240    Ok(items)
241}
242
243/// Depth-first walk over a folder that enumerates eagerly.
244struct WalkStream {
245    pending: RefCell<Vec<ContentFolderRef>>,
246    ready: RefCell<VecDeque<ContentHandle>>,
247    produced: std::cell::Cell<usize>,
248}
249
250impl WalkStream {
251    fn new(root: ContentFolderRef) -> Self {
252        Self {
253            pending: RefCell::new(vec![root]),
254            ready: RefCell::new(VecDeque::new()),
255            produced: std::cell::Cell::new(0),
256        }
257    }
258}
259
260impl ContentStream for WalkStream {
261    fn next(&self) -> ContentFuture<'_, Result<Option<ContentHandle>, ContentError>> {
262        Box::pin(async move {
263            loop {
264                if let Some(file) = self.ready.borrow_mut().pop_front() {
265                    self.produced.set(self.produced.get() + 1);
266                    return Ok(Some(file));
267                }
268                let Some(folder) = self.pending.borrow_mut().pop() else {
269                    return Ok(None);
270                };
271                for entry in folder.entries().await? {
272                    match entry {
273                        ContentEntry::File(file) => self.ready.borrow_mut().push_back(file),
274                        ContentEntry::Folder(child) => self.pending.borrow_mut().push(child),
275                    }
276                }
277            }
278        })
279    }
280
281    fn produced(&self) -> Option<usize> {
282        Some(self.produced.get())
283    }
284}
285
286/// The producing half of a [`ContentStream`] fed by a platform callback.
287///
288/// A backend that receives items from outside the composition — an Android
289/// enumeration callback, a drop target, a download — pushes into the channel and
290/// the pending collector future is woken. This is the replacement for every
291/// "drain what is ready each frame" contract.
292pub struct ContentChannel {
293    shared: Rc<ChannelShared>,
294}
295
296struct ChannelShared {
297    ready: RefCell<VecDeque<ContentHandle>>,
298    error: RefCell<Option<ContentError>>,
299    closed: std::cell::Cell<bool>,
300    produced: std::cell::Cell<usize>,
301    waker: RefCell<Option<Waker>>,
302}
303
304impl ChannelShared {
305    fn wake(&self) {
306        if let Some(waker) = self.waker.borrow_mut().take() {
307            waker.wake();
308        }
309    }
310}
311
312impl Default for ContentChannel {
313    fn default() -> Self {
314        Self::new()
315    }
316}
317
318impl ContentChannel {
319    /// Creates an open channel.
320    pub fn new() -> Self {
321        Self {
322            shared: Rc::new(ChannelShared {
323                ready: RefCell::new(VecDeque::new()),
324                error: RefCell::new(None),
325                closed: std::cell::Cell::new(false),
326                produced: std::cell::Cell::new(0),
327                waker: RefCell::new(None),
328            }),
329        }
330    }
331
332    /// The consuming half, handed to the application.
333    pub fn stream(&self) -> ContentStreamRef {
334        Rc::new(ChannelStream {
335            shared: Rc::clone(&self.shared),
336        })
337    }
338
339    /// Publishes one item and wakes a pending collector.
340    pub fn push(&self, content: ContentHandle) {
341        if self.shared.closed.get() {
342            return;
343        }
344        self.shared.ready.borrow_mut().push_back(content);
345        self.shared.wake();
346    }
347
348    /// Ends the stream with an error and wakes a pending collector.
349    pub fn fail(&self, error: ContentError) {
350        if self.shared.closed.get() {
351            return;
352        }
353        *self.shared.error.borrow_mut() = Some(error);
354        self.shared.closed.set(true);
355        self.shared.wake();
356    }
357
358    /// Ends the stream normally and wakes a pending collector.
359    pub fn close(&self) {
360        if self.shared.closed.get() {
361            return;
362        }
363        self.shared.closed.set(true);
364        self.shared.wake();
365    }
366
367    /// Whether the stream has been ended.
368    pub fn is_closed(&self) -> bool {
369        self.shared.closed.get()
370    }
371}
372
373struct ChannelStream {
374    shared: Rc<ChannelShared>,
375}
376
377impl ContentStream for ChannelStream {
378    fn next(&self) -> ContentFuture<'_, Result<Option<ContentHandle>, ContentError>> {
379        Box::pin(ChannelNext {
380            shared: Rc::clone(&self.shared),
381        })
382    }
383
384    fn produced(&self) -> Option<usize> {
385        Some(self.shared.produced.get())
386    }
387}
388
389struct ChannelNext {
390    shared: Rc<ChannelShared>,
391}
392
393impl Future for ChannelNext {
394    type Output = Result<Option<ContentHandle>, ContentError>;
395
396    fn poll(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
397        if let Some(item) = self.shared.ready.borrow_mut().pop_front() {
398            self.shared.produced.set(self.shared.produced.get() + 1);
399            return Poll::Ready(Ok(Some(item)));
400        }
401        if let Some(error) = self.shared.error.borrow_mut().take() {
402            return Poll::Ready(Err(error));
403        }
404        if self.shared.closed.get() {
405            return Poll::Ready(Ok(None));
406        }
407        *self.shared.waker.borrow_mut() = Some(context.waker().clone());
408        Poll::Pending
409    }
410}
411
412/// Content already held in memory: clipboard drops, web `File` payloads, tests.
413pub struct BytesContent {
414    metadata: ContentMetadata,
415    bytes: Rc<Vec<u8>>,
416}
417
418impl BytesContent {
419    /// Wraps `bytes` under `metadata`, filling in the byte length.
420    pub fn new(metadata: ContentMetadata, bytes: Vec<u8>) -> Self {
421        let len = bytes.len() as u64;
422        Self {
423            metadata: ContentMetadata {
424                len: Some(len),
425                ..metadata
426            },
427            bytes: Rc::new(bytes),
428        }
429    }
430
431    /// Wraps `bytes` under a display name.
432    pub fn named(name: impl Into<String>, bytes: Vec<u8>) -> Self {
433        Self::new(ContentMetadata::named(name), bytes)
434    }
435
436    /// A shared handle to this content.
437    pub fn handle(self) -> ContentHandle {
438        Rc::new(self)
439    }
440}
441
442impl Content for BytesContent {
443    fn metadata(&self) -> ContentMetadata {
444        self.metadata.clone()
445    }
446
447    fn open(&self) -> ContentFuture<'_, Result<ContentReaderRef, ContentError>> {
448        let bytes = Rc::clone(&self.bytes);
449        Box::pin(async move {
450            Ok(Rc::new(BytesReader {
451                bytes,
452                offset: std::cell::Cell::new(0),
453            }) as ContentReaderRef)
454        })
455    }
456
457    fn read_all(&self) -> ContentFuture<'_, Result<Vec<u8>, ContentError>> {
458        let bytes = Rc::clone(&self.bytes);
459        Box::pin(async move { Ok(bytes.as_ref().clone()) })
460    }
461}
462
463struct BytesReader {
464    bytes: Rc<Vec<u8>>,
465    offset: std::cell::Cell<usize>,
466}
467
468impl ContentReader for BytesReader {
469    fn read_chunk(&self) -> ContentFuture<'_, Result<Option<Vec<u8>>, ContentError>> {
470        Box::pin(async move {
471            let start = self.offset.get();
472            if start >= self.bytes.len() {
473                return Ok(None);
474            }
475            let end = (start + DEFAULT_CHUNK_LEN).min(self.bytes.len());
476            self.offset.set(end);
477            Ok(Some(self.bytes[start..end].to_vec()))
478        })
479    }
480}
481
482/// A folder whose children are already known.
483pub struct ReadyFolder {
484    metadata: ContentMetadata,
485    entries: RefCell<Option<Vec<ContentEntry>>>,
486}
487
488impl ReadyFolder {
489    /// Wraps already-enumerated `entries` under `metadata`.
490    pub fn new(metadata: ContentMetadata, entries: Vec<ContentEntry>) -> Self {
491        Self {
492            metadata,
493            entries: RefCell::new(Some(entries)),
494        }
495    }
496
497    /// A shared handle to this folder.
498    pub fn handle(self) -> ContentFolderRef {
499        Rc::new(self)
500    }
501}
502
503impl ContentFolder for ReadyFolder {
504    fn metadata(&self) -> ContentMetadata {
505        self.metadata.clone()
506    }
507
508    fn entries(&self) -> ContentFuture<'_, Result<Vec<ContentEntry>, ContentError>> {
509        Box::pin(async move {
510            self.entries
511                .borrow_mut()
512                .take()
513                .ok_or_else(|| ContentError::Io("folder entries already consumed".into()))
514        })
515    }
516}
517
518/// Turns a provider URI into readable content.
519///
520/// Every source that names content by URI rather than handing over bytes —
521/// a document intent, a shared item, a dropped file, a media source — resolves
522/// through this, so applications never learn what a `content://`, `file://` or
523/// blob URI means on the current platform.
524pub trait ContentResolver {
525    /// Resolves `uri`, or `None` when this platform cannot open it.
526    fn resolve(&self, uri: &str) -> Option<ContentHandle>;
527}
528
529/// Shared handle to a [`ContentResolver`].
530pub type ContentResolverRef = Rc<dyn ContentResolver>;
531
532thread_local! {
533    static PLATFORM_RESOLVER: RefCell<Option<ContentResolverRef>> = const { RefCell::new(None) };
534}
535
536/// Installs the platform content resolver (Android's `ContentResolver`, the
537/// iOS document store, the browser's blob registry).
538pub fn set_platform_content_resolver(resolver: ContentResolverRef) {
539    PLATFORM_RESOLVER.with(|cell| *cell.borrow_mut() = Some(resolver));
540}
541
542/// Removes any installed platform content resolver.
543pub fn clear_platform_content_resolver() {
544    PLATFORM_RESOLVER.with(|cell| *cell.borrow_mut() = None);
545}
546
547/// Resolves `uri` through the platform resolver, falling back to the local
548/// filesystem for `file://` URIs and plain paths.
549pub fn resolve_content(uri: &str) -> Option<ContentHandle> {
550    if let Some(resolver) = PLATFORM_RESOLVER.with(|cell| cell.borrow().clone()) {
551        if let Some(content) = resolver.resolve(uri) {
552            return Some(content);
553        }
554    }
555    #[cfg(not(target_arch = "wasm32"))]
556    {
557        let path = uri.strip_prefix("file://").unwrap_or(uri);
558        if !path.contains("://") {
559            return Some(file_content(path));
560        }
561    }
562    let _ = uri;
563    None
564}
565
566#[cfg(not(target_arch = "wasm32"))]
567pub use file::{file_content, file_folder, FileContent, FileFolder, FileSink};
568
569#[cfg(not(target_arch = "wasm32"))]
570mod file;
571
572/// Decodes the percent-escapes in a platform content URI, refusing input it
573/// cannot decode exactly.
574///
575/// Android hands a folder or document back as an opaque `content://` URI whose
576/// readable name is percent-encoded inside it, so anything that wants to show
577/// the user which folder they picked has to decode it. The pair here mirrors
578/// [`String::from_utf8`] and [`String::from_utf8_lossy`]: this one answers
579/// `None` rather than inventing a character, and [`percent_decode_lossy`] is
580/// the best-effort form for text that is only going to be displayed.
581///
582/// Prefer this when the result is used as an identity -- a key, a filename, a
583/// fingerprint -- where a replacement character would silently make two
584/// different inputs look the same.
585pub fn percent_decode(input: &str) -> Option<String> {
586    let bytes = input.as_bytes();
587    let mut out = Vec::with_capacity(bytes.len());
588    let mut index = 0;
589    while index < bytes.len() {
590        if bytes[index] == b'%' {
591            let high = bytes.get(index + 1).copied().and_then(hex_value)?;
592            let low = bytes.get(index + 2).copied().and_then(hex_value)?;
593            out.push((high << 4) | low);
594            index += 3;
595        } else {
596            out.push(bytes[index]);
597            index += 1;
598        }
599    }
600    String::from_utf8(out).ok()
601}
602
603/// Decodes the percent-escapes in a platform content URI for display.
604///
605/// A byte sequence that is not valid UTF-8 becomes U+FFFD, and an escape that
606/// is truncated or not hexadecimal is passed through as written rather than
607/// discarded -- a name is more useful slightly wrong than absent. Use
608/// [`percent_decode`] when the result carries identity rather than being shown.
609pub fn percent_decode_lossy(input: &str) -> String {
610    let bytes = input.as_bytes();
611    let mut out = Vec::with_capacity(bytes.len());
612    let mut index = 0;
613    while index < bytes.len() {
614        if bytes[index] == b'%' {
615            let pair = hex_value(bytes.get(index + 1).copied().unwrap_or(0))
616                .zip(hex_value(bytes.get(index + 2).copied().unwrap_or(0)));
617            if let Some((high, low)) = pair {
618                out.push((high << 4) | low);
619                index += 3;
620                continue;
621            }
622        }
623        out.push(bytes[index]);
624        index += 1;
625    }
626    String::from_utf8_lossy(&out).into_owned()
627}
628
629fn hex_value(byte: u8) -> Option<u8> {
630    match byte {
631        b'0'..=b'9' => Some(byte - b'0'),
632        b'a'..=b'f' => Some(byte - b'a' + 10),
633        b'A'..=b'F' => Some(byte - b'A' + 10),
634        _ => None,
635    }
636}
637
638#[cfg(test)]
639mod tests {
640
641    /// The two decoders answered differently across the codebase before they
642    /// were one pair: a strict one that refused what it could not decode and a
643    /// lossy one that substituted. Both behaviours are wanted -- a display name
644    /// should survive a bad byte, an identity must not -- so the split is
645    /// deliberate and pinned here rather than left to whichever copy a caller
646    /// happened to reach for.
647    #[test]
648    fn a_uri_that_cannot_be_decoded_exactly_is_refused_but_still_displays() {
649        assert_eq!(
650            percent_decode("Trip%20Photos").as_deref(),
651            Some("Trip Photos")
652        );
653        assert_eq!(percent_decode_lossy("Trip%20Photos"), "Trip Photos");
654
655        // %FF is a valid escape but not valid UTF-8 on its own.
656        assert_eq!(percent_decode("bad%FFname"), None);
657        assert_eq!(percent_decode_lossy("bad%FFname"), "bad\u{fffd}name");
658
659        // A truncated escape is malformed input, not data.
660        assert_eq!(percent_decode("cut%4"), None);
661        assert_eq!(percent_decode_lossy("cut%4"), "cut%4");
662
663        // A non-hexadecimal escape is passed through by the lossy form.
664        assert_eq!(percent_decode("100%zz"), None);
665        assert_eq!(percent_decode_lossy("100%zz"), "100%zz");
666    }
667
668    use super::*;
669
670    fn block<T>(future: impl Future<Output = T>) -> T {
671        pollster::block_on(future)
672    }
673
674    #[test]
675    fn metadata_reports_the_lowercase_extension() {
676        let metadata = ContentMetadata::named("Scan.PNG");
677        assert_eq!(metadata.extension().as_deref(), Some("png"));
678        assert_eq!(ContentMetadata::named("noext").extension(), None);
679        assert_eq!(ContentMetadata::named("trailing.").extension(), None);
680    }
681
682    #[test]
683    fn metadata_carries_what_a_provider_knew_about_the_item() {
684        let metadata = ContentMetadata::named("Scan.png")
685            .with_len(2_048)
686            .with_modified_millis(1_700_000_000_000)
687            .with_identifier("content://provider/17")
688            .with_mime_type("image/png");
689
690        assert_eq!(metadata.name, "Scan.png");
691        assert_eq!(metadata.len, Some(2_048));
692        assert_eq!(metadata.modified_millis, Some(1_700_000_000_000));
693        assert_eq!(metadata.identifier, "content://provider/17");
694        assert_eq!(metadata.mime_type.as_deref(), Some("image/png"));
695        // A size or a timestamp a provider did not report stays unknown rather
696        // than becoming zero; an item with no provider-scoped identifier is
697        // identified by its name, which is the only handle there is on it.
698        let bare = ContentMetadata::named("Scan.png");
699        assert_eq!(bare.len, None);
700        assert_eq!(bare.modified_millis, None);
701        assert_eq!(bare.mime_type, None);
702        assert_eq!(bare.identifier, "Scan.png");
703    }
704
705    #[test]
706    fn a_channel_reports_when_it_has_ended_and_ignores_what_arrives_after() {
707        let channel = ContentChannel::new();
708        assert!(!channel.is_closed());
709
710        channel.close();
711        assert!(channel.is_closed());
712
713        // A producer that has not noticed yet must not be able to reopen it.
714        channel.push(BytesContent::named("late.bin", vec![1]).handle());
715        channel.fail(ContentError::Unsupported("after close"));
716        assert!(channel.is_closed());
717
718        let stream = channel.stream();
719        assert!(
720            block(stream.next())
721                .expect("a closed channel ends cleanly")
722                .is_none(),
723            "a channel closed before anything was pushed must yield nothing"
724        );
725    }
726
727    #[test]
728    fn bytes_content_streams_in_chunks_and_reads_whole() {
729        let payload = vec![7u8; DEFAULT_CHUNK_LEN + 11];
730        let content = BytesContent::named("blob.bin", payload.clone()).handle();
731        assert_eq!(content.metadata().len, Some(payload.len() as u64));
732
733        let chunks = block(async {
734            let reader = content.open().await.unwrap();
735            let mut sizes = Vec::new();
736            while let Some(chunk) = reader.read_chunk().await.unwrap() {
737                sizes.push(chunk.len());
738            }
739            sizes
740        });
741        assert_eq!(chunks, vec![DEFAULT_CHUNK_LEN, 11]);
742        assert_eq!(block(content.read_all()).unwrap(), payload);
743    }
744
745    #[test]
746    fn walking_a_tree_yields_every_file_depth_first() {
747        let nested = ReadyFolder::new(
748            ContentMetadata::named("nested"),
749            vec![ContentEntry::File(
750                BytesContent::named("b.txt", b"b".to_vec()).handle(),
751            )],
752        )
753        .handle();
754        let root = ReadyFolder::new(
755            ContentMetadata::named("root"),
756            vec![
757                ContentEntry::File(BytesContent::named("a.txt", b"a".to_vec()).handle()),
758                ContentEntry::Folder(nested),
759            ],
760        )
761        .handle();
762
763        let stream = folder_files(root);
764        let files = block(collect_stream(&stream)).unwrap();
765        let names: Vec<String> = files.iter().map(|file| file.metadata().name).collect();
766        assert_eq!(names, vec!["a.txt", "b.txt"]);
767        assert_eq!(stream.produced(), Some(2));
768    }
769
770    #[test]
771    fn a_channel_wakes_its_collector_instead_of_being_polled() {
772        let channel = Rc::new(ContentChannel::new());
773        let stream = channel.stream();
774
775        let mut future = Box::pin(stream.next());
776        let waker = Waker::noop().clone();
777        let mut context = Context::from_waker(&waker);
778        assert!(future.as_mut().poll(&mut context).is_pending());
779
780        channel.push(BytesContent::named("late.txt", b"late".to_vec()).handle());
781        let Poll::Ready(Ok(Some(item))) = future.as_mut().poll(&mut context) else {
782            panic!("the pushed item should have completed the pending collector");
783        };
784        assert_eq!(item.metadata().name, "late.txt");
785
786        channel.close();
787        assert!(matches!(block(stream.next()), Ok(None)));
788        assert_eq!(stream.produced(), Some(1));
789    }
790
791    #[test]
792    fn a_failed_channel_reports_the_error_once_then_ends() {
793        let channel = ContentChannel::new();
794        let stream = channel.stream();
795        channel.fail(ContentError::Io("provider died".into()));
796        let Err(failure) = block(stream.next()) else {
797            panic!("a failed channel reports its error to the collector");
798        };
799        assert_eq!(failure, ContentError::Io("provider died".into()));
800        assert!(matches!(block(stream.next()), Ok(None)));
801    }
802}