vkit 0.1.4

Fast Rust dev CLI: manage git worktrees, Node ports, run scripts, install & sync VS Code / Cursor extensions.
//! 通过 `glab` 拉取分支对应的 MR 信息(无 glab / 失败则忽略)。

use std::collections::HashMap;
use std::path::Path;
use std::process::Command;

use serde::Deserialize;

/// MR 状态(GitLab:opened / merged / closed)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MrState {
    #[default]
    Opened,
    Merged,
    Closed,
}

impl MrState {
    /// 同分支多条 MR 时,优先保留更「活跃」的状态。
    fn rank(self) -> u8 {
        match self {
            Self::Opened => 3,
            Self::Merged => 2,
            Self::Closed => 1,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct MrRef {
    pub iid: u64,
    #[serde(alias = "source_branch")]
    pub source_branch: String,
    #[serde(default)]
    pub title: String,
    #[serde(default)]
    pub state: MrState,
}

impl MrRef {
    /// 展示:`!12` 打开 · `!12✓` 已合并 · `!12×` 已关闭。
    pub fn label(&self) -> String {
        match self.state {
            MrState::Opened => format!("!{}", self.iid),
            MrState::Merged => format!("!{}", self.iid),
            MrState::Closed => format!("!{}×", self.iid),
        }
    }
}

/// 列出当前项目全部 MR(含 merged/closed),按 source_branch 建索引。失败返回空 map。
pub fn index_by_source_branch(cwd: &Path) -> HashMap<String, MrRef> {
    let Ok(output) = Command::new("glab")
        .args(["mr", "list", "--all", "-F", "json", "--per-page", "100"])
        .current_dir(cwd)
        .output()
    else {
        return HashMap::new();
    };
    if !output.status.success() {
        return HashMap::new();
    }
    let raw = String::from_utf8_lossy(&output.stdout);
    parse_mr_list(&raw)
}

/// 解析 `glab mr list -F json` 输出(纯函数)。同分支多条时保留更高优先级状态。
pub fn parse_mr_list(raw: &str) -> HashMap<String, MrRef> {
    let Ok(list) = serde_json::from_str::<Vec<MrRef>>(raw.trim()) else {
        return HashMap::new();
    };
    let mut map: HashMap<String, MrRef> = HashMap::new();
    for mr in list {
        let branch = mr.source_branch.clone();
        let replace = map
            .get(&branch)
            .map(|existing| mr.state.rank() > existing.state.rank())
            .unwrap_or(true);
        if replace {
            map.insert(branch, mr);
        }
    }
    map
}

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

    #[test]
    fn parses_glab_json() {
        let raw = r#"[
          {"iid": 12, "source_branch": "feat/login", "title": "Add login", "state": "opened"},
          {"iid": 3, "source_branch": "fix-typo", "title": "typo", "state": "merged"}
        ]"#;
        let map = parse_mr_list(raw);
        assert_eq!(map["feat/login"].label(), "!12");
        assert_eq!(map["fix-typo"].label(), "!3✓");
        assert_eq!(map["fix-typo"].state, MrState::Merged);
    }

    #[test]
    fn prefers_opened_over_closed_same_branch() {
        let raw = r#"[
          {"iid": 1, "source_branch": "feat/x", "state": "closed"},
          {"iid": 2, "source_branch": "feat/x", "state": "opened"}
        ]"#;
        let map = parse_mr_list(raw);
        assert_eq!(map["feat/x"].iid, 2);
        assert_eq!(map["feat/x"].label(), "!2");
    }

    #[test]
    fn closed_label() {
        let raw = r#"[{"iid": 9, "source_branch": "old", "state": "closed"}]"#;
        assert_eq!(parse_mr_list(raw)["old"].label(), "!9×");
    }

    #[test]
    fn bad_json_yields_empty() {
        assert!(parse_mr_list("not-json").is_empty());
    }
}