gh_docs_download/
types.rs

1//! Domain types for GitHub documentation handling.
2//!
3//! This module provides semantic newtypes that prevent category errors and
4//! make the domain model more explicit and understandable.
5
6use crate::error::{RepoNameError, RepoOwnerError};
7use serde::{Deserialize, Serialize};
8use std::fmt;
9use std::path::PathBuf;
10use url::Url;
11
12/// Repository owner identifier (e.g., "rust-lang").
13///
14/// This type ensures that repository owners are validated and prevents
15/// confusion with other string types in the API.
16#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
17pub struct RepoOwner(String);
18
19impl RepoOwner {
20    /// Create a new repository owner after validation.
21    ///
22    /// # Errors
23    ///
24    /// Returns `RepoOwnerError` if the owner name is invalid.
25    pub fn new(owner: impl AsRef<str>) -> Result<Self, RepoOwnerError> {
26        let owner = owner.as_ref();
27
28        if owner.is_empty() {
29            return Err(RepoOwnerError::Empty);
30        }
31
32        if owner.len() > 39 {
33            return Err(RepoOwnerError::TooLong { len: owner.len() });
34        }
35
36        // GitHub username/organization validation
37        if !owner.chars().all(|c| c.is_alphanumeric() || c == '-') {
38            return Err(RepoOwnerError::InvalidCharacters {
39                owner: owner.to_string(),
40            });
41        }
42
43        Ok(Self(owner.to_string()))
44    }
45
46    /// Get the owner name as a string slice.
47    #[must_use]
48    pub fn as_str(&self) -> &str {
49        &self.0
50    }
51
52    /// Convert into the underlying string.
53    #[must_use]
54    pub fn into_string(self) -> String {
55        self.0
56    }
57}
58
59impl fmt::Display for RepoOwner {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        write!(f, "{}", self.0)
62    }
63}
64
65impl AsRef<str> for RepoOwner {
66    fn as_ref(&self) -> &str {
67        &self.0
68    }
69}
70
71/// Repository name identifier (e.g., "rust").
72///
73/// This type ensures that repository names are validated and prevents
74/// confusion with other string types in the API.
75#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
76pub struct RepoName(String);
77
78impl RepoName {
79    /// Create a new repository name after validation.
80    ///
81    /// # Errors
82    ///
83    /// Returns `RepoNameError` if the repository name is invalid.
84    pub fn new(name: impl AsRef<str>) -> Result<Self, RepoNameError> {
85        let name = name.as_ref();
86
87        if name.is_empty() {
88            return Err(RepoNameError::Empty);
89        }
90
91        if name.len() > 100 {
92            return Err(RepoNameError::TooLong { len: name.len() });
93        }
94
95        // GitHub repository name validation (simplified)
96        if !name
97            .chars()
98            .all(|c| c.is_alphanumeric() || "-_.".contains(c))
99        {
100            return Err(RepoNameError::InvalidCharacters {
101                name: name.to_string(),
102            });
103        }
104
105        Ok(Self(name.to_string()))
106    }
107
108    /// Get the repository name as a string slice.
109    #[must_use]
110    pub fn as_str(&self) -> &str {
111        &self.0
112    }
113
114    /// Convert into the underlying string.
115    #[must_use]
116    pub fn into_string(self) -> String {
117        self.0
118    }
119}
120
121impl fmt::Display for RepoName {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        write!(f, "{}", self.0)
124    }
125}
126
127impl AsRef<str> for RepoName {
128    fn as_ref(&self) -> &str {
129        &self.0
130    }
131}
132
133/// File name with validation.
134#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
135pub struct FileName(String);
136
137impl FileName {
138    /// Create a new file name.
139    pub fn new(name: impl Into<String>) -> Self {
140        Self(name.into())
141    }
142
143    /// Get the file name as a string slice.
144    #[must_use]
145    pub fn as_str(&self) -> &str {
146        &self.0
147    }
148
149    /// Convert into the underlying string.
150    #[must_use]
151    pub fn into_string(self) -> String {
152        self.0
153    }
154}
155
156impl fmt::Display for FileName {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        write!(f, "{}", self.0)
159    }
160}
161
162impl From<String> for FileName {
163    fn from(name: String) -> Self {
164        Self(name)
165    }
166}
167
168/// File path with semantic meaning.
169#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
170pub struct FilePath(PathBuf);
171
172impl FilePath {
173    /// Create a new file path.
174    pub fn new(path: impl Into<PathBuf>) -> Self {
175        Self(path.into())
176    }
177
178    /// Get the path as a `PathBuf` reference.
179    #[must_use]
180    pub fn as_path(&self) -> &std::path::Path {
181        &self.0
182    }
183
184    /// Convert into the underlying `PathBuf`.
185    #[must_use]
186    pub fn into_path_buf(self) -> PathBuf {
187        self.0
188    }
189
190    /// Get the path as a string, using lossy conversion if needed.
191    #[must_use]
192    pub fn as_string_lossy(&self) -> std::borrow::Cow<str> {
193        self.0.to_string_lossy()
194    }
195}
196
197impl fmt::Display for FilePath {
198    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199        write!(f, "{}", self.0.display())
200    }
201}
202
203impl From<PathBuf> for FilePath {
204    fn from(path: PathBuf) -> Self {
205        Self(path)
206    }
207}
208
209impl From<&str> for FilePath {
210    fn from(path: &str) -> Self {
211        Self(PathBuf::from(path))
212    }
213}
214
215/// Download URL with validation.
216#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
217pub struct DownloadUrl(Url);
218
219impl DownloadUrl {
220    /// Create a new download URL from a validated URL.
221    #[must_use]
222    pub fn new(url: Url) -> Self {
223        Self(url)
224    }
225
226    /// Parse a download URL from a string.
227    ///
228    /// # Errors
229    ///
230    /// Returns `url::ParseError` if the URL is invalid.
231    pub fn parse(url: impl AsRef<str>) -> Result<Self, url::ParseError> {
232        Ok(Self(Url::parse(url.as_ref())?))
233    }
234
235    /// Get the URL as a reference.
236    #[must_use]
237    pub fn as_url(&self) -> &Url {
238        &self.0
239    }
240
241    /// Convert into the underlying URL.
242    #[must_use]
243    pub fn into_url(self) -> Url {
244        self.0
245    }
246
247    /// Get the URL as a string.
248    #[must_use]
249    pub fn as_str(&self) -> &str {
250        self.0.as_str()
251    }
252}
253
254impl fmt::Display for DownloadUrl {
255    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
256        write!(f, "{}", self.0)
257    }
258}
259
260impl From<Url> for DownloadUrl {
261    fn from(url: Url) -> Self {
262        Self(url)
263    }
264}
265
266/// File size in bytes.
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
268pub struct FileSizeBytes(u64);
269
270impl FileSizeBytes {
271    /// Create a new file size.
272    #[must_use]
273    pub fn new(bytes: u64) -> Self {
274        Self(bytes)
275    }
276
277    /// Get the size in bytes.
278    #[must_use]
279    pub fn bytes(&self) -> u64 {
280        self.0
281    }
282
283    /// Get the size as a human-readable string.
284    #[must_use]
285    pub fn human_readable(&self) -> String {
286        const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
287        #[allow(clippy::cast_precision_loss)]
288        let mut size = self.0 as f64;
289        let mut unit_index = 0;
290
291        while size >= 1024.0 && unit_index < UNITS.len() - 1 {
292            size /= 1024.0;
293            unit_index += 1;
294        }
295
296        if unit_index == 0 {
297            format!("{} {}", self.0, UNITS[unit_index])
298        } else {
299            format!("{:.1} {}", size, UNITS[unit_index])
300        }
301    }
302}
303
304impl fmt::Display for FileSizeBytes {
305    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
306        write!(f, "{}", self.human_readable())
307    }
308}
309
310impl From<u64> for FileSizeBytes {
311    fn from(bytes: u64) -> Self {
312        Self(bytes)
313    }
314}
315
316/// Directory path for documentation.
317#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
318pub struct DocsDirectory(String);
319
320impl DocsDirectory {
321    /// Create a new documentation directory path.
322    pub fn new(path: impl Into<String>) -> Self {
323        Self(path.into())
324    }
325
326    /// Get the directory path as a string slice.
327    #[must_use]
328    pub fn as_str(&self) -> &str {
329        &self.0
330    }
331
332    /// Convert into the underlying string.
333    #[must_use]
334    pub fn into_string(self) -> String {
335        self.0
336    }
337}
338
339impl fmt::Display for DocsDirectory {
340    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
341        write!(f, "{}", self.0)
342    }
343}
344
345impl From<String> for DocsDirectory {
346    fn from(path: String) -> Self {
347        Self(path)
348    }
349}
350
351impl AsRef<str> for DocsDirectory {
352    fn as_ref(&self) -> &str {
353        &self.0
354    }
355}
356
357/// Documentation file with complete metadata.
358#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
359pub struct DocumentationFile {
360    /// Name of the documentation file
361    pub name: FileName,
362    /// Path to the file within the repository
363    pub path: FilePath,
364    /// URL for downloading the file content
365    pub download_url: DownloadUrl,
366    /// Size of the file in bytes
367    pub size: FileSizeBytes,
368    /// Documentation directory this file belongs to
369    pub docs_directory: DocsDirectory,
370}
371
372/// Repository specification combining owner and name.
373#[derive(Debug, Clone, PartialEq, Eq, Hash)]
374pub struct RepoSpec {
375    /// Repository owner (user or organization)
376    pub owner: RepoOwner,
377    /// Repository name
378    pub name: RepoName,
379}
380
381impl RepoSpec {
382    /// Create a new repository specification.
383    #[must_use]
384    pub fn new(owner: RepoOwner, name: RepoName) -> Self {
385        Self { owner, name }
386    }
387
388    /// Get the full repository identifier as "owner/repo".
389    #[must_use]
390    pub fn full_name(&self) -> String {
391        format!("{}/{}", self.owner, self.name)
392    }
393}
394
395impl fmt::Display for RepoSpec {
396    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
397        write!(f, "{}/{}", self.owner, self.name)
398    }
399}