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