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}