redskull 0.1.2

A conda recipe generator for Rust crates.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
//! Source URL generation and sha256 computation.

use anyhow::{Result, anyhow};
use flate2::read::GzDecoder;
use reqwest::blocking::Client;
use sha2::{Digest, Sha256};
use std::io::Cursor;
use std::path::PathBuf;
use tar::Archive;
use tempfile::TempDir;

/// Parsed GitHub repository info.
pub struct GitHubRepo {
    pub owner: String,
    pub name: String,
}

impl GitHubRepo {
    /// Parse owner/name from a GitHub URL.
    /// Handles trailing slashes, .git suffix, and various URL formats.
    pub fn from_url(url: &str) -> Result<Self> {
        if !url.contains("github.com") {
            return Err(anyhow!("URL is not a GitHub URL: {url}"));
        }
        let url = url.trim_end_matches('/').trim_end_matches(".git");
        let parts: Vec<&str> = url.rsplitn(3, '/').collect();
        if parts.len() < 2 {
            return Err(anyhow!("Cannot parse GitHub URL: {url}"));
        }
        Ok(Self { name: parts[0].to_string(), owner: parts[1].to_string() })
    }
}

/// Construct a GitHub archive URL for a given tag/version with a tag prefix.
pub fn github_archive_url(repo: &GitHubRepo, version: &str, tag_prefix: &str) -> String {
    format!("https://github.com/{}/{}/archive/{tag_prefix}{version}.tar.gz", repo.owner, repo.name)
}

/// Replace the version portion of a tag with the jinja `{{ version }}` placeholder.
/// If the tag does not contain the version, returns the literal tag.
pub fn tag_to_jinja_template(tag: &str, version: &str) -> String {
    if tag.contains(version) { tag.replace(version, "{{ version }}") } else { tag.to_string() }
}

/// Resolved GitHub source info.
pub struct ResolvedGitHubSource {
    /// URL template with `{{ version }}` jinja placeholder.
    pub url_template: String,
    /// SHA256 hash of the archive.
    pub sha256: String,
    /// The resolved tag (e.g., "v0.3.1" or "0.3.1").
    pub tag: String,
    /// Extracted source tree. Cleaned up when dropped.
    pub extracted: Option<ExtractedSource>,
}

/// Try to resolve the correct GitHub archive URL and compute its SHA256.
/// When `tag_override` is provided, it is used directly without tag prefix detection.
/// Otherwise, tries `v`-prefixed tag first, then bare version.
/// When `use_refs_tags` is true, the URL template uses `/archive/refs/tags/` instead of
/// `/archive/`.
pub fn resolve_github_source(
    client: &Client,
    repo: &GitHubRepo,
    version: &str,
    tag_override: Option<&str>,
    use_refs_tags: bool,
) -> Result<ResolvedGitHubSource> {
    if let Some(tag) = tag_override {
        return resolve_with_tag(client, repo, version, tag, use_refs_tags);
    }

    // Try v-prefixed tag first, then bare version
    // Each attempts the public archive URL, then falls back to API tarball
    for tag in &[format!("v{version}"), version.to_string()] {
        let result = resolve_with_tag(client, repo, version, tag, use_refs_tags);
        if result.is_ok() {
            return result;
        }
    }

    Err(anyhow!("Could not download GitHub archive for {}/{} v{}", repo.owner, repo.name, version))
}

