dot_linker/
lib.rs

1pub mod cli;
2
3pub use cli::Args;
4pub use config::get_config_path;
5pub use ignore::IgnoreList;
6pub use link::{LinkAction, handle_link};
7pub use ui::{UIMode, prompt_user};
8
9pub mod ui {
10    use anyhow::Result;
11    use std::io::Write;
12
13    #[derive(Clone, Copy)]
14    pub enum UIMode {
15        Interactive,
16        Silent,
17    }
18    pub fn get_ui_mode(mode: bool) -> UIMode {
19        if mode {
20            UIMode::Interactive
21        } else {
22            UIMode::Silent
23        }
24    }
25
26    pub fn verbose_println(msg: &str, is_verbose: bool) {
27        if is_verbose {
28            println!("[VERBOSE] {}", msg);
29        }
30    }
31
32    pub fn prompt_user(prompt: &str, mode: UIMode) -> Result<bool> {
33        match mode {
34            UIMode::Silent => Ok(true),
35            UIMode::Interactive => {
36                print!("{} (y/n) ", prompt);
37                std::io::stdout().flush()?;
38                let mut input = String::new();
39                std::io::stdin().read_line(&mut input)?;
40                Ok(matches!(
41                    input.trim().to_lowercase().as_str(),
42                    "y" | "yes" | ""
43                ))
44            }
45        }
46    }
47}
48
49pub mod config {
50    use anyhow::{Ok, Result};
51    use std::{
52        env,
53        path::{Path, PathBuf},
54    };
55    static CONFIG_DIRECTORY: &str = "dotlinker";
56    static CONFIG_FILE: &str = "dotignore";
57
58    pub fn get_config_path() -> Result<PathBuf> {
59        Ok(match env::var("XDG_CONFIG_HOME").ok() {
60            Some(path) => PathBuf::from(path),
61            None => PathBuf::from(env::var("HOME")?).join(".config"),
62        })
63    }
64    pub fn determine_config_file(
65        config: &Option<String>,
66        curr_dir: &Path,
67        base_dir: &Path,
68        config_dir: &Path,
69    ) -> Result<PathBuf> {
70        Ok(match config {
71            Some(c) => curr_dir.join(c),
72            None => {
73                if ignore_file_exists(config_dir) {
74                    config_dir.join(CONFIG_DIRECTORY).join(CONFIG_FILE)
75                } else if ignore_file_exists(base_dir) {
76                    base_dir.join(CONFIG_DIRECTORY).join(CONFIG_FILE)
77                } else {
78                    create_default_ignore_file(config_dir)?;
79                    config_dir.join(CONFIG_DIRECTORY).join(CONFIG_FILE)
80                }
81            }
82        })
83    }
84
85    fn create_default_ignore_file(config_path: &Path) -> Result<()> {
86        let dotlinker_dir = config_path.join(CONFIG_DIRECTORY);
87        std::fs::create_dir_all(&dotlinker_dir)?;
88        std::fs::write(
89            dotlinker_dir.join(CONFIG_FILE),
90            "# This file is used to ignore files when symlinking\n.git*\nREADME.md\nLICENSE",
91        )?;
92        Ok(())
93    }
94
95    fn ignore_file_exists(config_path: &Path) -> bool {
96        config_path
97            .join(CONFIG_DIRECTORY)
98            .join(CONFIG_FILE)
99            .exists()
100    }
101}
102
103pub mod link {
104
105    use anyhow::Result;
106    use std::path::Path;
107
108    use crate::{UIMode, prompt_user};
109
110    pub enum LinkAction {
111        Link,
112        Unlink,
113    }
114
115    pub fn get_link_action(action: bool) -> LinkAction {
116        if action {
117            LinkAction::Unlink
118        } else {
119            LinkAction::Link
120        }
121    }
122
123    pub fn handle_link(
124        source: &Path,
125        target_dir: &Path,
126        action: &LinkAction,
127        simulate: bool,
128        ui_mode: UIMode,
129    ) -> Result<()> {
130        let target_path = match source.file_name() {
131            Some(file_name) => target_dir.join(file_name),
132            None => {
133                println!("skipping '{}'  filename not found", source.display());
134                return Ok(());
135            }
136        };
137
138        match action {
139            LinkAction::Link => {
140                if target_path.exists() {
141                    println!(
142                        "'{}' already exists in {}, skipping.",
143                        target_path.file_name().unwrap().to_string_lossy(),
144                        target_dir.file_name().unwrap().to_string_lossy(),
145                    );
146                    return Ok(());
147                }
148                if prompt_user(
149                    &format!("link {}", source.file_name().unwrap().to_string_lossy()),
150                    ui_mode,
151                )? {
152                    simulate_println(
153                        &format!(
154                            "linking '{}' -> '{}'",
155                            source.file_name().unwrap().to_string_lossy(),
156                            target_path.display()
157                        ),
158                        simulate,
159                    );
160                    if !simulate {
161                        std::os::unix::fs::symlink(source, target_path)?;
162                    }
163                } else {
164                    println!("skipping '{}'  user skipped", source.display());
165                }
166            }
167            LinkAction::Unlink => {
168                if !target_path.exists() {
169                    println!(
170                        "'{}' doesn't exists, skipping.",
171                        target_path.file_name().unwrap().to_string_lossy(),
172                    );
173                    return Ok(());
174                }
175                if target_path.symlink_metadata()?.file_type().is_symlink() {
176                    if prompt_user(
177                        &format!("unlink {}", source.file_name().unwrap().to_string_lossy()),
178                        ui_mode,
179                    )? {
180                        simulate_println(
181                            &format!(
182                                "unlinking '{}' <- '{}'",
183                                source.file_name().unwrap().to_string_lossy(),
184                                target_path.display()
185                            ),
186                            simulate,
187                        );
188                        if !simulate {
189                            std::fs::remove_file(target_path)?;
190                        }
191                    } else {
192                        println!("skipping '{}'  user skipped", source.display());
193                    }
194                } else {
195                    println!(
196                        "target '{}' is not a symlink, skipping.",
197                        target_path.display()
198                    );
199                }
200            }
201        }
202
203        Ok(())
204    }
205
206    fn simulate_println(msg: &str, simulate: bool) {
207        if simulate {
208            println!("[SIMULATE] {}", msg);
209        } else {
210            println!("{}", msg);
211        }
212    }
213}
214
215pub mod ignore {
216
217    use anyhow::{Result, ensure};
218    use glob::Pattern;
219    use std::{
220        collections::HashSet,
221        path::{Path, PathBuf},
222    };
223
224    pub struct IgnoreList {
225        literals: HashSet<PathBuf>,
226        patterns: Vec<Pattern>,
227    }
228    impl IgnoreList {
229        pub fn new() -> Self {
230            Self {
231                literals: HashSet::new(),
232                patterns: Vec::new(),
233            }
234        }
235        pub fn load_from_file(&mut self, file_path: &Path, base_dir: &Path) -> Result<()> {
236            ensure!(
237                file_path.exists(),
238                "config file '{}' does not exist",
239                file_path.display()
240            );
241            let contents = std::fs::read_to_string(file_path)?;
242            for line in contents.lines() {
243                let line = line.trim();
244                if line.is_empty() || line.starts_with('#') {
245                    continue;
246                }
247                let mut pattern = line.to_string();
248                if pattern.ends_with('/') {
249                    pattern = pattern.strip_suffix('/').unwrap().to_string();
250                } else if pattern.starts_with('/') {
251                    pattern = pattern.strip_prefix('/').unwrap().to_string();
252                }
253                if is_literal_pattern(&pattern) {
254                    self.literals.insert(base_dir.join(pattern));
255                } else {
256                    self.patterns.push(Pattern::new(&pattern)?);
257                }
258            }
259            Ok(())
260        }
261
262        pub fn add_literals(&mut self, paths: Option<Vec<String>>, base_dir: &Path) {
263            let paths = match paths {
264                Some(p) => p,
265                None => return,
266            };
267            for path_str in paths {
268                self.literals.insert(base_dir.join(path_str));
269            }
270        }
271
272        pub fn is_ignored(&self, path: &Path) -> bool {
273            if self.literals.contains(path) {
274                return true;
275            }
276
277            let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
278            self.patterns.iter().any(|p| p.matches(file_name))
279        }
280    }
281
282    impl Default for IgnoreList {
283        fn default() -> Self {
284            Self::new()
285        }
286    }
287
288    fn is_literal_pattern(pattern: &str) -> bool {
289        !pattern.contains(&['*', '?', '[', ']'][..])
290    }
291}