use axum::{
Router,
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use sloc_web::{TEST_SERVER_MODE_API_KEY, make_test_router, make_test_router_server_mode};
use tower::ServiceExt;
use chrono::Utc;
use sloc_config::AppConfig;
use sloc_core::{
AnalysisRun, EffectiveCounts, EnvironmentMetadata, FileRecord, FileStatus, LanguageSummary,
SubmoduleSummary, SummaryTotals, ToolMetadata,
};
use sloc_languages::{Language, ParseMode, RawLineCounts};
async fn get(app: Router, uri: &str) -> (StatusCode, String) {
let resp = app
.oneshot(Request::get(uri).body(Body::empty()).unwrap())
.await
.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
(status, String::from_utf8_lossy(&bytes).into_owned())
}
async fn get_auth(app: Router, uri: &str, bearer: &str) -> (StatusCode, String) {
let resp = app
.oneshot(
Request::get(uri)
.header("authorization", format!("Bearer {bearer}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
(status, String::from_utf8_lossy(&bytes).into_owned())
}
async fn post_json(app: Router, uri: &str, json: &str) -> StatusCode {
let req = Request::post(uri)
.header("content-type", "application/json")
.body(Body::from(json.to_owned()))
.unwrap();
app.oneshot(req).await.unwrap().status()
}
async fn delete(app: Router, uri: &str) -> (StatusCode, String) {
let resp = app
.oneshot(
Request::builder()
.method("DELETE")
.uri(uri)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
(status, String::from_utf8_lossy(&bytes).into_owned())
}
fn pct_encode(s: &str) -> String {
s.bytes()
.flat_map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
vec![b as char]
}
_ => format!("%{b:02X}").chars().collect(),
})
.collect()
}
async fn wait_for_run_id(app: Router, wait_id: &str) -> String {
for _ in 0..200 {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let (_, body) = get(app.clone(), &format!("/api/runs/{wait_id}/status")).await;
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&body) {
match v["state"].as_str().unwrap_or("") {
"complete" => return v["run_id"].as_str().unwrap_or("").to_owned(),
"failed" | "cancelled" => return String::new(),
_ => {}
}
}
}
String::new()
}
fn base_run(id: &str) -> AnalysisRun {
AnalysisRun {
tool: ToolMetadata {
name: "oxide-sloc".into(),
version: "1.6.12".into(),
run_id: id.into(),
timestamp_utc: Utc::now(),
},
environment: EnvironmentMetadata {
operating_system: "linux".into(),
architecture: "x86_64".into(),
runtime_mode: "test".into(),
initiator_username: "tester".into(),
initiator_hostname: "ci".into(),
ci_name: None,
},
effective_configuration: AppConfig::default(),
input_roots: vec!["/test/gapproj".into()],
summary_totals: SummaryTotals {
files_considered: 1,
files_analyzed: 1,
code_lines: 50,
..SummaryTotals::default()
},
totals_by_language: vec![],
per_file_records: vec![],
skipped_file_records: vec![],
warnings: vec![],
submodule_summaries: vec![],
git_commit_short: None,
git_commit_long: None,
git_branch: None,
git_commit_author: None,
git_tags: None,
git_nearest_tag: None,
git_commit_date: None,
git_remote_url: None,
style_summary: None,
cocomo: None,
uloc: 0,
dryness_pct: None,
duplicate_groups: vec![],
duplicates_excluded: 0,
}
}
fn file_record(path: &str, lang: Language, code: u64) -> FileRecord {
FileRecord {
path: format!("/test/gapproj/{path}"),
relative_path: path.into(),
language: Some(lang),
size_bytes: code * 25,
detected_encoding: Some("utf-8".into()),
raw_line_categories: RawLineCounts {
total_physical_lines: code + 2,
code_only_lines: code,
blank_only_lines: 1,
single_comment_only_lines: 1,
..RawLineCounts::default()
},
effective_counts: EffectiveCounts {
code_lines: code,
comment_lines: 1,
blank_lines: 1,
mixed_lines_separate: 0,
},
status: FileStatus::AnalyzedExact,
warnings: vec![],
generated: false,
minified: false,
vendor: false,
parse_mode: Some(ParseMode::Lexical),
submodule: None,
coverage: None,
style_analysis: None,
cyclomatic_complexity: None,
lsloc: None,
commit_count: None,
last_commit_date: None,
content_hash: 0,
}
}
const fn lang_summary(lang: Language, files: u64, code: u64) -> LanguageSummary {
LanguageSummary {
language: lang,
files,
total_physical_lines: code + 2,
code_lines: code,
comment_lines: 1,
blank_lines: 1,
mixed_lines_separate: 0,
functions: 2,
classes: 0,
variables: 0,
variables_member: 0,
variables_local: 0,
variables_global: 0,
macro_definitions: 0,
imports: 1,
test_count: 0,
test_assertion_count: 0,
test_suite_count: 0,
coverage_lines_found: 0,
coverage_lines_hit: 0,
coverage_functions_found: 0,
coverage_functions_hit: 0,
coverage_branches_found: 0,
coverage_branches_hit: 0,
cyclomatic_complexity: 0,
lsloc: None,
}
}
fn run_basic(id: &str, root: &str, code: u64) -> AnalysisRun {
let mut run = base_run(id);
run.per_file_records = vec![file_record("src/lib.rs", Language::Rust, code)];
run.totals_by_language = vec![lang_summary(Language::Rust, 1, code)];
run.summary_totals = SummaryTotals {
files_considered: 1,
files_analyzed: 1,
code_lines: code,
total_physical_lines: code + 2,
..SummaryTotals::default()
};
run.input_roots = vec![root.into()];
run
}
fn run_with_submodules(id: &str) -> AnalysisRun {
let mut run = base_run(id);
run.per_file_records = vec![file_record("src/lib.rs", Language::Rust, 150)];
run.totals_by_language = vec![lang_summary(Language::Rust, 1, 150)];
run.summary_totals = SummaryTotals {
files_considered: 3,
files_analyzed: 3,
code_lines: 300,
total_physical_lines: 306,
..SummaryTotals::default()
};
run.submodule_summaries = vec![SubmoduleSummary {
name: "vendor/lib-a".into(),
relative_path: "vendor/lib-a".into(),
files_analyzed: 1,
total_physical_lines: 102,
code_lines: 100,
comment_lines: 1,
blank_lines: 1,
language_summaries: vec![lang_summary(Language::Rust, 1, 100)],
git_commit_short: Some("deadbeef".into()),
git_commit_long: None,
git_branch: Some("main".into()),
git_commit_author: None,
git_commit_date: None,
git_remote_url: Some("https://github.com/test-org/lib-a.git".into()),
}];
run.git_remote_url = Some("https://github.com/test-org/parent-repo.git".into());
run.input_roots = vec!["/test/parent-gap".into()];
run
}
fn json_of(run: &AnalysisRun) -> String {
serde_json::to_string(run).unwrap()
}
async fn ingest(app: &Router, run: &AnalysisRun) {
let st = post_json(app.clone(), "/api/ingest", &json_of(run)).await;
assert!(st.as_u16() < 500, "ingest must not 5xx, got {st}");
}
#[tokio::test]
async fn scan_setup_lists_recent_ingested_scans() {
let app = make_test_router();
for i in 0..3 {
ingest(
&app,
&run_basic(&format!("setup-{i}"), "/test/setupproj", 60 + i),
)
.await;
}
let (st, body) = get(app.clone(), "/scan-setup").await;
assert_eq!(st, StatusCode::OK, "scan-setup must render");
assert!(
body.contains("<html") || body.contains("<!doctype"),
"scan-setup must be HTML"
);
assert!(
body.contains("setupproj") || body.contains("project_label"),
"recent-scans data should be embedded in the page"
);
}
fn make_multi_repo_dir() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
for name in ["repo-alpha", "repo-beta"] {
let child = root.join(name);
std::fs::create_dir_all(child.join(".git")).unwrap();
std::fs::write(child.join(".git").join("HEAD"), "ref: refs/heads/main\n").unwrap();
std::fs::write(child.join("main.rs"), "fn main() {}\n").unwrap();
}
dir
}
fn make_root_repo_with_nested() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join(".git")).unwrap();
std::fs::write(root.join(".git").join("HEAD"), "ref: refs/heads/main\n").unwrap();
std::fs::write(root.join("top.rs"), "pub fn t() {}\n").unwrap();
let child = root.join("nested");
std::fs::create_dir_all(child.join(".git")).unwrap();
std::fs::write(child.join(".git").join("HEAD"), "ref: refs/heads/main\n").unwrap();
std::fs::write(child.join("inner.rs"), "fn i() {}\n").unwrap();
dir
}
#[tokio::test]
async fn preview_multiple_independent_repos_shows_warning() {
let dir = make_multi_repo_dir();
let app = make_test_router();
let uri = format!("/preview?path={}", pct_encode(dir.path().to_str().unwrap()));
let (st, body) = get(app.clone(), &uri).await;
assert_eq!(st, StatusCode::OK);
assert!(
body.contains("Multiple repositories detected"),
"expected the multiple-independent-repos warning, got: {}",
&body[..body.len().min(400)]
);
assert!(body.contains("repo-pick"), "repo pick buttons expected");
}
#[tokio::test]
async fn preview_root_repo_with_nested_shows_nested_warning() {
let dir = make_root_repo_with_nested();
let app = make_test_router();
let uri = format!("/preview?path={}", pct_encode(dir.path().to_str().unwrap()));
let (st, body) = get(app.clone(), &uri).await;
assert_eq!(st, StatusCode::OK);
assert!(
body.contains("Nested repositories detected"),
"expected the nested-repos (root-is-repo) warning, got: {}",
&body[..body.len().min(400)]
);
}
#[tokio::test]
async fn pdf_artifact_for_ingested_run_queues_generation() {
let app = make_test_router();
ingest(&app, &run_basic("pdf-gap-1", "/test/pdfgap", 90)).await;
let (st, body) = get(app.clone(), "/runs/pdf/pdf-gap-1").await;
assert!(st.as_u16() < 500, "/runs/pdf must not 5xx, got {st}");
assert!(!body.is_empty());
}
#[tokio::test]
async fn submodule_pdf_missing_submodule_returns_404_render() {
let app = make_test_router();
ingest(&app, &run_basic("subpdf-none", "/test/subgap", 70)).await;
let (st, body) = get(app.clone(), "/runs/sub_doesnotexist_pdf/subpdf-none").await;
assert_eq!(st, StatusCode::NOT_FOUND);
assert!(
body.contains("Sub-report PDF") || body.contains("could not be generated"),
"expected sub-report PDF 404 render, got: {}",
&body[..body.len().min(300)]
);
}
#[tokio::test]
async fn submodule_pdf_rebuilds_from_parent_json() {
let app = make_test_router();
ingest(&app, &run_with_submodules("subpdf-rebuild")).await;
for label in ["vendor-lib-a", "vendor_lib_a", "lib-a", "lib_a"] {
let (st, _) = get(
app.clone(),
&format!("/runs/sub_{label}_pdf/subpdf-rebuild"),
)
.await;
assert!(
st == StatusCode::OK || st == StatusCode::NOT_FOUND,
"sub pdf rebuild must be 200 or 404, got {st} for {label}"
);
}
}
#[tokio::test]
async fn server_mode_preview_rejects_path_without_scan_roots() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("x.rs"), "fn x() {}\n").unwrap();
let app = make_test_router_server_mode();
let uri = format!("/preview?path={}", pct_encode(dir.path().to_str().unwrap()));
let (st, body) = get_auth(app.clone(), &uri, TEST_SERVER_MODE_API_KEY).await;
assert_eq!(st, StatusCode::OK);
assert!(
body.contains("Preview rejected"),
"server-mode preview without scan roots must be rejected, got: {}",
&body[..body.len().min(300)]
);
}
#[tokio::test]
async fn server_mode_preview_rejects_unresolvable_path() {
let app = make_test_router_server_mode();
let uri = "/preview?path=%2Fnope%2Fdefinitely%2Fmissing%2Fdir12345";
let (st, body) = get_auth(app.clone(), uri, TEST_SERVER_MODE_API_KEY).await;
assert_eq!(st, StatusCode::OK);
assert!(
body.contains("Preview rejected"),
"unresolvable server-mode preview path must be rejected, got: {}",
&body[..body.len().min(300)]
);
}
#[tokio::test]
async fn delete_run_removes_ingested_run() {
let app = make_test_router();
ingest(&app, &run_basic("del-gap-1", "/test/delgap", 55)).await;
let (st, _) = delete(app.clone(), "/api/runs/del-gap-1").await;
assert!(st.as_u16() < 500, "DELETE run must not 5xx, got {st}");
let (st2, _) = delete(app.clone(), "/api/runs/del-gap-1").await;
assert!(st2.as_u16() < 500, "second DELETE must not 5xx, got {st2}");
}
#[tokio::test]
async fn wiki_markup_renders_for_ingested_run() {
let app = make_test_router();
ingest(&app, &run_basic("wiki-gap-1", "/test/wikigap", 130)).await;
let (st, body) = get(app.clone(), "/api/confluence/wiki-markup?run_id=wiki-gap-1").await;
assert_eq!(st, StatusCode::OK, "wiki-markup must render for a real run");
assert!(!body.is_empty(), "wiki markup body should be non-empty");
assert!(
body.contains('h') || body.contains('|') || body.contains('{'),
"expected wiki-markup formatting tokens, got: {}",
&body[..body.len().min(200)]
);
}
fn make_sample_project() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("lib.rs"),
"// c\nfn add(a: i32, b: i32) -> i32 { a + b }\n#[test]\nfn t() { assert_eq!(add(1,2),3); }\n",
)
.unwrap();
std::fs::write(root.join("app.py"), "def g(n):\n return n\n").unwrap();
dir
}
#[tokio::test]
async fn real_analyze_then_pdf_and_submodule_pdf_not_5xx() {
let project = make_sample_project();
let app = make_test_router();
let form = format!(
"path={}&report_title=PDF+Gap+Test",
pct_encode(project.path().to_str().unwrap())
);
let resp = app
.clone()
.oneshot(
Request::post("/analyze")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(form))
.unwrap(),
)
.await
.unwrap();
let wait_id = resp
.headers()
.get("x-wait-id")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_owned();
if wait_id.is_empty() {
return;
}
let run_id = wait_for_run_id(app.clone(), &wait_id).await;
if run_id.is_empty() {
return;
}
let (st, _) = get(app.clone(), &format!("/runs/pdf/{run_id}")).await;
assert!(st.as_u16() < 500, "/runs/pdf for real run must not 5xx");
let (st, _) = get(app.clone(), &format!("/runs/pdf/{run_id}?download=1")).await;
assert!(st.as_u16() < 500, "/runs/pdf?download must not 5xx");
}