/// Resolve a GitHub source using a specific tag.
/// Tries the public archive URL first, then falls back to the API tarball endpoint
/// (which works for private repos when GITHUB_TOKEN is set).
/// Downloads the archive, computes the sha256, and extracts the source tree.
fn resolve_with_tag(
    client: &Client,
    repo: &GitHubRepo,
    version: &str,
    tag: &str,
    use_refs_tags: bool,
) -> Result<ResolvedGitHubSource> {
    let url = format!("https://github.com/{}/{}/archive/{tag}.tar.gz", repo.owner, repo.name);
    let bytes = match client.get(&url).send() {
        Ok(resp) if resp.status().is_success() => resp.bytes()?,
        _ => {
            // Fall back to API tarball endpoint (works with auth for private repos)
            log::info!(
                "Public archive URL returned error; trying API tarball endpoint for {}/{}",
                repo.owner,
                repo.name
            );
            let api_url =
                format!("https://api.github.com/repos/{}/{}/tarball/{tag}", repo.owner, repo.name);
            let resp =
                client.get(&api_url).header("Accept", "application/vnd.github+json").send()?;
            if !resp.status().is_success() {
                return Err(anyhow!(
                    "Could not download GitHub archive for {}/{} at tag {tag}: HTTP {}",
                    repo.owner,
                    repo.name,
                    resp.status()
                ));
            }
            resp.bytes()?
        }
    };
    let hash = sha256_hex(&bytes);

    // Extract so callers can inspect Cargo.lock and the rest of the source tree.
    let extracted = match extract_tar_gz(&bytes) {
        Ok(e) => Some(e),
        Err(e) => {
            log::warn!("Failed to extract GitHub archive for {}/{tag}: {e}", repo.name);
            None
        }
    };

    let archive_base = if use_refs_tags { "archive/refs/tags" } else { "archive" };

    // Build URL template: replace the version portion of the tag with {{ version }}
    if !tag.contains(version) {
        log::warn!(
            "Tag '{tag}' does not contain version '{version}'; \
             URL template will use the literal tag and won't auto-update."
        );
    }
    let template_tag = tag_to_jinja_template(tag, version);
    let template = format!(
        "https://github.com/{}/{}/{archive_base}/{template_tag}.tar.gz",
        repo.owner, repo.name
    );

    Ok(ResolvedGitHubSource {
        url_template: template,
        sha256: hash,
        tag: tag.to_string(),
        extracted,
    })
}

/// Compute SHA256 hex digest from bytes.
fn sha256_hex(bytes: &[u8]) -> String {
    let mut hasher = Sha256::new();
    hasher.update(bytes);
    format!("{:x}", hasher.finalize())
}

/// Fetch a raw file from a GitHub repo at a given tag.
pub fn fetch_github_raw(
    client: &Client,
    repo: &GitHubRepo,
    tag: &str,
    path: &str,
) -> Result<String> {
    let url =
        format!("https://raw.githubusercontent.com/{}/{}/{tag}/{path}", repo.owner, repo.name);
    let resp = client.get(&url).send()?;
    if !resp.status().is_success() {
        return Err(anyhow!("Failed to fetch {path} from {}/{} at {tag}", repo.owner, repo.name));
    }
    Ok(resp.text()?)
}

/// Fetch the file listing of a GitHub repo at a given tag using the Trees API.
/// Returns a list of file paths relative to the repo root.
pub fn fetch_github_tree(client: &Client, repo: &GitHubRepo, tag: &str) -> Result<Vec<String>> {
    let url = format!(
        "https://api.github.com/repos/{}/{}/git/trees/{tag}?recursive=1",
        repo.owner, repo.name
    );
    let resp = client.get(&url).header("Accept", "application/vnd.github+json").send()?;
    if !resp.status().is_success() {
        return Err(anyhow!(
            "Failed to fetch tree for {}/{} at {tag}: HTTP {}",
            repo.owner,
            repo.name,
            resp.status()
        ));
    }
    let body: serde_json::Value = resp.json()?;
    let paths = body
        .get("tree")
        .and_then(|t| t.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|entry| entry.get("path").and_then(|p| p.as_str()).map(String::from))
                .collect()
        })
        .unwrap_or_default();
    Ok(paths)
}

/// Returns true if the given string looks like a valid SHA256 hex digest.
pub fn is_valid_sha256(hash: &str) -> bool {
    hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit())
}

