use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
use std::time::SystemTime;
use colored::Colorize as _;
use fallow_core::git_env::clear_ambient_git_env;
use globset::{Glob, GlobSet, GlobSetBuilder};
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use crate::api::{
NETWORK_EXIT_CODE, ResponseBodyReader, api_url, parse_error_envelope, response_message_suffix,
retry_delay_for_status, sanitize_network_error, should_retry_status,
try_api_agent_with_timeout,
};
const LOG_PREFIX: &str = "fallow coverage upload-source-maps";
const DEFAULT_ENDPOINT: &str = "https://api.fallow.cloud";
const CONNECT_TIMEOUT_SECS: u64 = 5;
const TOTAL_TIMEOUT_SECS: u64 = 60;
const MAX_ATTEMPTS: u8 = 3;
const WARN_MAP_BYTES: u64 = 10 * 1024 * 1024;
const MAX_MAP_BYTES: u64 = 100 * 1024 * 1024;
#[derive(Debug, Clone)]
pub struct UploadSourceMapsArgs {
pub dir: PathBuf,
pub include: String,
pub exclude: Vec<String>,
pub repo: Option<String>,
pub git_sha: Option<String>,
pub endpoint: Option<String>,
pub strip_path: bool,
pub dry_run: bool,
pub concurrency: usize,
pub fail_fast: bool,
}
#[derive(Debug)]
enum UploadSourceMapsError {
Validation(String),
Network(String),
Partial(Vec<MapOutcome>),
}
impl UploadSourceMapsError {
fn into_exit(self) -> ExitCode {
match self {
Self::Validation(message) => {
eprintln!("{LOG_PREFIX}: {}: {message}", "error".red().bold());
ExitCode::from(2)
}
Self::Network(message) => {
eprintln!("{LOG_PREFIX}: {}: {message}", "error".red().bold());
ExitCode::from(NETWORK_EXIT_CODE)
}
Self::Partial(outcomes) => {
print_failure_summary(&outcomes);
ExitCode::from(1)
}
}
}
}
pub fn run(args: &UploadSourceMapsArgs, root: &Path) -> ExitCode {
match run_inner(args, root) {
Ok(()) => ExitCode::SUCCESS,
Err(err) => err.into_exit(),
}
}
fn run_inner(args: &UploadSourceMapsArgs, root: &Path) -> Result<(), UploadSourceMapsError> {
let build_dir = resolve_build_dir(root, &args.dir);
if !build_dir.is_dir() {
return Err(UploadSourceMapsError::Validation(format!(
"directory not found: {}",
build_dir.display()
)));
}
let include_patterns = vec![args.include.clone()];
let include = compile_glob_set(&include_patterns, "--include")?;
let exclude = compile_glob_set(&args.exclude, "--exclude")?;
let repo = resolve_repo_name(args.repo.as_deref(), root)?;
let git_sha = resolve_git_sha(args.git_sha.as_deref(), root)?;
let maps = collect_source_maps(root, &build_dir, &include, &exclude, args.strip_path)?;
if maps.is_empty() {
return Err(UploadSourceMapsError::Validation(format!(
"no .map files found in {} (did the build step run?)",
build_dir.display()
)));
}
if args.dry_run {
print_dry_run(&repo, &git_sha, args.endpoint.as_deref(), &maps);
return Ok(());
}
let api_key = resolve_api_key()?;
upload_maps(args, &repo, &git_sha, &api_key, &maps)
}
fn resolve_build_dir(root: &Path, dir: &Path) -> PathBuf {
if dir.is_absolute() {
dir.to_path_buf()
} else {
root.join(dir)
}
}
fn compile_glob_set(patterns: &[String], flag: &str) -> Result<GlobSet, UploadSourceMapsError> {
let mut builder = GlobSetBuilder::new();
for pattern in patterns {
let glob = Glob::new(pattern).map_err(|err| {
UploadSourceMapsError::Validation(format!("invalid {flag} '{pattern}': {err}"))
})?;
builder.add(glob);
if let Some(without_prefix) = pattern.strip_prefix("**/") {
builder.add(Glob::new(without_prefix).map_err(|err| {
UploadSourceMapsError::Validation(format!("invalid {flag} '{pattern}': {err}"))
})?);
}
}
builder.build().map_err(|err| {
UploadSourceMapsError::Validation(format!("failed to compile {flag}: {err}"))
})
}
fn resolve_repo_name(explicit: Option<&str>, root: &Path) -> Result<String, UploadSourceMapsError> {
if let Some(repo) = explicit {
return validate_repo_name(repo.trim()).map(str::to_owned);
}
if let Some(repo) = package_json_repository_name(root) {
return validate_repo_name(&repo).map(str::to_owned);
}
if let Some(repo) = git_origin_repo_name(root) {
return validate_repo_name(&repo).map(str::to_owned);
}
Err(UploadSourceMapsError::Validation(
"unable to determine repo name; pass --repo".to_owned(),
))
}
fn package_json_repository_name(root: &Path) -> Option<String> {
let package_json = std::fs::read_to_string(root.join("package.json")).ok()?;
let value: serde_json::Value = serde_json::from_str(&package_json).ok()?;
let repository = value.get("repository")?;
let url = match repository {
serde_json::Value::String(url) => url.as_str(),
serde_json::Value::Object(map) => map.get("url")?.as_str()?,
_ => return None,
};
parse_repo_name_from_url(url)
}
fn git_origin_repo_name(root: &Path) -> Option<String> {
let mut command = Command::new("git");
command
.args(["remote", "get-url", "origin"])
.current_dir(root);
clear_ambient_git_env(&mut command);
let output = command.output().ok()?;
if !output.status.success() {
return None;
}
parse_repo_name_from_url(String::from_utf8_lossy(&output.stdout).trim())
}
fn parse_repo_name_from_url(url: &str) -> Option<String> {
let stripped_suffix = url
.trim()
.trim_end_matches('/')
.trim_end_matches(".git")
.trim_end_matches('/');
if !stripped_suffix.contains(':')
&& let Some(project_id) = take_last_two_segments(stripped_suffix)
{
return Some(project_id);
}
if let Some((_, path)) = stripped_suffix.split_once(':')
&& let Some(project_id) = take_last_two_segments(path)
{
return Some(project_id);
}
if let Some(path_part) = stripped_suffix.split("://").nth(1)
&& let Some((_, tail)) = path_part.split_once('/')
&& let Some(project_id) = take_last_two_segments(tail)
{
return Some(project_id);
}
None
}
fn take_last_two_segments(path: &str) -> Option<String> {
let mut parts: Vec<&str> = path
.trim_end_matches('/')
.split('/')
.filter(|segment| !segment.trim().is_empty())
.collect();
if parts.len() < 2 {
return None;
}
let repo = parts.pop()?.trim();
let owner = parts.pop()?.trim();
(!owner.is_empty() && !repo.is_empty()).then(|| format!("{owner}/{repo}"))
}
fn validate_repo_name(repo: &str) -> Result<&str, UploadSourceMapsError> {
if repo.is_empty() {
return Err(UploadSourceMapsError::Validation(
"unable to determine repo name; pass --repo".to_owned(),
));
}
if repo.contains("..") || repo.contains('\\') {
return Err(UploadSourceMapsError::Validation(
"repo name must not contain '..' or backslashes".to_owned(),
));
}
Ok(repo)
}
fn resolve_git_sha(explicit: Option<&str>, root: &Path) -> Result<String, UploadSourceMapsError> {
let sha = if let Some(sha) = explicit {
sha.trim().to_owned()
} else if let Some(sha) = env_non_empty("GITHUB_SHA")
.or_else(|| env_non_empty("CI_COMMIT_SHA"))
.or_else(|| env_non_empty("COMMIT_SHA"))
{
sha
} else {
let mut command = Command::new("git");
command.args(["rev-parse", "HEAD"]).current_dir(root);
clear_ambient_git_env(&mut command);
let output = command.output().map_err(|_| {
UploadSourceMapsError::Validation(
"unable to determine git SHA; pass --git-sha or set $GITHUB_SHA".to_owned(),
)
})?;
if !output.status.success() {
return Err(UploadSourceMapsError::Validation(
"unable to determine git SHA; pass --git-sha or set $GITHUB_SHA".to_owned(),
));
}
String::from_utf8_lossy(&output.stdout).trim().to_owned()
};
validate_git_sha(&sha)?;
Ok(sha)
}
fn env_non_empty(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
}
fn validate_git_sha(sha: &str) -> Result<(), UploadSourceMapsError> {
if !(7..=40).contains(&sha.len()) || !sha.chars().all(|ch| ch.is_ascii_hexdigit()) {
return Err(UploadSourceMapsError::Validation(
"unable to determine git SHA; pass --git-sha or set $GITHUB_SHA".to_owned(),
));
}
Ok(())
}
#[derive(Debug, Clone)]
struct SourceMapCandidate {
path: PathBuf,
rel_path: PathBuf,
file_name: String,
map_path: Option<String>,
bytes: u64,
}
fn collect_source_maps(
repo_root: &Path,
dir: &Path,
include: &GlobSet,
exclude: &GlobSet,
strip_path: bool,
) -> Result<Vec<SourceMapCandidate>, UploadSourceMapsError> {
let mut maps = Vec::new();
collect_source_maps_inner(repo_root, dir, dir, include, exclude, strip_path, &mut maps)?;
maps.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
Ok(maps)
}
fn collect_source_maps_inner(
repo_root: &Path,
root: &Path,
dir: &Path,
include: &GlobSet,
exclude: &GlobSet,
strip_path: bool,
maps: &mut Vec<SourceMapCandidate>,
) -> Result<(), UploadSourceMapsError> {
let entries = std::fs::read_dir(dir).map_err(|err| {
UploadSourceMapsError::Validation(format!("failed to read {}: {err}", dir.display()))
})?;
for entry in entries {
let entry = entry.map_err(|err| {
UploadSourceMapsError::Validation(format!("failed to read {}: {err}", dir.display()))
})?;
let path = entry.path();
let file_type = entry.file_type().map_err(|err| {
UploadSourceMapsError::Validation(format!("failed to stat {}: {err}", path.display()))
})?;
let rel_path = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
if exclude.is_match(&rel_path) {
continue;
}
if file_type.is_dir() {
collect_source_maps_inner(repo_root, root, &path, include, exclude, strip_path, maps)?;
continue;
}
if !include.is_match(&rel_path) || !path.is_file() {
continue;
}
let bytes = entry.metadata().map_or(0, |metadata| metadata.len());
let file_name = map_file_name(&rel_path, strip_path)?;
let map_path = repo_relative_map_path(repo_root, &path);
maps.push(SourceMapCandidate {
path,
rel_path,
file_name,
map_path,
bytes,
});
}
Ok(())
}
fn repo_relative_map_path(repo_root: &Path, path: &Path) -> Option<String> {
let rel = path.strip_prefix(repo_root).ok()?;
let value = to_posix_string(rel);
validate_file_name(&value).ok().map(|()| value)
}
fn map_file_name(rel_path: &Path, strip_path: bool) -> Result<String, UploadSourceMapsError> {
let value = if strip_path {
rel_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.to_owned()
} else {
to_posix_string(rel_path)
};
validate_file_name(&value)?;
Ok(value)
}
fn validate_file_name(file_name: &str) -> Result<(), UploadSourceMapsError> {
if file_name.is_empty()
|| file_name.len() > 255
|| file_name.starts_with('/')
|| file_name.contains('\\')
|| file_name
.split('/')
.any(|segment| segment.is_empty() || segment == "." || segment == "..")
{
return Err(UploadSourceMapsError::Validation(format!(
"invalid source map fileName '{file_name}'"
)));
}
Ok(())
}
fn to_posix_string(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn resolve_api_key() -> Result<String, UploadSourceMapsError> {
env_non_empty("FALLOW_API_KEY")
.ok_or_else(|| UploadSourceMapsError::Validation("FALLOW_API_KEY is required".to_owned()))
}
#[derive(Debug, Clone)]
struct PreparedSourceMap {
candidate: SourceMapCandidate,
source_map: serde_json::Value,
}
fn prepare_source_map(candidate: &SourceMapCandidate) -> MapOutcome {
if candidate.bytes > MAX_MAP_BYTES {
return MapOutcome::failed(
candidate,
FailureKind::Validation,
format!(
"source map is too large ({}); maximum is {}",
format_bytes(candidate.bytes),
format_bytes(MAX_MAP_BYTES)
),
);
}
match std::fs::read_to_string(&candidate.path) {
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
Ok(source_map) => MapOutcome::Ready(PreparedSourceMap {
candidate: candidate.clone(),
source_map,
}),
Err(err) => MapOutcome::failed(
candidate,
FailureKind::Validation,
format!("not valid JSON ({err}); skipping"),
),
},
Err(err) => MapOutcome::failed(
candidate,
FailureKind::Validation,
format!("read failed: {err}"),
),
}
}
fn upload_maps(
args: &UploadSourceMapsArgs,
repo: &str,
git_sha: &str,
api_key: &str,
maps: &[SourceMapCandidate],
) -> Result<(), UploadSourceMapsError> {
let mut outcomes = Vec::with_capacity(maps.len());
let mut ready = Vec::new();
for candidate in maps {
if candidate.bytes > WARN_MAP_BYTES && candidate.bytes <= MAX_MAP_BYTES {
eprintln!(
"{LOG_PREFIX}: {}: {} is large ({})",
"warning".yellow().bold(),
candidate.rel_path.display(),
format_bytes(candidate.bytes),
);
}
match prepare_source_map(candidate) {
MapOutcome::Ready(prepared) => ready.push(prepared),
failed => {
outcomes.push(failed);
if args.fail_fast {
return Err(UploadSourceMapsError::Partial(outcomes));
}
}
}
}
if ready.is_empty() {
return Err(UploadSourceMapsError::Partial(outcomes));
}
println!("{LOG_PREFIX}: repo={repo} sha={git_sha}");
println!(
"{LOG_PREFIX}: found {} maps ({})",
maps.len(),
format_bytes(maps.iter().map(|map| map.bytes).sum())
);
println!(
"{LOG_PREFIX}: uploading to {}",
display_endpoint_url(args.endpoint.as_deref(), repo)
);
let agent = try_api_agent_with_timeout(CONNECT_TIMEOUT_SECS, TOTAL_TIMEOUT_SECS)
.map_err(|err| UploadSourceMapsError::Network(err.to_string()))?;
let concurrency = args.concurrency.max(1);
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(concurrency)
.build()
.map_err(|err| {
UploadSourceMapsError::Validation(format!("invalid --concurrency: {err}"))
})?;
let mut uploaded = if args.fail_fast {
let mut uploaded = Vec::new();
for map in &ready {
let outcome = upload_one(
&agent,
args.endpoint.as_deref(),
repo,
git_sha,
api_key,
map,
);
let failed = matches!(outcome, MapOutcome::Failed { .. });
uploaded.push(outcome);
if failed {
break;
}
}
uploaded
} else {
pool.install(|| {
ready
.par_iter()
.map(|map| {
upload_one(
&agent,
args.endpoint.as_deref(),
repo,
git_sha,
api_key,
map,
)
})
.collect::<Vec<_>>()
})
};
outcomes.append(&mut uploaded);
let success_count = outcomes
.iter()
.filter(|outcome| outcome.is_success())
.count();
let failure_count = outcomes.len().saturating_sub(success_count);
if failure_count > 0 {
if success_count == 0
&& outcomes
.iter()
.filter_map(MapOutcome::failure_kind)
.all(|kind| kind == FailureKind::Network)
{
let detail = outcomes
.iter()
.find_map(MapOutcome::failure_reason)
.unwrap_or("network error");
return Err(UploadSourceMapsError::Network(format!(
"all source map uploads failed with network errors: {detail}"
)));
}
return Err(UploadSourceMapsError::Partial(outcomes));
}
println!(
"{LOG_PREFIX}: {}/{} uploaded",
success_count,
outcomes.len()
);
Ok(())
}
fn upload_one(
agent: &ureq::Agent,
endpoint_override: Option<&str>,
repo: &str,
git_sha: &str,
api_key: &str,
map: &PreparedSourceMap,
) -> MapOutcome {
for attempt in 1..=MAX_ATTEMPTS {
match send_source_map(agent, endpoint_override, repo, git_sha, api_key, map) {
Ok(response) => {
println!(
" {} {} ({})",
"ok".green(),
map.candidate.file_name,
format_bytes(response.data.file_size),
);
return MapOutcome::Success;
}
Err(err) if err.retryable && attempt < MAX_ATTEMPTS => {
let retry_delay = retry_delay_for_status(
err.status.unwrap_or(502),
err.retry_after.as_deref(),
attempt,
SystemTime::now(),
);
eprintln!(
"{LOG_PREFIX}: {} retrying {} in {}ms (attempt {}/{MAX_ATTEMPTS})",
"warning".yellow().bold(),
map.candidate.file_name,
retry_delay.as_millis(),
attempt + 1,
);
std::thread::sleep(retry_delay);
}
Err(err) => {
return MapOutcome::failed(&map.candidate, err.kind, err.message);
}
}
}
MapOutcome::failed(
&map.candidate,
FailureKind::Network,
"upload failed after retries".to_owned(),
)
}
#[derive(Debug, Serialize)]
struct SourceMapRequest<'a> {
#[serde(rename = "gitSha")]
git_sha: &'a str,
#[serde(rename = "fileName")]
file_name: &'a str,
#[serde(rename = "mapPath", skip_serializing_if = "Option::is_none")]
map_path: Option<&'a str>,
#[serde(rename = "sourceMap")]
source_map: &'a serde_json::Value,
}
#[derive(Debug, Deserialize)]
struct SourceMapUploadEnvelope {
data: SourceMapUploadData,
}
#[derive(Debug, Deserialize)]
struct SourceMapUploadData {
#[serde(rename = "fileSize")]
file_size: u64,
}
#[derive(Debug)]
struct UploadAttemptError {
message: String,
retryable: bool,
status: Option<u16>,
retry_after: Option<String>,
kind: FailureKind,
}
fn send_source_map(
agent: &ureq::Agent,
endpoint_override: Option<&str>,
repo: &str,
git_sha: &str,
api_key: &str,
map: &PreparedSourceMap,
) -> Result<SourceMapUploadEnvelope, UploadAttemptError> {
let url = endpoint_url(endpoint_override, repo);
let payload = SourceMapRequest {
git_sha,
file_name: &map.candidate.file_name,
map_path: map.candidate.map_path.as_deref(),
source_map: &map.source_map,
};
let mut response = agent
.post(&url)
.header("Authorization", &format!("Bearer {api_key}"))
.send_json(&payload)
.map_err(|err| UploadAttemptError {
message: sanitize_network_error(&format!("network error: {err}")),
retryable: true,
status: Some(502),
retry_after: None,
kind: FailureKind::Network,
})?;
let status = response.status().as_u16();
if matches!(status, 200 | 201) {
return response
.read_json::<SourceMapUploadEnvelope>()
.map_err(|err| UploadAttemptError {
message: format!("malformed response body: {err}"),
retryable: false,
status: None,
retry_after: None,
kind: FailureKind::Http,
});
}
let retry_after = response
.headers()
.get("Retry-After")
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
let body = response.body_mut().read_to_string().unwrap_or_default();
Err(UploadAttemptError {
message: classify_http_error(status, &body),
retryable: should_retry_status(status),
status: Some(status),
retry_after,
kind: FailureKind::Http,
})
}
fn endpoint_url(override_endpoint: Option<&str>, repo: &str) -> String {
let path = format!("/v1/coverage/{}/source-maps", url_encode_path_segment(repo));
match override_endpoint {
Some(base) => format!("{}{path}", base.trim().trim_end_matches('/')),
None => api_url(&path),
}
}
fn display_endpoint_url(override_endpoint: Option<&str>, repo: &str) -> String {
let base = override_endpoint.map_or_else(
|| {
std::env::var("FALLOW_API_URL")
.ok()
.filter(|value| !value.trim().is_empty())
.map_or_else(
|| DEFAULT_ENDPOINT.to_owned(),
|value| value.trim().trim_end_matches('/').to_owned(),
)
},
|value| value.trim().trim_end_matches('/').to_owned(),
);
format!(
"{base}/v1/coverage/{}/source-maps",
url_encode_path_segment(repo)
)
}
#[expect(
clippy::expect_used,
reason = "formatting percent-encoded bytes into String is infallible"
)]
fn url_encode_path_segment(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(byte as char);
}
_ => {
write!(out, "%{byte:02X}").expect("writing to String never fails");
}
}
}
out
}
fn classify_http_error(status: u16, body: &str) -> String {
let envelope = parse_error_envelope(body);
match status {
401 | 403 => "authentication failed: invalid or expired API key".to_owned(),
429 => "rate limited; retry with fewer concurrent uploads via --concurrency".to_owned(),
500..=599 => {
let suffix = source_map_message_suffix(body, &envelope);
format!("server error: {status}{suffix}")
}
_ => {
let suffix = source_map_message_suffix(body, &envelope);
format!("server rejected: HTTP {status}{suffix}")
}
}
}
fn source_map_message_suffix(body: &str, envelope: &crate::api::ParsedErrorEnvelope) -> String {
response_message_suffix(body, envelope)
.strip_prefix(':')
.map_or_else(String::new, ToOwned::to_owned)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FailureKind {
Validation,
Network,
Http,
}
#[derive(Debug, Clone)]
enum MapOutcome {
Ready(PreparedSourceMap),
Success,
Failed {
file_name: String,
reason: String,
kind: FailureKind,
},
}
impl MapOutcome {
fn failed(candidate: &SourceMapCandidate, kind: FailureKind, reason: String) -> Self {
Self::Failed {
file_name: candidate.file_name.clone(),
reason,
kind,
}
}
const fn is_success(&self) -> bool {
matches!(self, Self::Success)
}
const fn failure_kind(&self) -> Option<FailureKind> {
match self {
Self::Failed { kind, .. } => Some(*kind),
Self::Ready(_) | Self::Success => None,
}
}
fn failure_reason(&self) -> Option<&str> {
match self {
Self::Failed { reason, .. } => Some(reason),
Self::Ready(_) | Self::Success => None,
}
}
}
fn print_failure_summary(outcomes: &[MapOutcome]) {
let total = outcomes.len();
let success_count = outcomes
.iter()
.filter(|outcome| outcome.is_success())
.count();
eprintln!("{LOG_PREFIX}: {success_count}/{total} uploaded");
eprintln!("{LOG_PREFIX}: failed:");
for outcome in outcomes {
if let MapOutcome::Failed {
file_name, reason, ..
} = outcome
{
eprintln!(" {} {file_name} ({reason})", "x".red());
}
}
eprintln!("{LOG_PREFIX}: re-run to retry failed uploads");
}
fn print_dry_run(
repo: &str,
git_sha: &str,
endpoint_override: Option<&str>,
maps: &[SourceMapCandidate],
) {
let total_bytes: u64 = maps.iter().map(|map| map.bytes).sum();
println!("{LOG_PREFIX}: repo={repo} sha={git_sha}");
println!(
"{LOG_PREFIX}: would upload {} maps ({}) to {}",
maps.len(),
format_bytes(total_bytes),
display_endpoint_url(endpoint_override, repo)
);
for map in maps.iter().take(20) {
let map_path = map.map_path.as_deref().unwrap_or("-");
println!(
" - {} ({}) -> fileName={} mapPath={}",
map.rel_path.display(),
format_bytes(map.bytes),
map.file_name,
map_path
);
}
if maps.len() > 20 {
println!(" ... and {} more", maps.len() - 20);
}
println!("{LOG_PREFIX}: dry run, no uploads performed");
}
#[expect(
clippy::cast_precision_loss,
reason = "source map byte sizes are well under f64 precision loss range"
)]
fn format_bytes(bytes: u64) -> String {
const KIB: u64 = 1024;
const MIB: u64 = KIB * 1024;
const GIB: u64 = MIB * 1024;
if bytes >= GIB {
format!("{:.1} GiB", bytes as f64 / GIB as f64)
} else if bytes >= MIB {
format!("{:.1} MiB", bytes as f64 / MIB as f64)
} else if bytes >= KIB {
format!("{:.0} KiB", bytes as f64 / KIB as f64)
} else {
format!("{bytes} B")
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn parses_repo_name_from_common_urls() {
assert_eq!(
parse_repo_name_from_url("https://github.com/acme/widgets.git"),
Some("acme/widgets".to_owned())
);
assert_eq!(
parse_repo_name_from_url("git@github.com:acme/widgets.git"),
Some("acme/widgets".to_owned())
);
assert_eq!(
parse_repo_name_from_url("ssh://git@gitlab.com/acme/team/widgets"),
Some("team/widgets".to_owned())
);
assert_eq!(
parse_repo_name_from_url("acme/widgets"),
Some("acme/widgets".to_owned())
);
}
#[test]
fn validates_git_sha_like_server_schema() {
assert!(validate_git_sha("abcdef1").is_ok());
assert!(validate_git_sha("abcdef1234567890abcdef1234567890abcdef12").is_ok());
assert!(validate_git_sha("abc").is_err());
assert!(validate_git_sha("xyz1234").is_err());
assert!(validate_git_sha("abcdef1234567890abcdef1234567890abcdef123").is_err());
}
#[test]
fn map_file_name_strips_path_by_default() {
assert_eq!(
map_file_name(Path::new("assets/bundle-a1b2.js.map"), true).unwrap(),
"bundle-a1b2.js.map"
);
}
#[test]
fn map_file_name_keeps_relative_path_when_requested() {
assert_eq!(
map_file_name(Path::new("assets/bundle.js.map"), false).unwrap(),
"assets/bundle.js.map"
);
}
#[test]
fn file_name_rejects_traversal_and_backslashes() {
assert!(validate_file_name("../bundle.js.map").is_err());
assert!(validate_file_name("assets/../bundle.js.map").is_err());
assert!(validate_file_name("assets\\bundle.js.map").is_err());
}
#[test]
fn collect_source_maps_applies_include_exclude_and_file_name_mode() {
let dir = tempdir().expect("tempdir");
std::fs::create_dir_all(dir.path().join("assets")).expect("assets dir");
std::fs::create_dir_all(dir.path().join("node_modules/pkg")).expect("node_modules dir");
std::fs::write(dir.path().join("root.js.map"), "{}").expect("root map");
std::fs::write(dir.path().join("assets/app.js.map"), "{}").expect("asset map");
std::fs::write(dir.path().join("node_modules/pkg/vendor.js.map"), "{}")
.expect("vendor map");
let include = compile_glob_set(&["**/*.map".to_owned()], "--include").unwrap();
let exclude = compile_glob_set(&["**/node_modules/**".to_owned()], "--exclude").unwrap();
let maps = collect_source_maps(dir.path(), dir.path(), &include, &exclude, false).unwrap();
let file_names: Vec<&str> = maps.iter().map(|map| map.file_name.as_str()).collect();
assert_eq!(file_names, vec!["assets/app.js.map", "root.js.map"]);
}
#[test]
fn map_path_is_repo_root_relative_when_build_dir_is_a_subdirectory() {
let repo_root = tempdir().expect("tempdir");
let build_dir = repo_root.path().join("dashboard/dist");
std::fs::create_dir_all(build_dir.join("assets")).expect("assets dir");
std::fs::write(build_dir.join("assets/app-a1b2.js.map"), "{}").expect("map");
let include = compile_glob_set(&["**/*.map".to_owned()], "--include").unwrap();
let exclude = compile_glob_set(&["**/node_modules/**".to_owned()], "--exclude").unwrap();
let maps =
collect_source_maps(repo_root.path(), &build_dir, &include, &exclude, true).unwrap();
assert_eq!(maps.len(), 1);
assert_eq!(maps[0].file_name, "app-a1b2.js.map");
assert_eq!(
maps[0].map_path.as_deref(),
Some("dashboard/dist/assets/app-a1b2.js.map")
);
}
#[test]
fn repo_relative_map_path_is_none_for_a_map_outside_the_repo_root() {
let repo_root = tempdir().expect("repo root");
let elsewhere = tempdir().expect("elsewhere");
let outside = elsewhere.path().join("app.js.map");
assert_eq!(repo_relative_map_path(repo_root.path(), &outside), None);
}
#[test]
fn request_serializes_map_path_and_omits_it_when_absent() {
let source_map = serde_json::json!({ "version": 3, "sources": [], "mappings": "" });
let with_path = SourceMapRequest {
git_sha: "abcdef1",
file_name: "app.js.map",
map_path: Some("dashboard/dist/assets/app.js.map"),
source_map: &source_map,
};
let json = serde_json::to_string(&with_path).unwrap();
assert!(json.contains(r#""mapPath":"dashboard/dist/assets/app.js.map""#));
let without_path = SourceMapRequest {
git_sha: "abcdef1",
file_name: "app.js.map",
map_path: None,
source_map: &source_map,
};
let json = serde_json::to_string(&without_path).unwrap();
assert!(!json.contains("mapPath"));
}
#[test]
fn endpoint_url_encodes_repo_as_one_segment() {
assert_eq!(
endpoint_url(Some("http://localhost:3000"), "owner/repo"),
"http://localhost:3000/v1/coverage/owner%2Frepo/source-maps"
);
}
#[test]
fn classify_http_errors_matches_spec_messages() {
assert_eq!(
classify_http_error(401, ""),
"authentication failed: invalid or expired API key"
);
assert_eq!(
classify_http_error(429, ""),
"rate limited; retry with fewer concurrent uploads via --concurrency"
);
assert!(classify_http_error(500, "oops").starts_with("server error: 500"));
}
#[test]
fn classify_http_error_preserves_malformed_response_body() {
let message = classify_http_error(500, "<html>bad gateway</html>");
assert!(message.contains("<html>bad gateway</html>"));
assert!(message.contains("malformed error envelope"));
}
#[test]
fn all_network_failures_are_reported_as_network_exit() {
let candidate = SourceMapCandidate {
path: PathBuf::from("dist/app.js.map"),
rel_path: PathBuf::from("dist/app.js.map"),
file_name: "dist/app.js.map".to_owned(),
map_path: Some("dist/app.js.map".to_owned()),
bytes: 10,
};
let outcomes = [MapOutcome::failed(
&candidate,
FailureKind::Network,
"network error: connection refused".to_owned(),
)];
let success_count = outcomes
.iter()
.filter(|outcome| outcome.is_success())
.count();
assert_eq!(success_count, 0);
assert!(
outcomes
.iter()
.filter_map(MapOutcome::failure_kind)
.all(|kind| kind == FailureKind::Network)
);
assert_eq!(
outcomes.iter().find_map(MapOutcome::failure_reason),
Some("network error: connection refused")
);
}
fn candidate(bytes: u64, path: PathBuf) -> SourceMapCandidate {
SourceMapCandidate {
rel_path: PathBuf::from("app.js.map"),
file_name: "app.js.map".to_owned(),
map_path: Some("app.js.map".to_owned()),
path,
bytes,
}
}
fn dry_run_args(dir: &Path) -> UploadSourceMapsArgs {
UploadSourceMapsArgs {
dir: dir.to_path_buf(),
include: "**/*.map".to_owned(),
exclude: Vec::new(),
repo: Some("acme/widgets".to_owned()),
git_sha: Some("abcdef1".to_owned()),
endpoint: Some("http://localhost:3000".to_owned()),
strip_path: true,
dry_run: true,
concurrency: 4,
fail_fast: false,
}
}
#[test]
fn into_exit_maps_each_variant_to_its_exit_code() {
assert_eq!(
UploadSourceMapsError::Validation("x".to_owned()).into_exit(),
ExitCode::from(2)
);
assert_eq!(
UploadSourceMapsError::Network("x".to_owned()).into_exit(),
ExitCode::from(NETWORK_EXIT_CODE)
);
let failed = MapOutcome::failed(
&candidate(10, PathBuf::from("app.js.map")),
FailureKind::Http,
"server rejected".to_owned(),
);
assert_eq!(
UploadSourceMapsError::Partial(vec![failed]).into_exit(),
ExitCode::from(1)
);
}
#[test]
fn run_dry_run_on_temp_build_dir_exits_zero() {
let dir = tempdir().expect("tempdir");
std::fs::write(dir.path().join("app.js.map"), "{}").expect("map");
let code = run(&dry_run_args(dir.path()), dir.path());
assert_eq!(code, ExitCode::SUCCESS);
}
#[test]
fn run_reports_missing_directory_as_validation_exit_2() {
let dir = tempdir().expect("tempdir");
let missing = dir.path().join("does-not-exist");
let code = run(&dry_run_args(&missing), dir.path());
assert_eq!(code, ExitCode::from(2));
}
#[test]
fn run_reports_no_maps_as_validation_exit_2() {
let dir = tempdir().expect("tempdir");
let code = run(&dry_run_args(dir.path()), dir.path());
assert_eq!(code, ExitCode::from(2));
}
#[test]
fn validate_repo_name_accepts_clean_and_rejects_unsafe() {
assert_eq!(validate_repo_name("acme/widgets").unwrap(), "acme/widgets");
assert!(validate_repo_name("").is_err());
assert!(validate_repo_name("acme/../widgets").is_err());
assert!(validate_repo_name("acme\\widgets").is_err());
}
#[test]
fn take_last_two_segments_needs_two_nonempty_segments() {
assert_eq!(take_last_two_segments("widgets"), None);
assert_eq!(
take_last_two_segments("acme/widgets"),
Some("acme/widgets".to_owned())
);
assert_eq!(
take_last_two_segments("group/acme/widgets/"),
Some("acme/widgets".to_owned())
);
}
#[test]
fn resolve_repo_name_reads_package_json_repository_url() {
let dir = tempdir().expect("tempdir");
std::fs::write(
dir.path().join("package.json"),
r#"{"name":"w","repository":{"url":"https://github.com/acme/widgets.git"}}"#,
)
.expect("package.json");
assert_eq!(resolve_repo_name(None, dir.path()).unwrap(), "acme/widgets");
}
#[test]
fn prepare_source_map_classifies_size_json_and_read_failures() {
let dir = tempdir().expect("tempdir");
let too_big = candidate(MAX_MAP_BYTES + 1, dir.path().join("big.js.map"));
assert!(matches!(
prepare_source_map(&too_big),
MapOutcome::Failed { kind: FailureKind::Validation, ref reason, .. } if reason.contains("too large")
));
let ok_path = dir.path().join("ok.js.map");
std::fs::write(&ok_path, r#"{"version":3,"sources":[],"mappings":""}"#).expect("ok map");
assert!(matches!(
prepare_source_map(&candidate(40, ok_path)),
MapOutcome::Ready(_)
));
let bad_path = dir.path().join("bad.js.map");
std::fs::write(&bad_path, "not json at all").expect("bad map");
assert!(matches!(
prepare_source_map(&candidate(15, bad_path)),
MapOutcome::Failed { kind: FailureKind::Validation, ref reason, .. } if reason.contains("not valid JSON")
));
let missing = candidate(10, dir.path().join("missing.js.map"));
assert!(matches!(
prepare_source_map(&missing),
MapOutcome::Failed { kind: FailureKind::Validation, ref reason, .. } if reason.contains("read failed")
));
}
#[test]
fn url_encode_path_segment_percent_encodes_reserved_bytes() {
assert_eq!(url_encode_path_segment("owner/repo"), "owner%2Frepo");
assert_eq!(url_encode_path_segment("a-b_c.d~e"), "a-b_c.d~e");
assert_eq!(url_encode_path_segment("a b"), "a%20b");
}
#[test]
fn display_endpoint_url_uses_override_and_trims_trailing_slash() {
assert_eq!(
display_endpoint_url(Some("http://localhost:3000/"), "owner/repo"),
"http://localhost:3000/v1/coverage/owner%2Frepo/source-maps"
);
}
#[test]
fn format_bytes_scales_through_units() {
assert_eq!(format_bytes(512), "512 B");
assert_eq!(format_bytes(2 * 1024), "2 KiB");
assert_eq!(format_bytes(5 * 1024 * 1024), "5.0 MiB");
assert_eq!(format_bytes(3 * 1024 * 1024 * 1024), "3.0 GiB");
}
#[test]
fn to_posix_string_normalizes_separators() {
assert_eq!(to_posix_string(Path::new("a/b/c.map")), "a/b/c.map");
}
#[test]
fn compile_glob_set_rejects_an_invalid_pattern() {
let err = compile_glob_set(&["a[b".to_owned()], "--include")
.expect_err("an unterminated character class must be rejected");
assert!(matches!(err, UploadSourceMapsError::Validation(_)));
}
}