dodot-lib 0.9.1

Core library for dodot dotfiles manager
Documentation
//! Shell integration — generates `dodot-init.sh`.
//!
//! Unlike the Go implementation which ships a ~400-line shell script
//! that re-discovers the datastore layout at runtime, we generate a
//! flat, declarative script from the actual datastore state. This
//! means:
//!
//! - Zero logic duplication between Rust and shell
//! - The script is just `source` and `PATH=` lines — trivially fast
//! - Changes to the datastore layout only need to happen in Rust
//!
//! The generated script is written to `data_dir/shell/dodot-init.sh`.
//! Users source it from their shell profile:
//!
//! ```sh
//! [ -f ~/.local/share/dodot/shell/dodot-init.sh ] && . ~/.local/share/dodot/shell/dodot-init.sh
//! ```
//!
//! In the future, this can also be exposed as `dodot init-sh` or
//! a minimal standalone binary for even faster shell startup.

use std::fmt::Write;
use std::path::PathBuf;

use crate::fs::Fs;
use crate::paths::Pather;
use crate::Result;

/// Generate the shell init script content from the current datastore state.
///
/// Scans the datastore for:
/// - `packs/*/shell/*` — symlinks to shell scripts → `source` lines
/// - `packs/*/path/*` — symlinks to directories → `PATH=` lines
///
/// Returns the script content as a string.
pub fn generate_init_script(fs: &dyn Fs, paths: &dyn Pather) -> Result<String> {
    let mut script = String::new();

    writeln!(script, "#!/bin/sh").unwrap();
    writeln!(script, "# Generated by dodot — do not edit manually.").unwrap();
    writeln!(script, "# Regenerated on every `dodot up` / `dodot down`.").unwrap();
    writeln!(script).unwrap();

    // Discover all packs with state
    let packs_dir = paths.data_dir().join("packs");
    if !fs.exists(&packs_dir) {
        return Ok(script);
    }

    let pack_entries = fs.read_dir(&packs_dir)?;

    // Collect shell sources and path additions separately so we can
    // group them in the output for readability.
    let mut shell_sources: Vec<(String, PathBuf)> = Vec::new(); // (pack, target)
    let mut path_additions: Vec<(String, PathBuf)> = Vec::new(); // (pack, target)

    for pack_entry in &pack_entries {
        if !pack_entry.is_dir {
            continue;
        }
        let pack_name = &pack_entry.name;

        // Shell handler: source scripts
        let shell_dir = paths.handler_data_dir(pack_name, "shell");
        if fs.is_dir(&shell_dir) {
            if let Ok(entries) = fs.read_dir(&shell_dir) {
                for entry in entries {
                    if !entry.is_symlink {
                        continue;
                    }
                    // Follow the symlink to get the actual file path
                    let target = fs.readlink(&entry.path)?;
                    shell_sources.push((pack_name.clone(), target));
                }
            }
        }

        // Path handler: add to PATH
        let path_dir = paths.handler_data_dir(pack_name, "path");
        if fs.is_dir(&path_dir) {
            if let Ok(entries) = fs.read_dir(&path_dir) {
                for entry in entries {
                    if !entry.is_symlink {
                        continue;
                    }
                    let target = fs.readlink(&entry.path)?;
                    path_additions.push((pack_name.clone(), target));
                }
            }
        }
    }

    // Emit PATH additions
    if !path_additions.is_empty() {
        writeln!(script, "# PATH additions").unwrap();
        for (pack, target) in &path_additions {
            writeln!(script, "# [{pack}]").unwrap();
            writeln!(script, "export PATH=\"{}:$PATH\"", target.display()).unwrap();
        }
        writeln!(script).unwrap();
    }

    // Emit shell sources
    if !shell_sources.is_empty() {
        writeln!(script, "# Shell scripts").unwrap();
        for (pack, target) in &shell_sources {
            writeln!(script, "# [{pack}]").unwrap();
            writeln!(
                script,
                "[ -f \"{}\" ] && . \"{}\"",
                target.display(),
                target.display()
            )
            .unwrap();
        }
        writeln!(script).unwrap();
    }

    Ok(script)
}

