use std::fmt::{self, Write as _};
use std::path::Path;
use std::process::{Command, ExitCode};
use fallow_config::{FallowConfig, ResolvedConfig};
use fallow_core::git_env::clear_ambient_git_env;
use serde::{Deserialize, Serialize};
use colored::Colorize as _;
use crate::api::{
NETWORK_EXIT_CODE, ParsedErrorEnvelope, ResponseBodyReader, actionable_error_hint, api_url,
parse_error_envelope, response_message_suffix, sanitize_network_error,
try_api_agent_with_timeout,
};
const LOG_PREFIX: &str = "fallow coverage upload-static-findings";
const STATIC_FINDINGS_MAX: usize = 200_000;
const GIT_SHA_MAX_LEN: usize = 64;
const UPLOAD_CONNECT_TIMEOUT_SECS: u64 = 5;
const UPLOAD_TOTAL_TIMEOUT_SECS: u64 = 30;
const KIND_UNUSED_EXPORT: &str = "unused_export";
const KIND_DEAD_FILE: &str = "dead_file";
const EXIT_VALIDATION: u8 = 10;
const EXIT_PAYLOAD_TOO_LARGE: u8 = 11;
const EXIT_AUTH_REJECTED: u8 = 12;
const EXIT_SERVER_ERROR: u8 = 13;
#[derive(Clone, Default)]
pub struct UploadStaticFindingsArgs {
pub api_key: Option<String>,
pub api_endpoint: Option<String>,
pub project_id: Option<String>,
pub git_sha: Option<String>,
pub allow_dirty: bool,
pub dry_run: bool,
pub ignore_upload_errors: bool,
}
impl fmt::Debug for UploadStaticFindingsArgs {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("UploadStaticFindingsArgs")
.field("api_key", &self.api_key.as_ref().map(|_| "***"))
.field("api_endpoint", &self.api_endpoint)
.field("project_id", &self.project_id)
.field("git_sha", &self.git_sha)
.field("allow_dirty", &self.allow_dirty)
.field("dry_run", &self.dry_run)
.field("ignore_upload_errors", &self.ignore_upload_errors)
.finish()
}
}
pub fn run(args: &UploadStaticFindingsArgs, root: &Path) -> ExitCode {
match run_inner(args, root) {
Ok(()) => ExitCode::SUCCESS,
Err(err) => err.into_exit(args.ignore_upload_errors),
}
}
#[derive(Debug)]
enum UploadError {
Validation(String),
PayloadTooLarge(String),
AuthRejected(String),
ServerError(String),
Network(String),
}
impl UploadError {
fn into_exit(self, 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)
}
}
fn run_inner(args: &UploadStaticFindingsArgs, root: &Path) -> Result<(), UploadError> {
let project_id = resolve_project_id(args, root)?;
let git_sha = resolve_git_sha(args, root)?;
enforce_clean_worktree(args, root)?;
let config = load_resolved_config(root)?;
#[expect(
deprecated,
reason = "ADR-008 deprecates fallow_core::analyze* externally; the CLI still uses the workspace path dependency"
)]
let results = fallow_core::analyze(&config)
.map_err(|err| UploadError::Validation(format!("analysis failed: {err}")))?;
let findings = collect_findings(&config, &results);
if findings.len() > STATIC_FINDINGS_MAX {
return Err(UploadError::PayloadTooLarge(format!(
"static analysis produced {} findings, exceeds the server limit of {}. \
Scope the analysis with your fallow ignore rules, or open an issue if \
your repo is legitimately larger.",
findings.len(),
STATIC_FINDINGS_MAX
)));
}
let payload = StaticFindingsRequest {
git_sha: &git_sha,
findings: &findings,
};
if args.dry_run {
print_dry_run_summary(
&project_id,
&git_sha,
&findings,
args.api_endpoint.as_deref(),
);
return Ok(());
}
let api_key = resolve_api_key(args)?;
upload(
&project_id,
args.api_endpoint.as_deref(),
&api_key,
&payload,
)
}
fn resolve_project_id(args: &UploadStaticFindingsArgs, root: &Path) -> Result<String, UploadError> {
if let Some(explicit) = args.project_id.as_deref() {
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(UploadError::Validation(
"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(),
))
}
fn validate_project_id(id: &str) -> Result<&str, UploadError> {
if id.is_empty() {
return Err(UploadError::Validation("project id is empty".to_owned()));
}
if id.contains("..") {
return Err(UploadError::Validation(
"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)
}
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
}
fn take_last_two_segments(path: &str) -> Option<String> {
let mut parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if parts.len() < 2 {
return None;
}
let repo = parts.pop()?;
let owner = parts.pop()?;
Some(format!("{owner}/{repo}"))
}
fn resolve_git_sha(args: &UploadStaticFindingsArgs, root: &Path) -> Result<String, UploadError> {
let sha = if let Some(explicit) = args.git_sha.as_deref() {
explicit.trim().to_owned()
} 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(|err| {
UploadError::Validation(format!(
"could not resolve git SHA: {err}. Pass --git-sha <sha> explicitly."
))
})?;
if !output.status.success() {
return Err(UploadError::Validation(
"`git rev-parse HEAD` failed. Pass --git-sha <sha> explicitly.".to_owned(),
));
}
String::from_utf8_lossy(&output.stdout).trim().to_owned()
};
if sha.is_empty() {
return Err(UploadError::Validation("git sha is empty".to_owned()));
}
if sha.len() > GIT_SHA_MAX_LEN {
return Err(UploadError::Validation(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(UploadError::Validation(format!(
"git sha '{sha}' contains characters outside [A-Za-z0-9._-]"
)));
}
Ok(sha)
}
fn enforce_clean_worktree(args: &UploadStaticFindingsArgs, root: &Path) -> Result<(), UploadError> {
if !dirty_worktree(root) {
return Ok(());
}
if args.allow_dirty {
eprintln!(
"{LOG_PREFIX}: {}: working tree has uncommitted changes. Proceeding because --allow-dirty was set, but the findings come from the working copy and may not match the uploaded git SHA.",
"warning".yellow().bold(),
);
return Ok(());
}
Err(UploadError::Validation(
"working tree has uncommitted changes. `upload-static-findings` 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."
.to_owned(),
))
}
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())
}
fn load_resolved_config(root: &Path) -> Result<ResolvedConfig, UploadError> {
let user_config = match FallowConfig::find_and_load(root) {
Ok(Some((config, _path))) => Some(config),
Ok(None) => None,
Err(e) => return Err(UploadError::Validation(format!("config load failed: {e}"))),
};
let config = user_config.unwrap_or_default();
let threads = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
Ok(config.resolve(
root.to_path_buf(),
fallow_config::OutputFormat::Human,
threads,
true,
true,
None,
))
}
fn collect_findings(config: &ResolvedConfig, results: &impl AnalysisLike) -> Vec<StaticFinding> {
let mut out: Vec<StaticFinding> = Vec::new();
for finding in results.unused_files() {
out.push(StaticFinding {
kind: KIND_DEAD_FILE,
file_path: repo_relative_posix(config, finding),
export_name: None,
line_number: None,
});
}
for (path, export_name, line) in results.unused_exports() {
out.push(StaticFinding {
kind: KIND_UNUSED_EXPORT,
file_path: repo_relative_posix(config, path),
export_name: Some(export_name),
line_number: Some(line),
});
}
out.sort_by(|a, b| {
a.file_path
.cmp(&b.file_path)
.then(a.kind.cmp(b.kind))
.then(a.line_number.cmp(&b.line_number))
.then(a.export_name.cmp(&b.export_name))
});
out
}
fn repo_relative_posix(config: &ResolvedConfig, path: &Path) -> String {
let rel = path
.strip_prefix(&config.root)
.map_or(path, |stripped| stripped);
to_posix_string(rel)
}
fn to_posix_string(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
#[derive(Debug, Clone, Serialize)]
struct StaticFinding {
kind: &'static str,
#[serde(rename = "filePath")]
file_path: String,
#[serde(rename = "exportName", skip_serializing_if = "Option::is_none")]
export_name: Option<String>,
#[serde(rename = "lineNumber", skip_serializing_if = "Option::is_none")]
line_number: Option<u32>,
}
#[derive(Debug, Serialize)]
struct StaticFindingsRequest<'a> {
#[serde(rename = "gitSha")]
git_sha: &'a str,
findings: &'a [StaticFinding],
}
#[derive(Debug, Deserialize)]
struct StaticFindingsResponseData {
#[serde(rename = "gitSha")]
git_sha: String,
count: u64,
}
#[derive(Debug, Deserialize)]
struct StaticFindingsResponseEnvelope {
data: StaticFindingsResponseData,
}
fn resolve_api_key(args: &UploadStaticFindingsArgs) -> Result<String, UploadError> {
if let Some(explicit) = args.api_key.as_deref() {
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(UploadError::Validation(
"no API key. Set $FALLOW_API_KEY or pass --api-key <KEY>. Generate at \
https://fallow.cloud/settings#api-keys."
.to_owned(),
))
}
fn endpoint_url(override_endpoint: Option<&str>, project_id: &str) -> String {
let path = format!(
"/v1/coverage/{}/static-findings",
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"
)]
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 upload(
project_id: &str,
endpoint_override: Option<&str>,
api_key: &str,
payload: &StaticFindingsRequest<'_>,
) -> Result<(), UploadError> {
let url = endpoint_url(endpoint_override, project_id);
println!(
"{LOG_PREFIX}: uploading {} findings for {project_id} @ {}",
format_count(payload.findings.len()),
payload.git_sha,
);
let agent = try_api_agent_with_timeout(UPLOAD_CONNECT_TIMEOUT_SECS, UPLOAD_TOTAL_TIMEOUT_SECS)
.map_err(|err| UploadError::Network(err.to_string()))?;
let mut response = agent
.post(&url)
.header("Authorization", &format!("Bearer {api_key}"))
.send_json(payload)
.map_err(|err| {
UploadError::Network(sanitize_network_error(&format!("network error: {err}")))
})?;
let status = response.status().as_u16();
if matches!(status, 200 | 201) {
let data: StaticFindingsResponseEnvelope = response
.read_json()
.map_err(|err| UploadError::ServerError(format!("malformed response body: {err}")))?;
let count = usize::try_from(data.data.count).unwrap_or(usize::MAX);
println!(
"{LOG_PREFIX}: {} · {} findings stored @ {}",
"ok".green().bold(),
format_count(count),
data.data.git_sha,
);
println!(
" -> Static findings stored. View them on the source-evidence viewer: https://fallow.cloud/{project_id}"
);
return Ok(());
}
let body = response.read_to_string().unwrap_or_default();
let envelope = parse_error_envelope(&body);
let code = envelope.code();
let message = format_upload_error_message(status, &body, code, &envelope);
classify_upload_error(status, code, message)
}
fn format_upload_error_message(
status: u16,
body: &str,
code: Option<&str>,
envelope: &ParsedErrorEnvelope,
) -> String {
if let Some(code) = code
&& let Some(hint) = actionable_error_hint("upload-static-findings", code)
{
return format!("{hint} (HTTP {status}, code {code})");
}
let body_suffix = response_message_suffix(body, envelope);
format!("upload-static-findings request failed with HTTP {status}{body_suffix}")
}
fn classify_upload_error(
status: u16,
_code: Option<&str>,
message: String,
) -> Result<(), UploadError> {
match status {
413 => Err(UploadError::PayloadTooLarge(message)),
400 => Err(UploadError::Validation(message)),
401 | 403 => Err(UploadError::AuthRejected(message)),
_ => Err(UploadError::ServerError(message)),
}
}
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
}
fn print_dry_run_summary(
project_id: &str,
git_sha: &str,
findings: &[StaticFinding],
endpoint_override: Option<&str>,
) {
let decoded_url = display_endpoint_url(endpoint_override, project_id);
let dead_files = findings.iter().filter(|f| f.kind == KIND_DEAD_FILE).count();
let unused_exports = findings
.iter()
.filter(|f| f.kind == KIND_UNUSED_EXPORT)
.count();
println!("{LOG_PREFIX} {}", "(dry run)".bright_black());
println!(" project-id: {project_id}");
println!(" git-sha: {git_sha}");
println!(" findings: {}", format_count(findings.len()));
println!(" dead_file: {}", format_count(dead_files));
println!(" unused_export:{}", format_count(unused_exports));
println!(" endpoint: {decoded_url}");
println!();
let shown = findings.len().min(5);
let total = findings.len();
println!("first {shown} of {} findings:", format_count(total));
for finding in findings.iter().take(shown) {
match (&finding.export_name, finding.line_number) {
(Some(name), Some(line)) => {
println!(" {} {}:{} {name}", finding.kind, finding.file_path, line);
}
_ => {
println!(" {} {}", finding.kind, finding.file_path);
}
}
}
if total > shown {
println!(
" ... and {} more",
format_count(total.saturating_sub(shown)),
);
}
}
fn display_endpoint_url(override_endpoint: Option<&str>, project_id: &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}/static-findings")
}
trait AnalysisLike {
fn unused_files(&self) -> Vec<&Path>;
fn unused_exports(&self) -> Vec<(&Path, String, u32)>;
}
impl AnalysisLike for fallow_core::results::AnalysisResults {
fn unused_files(&self) -> Vec<&Path> {
self.unused_files
.iter()
.map(|finding| finding.file.path.as_path())
.collect()
}
fn unused_exports(&self) -> Vec<(&Path, String, u32)> {
self.unused_exports
.iter()
.map(|finding| {
(
finding.export.path.as_path(),
finding.export.export_name.clone(),
finding.export.line,
)
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use tempfile::TempDir;
struct StubResults {
files: Vec<PathBuf>,
exports: Vec<(PathBuf, String, u32)>,
}
impl AnalysisLike for StubResults {
fn unused_files(&self) -> Vec<&Path> {
self.files.iter().map(PathBuf::as_path).collect()
}
fn unused_exports(&self) -> Vec<(&Path, String, u32)> {
self.exports
.iter()
.map(|(path, name, line)| (path.as_path(), name.clone(), *line))
.collect()
}
}
fn stub_config(root: &Path) -> ResolvedConfig {
FallowConfig::default().resolve(
root.to_path_buf(),
fallow_config::OutputFormat::Human,
1,
true,
true,
None,
)
}
#[test]
fn upload_static_findings_args_debug_masks_api_key() {
let args = UploadStaticFindingsArgs {
api_key: Some("fallow_live_secret_token_value".to_owned()),
api_endpoint: Some("https://api.fallow.cloud".to_owned()),
project_id: Some("acme/web".to_owned()),
..UploadStaticFindingsArgs::default()
};
let formatted = format!("{args:?}");
assert!(
!formatted.contains("fallow_live_secret_token_value"),
"api_key leaked through Debug: {formatted}"
);
assert!(
formatted.contains("api_key: Some(\"***\")"),
"expected explicit redaction marker, got: {formatted}"
);
let bare = UploadStaticFindingsArgs::default();
let formatted_bare = format!("{bare:?}");
assert!(
formatted_bare.contains("api_key: None"),
"expected None for unset api_key, got: {formatted_bare}"
);
}
#[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_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 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 url_encode_path_segment_encodes_slash() {
assert_eq!(
url_encode_path_segment("fallow-rs/fallow"),
"fallow-rs%2Ffallow"
);
assert_eq!(url_encode_path_segment("a b"), "a%20b");
}
#[test]
fn endpoint_url_builds_static_findings_path() {
let url = endpoint_url(Some("http://127.0.0.1:3000"), "a/b");
assert_eq!(
url,
"http://127.0.0.1:3000/v1/coverage/a%2Fb/static-findings"
);
let trimmed = endpoint_url(Some("http://127.0.0.1:3000/"), "a/b");
assert_eq!(
trimmed,
"http://127.0.0.1:3000/v1/coverage/a%2Fb/static-findings"
);
}
#[test]
fn display_endpoint_url_uses_override_unencoded() {
let url = display_endpoint_url(Some("http://127.0.0.1:3000/"), "a/b");
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 collect_findings_maps_kinds_with_repo_relative_paths() {
let root = PathBuf::from("/repo");
let config = stub_config(&root);
let results = StubResults {
files: vec![root.join("src/legacy/old.ts")],
exports: vec![(
root.join("src/utils/format.ts"),
"formatBytes".to_owned(),
42,
)],
};
let findings = collect_findings(&config, &results);
assert_eq!(findings.len(), 2);
let dead = &findings[0];
assert_eq!(dead.kind, KIND_DEAD_FILE);
assert_eq!(dead.file_path, "src/legacy/old.ts");
assert_eq!(dead.export_name, None);
assert_eq!(dead.line_number, None);
let export = &findings[1];
assert_eq!(export.kind, KIND_UNUSED_EXPORT);
assert_eq!(export.file_path, "src/utils/format.ts");
assert_eq!(export.export_name.as_deref(), Some("formatBytes"));
assert_eq!(export.line_number, Some(42));
}
#[test]
fn collect_findings_empty_results_is_empty() {
let root = PathBuf::from("/repo");
let config = stub_config(&root);
let results = StubResults {
files: Vec::new(),
exports: Vec::new(),
};
assert!(collect_findings(&config, &results).is_empty());
}
#[test]
fn static_finding_serde_renames_and_skips_null_optionals() {
let dead = StaticFinding {
kind: KIND_DEAD_FILE,
file_path: "src/dead.ts".to_owned(),
export_name: None,
line_number: None,
};
let json = serde_json::to_string(&dead).expect("serialize dead file");
assert!(json.contains(r#""filePath":"src/dead.ts""#));
assert!(
!json.contains("exportName"),
"null exportName must be omitted: {json}"
);
assert!(
!json.contains("lineNumber"),
"null lineNumber must be omitted: {json}"
);
let export = StaticFinding {
kind: KIND_UNUSED_EXPORT,
file_path: "src/a.ts".to_owned(),
export_name: Some("foo".to_owned()),
line_number: Some(7),
};
let json = serde_json::to_string(&export).expect("serialize export");
assert!(json.contains(r#""exportName":"foo""#));
assert!(json.contains(r#""lineNumber":7"#));
}
#[test]
fn request_serde_renames_git_sha() {
let findings: Vec<StaticFinding> = Vec::new();
let req = StaticFindingsRequest {
git_sha: "abc123",
findings: &findings,
};
let json = serde_json::to_string(&req).expect("serialize request");
assert!(json.contains(r#""gitSha":"abc123""#));
assert!(json.contains(r#""findings":[]"#));
}
#[test]
fn classify_upload_error_maps_413_to_payload_too_large() {
let err = classify_upload_error(413, Some("payload_too_large"), "stub".to_owned())
.expect_err("413 must error");
assert!(matches!(err, UploadError::PayloadTooLarge(_)));
let err = classify_upload_error(413, None, "stub".to_owned())
.expect_err("413 must error without code");
assert!(matches!(err, UploadError::PayloadTooLarge(_)));
}
#[test]
fn classify_upload_error_maps_400_to_validation() {
let err = classify_upload_error(400, Some("bad_request"), "stub".to_owned())
.expect_err("400 must error");
assert!(matches!(err, UploadError::Validation(_)));
}
#[test]
fn classify_upload_error_maps_auth_codes_to_auth_rejected() {
for status in [401, 403] {
let err = classify_upload_error(status, Some("unauthorized"), "stub".to_owned())
.expect_err("auth status must error");
assert!(
matches!(err, UploadError::AuthRejected(_)),
"status={status}"
);
}
}
#[test]
fn classify_upload_error_maps_5xx_to_server_error() {
for status in [500, 502, 503, 504] {
let err =
classify_upload_error(status, None, "stub".to_owned()).expect_err("5xx must error");
assert!(
matches!(err, UploadError::ServerError(_)),
"status={status}"
);
}
}
#[test]
fn ignore_upload_errors_does_not_soft_fail_auth_rejection() {
let exit = UploadError::AuthRejected("bad key".to_owned()).into_exit(true);
assert_eq!(
format!("{exit:?}"),
format!("{:?}", ExitCode::from(EXIT_AUTH_REJECTED))
);
}
#[test]
fn ignore_upload_errors_does_not_soft_fail_payload_too_large() {
let exit = UploadError::PayloadTooLarge("too big".to_owned()).into_exit(true);
assert_eq!(
format!("{exit:?}"),
format!("{:?}", ExitCode::from(EXIT_PAYLOAD_TOO_LARGE))
);
}
#[test]
fn format_upload_error_message_uses_hint_for_known_code() {
let envelope = parse_error_envelope(r#"{"code":"payload_too_large"}"#);
let message = format_upload_error_message(413, "{}", Some("payload_too_large"), &envelope);
assert!(message.contains("200,000"), "got: {message}");
assert!(message.contains("HTTP 413"));
assert!(message.contains("code payload_too_large"));
}
#[test]
fn format_upload_error_message_falls_back_to_server_message() {
let body = r#"{"code":"internal","message":"database timeout"}"#;
let envelope = parse_error_envelope(body);
let message = format_upload_error_message(500, body, Some("internal"), &envelope);
assert!(message.starts_with("upload-static-findings request failed with HTTP 500"));
assert!(message.ends_with(": database timeout"));
}
#[test]
fn dirty_worktree_is_rejected_by_default() {
let repo = create_dirty_git_repo();
let err = enforce_clean_worktree(&UploadStaticFindingsArgs::default(), 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("--allow-dirty"));
}
#[test]
fn dirty_worktree_is_allowed_with_explicit_opt_in() {
let repo = create_dirty_git_repo();
let args = UploadStaticFindingsArgs {
allow_dirty: true,
..UploadStaticFindingsArgs::default()
};
assert!(enforce_clean_worktree(&args, 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 = Command::new("git")
.args(args)
.current_dir(root)
.status()
.expect("run git");
assert!(status.success(), "git {args:?} failed");
}
fn project_with_unused_export() -> TempDir {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(
root.join("package.json"),
r#"{"name":"sf","main":"src/index.ts"}"#,
)
.unwrap();
std::fs::write(root.join("src/index.ts"), "export const used = 1;\n").unwrap();
std::fs::write(root.join("src/orphan.ts"), "export const orphan = 2;\n").unwrap();
dir
}
fn dry_run_args() -> UploadStaticFindingsArgs {
UploadStaticFindingsArgs {
project_id: Some("acme/web".to_owned()),
git_sha: Some("abcdef1".to_owned()),
api_endpoint: Some("http://localhost:3000".to_owned()),
allow_dirty: true,
dry_run: true,
..UploadStaticFindingsArgs::default()
}
}
#[test]
fn run_dry_run_analyzes_and_exits_zero() {
let project = project_with_unused_export();
let code = run(&dry_run_args(), project.path());
assert_eq!(code, ExitCode::SUCCESS);
}
#[test]
fn into_exit_maps_variants_and_soft_fails_transient_when_opted_in() {
assert_eq!(
UploadError::Validation("v".to_owned()).into_exit(false),
ExitCode::from(EXIT_VALIDATION)
);
assert_eq!(
UploadError::PayloadTooLarge("p".to_owned()).into_exit(false),
ExitCode::from(EXIT_PAYLOAD_TOO_LARGE)
);
assert_eq!(
UploadError::AuthRejected("a".to_owned()).into_exit(false),
ExitCode::from(EXIT_AUTH_REJECTED)
);
assert_eq!(
UploadError::ServerError("s".to_owned()).into_exit(false),
ExitCode::from(EXIT_SERVER_ERROR)
);
assert_eq!(
UploadError::Network("n".to_owned()).into_exit(false),
ExitCode::from(NETWORK_EXIT_CODE)
);
assert_eq!(
UploadError::ServerError("s".to_owned()).into_exit(true),
ExitCode::SUCCESS
);
assert_eq!(
UploadError::Network("n".to_owned()).into_exit(true),
ExitCode::SUCCESS
);
}
#[test]
fn resolve_git_sha_validates_explicit_value() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
let with_sha = |sha: &str| UploadStaticFindingsArgs {
git_sha: Some(sha.to_owned()),
..UploadStaticFindingsArgs::default()
};
assert_eq!(
resolve_git_sha(&with_sha("abcdef1"), root).unwrap(),
"abcdef1"
);
assert!(resolve_git_sha(&with_sha(""), root).is_err());
assert!(resolve_git_sha(&with_sha(&"a".repeat(GIT_SHA_MAX_LEN + 1)), root).is_err());
assert!(resolve_git_sha(&with_sha("bad sha!"), root).is_err());
}
}