/// Fetch the latest release tag from a GitHub repo.
/// Tries both the releases API and tags API, then picks the best version-like tag.
/// Skips non-version tags like "latest", "nightly", etc.
/// Returns the tag name (e.g., "v1.2.3" or "1.2.3").
pub fn latest_github_release(client: &Client, repo: &GitHubRepo) -> Result<String> {
    let mut candidates: Vec<String> = Vec::new();

    // Try releases API — check up to 10 recent releases for a stable version-like tag
    let releases_url =
        format!("https://api.github.com/repos/{}/{}/releases?per_page=10", repo.owner, repo.name);
    if let Ok(resp) =
        client.get(&releases_url).header("Accept", "application/vnd.github+json").send()
    {
        if resp.status().is_success() {
            if let Ok(body) = resp.json::<serde_json::Value>() {
                if let Some(releases) = body.as_array() {
                    for release in releases {
                        // Skip drafts and prereleases
                        let is_pre =
                            release.get("prerelease").and_then(|v| v.as_bool()).unwrap_or(false);
                        let is_draft =
                            release.get("draft").and_then(|v| v.as_bool()).unwrap_or(false);
                        if is_pre || is_draft {
                            continue;
                        }
                        if let Some(tag) = release.get("tag_name").and_then(|t| t.as_str()) {
                            if looks_like_version_tag(tag) && !is_prerelease_tag(tag) {
                                candidates.push(tag.to_string());
                                break;
                            }
                            log::debug!(
                                "Skipping non-version release tag '{tag}' for {}/{}",
                                repo.owner,
                                repo.name
                            );
                        }
                    }
                }
            }
        }
    }

    // Try tags API — get the most recent stable version-like tag
    let tags_url =
        format!("https://api.github.com/repos/{}/{}/tags?per_page=10", repo.owner, repo.name);
    if let Ok(resp) = client.get(&tags_url).header("Accept", "application/vnd.github+json").send() {
        if resp.status().is_success() {
            if let Ok(body) = resp.json::<serde_json::Value>() {
                if let Some(tags) = body.as_array() {
                    for tag_obj in tags {
                        if let Some(tag) = tag_obj.get("name").and_then(|n| n.as_str()) {
                            if looks_like_version_tag(tag) && !is_prerelease_tag(tag) {
                                candidates.push(tag.to_string());
                                break;
                            }
                            log::debug!(
                                "Skipping non-stable tag '{tag}' for {}/{}",
                                repo.owner,
                                repo.name
                            );
                        }
                    }
                }
            }
        }
    }

    if candidates.is_empty() {
        return Err(anyhow!(
            "No version-like tags found for {}/{}. \
             Use --tag to specify the release tag manually.",
            repo.owner,
            repo.name
        ));
    }

    // If we have candidates from both sources, pick the higher version
    if candidates.len() > 1 {
        let v0 = tag_to_version(&candidates[0]);
        let v1 = tag_to_version(&candidates[1]);
        if v0 != v1 {
            log::debug!(
                "Release tag '{}' vs tags API '{}' — comparing versions",
                candidates[0],
                candidates[1]
            );
            // Compare as semver-ish: split on dots, compare numerically
            if compare_version_strings(&v1, &v0) {
                log::info!(
                    "Tags API has newer version '{}' than latest release '{}'; using tags",
                    candidates[1],
                    candidates[0]
                );
                return Ok(candidates.swap_remove(1));
            }
        }
    }

    Ok(candidates.swap_remove(0))
}

/// Compare two version strings, returning true if `a` is greater than `b`.
/// Splits on `.` and `-`, compares segments numerically where possible.
fn compare_version_strings(a: &str, b: &str) -> bool {
    let parse_segments = |s: &str| -> Vec<u64> {
        s.split(['.', '-']).filter_map(|seg| seg.parse::<u64>().ok()).collect()
    };
    let sa = parse_segments(a);
    let sb = parse_segments(b);
    sa > sb
}

