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
243struct WalkStream {
244    pending: RefCell<Vec<ContentFolderRef>>,
245    ready: RefCell<VecDeque<ContentHandle>>,
246    produced: std::cell::Cell<usize>,
247}
248
249impl WalkStream {
250    fn new(root: ContentFolderRef) -> Self {
251        Self {
252            pending: RefCell::new(vec![root]),
253            ready: RefCell::new(VecDeque::new()),
254            produced: std::cell::Cell::new(0),
255        }
256    }
257}
258
259impl ContentStream for WalkStream {
260    fn next(&self) -> ContentFuture<'_, Result<Option<ContentHandle>, ContentError>> {
261        Box::pin(async move {
262            loop {
263                if let Some(file) = self.ready.borrow_mut().pop_front() {
264                    self.produced.set(self.produced.get() + 1);
265                    return Ok(Some(file));
266                }
267                let Some(folder) = self.pending.borrow_mut().pop() else {
268                    return Ok(None);
269                };
270                for entry in folder.entries().await? {
271                    match entry {
272                        ContentEntry::File(file) => self.ready.borrow_mut().push_back(file),
273                        ContentEntry::Folder(child) => self.pending.borrow_mut().push(child),
274                    }
275                }
276            }
277        })
278    }
279
280    fn produced(&self) -> Option<usize> {
281        Some(self.produced.get())
282    }
283}
284
285/// The producing half of a [`ContentStream`] fed by a platform callback.
286///
287/// A backend that receives items from outside the composition — an Android
288/// enumeration callback, a drop target, a download — pushes into the channel and
289/// the pending collector future is woken. This is the replacement for every
290/// "drain what is ready each frame" contract.
291pub struct ContentChannel {
292    shared: Rc<ChannelShared>,
293}
294
295struct ChannelShared {
296    ready: RefCell<VecDeque<ContentHandle>>,
297    error: RefCell<Option<ContentError>>,
298    closed: std::cell::Cell<bool>,
299    produced: std::cell::Cell<usize>,
300    waker: RefCell<Option<Waker>>,
301}
302
303impl ChannelShared {
304    fn wake(&self) {
305        if let Some(waker) = self.waker.borrow_mut().take() {
306            waker.wake();
307        }
308    }
309}
310
311impl Default for ContentChannel {
312    fn default() -> Self {
313        Self::new()
314    }
315}
316
317impl ContentChannel {
318    /// Creates an open channel.
319    pub fn new() -> Self {
320        Self {
321            shared: Rc::new(ChannelShared {
322                ready: RefCell::new(VecDeque::new()),
323                error: RefCell::new(None),
324                closed: std::cell::Cell::new(false),
325                produced: std::cell::Cell::new(0),
326                waker: RefCell::new(None),
327            }),
328        }
329    }
330
331    /// The consuming half, handed to the application.
332    pub fn stream(&self) -> ContentStreamRef {
333        Rc::new(ChannelStream {
334            shared: Rc::clone(&self.shared),
335        })
336    }
337
338    /// Publishes one item and wakes a pending collector.
339    pub fn push(&self, content: ContentHandle) {
340        if self.shared.closed.get() {
341            return;
342        }
343        self.shared.ready.borrow_mut().push_back(content);
344        self.shared.wake();
345    }
346
347    /// Ends the stream with an error and wakes a pending collector.
348    pub fn fail(&self, error: ContentError) {
349        if self.shared.closed.get() {
350            return;
351        }
352        *self.shared.error.borrow_mut() = Some(error);
353        self.shared.closed.set(true);
354        self.shared.wake();
355    }
356
357    /// Ends the stream normally and wakes a pending collector.
358    pub fn close(&self) {
359        if self.shared.closed.get() {
360            return;
361        }
362        self.shared.closed.set(true);
363        self.shared.wake();
364    }
365
366    /// Whether the stream has been ended.
367    pub fn is_closed(&self) -> bool {
368        self.shared.closed.get()
369    }
370}
371
372struct ChannelStream {
373    shared: Rc<ChannelShared>,
374}
375
376impl ContentStream for ChannelStream {
377    fn next(&self) -> ContentFuture<'_, Result<Option<ContentHandle>, ContentError>> {
378        Box::pin(ChannelNext {
379            shared: Rc::clone(&self.shared),
380        })
381    }
382
383    fn produced(&self) -> Option<usize> {
384        Some(self.shared.produced.get())
385    }
386}
387
388struct ChannelNext {
389    shared: Rc<ChannelShared>,
390}
391
392impl Future for ChannelNext {
393    type Output = Result<Option<ContentHandle>, ContentError>;
394
395    fn poll(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
396        if let Some(item) = self.shared.ready.borrow_mut().pop_front() {
397            self.shared.produced.set(self.shared.produced.get() + 1);
398            return Poll::Ready(Ok(Some(item)));
399        }
400        if let Some(error) = self.shared.error.borrow_mut().take() {
401            return Poll::Ready(Err(error));
402        }
403        if self.shared.closed.get() {
404            return Poll::Ready(Ok(None));
405        }
406        *self.shared.waker.borrow_mut() = Some(context.waker().clone());
407        Poll::Pending
408    }
409}
410
411/// Content already held in memory: clipboard drops, web `File` payloads, tests.
412pub struct BytesContent {
413    metadata: ContentMetadata,
414    bytes: Rc<Vec<u8>>,
415}
416
417impl BytesContent {
418    /// Wraps `bytes` under `metadata`, filling in the byte length.
419    pub fn new(metadata: ContentMetadata, bytes: Vec<u8>) -> Self {
420        let len = bytes.len() as u64;
421        Self {
422            metadata: ContentMetadata {
423                len: Some(len),
424                ..metadata
425            },
426            bytes: Rc::new(bytes),
427        }
428    }
429
430    /// Wraps `bytes` under a display name.
431    pub fn named(name: impl Into<String>, bytes: Vec<u8>) -> Self {
432        Self::new(ContentMetadata::named(name), bytes)
433    }
434
435    /// A shared handle to this content.
436    pub fn handle(self) -> ContentHandle {
437        Rc::new(self)
438    }
439}
440
441impl Content for BytesContent {
442    fn metadata(&self) -> ContentMetadata {
443        self.metadata.clone()
444    }
445
446    fn open(&self) -> ContentFuture<'_, Result<ContentReaderRef, ContentError>> {
447        let bytes = Rc::clone(&self.bytes);
448        Box::pin(async move {
449            Ok(Rc::new(BytesReader {
450                bytes,
451                offset: std::cell::Cell::new(0),
452            }) as ContentReaderRef)
453        })
454    }
455
456    fn read_all(&self) -> ContentFuture<'_, Result<Vec<u8>, ContentError>> {
457        let bytes = Rc::clone(&self.bytes);
458        Box::pin(async move { Ok(bytes.as_ref().clone()) })
459    }
460}
461
462struct BytesReader {
463    bytes: Rc<Vec<u8>>,
464    offset: std::cell::Cell<usize>,
465}
466
467impl ContentReader for BytesReader {
468    fn read_chunk(&self) -> ContentFuture<'_, Result<Option<Vec<u8>>, ContentError>> {
469        Box::pin(async move {
470            let start = self.offset.get();
471            if start >= self.bytes.len() {
472                return Ok(None);
473            }
474            let end = (start + DEFAULT_CHUNK_LEN).min(self.bytes.len());
475            self.offset.set(end);
476            Ok(Some(self.bytes[start..end].to_vec()))
477        })
478    }
479}
480
481/// A folder whose children are already known.
482pub struct ReadyFolder {
483    metadata: ContentMetadata,
484    entries: RefCell<Option<Vec<ContentEntry>>>,
485}
486
487impl ReadyFolder {
488    /// Wraps already-enumerated `entries` under `metadata`.
489    pub fn new(metadata: ContentMetadata, entries: Vec<ContentEntry>) -> Self {
490        Self {
491            metadata,
492            entries: RefCell::new(Some(entries)),
493        }
494    }
495
496    /// A shared handle to this folder.
497    pub fn handle(self) -> ContentFolderRef {
498        Rc::new(self)
499    }
500}
501
502impl ContentFolder for ReadyFolder {
503    fn metadata(&self) -> ContentMetadata {
504        self.metadata.clone()
505    }
506
507    fn entries(&self) -> ContentFuture<'_, Result<Vec<ContentEntry>, ContentError>> {
508        Box::pin(async move {
509            self.entries
510                .borrow_mut()
511                .take()
512                .ok_or_else(|| ContentError::Io("folder entries already consumed".into()))
513        })
514    }
515}
516
517/// Turns a provider URI into readable content.
518///
519/// Every source that names content by URI rather than handing over bytes —
520/// a document intent, a shared item, a dropped file, a media source — resolves
521/// through this, so applications never learn what a `content://`, `file://` or
522/// blob URI means on the current platform.
523pub trait ContentResolver {
524    /// Resolves `uri`, or `None` when this platform cannot open it.
525    fn resolve(&self, uri: &str) -> Option<ContentHandle>;
526}
527
528/// Shared handle to a [`ContentResolver`].
529pub type ContentResolverRef = Rc<dyn ContentResolver>;
530
531thread_local! {
532    static PLATFORM_RESOLVER: RefCell<Option<ContentResolverRef>> = const { RefCell::new(None) };
533}
534
535/// Installs the platform content resolver (Android's `ContentResolver`, the
536/// iOS document store, the browser's blob registry).
537pub fn set_platform_content_resolver(resolver: ContentResolverRef) {
538    PLATFORM_RESOLVER.with(|cell| *cell.borrow_mut() = Some(resolver));
539}
540
541/// Removes any installed platform content resolver.
542pub fn clear_platform_content_resolver() {
543    PLATFORM_RESOLVER.with(|cell| *cell.borrow_mut() = None);
544}
545
546/// Resolves `uri` through the platform resolver, falling back to the local
547/// filesystem for `file://` URIs and plain paths.
548pub fn resolve_content(uri: &str) -> Option<ContentHandle> {
549    if let Some(resolver) = PLATFORM_RESOLVER.with(|cell| cell.borrow().clone())
550        && let Some(content) = resolver.resolve(uri)
551    {
552        return Some(content);
553    }
554    #[cfg(not(target_arch = "wasm32"))]
555    {
556        let path = uri.strip_prefix("file://").unwrap_or(uri);
557        if !path.contains("://") {
558            return Some(file_content(path));
559        }
560    }
561    let _ = uri;
562    None
563}
564
565#[cfg(not(target_arch = "wasm32"))]
566pub use file::{FileContent, FileFolder, FileSink, file_content, file_folder};
567
568#[cfg(not(target_arch = "wasm32"))]
569mod file;
570
571/// Decodes the percent-escapes in a platform content URI, refusing input it
572/// cannot decode exactly.
573///
574/// Android hands a folder or document back as an opaque `content://` URI whose
575/// readable name is percent-encoded inside it, so anything that wants to show
576/// the user which folder they picked has to decode it. The pair here mirrors
577/// [`String::from_utf8`] and [`String::from_utf8_lossy`]: this one answers
578/// `None` rather than inventing a character, and [`percent_decode_lossy`] is
579/// the best-effort form for text that is only going to be displayed.
580///
581/// Prefer this when the result is used as an identity -- a key, a filename, a
582/// fingerprint -- where a replacement character would silently make two
583/// different inputs look the same.
584pub fn percent_decode(input: &str) -> Option<String> {
585    let bytes = input.as_bytes();
586    let mut out = Vec::with_capacity(bytes.len());
587    let mut index = 0;
588    while index < bytes.len() {
589        if bytes[index] == b'%' {
590            let high = bytes.get(index + 1).copied().and_then(hex_value)?;
591            let low = bytes.get(index + 2).copied().and_then(hex_value)?;
592            out.push((high << 4) | low);
593            index += 3;
594        } else {
595            out.push(bytes[index]);
596            index += 1;
597        }
598    }
599    String::from_utf8(out).ok()
600}
601
602/// Decodes the percent-escapes in a platform content URI for display.
603///
604/// A byte sequence that is not valid UTF-8 becomes U+FFFD, and an escape that
605/// is truncated or not hexadecimal is passed through as written rather than
606/// discarded -- a name is more useful slightly wrong than absent. Use
607/// [`percent_decode`] when the result carries identity rather than being shown.
608pub fn percent_decode_lossy(input: &str) -> String {
609    let bytes = input.as_bytes();
610    let mut out = Vec::with_capacity(bytes.len());
611    let mut index = 0;
612    while index < bytes.len() {
613        if bytes[index] == b'%' {
614            let pair = hex_value(bytes.get(index + 1).copied().unwrap_or(0))
615                .zip(hex_value(bytes.get(index + 2).copied().unwrap_or(0)));
616            if let Some((high, low)) = pair {
617                out.push((high << 4) | low);
618                index += 3;
619                continue;
620            }
621        }
622        out.push(bytes[index]);
623        index += 1;
624    }
625    String::from_utf8_lossy(&out).into_owned()
626}
627
628fn hex_value(byte: u8) -> Option<u8> {
629    match byte {
630        b'0'..=b'9' => Some(byte - b'0'),
631        b'a'..=b'f' => Some(byte - b'a' + 10),
632        b'A'..=b'F' => Some(byte - b'A' + 10),
633        _ => None,
634    }
635}
636
637#[cfg(test)]
638mod tests {
639
640    #[test]
641    fn a_uri_that_cannot_be_decoded_exactly_is_refused_but_still_displays() {
642        assert_eq!(
643            percent_decode("Trip%20Photos").as_deref(),
644            Some("Trip Photos")
645        );
646        assert_eq!(percent_decode_lossy("Trip%20Photos"), "Trip Photos");
647
648        assert_eq!(percent_decode("bad%FFname"), None);
649        assert_eq!(percent_decode_lossy("bad%FFname"), "bad\u{fffd}name");
650
651        assert_eq!(percent_decode("cut%4"), None);
652        assert_eq!(percent_decode_lossy("cut%4"), "cut%4");
653
654        assert_eq!(percent_decode("100%zz"), None);
655        assert_eq!(percent_decode_lossy("100%zz"), "100%zz");
656    }
657
658    use super::*;
659
660    fn block<T>(future: impl Future<Output = T>) -> T {
661        pollster::block_on(future)
662    }
663
664    #[test]
665    fn metadata_reports_the_lowercase_extension() {
666        let metadata = ContentMetadata::named("Scan.PNG");
667        assert_eq!(metadata.extension().as_deref(), Some("png"));
668        assert_eq!(ContentMetadata::named("noext").extension(), None);
669        assert_eq!(ContentMetadata::named("trailing.").extension(), None);
670    }
671
672    #[test]
673    fn metadata_carries_what_a_provider_knew_about_the_item() {
674        let metadata = ContentMetadata::named("Scan.png")
675            .with_len(2_048)
676            .with_modified_millis(1_700_000_000_000)
677            .with_identifier("content://provider/17")
678            .with_mime_type("image/png");
679
680        assert_eq!(metadata.name, "Scan.png");
681        assert_eq!(metadata.len, Some(2_048));
682        assert_eq!(metadata.modified_millis, Some(1_700_000_000_000));
683        assert_eq!(metadata.identifier, "content://provider/17");
684        assert_eq!(metadata.mime_type.as_deref(), Some("image/png"));
685        let bare = ContentMetadata::named("Scan.png");
686        assert_eq!(bare.len, None);
687        assert_eq!(bare.modified_millis, None);
688        assert_eq!(bare.mime_type, None);
689        assert_eq!(bare.identifier, "Scan.png");
690    }
691
692    #[test]
693    fn a_channel_reports_when_it_has_ended_and_ignores_what_arrives_after() {
694        let channel = ContentChannel::new();
695        assert!(!channel.is_closed());
696
697        channel.close();
698        assert!(channel.is_closed());
699
700        channel.push(BytesContent::named("late.bin", vec![1]).handle());
701        channel.fail(ContentError::Unsupported("after close"));
702        assert!(channel.is_closed());
703
704        let stream = channel.stream();
705        assert!(
706            block(stream.next())
707                .expect("a closed channel ends cleanly")
708                .is_none(),
709            "a channel closed before anything was pushed must yield nothing"
710        );
711    }
712
713    #[test]
714    fn bytes_content_streams_in_chunks_and_reads_whole() {
715        let payload = vec![7u8; DEFAULT_CHUNK_LEN + 11];
716        let content = BytesContent::named("blob.bin", payload.clone()).handle();
717        assert_eq!(content.metadata().len, Some(payload.len() as u64));
718
719        let chunks = block(async {
720            let reader = content.open().await.unwrap();
721            let mut sizes = Vec::new();
722            while let Some(chunk) = reader.read_chunk().await.unwrap() {
723                sizes.push(chunk.len());
724            }
725            sizes
726        });
727        assert_eq!(chunks, vec![DEFAULT_CHUNK_LEN, 11]);
728        assert_eq!(block(content.read_all()).unwrap(), payload);
729    }
730
731    #[test]
732    fn walking_a_tree_yields_every_file_depth_first() {
733        let nested = ReadyFolder::new(
734            ContentMetadata::named("nested"),
735            vec![ContentEntry::File(
736                BytesContent::named("b.txt", b"b".to_vec()).handle(),
737            )],
738        )
739        .handle();
740        let root = ReadyFolder::new(
741            ContentMetadata::named("root"),
742            vec![
743                ContentEntry::File(BytesContent::named("a.txt", b"a".to_vec()).handle()),
744                ContentEntry::Folder(nested),
745            ],
746        )
747        .handle();
748
749        let stream = folder_files(root);
750        let files = block(collect_stream(&stream)).unwrap();
751        let names: Vec<String> = files.iter().map(|file| file.metadata().name).collect();
752        assert_eq!(names, vec!["a.txt", "b.txt"]);
753        assert_eq!(stream.produced(), Some(2));
754    }
755
756    #[test]
757    fn a_channel_wakes_its_collector_instead_of_being_polled() {
758        let channel = Rc::new(ContentChannel::new());
759        let stream = channel.stream();
760
761        let mut future = Box::pin(stream.next());
762        let waker = Waker::noop().clone();
763        let mut context = Context::from_waker(&waker);
764        assert!(future.as_mut().poll(&mut context).is_pending());
765
766        channel.push(BytesContent::named("late.txt", b"late".to_vec()).handle());
767        let Poll::Ready(Ok(Some(item))) = future.as_mut().poll(&mut context) else {
768            panic!("the pushed item should have completed the pending collector");
769        };
770        assert_eq!(item.metadata().name, "late.txt");
771
772        channel.close();
773        assert!(matches!(block(stream.next()), Ok(None)));
774        assert_eq!(stream.produced(), Some(1));
775    }
776
777    #[test]
778    fn a_failed_channel_reports_the_error_once_then_ends() {
779        let channel = ContentChannel::new();
780        let stream = channel.stream();
781        channel.fail(ContentError::Io("provider died".into()));
782        let Err(failure) = block(stream.next()) else {
783            panic!("a failed channel reports its error to the collector");
784        };
785        assert_eq!(failure, ContentError::Io("provider died".into()));
786        assert!(matches!(block(stream.next()), Ok(None)));
787    }
788}