Skip to main content

acdc_parser/model/
media.rs

1//! Media types for `AsciiDoc` documents (images, audio, video).
2
3use std::fmt::Display;
4
5use serde::{
6    Serialize,
7    ser::{SerializeMap, Serializer},
8};
9
10use crate::{Error, Positioning, SourceLocation};
11
12use super::location::{Location, Position};
13use super::metadata::BlockMetadata;
14use super::title::Title;
15
16/// The source location for media content (images, audio, video).
17///
18/// `Source` is an **enum**, not a struct with a `path` field. Use pattern matching
19/// to extract the underlying value:
20///
21/// # Accessing the Source
22///
23/// ```
24/// # use acdc_parser::Source;
25/// # use std::path::PathBuf;
26/// fn get_path_string(source: &Source) -> String {
27///     match source {
28///         Source::Path(path) => path.display().to_string(),
29///         Source::Url(url) => url.to_string(),
30///         Source::Name(name) => (*name).to_string(),
31///     }
32/// }
33/// ```
34///
35/// Or use the `Display` implementation for simple string conversion:
36///
37/// ```
38/// # use acdc_parser::Source;
39/// # let source = Source::Name("example");
40/// let source_str = source.to_string();
41/// ```
42///
43/// # Variants
44///
45/// - `Path(PathBuf)` - Local filesystem path (e.g., `images/photo.png`)
46/// - `Url(url::Url)` - Remote URL (e.g., `https://example.com/image.png`)
47/// - `Name(&str)` - Simple identifier (e.g., icon names like `heart`, `github`)
48#[derive(Clone, Debug, PartialEq)]
49pub enum Source<'a> {
50    /// A filesystem path (relative or absolute).
51    Path(std::path::PathBuf),
52    /// A URL (http, https, ftp, etc.).
53    Url(SourceUrl<'a>),
54    /// A simple name (used for icon names, menu targets, etc.).
55    Name(&'a str),
56}
57
58/// A parsed URL that preserves the author's original input for display.
59///
60/// The `url` crate normalizes URLs (e.g., `http://example.com` becomes
61/// `http://example.com/`). This wrapper stores the original string so URLs
62/// are displayed exactly as written.
63///
64/// See [issue #335](https://github.com/nlopes/acdc/issues/335).
65#[derive(Clone, Debug)]
66pub struct SourceUrl<'a> {
67    url: url::Url,
68    original: &'a str,
69}
70
71impl<'a> SourceUrl<'a> {
72    /// Create a new `SourceUrl` from a string, preserving the original for display.
73    ///
74    /// # Errors
75    ///
76    /// Returns an error if the input is not a valid URL.
77    pub fn new(original: &'a str) -> Result<Self, url::ParseError> {
78        let url = url::Url::parse(original)?;
79        Ok(Self { url, original })
80    }
81
82    /// Get the underlying `url::Url`.
83    #[must_use]
84    pub fn url(&self) -> &url::Url {
85        &self.url
86    }
87}
88
89impl std::ops::Deref for SourceUrl<'_> {
90    type Target = url::Url;
91    fn deref(&self) -> &Self::Target {
92        &self.url
93    }
94}
95
96impl PartialEq for SourceUrl<'_> {
97    fn eq(&self, other: &Self) -> bool {
98        self.url == other.url
99    }
100}
101
102impl Display for SourceUrl<'_> {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        write!(f, "{}", self.original)
105    }
106}
107
108impl Source<'_> {
109    /// Get the filename from the source.
110    ///
111    /// For paths, this returns the file name component. For URLs, it returns the last path
112    /// segment. For names, it returns the name itself.
113    #[must_use]
114    pub fn get_filename(&self) -> Option<&str> {
115        match self {
116            Source::Path(path) => path.file_name().and_then(|os_str| os_str.to_str()),
117            Source::Url(url) => url
118                .path_segments()
119                .and_then(std::iter::Iterator::last)
120                .filter(|s| !s.is_empty()),
121            Source::Name(name) => Some(name),
122        }
123    }
124}
125
126impl<'a> From<SourceUrl<'a>> for Source<'a> {
127    fn from(url: SourceUrl<'a>) -> Self {
128        Source::Url(url)
129    }
130}
131
132impl<'a> Source<'a> {
133    /// Construct a `Source` from a borrowed string, classifying it as either a
134    /// URL or a filesystem path based on its scheme.
135    ///
136    /// # Errors
137    /// Returns [`Error::Parse`] when `value` looks like a URL (starts with a
138    /// recognised scheme) but fails URL parsing.
139    pub fn from_str_borrowed(value: &'a str) -> Result<Self, Error> {
140        // Try to parse as URL first
141        if value.starts_with("http://")
142            || value.starts_with("https://")
143            || value.starts_with("ftp://")
144            || value.starts_with("irc://")
145            || value.starts_with("mailto:")
146        {
147            SourceUrl::new(value).map(Source::Url).map_err(|e| {
148                Error::Parse(
149                    Box::new(SourceLocation {
150                        file: None,
151                        positioning: Positioning::Position(Position::default()),
152                    }),
153                    format!("invalid URL: {e}"),
154                )
155            })
156        } else if value.contains('/') || value.contains('\\') || value.contains('.') {
157            // Contains path separators or dot (filename with extension)
158            Ok(Source::Path(std::path::PathBuf::from(value)))
159        } else {
160            // Contains special characters or spaces - treat as a name
161            Ok(Source::Name(value))
162        }
163    }
164}
165
166impl Display for Source<'_> {
167    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168        match self {
169            Source::Path(path) => write!(f, "{}", path.display()),
170            Source::Url(url) => write!(f, "{url}"),
171            Source::Name(name) => write!(f, "{name}"),
172        }
173    }
174}
175
176impl Serialize for Source<'_> {
177    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
178    where
179        S: Serializer,
180    {
181        let mut state = serializer.serialize_map(None)?;
182        match self {
183            Source::Path(path) => {
184                state.serialize_entry("type", "path")?;
185                state.serialize_entry("value", &path.display().to_string())?;
186            }
187            Source::Url(url) => {
188                state.serialize_entry("type", "url")?;
189                state.serialize_entry("value", &url.to_string())?;
190            }
191            Source::Name(name) => {
192                state.serialize_entry("type", "name")?;
193                state.serialize_entry("value", name)?;
194            }
195        }
196        state.end()
197    }
198}
199
200/// An `Audio` represents an audio block in a document.
201#[derive(Clone, Debug, PartialEq)]
202#[non_exhaustive]
203pub struct Audio<'a> {
204    pub title: Title<'a>,
205    pub source: Source<'a>,
206    pub metadata: BlockMetadata<'a>,
207    pub location: Location,
208}
209
210impl<'a> Audio<'a> {
211    /// Create a new audio with the given source and location.
212    #[must_use]
213    pub fn new(source: Source<'a>, location: Location) -> Self {
214        Self {
215            title: Title::default(),
216            source,
217            metadata: BlockMetadata::default(),
218            location,
219        }
220    }
221
222    /// Set the title.
223    #[must_use]
224    pub fn with_title(mut self, title: Title<'a>) -> Self {
225        self.title = title;
226        self
227    }
228
229    /// Set the metadata.
230    #[must_use]
231    pub fn with_metadata(mut self, metadata: BlockMetadata<'a>) -> Self {
232        self.metadata = metadata;
233        self
234    }
235}
236
237/// A `Video` represents a video block in a document.
238#[derive(Clone, Debug, PartialEq)]
239#[non_exhaustive]
240pub struct Video<'a> {
241    pub title: Title<'a>,
242    pub sources: Vec<Source<'a>>,
243    pub metadata: BlockMetadata<'a>,
244    pub location: Location,
245}
246
247impl<'a> Video<'a> {
248    /// Create a new video with the given sources and location.
249    #[must_use]
250    pub fn new(sources: Vec<Source<'a>>, location: Location) -> Self {
251        Self {
252            title: Title::default(),
253            sources,
254            metadata: BlockMetadata::default(),
255            location,
256        }
257    }
258
259    /// Set the title.
260    #[must_use]
261    pub fn with_title(mut self, title: Title<'a>) -> Self {
262        self.title = title;
263        self
264    }
265
266    /// Set the metadata.
267    #[must_use]
268    pub fn with_metadata(mut self, metadata: BlockMetadata<'a>) -> Self {
269        self.metadata = metadata;
270        self
271    }
272}
273
274/// An `Image` represents an image block in a document.
275#[derive(Clone, Debug, PartialEq)]
276#[non_exhaustive]
277pub struct Image<'a> {
278    pub title: Title<'a>,
279    pub source: Source<'a>,
280    pub metadata: BlockMetadata<'a>,
281    pub location: Location,
282}
283
284impl<'a> Image<'a> {
285    /// Create a new image with the given source and location.
286    #[must_use]
287    pub fn new(source: Source<'a>, location: Location) -> Self {
288        Self {
289            title: Title::default(),
290            source,
291            metadata: BlockMetadata::default(),
292            location,
293        }
294    }
295
296    /// Set the title.
297    #[must_use]
298    pub fn with_title(mut self, title: Title<'a>) -> Self {
299        self.title = title;
300        self
301    }
302
303    /// Set the metadata.
304    #[must_use]
305    pub fn with_metadata(mut self, metadata: BlockMetadata<'a>) -> Self {
306        self.metadata = metadata;
307        self
308    }
309}
310
311impl Serialize for Audio<'_> {
312    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
313    where
314        S: Serializer,
315    {
316        let mut state = serializer.serialize_map(None)?;
317        state.serialize_entry("name", "audio")?;
318        state.serialize_entry("type", "block")?;
319        state.serialize_entry("form", "macro")?;
320        if !self.metadata.is_default() {
321            state.serialize_entry("metadata", &self.metadata)?;
322        }
323        if !self.title.is_empty() {
324            state.serialize_entry("title", &self.title)?;
325        }
326        state.serialize_entry("source", &self.source)?;
327        state.serialize_entry("location", &self.location)?;
328        state.end()
329    }
330}
331
332impl Serialize for Image<'_> {
333    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
334    where
335        S: Serializer,
336    {
337        let mut state = serializer.serialize_map(None)?;
338        state.serialize_entry("name", "image")?;
339        state.serialize_entry("type", "block")?;
340        state.serialize_entry("form", "macro")?;
341        if !self.metadata.is_default() {
342            state.serialize_entry("metadata", &self.metadata)?;
343        }
344        if !self.title.is_empty() {
345            state.serialize_entry("title", &self.title)?;
346        }
347        state.serialize_entry("source", &self.source)?;
348        state.serialize_entry("location", &self.location)?;
349        state.end()
350    }
351}
352
353impl Serialize for Video<'_> {
354    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
355    where
356        S: Serializer,
357    {
358        let mut state = serializer.serialize_map(None)?;
359        state.serialize_entry("name", "video")?;
360        state.serialize_entry("type", "block")?;
361        state.serialize_entry("form", "macro")?;
362        if !self.metadata.is_default() {
363            state.serialize_entry("metadata", &self.metadata)?;
364        }
365        if !self.title.is_empty() {
366            state.serialize_entry("title", &self.title)?;
367        }
368        if !self.sources.is_empty() {
369            state.serialize_entry("sources", &self.sources)?;
370        }
371        state.serialize_entry("location", &self.location)?;
372        state.end()
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn source_display_preserves_trailing_slash() -> Result<(), Error> {
382        // Issue #335: URLs with trailing slashes should preserve them
383        let source = Source::from_str_borrowed("http://example.com/")?;
384        assert_eq!(source.to_string(), "http://example.com/");
385        Ok(())
386    }
387
388    #[test]
389    fn source_display_no_trailing_slash_when_absent() -> Result<(), Error> {
390        // Domain-only URL without trailing slash should not gain one
391        let source = Source::from_str_borrowed("http://example.com")?;
392        assert_eq!(source.to_string(), "http://example.com");
393        Ok(())
394    }
395
396    #[test]
397    fn source_display_preserves_path_trailing_slash() -> Result<(), Error> {
398        let source = Source::from_str_borrowed("http://example.com/foo/")?;
399        assert_eq!(source.to_string(), "http://example.com/foo/");
400        Ok(())
401    }
402
403    #[test]
404    fn source_display_preserves_path_without_trailing_slash() -> Result<(), Error> {
405        let source = Source::from_str_borrowed("http://example.com/foo")?;
406        assert_eq!(source.to_string(), "http://example.com/foo");
407        Ok(())
408    }
409
410    #[test]
411    fn source_display_preserves_query_without_path() -> Result<(), Error> {
412        // Original URL preserved exactly, even without explicit path before query
413        let source = Source::from_str_borrowed("https://example.com?a=1&b=2")?;
414        assert_eq!(source.to_string(), "https://example.com?a=1&b=2");
415        Ok(())
416    }
417
418    #[test]
419    fn source_display_preserves_trailing_slash_with_query() -> Result<(), Error> {
420        let source = Source::from_str_borrowed("https://example.com/?a=1&b=2")?;
421        assert_eq!(source.to_string(), "https://example.com/?a=1&b=2");
422        Ok(())
423    }
424}