Skip to main content

spool/bootstrap/
path_config.rs

1//! Shell PATH integration.
2//!
3//! Adds `~/.spool/bin` to the user's shell PATH by appending a marked
4//! block to `~/.zshrc` / `~/.bashrc` / `~/.config/fish/config.fish`.
5//!
6//! The block is delimited by `# >>> spool >>>` / `# <<< spool <<<` lines,
7//! so it can be safely re-inserted on upgrades or removed on uninstall.
8//!
9//! Best-effort: if no shell rc file is detected or writable, returns
10//! `path_configured: false` without erroring.
11
12use anyhow::Result;
13use std::path::{Path, PathBuf};
14
15const BLOCK_START: &str = "# >>> spool >>>";
16const BLOCK_END: &str = "# <<< spool <<<";
17
18/// Result of a PATH configuration attempt.
19#[derive(Debug, Clone, Default)]
20pub struct PathConfigReport {
21    /// True when at least one shell rc file already contains, or now
22    /// contains, the spool PATH block.
23    pub configured: bool,
24    /// Files newly written to during this run.
25    pub modified_files: Vec<PathBuf>,
26    /// Files that already had the PATH block before this run.
27    pub already_configured_files: Vec<PathBuf>,
28    pub notes: Vec<String>,
29}
30
31/// Add `~/.spool/bin` to PATH in every detected shell rc file. Idempotent
32/// — running twice is a no-op.
33pub fn configure_path(bin_dir: &Path) -> Result<PathConfigReport> {
34    let home = match crate::support::home_dir() {
35        Some(h) => h,
36        None => {
37            return Ok(PathConfigReport {
38                configured: false,
39                modified_files: Vec::new(),
40                already_configured_files: Vec::new(),
41                notes: vec!["could not resolve home directory".to_string()],
42            });
43        }
44    };
45
46    let mut report = PathConfigReport::default();
47    let block = build_shell_block(bin_dir);
48
49    for rc in candidate_rc_files(&home) {
50        match apply_block(&rc, &block) {
51            Ok(BlockApply::Inserted) => {
52                report.configured = true;
53                report.modified_files.push(rc.clone());
54                report.notes.push(format!("PATH added to {}", rc.display()));
55            }
56            Ok(BlockApply::AlreadyPresent) => {
57                report.configured = true;
58                report.already_configured_files.push(rc.clone());
59                report
60                    .notes
61                    .push(format!("PATH already present in {}", rc.display()));
62            }
63            Ok(BlockApply::Skipped) => {
64                report
65                    .notes
66                    .push(format!("skipped {} (does not exist)", rc.display()));
67            }
68            Err(err) => {
69                report
70                    .notes
71                    .push(format!("failed to update {}: {err:#}", rc.display()));
72            }
73        }
74    }
75
76    Ok(report)
77}
78
79/// Compute the candidate shell rc files for the current home directory.
80/// Only files that already exist are touched.
81fn candidate_rc_files(home: &Path) -> Vec<PathBuf> {
82    vec![
83        home.join(".zshrc"),
84        home.join(".bashrc"),
85        home.join(".bash_profile"),
86        home.join(".config/fish/config.fish"),
87    ]
88}
89
90fn build_shell_block(bin_dir: &Path) -> String {
91    let bin_str = bin_dir.display();
92    format!(
93        "{start}\n# Added by Spool desktop app — do not edit manually.\nexport PATH=\"{bin}:$PATH\"\n{end}\n",
94        start = BLOCK_START,
95        bin = bin_str,
96        end = BLOCK_END,
97    )
98}
99
100enum BlockApply {
101    Inserted,
102    AlreadyPresent,
103    Skipped,
104}
105
106fn apply_block(rc_file: &Path, block: &str) -> Result<BlockApply> {
107    if !rc_file.exists() {
108        return Ok(BlockApply::Skipped);
109    }
110    let existing = std::fs::read_to_string(rc_file)?;
111    if existing.contains(BLOCK_START) {
112        return Ok(BlockApply::AlreadyPresent);
113    }
114    let mut new_content = existing;
115    if !new_content.ends_with('\n') {
116        new_content.push('\n');
117    }
118    new_content.push('\n');
119    new_content.push_str(block);
120    std::fs::write(rc_file, new_content)?;
121    Ok(BlockApply::Inserted)
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use std::fs;
128    use tempfile::tempdir;
129
130    #[test]
131    fn configure_path_adds_block_to_existing_zshrc() {
132        let temp = tempdir().unwrap();
133        let zshrc = temp.path().join(".zshrc");
134        fs::write(&zshrc, "# user content\nexport FOO=bar\n").unwrap();
135
136        let bin_dir = temp.path().join(".spool/bin");
137        // Use the temp home directly by checking apply_block in isolation
138        // since configure_path() uses the global home_dir().
139        let block = build_shell_block(&bin_dir);
140        let outcome = apply_block(&zshrc, &block).unwrap();
141        assert!(matches!(outcome, BlockApply::Inserted));
142
143        let content = fs::read_to_string(&zshrc).unwrap();
144        assert!(content.contains(BLOCK_START));
145        assert!(content.contains(BLOCK_END));
146        assert!(content.contains(&bin_dir.display().to_string()));
147        // Original content preserved
148        assert!(content.contains("export FOO=bar"));
149    }
150
151    #[test]
152    fn apply_block_is_idempotent() {
153        let temp = tempdir().unwrap();
154        let zshrc = temp.path().join(".zshrc");
155        fs::write(&zshrc, "# original\n").unwrap();
156
157        let bin_dir = temp.path().join(".spool/bin");
158        let block = build_shell_block(&bin_dir);
159
160        let first = apply_block(&zshrc, &block).unwrap();
161        let second = apply_block(&zshrc, &block).unwrap();
162
163        assert!(matches!(first, BlockApply::Inserted));
164        assert!(matches!(second, BlockApply::AlreadyPresent));
165
166        // Should still only contain the block once
167        let content = fs::read_to_string(&zshrc).unwrap();
168        let count = content.matches(BLOCK_START).count();
169        assert_eq!(count, 1);
170    }
171
172    #[test]
173    fn apply_block_skips_nonexistent_file() {
174        let temp = tempdir().unwrap();
175        let zshrc = temp.path().join(".does-not-exist");
176        let block = build_shell_block(Path::new("/tmp/bin"));
177        let outcome = apply_block(&zshrc, &block).unwrap();
178        assert!(matches!(outcome, BlockApply::Skipped));
179    }
180
181    #[test]
182    fn build_shell_block_contains_path_export() {
183        let block = build_shell_block(Path::new("/Users/me/.spool/bin"));
184        assert!(block.contains("export PATH="));
185        assert!(block.contains("/Users/me/.spool/bin"));
186        assert!(block.starts_with(BLOCK_START));
187        assert!(block.trim_end().ends_with(BLOCK_END));
188    }
189}