onpath 0.2.0

Get your tools on the PATH — cross-shell, cross-platform, zero fuss
Documentation
use std::fmt;
use std::path::PathBuf;

use crate::shell_kind::ShellKind;

/// A record of every action taken (or that would be taken in dry-run mode).
#[derive(Debug, Clone, PartialEq)]
#[must_use = "this report contains the actions taken"]
pub struct Report {
    /// The list of actions performed (or that would be performed in dry-run mode).
    pub actions: Vec<Action>,
    /// Whether this report was generated in dry-run mode.
    pub dry_run: bool,
}

impl Report {
    pub(crate) fn new(dry_run: bool) -> Self {
        Self {
            actions: Vec::new(),
            dry_run,
        }
    }

    pub(crate) fn push(&mut self, action: Action) {
        self.actions.push(action);
    }

    /// Returns `true` if any action of the given kind was recorded.
    #[must_use]
    pub fn has(&self, kind: ActionKind) -> bool {
        self.actions.iter().any(|a| a.kind() == kind)
    }
}

impl fmt::Display for Report {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if self.dry_run {
            writeln!(f, "[dry run] The following actions would be taken:")?;
        }
        for action in &self.actions {
            writeln!(f, "  {action}")?;
        }
        if self.actions.is_empty() {
            writeln!(f, "  (no changes needed)")?;
        }
        Ok(())
    }
}

/// A single operation performed during PATH installation or removal.
#[derive(Debug, Clone, PartialEq)]
pub enum Action {
    /// A new env script was written for a shell.
    EnvScriptWritten {
        /// The shell this script targets.
        shell: ShellKind,
        /// The path where the script was written.
        path: PathBuf,
    },
    /// An env script already existed with the correct content.
    EnvScriptAlreadyExists {
        /// The shell this script targets.
        shell: ShellKind,
        /// The path of the existing script.
        path: PathBuf,
    },
    /// An env script was removed during uninstallation.
    EnvScriptRemoved {
        /// The shell this script targeted.
        shell: ShellKind,
        /// The path of the removed script.
        path: PathBuf,
    },
    /// A source line was added to a shell RC file.
    SourceLineAdded {
        /// The shell whose RC file was modified.
        shell: ShellKind,
        /// The RC file that was modified.
        rc_file: PathBuf,
    },
    /// A source line was already present in the RC file.
    SourceLineAlreadyPresent {
        /// The shell whose RC file was checked.
        shell: ShellKind,
        /// The RC file that already contained the source line.
        rc_file: PathBuf,
    },
    /// A source line was removed from a shell RC file.
    SourceLineRemoved {
        /// The shell whose RC file was modified.
        shell: ShellKind,
        /// The RC file that was modified.
        rc_file: PathBuf,
    },
    /// A backup was created before modifying an RC file.
    BackupCreated {
        /// The original file that was backed up.
        original: PathBuf,
        /// The path of the backup file.
        backup: PathBuf,
    },
    /// A shell was skipped because no writable RC files were found.
    ShellSkipped {
        /// The shell that was skipped.
        shell: ShellKind,
        /// The reason the shell was skipped.
        reason: String,
    },
    /// The Windows registry PATH was modified.
    RegistryModified {
        /// The previous PATH value.
        old_value: String,
        /// The new PATH value.
        new_value: String,
    },
    /// The Windows registry PATH already contained the directory.
    RegistryAlreadyContains,
    /// An entry was removed from the Windows registry PATH.
    RegistryEntryRemoved {
        /// The previous PATH value.
        old_value: String,
        /// The new PATH value.
        new_value: String,
    },
}

/// Discriminant for matching action kinds without payload.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActionKind {
    /// An env script was written.
    EnvScriptWritten,
    /// An env script already existed.
    EnvScriptAlreadyExists,
    /// An env script was removed.
    EnvScriptRemoved,
    /// A source line was added to an RC file.
    SourceLineAdded,
    /// A source line was already present.
    SourceLineAlreadyPresent,
    /// A source line was removed from an RC file.
    SourceLineRemoved,
    /// A backup was created.
    BackupCreated,
    /// A shell was skipped.
    ShellSkipped,
    /// The Windows registry was modified.
    RegistryModified,
    /// The Windows registry already contained the entry.
    RegistryAlreadyContains,
    /// An entry was removed from the Windows registry.
    RegistryEntryRemoved,
}

impl Action {
    /// Returns the discriminant of this action, for matching without destructuring.
    #[must_use]
    pub fn kind(&self) -> ActionKind {
        match self {
            Self::EnvScriptWritten { .. } => ActionKind::EnvScriptWritten,
            Self::EnvScriptAlreadyExists { .. } => ActionKind::EnvScriptAlreadyExists,
            Self::EnvScriptRemoved { .. } => ActionKind::EnvScriptRemoved,
            Self::SourceLineAdded { .. } => ActionKind::SourceLineAdded,
            Self::SourceLineAlreadyPresent { .. } => ActionKind::SourceLineAlreadyPresent,
            Self::SourceLineRemoved { .. } => ActionKind::SourceLineRemoved,
            Self::BackupCreated { .. } => ActionKind::BackupCreated,
            Self::ShellSkipped { .. } => ActionKind::ShellSkipped,
            Self::RegistryModified { .. } => ActionKind::RegistryModified,
            Self::RegistryAlreadyContains => ActionKind::RegistryAlreadyContains,
            Self::RegistryEntryRemoved { .. } => ActionKind::RegistryEntryRemoved,
        }
    }
}

