ghr-cli 0.1.0

A fast terminal dashboard for GitHub pull requests, issues, and notifications.
use std::fs;
use std::path::Path;

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

use crate::model::SectionKind;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
    pub defaults: Defaults,
    pub exclude_repos: Vec<String>,
    pub pr_sections: Vec<SearchSection>,
    pub issue_sections: Vec<SearchSection>,
    pub notification_sections: Vec<SearchSection>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Defaults {
    pub view: SectionKind,
    pub pr_limit: usize,
    pub issue_limit: usize,
    pub notification_limit: usize,
    pub refetch_interval_seconds: u64,
    pub include_read_notifications: bool,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct SearchSection {
    pub title: String,
    #[serde(skip_serializing_if = "String::is_empty")]
    pub filters: String,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub queries: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub limit: Option<usize>,
}

impl SearchSection {
    pub fn search_filters(&self) -> Vec<String> {
        if self.queries.is_empty() {
            vec![self.filters.clone()]
        } else {
            self.queries.clone()
        }
        .into_iter()
        .filter(|query| !query.trim().is_empty())
        .collect()
    }

    pub fn display_filters(&self) -> String {
        if self.queries.is_empty() {
            self.filters.clone()
        } else {
            self.queries.join(" | ")
        }
    }
}

impl Config {
    pub fn load_or_create(path: &Path) -> Result<Self> {
        if path.exists() {
            let content = fs::read_to_string(path)
                .with_context(|| format!("failed to read {}", path.display()))?;
            let config = toml::from_str(&content)
                .with_context(|| format!("failed to parse {}", path.display()))?;
            return Ok(config);
        }

        let config = Self::default();
        let content = toml::to_string_pretty(&config).context("failed to encode default config")?;
        fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))?;
        Ok(config)
    }
}

impl Default for Config {
    fn default() -> Self {
        Self {
            defaults: Defaults::default(),
            exclude_repos: vec![],
            pr_sections: vec![
                SearchSection {
                    title: "My Pull Requests".to_string(),
                    filters: "is:open author:@me archived:false sort:updated-desc".to_string(),
                    queries: Vec::new(),
                    limit: None,
                },
                SearchSection {
                    title: "Assigned to Me".to_string(),
                    filters: "is:open assignee:@me archived:false sort:updated-desc".to_string(),
                    queries: Vec::new(),
                    limit: None,
                },
                SearchSection {
                    title: "All Requests".to_string(),
                    filters: String::new(),
                    queries: vec![
                        "author:@me archived:false sort:updated-desc".to_string(),
                        "involves:@me -author:@me archived:false sort:updated-desc".to_string(),
                        "reviewed-by:@me -author:@me archived:false sort:updated-desc".to_string(),
                    ],
                    limit: None,
                },
            ],
            issue_sections: vec![
                SearchSection {
                    title: "Assigned to Me".to_string(),
                    filters: "is:open assignee:@me archived:false sort:updated-desc".to_string(),
                    queries: Vec::new(),
                    limit: None,
                },
                SearchSection {
                    title: "Mentioned".to_string(),
                    filters: "is:open mentions:@me archived:false sort:updated-desc".to_string(),
                    queries: Vec::new(),
                    limit: None,
                },
                SearchSection {
                    title: "Involved".to_string(),
                    filters: "is:open involves:@me archived:false sort:updated-desc".to_string(),
                    queries: Vec::new(),
                    limit: None,
                },
            ],
            notification_sections: vec![
                SearchSection {
                    title: "Unread".to_string(),
                    filters: "is:unread".to_string(),
                    queries: Vec::new(),
                    limit: None,
                },
                SearchSection {
                    title: "Review Requested".to_string(),
                    filters: "reason:review-requested".to_string(),
                    queries: Vec::new(),
                    limit: None,
                },
                SearchSection {
                    title: "Assigned".to_string(),
                    filters: "reason:assign".to_string(),
                    queries: Vec::new(),
                    limit: None,
                },
                SearchSection {
                    title: "Mentioned".to_string(),
                    filters: "reason:mention".to_string(),
                    queries: Vec::new(),
                    limit: None,
                },
                SearchSection {
                    title: "All".to_string(),
                    filters: "is:all".to_string(),
                    queries: Vec::new(),
                    limit: None,
                },
            ],
        }
    }
}

