paperless-cli 2.1.0

Rust TUI and LLM-friendly client for Paperless-ngx
Documentation
use std::fmt;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};

use clap::ValueEnum;
use serde::{Deserialize, Serialize};
use url::Url;

use crate::error::AppError;

#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize, ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum OutputMode {
    Json,
    #[default]
    Markdown,
    Tui,
}

#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
pub struct AppConfig {
    pub base_url: String,
    pub token: String,
    #[serde(default)]
    pub preferred_output: OutputMode,
}

impl fmt::Debug for AppConfig {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("AppConfig")
            .field("base_url", &self.base_url)
            .field("token", &self.masked_token())
            .field("preferred_output", &self.preferred_output)
            .finish()
    }
}

impl AppConfig {
    pub fn new(
        base_url: impl Into<String>,
        token: impl Into<String>,
        preferred_output: OutputMode,
    ) -> Result<Self, AppError> {
        let base_url = normalize_url(&base_url.into())?;
        let token = token.into().trim().to_string();
        if token.is_empty() {
            return Err(AppError::MissingCredentials);
        }

        Ok(Self {
            base_url,
            token,
            preferred_output,
        })
    }

    pub fn api_url(&self, path: &str) -> String {
        let suffix = path.trim_start_matches('/');
        format!("{}/api/{}", self.base_url.trim_end_matches('/'), suffix)
    }

    pub fn masked_token(&self) -> String {
        let total_chars = self.token.chars().count();
        if total_chars <= 8 {
            return "***".to_string();
        }

        let prefix = self.token.chars().take(8).collect::<String>();
        format!("{prefix}...")
    }
}

#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct SessionState {
    #[serde(default)]
    pub last_query: String,
    #[serde(default)]
    pub selected_docs: Vec<u64>,
    #[serde(default)]
    pub history: Vec<String>,
}