impl fmt::Display for Action {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::EnvScriptWritten { shell, path } => {
                write!(f, "[{shell}] wrote env script: {}", path.display())
            }
            Self::EnvScriptAlreadyExists { shell, path } => {
                write!(f, "[{shell}] env script already exists: {}", path.display())
            }
            Self::EnvScriptRemoved { shell, path } => {
                write!(f, "[{shell}] removed env script: {}", path.display())
            }
            Self::SourceLineAdded { shell, rc_file } => {
                write!(f, "[{shell}] added source line to {}", rc_file.display())
            }
            Self::SourceLineAlreadyPresent { shell, rc_file } => {
                write!(
                    f,
                    "[{shell}] source line already present in {}",
                    rc_file.display()
                )
            }
            Self::SourceLineRemoved { shell, rc_file } => {
                write!(
                    f,
                    "[{shell}] removed source line from {}",
                    rc_file.display()
                )
            }
            Self::BackupCreated { original, backup } => {
                write!(
                    f,
                    "backed up {} -> {}",
                    original.display(),
                    backup.display()
                )
            }
            Self::ShellSkipped { shell, reason } => {
                write!(f, "[{shell}] skipped: {reason}")
            }
            Self::RegistryModified { .. } => {
                write!(f, "[Windows] modified HKCU\\Environment\\PATH")
            }
            Self::RegistryAlreadyContains => {
                write!(f, "[Windows] PATH already contains the directory")
            }
            Self::RegistryEntryRemoved { .. } => {
                write!(f, "[Windows] removed entry from HKCU\\Environment\\PATH")
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn report_display_multiple_actions() {
        let report = Report {
            actions: vec![
                Action::EnvScriptWritten {
                    shell: ShellKind::Bash,
                    path: PathBuf::from("/home/user/.myapp/env"),
                },
                Action::SourceLineAdded {
                    shell: ShellKind::Bash,
                    rc_file: PathBuf::from("/home/user/.bashrc"),
                },
                Action::BackupCreated {
                    original: PathBuf::from("/home/user/.bashrc"),
                    backup: PathBuf::from("/home/user/.bashrc.onpath.bak"),
                },
            ],
            dry_run: false,
        };
        let output = format!("{report}");
        assert!(output.contains("[Bash] wrote env script"));
        assert!(output.contains("[Bash] added source line"));
        assert!(output.contains("backed up"));
    }

    #[test]
    fn report_display_empty_actions() {
        let report = Report {
            actions: vec![],
            dry_run: false,
        };
        let output = format!("{report}");
        assert!(output.contains("no changes needed"));
    }

    #[test]
    fn report_display_dry_run_prefix() {
        let report = Report {
            actions: vec![Action::RegistryAlreadyContains],
            dry_run: true,
        };
        let output = format!("{report}");
        assert!(output.contains("[dry run]"));
    }

    #[test]
    fn action_display_env_script_variants() {
        let written = Action::EnvScriptWritten {
            shell: ShellKind::Zsh,
            path: PathBuf::from("/home/user/.myapp/env"),
        };
        assert!(format!("{written}").contains("[Zsh] wrote env script"));

        let exists = Action::EnvScriptAlreadyExists {
            shell: ShellKind::Fish,
            path: PathBuf::from("/home/user/.config/fish/conf.d/onpath-myapp.fish"),
        };
        assert!(format!("{exists}").contains("[Fish] env script already exists"));

        let removed = Action::EnvScriptRemoved {
            shell: ShellKind::Nushell,
            path: PathBuf::from("/home/user/.myapp/env.nu"),
        };
        assert!(format!("{removed}").contains("[Nushell] removed env script"));
    }

    #[test]
    fn action_display_source_line_and_registry_variants() {
        let added = Action::SourceLineAdded {
            shell: ShellKind::Posix,
            rc_file: PathBuf::from("/home/user/.profile"),
        };
        assert!(format!("{added}").contains("[sh] added source line"));

        let present = Action::SourceLineAlreadyPresent {
            shell: ShellKind::Bash,
            rc_file: PathBuf::from("/home/user/.bashrc"),
        };
        assert!(format!("{present}").contains("already present"));

        let removed = Action::SourceLineRemoved {
            shell: ShellKind::Tcsh,
            rc_file: PathBuf::from("/home/user/.cshrc"),
        };
        assert!(format!("{removed}").contains("[Tcsh] removed source line"));

        let skipped = Action::ShellSkipped {
            shell: ShellKind::Xonsh,
            reason: "no RC files found".to_owned(),
        };
        assert!(format!("{skipped}").contains("[Xonsh] skipped"));

        let reg_mod = Action::RegistryModified {
            old_value: "old".to_owned(),
            new_value: "new".to_owned(),
        };
        assert!(format!("{reg_mod}").contains("[Windows]"));

        let reg_contains = Action::RegistryAlreadyContains;
        assert!(format!("{reg_contains}").contains("already contains"));

        let reg_removed = Action::RegistryEntryRemoved {
            old_value: "old".to_owned(),
            new_value: "new".to_owned(),
        };
        assert!(format!("{reg_removed}").contains("removed entry"));
    }
}