Skip to main content

acdc_parser/model/
media.rs

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