Skip to main content

fsearch/
config.rs

1// File: src\config.rs
2// Author: Hadi Cahyadi <cumulus13@gmail.com>
3// Date: 2026-05-11
4// Description:
5// License: MIT
6
7//! Configuration loading and persistence for `fsearch`.
8//!
9//! Priority (highest first):
10//! 1. `./fsearch.toml`
11//! 2. `~/.config/fsearch/config.toml`
12//! 3. Built-in defaults
13
14use crate::error::{FsearchError, FsearchResult};
15use serde::{Deserialize, Serialize};
16use std::path::PathBuf;
17
18/// Complete fsearch configuration.
19///
20/// All fields are optional at the TOML level — missing keys fall back to
21/// the values in [`Config::default`].
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(default)]
24pub struct Config {
25    // ── Search ────────────────────────────────────────────────────────────────
26    /// Default max depth for `-d` flag.
27    pub default_depth: u32,
28    /// 1 = walkdir+rayon, 2 = recursive.
29    pub default_method: u8,
30    /// Match case-insensitively by default.
31    pub case_insensitive: bool,
32    /// Include directory entries in filename-search results.
33    pub include_dirs: bool,
34    /// Bytes to probe when checking for binary content.
35    pub binary_check_bytes: usize,
36    /// Lines longer than this are skipped in content search.
37    pub max_line_length: usize,
38    /// Rayon thread count (0 = all logical CPUs).
39    pub threads: usize,
40
41    // ── Duplicate detection ───────────────────────────────────────────────────
42    /// Hashing algorithm: `"md5"` or `"sha256"`.
43    pub hash_algorithm: String,
44    /// Buffer size (bytes) used when streaming files for hashing.
45    pub hash_buffer_size: usize,
46    /// Minimum file size (bytes) to consider for duplicate detection (0 = all).
47    pub dup_min_size: u64,
48    /// Maximum file size (bytes) to consider (0 = unlimited).
49    pub dup_max_size: u64,
50
51    // ── Output ────────────────────────────────────────────────────────────────
52    /// Print verbose status to stderr.
53    pub verbose: bool,
54    /// Show file sizes next to results.
55    pub show_size: bool,
56    /// Show last-modified timestamps next to results.
57    pub show_modified: bool,
58    /// Maximum results to return (0 = unlimited).
59    pub max_results: usize,
60
61    // ── Colours ───────────────────────────────────────────────────────────────
62    pub color_index: String,
63    pub color_path: String,
64    pub color_line_num: String,
65    pub color_line_text: String,
66    pub color_header: String,
67    pub color_count: String,
68    pub color_error: String,
69    pub color_warn: String,
70    pub color_info: String,
71    pub color_pattern: String,
72    pub color_dup_group: String,
73    pub color_dup_path: String,
74    pub color_dup_size: String,
75
76    // ── Filters ───────────────────────────────────────────────────────────────
77    /// Comma-separated directory names always skipped during traversal.
78    pub exclude_dirs: String,
79    /// Comma-separated glob patterns included by default (empty = all).
80    pub default_include: String,
81}
82
83impl Default for Config {
84    fn default() -> Self {
85        Self {
86            default_depth: 1,
87            default_method: 1,
88            case_insensitive: true,
89            include_dirs: true,
90            binary_check_bytes: 1024,
91            max_line_length: 10_000,
92            threads: 0,
93            hash_algorithm: "sha256".into(),
94            hash_buffer_size: 65_536, // 64 KiB
95            dup_min_size: 1,
96            dup_max_size: 0,
97            verbose: false,
98            show_size: false,
99            show_modified: false,
100            max_results: 0,
101            color_index: "#FF88FF".into(),
102            color_path: "#FFFF00".into(),
103            color_line_num: "#FF4444".into(),
104            color_line_text: "#00FFFF".into(),
105            color_header: "#FFFFFF".into(),
106            color_count: "#00FFFF".into(),
107            color_error: "#FF3333".into(),
108            color_warn: "#FFAA00".into(),
109            color_info: "#00FF88".into(),
110            color_pattern: "#FF00FF".into(),
111            color_dup_group: "#FF8800".into(),
112            color_dup_path: "#FFFF00".into(),
113            color_dup_size: "#88FF88".into(),
114            exclude_dirs: ".git,node_modules,.svn,__pycache__,.hg,target,.cache".into(),
115            default_include: "".into(),
116        }
117    }
118}
119
120impl Config {
121    /// Load config: local override → user config → defaults.
122    pub fn load() -> Self {
123        if let Ok(cfg) = Self::load_from_path(PathBuf::from("fsearch.toml")) {
124            return cfg;
125        }
126        if let Some(dir) = dirs::config_dir() {
127            if let Ok(cfg) = Self::load_from_path(dir.join("fsearch").join("config.toml")) {
128                return cfg;
129            }
130        }
131        Self::default()
132    }
133
134    /// Load from an explicit path — useful for testing and library consumers.
135    pub fn load_from_path(path: PathBuf) -> FsearchResult<Self> {
136        let text = std::fs::read_to_string(&path).map_err(|e| FsearchError::Io {
137            path: path.display().to_string(),
138            source: e,
139        })?;
140        toml::from_str(&text).map_err(|e| FsearchError::ConfigParse {
141            path: path.display().to_string(),
142            source: e,
143        })
144    }
145
146    /// Write an annotated default config file and return its path.
147    pub fn write_default() -> FsearchResult<PathBuf> {
148        let dir = dirs::config_dir()
149            .ok_or_else(|| FsearchError::Config("cannot find user config directory".into()))?
150            .join("fsearch");
151        std::fs::create_dir_all(&dir).map_err(|e| FsearchError::Io {
152            path: dir.display().to_string(),
153            source: e,
154        })?;
155        let path = dir.join("config.toml");
156        let raw = toml::to_string_pretty(&Self::default())
157            .map_err(|e| FsearchError::Config(e.to_string()))?;
158        let annotated = format!(
159            "# fsearch configuration  ({})\n\
160             # All values are defaults. Remove the leading '#' to override.\n\n",
161            path.display()
162        ) + &raw
163            .lines()
164            .map(|l| {
165                if l.starts_with('[') || l.is_empty() {
166                    l.to_string()
167                } else {
168                    format!("# {l}")
169                }
170            })
171            .collect::<Vec<_>>()
172            .join("\n");
173        std::fs::write(&path, annotated).map_err(|e| FsearchError::Io {
174            path: path.display().to_string(),
175            source: e,
176        })?;
177        Ok(path)
178    }
179
180    /// Parsed list of directory-name globs to skip.
181    pub fn excluded_dirs(&self) -> Vec<String> {
182        split_csv(&self.exclude_dirs)
183    }
184}
185
186/// Split a comma-separated string, trimming whitespace and dropping empties.
187pub(crate) fn split_csv(s: &str) -> Vec<String> {
188    s.split(',')
189        .map(|p| p.trim().to_string())
190        .filter(|p| !p.is_empty())
191        .collect()
192}