rebecca 0.3.0

Cross-platform cleanup CLI and Rust library surface for Rebecca cleanup planning, rules, and platform adapters.
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use rebecca::core::config::AppRuntimeConfig;
use rebecca::core::disk_map::DiskMapSortField;
use rebecca::core::scan::ScanBackendKind;
use serde::{Deserialize, Serialize};

use crate::tui::app::TuiApp;
use crate::tui::model::TuiScreen;
use crate::tui::view::ViewOptions;

const PREFERENCES_VERSION: u32 = 1;
const PREFERENCES_FILE_NAME: &str = "tui-preferences.json";

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TuiPreferenceLoad {
    pub(crate) preferences: TuiPreferences,
    pub(crate) warning: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct TuiPreferences {
    pub(crate) version: u32,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) last_screen: Option<TuiScreen>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) sort: Option<DiskMapSortField>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) entry_limit: Option<usize>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) scan_backend: Option<ScanBackendKind>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) screen_reader: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) no_color: Option<bool>,
}

impl Default for TuiPreferences {
    fn default() -> Self {
        Self {
            version: PREFERENCES_VERSION,
            last_screen: None,
            sort: None,
            entry_limit: None,
            scan_backend: None,
            screen_reader: None,
            no_color: None,
        }
    }
}

impl TuiPreferences {
    pub(crate) fn load(path: &Path) -> TuiPreferenceLoad {
        let Ok(raw) = fs::read_to_string(path) else {
            return TuiPreferenceLoad {
                preferences: Self::default(),
                warning: None,
            };
        };
        match serde_json::from_str::<Self>(&raw) {
            Ok(mut preferences) if preferences.version == PREFERENCES_VERSION => {
                preferences.last_screen = preferences.last_screen.and_then(persistable_screen);
                TuiPreferenceLoad {
                    preferences,
                    warning: None,
                }
            }
            Ok(_) => TuiPreferenceLoad {
                preferences: Self::default(),
                warning: Some("Ignored unsupported TUI preferences version.".to_string()),
            },
            Err(err) => TuiPreferenceLoad {
                preferences: Self::default(),
                warning: Some(format!("Ignored corrupt TUI preferences: {err}")),
            },
        }
    }

    pub(crate) fn from_app(app: &TuiApp, view_options: ViewOptions) -> Self {
        Self {
            version: PREFERENCES_VERSION,
            last_screen: persistable_screen(app.screen),
            sort: Some(app.sort),
            entry_limit: Some(app.entry_limit),
            scan_backend: Some(app.scan_backend),
            screen_reader: Some(!view_options.visual_bars),
            no_color: Some(!view_options.color),
        }
    }

    pub(crate) fn save(&self, path: &Path) -> Result<()> {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).with_context(|| {
                format!(
                    "failed to create TUI preference directory {}",
                    parent.display()
                )
            })?;
        }
        let mut encoded = serde_json::to_string_pretty(self)
            .context("failed to encode TUI preferences as JSON")?;
        encoded.push('\n');
        fs::write(path, encoded)
            .with_context(|| format!("failed to write TUI preferences {}", path.display()))
    }
}

pub(crate) fn preferences_path(runtime_config: &AppRuntimeConfig) -> PathBuf {
    runtime_config
        .app_paths
        .state_dir
        .join(PREFERENCES_FILE_NAME)
}

pub(crate) fn persistable_screen(screen: TuiScreen) -> Option<TuiScreen> {
    match screen {
        TuiScreen::Map | TuiScreen::Treemap | TuiScreen::Types | TuiScreen::Extensions => {
            Some(screen)
        }
        TuiScreen::RootPicker
        | TuiScreen::Busy
        | TuiScreen::Preview
        | TuiScreen::Confirm
        | TuiScreen::Executed
        | TuiScreen::History
        | TuiScreen::Help
        | TuiScreen::Error => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn missing_preferences_load_defaults() {
        let temp = tempfile::tempdir().unwrap();

        let loaded = TuiPreferences::load(&temp.path().join("missing.json"));

        assert_eq!(loaded.preferences, TuiPreferences::default());
        assert!(loaded.warning.is_none());
    }

    #[test]
    fn corrupt_preferences_load_defaults_with_warning() {
        let temp = tempfile::tempdir().unwrap();
        let path = temp.path().join("tui-preferences.json");
        fs::write(&path, "{not-json").unwrap();

        let loaded = TuiPreferences::load(&path);

        assert_eq!(loaded.preferences, TuiPreferences::default());
        assert!(loaded.warning.unwrap().contains("Ignored corrupt"));
    }

    #[test]
    fn save_creates_parent_directory_and_omits_private_state() {
        let temp = tempfile::tempdir().unwrap();
        let path = temp.path().join("state").join("tui-preferences.json");
        let preferences = TuiPreferences {
            version: PREFERENCES_VERSION,
            last_screen: Some(TuiScreen::Treemap),
            sort: Some(DiskMapSortField::Files),
            entry_limit: Some(500),
            scan_backend: Some(ScanBackendKind::PortableRecursive),
            screen_reader: Some(true),
            no_color: Some(true),
        };

        preferences.save(&path).unwrap();

        let raw = fs::read_to_string(&path).unwrap();
        assert!(raw.contains("treemap"));
        assert!(!raw.contains("root"));
        assert!(!raw.contains("filter"));
        assert!(!raw.contains("basket"));
        assert_eq!(TuiPreferences::load(&path).preferences, preferences);
    }
}