spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
//! Shell PATH integration.
//!
//! Adds `~/.spool/bin` to the user's shell PATH by appending a marked
//! block to `~/.zshrc` / `~/.bashrc` / `~/.config/fish/config.fish`.
//!
//! The block is delimited by `# >>> spool >>>` / `# <<< spool <<<` lines,
//! so it can be safely re-inserted on upgrades or removed on uninstall.
//!
//! Best-effort: if no shell rc file is detected or writable, returns
//! `path_configured: false` without erroring.

use anyhow::Result;
use std::path::{Path, PathBuf};

const BLOCK_START: &str = "# >>> spool >>>";
const BLOCK_END: &str = "# <<< spool <<<";

/// Result of a PATH configuration attempt.
#[derive(Debug, Clone, Default)]
pub struct PathConfigReport {
    /// True when at least one shell rc file already contains, or now
    /// contains, the spool PATH block.
    pub configured: bool,
    /// Files newly written to during this run.
    pub modified_files: Vec<PathBuf>,
    /// Files that already had the PATH block before this run.
    pub already_configured_files: Vec<PathBuf>,
    pub notes: Vec<String>,
}

/// Add `~/.spool/bin` to PATH in every detected shell rc file. Idempotent
/// — running twice is a no-op.
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)
}

/// Compute the candidate shell rc files for the current home directory.
/// Only files that already exist are touched.
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");
        // Use the temp home directly by checking apply_block in isolation
        // since configure_path() uses the global home_dir().
        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()));
        // Original content preserved
        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));

        // Should still only contain the block once
        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));
    }
}