use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SortOrder {
Alphabetical,
RecentVisit,
LatestChanges,
Custom,
}
fn default_sort_by() -> SortOrder {
SortOrder::Custom
}
fn default_visits() -> std::collections::HashMap<String, u64> {
std::collections::HashMap::new()
}
fn default_poll_interval_ms() -> u64 {
100
}
fn default_max_commits() -> usize {
500
}
fn default_graph_max_commits() -> usize {
1000
}
fn default_detail_cache_ttl_secs() -> u64 {
30
}
fn default_tab_ttl_secs() -> u64 {
60
}
fn default_page_size() -> usize {
10
}
fn default_git_app() -> String {
"gitui".to_string()
}
pub fn ssh_command_val() -> &'static str {
if std::env::var("GITWIG_SSH_STRICT").map(|v| v == "1").unwrap_or(false) {
"ssh -o StrictHostKeyChecking=yes"
} else {
"ssh -o StrictHostKeyChecking=accept-new"
}
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
pub struct ThemeConfig {
#[serde(default = "default_accent")]
pub accent: String,
#[serde(default = "default_warning")]
pub warning: String,
#[serde(default = "default_danger")]
pub danger: String,
#[serde(default = "default_success")]
pub success: String,
#[serde(default = "default_border_type")]
pub border_type: String,
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Default)]
pub struct RepoConfig {
#[serde(default)]
pub theme: Option<String>,
#[serde(default)]
pub page_size: Option<usize>,
#[serde(default)]
pub max_commits: Option<usize>,
#[serde(default)]
pub resync_on_tab_change: Option<bool>,
#[serde(default)]
pub editor: Option<String>,
#[serde(default)]
pub note: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
pub struct ScanConfig {
#[serde(default = "default_scan_max_depth")]
pub max_depth: usize,
#[serde(default = "default_scan_excludes")]
pub excludes: Vec<String>,
#[serde(default = "default_scan_start_dir")]
pub start_dir: String,
#[serde(default = "default_scan_git_only")]
pub git_only: bool,
}
impl Default for ThemeConfig {
fn default() -> Self {
default_theme()
}
}
impl Default for ScanConfig {
fn default() -> Self {
default_scan()
}
}
impl Default for Config {
fn default() -> Self {
Config {
items: Vec::new(),
poll_interval_ms: default_poll_interval_ms(),
max_commits: default_max_commits(),
graph_max_commits: default_graph_max_commits(),
detail_cache_ttl_secs: default_detail_cache_ttl_secs(),
tab_ttl_secs: default_tab_ttl_secs(),
page_size: default_page_size(),
sort_by: default_sort_by(),
visits: std::collections::HashMap::new(),
labels: std::collections::HashMap::new(),
repo_configs: std::collections::HashMap::new(),
sort_reverse: false,
pinned: std::collections::HashSet::new(),
starred: std::collections::HashSet::new(),
watch_dirs: Vec::new(),
theme: ThemeConfig::default(),
theme_name: default_theme_name(),
scan: default_scan(),
git_app: default_git_app(),
compatibility_mode: default_compatibility_mode(),
resync_on_tab_change: default_resync_on_tab_change(),
enable_commit_signatures: default_enable_commit_signatures(),
ssh_strict_host_checking: default_ssh_strict_host_checking(),
editor: default_editor(),
compact_view: false,
view_mode: HomeViewMode::Normal,
tile_columns: 0,
show_grouping: true,
auto_fetch_interval_mins: default_auto_fetch_interval_mins(),
show_system_stats: default_show_system_stats(),
stale_threshold_months: default_stale_threshold_months(),
show_stale_projects: default_show_stale_projects(),
}
}
}
fn default_ssh_strict_host_checking() -> bool {
false
}
fn default_editor() -> String {
std::env::var("EDITOR").or_else(|_| std::env::var("VISUAL")).unwrap_or_else(|_| {
if cfg!(target_os = "windows") { "notepad".to_string() } else { "vim".to_string() }
})
}
fn default_accent() -> String {
"cyan".to_string()
}
fn default_warning() -> String {
"yellow".to_string()
}
fn default_danger() -> String {
"red".to_string()
}
fn default_success() -> String {
"green".to_string()
}
fn default_border_type() -> String {
"rounded".to_string()
}
fn default_theme() -> ThemeConfig {
ThemeConfig {
accent: default_accent(),
warning: default_warning(),
danger: default_danger(),
success: default_success(),
border_type: default_border_type(),
}
}
fn default_theme_name() -> String {
"default".to_string()
}
fn default_scan_max_depth() -> usize {
6
}
fn default_scan_excludes() -> Vec<String> {
vec!["node_modules".to_string(), "target".to_string(), "venv".to_string(), ".venv".to_string()]
}
fn default_scan_start_dir() -> String {
dirs::home_dir()
.map(|p| {
let mut s = p.to_string_lossy().into_owned();
if !s.ends_with(std::path::MAIN_SEPARATOR) {
s.push(std::path::MAIN_SEPARATOR);
}
s
})
.unwrap_or_else(|| "/".to_string())
}
fn default_scan_git_only() -> bool {
true
}
fn default_compatibility_mode() -> bool {
true
}
fn default_show_grouping() -> bool {
true
}
fn default_resync_on_tab_change() -> bool {
false
}
fn default_enable_commit_signatures() -> bool {
false
}
fn default_auto_fetch_interval_mins() -> u64 {
10
}
fn default_show_system_stats() -> bool {
false
}
fn default_stale_threshold_months() -> u32 {
1
}
fn default_show_stale_projects() -> bool {
true
}
fn default_scan() -> ScanConfig {
ScanConfig {
max_depth: default_scan_max_depth(),
excludes: default_scan_excludes(),
start_dir: default_scan_start_dir(),
git_only: default_scan_git_only(),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum HomeViewMode {
#[default]
Normal,
Compact,
Tile,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Config {
pub items: Vec<String>,
#[serde(default = "default_poll_interval_ms")]
pub poll_interval_ms: u64,
#[serde(default = "default_max_commits")]
pub max_commits: usize,
#[serde(default = "default_graph_max_commits")]
pub graph_max_commits: usize,
#[serde(default = "default_detail_cache_ttl_secs")]
pub detail_cache_ttl_secs: u64,
#[serde(default = "default_tab_ttl_secs")]
pub tab_ttl_secs: u64,
#[serde(default = "default_page_size")]
pub page_size: usize,
#[serde(default = "default_sort_by")]
pub sort_by: SortOrder,
#[serde(default = "default_visits")]
pub visits: std::collections::HashMap<String, u64>,
#[serde(default)]
pub labels: std::collections::HashMap<String, Vec<String>>,
#[serde(default)]
pub repo_configs: std::collections::HashMap<String, RepoConfig>,
#[serde(default)]
pub sort_reverse: bool,
#[serde(default)]
pub pinned: std::collections::HashSet<String>,
#[serde(default)]
pub starred: std::collections::HashSet<String>,
#[serde(default)]
pub watch_dirs: Vec<String>,
#[serde(skip)]
pub theme: ThemeConfig,
#[serde(rename = "theme", default = "default_theme_name")]
pub theme_name: String,
#[serde(alias = "fzf", rename = "scan", default = "default_scan")]
pub scan: ScanConfig,
#[serde(default = "default_git_app")]
pub git_app: String,
#[serde(default = "default_compatibility_mode")]
pub compatibility_mode: bool,
#[serde(default = "default_resync_on_tab_change")]
pub resync_on_tab_change: bool,
#[serde(default = "default_enable_commit_signatures")]
pub enable_commit_signatures: bool,
#[serde(default = "default_ssh_strict_host_checking")]
pub ssh_strict_host_checking: bool,
#[serde(default = "default_editor")]
pub editor: String,
#[serde(default)]
pub compact_view: bool,
#[serde(default)]
pub view_mode: HomeViewMode,
#[serde(default)]
pub tile_columns: usize,
#[serde(default = "default_show_grouping")]
pub show_grouping: bool,
#[serde(default = "default_auto_fetch_interval_mins")]
pub auto_fetch_interval_mins: u64,
#[serde(default = "default_show_system_stats")]
pub show_system_stats: bool,
#[serde(default = "default_stale_threshold_months")]
pub stale_threshold_months: u32,
#[serde(default = "default_show_stale_projects")]
pub show_stale_projects: bool,
}
impl Config {
pub fn sym(&self, key: &str) -> &'static str {
if self.compatibility_mode {
match key {
"branch" => "* ",
"git_repo" => "G ",
"arrow_down" => "v",
"arrow_right" => ">",
"folder_tree_expanded" => "v ",
"folder_tree_collapsed" => "> ",
"file_tree" => " - ",
"folder" => "[D]",
"file" => "[F]",
"pinned" => "[P]",
"action" => "[!]",
"warning" => "! ",
"close" => "x",
"bullet_empty" => "o",
"bullet_filled" => "*",
"star" => "*",
"block" => "#",
"bar" => "|",
"esc" => "ESC",
"backspace" => "Backspace",
"tab" => "Tab",
"shift" => "Shift",
"enter" => "Enter",
"up" => "^",
"down" => "v",
"page_up" => "PgUp",
"page_down" => "PgDn",
"transfer" => "<->",
"up_down" => "^/v",
"selection_mark" => "> ",
_ => "",
}
} else {
match key {
"branch" => " ",
"git_repo" => "⎇ ",
"arrow_down" => "▼",
"arrow_right" => "▶",
"folder_tree_expanded" => "▼ ",
"folder_tree_collapsed" => "> ",
"file_tree" => " 📄 ",
"folder" => "📁 ",
"file" => "📄 ",
"pinned" => "📌 ",
"action" => "⚡ ",
"warning" => "⚠ ",
"close" => "✕",
"bullet_empty" => "○",
"bullet_filled" => "●",
"star" => "★",
"block" => "█",
"bar" => "▍",
"esc" => "⎋",
"backspace" => "⌫",
"tab" => "⇥",
"shift" => "⇧",
"enter" => "↵",
"up" => "↑",
"down" => "↓",
"page_up" => "⇞",
"page_down" => "⇟",
"transfer" => "⇆",
"up_down" => "↑↓",
"selection_mark" => "▌ ",
_ => "",
}
}
}
}
fn home_gitwig_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".gitwig")
}
fn handle_parse_error(path: &Path, _error: Box<dyn Error>) -> (Config, Option<String>) {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let corrupt_path = path.with_extension(format!("toml.corrupt-{}", ts));
let rename_result = fs::rename(path, &corrupt_path);
let fallback = Config {
items: vec![],
poll_interval_ms: default_poll_interval_ms(),
max_commits: default_max_commits(),
graph_max_commits: default_graph_max_commits(),
detail_cache_ttl_secs: default_detail_cache_ttl_secs(),
tab_ttl_secs: default_tab_ttl_secs(),
page_size: default_page_size(),
sort_by: default_sort_by(),
visits: default_visits(),
labels: std::collections::HashMap::new(),
repo_configs: std::collections::HashMap::new(),
sort_reverse: false,
pinned: std::collections::HashSet::new(),
starred: std::collections::HashSet::new(),
watch_dirs: vec![],
theme_name: default_theme_name(),
theme: default_theme(),
scan: default_scan(),
git_app: default_git_app(),
compatibility_mode: true,
resync_on_tab_change: false,
enable_commit_signatures: false,
ssh_strict_host_checking: false,
editor: default_editor(),
compact_view: false,
view_mode: HomeViewMode::Normal,
tile_columns: 0,
show_grouping: true,
auto_fetch_interval_mins: default_auto_fetch_interval_mins(),
show_system_stats: default_show_system_stats(),
stale_threshold_months: default_stale_threshold_months(),
show_stale_projects: default_show_stale_projects(),
};
let _ = save_config(&fallback, path);
let msg = match rename_result {
Ok(_) => format!(
"Config corrupt! Moved to {} and reset to defaults",
corrupt_path.file_name().unwrap_or_default().to_string_lossy()
),
Err(e) => format!("Config corrupt! Reset to defaults (failed to rename: {})", e),
};
(fallback, Some(msg))
}
fn load_and_parse_config(path: &Path) -> (Config, Option<String>) {
match fs::read_to_string(path) {
Ok(contents) => match toml::from_str::<Config>(&contents) {
Ok(mut config) => {
if config.compact_view && config.view_mode == HomeViewMode::Normal {
config.view_mode = HomeViewMode::Compact;
config.compact_view = false;
}
let allowed = ["git", "gitui", "lazygit"];
if !allowed.contains(&config.git_app.as_str()) {
let old_val = config.git_app.clone();
config.git_app = "gitui".to_string();
(
config,
Some(format!(
"Invalid preferred Git client '{}' reset to 'gitui'",
old_val
)),
)
} else {
(config, None)
}
}
Err(err) => handle_parse_error(path, err.into()),
},
Err(err) => handle_parse_error(path, err.into()),
}
}
pub fn load_config(
cli_path: Option<PathBuf>,
) -> Result<(Config, PathBuf, Option<String>), Box<dyn Error>> {
if let Some(path) = cli_path {
if path.exists() {
let (mut config, warning) = load_and_parse_config(&path);
let themes_dir = path.parent().unwrap_or(&path).join("themes");
let _ = fs::create_dir_all(&themes_dir);
let _ = write_popular_themes(&themes_dir);
let theme_path = themes_dir.join(format!("{}.theme", config.theme_name));
if theme_path.exists() {
if let Ok(theme_contents) = fs::read_to_string(&theme_path) {
if let Ok(theme) = toml::from_str::<ThemeConfig>(&theme_contents) {
config.theme = theme;
} else {
config.theme = default_theme();
}
} else {
config.theme = default_theme();
}
} else {
let legacy_theme_path = path.with_file_name("theme.toml");
if legacy_theme_path.exists() {
let _ = fs::copy(&legacy_theme_path, themes_dir.join("default.theme"));
let _ = fs::remove_file(&legacy_theme_path);
}
if let Ok(theme_serialized) = toml::to_string_pretty(&config.theme) {
let _ = fs::write(&theme_path, theme_serialized);
}
}
return Ok((config, path, warning));
}
let fallback_theme = default_theme();
let fallback_theme_name = default_theme_name();
let themes_dir = path.parent().unwrap_or(&path).join("themes");
let _ = fs::create_dir_all(&themes_dir);
let _ = write_popular_themes(&themes_dir);
let theme_path = themes_dir.join(format!("{}.theme", fallback_theme_name));
if let Ok(theme_serialized) = toml::to_string_pretty(&fallback_theme) {
let _ = fs::write(&theme_path, theme_serialized);
}
return Ok((
Config {
items: vec![],
poll_interval_ms: default_poll_interval_ms(),
max_commits: default_max_commits(),
graph_max_commits: default_graph_max_commits(),
detail_cache_ttl_secs: default_detail_cache_ttl_secs(),
tab_ttl_secs: default_tab_ttl_secs(),
page_size: default_page_size(),
sort_by: default_sort_by(),
visits: default_visits(),
labels: std::collections::HashMap::new(),
repo_configs: std::collections::HashMap::new(),
sort_reverse: false,
pinned: std::collections::HashSet::new(),
starred: std::collections::HashSet::new(),
watch_dirs: vec![],
theme_name: fallback_theme_name,
theme: fallback_theme,
scan: default_scan(),
git_app: default_git_app(),
compatibility_mode: true,
resync_on_tab_change: false,
enable_commit_signatures: false,
ssh_strict_host_checking: false,
editor: default_editor(),
compact_view: false,
view_mode: HomeViewMode::Normal,
tile_columns: 0,
show_grouping: true,
auto_fetch_interval_mins: default_auto_fetch_interval_mins(),
show_system_stats: default_show_system_stats(),
stale_threshold_months: default_stale_threshold_months(),
show_stale_projects: default_show_stale_projects(),
},
path,
None,
));
}
let gitwig_dir = home_gitwig_dir();
let _ = fs::create_dir_all(&gitwig_dir);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(&gitwig_dir) {
let mut perms = meta.permissions();
perms.set_mode(0o700);
let _ = fs::set_permissions(&gitwig_dir, perms);
}
}
let canonical = gitwig_dir.join("config.toml");
let themes_dir = gitwig_dir.join("themes");
let _ = fs::create_dir_all(&themes_dir);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(&themes_dir) {
let mut perms = meta.permissions();
perms.set_mode(0o700);
let _ = fs::set_permissions(&themes_dir, perms);
}
}
let _ = write_popular_themes(&themes_dir);
if canonical.exists() {
let (mut config, warning) = load_and_parse_config(&canonical);
let theme_path = themes_dir.join(format!("{}.theme", config.theme_name));
if theme_path.exists() {
if let Ok(theme_contents) = fs::read_to_string(&theme_path) {
if let Ok(theme) = toml::from_str::<ThemeConfig>(&theme_contents) {
config.theme = theme;
} else {
config.theme = default_theme();
}
} else {
config.theme = default_theme();
}
} else {
let legacy_theme_path = gitwig_dir.join("theme.toml");
if legacy_theme_path.exists() {
let _ = fs::copy(&legacy_theme_path, themes_dir.join("default.theme"));
let _ = fs::remove_file(&legacy_theme_path);
}
if let Ok(theme_serialized) = toml::to_string_pretty(&config.theme) {
let _ = fs::write(&theme_path, theme_serialized);
}
}
return Ok((config, canonical, warning));
}
if let Some(source) = find_legacy_config() {
fs::copy(&source, &canonical)?;
let (mut config, warning) = load_and_parse_config(&canonical);
let theme_path = themes_dir.join(format!("{}.theme", config.theme_name));
if theme_path.exists() {
if let Ok(theme_contents) = fs::read_to_string(&theme_path) {
if let Ok(theme) = toml::from_str::<ThemeConfig>(&theme_contents) {
config.theme = theme;
} else {
config.theme = default_theme();
}
} else {
config.theme = default_theme();
}
} else {
let legacy_theme_path = gitwig_dir.join("theme.toml");
if legacy_theme_path.exists() {
let _ = fs::copy(&legacy_theme_path, themes_dir.join("default.theme"));
let _ = fs::remove_file(&legacy_theme_path);
}
if let Ok(theme_serialized) = toml::to_string_pretty(&config.theme) {
let _ = fs::write(&theme_path, theme_serialized);
}
}
return Ok((config, canonical, warning));
}
let fallback = Config {
items: vec![
"Nice job. You forgot the config, genius.".to_string(),
"Still looking... it's not here either.".to_string(),
],
poll_interval_ms: default_poll_interval_ms(),
max_commits: default_max_commits(),
graph_max_commits: default_graph_max_commits(),
detail_cache_ttl_secs: default_detail_cache_ttl_secs(),
tab_ttl_secs: default_tab_ttl_secs(),
page_size: default_page_size(),
sort_by: default_sort_by(),
visits: default_visits(),
labels: std::collections::HashMap::new(),
repo_configs: std::collections::HashMap::new(),
sort_reverse: false,
pinned: std::collections::HashSet::new(),
starred: std::collections::HashSet::new(),
watch_dirs: vec![],
theme_name: default_theme_name(),
theme: default_theme(),
scan: default_scan(),
git_app: default_git_app(),
compatibility_mode: true,
resync_on_tab_change: false,
enable_commit_signatures: false,
ssh_strict_host_checking: false,
editor: default_editor(),
compact_view: false,
view_mode: HomeViewMode::Normal,
tile_columns: 0,
show_grouping: true,
auto_fetch_interval_mins: default_auto_fetch_interval_mins(),
show_system_stats: default_show_system_stats(),
stale_threshold_months: default_stale_threshold_months(),
show_stale_projects: default_show_stale_projects(),
};
save_config(&fallback, &canonical)?;
let theme_path = themes_dir.join(format!("{}.theme", fallback.theme_name));
if let Ok(theme_serialized) = toml::to_string_pretty(&fallback.theme) {
let _ = fs::write(&theme_path, theme_serialized);
}
Ok((fallback, canonical, None))
}
fn write_popular_themes(themes_dir: &Path) -> Result<(), Box<dyn Error>> {
let popular_themes = [
(
"catppuccin",
r#"accent = "lightblue"
warning = "lightyellow"
danger = "lightred"
success = "lightgreen"
border_type = "rounded"
"#,
),
(
"cyberpunk",
r#"accent = "lightmagenta"
warning = "lightyellow"
danger = "lightred"
success = "lightcyan"
border_type = "double"
"#,
),
(
"dracula",
r#"accent = "lightmagenta"
warning = "lightyellow"
danger = "lightred"
success = "lightgreen"
border_type = "rounded"
"#,
),
(
"forest",
r#"accent = "lightgreen"
warning = "yellow"
danger = "lightred"
success = "green"
border_type = "rounded"
"#,
),
(
"gruvbox",
r#"accent = "yellow"
warning = "lightyellow"
danger = "red"
success = "green"
border_type = "plain"
"#,
),
(
"monokai",
r#"accent = "lightyellow"
warning = "yellow"
danger = "red"
success = "green"
border_type = "rounded"
"#,
),
(
"nord",
r#"accent = "lightblue"
warning = "yellow"
danger = "red"
success = "green"
border_type = "rounded"
"#,
),
(
"oceanic",
r#"accent = "lightcyan"
warning = "yellow"
danger = "red"
success = "lightgreen"
border_type = "rounded"
"#,
),
(
"onedark",
r#"accent = "blue"
warning = "yellow"
danger = "lightred"
success = "green"
border_type = "rounded"
"#,
),
(
"rosepine",
r#"accent = "lightmagenta"
warning = "yellow"
danger = "lightred"
success = "lightcyan"
border_type = "rounded"
"#,
),
(
"solarized_dark",
r#"accent = "cyan"
warning = "yellow"
danger = "red"
success = "green"
border_type = "rounded"
"#,
),
(
"tokyonight",
r#"accent = "lightcyan"
warning = "yellow"
danger = "red"
success = "lightgreen"
border_type = "rounded"
"#,
),
];
for (name, content) in popular_themes {
let theme_path = themes_dir.join(format!("{}.theme", name));
if !theme_path.exists() {
fs::write(theme_path, content)?;
}
}
Ok(())
}
fn find_legacy_config() -> Option<PathBuf> {
let local = PathBuf::from("config/config.toml");
if local.exists() {
return Some(local);
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let p = dir.join("config/config.toml");
if p.exists() {
return Some(p);
}
}
}
if let Some(home) = dirs::home_dir() {
let p = home.join(".twig/config.toml");
if p.exists() {
return Some(p);
}
}
if let Some(p) = dirs::config_dir().map(|d| d.join("gitwig/config.toml")) {
if p.exists() {
return Some(p);
}
}
if let Some(p) = dirs::config_dir().map(|d| d.join("twig/config.toml")) {
if p.exists() {
return Some(p);
}
}
None
}
pub fn save_config(config: &Config, path: &Path) -> Result<(), Box<dyn Error>> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(parent) {
let mut perms = meta.permissions();
perms.set_mode(0o700);
let _ = fs::set_permissions(parent, perms);
}
}
}
}
let serialized = toml::to_string_pretty(config)?;
let tmp_path = path.with_extension("toml.tmp");
if let Err(e) = fs::write(&tmp_path, serialized) {
let _ = fs::remove_file(&tmp_path);
return Err(e.into());
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(&tmp_path) {
let mut perms = meta.permissions();
perms.set_mode(0o600);
let _ = fs::set_permissions(&tmp_path, perms);
}
}
if let Err(e) = fs::rename(&tmp_path, path) {
let _ = fs::remove_file(&tmp_path);
return Err(e.into());
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
static TEST_DIR_COUNTER: AtomicUsize = AtomicUsize::new(0);
fn get_unique_id() -> String {
let count = TEST_DIR_COUNTER.fetch_add(1, Ordering::SeqCst);
let pid = std::process::id();
let nanos =
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
format!("{}_{}_{}", pid, nanos, count)
}
#[test]
fn test_theme_separation_load_and_save() {
let unique_id = get_unique_id();
let test_dir = std::env::temp_dir().join(format!("gitwig_test_theme_{}", unique_id));
fs::create_dir_all(&test_dir).unwrap();
let config_path = test_dir.join("config.toml");
let themes_dir = test_dir.join("themes");
let theme_path = themes_dir.join("default.theme");
let (config, path, _) = load_config(Some(config_path.clone())).unwrap();
assert_eq!(path, config_path);
assert!(!config_path.exists());
assert!(theme_path.exists());
assert_eq!(config.theme.accent, "cyan");
assert_eq!(config.theme_name, "default");
let theme_content = fs::read_to_string(&theme_path).unwrap();
assert!(theme_content.contains("accent = \"cyan\""));
save_config(&config, &config_path).unwrap();
assert!(config_path.exists());
let config_content = fs::read_to_string(&config_path).unwrap();
assert!(!config_content.contains("[theme]"));
assert!(config_content.contains("theme = \"default\""));
let custom_theme = r#"accent = "magenta"
warning = "yellow"
danger = "red"
success = "green"
border_type = "double"
"#;
fs::write(&theme_path, custom_theme).unwrap();
let (loaded_config, _, _) = load_config(Some(config_path.clone())).unwrap();
assert_eq!(loaded_config.theme.accent, "magenta");
assert_eq!(loaded_config.theme.border_type, "double");
save_config(&loaded_config, &config_path).unwrap();
let config_content_after_save = fs::read_to_string(&config_path).unwrap();
assert!(!config_content_after_save.contains("[theme]"));
assert!(config_content_after_save.contains("theme = \"default\""));
let _ = fs::remove_dir_all(&test_dir);
}
#[test]
fn test_write_popular_themes_creates_files() {
let unique_id = get_unique_id();
let test_dir =
std::env::temp_dir().join(format!("gitwig_test_popular_themes_{}", unique_id));
fs::create_dir_all(&test_dir).unwrap();
write_popular_themes(&test_dir).unwrap();
assert!(test_dir.join("catppuccin.theme").exists());
assert!(test_dir.join("cyberpunk.theme").exists());
assert!(test_dir.join("dracula.theme").exists());
assert!(test_dir.join("forest.theme").exists());
assert!(test_dir.join("gruvbox.theme").exists());
assert!(test_dir.join("monokai.theme").exists());
assert!(test_dir.join("nord.theme").exists());
assert!(test_dir.join("oceanic.theme").exists());
assert!(test_dir.join("onedark.theme").exists());
assert!(test_dir.join("rosepine.theme").exists());
assert!(test_dir.join("solarized_dark.theme").exists());
assert!(test_dir.join("tokyonight.theme").exists());
let contents = fs::read_to_string(test_dir.join("oceanic.theme")).unwrap();
assert!(contents.contains("accent = \"lightcyan\""));
let _ = fs::remove_dir_all(&test_dir);
}
#[test]
fn test_corrupt_config_recovery() {
let unique_id = get_unique_id();
let test_dir = std::env::temp_dir().join(format!("gitwig_test_corrupt_{}", unique_id));
fs::create_dir_all(&test_dir).unwrap();
let config_path = test_dir.join("config.toml");
fs::write(&config_path, "items = [").unwrap();
let (config, path, warning) = load_config(Some(config_path.clone())).unwrap();
assert_eq!(path, config_path);
assert!(config_path.exists());
assert!(warning.is_some());
assert!(warning.unwrap().contains("Config corrupt!"));
assert!(config.items.is_empty());
let files = fs::read_dir(&test_dir).unwrap();
let corrupt_exists = files
.filter_map(Result::ok)
.any(|entry| entry.file_name().to_string_lossy().contains("config.toml.corrupt-"));
assert!(corrupt_exists);
let _ = fs::remove_dir_all(&test_dir);
}
#[test]
fn test_git_app_validation_recovery() {
let unique_id = get_unique_id();
let test_dir = std::env::temp_dir().join(format!("gitwig_test_git_app_{}", unique_id));
fs::create_dir_all(&test_dir).unwrap();
let config_path = test_dir.join("config.toml");
let config_toml = r#"
items = []
poll_interval_ms = 1000
max_commits = 100
graph_max_commits = 100
detail_cache_ttl_secs = 60
tab_ttl_secs = 60
page_size = 50
sort_by = "alphabetical"
visits = {}
labels = {}
repo_configs = {}
sort_reverse = false
pinned = []
theme_name = "default"
scan = { excludes = [], max_depth = 5, start_dir = "~" }
git_app = "malicious_binary"
compatibility_mode = true
resync_on_tab_change = false
enable_commit_signatures = false
"#;
fs::write(&config_path, config_toml).unwrap();
let (config, path, warning) = load_config(Some(config_path.clone())).unwrap();
assert_eq!(path, config_path);
assert_eq!(config.git_app, "gitui");
assert!(warning.is_some());
assert!(warning.unwrap().contains("Invalid preferred Git client"));
let _ = fs::remove_dir_all(&test_dir);
}
#[test]
fn test_config_coverage_boost() {
let ssh_val = ssh_command_val();
assert!(!ssh_val.is_empty());
let config = Config { compatibility_mode: true, ..Config::default() };
assert_eq!(config.sym("branch"), "* ");
let config2 = Config { compatibility_mode: false, ..Config::default() };
assert_eq!(config2.sym("branch"), "\u{e0a0} ");
let unique_id = get_unique_id();
let test_dir = std::env::temp_dir().join(format!("gitwig_test_parse_err_{}", unique_id));
fs::create_dir_all(&test_dir).unwrap();
let err_path = test_dir.join("config.toml");
let (config, warning) =
handle_parse_error(&err_path, Box::new(std::io::Error::other("mock error")));
assert!(warning.is_some());
assert!(config.items.is_empty());
let _ = fs::remove_dir_all(&test_dir);
let _legacy = find_legacy_config();
}
}