/// Strip a version prefix (`v` or `v.`) from a tag to get a bare version string.
/// Handles `v1.2.3`, `v.1.2.3`, and bare `1.2.3` patterns.
pub fn tag_to_version(tag: &str) -> String {
    // Strip `v.` before `v` to handle `v.1.2.3` (e.g., alejandrogzi tools)
    if let Some(rest) = tag.strip_prefix("v.") {
        return rest.to_string();
    }
    tag.strip_prefix('v').unwrap_or(tag).to_string()
}

/// Returns true if the given string looks like a version tag.
/// Matches patterns like `v1.2.3`, `1.2.3`, `v.1.2.3`, `tool-v1.2.3`, etc.
/// Returns false for tags like `latest`, `nightly`, `stable`.
pub fn looks_like_version_tag(tag: &str) -> bool {
    // Strip known prefixes to find the version portion
    let version_part = tag.strip_prefix("v.").or_else(|| tag.strip_prefix('v')).unwrap_or(tag);
    // A version-like string starts with a digit
    version_part.starts_with(|c: char| c.is_ascii_digit())
}

/// Returns true if the tag looks like a pre-release version.
/// Detects common pre-release suffixes: -alpha, -beta, -rc, -dev, -pre.
pub fn is_prerelease_tag(tag: &str) -> bool {
    let lower = tag.to_lowercase();
    ["-alpha", "-beta", "-rc", "-dev", "-pre", ".alpha", ".beta", ".rc"]
        .iter()
        .any(|suffix| lower.contains(suffix))
}

/// Construct a crates.io download URL.
pub fn crates_io_url(base_url: &str, dl_path: &str) -> String {
    format!("{base_url}{dl_path}")
}

/// Download a URL and compute its sha256 hex digest.
pub fn compute_sha256(client: &Client, url: &str) -> Result<(Vec<u8>, String)> {
    let response = client.get(url).send()?;
    let bytes = response.bytes()?;
    let hash = sha256_hex(&bytes);
    Ok((bytes.to_vec(), hash))
}

/// An extracted crate source tree in a temporary directory.
///
/// Owns a `TempDir` that is cleaned up on drop. `root` is the actual crate root
/// (the single top-level directory inside the archive, where `Cargo.toml` lives).
pub struct ExtractedSource {
    #[allow(dead_code)]
    tmp: TempDir,
    /// The path to the crate root inside the tempdir.
    pub root: PathBuf,
}

/// Extract a gzipped tarball (bytes) into a new temp directory and return the
/// path to the single top-level directory inside it (the "crate root").
///
/// Most tarballs from crates.io and GitHub have exactly one top-level directory;
/// if there are multiple or none, the tempdir root is returned.
pub fn extract_tar_gz(bytes: &[u8]) -> Result<ExtractedSource> {
    let tmp = tempfile::Builder::new().prefix("redskull-src-").tempdir()?;
    let mut archive = Archive::new(GzDecoder::new(Cursor::new(bytes)));
    archive.unpack(tmp.path())?;

    // Find the single top-level directory (typical for github/crates.io archives).
    let mut entries: Vec<PathBuf> =
        std::fs::read_dir(tmp.path())?.filter_map(|e| e.ok().map(|e| e.path())).collect();
    entries.sort();
    let root = if entries.len() == 1 && entries[0].is_dir() {
        entries.remove(0)
    } else {
        tmp.path().to_path_buf()
    };
    Ok(ExtractedSource { tmp, root })
}

/// Download a tarball, compute its sha256, and extract it to a temp directory.
/// Returns the hash and the extracted source tree.
pub fn fetch_and_extract(client: &Client, url: &str) -> Result<(String, ExtractedSource)> {
    let resp = client.get(url).send()?;
    if !resp.status().is_success() {
        return Err(anyhow!("Failed to download {url}: HTTP {}", resp.status()));
    }
    let bytes = resp.bytes()?;
    let hash = sha256_hex(&bytes);
    let extracted = extract_tar_gz(&bytes)?;
    Ok((hash, extracted))
}