Skip to main content

uxn_tal/fetch/
codeberg.rs

1use super::{
2    downloader::{http_get, write_bytes},
3    includes::{parse_includes, resolve_include},
4    provider::{FetchResult, Provider, RepoRef},
5};
6use std::{
7    collections::{HashSet, VecDeque},
8    fs,
9    path::{Path, PathBuf},
10};
11use url::Url;
12
13pub struct Codeberg;
14
15impl Codeberg {
16    // Gitea/Codeberg raw:
17    //   https://codeberg.org/<owner>/<repo>/raw/<branch>/<path>
18    fn raw_url(r: &RepoRef, repo_rel: &str) -> String {
19        format!(
20            "https://codeberg.org/{}/{}/raw/{}/{}",
21            r.owner, r.repo, r.branch, repo_rel
22        )
23    }
24
25    // fn try_entry(r: &RepoRef, out: &Path, repo_rel: &str) -> Option<PathBuf> {
26    //     let url = Self::raw_url(r, repo_rel);
27    //     if let Ok(bytes) = http_get(&url) {
28    //         let local = out.join(repo_rel);
29    //         if write_bytes(&local, &bytes).is_ok() {
30    //             return Some(local);
31    //         }
32    //     }
33    //     None
34    // }
35
36    // fn branch_candidates(branch: &str) -> Vec<String> {
37    //     if branch != "HEAD" {
38    //         return vec![branch.to_string()];
39    //     }
40    //     // common defaults
41    //     vec!["main".into(), "master".into(), "trunk".into()]
42    // }
43
44    fn fetch_file(
45        r: &RepoRef,
46        out_root: &Path,
47        repo_rel: &str,
48    ) -> Result<PathBuf, Box<dyn std::error::Error>> {
49        let url = Self::raw_url(r, repo_rel);
50        let bytes = http_get(&url)?;
51        let local = out_root.join(repo_rel);
52        // Check for HTML/404 response
53        let is_html = bytes.starts_with(b"<!doctype html") || bytes.starts_with(b"<html");
54        if is_html {
55            let _ = std::fs::remove_file(&local);
56            return Err(format!("Codeberg: Got HTML/404 for {}", url).into());
57        }
58        if let Some(p) = local.parent() {
59            fs::create_dir_all(p)?;
60        }
61        write_bytes(&local, &bytes)?;
62        Ok(local)
63    }
64}
65
66impl Provider for Codeberg {
67    fn parse_url(&self, url: &str) -> Option<RepoRef> {
68        let u = Url::parse(url).ok()?;
69        if u.domain()? != "codeberg.org" {
70            return None;
71        }
72        let segs = u.path_segments()?.collect::<Vec<_>>();
73        if segs.len() < 2 {
74            return None;
75        }
76        let owner = segs[0].to_string();
77        let repo = segs[1].trim_end_matches(".git").to_string();
78
79        if segs.len() == 2 {
80            // repo root
81            return Some(RepoRef {
82                host: "codeberg.org".into(),
83                owner,
84                repo,
85                branch: "HEAD".into(),
86                path: None,
87            });
88        }
89
90        // Accept Gitea view routes: src/raw/tree/blob/blame and also tag/commit forms
91        match segs[2] {
92            // Standard: /<view>/<branch>/<path...>
93            "src" | "raw" | "tree" | "blob" | "blame" if segs.len() >= 4 => {
94                // handle multi-part ref: tag/<tag> or commit/<sha>
95                if segs[3] == "tag" && segs.len() >= 6 {
96                    let branch = format!("tag/{}", segs[4]);
97                    let path = if segs.len() > 5 {
98                        Some(segs[5..].join("/"))
99                    } else {
100                        None
101                    };
102                    return Some(RepoRef {
103                        host: "codeberg.org".into(),
104                        owner,
105                        repo,
106                        branch,
107                        path,
108                    });
109                }
110                if segs[3] == "commit" && segs.len() >= 6 {
111                    let branch = format!("commit/{}", segs[4]);
112                    let path = if segs.len() > 5 {
113                        Some(segs[5..].join("/"))
114                    } else {
115                        None
116                    };
117                    return Some(RepoRef {
118                        host: "codeberg.org".into(),
119                        owner,
120                        repo,
121                        branch,
122                        path,
123                    });
124                }
125                // Normal branch name
126                let branch = segs[3].to_string();
127                let path = if segs.len() > 4 {
128                    Some(segs[4..].join("/"))
129                } else {
130                    None
131                };
132                Some(RepoRef {
133                    host: "codeberg.org".into(),
134                    owner,
135                    repo,
136                    branch,
137                    path,
138                })
139            }
140            _ => {
141                // Fallback: treat the rest as a path, unknown ref
142                let path = if segs.len() > 2 {
143                    Some(segs[2..].join("/"))
144                } else {
145                    None
146                };
147                Some(RepoRef {
148                    host: "codeberg.org".into(),
149                    owner,
150                    repo,
151                    branch: "HEAD".into(),
152                    path,
153                })
154            }
155        }
156    }
157
158    fn fetch_tal_tree(
159        &self,
160        r: &RepoRef,
161        out_root: &Path,
162    ) -> Result<FetchResult, Box<dyn std::error::Error>> {
163        let entry_rel = match &r.path {
164            Some(p)
165                if p.to_ascii_lowercase().ends_with(".tal")
166                    || p.to_ascii_lowercase().ends_with(".rom")
167                    || p.to_ascii_lowercase().ends_with(".rom.txt")
168                    || p.to_ascii_lowercase().ends_with(".orca")
169                    || p.to_ascii_lowercase().ends_with(".bas") =>
170            {
171                p.replace('\\', "/")
172            }
173            _ => return Err(
174                "codeberg: URL must point to a .tal, .rom, .rom.txt, .orca, or .bas file; not guessing entries"
175                    .into(),
176            ),
177        };
178
179        // Check cache for entry file first
180        let entry_local = out_root.join(&entry_rel);
181        if entry_local.exists() {
182            eprintln!("[Codeberg] Using cached file: {}", entry_local.display());
183            return Ok(FetchResult {
184                entry_local: entry_local.clone(),
185                all_files: vec![entry_local],
186            });
187        }
188        let entry_local = Self::fetch_file(r, out_root, &entry_rel)?;
189        let mut all = vec![entry_local.clone()];
190        // Recursively fetch includes for .tal files
191        if entry_rel.to_ascii_lowercase().ends_with(".tal") {
192            let mut visited: HashSet<String> = [entry_rel.clone()].into_iter().collect();
193            let mut q: VecDeque<(String, PathBuf)> =
194                VecDeque::from([(entry_rel.clone(), entry_local.clone())]);
195            while let Some((curr_rel, curr_local)) = q.pop_front() {
196                let src = fs::read_to_string(&curr_local).unwrap_or_default();
197                for inc in parse_includes(&src) {
198                    let target = resolve_include(&curr_rel, &inc);
199                    if !visited.insert(target.clone()) {
200                        continue;
201                    }
202                    let mut attempts: Vec<(String, Option<std::path::PathBuf>)> =
203                        vec![(target.clone(), None)];
204                    let mut success: Option<(String, PathBuf)> = None;
205                    let mut errors: Vec<(String, String)> = vec![];
206                    // Try original
207                    match Self::fetch_file(r, out_root, &target) {
208                        Ok(loc) => {
209                            success = Some((target.clone(), loc));
210                        }
211                        Err(e) => {
212                            let local = out_root.join(&target);
213                            let _ = std::fs::remove_file(&local);
214                            errors.push((target.clone(), format!("{}", e)));
215                        }
216                    }
217                    // Fallback 1: deduped path
218                    if success.is_none() {
219                        let parts: Vec<&str> = target.split('/').collect();
220                        if parts.len() >= 3 && parts[0] == parts[1] {
221                            let deduped = parts[1..].join("/");
222                            attempts.push((deduped.clone(), None));
223                            match Self::fetch_file(r, out_root, &deduped) {
224                                Ok(loc) => {
225                                    success = Some((deduped.clone(), loc));
226                                }
227                                Err(e) => {
228                                    let local = out_root.join(&deduped);
229                                    let _ = std::fs::remove_file(&local);
230                                    errors.push((deduped.clone(), format!("{}", e)));
231                                }
232                            }
233                        }
234                    }
235                    // Fallback 2: walk up ancestor directories
236                    if success.is_none() {
237                        if let Some(fname) = Path::new(&target).file_name() {
238                            let mut ancestor = Path::new(&target);
239                            while let Some(parent) = ancestor.parent() {
240                                let try_path = parent.join(fname);
241                                let try_str = try_path.to_string_lossy().replace('\\', "/");
242                                if try_str == target {
243                                    break;
244                                }
245                                attempts.push((try_str.clone(), None));
246                                match Self::fetch_file(r, out_root, &try_str) {
247                                    Ok(loc) => {
248                                        success = Some((try_str.clone(), loc));
249                                        break;
250                                    }
251                                    Err(e) => {
252                                        let local = out_root.join(&try_str);
253                                        let _ = std::fs::remove_file(&local);
254                                        errors.push((try_str.clone(), format!("{}", e)));
255                                    }
256                                }
257                                ancestor = parent;
258                            }
259                        }
260                    }
261                    // Fallback 3: try just the filename in the entry file's directory
262                    if success.is_none() {
263                        if let Some(fname) = Path::new(&target).file_name() {
264                            if let Some(entry_dir) = Path::new(&entry_rel).parent() {
265                                let entry_dir_path = entry_dir.join(fname);
266                                let entry_dir_str =
267                                    entry_dir_path.to_string_lossy().replace('\\', "/");
268                                attempts.push((entry_dir_str.clone(), None));
269                                match Self::fetch_file(r, out_root, &entry_dir_str) {
270                                    Ok(loc) => {
271                                        success = Some((entry_dir_str.clone(), loc));
272                                    }
273                                    Err(e) => {
274                                        let local = out_root.join(&entry_dir_str);
275                                        let _ = std::fs::remove_file(&local);
276                                        errors.push((entry_dir_str.clone(), format!("{}", e)));
277                                    }
278                                }
279                            }
280                        }
281                    }
282                    if let Some((path, loc)) = success {
283                        all.push(loc.clone());
284                        q.push_back((path, loc));
285                        continue;
286                    }
287                    // No fallback possible, return error with all attempts
288                    let error_msg = format!(
289                        "Failed to fetch include '{}'. Attempts: {}. Errors: {}",
290                        inc,
291                        attempts
292                            .iter()
293                            .map(|(p, _)| format!("'{}'", p))
294                            .collect::<Vec<_>>()
295                            .join(", "),
296                        errors
297                            .iter()
298                            .map(|(p, e)| format!("{}: {}", p, e))
299                            .collect::<Vec<_>>()
300                            .join("; ")
301                    );
302                    return Err(error_msg.into());
303                }
304            }
305        }
306        Ok(FetchResult {
307            entry_local: all[0].clone(),
308            all_files: all,
309        })
310
311        // for b in Self::branch_candidates(&r_in.branch) {
312        //     let r = RepoRef {
313        //         branch: b.clone(),
314        //         ..r_in.clone()
315        //     };
316
317        //     // figure entry candidates
318        //     let candidates: Vec<String> = match &r.path {
319        //         Some(p) if p.ends_with(".tal") => vec![p.clone()],
320        //         Some(p) if !p.is_empty() => repo_entry_guesses()
321        //             .iter()
322        //             .map(|g| format!("{}/{}", p.trim_end_matches('/'), g))
323        //             .collect(),
324        //         _ => repo_entry_guesses().iter().map(|s| s.to_string()).collect(),
325        //     };
326
327        //     if let Some((entry_rel, entry_local)) = candidates
328        //         .into_iter()
329        //         .find_map(|c| Self::try_entry(&r, out_root, &c).map(|l| (c, l)))
330        //     {
331        //         // BFS includes
332        //         let mut visited = HashSet::new();
333        //         let mut all = vec![entry_local.clone()];
334        //         visited.insert(entry_rel.clone());
335
336        //         let mut q = VecDeque::new();
337        //         q.push_back((entry_rel, entry_local));
338
339        //         let mut steps = 0usize;
340        //         while let Some((repo_rel, local)) = q.pop_front() {
341        //             if steps >= 200 {
342        //                 break;
343        //             }
344        //             steps += 1;
345
346        //             let src = fs::read_to_string(&local).unwrap_or_default();
347        //             for inc in parse_includes(&src) {
348        //                 let target = resolve_include(&repo_rel, &inc);
349        //                 if !visited.insert(target.clone()) {
350        //                     continue;
351        //                 }
352        //                 let url = Self::raw_url(&r, &target);
353        //                 match http_get(&url) {
354        //                     Ok(bytes) => {
355        //                         let loc = out_root.join(&target);
356        //                         if write_bytes(&loc, &bytes).is_ok() {
357        //                             all.push(loc.clone());
358        //                             q.push_back((target, loc));
359        //                         }
360        //                     }
361        //                     Err(_) => eprintln!(
362        //                         "warning: include not found in Codeberg repo: {} (branch {})",
363        //                         target, r.branch
364        //                     ),
365        //                 }
366        //             }
367        //         }
368
369        //         return Ok(FetchResult {
370        //             entry_local: all[0].clone(),
371        //             all_files: all,
372        //         });
373        //     }
374        // }
375
376        // Err("Could not find an entry .tal in Codeberg repo across tried branches".into())
377    }
378
379    fn parse_git_url(&self, url: &str) -> Option<(RepoRef, String)> {
380        // Handle git@codeberg.org:owner/repo/branch/path/file.tal (SSH format)
381        if let Some(path_part) = url.strip_prefix("git@codeberg.org:") {
382            // Remove "git@codeberg.org:"
383            let segments: Vec<&str> = path_part.split('/').collect();
384
385            if segments.len() < 4 {
386                return None;
387            }
388
389            let owner = segments[0].to_string();
390            let repo = segments[1].to_string();
391
392            // Skip "tree" segment if present
393            let (branch, path_start) = if segments[2] == "tree" {
394                (segments[3].to_string(), 4)
395            } else {
396                (segments[2].to_string(), 3)
397            };
398
399            let path = if segments.len() > path_start {
400                Some(segments[path_start..].join("/"))
401            } else {
402                None
403            };
404
405            let repo_ref = RepoRef {
406                host: "codeberg.org".to_string(),
407                owner,
408                repo,
409                branch,
410                path,
411            };
412
413            let url_git = format!("git@codeberg.org:{}/{}", repo_ref.owner, repo_ref.repo);
414            return Some((repo_ref, url_git));
415        }
416        // Handle git@https://... or git@http://... (strip git@ prefix and parse as HTTPS)
417        else if url.starts_with("git@https://") || url.starts_with("git@http://") {
418            let stripped_url = &url[4..]; // Remove "git@" prefix
419            if let Some(repo_ref) = self.parse_url(stripped_url) {
420                let url_git = format!("https://codeberg.org/{}/{}", repo_ref.owner, repo_ref.repo);
421                return Some((repo_ref, url_git));
422            }
423        }
424
425        None
426    }
427}