use std::io::Write;
use std::path::{Path, PathBuf};
use crate::store::{Account, ReviewState, StoreError};
#[derive(Debug, Clone)]
pub struct Reviews {
dir: PathBuf,
account: Account,
}
impl Reviews {
pub fn open(dir: &Path) -> Result<Self, StoreError> {
std::fs::create_dir_all(dir)?;
Ok(Self {
dir: dir.to_owned(),
account: Account::default(),
})
}
#[must_use]
pub fn for_account(self, account: Account) -> Self {
Self { account, ..self }
}
fn path(&self, owner: &str, repo: &str, number: u64) -> Result<PathBuf, StoreError> {
for part in [owner, repo] {
if part.is_empty() || part == "." || part == ".." || part.contains(['/', '\\', '\0']) {
return Err(StoreError::Io(std::io::Error::other(format!(
"bad repository name {part:?}"
))));
}
}
Ok(self
.dir
.join(self.account.scope())
.join(owner)
.join(repo)
.join(format!("{number}.json")))
}
pub fn get(&self, owner: &str, repo: &str, number: u64) -> Result<ReviewState, StoreError> {
let path = self.path(owner, repo, number)?;
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(ReviewState::default());
}
Err(err) => return Err(err.into()),
};
match serde_json::from_slice(&bytes) {
Ok(state) => Ok(state),
Err(err) => {
let aside = path.with_extension(format!("corrupt-{}.json", crate::store::now()));
tracing::warn!(%err, path = %path.display(), aside = %aside.display(), "review file unreadable; moved aside");
std::fs::rename(&path, &aside)?;
Ok(ReviewState::default())
}
}
}
pub fn put(
&self,
owner: &str,
repo: &str,
number: u64,
state: &ReviewState,
) -> Result<(), StoreError> {
let path = self.path(owner, repo, number)?;
if *state == ReviewState::default() {
return match std::fs::remove_file(&path) {
Err(err) if err.kind() != std::io::ErrorKind::NotFound => Err(err.into()),
_ => Ok(()),
};
}
let dir = path.parent().unwrap_or(&self.dir);
std::fs::create_dir_all(dir)?;
let mut file = tempfile::NamedTempFile::new_in(dir)?;
file.write_all(&serde_json::to_vec_pretty(state)?)?;
file.as_file().sync_all()?;
file.persist(&path).map_err(|e| StoreError::Io(e.error))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::DraftComment;
fn draft(body: &str) -> ReviewState {
ReviewState {
pending: vec![DraftComment {
id: 1,
path: "a.rs".into(),
body: body.into(),
line: Some(3),
commit: "abc".into(),
..Default::default()
}],
..ReviewState::default()
}
}
#[test]
fn round_trips_and_empty_removes() {
let dir = tempfile::tempdir().unwrap();
let reviews = Reviews::open(dir.path()).unwrap();
let file = dir.path().join("o/r/1.json");
assert_eq!(reviews.get("o", "r", 1).unwrap(), ReviewState::default());
assert_eq!(ReviewState::default().next_draft_id(), 1);
reviews.put("o", "r", 1, &draft("nit")).unwrap();
assert_eq!(draft("nit").next_draft_id(), 2);
assert!(file.exists());
assert_eq!(reviews.get("o", "r", 1).unwrap(), draft("nit"));
reviews.put("o", "r", 1, &draft("better")).unwrap();
assert_eq!(reviews.get("o", "r", 1).unwrap(), draft("better"));
reviews.put("o", "r", 1, &ReviewState::default()).unwrap();
assert!(!file.exists());
}
#[test]
fn corrupt_files_are_moved_aside_not_lost() {
let dir = tempfile::tempdir().unwrap();
let reviews = Reviews::open(dir.path()).unwrap();
let path = dir.path().join("o/r/1.json");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"{not json").unwrap();
assert_eq!(reviews.get("o", "r", 1).unwrap(), ReviewState::default());
let kept: Vec<_> = std::fs::read_dir(path.parent().unwrap())
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.collect();
assert!(kept.iter().any(|n| n.contains("corrupt")), "{kept:?}");
}
#[test]
fn keeps_accounts_apart() {
let dir = tempfile::tempdir().unwrap();
let account = Account::new("github.com", "aaaa", None);
let reviews = Reviews::open(dir.path())
.unwrap()
.for_account(account.clone());
reviews.put("o", "r", 1, &draft("mine")).unwrap();
assert!(dir.path().join("github.com/token-aaaa/o/r/1.json").exists());
account.sign_in("alice");
assert_eq!(reviews.get("o", "r", 1).unwrap(), ReviewState::default());
reviews.put("o", "r", 1, &draft("alice's")).unwrap();
assert!(dir.path().join("github.com/alice/o/r/1.json").exists());
}
#[test]
fn rejects_path_tricks() {
let dir = tempfile::tempdir().unwrap();
let reviews = Reviews::open(dir.path()).unwrap();
assert!(reviews.put("..", "r", 1, &draft("x")).is_err());
assert!(reviews.put("o", "a/b", 1, &draft("x")).is_err());
}
}