use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use strum::{Display, EnumIter, FromRepr, IntoEnumIterator};
use gravityfile_analyze::{AgeReport, DuplicateReport};
use gravityfile_core::FileTree;
use gravityfile_ops::{ConflictRequest, OperationProgress, OperationType};
use gravityfile_scan::ScanProgress;
use crate::ui::{MillerState, TreeState, TreemapState};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AppMode {
#[default]
Normal,
Help,
Search,
ConfirmDelete,
Deleting,
Copying,
Moving,
Undoing,
UpdatingEntry,
Renaming,
CreatingFile,
CreatingDirectory,
Taking,
GoingTo,
ConflictResolution,
Command,
Settings,
SettingBookmark,
JumpingToBookmark,
BookmarkList,
ConfirmBulkRename,
BulkRenaming,
Visual,
Quit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LayoutMode {
#[default]
Tree,
Miller,
}
impl LayoutMode {
pub fn toggle(self) -> Self {
match self {
Self::Tree => Self::Miller,
Self::Miller => Self::Tree,
}
}
pub fn name(self) -> &'static str {
match self {
Self::Tree => "tree",
Self::Miller => "miller",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct VisualState {
pub anchor: usize,
pub cursor: usize,
}
impl VisualState {
pub fn new(start_index: usize) -> Self {
Self {
anchor: start_index,
cursor: start_index,
}
}
pub fn selection_range(&self) -> (usize, usize) {
let start = self.anchor.min(self.cursor);
let end = self.anchor.max(self.cursor);
(start, end)
}
pub fn move_up(&mut self) {
self.cursor = self.cursor.saturating_sub(1);
}
pub fn move_down(&mut self, max: usize) {
if max > 0 && self.cursor < max - 1 {
self.cursor += 1;
}
}
pub fn page_up(&mut self, page_size: usize) {
self.cursor = self.cursor.saturating_sub(page_size);
}
pub fn page_down(&mut self, page_size: usize, max: usize) {
if max > 0 {
self.cursor = (self.cursor + page_size).min(max - 1);
}
}
pub fn jump_to_top(&mut self) {
self.cursor = 0;
}
pub fn jump_to_bottom(&mut self, max: usize) {
if max > 0 {
self.cursor = max - 1;
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ClipboardMode {
#[default]
Empty,
Copy,
Cut,
}
#[derive(Debug, Clone, Default)]
pub struct ClipboardState {
pub paths: Vec<PathBuf>,
pub mode: ClipboardMode,
pub source_root: Option<PathBuf>,
}
impl ClipboardState {
pub fn yank(&mut self, paths: impl IntoIterator<Item = PathBuf>, source_root: PathBuf) {
self.paths = paths.into_iter().collect();
self.mode = ClipboardMode::Copy;
self.source_root = Some(source_root);
}
pub fn cut(&mut self, paths: impl IntoIterator<Item = PathBuf>, source_root: PathBuf) {
self.paths = paths.into_iter().collect();
self.mode = ClipboardMode::Cut;
self.source_root = Some(source_root);
}
pub fn clear(&mut self) {
self.paths.clear();
self.mode = ClipboardMode::Empty;
self.source_root = None;
}
pub fn is_empty(&self) -> bool {
self.paths.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Display, EnumIter, FromRepr)]
pub enum SortMode {
#[default]
#[strum(to_string = "Size ↓")]
SizeDescending,
#[strum(to_string = "Size ↑")]
SizeAscending,
#[strum(to_string = "Name ↑")]
NameAscending,
#[strum(to_string = "Name ↓")]
NameDescending,
#[strum(to_string = "Date ↓")]
ModifiedDescending,
#[strum(to_string = "Date ↑")]
ModifiedAscending,
#[strum(to_string = "Count ↓")]
CountDescending,
#[strum(to_string = "Count ↑")]
CountAscending,
}
impl SortMode {
pub fn next(self) -> Self {
let current = self as usize;
let next = (current + 1) % Self::iter().count();
Self::from_repr(next).unwrap_or_default()
}
pub fn reverse(self) -> Self {
match self {
Self::SizeDescending => Self::SizeAscending,
Self::SizeAscending => Self::SizeDescending,
Self::NameAscending => Self::NameDescending,
Self::NameDescending => Self::NameAscending,
Self::ModifiedDescending => Self::ModifiedAscending,
Self::ModifiedAscending => Self::ModifiedDescending,
Self::CountDescending => Self::CountAscending,
Self::CountAscending => Self::CountDescending,
}
}
pub fn short_label(&self) -> &'static str {
match self {
Self::SizeDescending => "SZ↓",
Self::SizeAscending => "SZ↑",
Self::NameAscending => "NM↑",
Self::NameDescending => "NM↓",
Self::ModifiedDescending => "DT↓",
Self::ModifiedAscending => "DT↑",
Self::CountDescending => "CT↓",
Self::CountAscending => "CT↑",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Display, EnumIter, FromRepr)]
pub enum View {
#[default]
Explorer,
Duplicates,
Age,
Errors,
Treemap,
}
impl View {
pub fn next(self) -> Self {
let current = self as usize;
let next = (current + 1) % Self::iter().count();
Self::from_repr(next).unwrap_or_default()
}
pub fn prev(self) -> Self {
let current = self as usize;
let count = Self::iter().count();
let prev = (current + count - 1) % count;
Self::from_repr(prev).unwrap_or_default()
}
}
#[derive(Debug, Clone)]
pub struct DeletionProgress {
pub total: usize,
pub deleted: usize,
pub failed: usize,
pub bytes_freed: u64,
pub current: Option<PathBuf>,
}
impl DeletionProgress {
#[cfg(test)]
pub fn new(total: usize) -> Self {
Self {
total,
deleted: 0,
failed: 0,
bytes_freed: 0,
current: None,
}
}
pub fn percentage(&self) -> u16 {
if self.total > 0 {
((self.deleted + self.failed) as f64 / self.total as f64 * 100.0) as u16
} else {
0
}
}
}
pub enum ScanResult {
Progress(ScanProgress),
ScanComplete(Result<FileTree, gravityfile_scan::ScanError>),
AnalysisComplete {
duplicates: DuplicateReport,
age_report: AgeReport,
duplicate_scope: (PathBuf, Vec<(usize, bool)>),
},
AnalysisFailed(String),
AnalysisCancelled,
DeletionProgress(DeletionProgress),
DeletionComplete {
mode: gravityfile_ops::DeleteMode,
deleted: Vec<PathBuf>,
failed: usize,
bytes_freed: u64,
errors: Vec<gravityfile_ops::OperationError>,
},
OperationProgress(OperationProgress),
OperationConflict(ConflictRequest),
TakeComplete {
path: PathBuf,
result: Result<bool, String>,
},
BulkRenameComplete(Result<Vec<(std::path::PathBuf, std::path::PathBuf)>, String>),
OperationComplete {
cancelled: bool,
operation_type: OperationType,
succeeded: usize,
failed: usize,
skipped: usize,
bytes_processed: u64,
errors: Vec<gravityfile_ops::OperationError>,
undo_info: Option<gravityfile_ops::UndoableOperation>,
undo_remaining: Option<gravityfile_ops::UndoEntry>,
touched: Vec<PathBuf>,
},
}
pub struct Refreshed {
pub path: PathBuf,
pub entry: Result<Option<gravityfile_scan::ScannedEntry>, String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ListingFor {
Children,
Parent,
}
pub struct Listed {
pub dir: PathBuf,
pub purpose: ListingFor,
pub result: Result<FileTree, String>,
}
#[derive(Debug, Clone)]
pub struct SelectedInfo {
pub name: String,
pub path: PathBuf,
pub size: u64,
pub file_count: u64,
pub dir_count: u64,
pub modified: std::time::SystemTime,
pub is_dir: bool,
}
#[derive(Debug, Clone, Default)]
pub struct DuplicatesViewState {
pub selected_group: usize,
pub expanded_groups: std::collections::HashSet<usize>,
pub selected_in_group: std::collections::HashMap<usize, usize>,
}
impl DuplicatesViewState {
pub fn new() -> Self {
Self::default()
}
pub fn toggle_expand(&mut self) {
if self.expanded_groups.contains(&self.selected_group) {
self.expanded_groups.remove(&self.selected_group);
self.selected_in_group.remove(&self.selected_group);
} else {
self.expanded_groups.insert(self.selected_group);
self.selected_in_group.insert(self.selected_group, 0);
}
}
pub fn is_expanded(&self, group_index: usize) -> bool {
self.expanded_groups.contains(&group_index)
}
pub fn selected_item(&self, group_index: usize) -> usize {
self.selected_in_group
.get(&group_index)
.copied()
.unwrap_or(0)
}
pub fn move_up(&mut self, _group_count: usize, get_file_count: impl Fn(usize) -> usize) {
if self.is_expanded(self.selected_group) {
let selected = self.selected_item(self.selected_group);
if selected > 0 {
self.selected_in_group
.insert(self.selected_group, selected - 1);
return;
}
}
if self.selected_group > 0 {
self.selected_group -= 1;
if self.is_expanded(self.selected_group) {
let file_count = get_file_count(self.selected_group);
self.selected_in_group
.insert(self.selected_group, file_count);
}
}
}
pub fn move_down(&mut self, group_count: usize, get_file_count: impl Fn(usize) -> usize) {
if self.is_expanded(self.selected_group) {
let file_count = get_file_count(self.selected_group);
let selected = self.selected_item(self.selected_group);
if selected < file_count {
self.selected_in_group
.insert(self.selected_group, selected + 1);
return;
}
}
if self.selected_group < group_count.saturating_sub(1) {
self.selected_group += 1;
if self.is_expanded(self.selected_group) {
self.selected_in_group.insert(self.selected_group, 0);
}
}
}
pub fn reset(&mut self) {
*self = Self::default();
}
pub fn selected_file_path<'a>(
&self,
groups: &'a [&gravityfile_analyze::DuplicateGroup],
) -> Option<&'a PathBuf> {
let group = groups.get(self.selected_group)?;
let selected = self.selected_item(self.selected_group);
if selected == 0 || !self.is_expanded(self.selected_group) {
None } else {
group.paths.get(selected - 1)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Visit {
pub dir: PathBuf,
pub selected: Option<PathBuf>,
pub expanded: HashSet<PathBuf>,
}
#[derive(Debug, Clone, Default)]
pub struct Navigation {
pub view_root: PathBuf,
pub tree_state: TreeState,
pub miller_state: MillerState,
pub treemap_state: TreemapState,
pub back: Vec<Visit>,
pub forward: Vec<Visit>,
}
impl Navigation {
pub fn new(view_root: PathBuf) -> Self {
Self {
tree_state: TreeState::new(view_root.clone()),
view_root,
..Self::default()
}
}
pub fn label(&self) -> String {
self.view_root
.file_name()
.unwrap_or(self.view_root.as_os_str())
.to_string_lossy()
.into_owned()
}
}
#[derive(Debug)]
pub struct TabManager {
stashed: Vec<Navigation>,
active: usize,
max_tabs: usize,
}
impl TabManager {
pub fn new(max_tabs: usize) -> Self {
Self {
stashed: vec![Navigation::default()],
active: 0,
max_tabs,
}
}
pub fn len(&self) -> usize {
self.stashed.len()
}
pub fn active_index(&self) -> usize {
self.active
}
pub fn labels(&self, current: &Navigation) -> Vec<String> {
(0..self.stashed.len())
.map(|index| {
if index == self.active {
current.label()
} else {
self.stashed[index].label()
}
})
.collect()
}
pub fn open(&mut self, current: &mut Navigation, navigation: Navigation) -> bool {
if self.stashed.len() >= self.max_tabs {
return false;
}
self.stashed.push(navigation);
self.switch(current, self.stashed.len() - 1)
}
pub fn close_active(&mut self, current: &mut Navigation) -> bool {
if self.stashed.len() <= 1 {
return false;
}
self.stashed.remove(self.active);
self.active = self.active.min(self.stashed.len() - 1);
std::mem::swap(current, &mut self.stashed[self.active]);
true
}
pub fn switch(&mut self, current: &mut Navigation, index: usize) -> bool {
if index == self.active || index >= self.stashed.len() {
return false;
}
std::mem::swap(current, &mut self.stashed[self.active]);
self.active = index;
std::mem::swap(current, &mut self.stashed[self.active]);
true
}
pub fn next(&mut self, current: &mut Navigation) -> bool {
self.switch(current, (self.active + 1) % self.stashed.len())
}
pub fn prev(&mut self, current: &mut Navigation) -> bool {
let count = self.stashed.len();
self.switch(current, (self.active + count - 1) % count)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Bookmarks {
pub marks: HashMap<char, PathBuf>,
}
impl Bookmarks {
pub fn with_defaults() -> Self {
use gravityfile_core::paths;
let mut marks = HashMap::new();
marks.insert('r', PathBuf::from("/"));
if let Some(config) = paths::config_root() {
marks.insert('c', config);
}
if let Some(home) = paths::home() {
for (key, name) in [('d', "Downloads"), ('o', "Documents")] {
let directory = home.join(name);
if directory.is_dir() {
marks.insert(key, directory);
}
}
marks.insert('h', home);
}
Self { marks }
}
pub fn set(&mut self, key: char, path: PathBuf) {
self.marks.insert(key, path);
}
pub fn get(&self, key: char) -> Option<&PathBuf> {
self.marks.get(&key)
}
pub fn remove(&mut self, key: char) -> Option<PathBuf> {
self.marks.remove(&key)
}
pub fn sorted_entries(&self) -> Vec<(char, &PathBuf)> {
let mut entries: Vec<_> = self.marks.iter().map(|(k, v)| (*k, v)).collect();
entries.sort_by_key(|(k, _)| *k);
entries
}
pub fn is_valid_key(key: char) -> bool {
key.is_ascii_alphanumeric()
}
}
#[derive(Debug, Clone, Default)]
pub struct BookmarkListState {
pub selected: usize,
entries_count: usize,
}
impl BookmarkListState {
pub fn new(bookmarks: &Bookmarks) -> Self {
Self {
selected: 0,
entries_count: bookmarks.marks.len(),
}
}
pub fn update_count(&mut self, bookmarks: &Bookmarks) {
self.entries_count = bookmarks.marks.len();
if self.selected >= self.entries_count && self.entries_count > 0 {
self.selected = self.entries_count - 1;
}
}
pub fn move_up(&mut self) {
if self.selected > 0 {
self.selected -= 1;
}
}
pub fn move_down(&mut self) {
if self.entries_count > 0 && self.selected < self.entries_count - 1 {
self.selected += 1;
}
}
}
#[derive(Debug, Clone)]
pub struct RenameEntry {
pub original: PathBuf,
pub new_name: String,
}
#[derive(Debug, Clone, Default)]
pub struct BulkRenameState {
pub entries: Vec<RenameEntry>,
pub selected: usize,
pub error: Option<String>,
}
impl BulkRenameState {
pub fn new(original_paths: Vec<PathBuf>, new_names: Vec<String>) -> Self {
let entries: Vec<RenameEntry> = original_paths
.into_iter()
.zip(new_names)
.filter_map(|(original, new_name)| {
let old_name = original.file_name()?.to_string_lossy().to_string();
if old_name != new_name && !new_name.is_empty() {
Some(RenameEntry { original, new_name })
} else {
None
}
})
.collect();
Self {
entries,
selected: 0,
error: None,
}
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn move_up(&mut self) {
if self.selected > 0 {
self.selected -= 1;
}
}
pub fn move_down(&mut self) {
if !self.entries.is_empty() && self.selected < self.entries.len() - 1 {
self.selected += 1;
}
}
pub fn validate(&mut self) -> bool {
let mut seen_names: std::collections::HashSet<String> = std::collections::HashSet::new();
for entry in &self.entries {
if seen_names.contains(&entry.new_name) {
self.error = Some(format!("Duplicate name: {}", entry.new_name));
return false;
}
seen_names.insert(entry.new_name.clone());
if let Err(error) = gravityfile_ops::validate_filename(&entry.new_name) {
self.error = Some(format!("{}: {error}", entry.new_name));
return false;
}
}
self.error = None;
true
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct UserSettings {
#[serde(default)]
pub max_scan_entries: Option<u64>,
pub scan_on_startup: bool,
pub show_hidden: bool,
pub default_layout: LayoutMode,
pub openers: FileOpeners,
#[serde(default)]
pub editor: opensesame::EditorConfig,
#[serde(default = "Bookmarks::with_defaults")]
pub bookmarks: Bookmarks,
#[serde(default = "default_true")]
pub image_previews: bool,
#[serde(default = "default_theme")]
pub theme: String,
#[serde(default = "default_true")]
pub record_history: bool,
}
fn default_true() -> bool {
true
}
fn default_theme() -> String {
crate::theme::Palette::GESTALT.name.to_string()
}
impl Default for UserSettings {
fn default() -> Self {
Self {
max_scan_entries: None,
scan_on_startup: true, show_hidden: false,
default_layout: LayoutMode::Tree,
openers: FileOpeners::default(),
editor: opensesame::EditorConfig::default(),
bookmarks: Bookmarks::with_defaults(),
image_previews: true,
theme: default_theme(),
record_history: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct FileOpeners {
pub md: String,
pub text: String,
pub default: String,
}
impl Default for FileOpeners {
fn default() -> Self {
Self {
md: "treemd".to_string(),
text: "editor".to_string(),
default: "open".to_string(),
}
}
}
impl UserSettings {
pub fn config_path() -> Option<PathBuf> {
if cfg!(test) {
return None;
}
gravityfile_core::paths::Dirs::discover().map(|dirs| dirs.settings_file())
}
pub fn try_load() -> std::io::Result<Self> {
let Some(path) = Self::config_path() else {
return Ok(Self::default());
};
Self::load_from(&path).map_err(|error| {
std::io::Error::new(error.kind(), format!("{}: {error}", path.display()))
})
}
fn load_from(path: &std::path::Path) -> std::io::Result<Self> {
let Some(content) = crate::config::read(path)? else {
return Ok(Self::default());
};
toml::from_str(&content)
.map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))
}
pub fn save(&self) -> std::io::Result<()> {
let path = Self::config_path().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::NotFound, "No config directory")
})?;
self.save_to(&path)
}
fn save_to(&self, path: &std::path::Path) -> std::io::Result<()> {
self.save_to_with_directory_sync(path, sync_settings_directory)
}
fn save_to_with_directory_sync(
&self,
path: &std::path::Path,
sync_directory: impl FnOnce(&std::path::Path) -> std::io::Result<()>,
) -> std::io::Result<()> {
use std::io::Write;
let content = toml::to_string_pretty(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
if content.len() as u64 > crate::config::MAX_BYTES {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"settings exceed 1 MiB",
));
}
let parent = path
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or(std::path::Path::new("."));
std::fs::create_dir_all(parent)?;
let mut staged = tempfile::Builder::new()
.prefix(".gravityfile-settings-")
.tempfile_in(parent)?;
staged.write_all(content.as_bytes())?;
staged.as_file().sync_all()?;
staged.persist(path).map_err(|error| error.error)?;
sync_directory(parent).map_err(|error| {
std::io::Error::new(error.kind(), format!(
"Settings installed, but directory sync failed; durability is uncertain: {error}"
))
})?;
Ok(())
}
}
fn sync_settings_directory(path: &std::path::Path) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_DIRECTORY | libc::O_NONBLOCK)
.open(path)?
.sync_all()
}
#[cfg(not(unix))]
{
let _ = path;
Ok(())
}
}
#[derive(Debug, Clone, Default)]
pub struct SettingsState {
pub selected: usize,
pub settings: UserSettings,
pub dirty: bool,
}
impl SettingsState {
pub fn new(settings: UserSettings) -> Self {
Self {
selected: 0,
settings,
dirty: false,
}
}
pub fn item_count(&self) -> usize {
4 }
pub fn move_up(&mut self) {
if self.selected > 0 {
self.selected -= 1;
}
}
pub fn move_down(&mut self) {
if self.selected < self.item_count() - 1 {
self.selected += 1;
}
}
pub fn toggle_selected(&mut self) {
match self.selected {
0 => {
self.settings.scan_on_startup = !self.settings.scan_on_startup;
self.dirty = true;
}
1 => {
self.settings.show_hidden = !self.settings.show_hidden;
self.dirty = true;
}
2 => {
self.settings.default_layout = self.settings.default_layout.toggle();
self.dirty = true;
}
3 => {
let palettes = crate::theme::Palette::ALL;
let next = palettes
.iter()
.position(|palette| palette.name == self.settings.theme)
.map_or(0, |index| (index + 1) % palettes.len());
self.settings.theme = palettes[next].name.to_string();
self.dirty = true;
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn saved_entry_budget_roundtrips_without_changing_legacy_defaults() {
use super::UserSettings;
assert_eq!(
toml::from_str::<UserSettings>("").unwrap().max_scan_entries,
None
);
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("settings.toml");
for limit in [0, 1, 100_000] {
let settings = UserSettings {
max_scan_entries: Some(limit),
..UserSettings::default()
};
settings.save_to(&path).unwrap();
assert_eq!(
UserSettings::load_from(&path).unwrap().max_scan_entries,
Some(limit)
);
}
assert!(toml::from_str::<UserSettings>("max_scan_entries = -1").is_err());
}
#[test]
fn tab_labels_fit_terminal_columns_without_unicode_panics() {
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
for label in [
"",
"documents",
"éééééé",
"日本語資料",
"🗂️📁📂",
"👩💻🇺🇸1️⃣",
"e\u{301}e\u{301}",
] {
for width in 0..=32 {
for slice in [
crate::ui::truncate_to_width(label, width),
crate::ui::truncate_left_to_width(label, width),
] {
assert!(slice.width() <= width);
let boundaries: Vec<_> = label
.grapheme_indices(true)
.map(|(index, _)| index)
.chain(std::iter::once(label.len()))
.collect();
let start = slice.as_ptr() as usize - label.as_ptr() as usize;
assert!(boundaries.contains(&start));
assert!(boundaries.contains(&(start + slice.len())));
}
assert!(crate::ui::fit_left_to_width(label, width).width() <= width);
let shortened = crate::ui::fit_to_width(label, width);
assert!(shortened.width() <= width, "{label:?}, width {width}");
if label.width() <= width {
assert_eq!(shortened, label);
}
if width == 0 {
assert!(shortened.is_empty());
}
}
}
}
use super::*;
#[test]
fn settings_load_missing_defaults_and_existing_values() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("settings.toml");
assert!(UserSettings::load_from(&path).unwrap().scan_on_startup);
std::fs::write(&path, "scan_on_startup = false\nimage_previews = false\n").unwrap();
let settings = UserSettings::load_from(&path).unwrap();
assert!(!settings.scan_on_startup);
assert!(!settings.image_previews);
}
#[test]
fn settings_load_rejects_invalid_and_oversized_files() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("settings.toml");
for content in [b"scan_on_startup = [".as_slice(), &[0xff]] {
std::fs::write(&path, content).unwrap();
assert_eq!(
UserSettings::load_from(&path).unwrap_err().kind(),
std::io::ErrorKind::InvalidData
);
}
let file = std::fs::File::create(&path).unwrap();
file.set_len(crate::config::MAX_BYTES + 1).unwrap();
assert_eq!(
UserSettings::load_from(&path).unwrap_err().kind(),
std::io::ErrorKind::InvalidData
);
assert!(UserSettings::load_from(directory.path()).is_err());
}
#[test]
fn settings_oversized_save_preserves_existing_file() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("settings.toml");
std::fs::write(&path, b"original").unwrap();
let settings = UserSettings {
openers: FileOpeners {
md: "x".repeat(crate::config::MAX_BYTES as usize),
..FileOpeners::default()
},
..UserSettings::default()
};
assert_eq!(
settings.save_to(&path).unwrap_err().kind(),
std::io::ErrorKind::InvalidData
);
assert_eq!(std::fs::read(&path).unwrap(), b"original");
assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1);
}
#[cfg(unix)]
#[test]
fn settings_load_accepts_regular_symlink_and_rejects_fifo() {
use std::os::unix::{ffi::OsStrExt, fs::symlink};
let directory = tempfile::tempdir().unwrap();
let target = directory.path().join("target");
let path = directory.path().join("settings.toml");
std::fs::write(&target, "image_previews = false\n").unwrap();
symlink(&target, &path).unwrap();
assert!(!UserSettings::load_from(&path).unwrap().image_previews);
let fifo = directory.path().join("fifo");
let native = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap();
assert_eq!(unsafe { libc::mkfifo(native.as_ptr(), 0o600) }, 0);
assert_eq!(
UserSettings::load_from(&fifo).unwrap_err().kind(),
std::io::ErrorKind::InvalidData
);
}
#[test]
fn settings_save_creates_parents_and_replaces_complete_document() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config/settings.toml");
UserSettings::default().save_to(&path).unwrap();
let settings = UserSettings {
scan_on_startup: false,
default_layout: LayoutMode::Miller,
..UserSettings::default()
};
settings.save_to(&path).unwrap();
let saved = std::fs::read_to_string(&path).unwrap();
assert!(saved.contains("default_layout = \"miller\""), "{saved}");
let restored: UserSettings = toml::from_str(&saved).unwrap();
assert!(!restored.scan_on_startup);
assert_eq!(restored.default_layout, LayoutMode::Miller);
assert_eq!(
std::fs::read_dir(path.parent().unwrap()).unwrap().count(),
1
);
}
#[test]
fn settings_publication_failure_preserves_destination_and_removes_staging() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("settings.toml");
std::fs::create_dir(&path).unwrap();
std::fs::write(path.join("existing"), b"preserve me").unwrap();
assert!(UserSettings::default().save_to(&path).is_err());
assert_eq!(
std::fs::read(path.join("existing")).unwrap(),
b"preserve me"
);
assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1);
}
#[test]
fn settings_directory_sync_failure_reports_installed_document_and_allows_retry() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("settings.toml");
UserSettings::default().save_to(&path).unwrap();
let settings = UserSettings {
scan_on_startup: false,
..UserSettings::default()
};
let error = settings
.save_to_with_directory_sync(&path, |parent| {
assert_eq!(parent, directory.path());
assert!(!UserSettings::load_from(&path).unwrap().scan_on_startup);
Err(std::io::Error::other("injected sync failure"))
})
.unwrap_err();
assert!(error.to_string().contains("Settings installed"));
assert!(error.to_string().contains("durability is uncertain"));
assert!(!UserSettings::load_from(&path).unwrap().scan_on_startup);
assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1);
settings.save_to(&path).unwrap();
assert!(!UserSettings::load_from(&path).unwrap().scan_on_startup);
}
#[cfg(unix)]
#[test]
fn settings_directory_sync_rejects_regular_files() {
let directory = tempfile::tempdir().unwrap();
let file = directory.path().join("file");
std::fs::write(&file, b"keep").unwrap();
assert!(sync_settings_directory(&file).is_err());
sync_settings_directory(directory.path()).unwrap();
assert_eq!(std::fs::read(file).unwrap(), b"keep");
}
#[cfg(unix)]
#[test]
fn settings_save_replaces_leaf_symlink_with_private_file() {
use std::os::unix::fs::{PermissionsExt, symlink};
let directory = tempfile::tempdir().unwrap();
let target = directory.path().join("target");
let path = directory.path().join("settings.toml");
std::fs::write(&target, b"untouched").unwrap();
symlink(&target, &path).unwrap();
UserSettings::default().save_to(&path).unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"untouched");
let metadata = std::fs::symlink_metadata(&path).unwrap();
assert!(metadata.is_file());
assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
}
#[test]
fn test_visual_state_creation() {
let state = VisualState::new(5);
assert_eq!(state.anchor, 5);
assert_eq!(state.cursor, 5);
}
#[test]
fn test_visual_state_selection_range() {
let mut state = VisualState::new(5);
assert_eq!(state.selection_range(), (5, 5));
state.cursor = 8;
assert_eq!(state.selection_range(), (5, 8));
state.cursor = 2;
assert_eq!(state.selection_range(), (2, 5));
}
#[test]
fn test_visual_state_move_down_bounds() {
let mut state = VisualState::new(0);
state.move_down(0);
assert_eq!(state.cursor, 0);
state.move_down(1);
assert_eq!(state.cursor, 0);
state.move_down(5);
assert_eq!(state.cursor, 1);
state.cursor = 4;
state.move_down(5);
assert_eq!(state.cursor, 4); }
#[test]
fn test_visual_state_move_up() {
let mut state = VisualState::new(5);
state.move_up();
assert_eq!(state.cursor, 4);
state.cursor = 0;
state.move_up();
assert_eq!(state.cursor, 0); }
#[test]
fn test_visual_state_page_up() {
let mut state = VisualState::new(10);
state.page_up(5);
assert_eq!(state.cursor, 5);
state.page_up(10);
assert_eq!(state.cursor, 0); }
#[test]
fn test_visual_state_page_down_bounds() {
let mut state = VisualState::new(0);
state.page_down(5, 0);
assert_eq!(state.cursor, 0);
state.page_down(5, 10);
assert_eq!(state.cursor, 5);
state.page_down(10, 10);
assert_eq!(state.cursor, 9); }
#[test]
fn test_visual_state_jump_to_top() {
let mut state = VisualState::new(50);
state.jump_to_top();
assert_eq!(state.cursor, 0);
}
#[test]
fn test_visual_state_jump_to_bottom_bounds() {
let mut state = VisualState::new(0);
state.jump_to_bottom(0);
assert_eq!(state.cursor, 0);
state.jump_to_bottom(10);
assert_eq!(state.cursor, 9);
}
}