chezmoi-tui 0.2.0

A visual TUI wrapper around chezmoi
use crate::app::LayoutMode;
use crate::cli::CliArgs;
use crate::domain::ListView;
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone)]
pub struct AppConfig {
    pub require_two_step_confirmation: bool,
    pub initial_view: ListView,
    pub auto_preview: bool,
    pub show_apply_plan: bool,
    pub show_base_context: bool,
    pub show_notices: bool,
    pub default_layout: LayoutMode,
    pub list_ratio: u16,
    pub detail_ratio: u16,
    pub footer_help: bool,
    pub destination_dir: Option<PathBuf>,
    pub source_dir: Option<PathBuf>,
    pub log_file: Option<PathBuf>,
    pub config_file: Option<PathBuf>,
    pub editor: Option<String>,
    pub external_diff: Option<String>,
    pub working_dir: PathBuf,
}

impl Default for AppConfig {
    fn default() -> Self {
        let working_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
        Self {
            require_two_step_confirmation: true,
            initial_view: ListView::Status,
            auto_preview: true,
            show_apply_plan: true,
            show_base_context: true,
            show_notices: true,
            default_layout: LayoutMode::Normal,
            list_ratio: 35,
            detail_ratio: 65,
            footer_help: false,
            destination_dir: None,
            source_dir: None,
            log_file: None,
            config_file: None,
            editor: None,
            external_diff: None,
            working_dir,
        }
    }
}

#[derive(Debug, Clone, Deserialize, Default)]
struct FileConfig {
    ui: Option<UiConfig>,
    safety: Option<SafetyConfig>,
    tools: Option<ToolsConfig>,
}

#[derive(Debug, Clone, Deserialize, Default)]
struct UiConfig {
    default_view: Option<String>,
    auto_preview: Option<bool>,
    show_base_context: Option<bool>,
    show_notices: Option<bool>,
    default_layout: Option<String>,
    list_ratio: Option<u16>,
    detail_ratio: Option<u16>,
    footer_help: Option<bool>,
}

#[derive(Debug, Clone, Deserialize, Default)]
struct SafetyConfig {
    require_two_step_confirmation: Option<bool>,
    show_apply_plan: Option<bool>,
}

#[derive(Debug, Clone, Deserialize, Default)]
struct ToolsConfig {
    editor: Option<String>,
    external_diff: Option<String>,
}

impl AppConfig {
    pub(crate) fn from_cli(args: CliArgs) -> Result<Self> {
        let mut config = Self::default();

        if !args.no_config
            && let Some(config_path) = args.config.clone().or_else(default_config_path)
            && config_path.exists()
        {
            config.apply_file_config(&config_path)?;
            config.config_file = Some(config_path);
        }

        if let Some(view) = args.view {
            config.initial_view = view.into();
        }
        if args.no_auto_preview {
            config.auto_preview = false;
        }
        config.destination_dir = args.destination;
        config.source_dir = args.source;
        config.log_file = args.log_file;
        if args.no_config {
            config.config_file = None;
        } else if args.config.is_some() && config.config_file.is_none() {
            config.config_file = args.config;
        }
        Ok(config)
    }

    fn apply_file_config(&mut self, path: &Path) -> Result<()> {
        let content = std::fs::read_to_string(path)
            .with_context(|| format!("failed to read config {}", path.display()))?;
        let file_config: FileConfig = toml::from_str(&content)
            .with_context(|| format!("failed to parse config {}", path.display()))?;

        if let Some(ui) = file_config.ui {
            if let Some(view) = ui.default_view {
                self.initial_view = parse_list_view(&view)?;
            }
            if let Some(auto_preview) = ui.auto_preview {
                self.auto_preview = auto_preview;
            }
            if let Some(show_base_context) = ui.show_base_context {
                self.show_base_context = show_base_context;
            }
            if let Some(show_notices) = ui.show_notices {
                self.show_notices = show_notices;
            }
            if let Some(default_layout) = ui.default_layout {
                self.default_layout = parse_layout_mode(&default_layout)?;
            }
            if let Some(list_ratio) = ui.list_ratio {
                self.list_ratio = validate_ratio("ui.list_ratio", list_ratio)?;
            }
            if let Some(detail_ratio) = ui.detail_ratio {
                self.detail_ratio = validate_ratio("ui.detail_ratio", detail_ratio)?;
            }
            if let Some(footer_help) = ui.footer_help {
                self.footer_help = footer_help;
            }
        }

        if let Some(safety) = file_config.safety {
            if let Some(require_two_step_confirmation) = safety.require_two_step_confirmation {
                self.require_two_step_confirmation = require_two_step_confirmation;
            }
            if let Some(show_apply_plan) = safety.show_apply_plan {
                self.show_apply_plan = show_apply_plan;
            }
        }

        if let Some(tools) = file_config.tools {
            self.editor = tools.editor;
            self.external_diff = tools.external_diff;
        }

        Ok(())
    }
}

