use std::fmt::Write as _;
use std::path::Path;
use std::process::{Command, ExitCode};
use colored::Colorize as _;
use fallow_config::{FallowConfig, OutputFormat, ProductionAnalysis, ResolvedConfig};
use fallow_engine::changed_files::clear_ambient_git_env;
use crate::api::{
NETWORK_EXIT_CODE, ParsedErrorEnvelope, actionable_error_hint, api_url, response_message_suffix,
};
use crate::coverage::{
COVERAGE_UPLOAD_AUTH_REJECTED_EXIT_CODE as EXIT_AUTH_REJECTED,
COVERAGE_UPLOAD_PAYLOAD_TOO_LARGE_EXIT_CODE as EXIT_PAYLOAD_TOO_LARGE,
COVERAGE_UPLOAD_SERVER_ERROR_EXIT_CODE as EXIT_SERVER_ERROR,
COVERAGE_UPLOAD_VALIDATION_EXIT_CODE as EXIT_VALIDATION,
};
pub(super) const GIT_SHA_MAX_LEN: usize = 64;
#[derive(Debug)]
pub(super) enum UploadError {
Validation(String),
PayloadTooLarge(String),
AuthRejected(String),
ServerError(String),
Network(String),
}
impl UploadError {
pub(super) fn into_exit(self, log_prefix: &str, ignore_upload_errors: bool) -> ExitCode {
let soft_fail =
ignore_upload_errors && matches!(&self, Self::ServerError(_) | Self::Network(_));
let (code, body) = match self {
Self::Validation(m) => (EXIT_VALIDATION, m),
Self::PayloadTooLarge(m) => (EXIT_PAYLOAD_TOO_LARGE, m),
Self::AuthRejected(m) => (EXIT_AUTH_REJECTED, m),
Self::ServerError(m) => (EXIT_SERVER_ERROR, m),
Self::Network(m) => (NETWORK_EXIT_CODE, m),
};
let severity = if soft_fail {
"warning".yellow().bold()
} else {
"error".red().bold()
};
eprintln!("{log_prefix}: {severity}: {body}");
if soft_fail {
eprintln!(" -> --ignore-upload-errors set, continuing with exit 0");
return ExitCode::SUCCESS;
}
ExitCode::from(code)
}
}
pub(super) fn enforce_clean_worktree(
log_prefix: &str,
command: &str,
working_copy_subject: &str,
dry_run: bool,
allow_dirty: bool,
root: &Path,
) -> Result<(), UploadError> {
if dry_run || !dirty_worktree(root) {
return Ok(());
}
if allow_dirty {
eprintln!(
"{log_prefix}: {}: working tree has uncommitted changes. Proceeding because --allow-dirty was set, but {working_copy_subject} from the working copy and may not match the uploaded git SHA.",
"warning".yellow().bold(),
);
return Ok(());
}
Err(UploadError::Validation(format!(
"working tree has uncommitted changes. `{command}` is keyed to a git SHA, so uploading the working copy would drift from that commit. Commit or stash first, or pass --allow-dirty to intentionally upload the working copy."
)))
}
pub(super) fn format_upload_error_message(
command: &str,
status: u16,
body: &str,
code: Option<&str>,
envelope: &ParsedErrorEnvelope,
) -> String {
if let Some(code) = code
&& let Some(hint) = actionable_error_hint(command, code)
{
return format!("{hint} (HTTP {status}, code {code})");
}
let body_suffix = response_message_suffix(body, envelope);
format!("{command} request failed with HTTP {status}{body_suffix}")
}
pub(super) fn format_count(n: usize) -> String {
let mut s = n.to_string();
let mut i = s.len();
while i > 3 {
i -= 3;
s.insert(i, ',');
}
s
}
pub(super) fn display_endpoint_url(
override_endpoint: Option<&str>,
project_id: &str,
path_suffix: &str,
) -> String {
let base = override_endpoint.map_or_else(
|| {
std::env::var("FALLOW_API_URL")
.ok()
.filter(|v| !v.trim().is_empty())
.map_or_else(
|| "https://api.fallow.cloud".to_owned(),
|v| v.trim().trim_end_matches('/').to_owned(),
)
},
|v| v.trim().trim_end_matches('/').to_owned(),
);
format!("{base}/v1/coverage/{project_id}/{path_suffix}")
}
pub(super) fn to_posix_string(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
pub(super) fn resolve_project_id(
explicit_project_id: Option<&str>,
root: &Path,
) -> Result<String, String> {
if let Some(explicit) = explicit_project_id {
return validate_project_id(explicit.trim()).map(str::to_owned);
}
if let Ok(github_repo) = std::env::var("GITHUB_REPOSITORY") {
let trimmed = github_repo.trim();
if !trimmed.is_empty() {
return validate_project_id(trimmed).map(str::to_owned);
}
}
if let Ok(gitlab_path) = std::env::var("CI_PROJECT_PATH") {
let trimmed = gitlab_path.trim();
if !trimmed.is_empty() {
return validate_project_id(trimmed).map(str::to_owned);
}
}
if let Some(from_remote) = git_origin_project_id(root) {
return Ok(from_remote);
}
Err(
"could not determine project id. Pass --project-id <project-id>, or set \
$GITHUB_REPOSITORY / $CI_PROJECT_PATH, or ensure `git remote get-url origin` \
returns a recognizable URL."
.to_owned(),
)
}
pub(super) fn validate_project_id(id: &str) -> Result<&str, String> {
if id.is_empty() {
return Err("project id is empty".to_owned());
}
if id.contains("..") {
return Err("project id must not contain '..' path segments".to_owned());
}
Ok(id)
}
fn git_origin_project_id(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;
}
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
parse_git_remote_to_project_id(&url)
}
pub(super) fn parse_git_remote_to_project_id(url: &str) -> Option<String> {
let stripped_suffix = url.trim().trim_end_matches(".git");
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
}
pub(super) 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}"))
}
pub(super) fn resolve_api_key(explicit: Option<&str>) -> Result<String, String> {
if let Some(explicit) = explicit {
let trimmed = explicit.trim();
if !trimmed.is_empty() {
return Ok(trimmed.to_owned());
}
}
if let Ok(from_env) = std::env::var("FALLOW_API_KEY") {
let trimmed = from_env.trim();
if !trimmed.is_empty() {
return Ok(trimmed.to_owned());
}
}
Err(
"no API key. Set $FALLOW_API_KEY or pass --api-key <KEY>. Generate at \
https://fallow.cloud/settings#api-keys."
.to_owned(),
)
}
pub(super) fn endpoint_url(
override_endpoint: Option<&str>,
project_id: &str,
path_suffix: &str,
) -> String {
let path = format!(
"/v1/coverage/{}/{path_suffix}",
url_encode_path_segment(project_id)
);
match override_endpoint {
Some(base) => format!("{}{path}", base.trim().trim_end_matches('/')),
None => api_url(&path),
}
}
#[expect(
clippy::expect_used,
reason = "formatting percent-encoded bytes into String is infallible"
)]
pub 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
}
pub(super) fn dashboard_repo_url(project_id: &str) -> String {
format!(
"https://fallow.cloud/repo/{}",
url_encode_path_segment(project_id)
)
}
pub(super) fn resolve_git_sha(
explicit_git_sha: Option<&str>,
root: &Path,
) -> Result<String, String> {
let sha = if let Some(explicit) = explicit_git_sha {
explicit.trim().to_owned()
} else {
fallow_engine::repo_refs::head_sha(root)
.map_err(|err| {
format!("could not resolve git SHA: {err}. Pass --git-sha <sha> explicitly.")
})?
.ok_or_else(|| {
"`git rev-parse HEAD` failed. Pass --git-sha <sha> explicitly.".to_owned()
})?
};
if sha.is_empty() {
return Err("git sha is empty".to_owned());
}
if sha.len() > GIT_SHA_MAX_LEN {
return Err(format!(
"git sha is {} chars, server limit is {}",
sha.len(),
GIT_SHA_MAX_LEN
));
}
if !sha
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
{
return Err(format!(
"git sha '{sha}' contains characters outside [A-Za-z0-9._-]"
));
}
Ok(sha)
}
pub(super) fn dirty_worktree(root: &Path) -> bool {
let mut command = Command::new("git");
command.args(["status", "--porcelain"]).current_dir(root);
clear_ambient_git_env(&mut command);
let Ok(output) = command.output() else {
return false;
};
if !output.status.success() {
return false;
}
output.stdout.iter().any(|b| !b.is_ascii_whitespace())
}
#[cfg(test)]
pub(super) fn load_resolved_config(root: &Path) -> Result<ResolvedConfig, String> {
load_resolved_config_with_options(root, false)
}
pub(super) fn load_resolved_config_with_options(
root: &Path,
allow_remote_extends: bool,
) -> Result<ResolvedConfig, String> {
let user_config = match FallowConfig::find_and_load_with_options(
root,
fallow_config::ConfigLoadOptions {
allow_remote_extends,
},
) {
Ok(Some((config, _path))) => Some(config),
Ok(None) => None,
Err(e) => return Err(format!("config load failed: {e}")),
};
let mut config = user_config.unwrap_or_default();
config.production = config
.production
.for_analysis(ProductionAnalysis::DeadCode)
.into();
let threads = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
Ok(config.resolve(
root.to_path_buf(),
OutputFormat::Human,
threads,
true,
true,
None,
))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn into_exit_maps_variants_and_soft_fails_transient_when_opted_in() {
let exit = |err: UploadError, ignore: bool| err.into_exit("fallow coverage test", ignore);
assert_eq!(
exit(UploadError::Validation("v".to_owned()), false),
ExitCode::from(EXIT_VALIDATION)
);
assert_eq!(
exit(UploadError::PayloadTooLarge("p".to_owned()), false),
ExitCode::from(EXIT_PAYLOAD_TOO_LARGE)
);
assert_eq!(
exit(UploadError::AuthRejected("a".to_owned()), false),
ExitCode::from(EXIT_AUTH_REJECTED)
);
assert_eq!(
exit(UploadError::ServerError("s".to_owned()), false),
ExitCode::from(EXIT_SERVER_ERROR)
);
assert_eq!(
exit(UploadError::Network("n".to_owned()), false),
ExitCode::from(NETWORK_EXIT_CODE)
);
assert_eq!(
exit(UploadError::ServerError("s".to_owned()), true),
ExitCode::SUCCESS
);
assert_eq!(
exit(UploadError::Network("n".to_owned()), true),
ExitCode::SUCCESS
);
assert_eq!(
exit(UploadError::AuthRejected("a".to_owned()), true),
ExitCode::from(EXIT_AUTH_REJECTED)
);
assert_eq!(
exit(UploadError::PayloadTooLarge("p".to_owned()), true),
ExitCode::from(EXIT_PAYLOAD_TOO_LARGE)
);
}
fn enforce(dry_run: bool, allow_dirty: bool, root: &Path) -> Result<(), UploadError> {
enforce_clean_worktree(
"fallow coverage upload-inventory",
"upload-inventory",
"the inventory comes",
dry_run,
allow_dirty,
root,
)
}
#[test]
fn dirty_worktree_is_rejected_by_default() {
let repo = create_dirty_git_repo();
let err = enforce(false, false, repo.path())
.expect_err("dirty repo should fail without --allow-dirty");
let UploadError::Validation(message) = err else {
panic!("expected validation error, got {err:?}");
};
assert!(message.contains("working tree has uncommitted changes"));
assert!(message.contains("`upload-inventory` is keyed to a git SHA"));
assert!(message.contains("--allow-dirty"));
}
#[test]
fn dirty_worktree_is_allowed_with_explicit_opt_in() {
let repo = create_dirty_git_repo();
assert!(enforce(false, true, repo.path()).is_ok());
}
#[test]
fn dry_run_skips_dirty_worktree_validation() {
let repo = create_dirty_git_repo();
assert!(enforce(true, false, repo.path()).is_ok());
}
fn create_dirty_git_repo() -> TempDir {
let dir = tempfile::tempdir().expect("create temp repo");
run_git(dir.path(), &["init", "-q"]);
run_git(dir.path(), &["config", "commit.gpgsign", "false"]);
run_git(dir.path(), &["config", "user.email", "review@example.com"]);
run_git(dir.path(), &["config", "user.name", "Reviewer"]);
std::fs::write(dir.path().join("a.js"), "function committed() {}\n")
.expect("write committed file");
run_git(dir.path(), &["add", "a.js"]);
run_git(dir.path(), &["commit", "-qm", "init"]);
std::fs::write(
dir.path().join("a.js"),
"function committed() {}\nfunction dirty() {}\n",
)
.expect("write dirty file");
dir
}
fn run_git(root: &Path, args: &[&str]) {
let status = fallow_engine::changed_files::clear_ambient_git_env(&mut Command::new("git"))
.args(args)
.current_dir(root)
.status()
.expect("run git");
assert!(status.success(), "git {args:?} failed");
}
#[test]
fn resolve_git_sha_validates_explicit_value() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
assert_eq!(resolve_git_sha(Some("abcdef1"), root).unwrap(), "abcdef1");
assert!(resolve_git_sha(Some(""), root).is_err(), "empty sha");
assert!(
resolve_git_sha(Some(&"a".repeat(GIT_SHA_MAX_LEN + 1)), root).is_err(),
"over-length sha"
);
assert!(
resolve_git_sha(Some("bad sha!"), root).is_err(),
"illegal characters"
);
}
#[test]
fn format_count_groups_thousands() {
assert_eq!(format_count(0), "0");
assert_eq!(format_count(999), "999");
assert_eq!(format_count(1_000), "1,000");
assert_eq!(format_count(14_280), "14,280");
assert_eq!(format_count(1_234_567), "1,234,567");
}
#[test]
fn display_endpoint_url_uses_override_unencoded() {
let url = display_endpoint_url(Some("http://127.0.0.1:3000/"), "a/b", "static-findings");
assert_eq!(url, "http://127.0.0.1:3000/v1/coverage/a/b/static-findings");
}
#[test]
fn to_posix_string_normalizes_windows_separators() {
let p = Path::new("src\\foo\\bar.ts");
assert_eq!(to_posix_string(p), "src/foo/bar.ts");
}
#[test]
fn load_resolved_config_flattens_per_analysis_production() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".fallowrc.json"),
r#"{"production": {"deadCode": true}}"#,
)
.unwrap();
let resolved = load_resolved_config_with_options(dir.path(), false).unwrap();
assert!(
resolved.production,
"per-analysis deadCode production must survive resolve"
);
}
#[test]
fn parse_git_remote_https_with_dot_git() {
assert_eq!(
parse_git_remote_to_project_id("https://github.com/fallow-rs/fallow.git"),
Some("fallow-rs/fallow".to_owned())
);
}
#[test]
fn parse_git_remote_https_without_dot_git() {
assert_eq!(
parse_git_remote_to_project_id("https://gitlab.com/acme/widgets"),
Some("acme/widgets".to_owned())
);
}
#[test]
fn parse_git_remote_ssh_colon_shape() {
assert_eq!(
parse_git_remote_to_project_id("git@github.com:fallow-rs/fallow.git"),
Some("fallow-rs/fallow".to_owned())
);
}
#[test]
fn parse_git_remote_ssh_scheme_shape() {
assert_eq!(
parse_git_remote_to_project_id("ssh://git@github.com/fallow-rs/fallow.git"),
Some("fallow-rs/fallow".to_owned())
);
}
#[test]
fn parse_git_remote_nested_group_uses_last_two_segments() {
assert_eq!(
parse_git_remote_to_project_id("https://gitlab.com/acme/team/widgets.git"),
Some("team/widgets".to_owned())
);
assert_eq!(
parse_git_remote_to_project_id("ssh://git@gitlab.com/group/subgroup/repo.git"),
Some("subgroup/repo".to_owned())
);
}
#[test]
fn parse_git_remote_rejects_incomplete_urls() {
assert_eq!(parse_git_remote_to_project_id("https://example.com/"), None);
assert_eq!(parse_git_remote_to_project_id(""), None);
assert_eq!(parse_git_remote_to_project_id("not-a-remote"), None);
assert_eq!(parse_git_remote_to_project_id("git@github.com:owner"), None);
assert_eq!(parse_git_remote_to_project_id("https://github.com"), None);
assert_eq!(parse_git_remote_to_project_id("not a remote"), None);
}
#[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 validate_project_id_accepts_owner_repo_and_bare() {
assert!(validate_project_id("fallow-rs/fallow").is_ok());
assert!(validate_project_id("fallow-cloud-api").is_ok());
}
#[test]
fn validate_project_id_rejects_path_traversal_and_empty() {
assert!(validate_project_id("../etc/passwd").is_err());
assert!(validate_project_id("acme/../secret").is_err());
assert!(validate_project_id("").is_err());
}
#[test]
fn dashboard_repo_url_targets_the_repo_route() {
assert_eq!(
dashboard_repo_url("my-service"),
"https://fallow.cloud/repo/my-service"
);
}
#[test]
fn dashboard_repo_url_encodes_a_slash_scoped_project_id() {
assert_eq!(
dashboard_repo_url("owner/my-service"),
"https://fallow.cloud/repo/owner%2Fmy-service"
);
}
#[test]
fn url_encode_path_segment_passthrough_for_unreserved_chars() {
assert_eq!(
url_encode_path_segment("abc-123_foo.bar~"),
"abc-123_foo.bar~"
);
assert_eq!(url_encode_path_segment("a-b_c.d~e"), "a-b_c.d~e");
}
#[test]
fn url_encode_path_segment_encodes_reserved_bytes() {
assert_eq!(url_encode_path_segment("/"), "%2F");
assert_eq!(url_encode_path_segment("@"), "%40");
assert_eq!(url_encode_path_segment("a b"), "a%20b");
assert_eq!(
url_encode_path_segment("fallow-rs/fallow"),
"fallow-rs%2Ffallow"
);
assert_eq!(url_encode_path_segment("a/b@c"), "a%2Fb%40c");
}
#[test]
fn url_encode_path_segment_empty_string_returns_empty() {
assert_eq!(url_encode_path_segment(""), "");
}
#[test]
fn url_encode_path_segment_percent_encodes_utf8() {
assert_eq!(url_encode_path_segment("caf\u{e9}"), "caf%C3%A9");
}
#[test]
fn endpoint_url_uses_override_and_encodes_project_id() {
assert_eq!(
endpoint_url(Some("http://127.0.0.1:3000"), "a/b", "inventory"),
"http://127.0.0.1:3000/v1/coverage/a%2Fb/inventory"
);
assert_eq!(
endpoint_url(Some("http://127.0.0.1:3000/"), "a/b", "static-findings"),
"http://127.0.0.1:3000/v1/coverage/a%2Fb/static-findings"
);
assert_eq!(
endpoint_url(Some("http://localhost:3000"), "owner/repo", "source-maps"),
"http://localhost:3000/v1/coverage/owner%2Frepo/source-maps"
);
}
#[test]
fn resolve_api_key_trims_explicit_value() {
assert_eq!(
resolve_api_key(Some(" fallow_key_123 ")).unwrap(),
"fallow_key_123"
);
}
}