git-delta 0.19.2

A syntax-highlighting pager for git
use std::io::Write;
#[cfg(target_os = "windows")]
use std::io::{Error, ErrorKind};
use std::path::PathBuf;

use crate::features::OptionValueFunction;
use crate::utils::bat::output::PagerCfg;

pub fn make_feature() -> Vec<(String, OptionValueFunction)> {
    builtin_feature!([
        (
            "navigate",
            bool,
            None,
            _opt => true
        ),
        (
            "file-modified-label",
            String,
            None,
            _opt => "Δ"
        ),
        (
            "hunk-label",
            String,
            None,
            _opt => ""
        )
    ])
}

// Construct the regexp used by less for paging, if --show-themes or --navigate is enabled.
pub fn make_navigate_regex(
    show_themes: bool,
    file_modified_label: &str,
    file_added_label: &str,
    file_removed_label: &str,
    file_renamed_label: &str,
    hunk_label: &str,
) -> String {
    if show_themes {
        "^Theme:".to_string()
    } else {
        let optional_regexp = |find: &str| {
            if !find.is_empty() {
                format!("|{}", regex::escape(find))
            } else {
                "".to_string()
            }
        };
        format!(
            "^(commit{}{}{}{}{})",
            optional_regexp(file_added_label),
            optional_regexp(file_removed_label),
            optional_regexp(file_renamed_label),
            optional_regexp(file_modified_label),
            optional_regexp(hunk_label),
        )
    }
}

// Create a less history file to be used by delta's child less process. This file is initialized
// with the contents of user's real less hist file, to which the navigate regex is appended. This
// has the effect that 'n' or 'N' in delta's less process will search for the navigate regex,
// without the undesirable aspects of using --pattern, yet without polluting the user's less search
// history with delta's navigate regex. See
// https://github.com/dandavison/delta/issues/237#issuecomment-780654036. Note that with the
// current implementation, no writes to the delta less history file are propagated back to the real
// history file so, for example, a (non-navigate) search performed in the delta less process will
// not be stored in history.
pub fn copy_less_hist_file_and_append_navigate_regex(
    config: &PagerCfg,
) -> std::io::Result<PathBuf> {
    let delta_less_hist_file = get_delta_less_hist_file()?;
    let initial_contents = ".less-history-file:\n".to_string();
    let mut contents = if let Some(hist_file) = get_less_hist_file() {
        std::fs::read_to_string(hist_file).unwrap_or(initial_contents)
    } else {
        initial_contents
    };
    if !contents.ends_with(".search\n") {
        contents = format!("{contents}.search\n");
    }
    writeln!(
        std::fs::File::create(&delta_less_hist_file)?,
        "{}\"{}",
        contents,
        config.navigate_regex.as_ref().unwrap(),
    )?;
    Ok(delta_less_hist_file)
}

#[cfg(target_os = "windows")]
fn get_delta_less_hist_file() -> std::io::Result<PathBuf> {
    let mut path = dirs::data_local_dir()
        .ok_or_else(|| Error::new(ErrorKind::NotFound, "Can't find AppData\\Local folder"))?;
    path.push("delta");
    std::fs::create_dir_all(&path)?;
    path.push("delta.lesshst");
    Ok(path)
}

#[cfg(not(target_os = "windows"))]
fn get_delta_less_hist_file() -> std::io::Result<PathBuf> {
    let dir = xdg::BaseDirectories::with_prefix("delta")?;
    dir.place_data_file("lesshst")
}

