spool/bootstrap/
path_config.rs1use anyhow::Result;
13use std::path::{Path, PathBuf};
14
15const BLOCK_START: &str = "# >>> spool >>>";
16const BLOCK_END: &str = "# <<< spool <<<";
17
18#[derive(Debug, Clone, Default)]
20pub struct PathConfigReport {
21 pub configured: bool,
24 pub modified_files: Vec<PathBuf>,
26 pub already_configured_files: Vec<PathBuf>,
28 pub notes: Vec<String>,
29}
30
31pub 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
79fn 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 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 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 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}