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(url::Url),
55    /// A simple name (used for icon names, menu targets, etc.).
56    Name(String),
57}
58
59impl Source {
60    /// Get the filename from the source.
61    ///
62    /// For paths, this returns the file name component. For URLs, it returns the last path
63    /// segment. For names, it returns the name itself.
64    #[must_use]
65    pub fn get_filename(&self) -> Option<&str> {
66        match self {
67            Source::Path(path) => path.file_name().and_then(|os_str| os_str.to_str()),
68            Source::Url(url) => url
69                .path_segments()
70                .and_then(std::iter::Iterator::last)
71                .filter(|s| !s.is_empty()),
72            Source::Name(name) => Some(name.as_str()),
73        }
74    }
75}
76
77impl FromStr for Source {
78    type Err = Error;
79
80    fn from_str(value: &str) -> Result<Self, Self::Err> {
81        // Try to parse as URL first
82        if value.starts_with("http://")
83            || value.starts_with("https://")
84            || value.starts_with("ftp://")
85            || value.starts_with("irc://")
86            || value.starts_with("mailto:")
87        {
88            url::Url::parse(value).map(Source::Url).map_err(|e| {
89                Error::Parse(
90                    Box::new(SourceLocation {
91                        file: None,
92                        positioning: Positioning::Position(Position::default()),
93                    }),
94                    format!("invalid URL: {e}"),
95                )
96            })
97        } else if value.contains('/') || value.contains('\\') || value.contains('.') {
98            // Contains path separators - treat as filesystem path or contains a dot which
99            // might indicate a filename with extension
100            Ok(Source::Path(std::path::PathBuf::from(value)))
101        } else {
102            // Contains special characters or spaces - treat as a name
103            Ok(Source::Name(value.to_string()))
104        }
105    }
106}
107
108impl Display for Source {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        match self {
111            Source::Path(path) => write!(f, "{}", path.display()),
112            Source::Url(url) => {
113                // The url crate normalizes domain-only URLs by adding a trailing slash
114                // (e.g., "https://example.com" -> "https://example.com/").
115                // Strip it to match asciidoctor's output behavior.
116                let url_str = url.as_str();
117                if url.path() == "/" && !url_str.ends_with("://") {
118                    write!(f, "{}", url_str.trim_end_matches('/'))
119                } else {
120                    write!(f, "{url}")
121                }
122            }
123            Source::Name(name) => write!(f, "{name}"),
124        }
125    }
126}
127
128impl Serialize for Source {
129    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
130    where
131        S: Serializer,
132    {
133        let mut state = serializer.serialize_map(None)?;
134        match self {
135            Source::Path(path) => {
136                state.serialize_entry("type", "path")?;
137                state.serialize_entry("value", &path.display().to_string())?;
138            }
139            Source::Url(url) => {
140                state.serialize_entry("type", "url")?;
141                state.serialize_entry("value", url.as_str())?;
142            }
143            Source::Name(name) => {
144                state.serialize_entry("type", "name")?;
145                state.serialize_entry("value", name)?;
146            }
147        }
148        state.end()
149    }
150}
151
152/// An `Audio` represents an audio block in a document.
153#[derive(Clone, Debug, PartialEq)]
154#[non_exhaustive]
155pub struct Audio {
156    pub title: Title,
157    pub source: Source,
158    pub metadata: BlockMetadata,
159    pub location: Location,
160}
161
162impl Audio {
163    /// Create a new audio with the given source and location.
164    #[must_use]
165    pub fn new(source: Source, location: Location) -> Self {
166        Self {
167            title: Title::default(),
168            source,
169            metadata: BlockMetadata::default(),
170            location,
171        }
172    }
173
174    /// Set the title.
175    #[must_use]
176    pub fn with_title(mut self, title: Title) -> Self {
177        self.title = title;
178        self
179    }
180
181    /// Set the metadata.
182    #[must_use]
183    pub fn with_metadata(mut self, metadata: BlockMetadata) -> Self {
184        self.metadata = metadata;
185        self
186    }
187}
188
189/// A `Video` represents a video block in a document.
190#[derive(Clone, Debug, PartialEq)]
191#[non_exhaustive]
192pub struct Video {
193    pub title: Title,
194    pub sources: Vec<Source>,
195    pub metadata: BlockMetadata,
196    pub location: Location,
197}
198
199impl Video {
200    /// Create a new video with the given sources and location.
201    #[must_use]
202    pub fn new(sources: Vec<Source>, location: Location) -> Self {
203        Self {
204            title: Title::default(),
205            sources,
206            metadata: BlockMetadata::default(),
207            location,
208        }
209    }
210
211    /// Set the title.
212    #[must_use]
213    pub fn with_title(mut self, title: Title) -> Self {
214        self.title = title;
215        self
216    }
217
218    /// Set the metadata.
219    #[must_use]
220    pub fn with_metadata(mut self, metadata: BlockMetadata) -> Self {
221        self.metadata = metadata;
222        self
223    }
224}
225
226/// An `Image` represents an image block in a document.
227#[derive(Clone, Debug, PartialEq)]
228#[non_exhaustive]
229pub struct Image {
230    pub title: Title,
231    pub source: Source,
232    pub metadata: BlockMetadata,
233    pub location: Location,
234}
235
236impl Image {
237    /// Create a new image with the given source and location.
238    #[must_use]
239    pub fn new(source: Source, location: Location) -> Self {
240        Self {
241            title: Title::default(),
242            source,
243            metadata: BlockMetadata::default(),
244            location,
245        }
246    }
247
248    /// Set the title.
249    #[must_use]
250    pub fn with_title(mut self, title: Title) -> Self {
251        self.title = title;
252        self
253    }
254
255    /// Set the metadata.
256    #[must_use]
257    pub fn with_metadata(mut self, metadata: BlockMetadata) -> Self {
258        self.metadata = metadata;
259        self
260    }
261}
262
263impl Serialize for Audio {
264    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
265    where
266        S: Serializer,
267    {
268        let mut state = serializer.serialize_map(None)?;
269        state.serialize_entry("name", "audio")?;
270        state.serialize_entry("type", "block")?;
271        state.serialize_entry("form", "macro")?;
272        if !self.metadata.is_default() {
273            state.serialize_entry("metadata", &self.metadata)?;
274        }
275        if !self.title.is_empty() {
276            state.serialize_entry("title", &self.title)?;
277        }
278        state.serialize_entry("source", &self.source)?;
279        state.serialize_entry("location", &self.location)?;
280        state.end()
281    }
282}
283
284impl Serialize for Image {
285    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
286    where
287        S: Serializer,
288    {
289        let mut state = serializer.serialize_map(None)?;
290        state.serialize_entry("name", "image")?;
291        state.serialize_entry("type", "block")?;
292        state.serialize_entry("form", "macro")?;
293        if !self.metadata.is_default() {
294            state.serialize_entry("metadata", &self.metadata)?;
295        }
296        if !self.title.is_empty() {
297            state.serialize_entry("title", &self.title)?;
298        }
299        state.serialize_entry("source", &self.source)?;
300        state.serialize_entry("location", &self.location)?;
301        state.end()
302    }
303}
304
305impl Serialize for Video {
306    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
307    where
308        S: Serializer,
309    {
310        let mut state = serializer.serialize_map(None)?;
311        state.serialize_entry("name", "video")?;
312        state.serialize_entry("type", "block")?;
313        state.serialize_entry("form", "macro")?;
314        if !self.metadata.is_default() {
315            state.serialize_entry("metadata", &self.metadata)?;
316        }
317        if !self.title.is_empty() {
318            state.serialize_entry("title", &self.title)?;
319        }
320        if !self.sources.is_empty() {
321            state.serialize_entry("sources", &self.sources)?;
322        }
323        state.serialize_entry("location", &self.location)?;
324        state.end()
325    }
326}