// Get path of the less history file. See `man less` for more details.
// On Unix, check all possible locations and pick the newest file.
fn get_less_hist_file() -> Option<PathBuf> {
    if let Some(home_dir) = dirs::home_dir() {
        match std::env::var("LESSHISTFILE").as_deref() {
            Ok("-") | Ok("/dev/null") => {
                // The user has explicitly disabled less history.
                None
            }
            Ok(path) => {
                // The user has specified a custom histfile.
                Some(PathBuf::from(path))
            }
            Err(_) => {
                // The user is using the default less histfile location.
                #[cfg(unix)]
                {
                    // According to the less 643 manual:
                    // "$XDG_STATE_HOME/lesshst" or "$HOME/.local/state/lesshst" or
                    // "$XDG_DATA_HOME/lesshst" or "$HOME/.lesshst".
                    let xdg_dirs = xdg::BaseDirectories::new().ok()?;
                    [
                        xdg_dirs.get_state_home().join("lesshst"),
                        xdg_dirs.get_data_home().join("lesshst"),
                        home_dir.join(".lesshst"),
                    ]
                    .iter()
                    .filter(|path| path.exists())
                    .max_by_key(|path| {
                        std::fs::metadata(path)
                            .and_then(|m| m.modified())
                            .unwrap_or(std::time::UNIX_EPOCH)
                    })
                    .cloned()
                }
                #[cfg(not(unix))]
                {
                    let mut hist_file = home_dir;
                    hist_file.push(if cfg!(windows) {
                        "_lesshst"
                    } else {
                        ".lesshst"
                    });
                    Some(hist_file)
                }
            }
        }
    } else {
        None
    }
}

#[cfg(test)]
mod tests {
    use std::fs::remove_file;

    use crate::tests::integration_test_utils;

    #[test]
    #[ignore]
    // manually verify: cargo test -- test_get_less_hist_file --ignored --nocapture
    fn test_get_less_hist_file() {
        let hist_file = super::get_less_hist_file();
        dbg!(hist_file);
    }

    #[test]
    fn test_navigate_with_overridden_key_in_main_section() {
        let git_config_contents = b"
[delta]
    features = navigate
    file-modified-label = \"modified: \"
";
        let git_config_path = "delta__test_navigate_with_overridden_key_in_main_section.gitconfig";

        assert_eq!(
            integration_test_utils::make_options_from_args_and_git_config(&[], None, None)
                .file_modified_label,
            ""
        );
        assert_eq!(
            integration_test_utils::make_options_from_args_and_git_config(
                &["--features", "navigate"],
                None,
                None
            )
            .file_modified_label,
            "Δ"
        );
        assert_eq!(
            integration_test_utils::make_options_from_args_and_git_config(
                &["--navigate"],
                None,
                None
            )
            .file_modified_label,
            "Δ"
        );
        assert_eq!(
            integration_test_utils::make_options_from_args_and_git_config(
                &[],
                Some(git_config_contents),
                Some(git_config_path)
            )
            .file_modified_label,
            "modified: "
        );

        remove_file(git_config_path).unwrap();
    }

    #[test]
    fn test_navigate_with_overridden_key_in_custom_navigate_section() {
        let git_config_contents = b"
[delta]
    features = navigate

[delta \"navigate\"]
    file-modified-label = \"modified: \"
";
        let git_config_path =
            "delta__test_navigate_with_overridden_key_in_custom_navigate_section.gitconfig";

        assert_eq!(
            integration_test_utils::make_options_from_args_and_git_config(&[], None, None)
                .file_modified_label,
            ""
        );
        assert_eq!(
            integration_test_utils::make_options_from_args_and_git_config(
                &["--features", "navigate"],
                None,
                None
            )
            .file_modified_label,
            "Δ"
        );
        assert_eq!(
            integration_test_utils::make_options_from_args_and_git_config(
                &[],
                Some(git_config_contents),
                Some(git_config_path)
            )
            .file_modified_label,
            "modified: "
        );

        remove_file(git_config_path).unwrap();
    }

    #[test]
    fn test_navigate_activated_by_custom_feature() {
        let git_config_contents = b"
[delta \"my-navigate-feature\"]
    features = navigate
    file-modified-label = \"modified: \"
";
        let git_config_path = "delta__test_navigate_activated_by_custom_feature.gitconfig";

        assert_eq!(
            integration_test_utils::make_options_from_args_and_git_config(
                &[],
                Some(git_config_contents),
                Some(git_config_path)
            )
            .file_modified_label,
            ""
        );
        assert_eq!(
            integration_test_utils::make_options_from_args_and_git_config(
                &["--features", "my-navigate-feature"],
                Some(git_config_contents),
                Some(git_config_path)
            )
            .file_modified_label,
            "modified: "
        );

        remove_file(git_config_path).unwrap();
    }
}