Skip to main content

dotr_dear/
utils.rs

1use std::{
2    collections::HashMap,
3    path::{Path, PathBuf},
4};
5
6pub const BACKUP_EXT: &str = "dotrbak";
7pub const SYMLINK_FOLDER: &str = "deployed";
8
9/// Resolve a path string to an absolute PathBuf
10/// - If the path starts with '/', it's treated as an absolute path
11/// - If the path starts with '~', it's treated as relative to the home directory
12/// - Otherwise, it's treated as relative to the current working directory (cwd)
13pub fn resolve_path(path: &str, cwd: &Path) -> anyhow::Result<PathBuf> {
14    if path.starts_with('/') {
15        Ok(PathBuf::from(path))
16    } else if path.starts_with("~") {
17        let home_dir = std::env::var("HOME")
18            .map(PathBuf::from)
19            .map_err(|_| anyhow::anyhow!("HOME environment variable not set"))?;
20        let p = path.splitn(2, '/').collect::<Vec<&str>>();
21        Ok(home_dir.join(p[1..].join("/")))
22    } else {
23        let p = cwd.join(path);
24        Ok(std::path::absolute(&p)?)
25    }
26}
27
28/// Convert an absolute path to use ~ notation if it's in the home directory
29/// - If the path is within the home directory, converts it to ~/...
30/// - Otherwise, returns the original path as a string
31pub fn normalize_home_path(path: &str) -> String {
32    if path.starts_with('~') {
33        return path.to_string();
34    }
35
36    if let Ok(home_dir) = std::env::var("HOME").map(PathBuf::from) {
37        let home_str = home_dir.to_string_lossy();
38
39        if path == home_str.as_ref() {
40            return "~".to_string();
41        }
42
43        let home_with_slash = format!("{}/", home_str);
44        if path.starts_with(&home_with_slash) {
45            let relative = &path[home_str.len()..];
46            return format!("~{}", relative);
47        }
48    }
49
50    path.to_string()
51}
52
53pub const COLOR_WARNING: &str = "\x1b[33m"; // Yellow
54pub const COLOR_ERROR: &str = "\x1b[31m"; // Red
55pub const COLOR_INFO: &str = "\x1b[34m"; // Blue
56pub const COLOR_FATAL: &str = "\x1b[35m"; // Magenta
57pub const RESET_COLOR: &str = "\x1b[0m"; // Reset
58
59pub enum LogLevel {
60    Warning,
61    Error,
62    Info,
63    Fatal,
64}
65
66impl LogLevel {
67    pub fn as_str(&self) -> &str {
68        match self {
69            LogLevel::Warning => "WARNING",
70            LogLevel::Error => "ERROR",
71            LogLevel::Info => "INFO",
72            LogLevel::Fatal => "FATAL",
73        }
74    }
75
76    pub fn to_colorful_str(&self) -> String {
77        match self {
78            LogLevel::Warning => format!("{}[{}]{}", COLOR_WARNING, self.as_str(), RESET_COLOR),
79            LogLevel::Error => format!("{}[{}]{}", COLOR_ERROR, self.as_str(), RESET_COLOR),
80            LogLevel::Info => format!("{}[{}]{}", COLOR_INFO, self.as_str(), RESET_COLOR),
81            LogLevel::Fatal => format!("{}[{}]{}", COLOR_FATAL, self.as_str(), RESET_COLOR),
82        }
83    }
84}
85
86pub fn cprintln(message: &str, level: &LogLevel) {
87    match level {
88        LogLevel::Error | LogLevel::Fatal => {
89            eprintln!("{} {}", level.to_colorful_str(), message);
90        }
91        LogLevel::Warning | LogLevel::Info => {
92            println!("{} {}", level.to_colorful_str(), message);
93        }
94    }
95}
96
97pub fn get_string_from_value(v: Option<&toml::Value>, field_name: &str) -> anyhow::Result<String> {
98    Ok(
99        v.ok_or_else(|| anyhow::anyhow!("'{}' is required", field_name))?
100            .as_str()
101            .ok_or_else(|| anyhow::anyhow!("'{}' must be a string", field_name))?
102            .to_string(),
103    )
104}
105
106pub fn get_string_hashmap_from_value(
107    v: Option<&toml::Value>,
108) -> anyhow::Result<HashMap<String, String>> {
109    match v {
110        Some(value) => value
111            .as_table()
112            .ok_or_else(|| anyhow::anyhow!("field must be a table"))?
113            .iter()
114            .map(|(key, value)| {
115                let s = value
116                    .as_str()
117                    .ok_or_else(|| anyhow::anyhow!("table values must be strings"))?;
118                Ok((key.clone(), s.to_string()))
119            })
120            .collect::<Result<HashMap<_, _>, _>>(),
121        None => Ok(HashMap::new()),
122    }
123}
124
125pub fn get_vec_string_from_value(v: Option<&toml::Value>) -> anyhow::Result<Vec<String>> {
126    match v {
127        Some(block) => block
128            .as_array()
129            .ok_or_else(|| anyhow::anyhow!("field must be an array"))?
130            .iter()
131            .map(|v| {
132                v.as_str()
133                    .ok_or_else(|| anyhow::anyhow!("array elements must be strings"))
134                    .map(|s| s.to_string())
135            })
136            .collect::<Result<Vec<_>, _>>(),
137        None => Ok(Vec::new()),
138    }
139}
140
141pub fn is_empty_table(t: &toml::Table) -> bool {
142    t.is_empty()
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use std::path::PathBuf;
149
150    #[test]
151    fn test_resolve_path_absolute() {
152        let cwd = PathBuf::from("/some/cwd");
153        let path = "/absolute/path";
154        let resolved = resolve_path(path, &cwd).expect("Failed to resolve path");
155        assert_eq!(resolved, PathBuf::from("/absolute/path"));
156    }
157
158    #[test]
159    fn test_resolve_path_with_tilde() {
160        let cwd = PathBuf::from("/some/cwd");
161        let home = std::env::home_dir().expect("Failed to get home directory");
162
163        // Test ~/subdir
164        let path = "~/Documents";
165        let resolved = resolve_path(path, &cwd).unwrap();
166        assert_eq!(resolved, home.join("Documents"));
167
168        // Test just ~
169        let path = "~";
170        let resolved = resolve_path(path, &cwd).unwrap();
171        assert_eq!(resolved, home);
172    }
173
174    #[test]
175    fn test_resolve_path_relative() {
176        let cwd = PathBuf::from("/some/cwd");
177        let path = "relative/path";
178        let resolved = resolve_path(path, &cwd).unwrap();
179
180        // Should be absolute path based on cwd
181        assert!(resolved.is_absolute());
182        assert!(resolved.ends_with("relative/path"));
183    }
184
185    #[test]
186    fn test_resolve_path_dot_relative() {
187        let cwd = PathBuf::from("/some/cwd");
188        let path = "./file.txt";
189        let resolved = resolve_path(path, &cwd).unwrap();
190
191        assert!(resolved.is_absolute());
192        assert!(resolved.ends_with("file.txt"));
193    }
194
195    #[test]
196    fn test_resolve_path_parent_relative() {
197        let cwd = PathBuf::from("/some/cwd/subdir");
198        let path = "../file.txt";
199        let resolved = resolve_path(path, &cwd).unwrap();
200
201        assert!(resolved.is_absolute());
202    }
203
204    #[test]
205    fn test_normalize_home_path_already_normalized() {
206        let path = "~/.config/nvim";
207        let normalized = normalize_home_path(path);
208        assert_eq!(normalized, "~/.config/nvim");
209    }
210
211    #[test]
212    fn test_normalize_home_path_in_home_directory() {
213        let home = std::env::home_dir().expect("Failed to get home directory");
214        let home_str = home.to_string_lossy();
215
216        // Test a path in home directory
217        let path = format!("{}/.config/nvim", home_str);
218        let normalized = normalize_home_path(&path);
219        assert_eq!(normalized, "~/.config/nvim");
220    }
221
222    #[test]
223    fn test_normalize_home_path_home_root() {
224        let home = std::env::home_dir().expect("Failed to get home directory");
225        let home_str = home.to_string_lossy().to_string();
226
227        // Test the home directory itself
228        let normalized = normalize_home_path(&home_str);
229        assert_eq!(normalized, "~");
230    }
231
232    #[test]
233    fn test_normalize_home_path_outside_home() {
234        let path = "/etc/config";
235        let normalized = normalize_home_path(path);
236        assert_eq!(normalized, "/etc/config");
237
238        let path = "/tmp/test";
239        let normalized = normalize_home_path(path);
240        assert_eq!(normalized, "/tmp/test");
241    }
242
243    #[test]
244    fn test_normalize_home_path_with_trailing_slash() {
245        let home = std::env::home_dir().expect("Failed to get home directory");
246        let home_str = home.to_string_lossy();
247
248        let path = format!("{}/.config/", home_str);
249        let normalized = normalize_home_path(&path);
250        assert_eq!(normalized, "~/.config/");
251    }
252
253    #[test]
254    fn test_normalize_home_path_deep_nested() {
255        let home = std::env::home_dir().expect("Failed to get home directory");
256        let home_str = home.to_string_lossy();
257
258        let path = format!("{}/a/b/c/d/e/f", home_str);
259        let normalized = normalize_home_path(&path);
260        assert_eq!(normalized, "~/a/b/c/d/e/f");
261    }
262
263    #[test]
264    fn test_backup_ext_constant() {
265        assert_eq!(BACKUP_EXT, "dotrbak");
266    }
267
268    #[test]
269    fn test_resolve_path_empty_relative() {
270        let cwd = PathBuf::from("/some/cwd");
271        let path = "";
272        let resolved = resolve_path(path, &cwd).unwrap();
273
274        assert!(resolved.is_absolute());
275    }
276
277    #[test]
278    fn test_normalize_home_path_similar_prefix() {
279        // Test that paths that start with home-like prefix but aren't in home work
280        let home = std::env::home_dir().expect("Failed to get home directory");
281        let home_str = home.to_string_lossy();
282
283        // Create a path that has home as substring but isn't actually in home
284        let fake_path = format!("{}_fake/config", home_str);
285        let normalized = normalize_home_path(&fake_path);
286        // Should not be normalized since it's not actually in home
287        assert_eq!(normalized, fake_path);
288    }
289
290    #[test]
291    fn test_resolve_and_normalize_round_trip() {
292        let cwd = PathBuf::from("/some/cwd");
293        let home = std::env::home_dir().expect("Failed to get home directory");
294
295        // Start with tilde path
296        let original = "~/.bashrc";
297
298        // Resolve it
299        let resolved = resolve_path(original, &cwd).unwrap();
300        assert_eq!(resolved, home.join(".bashrc"));
301
302        // Normalize it back
303        let normalized = normalize_home_path(resolved.to_string_lossy().as_ref());
304        assert_eq!(normalized, original);
305    }
306
307    #[test]
308    fn test_normalize_home_path_with_spaces() {
309        let home = std::env::home_dir().expect("Failed to get home directory");
310        let home_str = home.to_string_lossy();
311
312        let path = format!("{}/My Documents/file.txt", home_str);
313        let normalized = normalize_home_path(&path);
314        assert_eq!(normalized, "~/My Documents/file.txt");
315    }
316
317    #[test]
318    fn test_normalize_home_path_with_dots() {
319        let home = std::env::home_dir().expect("Failed to get home directory");
320        let home_str = home.to_string_lossy();
321
322        let path = format!("{}/.config/.hidden/..dotfile", home_str);
323        let normalized = normalize_home_path(&path);
324        assert_eq!(normalized, "~/.config/.hidden/..dotfile");
325    }
326}