use std::cell::OnceCell;
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use thiserror::Error;
use crate::git::GitVcs;
use crate::model::DiffModel;
use crate::session::Session;
use crate::source::ReviewSource;
use crate::store::{self, StoreError};
use crate::vcs::{StatusModel, Vcs, VcsError};
#[derive(Debug, Error)]
pub enum ReviewError {
#[error(transparent)]
Vcs(#[from] VcsError),
#[error(transparent)]
Store(#[from] StoreError),
}
#[derive(Debug)]
pub struct FileSnapshot {
pub path: String,
pub content: String,
pub blame: Vec<crate::vcs::BlameSpan>,
}
#[derive(Debug)]
pub struct Refreshed {
pub status: StatusModel,
pub model: DiffModel,
pub against: Option<(String, Result<DiffModel, VcsError>)>,
}
pub struct Review {
pub repo_root: PathBuf,
pub vcs: Box<dyn Vcs>,
pub status: StatusModel,
model: OnceCell<DiffModel>,
pub session: Session,
sources: HashMap<String, (ReviewSource, Session)>,
empty: Session,
}
impl Review {
pub fn open(repo_root: &Path) -> Result<Self, ReviewError> {
Self::open_with_context(repo_root, crate::git::DEFAULT_CONTEXT_LINES)
}
pub fn open_with_context(repo_root: &Path, context_lines: u32) -> Result<Self, ReviewError> {
let vcs: Box<dyn Vcs> = Box::new(GitVcs::open_with_context(repo_root, context_lines)?);
let status = vcs.status()?;
let session = store::load(repo_root)?;
Ok(Self {
repo_root: repo_root.to_path_buf(),
vcs,
status,
model: OnceCell::new(),
session,
sources: HashMap::new(),
empty: Session::default(),
})
}
pub fn model(&self) -> &DiffModel {
self.model
.get_or_init(|| self.vcs.working_tree_diff().unwrap_or_default())
}
pub fn model_mut(&mut self) -> &mut DiffModel {
self.model();
#[allow(clippy::expect_used)]
self.model.get_mut().expect("model just initialized")
}
pub fn refresh(&mut self) -> Result<(), ReviewError> {
self.status = self.vcs.status()?;
let model = self.vcs.working_tree_diff()?;
self.install_refresh(self.status.clone(), model);
Ok(())
}
pub fn compute_refresh(
repo_root: &Path,
context_lines: u32,
against: Option<&str>,
) -> Result<Refreshed, ReviewError> {
let vcs = GitVcs::open_with_context(repo_root, context_lines)?;
let status = vcs.status()?;
let model = vcs.working_tree_diff()?;
let against = against.map(|rev| (rev.to_owned(), crate::vcs::against_diff(&vcs, rev)));
Ok(Refreshed {
status,
model,
against,
})
}
pub fn compute_declared(
repo_root: &Path,
paths: &[String],
) -> Result<HashMap<String, crate::classify::Kind>, ReviewError> {
let vcs = GitVcs::open(repo_root)?;
Ok(paths
.iter()
.filter_map(|path| {
let rel = Path::new(path);
let kind = crate::classify::declared(|name| vcs.attr(rel, name))?;
Some((path.clone(), kind))
})
.collect())
}
pub fn compute_file(repo_root: &Path, rel: &str) -> Result<FileSnapshot, ReviewError> {
let vcs = GitVcs::open(repo_root)?;
let path = Path::new(rel);
let content = std::fs::read_to_string(repo_root.join(path)).map_err(VcsError::from)?;
Ok(FileSnapshot {
path: rel.to_owned(),
blame: vcs.blame(path).unwrap_or_default(),
content,
})
}
pub fn install_refresh(&mut self, status: StatusModel, model: DiffModel) {
self.status = status;
self.session.reconcile(&model);
self.model = OnceCell::from(model);
}
pub fn save(&self) -> Result<(), ReviewError> {
store::save(&self.repo_root, &self.session)?;
Ok(())
}
pub fn ensure_source(&mut self, source: &ReviewSource) -> Result<(), ReviewError> {
if matches!(source, ReviewSource::WorkingTree) {
return Ok(());
}
let key = source.key();
if !self.sources.contains_key(&key) {
let session = store::load_source(&self.repo_root, source)?;
self.sources.insert(key, (source.clone(), session));
}
Ok(())
}
pub fn session_for(&self, source: &ReviewSource) -> &Session {
match source {
ReviewSource::WorkingTree => &self.session,
other => self
.sources
.get(&other.key())
.map_or(&self.empty, |(_, session)| session),
}
}
pub fn session_for_mut(&mut self, source: &ReviewSource) -> &mut Session {
match source {
ReviewSource::WorkingTree => &mut self.session,
other => {
&mut self
.sources
.entry(other.key())
.or_insert_with(|| (other.clone(), Session::default()))
.1
}
}
}
pub fn save_for(&self, source: &ReviewSource) -> Result<(), ReviewError> {
store::save_source(&self.repo_root, source, self.session_for(source))?;
Ok(())
}
pub fn all_reviews(&self) -> Result<Vec<(ReviewSource, Session)>, ReviewError> {
let mut by_key: BTreeMap<String, (ReviewSource, Session)> =
store::load_all(&self.repo_root)?
.into_iter()
.map(|(source, session)| (source.key(), (source, session)))
.collect();
by_key.insert(
ReviewSource::WorkingTree.key(),
(ReviewSource::WorkingTree, self.session.clone()),
);
for (key, (source, session)) in &self.sources {
by_key.insert(key.clone(), (source.clone(), session.clone()));
}
Ok(by_key.into_values().collect())
}
pub fn restore_model(&mut self, model: DiffModel) {
self.model = OnceCell::from(model);
}
#[cfg(test)]
fn model_is_cached(&self) -> bool {
self.model.get().is_some()
}
}
#[cfg(test)]
mod tests {
use crate::repo;
use super::*;
#[allow(clippy::expect_used)]
fn write(root: &std::path::Path, rel: &str, content: &str) {
std::fs::write(root.join(rel), content).expect("write");
}
#[allow(clippy::expect_used)]
fn commit_all(root: &std::path::Path, message: &str) {
for args in [&["add", "-A"][..], &["commit", "-q", "-m", message][..]] {
let status = std::process::Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t")
.status()
.expect("git");
assert!(status.success(), "git {args:?}");
}
}
#[allow(clippy::expect_used)]
fn init_repo(root: &std::path::Path) {
let status = std::process::Command::new("git")
.arg("-C")
.arg(root)
.args(["init", "-q"])
.status()
.expect("git init");
assert!(status.success());
}
#[test]
fn open_defers_the_working_model_until_first_access() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
init_repo(root);
write(root, "a.py", "value = old\n");
commit_all(root, "base");
write(root, "a.py", "value = new\n");
let root = repo::discover(root).expect("discover");
let review = Review::open(&root).expect("open");
assert!(
!review.model_is_cached(),
"open must not compute the working model"
);
assert_eq!(review.status.unstaged.files.len(), 1);
let lazy = review.model().clone();
assert!(review.model_is_cached(), "access caches the model");
let eager = review.vcs.working_tree_diff().expect("diff");
assert_eq!(lazy, eager, "lazy model equals the eager build");
}
#[allow(clippy::expect_used)]
fn git(root: &std::path::Path, args: &[&str]) {
let status = std::process::Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.status()
.expect("git");
assert!(status.success(), "git {args:?}");
}
#[test]
fn against_a_base_branch_shows_committed_and_uncommitted_work() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
init_repo(root);
git(root, &["symbolic-ref", "HEAD", "refs/heads/main"]);
write(root, "base.txt", "base\n");
commit_all(root, "base");
git(root, &["checkout", "-q", "-b", "feature"]);
write(root, "committed.txt", "landed\n");
commit_all(root, "feature work");
git(root, &["checkout", "-q", "main"]);
write(root, "elsewhere.txt", "not mine\n");
commit_all(root, "base moved on");
git(root, &["checkout", "-q", "feature"]);
write(root, "dirty.txt", "still editing\n");
let root = repo::discover(root).expect("discover");
let review = Review::open(&root).expect("open");
let model = crate::vcs::against_diff(review.vcs.as_ref(), "main").expect("against");
let paths: Vec<&str> = model.files.iter().map(|f| f.path.as_str()).collect();
assert_eq!(paths, ["committed.txt", "dirty.txt"]);
}
#[test]
fn per_source_sessions_persist_independently_and_aggregate() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
init_repo(root);
write(root, "a.py", "value = old\n");
commit_all(root, "base");
write(root, "a.py", "value = new\n");
let root = repo::discover(root).expect("discover");
let mut review = Review::open(&root).expect("open");
let commit = crate::source::ReviewSource::commit("deadbeef");
review.ensure_source(&commit).expect("ensure");
review
.session_for_mut(&commit)
.mark_viewed("a.py", "hash-commit");
review.save_for(&commit).expect("save commit");
review.session.mark_viewed("a.py", "hash-working");
review.save().expect("save working");
assert!(review.session_for(&commit).is_viewed("a.py", "hash-commit"));
assert!(!review.session.is_viewed("a.py", "hash-commit"));
let mut reopened = Review::open(&root).expect("reopen");
reopened.ensure_source(&commit).expect("ensure");
assert!(
reopened
.session_for(&commit)
.is_viewed("a.py", "hash-commit")
);
assert!(reopened.session.is_viewed("a.py", "hash-working"));
let all = reopened.all_reviews().expect("all");
let keys: Vec<String> = all.iter().map(|(s, _)| s.key()).collect();
assert_eq!(keys, ["commit-deadbeef", "working"]);
}
}