1use crate::config::{Config, ShellType};
23use std::fs;
24use std::path::{Path, PathBuf};
25
26const BASH_SCRIPT: &str = include_str!("../shell_integration/par_term_shell_integration.bash");
28const ZSH_SCRIPT: &str = include_str!("../shell_integration/par_term_shell_integration.zsh");
29const FISH_SCRIPT: &str = include_str!("../shell_integration/par_term_shell_integration.fish");
30
31const PT_DL_SCRIPT: &str = include_str!("../shell_integration/pt-dl");
33const PT_UL_SCRIPT: &str = include_str!("../shell_integration/pt-ul");
34const PT_IMGCAT_SCRIPT: &str = include_str!("../shell_integration/pt-imgcat");
35
36const MARKER_START: &str = "# >>> par-term shell integration >>>";
38const MARKER_END: &str = "# <<< par-term shell integration <<<";
39
40#[derive(Debug)]
42pub struct InstallResult {
43 pub shell: ShellType,
45 pub script_path: PathBuf,
47 pub rc_file: PathBuf,
49 pub needs_restart: bool,
51}
52
53#[derive(Debug, Default)]
55pub struct UninstallResult {
56 pub cleaned: Vec<PathBuf>,
58 pub needs_manual: Vec<PathBuf>,
60 pub scripts_removed: Vec<PathBuf>,
62}
63
64fn install_utilities() -> Result<PathBuf, String> {
69 let bin_dir = Config::shell_integration_dir().join("bin");
70 fs::create_dir_all(&bin_dir)
71 .map_err(|e| format!("Failed to create bin directory {:?}: {}", bin_dir, e))?;
72
73 let utilities: &[(&str, &str)] = &[
74 ("pt-dl", PT_DL_SCRIPT),
75 ("pt-ul", PT_UL_SCRIPT),
76 ("pt-imgcat", PT_IMGCAT_SCRIPT),
77 ];
78
79 for (name, content) in utilities {
80 let path = bin_dir.join(name);
81 fs::write(&path, content).map_err(|e| format!("Failed to write {:?}: {}", path, e))?;
82
83 #[cfg(unix)]
84 {
85 use std::os::unix::fs::PermissionsExt;
86 let perms = std::fs::Permissions::from_mode(0o755);
87 fs::set_permissions(&path, perms)
88 .map_err(|e| format!("Failed to set permissions on {:?}: {}", path, e))?;
89 }
90 }
91
92 Ok(bin_dir)
93}
94
95pub fn install(shell: Option<ShellType>) -> Result<InstallResult, String> {
104 let shell = shell.unwrap_or_else(detected_shell);
105
106 if shell == ShellType::Unknown {
107 return Err(
108 "Could not detect shell type. Please specify shell manually (bash, zsh, or fish)."
109 .to_string(),
110 );
111 }
112
113 let script_content = get_script_content(shell);
115
116 let integration_dir = Config::shell_integration_dir();
118
119 fs::create_dir_all(&integration_dir)
121 .map_err(|e| format!("Failed to create directory {:?}: {}", integration_dir, e))?;
122
123 let script_filename = format!("shell_integration.{}", shell.extension());
125 let script_path = integration_dir.join(&script_filename);
126
127 fs::write(&script_path, script_content)
128 .map_err(|e| format!("Failed to write script to {:?}: {}", script_path, e))?;
129
130 install_utilities()?;
132
133 let rc_file = get_rc_file(shell)?;
135
136 add_to_rc_file(&rc_file, shell)?;
138
139 Ok(InstallResult {
140 shell,
141 script_path,
142 rc_file,
143 needs_restart: true,
144 })
145}
146
147pub fn uninstall() -> Result<UninstallResult, String> {
155 let mut result = UninstallResult::default();
156
157 for shell in [ShellType::Bash, ShellType::Zsh, ShellType::Fish] {
159 if let Ok(rc_file) = get_rc_file(shell)
160 && rc_file.exists()
161 {
162 match remove_from_rc_file(&rc_file) {
163 Ok(true) => result.cleaned.push(rc_file),
164 Ok(false) => { }
165 Err(_) => result.needs_manual.push(rc_file),
166 }
167 }
168 }
169
170 let integration_dir = Config::shell_integration_dir();
172 for shell in [ShellType::Bash, ShellType::Zsh, ShellType::Fish] {
173 let script_filename = format!("shell_integration.{}", shell.extension());
174 let script_path = integration_dir.join(&script_filename);
175
176 if script_path.exists() && fs::remove_file(&script_path).is_ok() {
177 result.scripts_removed.push(script_path);
178 }
179 }
180
181 let bin_dir = integration_dir.join("bin");
183 if bin_dir.exists() {
184 let _ = fs::remove_dir_all(&bin_dir);
185 }
186
187 Ok(result)
188}
189
190pub fn is_installed() -> bool {
196 let shell = detected_shell();
197 if shell == ShellType::Unknown {
198 return false;
199 }
200
201 let integration_dir = Config::shell_integration_dir();
203 let script_filename = format!("shell_integration.{}", shell.extension());
204 let script_path = integration_dir.join(&script_filename);
205
206 if !script_path.exists() {
207 return false;
208 }
209
210 if let Ok(rc_file) = get_rc_file(shell)
212 && let Ok(content) = fs::read_to_string(&rc_file)
213 {
214 return content.contains(MARKER_START) && content.contains(MARKER_END);
215 }
216
217 false
218}
219
220pub fn detected_shell() -> ShellType {
222 ShellType::detect()
223}
224
225fn get_script_content(shell: ShellType) -> &'static str {
227 match shell {
228 ShellType::Bash => BASH_SCRIPT,
229 ShellType::Zsh => ZSH_SCRIPT,
230 ShellType::Fish => FISH_SCRIPT,
231 ShellType::Unknown => BASH_SCRIPT, }
233}
234
235fn get_rc_file(shell: ShellType) -> Result<PathBuf, String> {
237 let home = dirs::home_dir().ok_or("Could not determine home directory")?;
238
239 let rc_file = match shell {
240 ShellType::Bash => {
241 let bashrc = home.join(".bashrc");
243 let bash_profile = home.join(".bash_profile");
244 if bashrc.exists() {
245 bashrc
246 } else {
247 bash_profile
248 }
249 }
250 ShellType::Zsh => home.join(".zshrc"),
251 ShellType::Fish => {
252 let xdg_config = std::env::var("XDG_CONFIG_HOME")
254 .map(PathBuf::from)
255 .unwrap_or_else(|_| home.join(".config"));
256 xdg_config.join("fish").join("config.fish")
257 }
258 ShellType::Unknown => return Err("Unknown shell type".to_string()),
259 };
260
261 Ok(rc_file)
262}
263
264fn add_to_rc_file(rc_file: &Path, shell: ShellType) -> Result<(), String> {
266 let existing_content = if rc_file.exists() {
268 fs::read_to_string(rc_file).map_err(|e| format!("Failed to read {:?}: {}", rc_file, e))?
269 } else {
270 if let Some(parent) = rc_file.parent() {
272 fs::create_dir_all(parent)
273 .map_err(|e| format!("Failed to create directory {:?}: {}", parent, e))?;
274 }
275 String::new()
276 };
277
278 let new_content = if existing_content.contains(MARKER_START) {
280 let cleaned = remove_marker_block(&existing_content);
282 format!("{}\n{}", cleaned.trim_end(), generate_source_block(shell))
283 } else if existing_content.is_empty() {
284 generate_source_block(shell)
285 } else if existing_content.ends_with('\n') {
286 format!("{}\n{}", existing_content, generate_source_block(shell))
287 } else {
288 format!("{}\n\n{}", existing_content, generate_source_block(shell))
289 };
290
291 write_rc_file(rc_file, &new_content)
292}
293
294fn write_rc_file(rc_file: &Path, contents: &str) -> Result<(), String> {
306 crate::atomic_save::save_string_atomic_preserving_mode(rc_file, contents)
307 .map_err(|e| format!("Failed to write {:?}: {:#}", rc_file, e))
308}
309
310fn remove_from_rc_file(rc_file: &Path) -> Result<bool, String> {
316 let content =
317 fs::read_to_string(rc_file).map_err(|e| format!("Failed to read {:?}: {}", rc_file, e))?;
318
319 if !content.contains(MARKER_START) {
320 return Ok(false);
321 }
322
323 let cleaned = remove_marker_block(&content);
324
325 if cleaned != content {
327 write_rc_file(rc_file, &cleaned)?;
328 }
329
330 Ok(true)
331}
332
333fn home_relative_str(path: &Path) -> String {
337 if let Some(home) = dirs::home_dir()
338 && let Ok(rel) = path.strip_prefix(&home)
339 {
340 return format!("$HOME/{}", rel.display());
341 }
342 path.display().to_string()
343}
344
345fn generate_source_block(shell: ShellType) -> String {
347 let integration_dir = Config::shell_integration_dir();
348 let script_filename = format!("shell_integration.{}", shell.extension());
349 let script_path = integration_dir.join(&script_filename);
350 let bin_dir = integration_dir.join("bin");
351
352 let script_path_str = home_relative_str(&script_path);
353 let bin_dir_str = home_relative_str(&bin_dir);
354
355 match shell {
356 ShellType::Fish => {
357 format!(
359 "{}\nif test -d \"{}\"\n set -gx PATH \"{}\" $PATH\nend\nif test -f \"{}\"\n source \"{}\"\nend\n{}\n",
360 MARKER_START,
361 bin_dir_str,
362 bin_dir_str,
363 script_path_str,
364 script_path_str,
365 MARKER_END
366 )
367 }
368 _ => {
369 format!(
371 "{}\nif [ -d \"{}\" ]; then\n export PATH=\"{}:$PATH\"\nfi\nif [ -f \"{}\" ]; then\n source \"{}\"\nfi\n{}\n",
372 MARKER_START,
373 bin_dir_str,
374 bin_dir_str,
375 script_path_str,
376 script_path_str,
377 MARKER_END
378 )
379 }
380 }
381}
382
383fn remove_marker_block(content: &str) -> String {
385 let mut result = String::new();
386 let mut in_block = false;
387 let mut found_block = false;
388
389 for line in content.lines() {
390 if line.trim() == MARKER_START {
391 in_block = true;
392 found_block = true;
393 continue;
394 }
395 if line.trim() == MARKER_END {
396 in_block = false;
397 continue;
398 }
399 if !in_block {
400 result.push_str(line);
401 result.push('\n');
402 }
403 }
404
405 if found_block {
407 let trimmed = result.trim_end();
409 if trimmed.is_empty() {
410 String::new()
411 } else {
412 format!("{}\n", trimmed)
413 }
414 } else {
415 result
416 }
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422
423 #[test]
424 fn test_remove_marker_block() {
425 let content = format!(
426 "# existing content\n{}\nsource something\n{}\n# more content\n",
427 MARKER_START, MARKER_END
428 );
429 let result = remove_marker_block(&content);
430 assert!(!result.contains(MARKER_START));
431 assert!(!result.contains(MARKER_END));
432 assert!(result.contains("# existing content"));
433 assert!(result.contains("# more content"));
434 assert!(!result.contains("source something"));
435 }
436
437 #[test]
438 fn test_remove_marker_block_no_markers() {
439 let content = "# just some content\nno markers here\n";
440 let result = remove_marker_block(content);
441 assert_eq!(result, content);
442 }
443
444 #[test]
445 fn test_generate_source_block_bash() {
446 let block = generate_source_block(ShellType::Bash);
447 assert!(block.contains(MARKER_START));
448 assert!(block.contains(MARKER_END));
449 assert!(block.contains("source"));
450 assert!(block.contains(".bash"));
451 assert!(block.contains("export PATH="));
452 assert!(block.contains("$HOME/"));
453 }
454
455 #[test]
456 fn test_generate_source_block_zsh() {
457 let block = generate_source_block(ShellType::Zsh);
458 assert!(block.contains(MARKER_START));
459 assert!(block.contains(MARKER_END));
460 assert!(block.contains("source"));
461 assert!(block.contains(".zsh"));
462 assert!(block.contains("export PATH="));
463 assert!(block.contains("$HOME/"));
464 }
465
466 #[test]
467 fn test_generate_source_block_fish() {
468 let block = generate_source_block(ShellType::Fish);
469 assert!(block.contains(MARKER_START));
470 assert!(block.contains(MARKER_END));
471 assert!(block.contains("source"));
472 assert!(block.contains(".fish"));
473 assert!(block.contains("if test -f"));
475 assert!(block.contains("end"));
476 assert!(block.contains("set -gx PATH"));
477 assert!(block.contains("$HOME/"));
478 }
479
480 #[test]
481 fn test_get_script_content() {
482 assert!(!get_script_content(ShellType::Bash).is_empty());
484 assert!(!get_script_content(ShellType::Zsh).is_empty());
485 assert!(!get_script_content(ShellType::Fish).is_empty());
486 }
487
488 #[test]
489 fn test_detected_shell() {
490 let _shell = detected_shell();
493 }
494
495 #[test]
498 fn test_add_to_rc_file_preserves_user_content() {
499 let temp = tempfile::tempdir().expect("tempdir");
500 let rc_file = temp.path().join(".zshrc");
501 let original = "export EDITOR=vim\nalias ll='ls -la'\n";
502 fs::write(&rc_file, original).expect("seed rc file");
503
504 add_to_rc_file(&rc_file, ShellType::Zsh).expect("install into rc file");
505
506 let updated = fs::read_to_string(&rc_file).expect("read rc file");
507 assert!(updated.starts_with(original), "user content was altered");
508 assert!(updated.contains(MARKER_START));
509 assert!(updated.contains(MARKER_END));
510 }
511
512 #[test]
515 fn test_add_to_rc_file_creates_a_missing_file_and_parent() {
516 let temp = tempfile::tempdir().expect("tempdir");
517 let rc_file = temp.path().join("fish").join("config.fish");
518
519 add_to_rc_file(&rc_file, ShellType::Fish).expect("install into a missing rc file");
520
521 let created = fs::read_to_string(&rc_file).expect("read rc file");
522 assert!(created.starts_with(MARKER_START));
523 assert!(created.contains("set -gx PATH"));
524 }
525
526 #[test]
528 fn test_add_to_rc_file_is_idempotent() {
529 let temp = tempfile::tempdir().expect("tempdir");
530 let rc_file = temp.path().join(".zshrc");
531 fs::write(&rc_file, "export EDITOR=vim\n").expect("seed rc file");
532
533 add_to_rc_file(&rc_file, ShellType::Zsh).expect("first install");
534 add_to_rc_file(&rc_file, ShellType::Zsh).expect("second install");
535
536 let updated = fs::read_to_string(&rc_file).expect("read rc file");
537 assert_eq!(updated.matches(MARKER_START).count(), 1);
538 assert!(updated.contains("export EDITOR=vim"));
539 }
540
541 #[test]
542 fn test_remove_from_rc_file_restores_user_content() {
543 let temp = tempfile::tempdir().expect("tempdir");
544 let rc_file = temp.path().join(".bashrc");
545 fs::write(&rc_file, "export EDITOR=vim\n").expect("seed rc file");
546
547 add_to_rc_file(&rc_file, ShellType::Bash).expect("install");
548 assert!(remove_from_rc_file(&rc_file).expect("uninstall"));
549
550 let updated = fs::read_to_string(&rc_file).expect("read rc file");
551 assert_eq!(updated, "export EDITOR=vim\n");
552 }
553
554 #[cfg(unix)]
557 #[test]
558 fn test_rc_file_mode_is_preserved() {
559 use std::os::unix::fs::PermissionsExt;
560
561 let temp = tempfile::tempdir().expect("tempdir");
562 let rc_file = temp.path().join(".zshrc");
563 fs::write(&rc_file, "export EDITOR=vim\n").expect("seed rc file");
564 fs::set_permissions(&rc_file, fs::Permissions::from_mode(0o644)).expect("chmod");
565
566 add_to_rc_file(&rc_file, ShellType::Zsh).expect("install");
567
568 let mode = fs::metadata(&rc_file)
569 .expect("metadata")
570 .permissions()
571 .mode()
572 & 0o777;
573 assert_eq!(mode, 0o644, "expected 0644 to survive, got {mode:o}");
574 }
575
576 #[test]
579 fn test_rc_file_rewrite_leaves_no_staging_file() {
580 let temp = tempfile::tempdir().expect("tempdir");
581 let rc_file = temp.path().join(".zshrc");
582 fs::write(&rc_file, "export EDITOR=vim\n").expect("seed rc file");
583
584 add_to_rc_file(&rc_file, ShellType::Zsh).expect("install");
585
586 let entries: Vec<String> = fs::read_dir(temp.path())
587 .expect("read_dir")
588 .filter_map(|e| e.ok())
589 .map(|e| e.file_name().to_string_lossy().into_owned())
590 .collect();
591 assert_eq!(entries, vec![".zshrc".to_string()]);
592 }
593
594 #[test]
595 fn test_utility_scripts_embedded() {
596 assert!(!PT_DL_SCRIPT.is_empty());
598 assert!(!PT_UL_SCRIPT.is_empty());
599 assert!(!PT_IMGCAT_SCRIPT.is_empty());
600 assert!(PT_DL_SCRIPT.starts_with("#!/bin/sh"));
601 assert!(PT_UL_SCRIPT.starts_with("#!/bin/sh"));
602 assert!(PT_IMGCAT_SCRIPT.starts_with("#!/bin/sh"));
603 }
604}