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}