1use std::collections::BTreeMap;
2use std::fs;
3use std::io::ErrorKind;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6
7use anyhow::{Context, Result, bail};
8use serde::{Deserialize, Serialize};
9
10const PR_CACHE_VERSION: u32 = 1;
11
12#[derive(Debug, Clone)]
13pub struct PrCache {
14 path: PathBuf,
15 entries_by_remote: BTreeMap<String, PrCacheRemoteEntry>,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct PrCacheRemoteEntry {
20 pub remote_url: String,
21 pub refreshed_at: i64,
22 pub default_branch: String,
23 pub pull_requests_by_head: BTreeMap<String, CachedPullRequestRecord>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct CachedPullRequestRecord {
28 pub state: String,
29 pub url: String,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33struct PrCacheFile {
34 version: u32,
35 entries_by_remote: BTreeMap<String, PrCacheRemoteEntry>,
36}
37
38impl PrCache {
39 pub fn new(repo: &Path) -> Result<Self> {
40 Ok(Self {
41 path: pr_cache_path(repo)?,
42 entries_by_remote: BTreeMap::new(),
43 })
44 }
45
46 pub fn load(repo: &Path) -> Result<Self> {
47 let path = pr_cache_path(repo)?;
48 if !path.exists() {
49 return Ok(Self {
50 path,
51 entries_by_remote: BTreeMap::new(),
52 });
53 }
54
55 let contents = fs::read_to_string(&path)
56 .with_context(|| format!("failed to read PR cache {}", path.display()))?;
57 let parsed = serde_json::from_str::<PrCacheFile>(&contents)
58 .with_context(|| format!("failed to parse PR cache {}", path.display()))?;
59
60 if parsed.version != PR_CACHE_VERSION {
61 bail!(
62 "unsupported PR cache version {} in {}",
63 parsed.version,
64 path.display()
65 );
66 }
67
68 Ok(Self {
69 path,
70 entries_by_remote: parsed.entries_by_remote,
71 })
72 }
73
74 pub fn path(&self) -> &Path {
75 &self.path
76 }
77
78 pub fn remote_entry(&self, remote: &str, remote_url: &str) -> Option<&PrCacheRemoteEntry> {
79 self.entries_by_remote
80 .get(remote)
81 .filter(|entry| entry.remote_url == remote_url)
82 }
83
84 pub fn replace_remote(&mut self, remote: &str, entry: PrCacheRemoteEntry) -> Result<()> {
85 self.entries_by_remote.insert(remote.to_string(), entry);
86 self.persist()
87 }
88
89 fn persist(&self) -> Result<()> {
90 if self.entries_by_remote.is_empty() {
91 match fs::remove_file(&self.path) {
92 Ok(()) => {}
93 Err(error) if error.kind() == ErrorKind::NotFound => {}
94 Err(error) => {
95 return Err(error).with_context(|| {
96 format!("failed to remove PR cache {}", self.path.display())
97 });
98 }
99 }
100 return Ok(());
101 }
102
103 let parent = self
104 .path
105 .parent()
106 .context("PR cache path missing parent directory")?;
107 fs::create_dir_all(parent)
108 .with_context(|| format!("failed to create PR cache dir {}", parent.display()))?;
109
110 let file = PrCacheFile {
111 version: PR_CACHE_VERSION,
112 entries_by_remote: self.entries_by_remote.clone(),
113 };
114 let contents =
115 serde_json::to_string_pretty(&file).context("failed to serialize PR cache")?;
116 fs::write(&self.path, format!("{contents}\n"))
117 .with_context(|| format!("failed to write PR cache {}", self.path.display()))
118 }
119}
120
121fn pr_cache_path(repo: &Path) -> Result<PathBuf> {
122 let output = Command::new("git")
123 .args(["rev-parse", "--path-format=absolute", "--git-common-dir"])
124 .current_dir(repo)
125 .stdout(Stdio::piped())
126 .stderr(Stdio::piped())
127 .output()
128 .context("failed to resolve git common dir")?;
129
130 if !output.status.success() {
131 bail!(
132 "failed to resolve git common dir: {}",
133 String::from_utf8_lossy(&output.stderr).trim()
134 );
135 }
136
137 let common_dir =
138 String::from_utf8(output.stdout).context("git returned non-utf8 common dir output")?;
139 Ok(PathBuf::from(common_dir.trim()).join("git-broom/pr-cache.json"))
140}