use camino::Utf8Path;
use dictator_core::Source;
use mcp_host::protocol::types::{JsonRpcError, JsonRpcResponse};
use serde::Deserialize;
use serde_json::Value;
use std::sync::{Arc, Mutex};
use crate::mcp::regime::init_regime_from_config;
use crate::mcp::state::{DEFAULT_STALINT_LIMIT, ServerState};
use crate::mcp::utils::{
base64_decode, base64_encode, byte_to_line_col, collect_files, current_dir_or_default,
log_to_file, make_snippet,
};
pub fn handle_stalint(
id: Value,
arguments: Option<Value>,
watcher_state: Arc<Mutex<ServerState>>,
) -> JsonRpcResponse {
#[derive(Deserialize, Default)]
struct Args {
#[serde(default)]
paths: Vec<String>,
limit: Option<usize>,
cursor: Option<String>,
}
let args: Args = arguments
.and_then(|a| serde_json::from_value(a).ok())
.unwrap_or_default();
let paths = if !args.paths.is_empty() {
{
let mut state = watcher_state.lock().unwrap();
state.stalint_paths.clone_from(&args.paths);
}
args.paths
} else if args.cursor.is_some() {
let state = watcher_state.lock().unwrap();
if state.stalint_paths.is_empty() {
return JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: None,
error: Some(JsonRpcError {
code: -32602,
message:
"Cursor provided but no paths stored. Provide paths to start a new query."
.to_string(),
data: None,
}),
};
}
state.stalint_paths.clone()
} else {
vec![".".to_string()]
};
let limit = args.limit.unwrap_or(DEFAULT_STALINT_LIMIT);
let offset: usize = args
.cursor
.and_then(|c| String::from_utf8(base64_decode(&c)).ok())
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let cwd = current_dir_or_default();
let resolved_paths: Vec<std::path::PathBuf> = paths
.iter()
.map(|p| {
let path = std::path::Path::new(p);
if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
}
})
.collect();
log_to_file(&format!(
"STALINT: cwd={}, paths={:?}",
cwd.display(),
resolved_paths
));
let regime = init_regime_from_config();
let mut all_violations: Vec<serde_json::Value> = Vec::new();
let single_file = resolved_paths.len() == 1 && resolved_paths[0].is_file();
let all_files: Vec<std::path::PathBuf> = resolved_paths
.iter()
.filter(|p| p.exists())
.flat_map(|p| collect_files(p))
.collect();
let progress_token = {
let state = watcher_state.lock().unwrap();
let total = u32::try_from(all_files.len()).unwrap_or(u32::MAX);
state.progress_tracker.start("stalint", total)
};
for (file_idx, file) in all_files.iter().enumerate() {
{
let state = watcher_state.lock().unwrap();
let current = u32::try_from(file_idx + 1).unwrap_or(u32::MAX);
state.progress_tracker.progress(&progress_token, current);
}
let Ok(text) = std::fs::read_to_string(file) else {
continue;
};
let relative = file.strip_prefix(&cwd).unwrap_or(file);
let path_str = relative.to_str().unwrap_or("<invalid>");
let source = Source {
path: Utf8Path::new(path_str),
text: &text,
};
if let Ok(diags) = regime.enforce(&[source]) {
for diag in &diags {
let (line, col) = byte_to_line_col(&text, diag.span.start);
let snippet = make_snippet(&text, &diag.span, 160);
if single_file {
all_violations.push(serde_json::json!({
"line": line,
"col": col,
"rule": diag.rule,
"message": diag.message,
"enforced": diag.enforced,
"snippet": snippet,
}));
} else {
all_violations.push(serde_json::json!({
"file": path_str,
"line": line,
"col": col,
"rule": diag.rule,
"message": diag.message,
"enforced": diag.enforced,
"snippet": snippet,
}));
}
}
}
}
{
let state = watcher_state.lock().unwrap();
state.progress_tracker.finish(&progress_token);
}
let total = all_violations.len();
let page: Vec<_> = all_violations
.into_iter()
.skip(offset)
.take(limit)
.collect();
let next_offset = offset + page.len();
let next_cursor = if next_offset < total {
Some(base64_encode(next_offset.to_string().as_bytes()))
} else {
None
};
let mut result = serde_json::json!({
"content": [],
"structuredContent": {
"total": total,
"returned": page.len(),
"violations": page
}
});
if let Some(cursor) = next_cursor {
result["structuredContent"]["nextCursor"] = serde_json::json!(cursor);
}
JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: Some(result),
error: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mcp::utils::base64_encode;
#[test]
fn test_handle_stalint_missing_arguments_defaults_to_cwd() {
let state = Arc::new(Mutex::new(ServerState::default()));
let id = serde_json::json!(1);
let response = handle_stalint(id, None, state);
assert!(response.error.is_none());
let result = response.result.unwrap();
assert!(result["structuredContent"]["total"].is_number());
}
#[test]
fn test_handle_stalint_nonexistent_path() {
let state = Arc::new(Mutex::new(ServerState::default()));
let id = serde_json::json!(1);
let args = Some(serde_json::json!({
"paths": ["/nonexistent/path/that/does/not/exist"]
}));
let response = handle_stalint(id, args, state);
assert!(response.error.is_none());
let result = response.result.unwrap();
assert_eq!(result["structuredContent"]["total"], 0);
assert_eq!(result["structuredContent"]["returned"], 0);
}
#[test]
fn test_handle_stalint_with_limit() {
let state = Arc::new(Mutex::new(ServerState::default()));
let id = serde_json::json!(1);
let args = Some(serde_json::json!({
"paths": ["/nonexistent"],
"limit": 5
}));
let response = handle_stalint(id, args, state);
assert!(response.error.is_none());
}
#[test]
fn test_handle_stalint_pagination_with_stored_paths() {
let state = Arc::new(Mutex::new(ServerState::default()));
let id = serde_json::json!(1);
let args = Some(serde_json::json!({
"paths": ["/nonexistent"]
}));
let response = handle_stalint(id, args, Arc::clone(&state));
assert!(response.error.is_none());
let id = serde_json::json!(2);
let cursor = base64_encode(b"0");
let args = Some(serde_json::json!({
"cursor": cursor
}));
let response = handle_stalint(id, args, state);
assert!(response.error.is_none());
}
#[test]
fn test_handle_stalint_cursor_without_stored_paths() {
let state = Arc::new(Mutex::new(ServerState::default()));
let id = serde_json::json!(1);
let cursor = base64_encode(b"10");
let args = Some(serde_json::json!({
"cursor": cursor
}));
let response = handle_stalint(id, args, state);
assert!(response.error.is_some());
let error = response.error.unwrap();
assert_eq!(error.code, -32602);
assert!(error.message.contains("no paths stored"));
}
}