gruff-rs 0.1.1

Rust static analyzer and quality linter for CI: dead-code, complexity, security, secrets, and architecture rules with deterministic SARIF/JSON output and baseline support.
use super::*;

pub(crate) fn run_dashboard(args: DashboardArgs) -> ExitCode {
    let address = format!("{}:{}", args.host, args.port);
    let listener = match TcpListener::bind(&address) {
        Ok(listener) => listener,
        Err(error) => {
            eprintln!("gruff-rs: unable to bind {address}: {error}");
            return ExitCode::from(2);
        }
    };
    println!("gruff-rs dashboard listening at http://{address}");

    for stream in listener.incoming() {
        match stream {
            Ok(stream) => handle_dashboard_request(stream, &args.project_root),
            Err(error) => eprintln!("gruff-rs: dashboard connection error: {error}"),
        }
    }

    ExitCode::SUCCESS
}

fn handle_dashboard_request(mut stream: TcpStream, default_root: &Path) {
    let mut buffer = [0u8; 4096];
    let bytes_read = match stream.read(&mut buffer) {
        Ok(bytes_read) => bytes_read,
        Err(_) => return,
    };
    let request = String::from_utf8_lossy(&buffer[..bytes_read]);
    let request_line = request.lines().next().unwrap_or_default();
    let target = request_line.split_whitespace().nth(1).unwrap_or("/");
    let (path, query) = target.split_once('?').unwrap_or((target, ""));
    let response = dashboard_response(path, query, default_root);
    respond(
        &mut stream,
        response.status,
        response.content_type,
        &response.body,
    );
}

pub(crate) struct DashboardResponse {
    pub(crate) status: &'static str,
    pub(crate) content_type: &'static str,
    pub(crate) body: String,
}

pub(crate) fn dashboard_response(
    path: &str,
    query: &str,
    default_root: &Path,
) -> DashboardResponse {
    match path {
        "/health" => health_response(),
        "/scan" => scan_response(query, default_root),
        "/" => DashboardResponse {
            status: "200 OK",
            content_type: "text/html; charset=utf-8",
            body: dashboard_index(default_root),
        },
        _ => not_found_response(),
    }
}

fn health_response() -> DashboardResponse {
    DashboardResponse {
        status: "200 OK",
        content_type: "text/plain; charset=utf-8",
        body: "ok".to_string(),
    }
}

fn not_found_response() -> DashboardResponse {
    DashboardResponse {
        status: "404 Not Found",
        content_type: "text/plain; charset=utf-8",
        body: "not found".to_string(),
    }
}

fn scan_response(query: &str, default_root: &Path) -> DashboardResponse {
    let params = parse_query(query);
    let (root, scan_path) = match dashboard_scan_target(&params, default_root) {
        Ok(target) => target,
        Err(message) => {
            return DashboardResponse {
                status: "400 Bad Request",
                content_type: "text/plain; charset=utf-8",
                body: message,
            };
        }
    };
    let options = dashboard_scan_options(scan_path);
    let scope = RequestedScope::from_options(&options);
    let body = run_analysis_in_project(&root, &options)
        .map(|report| dashboard_shell(&report, &scope, &root))
        .unwrap_or_else(|error| format!("<pre>{}</pre>", html_escape(&error)));
    DashboardResponse {
        status: "200 OK",
        content_type: "text/html; charset=utf-8",
        body,
    }
}

fn dashboard_scan_target(
    params: &HashMap<String, String>,
    default_root: &Path,
) -> Result<(PathBuf, PathBuf), String> {
    let default_root = default_root
        .canonicalize()
        .map_err(|_| "invalid projectRoot".to_string())?;
    let root = resolve_dashboard_root(params, &default_root)?;
    let scan_path = resolve_dashboard_scan_path(params, &root)?;
    Ok((root, scan_path))
}

fn resolve_dashboard_root(
    params: &HashMap<String, String>,
    default_root: &Path,
) -> Result<PathBuf, String> {
    let requested = params
        .get("projectRoot")
        .map(PathBuf::from)
        .unwrap_or_else(|| default_root.to_path_buf());
    let candidate = if requested.is_absolute() {
        requested
    } else {
        default_root.join(requested)
    };
    let root = candidate
        .canonicalize()
        .map_err(|_| "invalid projectRoot".to_string())?;
    if !root.starts_with(default_root) || !root.is_dir() {
        return Err("projectRoot must stay inside the dashboard root".to_string());
    }
    Ok(root)
}

