use anyhow::Result;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use axum::routing::{get, post};
use axum::Router;
use serde::Deserialize;
use serde_json::json;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use crate::category::Category;
use crate::entry::{Entry, SortOrder};
use crate::scanner::ScanCounts;
const STALE_DAYS: u32 = 90;
const HTML_TEMPLATE: &str = include_str!("ui/index.html");
struct ScanState {
started_at: Instant,
counts: Arc<ScanCounts>,
scan_id: u64,
inner: Inner,
}
enum Inner {
Scanning,
Ready { tree: Entry, scanned_in_ms: u64 },
Error(String),
}
impl ScanState {
fn scanning(scan_id: u64) -> Self {
Self {
started_at: Instant::now(),
counts: Arc::new(ScanCounts::default()),
scan_id,
inner: Inner::Scanning,
}
}
}
struct AppState {
scan_root: PathBuf,
sort: SortOrder,
reverse: bool,
next_scan_id: AtomicU64,
scan_in_flight: AtomicBool,
state: Mutex<ScanState>,
}
pub async fn serve(scan_root: PathBuf, port: u16, sort: SortOrder, reverse: bool) -> Result<()> {
let app_state = Arc::new(AppState::new(scan_root, sort, reverse));
start_scan(Arc::clone(&app_state));
let app = build_router(app_state);
let (listener, bound_port) = bind_with_fallback(port).await?;
let url = format!("http://127.0.0.1:{bound_port}");
if bound_port != port {
println!("Port {port} is busy, falling back to {bound_port}.");
}
println!("Starting UI server at {url}");
println!("Press Ctrl+C to stop.");
let _ = open::that(&url);
axum::serve(listener, app).await?;
Ok(())
}
fn build_router(state: Arc<AppState>) -> axum::Router {
Router::new()
.route("/", get(index))
.route("/data.json", get(data_json))
.route("/rescan", post(rescan))
.route("/reveal", post(reveal))
.with_state(state)
}
impl AppState {
fn new(scan_root: PathBuf, sort: SortOrder, reverse: bool) -> Self {
Self {
scan_root,
sort,
reverse,
next_scan_id: AtomicU64::new(1),
scan_in_flight: AtomicBool::new(false),
state: Mutex::new(ScanState::scanning(0)),
}
}
}
fn start_scan(state: Arc<AppState>) {
if state
.scan_in_flight
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_err()
{
return;
}
let scan_id = state.next_scan_id.fetch_add(1, Ordering::Relaxed);
let fresh = ScanState::scanning(scan_id);
let counts = Arc::clone(&fresh.counts);
{
let mut s = state.state.lock().unwrap();
*s = fresh;
}
tokio::task::spawn_blocking(move || {
let started = Instant::now();
let scan_result = crate::scanner::scan_with_progress(&state.scan_root, &counts);
{
let mut s = state.state.lock().unwrap();
if s.scan_id == scan_id {
s.inner = match scan_result {
Ok(mut tree) => {
tree.sort(&state.sort, state.reverse);
let scanned_in_ms = started.elapsed().as_millis() as u64;
Inner::Ready {
tree,
scanned_in_ms,
}
}
Err(e) => Inner::Error(e.to_string()),
};
}
}
state.scan_in_flight.store(false, Ordering::Release);
});
}
async fn bind_with_fallback(port: u16) -> Result<(tokio::net::TcpListener, u16)> {
match tokio::net::TcpListener::bind(("127.0.0.1", port)).await {
Ok(listener) => {
let actual = listener.local_addr()?.port();
Ok((listener, actual))
}
Err(e) if port != 0 && e.kind() == std::io::ErrorKind::AddrInUse => {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await?;
let actual = listener.local_addr()?.port();
Ok((listener, actual))
}
Err(e) => Err(e.into()),
}
}
async fn index() -> Html<String> {
let html = HTML_TEMPLATE.replacen("__DUVIS_DATA__", "null", 1);
Html(html)
}
async fn data_json(State(s): State<Arc<AppState>>) -> Response {
let state = s.state.lock().unwrap();
let scan_root = s.scan_root.display().to_string();
let body = match &state.inner {
Inner::Scanning => json!({
"status": "scanning",
"elapsed_ms": state.started_at.elapsed().as_millis() as u64,
"items_scanned": state.counts.scanned(),
"items_skipped": state.counts.skipped(),
"scan_root": scan_root,
}),
Inner::Ready {
tree,
scanned_in_ms,
} => json!({
"status": "ready",
"scanned_in_ms": scanned_in_ms,
"items_scanned": state.counts.scanned(),
"items_skipped": state.counts.skipped(),
"scan_root": scan_root,
"tree": tree,
"meta": meta_block(),
}),
Inner::Error(msg) => json!({
"status": "error",
"message": msg,
"scan_root": scan_root,
}),
};
axum::Json(body).into_response()
}
fn meta_block() -> serde_json::Value {
let deletable: Vec<&'static str> = [
Category::Cache,
Category::Build,
Category::Log,
Category::Media,
Category::Vcs,
Category::Ide,
Category::Other,
]
.into_iter()
.filter(|c| c.is_deletable())
.map(|c| c.label())
.collect();
json!({
"deletable_categories": deletable,
"stale_days": STALE_DAYS,
})
}
async fn rescan(State(s): State<Arc<AppState>>) -> StatusCode {
start_scan(s);
StatusCode::ACCEPTED
}
#[derive(Deserialize)]
struct RevealReq {
segments: Vec<String>,
}
async fn reveal(
State(state): State<Arc<AppState>>,
axum::Json(req): axum::Json<RevealReq>,
) -> Response {
let mut path = state.scan_root.clone();
for seg in &req.segments {
if seg.is_empty() || seg == "." || seg == ".." || seg.contains('/') || seg.contains('\\') {
return (StatusCode::BAD_REQUEST, "invalid segment").into_response();
}
path.push(seg);
}
let canonical = match path.canonicalize() {
Ok(p) => p,
Err(_) => return (StatusCode::NOT_FOUND, "path does not exist").into_response(),
};
if !canonical.starts_with(&state.scan_root) {
return (StatusCode::FORBIDDEN, "outside scan root").into_response();
}
if let Err(e) = reveal_in_filer(&canonical) {
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
}
StatusCode::OK.into_response()
}
#[cfg(target_os = "macos")]
fn reveal_in_filer(path: &Path) -> std::io::Result<()> {
let status = std::process::Command::new("open")
.arg("-R")
.arg(path)
.status()?;
if !status.success() {
return Err(std::io::Error::other(format!(
"`open -R` exited with {status}"
)));
}
Ok(())
}
#[cfg(target_os = "windows")]
fn reveal_in_filer(path: &Path) -> std::io::Result<()> {
std::process::Command::new("explorer")
.arg(format!("/select,{}", path.display()))
.status()?;
Ok(())
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn reveal_in_filer(path: &Path) -> std::io::Result<()> {
let target = if path.is_file() {
path.parent().unwrap_or(path)
} else {
path
};
open::that(target).map_err(std::io::Error::other)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use serde_json::Value;
use tower::ServiceExt;
use crate::category::Category;
fn fake_entry(name: &str) -> Entry {
Entry {
name: name.to_string(),
size: 4096,
is_dir: true,
category: Category::Other,
modified_days_ago: Some(1),
children: Some(vec![Entry {
name: "file.txt".to_string(),
size: 4096,
is_dir: false,
category: Category::Other,
modified_days_ago: Some(1),
children: None,
}]),
}
}
fn ready_state(scan_root: PathBuf) -> Arc<AppState> {
let state = Arc::new(AppState::new(scan_root, SortOrder::Size, false));
{
let mut s = state.state.lock().unwrap();
s.inner = Inner::Ready {
tree: fake_entry("root"),
scanned_in_ms: 42,
};
}
state
}
async fn body_to_json(response: axum::response::Response) -> Value {
let bytes = response.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).expect("response body is valid json")
}
async fn body_to_string(response: axum::response::Response) -> String {
let bytes = response.into_body().collect().await.unwrap().to_bytes();
String::from_utf8(bytes.to_vec()).unwrap()
}
#[tokio::test]
async fn data_json_returns_scanning_initially() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(AppState::new(
dir.path().to_path_buf(),
SortOrder::Size,
false,
));
let response = build_router(state)
.oneshot(
Request::builder()
.uri("/data.json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let json = body_to_json(response).await;
assert_eq!(json["status"], "scanning");
assert!(json.get("items_scanned").is_some());
assert!(json.get("items_skipped").is_some());
assert!(json.get("scan_root").is_some());
}
#[tokio::test]
async fn data_json_returns_ready_with_meta() {
let dir = tempfile::tempdir().unwrap();
let state = ready_state(dir.path().to_path_buf());
let response = build_router(state)
.oneshot(
Request::builder()
.uri("/data.json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let json = body_to_json(response).await;
assert_eq!(json["status"], "ready");
assert_eq!(json["scanned_in_ms"], 42);
assert_eq!(json["tree"]["name"], "root");
let meta = &json["meta"];
assert!(meta["deletable_categories"].is_array());
assert!(meta["stale_days"].is_number());
}
#[tokio::test]
async fn rescan_returns_accepted() {
let dir = tempfile::tempdir().unwrap();
let state = ready_state(dir.path().to_path_buf());
let response = build_router(state)
.oneshot(
Request::builder()
.method("POST")
.uri("/rescan")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::ACCEPTED);
}
#[tokio::test]
async fn reveal_rejects_path_traversal_segments() {
let dir = tempfile::tempdir().unwrap();
let state = ready_state(dir.path().to_path_buf());
let router = build_router(state);
for bad in [
r#"{"segments":[".."]}"#,
r#"{"segments":["."]}"#,
r#"{"segments":[""]}"#,
r#"{"segments":["a/b"]}"#,
r#"{"segments":["a\\b"]}"#,
] {
let response = router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/reveal")
.header("content-type", "application/json")
.body(Body::from(bad))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"expected 400 for payload {bad}",
);
let body = body_to_string(response).await;
assert!(body.contains("invalid segment"), "body was {body}");
}
}
#[tokio::test]
async fn reveal_rejects_nonexistent_segment() {
let dir = tempfile::tempdir().unwrap();
let state = ready_state(dir.path().to_path_buf());
let response = build_router(state)
.oneshot(
Request::builder()
.method("POST")
.uri("/reveal")
.header("content-type", "application/json")
.body(Body::from(r#"{"segments":["does-not-exist"]}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn index_serves_html_shell() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(AppState::new(
dir.path().to_path_buf(),
SortOrder::Size,
false,
));
let response = build_router(state)
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_string(response).await;
assert!(
body.contains("<html") || body.contains("<!DOCTYPE"),
"expected HTML shell, got: {}",
&body[..body.len().min(200)],
);
}
}