1use crate::config::{Config, ShellType};
12use std::fs;
13use std::path::{Path, PathBuf};
14
15const BASH_SCRIPT: &str = include_str!("../shell_integration/par_term_shell_integration.bash");
17const ZSH_SCRIPT: &str = include_str!("../shell_integration/par_term_shell_integration.zsh");
18const FISH_SCRIPT: &str = include_str!("../shell_integration/par_term_shell_integration.fish");
19
20const PT_DL_SCRIPT: &str = include_str!("../shell_integration/pt-dl");
22const PT_UL_SCRIPT: &str = include_str!("../shell_integration/pt-ul");
23const PT_IMGCAT_SCRIPT: &str = include_str!("../shell_integration/pt-imgcat");
24
25const MARKER_START: &str = "# >>> par-term shell integration >>>";
27const MARKER_END: &str = "# <<< par-term shell integration <<<";
28
29#[derive(Debug)]
31pub struct InstallResult {
32 pub shell: ShellType,
34 pub script_path: PathBuf,
36 pub rc_file: PathBuf,
38 pub needs_restart: bool,
40}
41
42#[derive(Debug, Default)]
44pub struct UninstallResult {
45 pub cleaned: Vec<PathBuf>,
47 pub needs_manual: Vec<PathBuf>,
49 pub scripts_removed: Vec<PathBuf>,
51}
52
53fn install_utilities() -> Result<PathBuf, String> {
58 let bin_dir = Config::shell_integration_dir().join("bin");
59 fs::create_dir_all(&bin_dir)
60 .map_err(|e| format!("Failed to create bin directory {:?}: {}", bin_dir, e))?;
61
62 let utilities: &[(&str, &str)] = &[
63 ("pt-dl", PT_DL_SCRIPT),
64 ("pt-ul", PT_UL_SCRIPT),
65 ("pt-imgcat", PT_IMGCAT_SCRIPT),
66 ];
67
68 for (name, content) in utilities {
69 let path = bin_dir.join(name);
70 fs::write(&path, content).map_err(|e| format!("Failed to write {:?}: {}", path, e))?;
71
72 #[cfg(unix)]
73 {
74 use std::os::unix::fs::PermissionsExt;
75 let perms = std::fs::Permissions::from_mode(0o755);
76 fs::set_permissions(&path, perms)
77 .map_err(|e| format!("Failed to set permissions on {:?}: {}", path, e))?;
78 }
79 }
80
81 Ok(bin_dir)
82}
83
84pub fn install(shell: Option<ShellType>) -> Result<InstallResult, String> {
93 let shell = shell.unwrap_or_else(detected_shell);
94
95 if shell == ShellType::Unknown {
96 return Err(
97 "Could not detect shell type. Please specify shell manually (bash, zsh, or fish)."
98 .to_string(),
99 );
100 }
101
102 let script_content = get_script_content(shell);
104
105 let integration_dir = Config::shell_integration_dir();
107
108 fs::create_dir_all(&integration_dir)
110 .map_err(|e| format!("Failed to create directory {:?}: {}", integration_dir, e))?;
111
112 let script_filename = format!("shell_integration.{}", shell.extension());
114 let script_path = integration_dir.join(&script_filename);
115
116 fs::write(&script_path, script_content)
117 .map_err(|e| format!("Failed to write script to {:?}: {}", script_path, e))?;
118
119 install_utilities()?;
121
122 let rc_file = get_rc_file(shell)?;
124
125 add_to_rc_file(&rc_file, shell)?;
127
128 Ok(InstallResult {
129 shell,
130 script_path,
131 rc_file,
132 needs_restart: true,
133 })
134}
135
136pub fn uninstall() -> Result<UninstallResult, String> {
144 let mut result = UninstallResult::default();
145
146 for shell in [ShellType::Bash, ShellType::Zsh, ShellType::Fish] {
148 if let Ok(rc_file) = get_rc_file(shell)
149 && rc_file.exists()
150 {
151 match remove_from_rc_file(&rc_file) {
152 Ok(true) => result.cleaned.push(rc_file),
153 Ok(false) => { }
154 Err(_) => result.needs_manual.push(rc_file),
155 }
156 }
157 }
158
159 let integration_dir = Config::shell_integration_dir();
161 for shell in [ShellType::Bash, ShellType::Zsh, ShellType::Fish] {
162 let script_filename = format!("shell_integration.{}", shell.extension());
163 let script_path = integration_dir.join(&script_filename);
164
165 if script_path.exists() && fs::remove_file(&script_path).is_ok() {
166 result.scripts_removed.push(script_path);
167 }
168 }
169
170 let bin_dir = integration_dir.join("bin");
172 if bin_dir.exists() {
173 let _ = fs::remove_dir_all(&bin_dir);
174 }
175
176 Ok(result)
177}
178
179pub fn is_installed() -> bool {
185 let shell = detected_shell();
186 if shell == ShellType::Unknown {
187 return false;
188 }
189
190 let integration_dir = Config::shell_integration_dir();
192 let script_filename = format!("shell_integration.{}", shell.extension());
193 let script_path = integration_dir.join(&script_filename);
194
195 if !script_path.exists() {
196 return false;
197 }
198
199 if let Ok(rc_file) = get_rc_file(shell)
201 && let Ok(content) = fs::read_to_string(&rc_file)
202 {
203 return content.contains(MARKER_START) && content.contains(MARKER_END);
204 }
205
206 false
207}
208
209pub fn detected_shell() -> ShellType {
211 ShellType::detect()
212}
213
214fn get_script_content(shell: ShellType) -> &'static str {
216 match shell {
217 ShellType::Bash => BASH_SCRIPT,
218 ShellType::Zsh => ZSH_SCRIPT,
219 ShellType::Fish => FISH_SCRIPT,
220 ShellType::Unknown => BASH_SCRIPT, }
222}
223
224fn get_rc_file(shell: ShellType) -> Result<PathBuf, String> {
226 let home = dirs::home_dir().ok_or("Could not determine home directory")?;
227
228 let rc_file = match shell {
229 ShellType::Bash => {
230 let bashrc = home.join(".bashrc");
232 let bash_profile = home.join(".bash_profile");
233 if bashrc.exists() {
234 bashrc
235 } else {
236 bash_profile
237 }
238 }
239 ShellType::Zsh => home.join(".zshrc"),
240 ShellType::Fish => {
241 let xdg_config = std::env::var("XDG_CONFIG_HOME")
243 .map(PathBuf::from)
244 .unwrap_or_else(|_| home.join(".config"));
245 xdg_config.join("fish").join("config.fish")
246 }
247 ShellType::Unknown => return Err("Unknown shell type".to_string()),
248 };
249
250 Ok(rc_file)
251}
252
253fn add_to_rc_file(rc_file: &Path, shell: ShellType) -> Result<(), String> {
255 let existing_content = if rc_file.exists() {
257 fs::read_to_string(rc_file).map_err(|e| format!("Failed to read {:?}: {}", rc_file, e))?
258 } else {
259 if let Some(parent) = rc_file.parent() {
261 fs::create_dir_all(parent)
262 .map_err(|e| format!("Failed to create directory {:?}: {}", parent, e))?;
263 }
264 String::new()
265 };
266
267 if existing_content.contains(MARKER_START) {
269 let cleaned = remove_marker_block(&existing_content);
271 let new_content = format!("{}\n{}", cleaned.trim_end(), generate_source_block(shell));
272 fs::write(rc_file, new_content)
273 .map_err(|e| format!("Failed to write {:?}: {}", rc_file, e))?;
274 } else {
275 let new_content = if existing_content.is_empty() {
277 generate_source_block(shell)
278 } else if existing_content.ends_with('\n') {
279 format!("{}\n{}", existing_content, generate_source_block(shell))
280 } else {
281 format!("{}\n\n{}", existing_content, generate_source_block(shell))
282 };
283 fs::write(rc_file, new_content)
284 .map_err(|e| format!("Failed to write {:?}: {}", rc_file, e))?;
285 }
286
287 Ok(())
288}
289
290fn remove_from_rc_file(rc_file: &Path) -> Result<bool, String> {
296 let content =
297 fs::read_to_string(rc_file).map_err(|e| format!("Failed to read {:?}: {}", rc_file, e))?;
298
299 if !content.contains(MARKER_START) {
300 return Ok(false);
301 }
302
303 let cleaned = remove_marker_block(&content);
304
305 if cleaned != content {
307 fs::write(rc_file, &cleaned)
308 .map_err(|e| format!("Failed to write {:?}: {}", rc_file, e))?;
309 }
310
311 Ok(true)
312}
313
314fn generate_source_block(shell: ShellType) -> String {
316 let integration_dir = Config::shell_integration_dir();
317 let script_filename = format!("shell_integration.{}", shell.extension());
318 let script_path = integration_dir.join(&script_filename);
319 let bin_dir = integration_dir.join("bin");
320
321 let script_path_str = script_path.display();
323 let bin_dir_str = bin_dir.display();
324
325 match shell {
326 ShellType::Fish => {
327 format!(
329 "{}\nif test -d \"{}\"\n set -gx PATH \"{}\" $PATH\nend\nif test -f \"{}\"\n source \"{}\"\nend\n{}\n",
330 MARKER_START,
331 bin_dir_str,
332 bin_dir_str,
333 script_path_str,
334 script_path_str,
335 MARKER_END
336 )
337 }
338 _ => {
339 format!(
341 "{}\nif [ -d \"{}\" ]; then\n export PATH=\"{}:$PATH\"\nfi\nif [ -f \"{}\" ]; then\n source \"{}\"\nfi\n{}\n",
342 MARKER_START,
343 bin_dir_str,
344 bin_dir_str,
345 script_path_str,
346 script_path_str,
347 MARKER_END
348 )
349 }
350 }
351}
352
353fn remove_marker_block(content: &str) -> String {
355 let mut result = String::new();
356 let mut in_block = false;
357 let mut found_block = false;
358
359 for line in content.lines() {
360 if line.trim() == MARKER_START {
361 in_block = true;
362 found_block = true;
363 continue;
364 }
365 if line.trim() == MARKER_END {
366 in_block = false;
367 continue;
368 }
369 if !in_block {
370 result.push_str(line);
371 result.push('\n');
372 }
373 }
374
375 if found_block {
377 let trimmed = result.trim_end();
379 if trimmed.is_empty() {
380 String::new()
381 } else {
382 format!("{}\n", trimmed)
383 }
384 } else {
385 result
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 #[test]
394 fn test_remove_marker_block() {
395 let content = format!(
396 "# existing content\n{}\nsource something\n{}\n# more content\n",
397 MARKER_START, MARKER_END
398 );
399 let result = remove_marker_block(&content);
400 assert!(!result.contains(MARKER_START));
401 assert!(!result.contains(MARKER_END));
402 assert!(result.contains("# existing content"));
403 assert!(result.contains("# more content"));
404 assert!(!result.contains("source something"));
405 }
406
407 #[test]
408 fn test_remove_marker_block_no_markers() {
409 let content = "# just some content\nno markers here\n";
410 let result = remove_marker_block(content);
411 assert_eq!(result, content);
412 }
413
414 #[test]
415 fn test_generate_source_block_bash() {
416 let block = generate_source_block(ShellType::Bash);
417 assert!(block.contains(MARKER_START));
418 assert!(block.contains(MARKER_END));
419 assert!(block.contains("source"));
420 assert!(block.contains(".bash"));
421 assert!(block.contains("export PATH="));
422 assert!(block.contains("/bin"));
423 }
424
425 #[test]
426 fn test_generate_source_block_zsh() {
427 let block = generate_source_block(ShellType::Zsh);
428 assert!(block.contains(MARKER_START));
429 assert!(block.contains(MARKER_END));
430 assert!(block.contains("source"));
431 assert!(block.contains(".zsh"));
432 assert!(block.contains("export PATH="));
433 assert!(block.contains("/bin"));
434 }
435
436 #[test]
437 fn test_generate_source_block_fish() {
438 let block = generate_source_block(ShellType::Fish);
439 assert!(block.contains(MARKER_START));
440 assert!(block.contains(MARKER_END));
441 assert!(block.contains("source"));
442 assert!(block.contains(".fish"));
443 assert!(block.contains("if test -f"));
445 assert!(block.contains("end"));
446 assert!(block.contains("set -gx PATH"));
447 assert!(block.contains("/bin"));
448 }
449
450 #[test]
451 fn test_get_script_content() {
452 assert!(!get_script_content(ShellType::Bash).is_empty());
454 assert!(!get_script_content(ShellType::Zsh).is_empty());
455 assert!(!get_script_content(ShellType::Fish).is_empty());
456 }
457
458 #[test]
459 fn test_detected_shell() {
460 let _shell = detected_shell();
463 }
464
465 #[test]
466 fn test_utility_scripts_embedded() {
467 assert!(!PT_DL_SCRIPT.is_empty());
469 assert!(!PT_UL_SCRIPT.is_empty());
470 assert!(!PT_IMGCAT_SCRIPT.is_empty());
471 assert!(PT_DL_SCRIPT.starts_with("#!/bin/sh"));
472 assert!(PT_UL_SCRIPT.starts_with("#!/bin/sh"));
473 assert!(PT_IMGCAT_SCRIPT.starts_with("#!/bin/sh"));
474 }
475}