awase 0.1.1

Awase (合わせ) — global hotkey abstraction: key types, parser, and platform-agnostic manager trait
Documentation
use crate::chord::KeyChord;
use crate::hotkey::Hotkey;
use crate::mode::KeyMode;

/// A single detected conflict.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConflictEntry {
    /// The mode where the conflict was found.
    pub mode: String,
    /// The conflicting hotkey.
    pub hotkey: Hotkey,
    /// Description of the existing binding/chord.
    pub existing: String,
    /// Description of the conflicting binding/chord.
    pub new: String,
}

/// Report of all detected conflicts in a binding configuration.
#[derive(Debug, Clone, Default)]
pub struct ConflictReport {
    pub conflicts: Vec<ConflictEntry>,
}

impl ConflictReport {
    /// Returns `true` if no conflicts were found.
    #[must_use]
    pub fn is_clean(&self) -> bool {
        self.conflicts.is_empty()
    }
}

/// Detect conflicts within a set of modes and chords.
///
/// Detected conflicts:
/// - Same hotkey bound twice in the same mode (only possible with external
///   config merging — `HashMap` insert deduplicates in normal use)
/// - Chord leader that conflicts with a regular binding in the same mode
#[must_use]
pub fn detect_conflicts(modes: &[&KeyMode], chords: &[KeyChord]) -> ConflictReport {
    let mut report = ConflictReport::default();

    // Check chord leaders vs mode bindings
    for mode in modes {
        for chord in chords {
            if mode.bindings.contains_key(&chord.leader) {
                report.conflicts.push(ConflictEntry {
                    mode: mode.name.clone(),
                    hotkey: chord.leader,
                    existing: format!(
                        "binding: {:?}",
                        mode.bindings[&chord.leader].action
                    ),
                    new: format!("chord leader (follower: {})", chord.follower),
                });
            }
        }
    }

    report
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::action::Action;
    use crate::binding::Binding;
    use crate::hotkey::{Key, Modifiers};

    fn ctrl_a() -> Hotkey {
        Hotkey::new(Modifiers::CTRL, Key::A)
    }

    #[test]
    fn no_conflicts() {
        let mode = KeyMode::new("default", true);
        let report = detect_conflicts(&[&mode], &[]);
        assert!(report.is_clean());
    }

    #[test]
    fn chord_leader_conflicts_with_binding() {
        let mut mode = KeyMode::new("default", true);
        mode.add_binding(Binding::new(ctrl_a(), Action::command("select_all")));

        let chord = KeyChord {
            leader: ctrl_a(),
            follower: Hotkey::new(Modifiers::NONE, Key::C),
            timeout_ms: 1000,
            action: Action::command("new_window"),
        };

        let report = detect_conflicts(&[&mode], &[chord]);
        assert!(!report.is_clean());
        assert_eq!(report.conflicts.len(), 1);
        assert_eq!(report.conflicts[0].mode, "default");
        assert_eq!(report.conflicts[0].hotkey, ctrl_a());
    }

    #[test]
    fn chord_no_conflict_when_leader_not_bound() {
        let mut mode = KeyMode::new("default", true);
        mode.add_binding(Binding::new(
            Hotkey::new(Modifiers::CMD, Key::H),
            Action::command("focus_west"),
        ));

        let chord = KeyChord {
            leader: ctrl_a(),
            follower: Hotkey::new(Modifiers::NONE, Key::C),
            timeout_ms: 1000,
            action: Action::command("new_window"),
        };

        let report = detect_conflicts(&[&mode], &[chord]);
        assert!(report.is_clean());
    }

    #[test]
    fn conflict_in_specific_mode() {
        let mut resize = KeyMode::new("resize", false);
        resize.add_binding(Binding::new(ctrl_a(), Action::command("shrink")));

        let default = KeyMode::new("default", true);

        let chord = KeyChord {
            leader: ctrl_a(),
            follower: Hotkey::new(Modifiers::NONE, Key::C),
            timeout_ms: 1000,
            action: Action::command("new_window"),
        };

        let report = detect_conflicts(&[&default, &resize], &[chord]);
        assert_eq!(report.conflicts.len(), 1);
        assert_eq!(report.conflicts[0].mode, "resize");
    }

    // ── Additional conflict detection tests ─────────────────────────

    #[test]
    fn no_modes_no_conflicts() {
        let report = detect_conflicts(&[], &[]);
        assert!(report.is_clean());
    }

    #[test]
    fn chords_only_no_modes_no_conflicts() {
        let chord = KeyChord {
            leader: ctrl_a(),
            follower: Hotkey::new(Modifiers::NONE, Key::C),
            timeout_ms: 1000,
            action: Action::command("test"),
        };
        let report = detect_conflicts(&[], &[chord]);
        assert!(report.is_clean());
    }

    #[test]
    fn modes_only_no_chords_no_conflicts() {
        let mut mode = KeyMode::new("default", true);
        mode.add_binding(Binding::new(ctrl_a(), Action::command("test")));
        let report = detect_conflicts(&[&mode], &[]);
        assert!(report.is_clean());
    }

    #[test]
    fn same_chord_conflicts_in_multiple_modes() {
        let mut default = KeyMode::new("default", true);
        default.add_binding(Binding::new(ctrl_a(), Action::command("select_all")));

        let mut resize = KeyMode::new("resize", false);
        resize.add_binding(Binding::new(ctrl_a(), Action::command("shrink")));

        let chord = KeyChord {
            leader: ctrl_a(),
            follower: Hotkey::new(Modifiers::NONE, Key::C),
            timeout_ms: 1000,
            action: Action::command("new_window"),
        };

        let report = detect_conflicts(&[&default, &resize], &[chord]);
        assert_eq!(report.conflicts.len(), 2);
        let modes: Vec<&str> = report.conflicts.iter().map(|c| c.mode.as_str()).collect();
        assert!(modes.contains(&"default"));
        assert!(modes.contains(&"resize"));
    }

    #[test]
    fn multiple_chords_multiple_conflicts() {
        let mut mode = KeyMode::new("default", true);
        mode.add_binding(Binding::new(ctrl_a(), Action::command("a")));
        mode.add_binding(Binding::new(
            Hotkey::new(Modifiers::CTRL, Key::B),
            Action::command("b"),
        ));

        let chord1 = KeyChord {
            leader: ctrl_a(),
            follower: Hotkey::new(Modifiers::NONE, Key::C),
            timeout_ms: 1000,
            action: Action::command("chord_a"),
        };
        let chord2 = KeyChord {
            leader: Hotkey::new(Modifiers::CTRL, Key::B),
            follower: Hotkey::new(Modifiers::NONE, Key::C),
            timeout_ms: 1000,
            action: Action::command("chord_b"),
        };

        let report = detect_conflicts(&[&mode], &[chord1, chord2]);
        assert_eq!(report.conflicts.len(), 2);
    }

    #[test]
    fn conflict_entry_contains_hotkey() {
        let mut mode = KeyMode::new("default", true);
        mode.add_binding(Binding::new(ctrl_a(), Action::command("select_all")));

        let chord = KeyChord {
            leader: ctrl_a(),
            follower: Hotkey::new(Modifiers::NONE, Key::C),
            timeout_ms: 1000,
            action: Action::command("new_window"),
        };

        let report = detect_conflicts(&[&mode], &[chord]);
        assert_eq!(report.conflicts[0].hotkey, ctrl_a());
        assert!(report.conflicts[0].existing.contains("select_all"));
        assert!(report.conflicts[0].new.contains("chord leader"));
    }

    #[test]
    fn conflict_report_is_clean_true_when_empty() {
        let report = ConflictReport::default();
        assert!(report.is_clean());
    }

    #[test]
    fn conflict_report_is_clean_false_with_entries() {
        let report = ConflictReport {
            conflicts: vec![ConflictEntry {
                mode: "default".to_string(),
                hotkey: ctrl_a(),
                existing: "a".to_string(),
                new: "b".to_string(),
            }],
        };
        assert!(!report.is_clean());
    }
}