use std::collections::HashSet;
use std::fmt::Write as _;
use std::net::{SocketAddr, ToSocketAddrs};
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use anyhow::{Context, Result, bail};
use axum::body::Bytes;
use axum::extract::DefaultBodyLimit;
use axum::extract::State;
use axum::http::header::CONTENT_TYPE;
use axum::http::{HeaderMap, Method, StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use crate::cli::{Options, store_warning};
use crate::detector::{DetectionResult, Fragment, Statistics};
use crate::detector::{PreparedSourceDraft, detect_prepared_drafts, prepare_source_drafts};
use crate::files::{self, SourceFile};
mod mcp;
#[derive(Clone)]
pub struct ServerService {
state: Arc<RwLock<ServiceState>>,
}
#[derive(Clone)]
struct ServiceState {
working_directory: PathBuf,
options: Options,
project_drafts: Arc<[PreparedSourceDraft]>,
statistics: Option<Statistics>,
last_scan_time: Option<String>,
is_scanning: bool,
snippet_counter: u64,
mcp_sessions: HashSet<String>,
}
impl ServerService {
pub fn new(working_directory: PathBuf, options: Options) -> Self {
Self {
state: Arc::new(RwLock::new(ServiceState {
working_directory,
options,
project_drafts: Arc::from(Vec::<PreparedSourceDraft>::new()),
statistics: None,
last_scan_time: None,
is_scanning: false,
snippet_counter: 0,
mcp_sessions: HashSet::new(),
})),
}
}
pub fn initialize(&self) -> Result<()> {
self.recheck()
}
pub fn recheck(&self) -> Result<()> {
let options = {
let mut state = self.state.write().expect("server state lock poisoned");
if state.is_scanning {
bail!(SCAN_IN_PROGRESS);
}
state.is_scanning = true;
service_detection_options(&state)
};
let result = scan_project(&options);
let mut state = self.state.write().expect("server state lock poisoned");
state.is_scanning = false;
let (project_drafts, detection_result) = result?;
state.project_drafts = project_drafts;
state.statistics = Some(detection_result.statistics);
state.last_scan_time = Some(now_rfc3339());
Ok(())
}
pub fn check_snippet(&self, request: CheckSnippetRequest) -> Result<CheckSnippetResponse> {
if request.code.trim().is_empty() {
bail!(FIELD_CODE_EMPTY);
}
let (options, project_drafts, snippet_id, working_directory) = {
let mut state = self.state.write().expect("server state lock poisoned");
if state.is_scanning {
bail!(SCAN_IN_PROGRESS);
}
if state.statistics.is_none() {
bail!(NOT_INITIALIZED);
}
let snippet_id = format!("<snippet>/snippet_{:08x}", state.snippet_counter);
state.snippet_counter += 1;
(
service_detection_options(&state),
Arc::clone(&state.project_drafts),
snippet_id,
state.working_directory.clone(),
)
};
let total_lines = request.code.split('\n').count();
let snippet_drafts = prepare_source_drafts(
vec![SourceFile {
source_id: snippet_id.clone(),
format: request.format,
content: request.code,
}],
&options,
);
let mut prepared_drafts = Vec::with_capacity(project_drafts.len() + snippet_drafts.len());
prepared_drafts.extend(project_drafts.iter().cloned());
prepared_drafts.extend(snippet_drafts);
let result = detect_prepared_drafts(prepared_drafts, &options);
let duplications = result
.clones
.iter()
.filter_map(|clone| {
let snippet_is_a = clone.duplication_a.source_id == snippet_id;
let snippet_is_b = clone.duplication_b.source_id == snippet_id;
if snippet_is_a == snippet_is_b {
return None;
}
let (snippet, codebase) = if snippet_is_a {
(&clone.duplication_a, &clone.duplication_b)
} else {
(&clone.duplication_b, &clone.duplication_a)
};
Some(SnippetDuplication {
snippet_location: SnippetLocation::from_fragment(snippet),
codebase_location: DuplicationLocation::from_fragment(
codebase,
&working_directory,
&result,
),
lines_count: fragment_line_count(snippet),
})
})
.collect::<Vec<_>>();
let statistics = duplication_statistics(&duplications, total_lines);
Ok(CheckSnippetResponse {
duplications,
statistics,
})
}
pub fn statistics(&self) -> StatsResponse {
let state = self.state.read().expect("server state lock poisoned");
StatsResponse {
statistics: state.statistics.clone(),
timestamp: state.last_scan_time.clone().unwrap_or_else(now_rfc3339),
}
}
pub fn health(&self) -> HealthResponse {
let state = self.state.read().expect("server state lock poisoned");
HealthResponse {
status: if state.is_scanning {
"initializing"
} else {
"ready"
},
working_directory: state.working_directory.display().to_string(),
last_scan_time: state.last_scan_time.clone(),
}
}
pub(crate) fn create_mcp_session(&self) -> String {
let mut state = self.state.write().expect("server state lock poisoned");
let session_id = new_mcp_session_id();
state.mcp_sessions.insert(session_id.clone());
session_id
}
pub(crate) fn has_mcp_session(&self, session_id: &str) -> bool {
let state = self.state.read().expect("server state lock poisoned");
state.mcp_sessions.contains(session_id)
}
}
fn detection_options(options: &Options) -> Options {
let mut options = options.clone();
options.reporters = vec!["json".to_string()];
options.silent = true;
options.no_tips = true;
options
}
fn service_detection_options(state: &ServiceState) -> Options {
let mut options = detection_options(&state.options);
options.paths = vec![state.working_directory.clone()];
options
}
fn scan_project(options: &Options) -> Result<(Arc<[PreparedSourceDraft]>, DetectionResult)> {
let files = files::discover(options)?;
let project_drafts = prepare_source_drafts(files, options);
let result = detect_prepared_drafts(project_drafts.clone(), options);
Ok((Arc::from(project_drafts), result))
}
pub fn create_router(service: ServerService) -> Router {
Router::new()
.route("/", get(api_info))
.route("/api/check", post(check_snippet).fallback(not_found))
.route("/api/recheck", post(recheck).fallback(not_found))
.route("/api/stats", get(stats).fallback(not_found))
.route("/api/health", get(health).fallback(not_found))
.route(
"/mcp",
post(mcp::post_mcp)
.get(mcp::method_not_allowed)
.fallback(not_found),
)
.fallback(not_found)
.layer(DefaultBodyLimit::max(10 * 1024 * 1024))
.with_state(service)
}
pub async fn serve(options: Options, host: &str, port: u16) -> Result<()> {
let working_directory = server_working_directory(&options);
serve_with_working_directory(options, working_directory, host, port).await
}
pub async fn serve_with_working_directory(
options: Options,
working_directory: PathBuf,
host: &str,
port: u16,
) -> Result<()> {
if let Some(warning) = store_warning(&options) {
eprintln!("{warning}");
}
let service = ServerService::new(working_directory, options);
service.initialize()?;
let app = create_router(service);
let address = server_bind_address(host, port)?;
let listener = tokio::net::TcpListener::bind(address)
.await
.with_context(|| format!("failed to bind server address {address}"))?;
println!("JSCPD server running on {}", server_display_url(host, port));
axum::serve(listener, app).await.context("server failed")
}
fn server_bind_address(host: &str, port: u16) -> Result<SocketAddr> {
let bind_host = if host == "true" { "0.0.0.0" } else { host };
(bind_host, port)
.to_socket_addrs()
.with_context(|| format!("failed to resolve server address {host}:{port}"))?
.next()
.with_context(|| format!("failed to resolve server address {host}:{port}"))
}
fn server_display_url(host: &str, port: u16) -> String {
format!("http://{host}:{port}")
}
pub fn server_working_directory(options: &Options) -> PathBuf {
options
.paths
.first()
.cloned()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
}
async fn api_info() -> Json<ApiInfoResponse> {
Json(ApiInfoResponse {
name: "jscpd-server",
version: env!("CARGO_PKG_VERSION"),
endpoints: [
("POST /api/check", "Check code snippet for duplications"),
("GET /api/stats", "Get overall project statistics"),
("GET /api/health", "Server health check"),
("POST /api/recheck", "Trigger recheck of the directory"),
("POST /mcp", "MCP Protocol endpoint"),
]
.into_iter()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect(),
documentation: "https://github.com/kucherenko/jscpd",
})
}
async fn check_snippet(
State(service): State<ServerService>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let request = match parse_check_payload(&headers, &body) {
Ok(request) => request,
Err(CheckPayloadError::Validation(message)) => {
return error_response("ValidationError", message, 400);
}
Err(CheckPayloadError::Syntax(message)) => {
return error_response("SyntaxError", message, 400);
}
};
match service.check_snippet(request) {
Ok(response) => Json(response).into_response(),
Err(error) => error_response("Error", error.to_string(), 400),
}
}
async fn recheck(State(service): State<ServerService>) -> Response {
match service.recheck() {
Ok(()) => Json(RecheckResponse {
message: "Recheck started",
})
.into_response(),
Err(error) => error_response("Error", error.to_string(), 400),
}
}
async fn stats(State(service): State<ServerService>) -> Response {
let response = service.statistics();
if response.statistics.is_none() {
return error_response(
"NotReady",
"Statistics not available yet. Server is still initializing.",
503,
);
}
Json(response).into_response()
}
async fn health(State(service): State<ServerService>) -> Json<HealthResponse> {
Json(service.health())
}
async fn not_found(method: Method, uri: Uri) -> Response {
error_response(
"NotFound",
format!("Route {method} {} not found", uri.path()),
404,
)
}
fn error_response(error: &str, message: impl Into<String>, status_code: u16) -> Response {
let status = StatusCode::from_u16(status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
(
status,
Json(ErrorResponse {
error: error.to_string(),
message: message.into(),
status_code,
}),
)
.into_response()
}
fn parse_check_payload(
headers: &HeaderMap,
body: &[u8],
) -> std::result::Result<CheckSnippetRequest, CheckPayloadError> {
let content_type = headers
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_ascii_lowercase();
if content_type.starts_with("application/x-www-form-urlencoded") {
return parse_check_form(body).map_err(CheckPayloadError::Validation);
}
let payload = serde_json::from_slice(body)
.map_err(|error| CheckPayloadError::Syntax(json_syntax_error_message(body, &error)))?;
parse_check_request(payload).map_err(CheckPayloadError::Validation)
}
fn parse_check_form(body: &[u8]) -> std::result::Result<CheckSnippetRequest, String> {
let fields = form_urlencoded::parse(body)
.into_owned()
.collect::<Vec<_>>();
let code = required_form_field(&fields, "code")?;
if code.trim().is_empty() {
return Err(FIELD_CODE_EMPTY.to_string());
}
let format = required_form_field(&fields, "format")?;
if format.trim().is_empty() {
return Err(FIELD_FORMAT_EMPTY.to_string());
}
Ok(CheckSnippetRequest { code, format })
}
fn parse_check_request(payload: Value) -> std::result::Result<CheckSnippetRequest, String> {
let Some(object) = payload.as_object() else {
return Err("Request body must be an object".to_string());
};
let code = required_string_field(object, "code")?;
if code.trim().is_empty() {
return Err(FIELD_CODE_EMPTY.to_string());
}
let format = required_string_field(object, "format")?;
if format.trim().is_empty() {
return Err(FIELD_FORMAT_EMPTY.to_string());
}
Ok(CheckSnippetRequest { code, format })
}
fn required_string_field(
object: &serde_json::Map<String, Value>,
field: &str,
) -> std::result::Result<String, String> {
let Some(value) = object.get(field) else {
return Err(format!("Missing required field: {field}"));
};
let Some(value) = value.as_str() else {
return Err(format!("Field \"{field}\" must be a string"));
};
Ok(value.to_string())
}
fn required_form_field(
fields: &[(String, String)],
field: &str,
) -> std::result::Result<String, String> {
fields
.iter()
.find_map(|(name, value)| (name == field).then(|| value.clone()))
.ok_or_else(|| format!("Missing required field: {field}"))
}
fn json_syntax_error_message(body: &[u8], error: &serde_json::Error) -> String {
let body = String::from_utf8_lossy(body);
let trimmed = body.trim_start();
if let Some(first) = trimmed.chars().next()
&& !matches!(first, '{' | '[' | '"' | '-' | '0'..='9' | 't' | 'f' | 'n')
{
let preview = if trimmed.chars().count() > 20 {
format!("{}...", trimmed.chars().take(17).collect::<String>())
} else {
trimmed.to_string()
};
return format!("Unexpected token '{first}', \"{preview}\" is not valid JSON");
}
error.to_string()
}
fn duplication_statistics(
duplications: &[SnippetDuplication],
total_lines: usize,
) -> DuplicationStatistics {
let mut duplicated = Vec::<usize>::new();
for duplication in duplications {
duplicated.extend(
duplication.snippet_location.start_line..=duplication.snippet_location.end_line,
);
}
duplicated.sort_unstable();
duplicated.dedup();
let duplicated_lines = duplicated.len();
DuplicationStatistics {
total_duplications: duplications.len(),
duplicated_lines,
total_lines,
percentage_duplicated: percentage(total_lines, duplicated_lines),
}
}
fn percentage(total: usize, duplicated: usize) -> f64 {
if total == 0 {
0.0
} else {
((duplicated as f64 * 10000.0) / total as f64).round() / 100.0
}
}
fn relative_source_id(path: &str, working_directory: &Path) -> String {
let path_ref = Path::new(path);
path_ref
.strip_prefix(working_directory)
.ok()
.and_then(|relative| relative.to_str())
.map(str::to_string)
.unwrap_or_else(|| path.to_string())
}
fn slice_fragment(result: &DetectionResult, fragment: &Fragment) -> Option<String> {
result
.source_contents
.get(&fragment.source_id)
.and_then(|content| content.get(fragment.range[0]..fragment.range[1]))
.map(str::to_string)
}
fn fragment_line_count(fragment: &Fragment) -> usize {
fragment.end.line.saturating_sub(fragment.start.line) + 1
}
fn now_rfc3339() -> String {
OffsetDateTime::now_utc()
.format(&Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
}
fn new_mcp_session_id() -> String {
let mut bytes = [0u8; 16];
getrandom::fill(&mut bytes).expect("OS random unavailable for MCP session id");
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
let mut session_id = String::with_capacity(36);
for (index, byte) in bytes.iter().enumerate() {
if matches!(index, 4 | 6 | 8 | 10) {
session_id.push('-');
}
write!(&mut session_id, "{byte:02x}").expect("write to string");
}
session_id
}
const SCAN_IN_PROGRESS: &str = "Please wait for initial scan to complete";
const NOT_INITIALIZED: &str = "Server not initialized. Please wait for initial scan to complete.";
const FIELD_CODE_EMPTY: &str = "Field \"code\" cannot be empty";
const FIELD_FORMAT_EMPTY: &str = "Field \"format\" cannot be empty";
enum CheckPayloadError {
Validation(String),
Syntax(String),
}
#[derive(Clone, Debug, Deserialize)]
pub struct CheckSnippetRequest {
pub code: String,
pub format: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CheckSnippetResponse {
pub duplications: Vec<SnippetDuplication>,
pub statistics: DuplicationStatistics,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SnippetDuplication {
pub snippet_location: SnippetLocation,
pub codebase_location: DuplicationLocation,
pub lines_count: usize,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SnippetLocation {
pub start_line: usize,
pub end_line: usize,
pub start_column: usize,
pub end_column: usize,
}
impl SnippetLocation {
fn from_fragment(fragment: &Fragment) -> Self {
Self {
start_line: fragment.start.line,
end_line: fragment.end.line,
start_column: fragment.start.column,
end_column: fragment.end.column,
}
}
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DuplicationLocation {
pub file: String,
pub start_line: usize,
pub end_line: usize,
pub start_column: usize,
pub end_column: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub fragment: Option<String>,
}
impl DuplicationLocation {
fn from_fragment(
fragment: &Fragment,
working_directory: &Path,
result: &DetectionResult,
) -> Self {
Self {
file: relative_source_id(&fragment.source_id, working_directory),
start_line: fragment.start.line,
end_line: fragment.end.line,
start_column: fragment.start.column,
end_column: fragment.end.column,
fragment: slice_fragment(result, fragment),
}
}
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DuplicationStatistics {
pub total_duplications: usize,
pub duplicated_lines: usize,
pub total_lines: usize,
pub percentage_duplicated: f64,
}
#[derive(Clone, Debug, Serialize)]
pub struct StatsResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub statistics: Option<Statistics>,
pub timestamp: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HealthResponse {
pub status: &'static str,
pub working_directory: String,
pub last_scan_time: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct ApiInfoResponse {
pub name: &'static str,
pub version: &'static str,
pub endpoints: std::collections::BTreeMap<String, String>,
pub documentation: &'static str,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ErrorResponse {
pub error: String,
pub message: String,
pub status_code: u16,
}
#[derive(Clone, Debug, Serialize)]
pub struct RecheckResponse {
pub message: &'static str,
}
#[cfg(test)]
mod tests {
use std::fs;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use axum::body::{Body, to_bytes};
use axum::http::header::CONTENT_TYPE;
use axum::http::{Request, StatusCode};
use serde_json::Value;
use tower::ServiceExt;
use crate::cli::Options;
use super::*;
static TEMP_PROJECT_COUNTER: AtomicU64 = AtomicU64::new(0);
fn fixture_project() -> PathBuf {
let mut path = std::env::temp_dir();
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
let counter = TEMP_PROJECT_COUNTER.fetch_add(1, Ordering::Relaxed);
path.push(format!(
"jscpd-rs-server-{}-{stamp}-{counter}",
std::process::id()
));
fs::create_dir_all(&path).expect("create temp project");
let content = "const alpha = 1;\nconst beta = 2;\nconst gamma = alpha + beta;\n";
fs::write(path.join("a.js"), content).expect("write a.js");
fs::write(path.join("b.js"), content).expect("write b.js");
path
}
fn service_for(path: &Path) -> ServerService {
let options = Options {
paths: vec![path.to_path_buf()],
min_tokens: 5,
min_lines: 2,
max_size_bytes: 1024 * 1024,
..Options::default()
};
ServerService::new(path.to_path_buf(), options)
}
#[test]
fn server_initialization_populates_stats_and_health() {
let path = fixture_project();
let service = service_for(&path);
service.initialize().expect("initialize");
let stats = service.statistics();
assert!(stats.statistics.is_some());
assert!(stats.timestamp.contains('T'));
let health = service.health();
assert_eq!(health.status, "ready");
assert_eq!(health.working_directory, path.display().to_string());
assert!(health.last_scan_time.is_some());
fs::remove_dir_all(path).ok();
}
#[test]
fn server_host_binding_preserves_upstream_display_host() {
let true_addr = server_bind_address("true", 3000).expect("true host bind");
assert_eq!(true_addr.ip().to_string(), "0.0.0.0");
assert_eq!(true_addr.port(), 3000);
assert_eq!(server_display_url("true", 3000), "http://true:3000");
assert_eq!(
server_display_url("localhost", 3001),
"http://localhost:3001"
);
}
#[test]
fn server_check_snippet_reports_codebase_duplications() {
let path = fixture_project();
let service = service_for(&path);
service.initialize().expect("initialize");
let response = service
.check_snippet(CheckSnippetRequest {
code: "const alpha = 1;\nconst beta = 2;\nconst gamma = alpha + beta;\n"
.to_string(),
format: "javascript".to_string(),
})
.expect("check snippet");
assert!(!response.duplications.is_empty());
assert_eq!(
response.statistics.total_duplications,
response.duplications.len()
);
assert!(response.statistics.duplicated_lines > 0);
assert!(
response
.duplications
.iter()
.all(|duplication| !duplication.codebase_location.file.starts_with("<snippet>"))
);
fs::remove_dir_all(path).ok();
}
#[test]
fn server_check_snippet_rejects_empty_code() {
let path = fixture_project();
let service = service_for(&path);
service.initialize().expect("initialize");
let error = service
.check_snippet(CheckSnippetRequest {
code: " ".to_string(),
format: "javascript".to_string(),
})
.expect_err("empty code should fail");
assert_eq!(error.to_string(), FIELD_CODE_EMPTY);
fs::remove_dir_all(path).ok();
}
#[test]
fn server_recheck_refreshes_statistics() {
let path = fixture_project();
let service = service_for(&path);
service.initialize().expect("initialize");
let before = service
.statistics()
.statistics
.expect("stats before")
.total
.sources;
fs::write(path.join("c.js"), "const unique = 1;\n").expect("write c.js");
service.recheck().expect("recheck");
let after = service
.statistics()
.statistics
.expect("stats after")
.total
.sources;
assert!(after > before);
fs::remove_dir_all(path).ok();
}
#[test]
fn server_scan_uses_working_directory_over_config_paths_like_upstream() {
let working = fixture_project();
let configured = fixture_project();
fs::write(configured.join("c.js"), "const configured = 1;\n").expect("write c.js");
let options = Options {
paths: vec![configured.clone()],
min_tokens: 5,
min_lines: 2,
max_size_bytes: 1024 * 1024,
..Options::default()
};
let service = ServerService::new(working.clone(), options);
service.initialize().expect("initialize");
let stats = service.statistics().statistics.expect("statistics");
assert_eq!(stats.total.sources, 2);
fs::remove_dir_all(working).ok();
fs::remove_dir_all(configured).ok();
}
#[tokio::test]
async fn server_check_snippet_accepts_form_urlencoded_body() {
let path = fixture_project();
let service = service_for(&path);
service.initialize().expect("initialize");
let app = create_router(service);
let body = form_urlencoded::Serializer::new(String::new())
.append_pair(
"code",
"const alpha = 1;\nconst beta = 2;\nconst gamma = alpha + beta;\n",
)
.append_pair("format", "javascript")
.finish();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/check")
.header(CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(body))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let body: Value = serde_json::from_slice(&body).expect("json body");
assert!(body["duplications"].is_array());
assert_eq!(
body["statistics"]["totalDuplications"].as_u64(),
body["duplications"]
.as_array()
.map(|items| items.len() as u64)
);
fs::remove_dir_all(path).ok();
}
#[tokio::test]
async fn server_check_snippet_invalid_json_matches_upstream_error() {
let path = fixture_project();
let service = service_for(&path);
service.initialize().expect("initialize");
let app = create_router(service);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/check")
.header(CONTENT_TYPE, "application/json")
.body(Body::from("invalid-json"))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let body: Value = serde_json::from_slice(&body).expect("json body");
assert_eq!(body["error"], "SyntaxError");
assert_eq!(
body["message"],
"Unexpected token 'i', \"invalid-json\" is not valid JSON"
);
assert_eq!(body["statusCode"], 400);
fs::remove_dir_all(path).ok();
}
#[tokio::test]
async fn server_check_snippet_rejects_non_string_format_like_upstream() {
let path = fixture_project();
let service = service_for(&path);
service.initialize().expect("initialize");
let app = create_router(service);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/check")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"code":"console.log(1);","format":123}"#))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let body: Value = serde_json::from_slice(&body).expect("json body");
assert_eq!(body["error"], "ValidationError");
assert_eq!(body["message"], "Field \"format\" must be a string");
assert_eq!(body["statusCode"], 400);
fs::remove_dir_all(path).ok();
}
#[tokio::test]
async fn server_uninitialized_api_matches_upstream_error_shapes() {
let path = fixture_project();
let service = service_for(&path);
let app = create_router(service);
let check_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/check")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"code":"console.log(\"test\");","format":"javascript"}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(check_response.status(), StatusCode::BAD_REQUEST);
let body = to_bytes(check_response.into_body(), usize::MAX)
.await
.expect("body");
let body: Value = serde_json::from_slice(&body).expect("json body");
assert_eq!(body["error"], "Error");
assert_eq!(body["message"], NOT_INITIALIZED);
assert_eq!(body["statusCode"], 400);
let stats_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/stats")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(stats_response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = to_bytes(stats_response.into_body(), usize::MAX)
.await
.expect("body");
let body: Value = serde_json::from_slice(&body).expect("json body");
assert_eq!(body["error"], "NotReady");
assert_eq!(
body["message"],
"Statistics not available yet. Server is still initializing."
);
assert_eq!(body["statusCode"], 503);
let health_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/health")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(health_response.status(), StatusCode::OK);
let body = to_bytes(health_response.into_body(), usize::MAX)
.await
.expect("body");
let body: Value = serde_json::from_slice(&body).expect("json body");
assert!(matches!(
body["status"].as_str(),
Some("ready" | "initializing")
));
assert_eq!(body["workingDirectory"], path.display().to_string());
assert_eq!(body["lastScanTime"], Value::Null);
fs::remove_dir_all(path).ok();
}
#[tokio::test]
async fn server_unknown_routes_return_upstream_style_json_error() {
let path = fixture_project();
let service = service_for(&path);
let app = create_router(service);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/unknown?ignored=true")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let body: Value = serde_json::from_slice(&body).expect("json body");
assert_eq!(body["error"], "NotFound");
assert_eq!(body["message"], "Route GET /api/unknown not found");
assert_eq!(body["statusCode"], 404);
fs::remove_dir_all(path).ok();
}
#[tokio::test]
async fn server_wrong_api_methods_return_upstream_style_not_found() {
let path = fixture_project();
let service = service_for(&path);
let app = create_router(service);
for (method, uri) in [
("GET", "/api/check"),
("GET", "/api/recheck"),
("POST", "/api/stats"),
("POST", "/api/health"),
("PUT", "/api/check"),
("DELETE", "/api/stats"),
] {
let response = app
.clone()
.oneshot(
Request::builder()
.method(method)
.uri(uri)
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let body: Value = serde_json::from_slice(&body).expect("json body");
assert_eq!(body["error"], "NotFound");
assert_eq!(body["message"], format!("Route {method} {uri} not found"));
assert_eq!(body["statusCode"], 404);
}
fs::remove_dir_all(path).ok();
}
#[tokio::test]
async fn server_unsupported_mcp_methods_return_upstream_style_not_found() {
let path = fixture_project();
let service = service_for(&path);
let app = create_router(service);
for method in ["DELETE", "OPTIONS"] {
let response = app
.clone()
.oneshot(
Request::builder()
.method(method)
.uri("/mcp")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let body: Value = serde_json::from_slice(&body).expect("json body");
assert_eq!(body["error"], "NotFound");
assert_eq!(body["message"], format!("Route {method} /mcp not found"));
assert_eq!(body["statusCode"], 404);
}
fs::remove_dir_all(path).ok();
}
}