slash-files-rs 0.1.0

Configurable Rust file browser with HTMX UI, JSON API, previews, batch operations, and multi-framework adapters.
Documentation
use std::path::{Path, PathBuf};

use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};

const PATH_SEGMENT_ENCODE_SET: &AsciiSet = &CONTROLS
    .add(b' ')
    .add(b'"')
    .add(b'#')
    .add(b'%')
    .add(b'?')
    .add(b'[')
    .add(b']')
    .add(b'{')
    .add(b'}');

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FileServerConfig {
    mount_path: String,
    mounts: Vec<FileMount>,
    branding: Branding,
    theme: Theme,
    features: FeatureFlags,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FileMount {
    pub id: String,
    pub name: String,
    pub root_dir: PathBuf,
}

impl FileMount {
    pub fn new(
        id: impl Into<String>,
        name: impl Into<String>,
        root_dir: impl Into<PathBuf>,
    ) -> Self {
        Self {
            id: id.into(),
            name: name.into(),
            root_dir: root_dir.into(),
        }
    }
}

impl FileServerConfig {
    pub fn new(root_dir: impl Into<PathBuf>) -> Self {
        Self {
            mount_path: "/files".to_string(),
            mounts: vec![FileMount::new(
                "default",
                "Files and folders",
                root_dir.into(),
            )],
            branding: Branding::default(),
            theme: Theme::default(),
            features: FeatureFlags::default(),
        }
    }

    pub fn mount_path(&self) -> &str {
        &self.mount_path
    }

    pub fn root_dir(&self) -> &Path {
        &self.mounts[0].root_dir
    }

    pub fn mounts(&self) -> &[FileMount] {
        &self.mounts
    }

    pub fn default_mount(&self) -> &FileMount {
        &self.mounts[0]
    }

    pub fn mount(&self, mount_id: &str) -> Option<&FileMount> {
        self.mounts.iter().find(|mount| mount.id == mount_id)
    }

    pub fn branding(&self) -> &Branding {
        &self.branding
    }

    pub fn theme(&self) -> &Theme {
        &self.theme
    }

    pub fn features(&self) -> &FeatureFlags {
        &self.features
    }

    pub fn route_paths(&self) -> RoutePaths {
        RoutePaths::new(&self.mount_path)
    }

    pub fn with_mount_path(mut self, mount_path: impl Into<String>) -> Self {
        self.mount_path = normalize_mount_path(&mount_path.into());
        self
    }

    pub fn with_branding(mut self, branding: Branding) -> Self {
        self.branding = branding;
        self
    }

    pub fn with_mounts(mut self, mounts: Vec<FileMount>) -> Self {
        assert!(
            !mounts.is_empty(),
            "FileServerConfig requires at least one mount"
        );
        self.mounts = mounts;
        self
    }

    pub fn with_mount(mut self, mount: FileMount) -> Self {
        self.mounts.push(mount);
        self
    }

    pub fn with_theme(mut self, theme: Theme) -> Self {
        self.theme = theme;
        self
    }

    pub fn with_features(mut self, features: FeatureFlags) -> Self {
        self.features = features;
        self
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Branding {
    pub title: String,
    pub tagline: Option<String>,
    pub logo_url: Option<String>,
    pub favicon_url: Option<String>,
}

impl Branding {
    pub fn with_title(mut self, title: impl Into<String>) -> Self {
        self.title = title.into();
        self
    }

    pub fn with_tagline(mut self, tagline: impl Into<String>) -> Self {
        self.tagline = Some(tagline.into());
        self
    }

    pub fn with_logo_url(mut self, logo_url: impl Into<String>) -> Self {
        self.logo_url = Some(logo_url.into());
        self
    }

    pub fn with_favicon_url(mut self, favicon_url: impl Into<String>) -> Self {
        self.favicon_url = Some(favicon_url.into());
        self
    }
}

impl Default for Branding {
    fn default() -> Self {
        Self {
            title: "Slash Files".to_string(),
            tagline: Some("A polished, mountable file browser for Rust backends.".to_string()),
            logo_url: None,
            favicon_url: None,
        }
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Theme {
    pub background: String,
    pub surface: String,
    pub surface_elevated: String,
    pub text: String,
    pub muted_text: String,
    pub accent: String,
    pub accent_text: String,
    pub danger: String,
    pub border: String,
    pub radius: String,
}

impl Default for Theme {
    fn default() -> Self {
        Self {
            background: "#0b1020".to_string(),
            surface: "#121a2d".to_string(),
            surface_elevated: "#1a2440".to_string(),
            text: "#eef2ff".to_string(),
            muted_text: "#94a3b8".to_string(),
            accent: "#7c3aed".to_string(),
            accent_text: "#f8fafc".to_string(),
            danger: "#ef4444".to_string(),
            border: "#23314f".to_string(),
            radius: "18px".to_string(),
        }
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FeatureFlags {
    pub enable_search: bool,
    pub enable_preview: bool,
    pub enable_delete: bool,
    pub enable_download: bool,
    pub enable_move: bool,
    pub enable_batch_actions: bool,
}

impl Default for FeatureFlags {
    fn default() -> Self {
        Self {
            enable_search: true,
            enable_preview: true,
            enable_delete: true,
            enable_download: true,
            enable_move: true,
            enable_batch_actions: true,
        }
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RoutePaths {
    pub mount_path: String,
    pub api_root: String,
    pub api_mounts: String,
    pub api_entries: String,
    pub api_search: String,
    pub api_storage: String,
    pub api_delete_selected: String,
    pub api_download_selected: String,
    pub api_move_selected: String,
    pub browse: String,
    pub search: String,
    pub preview: String,
    pub raw: String,
    pub delete_selected: String,
    pub download_selected: String,
    pub download_jobs: String,
    pub move_selected: String,
    pub move_jobs: String,
    pub static_htmx_js: String,
    pub static_styles_css: String,
}

impl RoutePaths {
    pub fn new(mount_path: &str) -> Self {
        let mount_path = normalize_mount_path(mount_path);

        Self {
            api_root: join_mount_path(&mount_path, "api"),
            api_mounts: join_mount_path(&mount_path, "api/mounts"),
            api_entries: join_mount_path(&mount_path, "api/entries"),
            api_search: join_mount_path(&mount_path, "api/search"),
            api_storage: join_mount_path(&mount_path, "api/storage"),
            api_delete_selected: join_mount_path(&mount_path, "api/delete-selected"),
            api_download_selected: join_mount_path(&mount_path, "api/download-selected"),
            api_move_selected: join_mount_path(&mount_path, "api/move-selected"),
            browse: join_mount_path(&mount_path, "browse"),
            search: join_mount_path(&mount_path, "search"),
            preview: join_mount_path(&mount_path, "preview"),
            raw: join_mount_path(&mount_path, "raw"),
            delete_selected: join_mount_path(&mount_path, "delete-selected"),
            download_selected: join_mount_path(&mount_path, "download-selected"),
            download_jobs: join_mount_path(&mount_path, "download-selected/jobs"),
            move_selected: join_mount_path(&mount_path, "move-selected"),
            move_jobs: join_mount_path(&mount_path, "move-selected/jobs"),
            static_htmx_js: join_mount_path(&mount_path, "static/htmx.min.js"),
            static_styles_css: join_mount_path(&mount_path, "static/styles.css"),
            mount_path,
        }
    }

    pub fn raw_file_url(&self, relative_path: &str) -> String {
        self.raw_file_url_for_mount("", relative_path)
    }

    pub fn raw_file_url_for_mount(&self, mount_id: &str, relative_path: &str) -> String {
        if relative_path.is_empty() {
            self.raw.clone()
        } else {
            let encoded_path = relative_path
                .split('/')
                .map(|segment| utf8_percent_encode(segment, PATH_SEGMENT_ENCODE_SET).to_string())
                .collect::<Vec<_>>()
                .join("/");

            let base = format!("{}/{}", self.raw, encoded_path);
            if mount_id.is_empty() {
                base
            } else {
                format!("{base}?mount={}", urlencoding::encode(mount_id))
            }
        }
    }

    pub fn download_job_status_url(&self, job_id: &str) -> String {
        format!("{}/{job_id}/status", self.download_jobs)
    }

    pub fn download_job_file_url(&self, job_id: &str) -> String {
        format!("{}/{job_id}/file", self.download_jobs)
    }

    pub fn move_job_status_url(&self, job_id: &str) -> String {
        format!("{}/{job_id}/status", self.move_jobs)
    }
}

fn normalize_mount_path(mount_path: &str) -> String {
    let trimmed = mount_path.trim();

    if trimmed.is_empty() || trimmed == "/" {
        return "/".to_string();
    }

    let stripped = trimmed.trim_matches('/');
    format!("/{stripped}")
}

fn join_mount_path(mount_path: &str, suffix: &str) -> String {
    if mount_path == "/" {
        format!("/{}", suffix.trim_start_matches('/'))
    } else {
        format!("{mount_path}/{}", suffix.trim_start_matches('/'))
    }
}

#[cfg(test)]
mod tests {
    use super::{FileMount, FileServerConfig, RoutePaths};
    use std::path::Path;

    #[test]
    fn normalizes_mount_path_variants() {
        let config = FileServerConfig::new(".").with_mount_path("files/");

        assert_eq!(config.mount_path(), "/files");
        assert_eq!(config.root_dir(), Path::new("."));
    }

    #[test]
    fn supports_configuring_multiple_mounts() {
        let config = FileServerConfig::new(".").with_mounts(vec![
            FileMount::new("data", "Data", "."),
            FileMount::new("archive", "Archive", "./server"),
        ]);

        assert_eq!(config.default_mount().id, "data");
        assert_eq!(config.mount("archive").unwrap().name, "Archive");
    }

    #[test]
    fn supports_root_mount_path() {
        let routes = RoutePaths::new("/");

        assert_eq!(routes.mount_path, "/");
        assert_eq!(routes.api_mounts, "/api/mounts");
        assert_eq!(routes.browse, "/browse");
        assert_eq!(routes.static_styles_css, "/static/styles.css");
    }
}