use anyhow::Result;
use std::path::{Path, PathBuf};
const BLOCK_START: &str = "# >>> spool >>>";
const BLOCK_END: &str = "# <<< spool <<<";
#[derive(Debug, Clone, Default)]
pub struct PathConfigReport {
pub configured: bool,
pub modified_files: Vec<PathBuf>,
pub already_configured_files: Vec<PathBuf>,
pub notes: Vec<String>,
}
pub fn configure_path(bin_dir: &Path) -> Result<PathConfigReport> {
let home = match crate::support::home_dir() {
Some(h) => h,
None => {
return Ok(PathConfigReport {
configured: false,
modified_files: Vec::new(),
already_configured_files: Vec::new(),
notes: vec!["could not resolve home directory".to_string()],
});
}
};
let mut report = PathConfigReport::default();
let block = build_shell_block(bin_dir);
for rc in candidate_rc_files(&home) {
match apply_block(&rc, &block) {
Ok(BlockApply::Inserted) => {
report.configured = true;
report.modified_files.push(rc.clone());
report.notes.push(format!("PATH added to {}", rc.display()));
}
Ok(BlockApply::AlreadyPresent) => {
report.configured = true;
report.already_configured_files.push(rc.clone());
report
.notes
.push(format!("PATH already present in {}", rc.display()));
}
Ok(BlockApply::Skipped) => {
report
.notes
.push(format!("skipped {} (does not exist)", rc.display()));
}
Err(err) => {
report
.notes
.push(format!("failed to update {}: {err:#}", rc.display()));
}
}
}
Ok(report)
}
fn candidate_rc_files(home: &Path) -> Vec<PathBuf> {
vec![
home.join(".zshrc"),
home.join(".bashrc"),
home.join(".bash_profile"),
home.join(".config/fish/config.fish"),
]
}
fn build_shell_block(bin_dir: &Path) -> String {
let bin_str = bin_dir.display();
format!(
"{start}\n# Added by Spool desktop app — do not edit manually.\nexport PATH=\"{bin}:$PATH\"\n{end}\n",
start = BLOCK_START,
bin = bin_str,
end = BLOCK_END,
)
}
enum BlockApply {
Inserted,
AlreadyPresent,
Skipped,
}
fn apply_block(rc_file: &Path, block: &str) -> Result<BlockApply> {
if !rc_file.exists() {
return Ok(BlockApply::Skipped);
}
let existing = std::fs::read_to_string(rc_file)?;
if existing.contains(BLOCK_START) {
return Ok(BlockApply::AlreadyPresent);
}
let mut new_content = existing;
if !new_content.ends_with('\n') {
new_content.push('\n');
}
new_content.push('\n');
new_content.push_str(block);
std::fs::write(rc_file, new_content)?;
Ok(BlockApply::Inserted)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn configure_path_adds_block_to_existing_zshrc() {
let temp = tempdir().unwrap();
let zshrc = temp.path().join(".zshrc");
fs::write(&zshrc, "# user content\nexport FOO=bar\n").unwrap();
let bin_dir = temp.path().join(".spool/bin");
let block = build_shell_block(&bin_dir);
let outcome = apply_block(&zshrc, &block).unwrap();
assert!(matches!(outcome, BlockApply::Inserted));
let content = fs::read_to_string(&zshrc).unwrap();
assert!(content.contains(BLOCK_START));
assert!(content.contains(BLOCK_END));
assert!(content.contains(&bin_dir.display().to_string()));
assert!(content.contains("export FOO=bar"));
}
#[test]
fn apply_block_is_idempotent() {
let temp = tempdir().unwrap();
let zshrc = temp.path().join(".zshrc");
fs::write(&zshrc, "# original\n").unwrap();
let bin_dir = temp.path().join(".spool/bin");
let block = build_shell_block(&bin_dir);
let first = apply_block(&zshrc, &block).unwrap();
let second = apply_block(&zshrc, &block).unwrap();
assert!(matches!(first, BlockApply::Inserted));
assert!(matches!(second, BlockApply::AlreadyPresent));
let content = fs::read_to_string(&zshrc).unwrap();
let count = content.matches(BLOCK_START).count();
assert_eq!(count, 1);
}
#[test]
fn apply_block_skips_nonexistent_file() {
let temp = tempdir().unwrap();
let zshrc = temp.path().join(".does-not-exist");
let block = build_shell_block(Path::new("/tmp/bin"));
let outcome = apply_block(&zshrc, &block).unwrap();
assert!(matches!(outcome, BlockApply::Skipped));
}
#[test]
fn build_shell_block_contains_path_export() {
let block = build_shell_block(Path::new("/Users/me/.spool/bin"));
assert!(block.contains("export PATH="));
assert!(block.contains("/Users/me/.spool/bin"));
assert!(block.starts_with(BLOCK_START));
assert!(block.trim_end().ends_with(BLOCK_END));
}
}