fn default_config_path() -> Option<PathBuf> {
    dirs::config_dir().map(|dir| dir.join("chezmoi-tui").join("config.toml"))
}

fn parse_layout_mode(value: &str) -> Result<LayoutMode> {
    match value {
        "normal" => Ok(LayoutMode::Normal),
        "detail-max" | "detail_max" => Ok(LayoutMode::DetailMax),
        "log-max" | "log_max" => Ok(LayoutMode::LogMax),
        other => bail!("invalid ui.default_layout {other:?}"),
    }
}

fn validate_ratio(name: &str, value: u16) -> Result<u16> {
    if (10..=90).contains(&value) {
        Ok(value)
    } else {
        bail!("{name} must be between 10 and 90")
    }
}

fn parse_list_view(value: &str) -> Result<ListView> {
    match value {
        "status" => Ok(ListView::Status),
        "managed" => Ok(ListView::Managed),
        "unmanaged" => Ok(ListView::Unmanaged),
        "source" => Ok(ListView::Source),
        other => bail!("invalid ui.default_view {other:?}"),
    }
}

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

    #[test]
    fn default_values_are_safe() {
        let cfg = AppConfig::default();
        assert!(cfg.require_two_step_confirmation);
        assert!(cfg.show_apply_plan);
        assert!(cfg.show_base_context);
        assert!(cfg.show_notices);
    }

    #[test]
    fn file_config_loads_ui_safety_and_tools() {
        let path = std::env::temp_dir().join(format!(
            "chezmoi_tui_config_{}_{}.toml",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .expect("time")
                .as_nanos()
        ));
        std::fs::write(
            &path,
            r#"
[ui]
default_view = "source"
auto_preview = false
show_base_context = false
show_notices = false
default_layout = "detail-max"
list_ratio = 40
detail_ratio = 70
footer_help = true

[safety]
require_two_step_confirmation = false
show_apply_plan = false

[tools]
editor = "nvim"
external_diff = "delta"
"#,
        )
        .expect("write config");

        let cfg = AppConfig::from_cli(CliArgs {
            destination: None,
            source: None,
            view: None,
            log_file: None,
            config: Some(path.clone()),
            no_config: false,
            no_auto_preview: false,
        })
        .expect("load config");

        assert_eq!(cfg.initial_view, ListView::Source);
        assert!(!cfg.auto_preview);
        assert!(!cfg.show_base_context);
        assert!(!cfg.show_notices);
        assert_eq!(cfg.default_layout, LayoutMode::DetailMax);
        assert_eq!(cfg.list_ratio, 40);
        assert_eq!(cfg.detail_ratio, 70);
        assert!(cfg.footer_help);
        assert!(!cfg.require_two_step_confirmation);
        assert!(!cfg.show_apply_plan);
        assert_eq!(cfg.editor.as_deref(), Some("nvim"));
        assert_eq!(cfg.external_diff.as_deref(), Some("delta"));

        let _ = std::fs::remove_file(path);
    }

    #[test]
    fn cli_overrides_file_config() {
        let path = std::env::temp_dir().join(format!(
            "chezmoi_tui_config_override_{}_{}.toml",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .expect("time")
                .as_nanos()
        ));
        std::fs::write(
            &path,
            "[ui]\ndefault_view = \"source\"\nauto_preview = true\n",
        )
        .expect("write config");

        let cfg = AppConfig::from_cli(CliArgs {
            destination: None,
            source: None,
            view: Some(crate::cli::CliView::Managed),
            log_file: None,
            config: Some(path.clone()),
            no_config: false,
            no_auto_preview: true,
        })
        .expect("load config");

        assert_eq!(cfg.initial_view, ListView::Managed);
        assert!(!cfg.auto_preview);

        let _ = std::fs::remove_file(path);
    }

    #[test]
    fn invalid_default_view_is_an_error() {
        let path = std::env::temp_dir().join(format!(
            "chezmoi_tui_config_invalid_{}_{}.toml",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .expect("time")
                .as_nanos()
        ));
        std::fs::write(&path, "[ui]\ndefault_view = \"bad\"\n").expect("write config");

        let err = AppConfig::from_cli(CliArgs {
            destination: None,
            source: None,
            view: None,
            log_file: None,
            config: Some(path.clone()),
            no_config: false,
            no_auto_preview: false,
        })
        .expect_err("invalid config fails");
        assert!(format!("{err:#}").contains("invalid ui.default_view"));

        let _ = std::fs::remove_file(path);
    }
}