impl Default for Defaults {
    fn default() -> Self {
        Self {
            view: SectionKind::PullRequests,
            pr_limit: 50,
            issue_limit: 50,
            notification_limit: 50,
            refetch_interval_seconds: 120,
            include_read_notifications: true,
        }
    }
}

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

    #[test]
    fn parses_pull_requests_view_name() {
        let config = toml::from_str::<Config>(
            r#"
            [defaults]
            view = "pull_requests"
            "#,
        )
        .expect("pull_requests should be a valid default view");

        assert_eq!(config.defaults.view, SectionKind::PullRequests);
    }

    #[test]
    fn default_config_round_trips_through_toml() {
        let encoded = toml::to_string_pretty(&Config::default()).expect("encode default config");

        assert!(
            encoded.contains(r#"view = "pull_requests""#),
            "default config should use the public snake_case view name"
        );

        let decoded = toml::from_str::<Config>(&encoded).expect("decode generated default config");
        assert_eq!(decoded.defaults.view, SectionKind::PullRequests);
        assert!(!decoded.pr_sections.is_empty());
        assert!(!decoded.issue_sections.is_empty());
        assert!(!decoded.notification_sections.is_empty());
    }

    #[test]
    fn default_pr_sections_match_dashboard_order() {
        let config = Config::default();
        let titles = config
            .pr_sections
            .iter()
            .map(|section| section.title.as_str())
            .collect::<Vec<_>>();

        assert_eq!(
            titles,
            vec!["My Pull Requests", "Assigned to Me", "All Requests"]
        );
        assert_eq!(config.pr_sections[2].queries.len(), 3);
        assert!(
            config.pr_sections[2]
                .queries
                .iter()
                .any(|query| query.contains("reviewed-by:@me"))
        );
    }

    #[test]
    fn query_sections_use_queries_instead_of_filter() {
        let section = SearchSection {
            title: "All Requests".to_string(),
            filters: String::new(),
            queries: vec![
                "author:@me sort:updated-desc".to_string(),
                "reviewed-by:@me sort:updated-desc".to_string(),
            ],
            limit: None,
        };

        assert_eq!(
            section.search_filters(),
            vec![
                "author:@me sort:updated-desc".to_string(),
                "reviewed-by:@me sort:updated-desc".to_string()
            ]
        );
        assert_eq!(
            section.display_filters(),
            "author:@me sort:updated-desc | reviewed-by:@me sort:updated-desc"
        );
    }

    #[test]
    fn parses_existing_config_shape() {
        let config = toml::from_str::<Config>(
            r#"
            exclude_repos = ["nervosnetwork/archive-*"]

            [defaults]
            view = "pull_requests"
            pr_limit = 50
            issue_limit = 50
            notification_limit = 50
            refetch_interval_seconds = 120
            include_read_notifications = true

            [[pr_sections]]
            title = "Assigned to Me"
            filters = "is:open assignee:@me archived:false sort:updated-desc"

            [[issue_sections]]
            title = "Assigned to Me"
            filters = "is:open assignee:@me archived:false sort:updated-desc"

            [[notification_sections]]
            title = "Unread"
            filters = "is:unread"
            "#,
        )
        .expect("existing config should parse");

        assert_eq!(config.defaults.view, SectionKind::PullRequests);
        assert_eq!(config.exclude_repos, vec!["nervosnetwork/archive-*"]);
        assert_eq!(config.pr_sections[0].title, "Assigned to Me");
    }
}