Skip to main content

par_term/
shell_integration_installer.rs

1//! Shell integration installation logic.
2//!
3//! This module handles installing and uninstalling shell integration scripts for
4//! bash, zsh, and fish shells. It:
5//! - Embeds shell scripts via `include_str!`
6//! - Detects the current shell from $SHELL
7//! - Writes scripts to `~/.config/par-term/shell_integration.{bash,zsh,fish}`
8//! - Adds marker-wrapped source lines to RC files
9//! - Supports clean uninstall that safely removes the marker blocks
10
11use crate::config::{Config, ShellType};
12use std::fs;
13use std::path::{Path, PathBuf};
14
15// Embedded shell integration scripts
16const 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
20// Embedded file transfer utility scripts
21const 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
25/// Marker comments for identifying our additions to RC files
26const MARKER_START: &str = "# >>> par-term shell integration >>>";
27const MARKER_END: &str = "# <<< par-term shell integration <<<";
28
29/// Result of installation
30#[derive(Debug)]
31pub struct InstallResult {
32    /// Shell type that was installed
33    pub shell: ShellType,
34    /// Path where the integration script was written
35    pub script_path: PathBuf,
36    /// Path to the RC file that was modified
37    pub rc_file: PathBuf,
38    /// Whether a shell restart is needed to activate
39    pub needs_restart: bool,
40}
41
42/// Result of uninstallation
43#[derive(Debug, Default)]
44pub struct UninstallResult {
45    /// RC files that were successfully cleaned
46    pub cleaned: Vec<PathBuf>,
47    /// RC files that need manual cleanup (markers found but couldn't remove)
48    pub needs_manual: Vec<PathBuf>,
49    /// Integration script files that were removed
50    pub scripts_removed: Vec<PathBuf>,
51}
52
53/// Install file transfer utility scripts to the bin directory
54///
55/// Creates `~/.config/par-term/bin/` and writes `pt-dl`, `pt-ul`, `pt-imgcat`
56/// with executable permissions.
57fn 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
84/// Install shell integration for detected or specified shell
85///
86/// # Arguments
87/// * `shell` - Optional shell type override. If None, detects from $SHELL
88///
89/// # Returns
90/// * `Ok(InstallResult)` - Installation succeeded
91/// * `Err(String)` - Installation failed with error message
92pub 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    // Get the script content for this shell
103    let script_content = get_script_content(shell);
104
105    // Get the integration directory
106    let integration_dir = Config::shell_integration_dir();
107
108    // Create the directory if it doesn't exist
109    fs::create_dir_all(&integration_dir)
110        .map_err(|e| format!("Failed to create directory {:?}: {}", integration_dir, e))?;
111
112    // Write the script file
113    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 file transfer utilities to bin directory
120    install_utilities()?;
121
122    // Get the RC file path
123    let rc_file = get_rc_file(shell)?;
124
125    // Add source line to RC file
126    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
136/// Uninstall shell integration for all supported shells
137///
138/// Removes integration scripts and cleans up RC files for bash, zsh, and fish.
139///
140/// # Returns
141/// * `Ok(UninstallResult)` - Uninstallation completed (may have partial success)
142/// * `Err(String)` - Critical error during uninstallation
143pub fn uninstall() -> Result<UninstallResult, String> {
144    let mut result = UninstallResult::default();
145
146    // Clean up RC files for all shell types
147    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) => { /* No markers found, nothing to do */ }
154                Err(_) => result.needs_manual.push(rc_file),
155            }
156        }
157    }
158
159    // Remove integration script files
160    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    // Remove bin directory with file transfer utilities
171    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
179/// Check if shell integration is installed for the detected shell
180///
181/// Returns true if:
182/// - The integration script file exists
183/// - The RC file contains our marker block
184pub fn is_installed() -> bool {
185    let shell = detected_shell();
186    if shell == ShellType::Unknown {
187        return false;
188    }
189
190    // Check if script file exists
191    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    // Check if RC file has our markers
200    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
209/// Detect shell type from $SHELL environment variable
210pub fn detected_shell() -> ShellType {
211    ShellType::detect()
212}
213
214/// Get the script content for a given shell type
215fn 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, // Fallback to bash
221    }
222}
223
224/// Get the RC file path for a given shell type
225fn 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            // Prefer .bashrc if it exists, otherwise .bash_profile
231            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            // Fish config is at ~/.config/fish/config.fish
242            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
253/// Add the source line to the RC file, wrapped in markers
254fn add_to_rc_file(rc_file: &Path, shell: ShellType) -> Result<(), String> {
255    // Read existing content (or empty string if file doesn't exist)
256    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        // Create parent directories if needed
260        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    // Check if our markers already exist
268    if existing_content.contains(MARKER_START) {
269        // Remove existing block and add fresh one
270        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        // Append our block to the file
276        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
290/// Remove our marker block from an RC file
291///
292/// Returns Ok(true) if markers were found and removed,
293/// Ok(false) if no markers were found,
294/// Err if file couldn't be read/written
295fn 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    // Only write if content changed
306    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
314/// Generate the source block with markers for a given shell
315fn 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    // Use display() for path - will work on all platforms
322    let script_path_str = script_path.display();
323    let bin_dir_str = bin_dir.display();
324
325    match shell {
326        ShellType::Fish => {
327            // Fish uses 'source' command with different syntax
328            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            // Bash and Zsh use similar syntax
340            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
353/// Remove the marker block from content, preserving surrounding content
354fn 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 we found and removed a block, clean up extra blank lines
376    if found_block {
377        // Remove trailing blank lines that may have accumulated
378        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        // Fish uses different syntax
444        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        // Just verify we get non-empty content
453        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        // This will return whatever $SHELL is set to in the test environment
461        // We just verify it doesn't panic
462        let _shell = detected_shell();
463    }
464
465    #[test]
466    fn test_utility_scripts_embedded() {
467        // Verify that utility scripts are non-empty and start with shebang
468        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}