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//!
11//! RC files are rewritten through [`write_rc_file`], which stages the new
12//! contents in a sibling temp file and renames it into place while keeping the
13//! file's existing mode. Everything else in this module writes par-term's own
14//! reconstructable content and uses a plain `fs::write`.
15//!
16//! # Error Handling Convention
17//!
18//! Private helpers use `Result<(), String>` for simple string errors that are
19//! surfaced to the caller for display. New code added to this module should
20//! follow the same pattern.
21
22use crate::config::{Config, ShellType};
23use std::fs;
24use std::path::{Path, PathBuf};
25
26// Embedded shell integration scripts
27const 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
31// Embedded file transfer utility scripts
32const 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
36/// Marker comments for identifying our additions to RC files
37const MARKER_START: &str = "# >>> par-term shell integration >>>";
38const MARKER_END: &str = "# <<< par-term shell integration <<<";
39
40/// Result of installation
41#[derive(Debug)]
42pub struct InstallResult {
43    /// Shell type that was installed
44    pub shell: ShellType,
45    /// Path where the integration script was written
46    pub script_path: PathBuf,
47    /// Path to the RC file that was modified
48    pub rc_file: PathBuf,
49    /// Whether a shell restart is needed to activate
50    pub needs_restart: bool,
51}
52
53/// Result of uninstallation
54#[derive(Debug, Default)]
55pub struct UninstallResult {
56    /// RC files that were successfully cleaned
57    pub cleaned: Vec<PathBuf>,
58    /// RC files that need manual cleanup (markers found but couldn't remove)
59    pub needs_manual: Vec<PathBuf>,
60    /// Integration script files that were removed
61    pub scripts_removed: Vec<PathBuf>,
62}
63
64/// Install file transfer utility scripts to the bin directory
65///
66/// Creates `~/.config/par-term/bin/` and writes `pt-dl`, `pt-ul`, `pt-imgcat`
67/// with executable permissions.
68fn 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
95/// Install shell integration for detected or specified shell
96///
97/// # Arguments
98/// * `shell` - Optional shell type override. If None, detects from $SHELL
99///
100/// # Returns
101/// * `Ok(InstallResult)` - Installation succeeded
102/// * `Err(String)` - Installation failed with error message
103pub 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    // Get the script content for this shell
114    let script_content = get_script_content(shell);
115
116    // Get the integration directory
117    let integration_dir = Config::shell_integration_dir();
118
119    // Create the directory if it doesn't exist
120    fs::create_dir_all(&integration_dir)
121        .map_err(|e| format!("Failed to create directory {:?}: {}", integration_dir, e))?;
122
123    // Write the script file
124    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 file transfer utilities to bin directory
131    install_utilities()?;
132
133    // Get the RC file path
134    let rc_file = get_rc_file(shell)?;
135
136    // Add source line to RC file
137    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
147/// Uninstall shell integration for all supported shells
148///
149/// Removes integration scripts and cleans up RC files for bash, zsh, and fish.
150///
151/// # Returns
152/// * `Ok(UninstallResult)` - Uninstallation completed (may have partial success)
153/// * `Err(String)` - Critical error during uninstallation
154pub fn uninstall() -> Result<UninstallResult, String> {
155    let mut result = UninstallResult::default();
156
157    // Clean up RC files for all shell types
158    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) => { /* No markers found, nothing to do */ }
165                Err(_) => result.needs_manual.push(rc_file),
166            }
167        }
168    }
169
170    // Remove integration script files
171    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    // Remove bin directory with file transfer utilities
182    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
190/// Check if shell integration is installed for the detected shell
191///
192/// Returns true if:
193/// - The integration script file exists
194/// - The RC file contains our marker block
195pub fn is_installed() -> bool {
196    let shell = detected_shell();
197    if shell == ShellType::Unknown {
198        return false;
199    }
200
201    // Check if script file exists
202    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    // Check if RC file has our markers
211    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
220/// Detect shell type from $SHELL environment variable
221pub fn detected_shell() -> ShellType {
222    ShellType::detect()
223}
224
225/// Get the script content for a given shell type
226fn 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, // Fallback to bash
232    }
233}
234
235/// Get the RC file path for a given shell type
236fn 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            // Prefer .bashrc if it exists, otherwise .bash_profile
242            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            // Fish config is at ~/.config/fish/config.fish
253            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
264/// Add the source line to the RC file, wrapped in markers
265fn add_to_rc_file(rc_file: &Path, shell: ShellType) -> Result<(), String> {
266    // Read existing content (or empty string if file doesn't exist)
267    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        // Create parent directories if needed
271        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    // Check if our markers already exist
279    let new_content = if existing_content.contains(MARKER_START) {
280        // Remove existing block and add fresh one
281        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
294/// Replace an RC file's contents atomically, keeping the mode the user gave it.
295///
296/// This is a full rewrite of a file par-term did not author and cannot
297/// reconstruct, so a partial write is the worst outcome in the module: a
298/// truncated `.zshrc` breaks the user's login shell. The write is staged in a
299/// sibling temp file and renamed, so a crash leaves either the old file or the
300/// new one.
301///
302/// The mode is **preserved, not forced to `0o600`** — unlike par-term's own
303/// state files, an rc file belongs to the user and may legitimately be
304/// group-readable.
305fn 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
310/// Remove our marker block from an RC file
311///
312/// Returns Ok(true) if markers were found and removed,
313/// Ok(false) if no markers were found,
314/// Err if file couldn't be read/written
315fn 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    // Only write if content changed
326    if cleaned != content {
327        write_rc_file(rc_file, &cleaned)?;
328    }
329
330    Ok(true)
331}
332
333/// Convert an absolute path to a `$HOME`-relative string when possible.
334///
335/// `/Users/alice/.config/par-term/bin` → `$HOME/.config/par-term/bin`
336fn 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
345/// Generate the source block with markers for a given shell
346fn 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            // Fish uses 'source' command with different syntax
358            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            // Bash and Zsh use similar syntax
370            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
383/// Remove the marker block from content, preserving surrounding content
384fn 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 we found and removed a block, clean up extra blank lines
406    if found_block {
407        // Remove trailing blank lines that may have accumulated
408        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        // Fish uses different syntax
474        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        // Just verify we get non-empty content
483        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        // This will return whatever $SHELL is set to in the test environment
491        // We just verify it doesn't panic
492        let _shell = detected_shell();
493    }
494
495    /// The installer rewrites the whole file, so the user's own lines must come
496    /// back byte-for-byte with only our block appended.
497    #[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    /// The fish path on a machine with no fish config: the rc file and its
513    /// parent directory both have to be created.
514    #[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    /// Installing twice must replace the block rather than stack a second copy.
527    #[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    /// An rc file is the user's, not par-term's: a group-readable `.zshrc` must
555    /// stay group-readable after the installer rewrites it.
556    #[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    /// A rewrite must never leave debris beside the rc file for the next
577    /// install to trip over.
578    #[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        // Verify that utility scripts are non-empty and start with shebang
597        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}