fn resolve_dashboard_scan_path(
    params: &HashMap<String, String>,
    root: &Path,
) -> Result<PathBuf, String> {
    let scan_path = params
        .get("path")
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("."));
    if scan_path.is_absolute() {
        return Err("path must be relative to projectRoot".to_string());
    }
    if scan_path
        .components()
        .any(|component| matches!(component, std::path::Component::ParentDir))
    {
        return Err("path must not contain '..'".to_string());
    }
    let canonical_scan = root
        .join(&scan_path)
        .canonicalize()
        .map_err(|_| "path does not exist within projectRoot".to_string())?;
    if !canonical_scan.starts_with(root) {
        return Err("path must stay inside projectRoot".to_string());
    }
    Ok(scan_path)
}

fn dashboard_scan_options(scan_path: PathBuf) -> AnalysisOptions {
    AnalysisOptions {
        paths: vec![scan_path],
        config: None,
        no_config: false,
        format: OutputFormat::Html,
        fail_on: FailThreshold::None,
        include_ignored: false,
        diff: None,
        history_file: None,
        baseline: None,
        generate_baseline: None,
        no_baseline: false,
    }
}

fn dashboard_index(root: &Path) -> String {
    format!(
        r#"<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>gruff-rs dashboard</title>
  <style>
    body {{ font-family: system-ui, sans-serif; margin: 0; background: #f7f8fa; color: #172026; }}
    header {{ background: #172026; color: white; padding: 20px 24px; }}
    main {{ max-width: 960px; margin: 0 auto; padding: 24px; }}
    form {{ display: grid; gap: 12px; background: white; border: 1px solid #d9e0e7; border-radius: 8px; padding: 16px; }}
    input, button {{ font: inherit; padding: 10px; }}
    button {{ background: #146c5f; color: white; border: 0; border-radius: 6px; cursor: pointer; }}
  </style>
</head>
<body>
  <header><h1>gruff-rs dashboard</h1></header>
  <main>
    <form action="/scan" method="get">
      <label>Project root <input name="projectRoot" value="{root}"></label>
      <label>Path <input name="path" value="."></label>
      <button type="submit">Run scan</button>
    </form>
  </main>
</body>
</html>"#,
        root = html_escape(&root.display().to_string())
    )
}

fn dashboard_shell(report: &AnalysisReport, scope: &RequestedScope, root: &Path) -> String {
    let report_html = html_report::render(report, scope);
    let banner = format!(
        r#"<div class="dashboard-banner" role="region" aria-label="Dashboard scan"><strong>Dashboard scan</strong> · Project: <code>{}</code> · <a href="/">Change target</a></div>"#,
        html_escape(&root.display().to_string())
    );
    if let Some(position) = report_html.find("<body>") {
        let insert_at = position + "<body>".len();
        let mut output = String::with_capacity(report_html.len() + banner.len());
        output.push_str(&report_html[..insert_at]);
        output.push_str(&banner);
        output.push_str(&report_html[insert_at..]);
        output
    } else {
        format!("{banner}{report_html}")
    }
}

fn respond(stream: &mut TcpStream, status: &str, content_type: &str, body: &str) {
    let response = format!(
        "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{body}",
        body.len()
    );
    let _ = stream.write_all(response.as_bytes());
}

fn parse_query(query: &str) -> HashMap<String, String> {
    query
        .split('&')
        .filter_map(|pair| {
            let (key, value) = pair.split_once('=')?;
            Some((percent_decode(key), percent_decode(value)))
        })
        .collect()
}

fn percent_decode(value: &str) -> String {
    let bytes = value.as_bytes();
    let mut output = Vec::with_capacity(bytes.len());
    let mut index = 0;
    while index < bytes.len() {
        if bytes[index] == b'%' && index + 2 < bytes.len() {
            if let Some(hex) = percent_hex(bytes[index + 1], bytes[index + 2]) {
                output.push(hex);
                index += 3;
                continue;
            }
        }
        output.push(if bytes[index] == b'+' {
            b' '
        } else {
            bytes[index]
        });
        index += 1;
    }
    String::from_utf8_lossy(&output).to_string()
}

fn percent_hex(high: u8, low: u8) -> Option<u8> {
    Some(hex_nibble(high)? * 16 + hex_nibble(low)?)
}

fn hex_nibble(byte: u8) -> Option<u8> {
    match byte {
        b'0'..=b'9' => Some(byte - b'0'),
        b'a'..=b'f' => Some(byte - b'a' + 10),
        b'A'..=b'F' => Some(byte - b'A' + 10),
        _ => None,
    }
}