/// Generate and write the init script to `data_dir/shell/dodot-init.sh`.
///
/// Returns the path where the script was written.
pub fn write_init_script(fs: &dyn Fs, paths: &dyn Pather) -> Result<PathBuf> {
    let script_content = generate_init_script(fs, paths)?;
    let script_path = paths.init_script_path();

    fs.mkdir_all(paths.shell_dir())?;
    fs.write_file(&script_path, script_content.as_bytes())?;
    fs.set_permissions(&script_path, 0o755)?;

    Ok(script_path)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::datastore::{CommandOutput, CommandRunner, DataStore, FilesystemDataStore};
    use crate::testing::TempEnvironment;
    use std::sync::Arc;

    struct NoopRunner;
    impl CommandRunner for NoopRunner {
        fn run(&self, _: &str, _: &[String]) -> Result<CommandOutput> {
            Ok(CommandOutput {
                exit_code: 0,
                stdout: String::new(),
                stderr: String::new(),
            })
        }
    }

    fn make_datastore(env: &TempEnvironment) -> FilesystemDataStore {
        FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), Arc::new(NoopRunner))
    }

    #[test]
    fn empty_datastore_produces_minimal_script() {
        let env = TempEnvironment::builder().build();
        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();

        assert!(script.starts_with("#!/bin/sh"));
        assert!(script.contains("Generated by dodot"));
        // No source or PATH lines
        assert!(!script.contains("export PATH"));
        assert!(!script.contains(". \""));
    }

    #[test]
    fn shell_handler_state_produces_source_lines() {
        let env = TempEnvironment::builder()
            .pack("vim")
            .file("aliases.sh", "alias vi=vim")
            .done()
            .build();

        let ds = make_datastore(&env);
        let source = env.dotfiles_root.join("vim/aliases.sh");
        ds.create_data_link("vim", "shell", &source).unwrap();

        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();

        assert!(script.contains("# Shell scripts"), "script:\n{script}");
        assert!(script.contains("# [vim]"), "script:\n{script}");
        assert!(
            script.contains(&format!(
                "[ -f \"{}\" ] && . \"{}\"",
                source.display(),
                source.display()
            )),
            "script:\n{script}"
        );
    }

    #[test]
    fn path_handler_state_produces_path_lines() {
        let env = TempEnvironment::builder()
            .pack("vim")
            .file("bin/myscript", "#!/bin/sh")
            .done()
            .build();

        let ds = make_datastore(&env);
        let source = env.dotfiles_root.join("vim/bin");
        ds.create_data_link("vim", "path", &source).unwrap();

        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();

        assert!(script.contains("# PATH additions"), "script:\n{script}");
        assert!(script.contains("# [vim]"), "script:\n{script}");
        assert!(
            script.contains(&format!("export PATH=\"{}:$PATH\"", source.display())),
            "script:\n{script}"
        );
    }

    #[test]
    fn multiple_packs_combined() {
        let env = TempEnvironment::builder()
            .pack("git")
            .file("aliases.sh", "alias gs='git status'")
            .done()
            .pack("vim")
            .file("aliases.sh", "alias vi=vim")
            .file("bin/vimrun", "#!/bin/sh")
            .done()
            .build();

        let ds = make_datastore(&env);

        // Shell scripts
        ds.create_data_link("git", "shell", &env.dotfiles_root.join("git/aliases.sh"))
            .unwrap();
        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
            .unwrap();

        // Path
        ds.create_data_link("vim", "path", &env.dotfiles_root.join("vim/bin"))
            .unwrap();

        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();

        // Should have both shell sources
        assert!(script.contains("# [git]"), "script:\n{script}");
        assert!(script.contains("# [vim]"), "script:\n{script}");
        // Should have PATH addition
        assert!(script.contains("export PATH="), "script:\n{script}");
        // Should have source lines
        let source_count = script.matches(". \"").count();
        assert_eq!(
            source_count, 2,
            "expected 2 source lines, script:\n{script}"
        );
    }

    #[test]
    fn write_init_script_creates_executable_file() {
        let env = TempEnvironment::builder()
            .pack("vim")
            .file("aliases.sh", "alias vi=vim")
            .done()
            .build();

        let ds = make_datastore(&env);
        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
            .unwrap();

        let script_path = write_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();

        assert_eq!(script_path, env.paths.init_script_path());
        env.assert_exists(&script_path);

        let content = env.fs.read_to_string(&script_path).unwrap();
        assert!(content.starts_with("#!/bin/sh"));
        assert!(content.contains("aliases.sh"));

        // Check executable permission
        let meta = std::fs::metadata(&script_path).unwrap();
        use std::os::unix::fs::PermissionsExt;
        assert_eq!(meta.permissions().mode() & 0o111, 0o111);
    }

    #[test]
    fn script_regenerated_reflects_current_state() {
        let env = TempEnvironment::builder()
            .pack("vim")
            .file("aliases.sh", "alias vi=vim")
            .done()
            .build();

        let ds = make_datastore(&env);

        // Initially empty
        let script1 = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();
        assert!(!script1.contains("aliases.sh"));

        // Deploy shell script
        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
            .unwrap();

        let script2 = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();
        assert!(script2.contains("aliases.sh"));

        // Remove state
        ds.remove_state("vim", "shell").unwrap();

        let script3 = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();
        assert!(!script3.contains("aliases.sh"));
    }

    #[test]
    fn ignores_non_symlink_files_in_handler_dirs() {
        let env = TempEnvironment::builder().build();

        // Create a non-symlink file in the shell handler dir
        let shell_dir = env.paths.handler_data_dir("vim", "shell");
        env.fs.mkdir_all(&shell_dir).unwrap();
        env.fs
            .write_file(&shell_dir.join("not-a-symlink"), b"noise")
            .unwrap();

        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();
        assert!(!script.contains("not-a-symlink"));
    }

    #[test]
    fn path_additions_come_before_shell_sources() {
        let env = TempEnvironment::builder()
            .pack("vim")
            .file("aliases.sh", "alias vi=vim")
            .file("bin/myscript", "#!/bin/sh")
            .done()
            .build();

        let ds = make_datastore(&env);
        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
            .unwrap();
        ds.create_data_link("vim", "path", &env.dotfiles_root.join("vim/bin"))
            .unwrap();

        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();

        let path_pos = script.find("# PATH additions").unwrap();
        let shell_pos = script.find("# Shell scripts").unwrap();
        assert!(
            path_pos < shell_pos,
            "PATH additions should come before shell sources"
        );
    }
}