Skip to main content

guts_compat/
contents.rs

1//! Repository contents API types.
2
3use serde::{Deserialize, Serialize};
4
5/// Content type for repository entries.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum ContentType {
9    /// Regular file.
10    File,
11    /// Directory.
12    Dir,
13    /// Symbolic link.
14    Symlink,
15    /// Git submodule.
16    Submodule,
17}
18
19impl std::fmt::Display for ContentType {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        match self {
22            Self::File => write!(f, "file"),
23            Self::Dir => write!(f, "dir"),
24            Self::Symlink => write!(f, "symlink"),
25            Self::Submodule => write!(f, "submodule"),
26        }
27    }
28}
29
30/// A content entry (file or directory) in a repository.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ContentEntry {
33    /// Entry type.
34    #[serde(rename = "type")]
35    pub content_type: ContentType,
36    /// Encoding (e.g., "base64" for file content).
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub encoding: Option<String>,
39    /// Size in bytes (0 for directories).
40    pub size: u64,
41    /// Entry name (filename or directory name).
42    pub name: String,
43    /// Full path from repository root.
44    pub path: String,
45    /// Base64-encoded content (only for files when requested).
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub content: Option<String>,
48    /// Git object SHA.
49    pub sha: String,
50    /// URL to download raw content.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub download_url: Option<String>,
53    /// URL to view in web UI.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub html_url: Option<String>,
56    /// API URL for this entry.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub url: Option<String>,
59    /// Symlink target (only for symlinks).
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub target: Option<String>,
62    /// Submodule URL (only for submodules).
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub submodule_git_url: Option<String>,
65}
66
67impl ContentEntry {
68    /// Create a new file entry.
69    pub fn file(name: String, path: String, sha: String, size: u64) -> Self {
70        Self {
71            content_type: ContentType::File,
72            encoding: None,
73            size,
74            name,
75            path,
76            content: None,
77            sha,
78            download_url: None,
79            html_url: None,
80            url: None,
81            target: None,
82            submodule_git_url: None,
83        }
84    }
85
86    /// Create a new directory entry.
87    pub fn dir(name: String, path: String, sha: String) -> Self {
88        Self {
89            content_type: ContentType::Dir,
90            encoding: None,
91            size: 0,
92            name,
93            path,
94            content: None,
95            sha,
96            download_url: None,
97            html_url: None,
98            url: None,
99            target: None,
100            submodule_git_url: None,
101        }
102    }
103
104    /// Create a new symlink entry.
105    pub fn symlink(name: String, path: String, sha: String, target: String) -> Self {
106        Self {
107            content_type: ContentType::Symlink,
108            encoding: None,
109            size: target.len() as u64,
110            name,
111            path,
112            content: None,
113            sha,
114            download_url: None,
115            html_url: None,
116            url: None,
117            target: Some(target),
118            submodule_git_url: None,
119        }
120    }
121
122    /// Create a new submodule entry.
123    pub fn submodule(name: String, path: String, sha: String, git_url: String) -> Self {
124        Self {
125            content_type: ContentType::Submodule,
126            encoding: None,
127            size: 0,
128            name,
129            path,
130            content: None,
131            sha,
132            download_url: None,
133            html_url: None,
134            url: None,
135            target: None,
136            submodule_git_url: Some(git_url),
137        }
138    }
139
140    /// Set the content (base64-encoded).
141    pub fn with_content(mut self, content: String) -> Self {
142        self.encoding = Some("base64".to_string());
143        self.content = Some(content);
144        self
145    }
146
147    /// Set URLs.
148    pub fn with_urls(mut self, download_url: String, html_url: String, api_url: String) -> Self {
149        self.download_url = Some(download_url);
150        self.html_url = Some(html_url);
151        self.url = Some(api_url);
152        self
153    }
154}
155
156/// README file response.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct ReadmeResponse {
159    /// Content type (always "file").
160    #[serde(rename = "type")]
161    pub content_type: ContentType,
162    /// Encoding (usually "base64").
163    pub encoding: String,
164    /// Size in bytes.
165    pub size: u64,
166    /// Filename.
167    pub name: String,
168    /// Path.
169    pub path: String,
170    /// Base64-encoded content.
171    pub content: String,
172    /// Git SHA.
173    pub sha: String,
174    /// Download URL.
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub download_url: Option<String>,
177    /// HTML view URL.
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub html_url: Option<String>,
180}
181
182/// License file response.
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct LicenseResponse {
185    /// License name (e.g., "MIT", "Apache-2.0").
186    pub name: String,
187    /// License path in repository.
188    pub path: String,
189    /// SPDX license ID (if recognized).
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub spdx_id: Option<String>,
192    /// Git SHA.
193    pub sha: String,
194    /// Size in bytes.
195    pub size: u64,
196    /// Download URL.
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub download_url: Option<String>,
199    /// HTML view URL.
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub html_url: Option<String>,
202    /// Base64-encoded content.
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub content: Option<String>,
205    /// Encoding.
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub encoding: Option<String>,
208}
209
210/// Recognize license from filename.
211pub fn recognize_license_file(filename: &str) -> Option<&'static str> {
212    let lower = filename.to_lowercase();
213    if lower == "license" || lower == "license.txt" || lower == "license.md" {
214        Some("LICENSE")
215    } else if lower == "copying" || lower == "copying.txt" {
216        Some("COPYING")
217    } else if lower == "unlicense" {
218        Some("UNLICENSE")
219    } else {
220        None
221    }
222}
223
224/// Detect SPDX license ID from content.
225pub fn detect_spdx_id(content: &str) -> Option<&'static str> {
226    let content_lower = content.to_lowercase();
227
228    // Check for common license signatures
229    if content_lower.contains("mit license")
230        || content_lower.contains("permission is hereby granted, free of charge")
231    {
232        Some("MIT")
233    } else if content_lower.contains("apache license") && content_lower.contains("version 2.0") {
234        Some("Apache-2.0")
235    } else if content_lower.contains("gnu general public license") {
236        if content_lower.contains("version 3") {
237            Some("GPL-3.0")
238        } else if content_lower.contains("version 2") {
239            Some("GPL-2.0")
240        } else {
241            Some("GPL")
242        }
243    } else if content_lower.contains("bsd 3-clause") || content_lower.contains("new bsd license") {
244        Some("BSD-3-Clause")
245    } else if content_lower.contains("bsd 2-clause") || content_lower.contains("simplified bsd") {
246        Some("BSD-2-Clause")
247    } else if content_lower.contains("mozilla public license") && content_lower.contains("2.0") {
248        Some("MPL-2.0")
249    } else if content_lower.contains("isc license") {
250        Some("ISC")
251    } else if content_lower.contains("unlicense") || content_lower.contains("public domain") {
252        Some("Unlicense")
253    } else {
254        None
255    }
256}
257
258/// Recognize README file from filename.
259pub fn is_readme_file(filename: &str) -> bool {
260    let lower = filename.to_lowercase();
261    lower == "readme"
262        || lower == "readme.md"
263        || lower == "readme.txt"
264        || lower == "readme.rst"
265        || lower == "readme.markdown"
266        || lower == "readme.rdoc"
267        || lower == "readme.org"
268        || lower == "readme.adoc"
269}
270
271/// Query parameters for contents API.
272#[derive(Debug, Clone, Default, Serialize, Deserialize)]
273pub struct ContentsQuery {
274    /// Git ref (branch, tag, or SHA).
275    #[serde(rename = "ref")]
276    pub git_ref: Option<String>,
277}
278
279/// Base64 encode bytes.
280pub fn base64_encode(data: &[u8]) -> String {
281    const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
282
283    let mut result = String::with_capacity(data.len().div_ceil(3) * 4);
284
285    for chunk in data.chunks(3) {
286        let b0 = chunk[0] as usize;
287        let b1 = chunk.get(1).copied().unwrap_or(0) as usize;
288        let b2 = chunk.get(2).copied().unwrap_or(0) as usize;
289
290        result.push(ALPHABET[b0 >> 2] as char);
291        result.push(ALPHABET[((b0 & 0x03) << 4) | (b1 >> 4)] as char);
292
293        if chunk.len() > 1 {
294            result.push(ALPHABET[((b1 & 0x0f) << 2) | (b2 >> 6)] as char);
295        } else {
296            result.push('=');
297        }
298
299        if chunk.len() > 2 {
300            result.push(ALPHABET[b2 & 0x3f] as char);
301        } else {
302            result.push('=');
303        }
304    }
305
306    result
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn test_content_entry_file() {
315        let entry = ContentEntry::file(
316            "main.rs".to_string(),
317            "src/main.rs".to_string(),
318            "abc123".to_string(),
319            1024,
320        );
321
322        assert_eq!(entry.content_type, ContentType::File);
323        assert_eq!(entry.name, "main.rs");
324        assert_eq!(entry.path, "src/main.rs");
325        assert_eq!(entry.size, 1024);
326    }
327
328    #[test]
329    fn test_content_entry_with_content() {
330        let entry = ContentEntry::file(
331            "test.txt".to_string(),
332            "test.txt".to_string(),
333            "abc123".to_string(),
334            11,
335        )
336        .with_content(base64_encode(b"Hello World"));
337
338        assert_eq!(entry.encoding, Some("base64".to_string()));
339        assert!(entry.content.is_some());
340    }
341
342    #[test]
343    fn test_is_readme_file() {
344        assert!(is_readme_file("README"));
345        assert!(is_readme_file("README.md"));
346        assert!(is_readme_file("readme.txt"));
347        assert!(!is_readme_file("main.rs"));
348        assert!(!is_readme_file("READMEE"));
349    }
350
351    #[test]
352    fn test_recognize_license() {
353        assert_eq!(recognize_license_file("LICENSE"), Some("LICENSE"));
354        assert_eq!(recognize_license_file("license.txt"), Some("LICENSE"));
355        assert_eq!(recognize_license_file("COPYING"), Some("COPYING"));
356        assert_eq!(recognize_license_file("main.rs"), None);
357    }
358
359    #[test]
360    fn test_detect_spdx_id() {
361        assert_eq!(
362            detect_spdx_id("MIT License\n\nPermission is hereby granted, free of charge"),
363            Some("MIT")
364        );
365        assert_eq!(
366            detect_spdx_id("Apache License Version 2.0"),
367            Some("Apache-2.0")
368        );
369        assert_eq!(detect_spdx_id("Random text"), None);
370    }
371
372    #[test]
373    fn test_base64_encode() {
374        assert_eq!(base64_encode(b"Hello"), "SGVsbG8=");
375        assert_eq!(base64_encode(b"Hi"), "SGk=");
376        assert_eq!(base64_encode(b"A"), "QQ==");
377    }
378}