use std::collections::BTreeSet;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use sha1::{Digest, Sha1};
use differential_schema as schema;
use crate::EngineError;
use crate::gitio::Repo;
use crate::model::DiffView;
pub fn review_id(base_sha: &str, head_spec: &str) -> String {
let mut h = Sha1::new();
h.update(base_sha.as_bytes());
h.update([0]);
h.update(head_spec.as_bytes());
hex::encode(h.finalize())[..16].to_string()
}
pub fn class_content_key(member_digests: &[String]) -> String {
let mut sorted: Vec<&String> = member_digests.iter().collect();
sorted.sort_unstable();
let mut h = Sha1::new();
for d in sorted {
h.update(d.as_bytes());
h.update([0]);
}
hex::encode(h.finalize())[..16].to_string()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ReviewState {
#[serde(default)]
pub reviewed_classes: BTreeSet<String>,
#[serde(default)]
pub cursor: Option<(String, usize)>,
#[serde(default)]
pub split_diff: bool,
#[serde(default)]
pub file_view: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FindingStatus {
Open,
Resolved,
Orphaned,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Anchor {
pub file: String,
pub side: String,
pub line: u32,
pub hunk_digest: String,
#[serde(default)]
pub line_text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Finding {
pub id: String,
pub created: u64,
pub body: String,
pub status: FindingStatus,
#[serde(default)]
pub moved: bool,
pub plan_hash: String,
pub anchor: Anchor,
}
impl Finding {
pub fn new(body: String, plan_hash: String, anchor: Anchor) -> Self {
let created = now_unix();
let mut h = Sha1::new();
h.update(anchor.hunk_digest.as_bytes());
h.update(created.to_le_bytes());
h.update(body.as_bytes());
Finding {
id: hex::encode(h.finalize())[..12].to_string(),
created,
body,
status: FindingStatus::Open,
moved: false,
plan_hash,
anchor,
}
}
}
fn now_unix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub struct ReviewStore {
dir: PathBuf,
}
impl ReviewStore {
pub fn open(repo: &Repo, base_sha: &str, head_spec: &str) -> Result<Self, EngineError> {
let dir = repo
.common_dir()?
.join("differential")
.join("reviews")
.join(review_id(base_sha, head_spec));
Self::open_at(dir)
}
pub fn open_at(dir: PathBuf) -> Result<Self, EngineError> {
std::fs::create_dir_all(dir.join("plans")).map_err(|e| io_err(&dir, e))?;
Ok(ReviewStore { dir })
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn save_plan(&self, doc: &schema::PlanDocument) -> Result<String, EngineError> {
let json = doc.to_json()?;
let hash = hex::encode(Sha1::digest(json.as_bytes()))[..16].to_string();
let path = self.dir.join("plans").join(format!("{hash}.json"));
if !path.exists() {
std::fs::write(&path, &json).map_err(|e| io_err(&path, e))?;
}
std::fs::write(self.dir.join("current"), &hash).map_err(|e| io_err(&self.dir, e))?;
Ok(hash)
}
pub fn load_state(&self) -> Result<ReviewState, EngineError> {
let path = self.dir.join("state.json");
match std::fs::read_to_string(&path) {
Ok(text) => serde_json::from_str(&text).map_err(|e| EngineError::Cache {
path: path.display().to_string(),
source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ReviewState::default()),
Err(e) => Err(io_err(&path, e)),
}
}
pub fn save_state(&self, state: &ReviewState) -> Result<(), EngineError> {
let path = self.dir.join("state.json");
let text = serde_json::to_string_pretty(state).expect("state serialises");
std::fs::write(&path, text).map_err(|e| io_err(&path, e))
}
pub fn load_findings(&self) -> Result<Vec<Finding>, EngineError> {
let path = self.dir.join("findings.jsonl");
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(io_err(&path, e)),
};
let mut out = Vec::new();
for (n, line) in text.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let f: Finding = serde_json::from_str(line).map_err(|e| EngineError::Cache {
path: format!("{}:{}", path.display(), n + 1),
source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
})?;
out.push(f);
}
Ok(out)
}
pub fn append_finding(&self, finding: &Finding) -> Result<(), EngineError> {
let path = self.dir.join("findings.jsonl");
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| io_err(&path, e))?;
writeln!(
file,
"{}",
serde_json::to_string(finding).expect("serialises")
)
.map_err(|e| io_err(&path, e))
}
pub fn save_findings(&self, findings: &[Finding]) -> Result<(), EngineError> {
let path = self.dir.join("findings.jsonl");
let mut text = String::new();
for f in findings {
text.push_str(&serde_json::to_string(f).expect("serialises"));
text.push('\n');
}
std::fs::write(&path, text).map_err(|e| io_err(&path, e))
}
}
pub fn reanchor(
findings: &mut [Finding],
doc: &schema::PlanDocument,
view: &DiffView,
plan_hash: &str,
) {
for f in findings.iter_mut() {
if f.plan_hash == plan_hash {
continue; }
if let Some((idx, h)) = doc
.hunks
.iter()
.enumerate()
.find(|(_, h)| h.digest == f.anchor.hunk_digest)
{
let _ = idx;
f.anchor.file = h.file.clone();
f.anchor.line = if f.anchor.side == "old" {
h.old_start.max(1)
} else {
h.new_start.max(1)
};
f.plan_hash = plan_hash.to_string();
f.moved = false;
if f.status == FindingStatus::Orphaned {
f.status = FindingStatus::Open;
}
continue;
}
let text = f.anchor.line_text.as_bytes();
let matched = (!text.is_empty())
.then(|| {
view.hunks.iter().enumerate().find(|(_, h)| {
let file = view.file_of(h);
file.path == f.anchor.file.as_bytes()
&& (h.added.iter().any(|l| l == text)
|| h.removed.iter().any(|l| l == text))
})
})
.flatten();
if let Some((hi, h)) = matched {
f.anchor.hunk_digest = doc.hunks[hi].digest.clone();
f.anchor.line = if f.anchor.side == "old" {
h.old_start.max(1)
} else {
h.new_start.max(1)
};
f.plan_hash = plan_hash.to_string();
f.moved = true;
if f.status == FindingStatus::Orphaned {
f.status = FindingStatus::Open;
}
} else {
f.status = FindingStatus::Orphaned;
}
}
}
fn io_err(path: &Path, source: std::io::Error) -> EngineError {
EngineError::Cache {
path: path.display().to_string(),
source,
}
}