impl SessionState {
    pub fn push_history(&mut self, command: impl Into<String>) {
        self.history.push(command.into());
        if self.history.len() > 500 {
            let drain = self.history.len() - 500;
            self.history.drain(0..drain);
        }
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AppPaths {
    pub config_path: PathBuf,
    pub session_path: PathBuf,
}

impl Default for AppPaths {
    fn default() -> Self {
        let config_root = std::env::var_os("PAPERLESS_CONFIG_PATH")
            .or_else(|| std::env::var_os("PAPERLESS_CLI_CONFIG_PATH"))
            .map(PathBuf::from)
            .unwrap_or_else(|| {
                dirs::config_dir()
                    .unwrap_or_else(std::env::temp_dir)
                    .join("paperless-cli")
                    .join("config.toml")
            });

        let session_root = std::env::var_os("PAPERLESS_SESSION_PATH")
            .or_else(|| std::env::var_os("PAPERLESS_CLI_SESSION_PATH"))
            .map(PathBuf::from)
            .unwrap_or_else(|| {
                dirs::state_dir()
                    .unwrap_or_else(std::env::temp_dir)
                    .join("paperless-cli")
                    .join("session.toml")
            });

        Self {
            config_path: config_root,
            session_path: session_root,
        }
    }
}

impl AppPaths {
    pub fn new(config_path: impl Into<PathBuf>, session_path: impl Into<PathBuf>) -> Self {
        Self {
            config_path: config_path.into(),
            session_path: session_path.into(),
        }
    }

    pub fn config_permissions_restricted(&self) -> bool {
        restricted_permissions(&self.config_path)
    }
}

pub fn normalize_url(raw: &str) -> Result<String, AppError> {
    let trimmed = raw.trim().trim_end_matches('/');
    let parsed = Url::parse(trimmed).map_err(|_| AppError::InvalidUrl(raw.to_string()))?;

    match parsed.scheme() {
        "https" => Ok(trimmed.to_string()),
        "http" if is_loopback_host(parsed.host_str()) => Ok(trimmed.to_string()),
        "http" => Err(AppError::InsecureRemoteUrl(trimmed.to_string())),
        _ => Err(AppError::InvalidUrl(raw.to_string())),
    }
}

pub fn load_config(paths: &AppPaths) -> Result<AppConfig, AppError> {
    let path = &paths.config_path;
    let persisted = if path.exists() {
        let raw = fs::read_to_string(path)?;
        Some(
            toml::from_str::<AppConfig>(&raw)
                .or_else(|_| serde_json::from_str::<LegacyConfig>(&raw).map(AppConfig::from))
                .map_err(|error| AppError::ConfigMalformed {
                    path: path.clone(),
                    reason: error.to_string(),
                })?,
        )
    } else {
        None
    };

    let base_url = env_value("PAPERLESS_URL")
        .or_else(|| persisted.as_ref().map(|config| config.base_url.clone()));
    let token = env_value("PAPERLESS_TOKEN")
        .or_else(|| persisted.as_ref().map(|config| config.token.clone()));
    let preferred_output = persisted
        .as_ref()
        .map(|config| config.preferred_output)
        .unwrap_or_default();

    match (base_url, token) {
        (Some(base_url), Some(token)) => AppConfig::new(base_url, token, preferred_output),
        (None, None) => Err(AppError::ConfigMissing),
        _ => Err(AppError::Message(
            "Missing Paperless configuration. Set both `PAPERLESS_URL` and `PAPERLESS_TOKEN`, or run `paperless login`.".to_string(),
        )),
    }
}

pub fn save_config(paths: &AppPaths, config: &AppConfig) -> Result<(), AppError> {
    if let Some(parent) = paths.config_path.parent() {
        fs::create_dir_all(parent)?;
    }

    write_private_atomic(&paths.config_path, &toml::to_string_pretty(config)?)?;
    Ok(())
}

pub fn load_session(paths: &AppPaths) -> SessionState {
    let path = &paths.session_path;
    let Some(raw) = fs::read_to_string(path).ok() else {
        return SessionState::default();
    };

    toml::from_str::<SessionState>(&raw).unwrap_or_default()
}

pub fn save_session(paths: &AppPaths, session: &SessionState) -> Result<(), AppError> {
    if let Some(parent) = paths.session_path.parent() {
        fs::create_dir_all(parent)?;
    }

    write_private_atomic(&paths.session_path, &toml::to_string_pretty(session)?)?;
    Ok(())
}

fn write_private_atomic(path: &Path, contents: &str) -> Result<(), AppError> {
    let temp_path = path.with_extension("tmp");
    let mut options = OpenOptions::new();
    options.write(true).create(true).truncate(true);

    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        options.mode(0o600);
    }

    let mut file = options.open(&temp_path)?;
    file.write_all(contents.as_bytes())?;
    file.flush()?;
    drop(file);

    restrict_permissions(&temp_path)?;
    fs::rename(&temp_path, path)?;
    restrict_permissions(path)?;
    Ok(())
}

fn restrict_permissions(path: &Path) -> Result<(), AppError> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;

        let permissions = fs::Permissions::from_mode(0o600);
        fs::set_permissions(path, permissions)?;
    }

    Ok(())
}

fn is_loopback_host(host: Option<&str>) -> bool {
    matches!(host, Some("localhost") | Some("127.0.0.1") | Some("::1"))
}

fn restricted_permissions(path: &Path) -> bool {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;

        fs::metadata(path)
            .map(|metadata| metadata.permissions().mode() & 0o077 == 0)
            .unwrap_or(false)
    }

    #[cfg(not(unix))]
    {
        path.exists()
    }
}

fn env_value(name: &str) -> Option<String> {
    std::env::var(name)
        .ok()
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
}

#[derive(Clone, Debug, Deserialize)]
struct LegacyConfig {
    url: String,
    token: String,
}

impl From<LegacyConfig> for AppConfig {
    fn from(value: LegacyConfig) -> Self {
        Self {
            base_url: value.url.trim_end_matches('/').to_string(),
            token: value.token,
            preferred_output: OutputMode::Markdown,
        }
    }
}