use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::features::workspace_journal::Journal;
const MAX_DIFF_BYTES: u64 = 1024 * 1024;
const CONTEXT_LINES: usize = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffKind {
Hunk,
Context,
Added,
Removed,
}
#[derive(Debug, Clone, PartialEq)]
pub struct DiffLine {
pub kind: DiffKind,
pub text: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileState {
Modified,
Created,
Gone,
NotShown,
Unchanged,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FileChange {
pub path: String,
pub state: FileState,
pub added: usize,
pub removed: usize,
pub lines: Vec<DiffLine>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ChangeSet {
pub root: String,
pub files: Vec<FileChange>,
}
impl ChangeSet {
pub fn is_empty(&self) -> bool {
self.files.is_empty()
}
}
pub fn build(journal_dir: &Path, root: &str, hint: Option<&str>) -> ChangeSet {
let journal = Journal::new(journal_dir.to_path_buf());
if !journal.describes(root) {
return ChangeSet {
root: root.to_string(),
files: Vec::new(),
};
}
let files = journal
.entries()
.into_iter()
.map(|entry| {
let baseline = journal.baseline_of(&entry.path).unwrap_or_default();
diff_file(root, &entry.path, entry.existed, &baseline, hint)
})
.collect();
ChangeSet {
root: root.to_string(),
files,
}
}
fn resolve(root: &str, rel: &str) -> Option<PathBuf> {
let root = PathBuf::from(root).canonicalize().ok()?;
let joined = root.join(rel);
if joined
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return None;
}
joined.starts_with(&root).then_some(joined)
}
fn diff_file(
root: &str,
rel: &str,
existed: bool,
baseline: &[u8],
hint: Option<&str>,
) -> FileChange {
use crate::shared::text_decode;
let row = |state: FileState, lines: Vec<DiffLine>| FileChange {
path: rel.to_string(),
state,
added: 0,
removed: 0,
lines,
};
let Some(path) = resolve(root, rel) else {
return row(FileState::Gone, Vec::new());
};
let Ok(current) = std::fs::read(&path) else {
return row(FileState::Gone, Vec::new());
};
let too_large = current.len() as u64 > MAX_DIFF_BYTES || baseline.len() as u64 > MAX_DIFF_BYTES;
let markup = text_decode::is_markup_path(&path);
let current_file = (!too_large)
.then(|| text_decode::decode_file(¤t, markup, hint))
.flatten()
.filter(|_| text_decode::decode_file(baseline, markup, hint).is_some());
let Some(current_file) = current_file else {
return row(FileState::NotShown, Vec::new());
};
let before = current_file
.encoding
.decode_with_bom_removal(baseline)
.0
.replace("\r\n", "\n");
let after = current_file.text.replace("\r\n", "\n");
if before == after {
if !same_file(baseline, ¤t, current_file.encoding) {
let state = if existed {
FileState::Modified
} else {
FileState::Created
};
return row(state, Vec::new());
}
let state = if existed {
FileState::Unchanged
} else {
FileState::Created
};
return row(state, Vec::new());
}
let (lines, added, removed) = render(&before, &after);
FileChange {
path: rel.to_string(),
state: if existed {
FileState::Modified
} else {
FileState::Created
},
added,
removed,
lines,
}
}
fn same_file(baseline: &[u8], current: &[u8], encoding: &'static encoding_rs::Encoding) -> bool {
use crate::shared::text_decode;
let bom = text_decode::bom_of(encoding);
let lossless =
|bytes: &[u8]| text_decode::round_trips(bytes.strip_prefix(bom).unwrap_or(bytes), encoding);
baseline == current || (lossless(baseline) && lossless(current))
}
fn render(before: &str, after: &str) -> (Vec<DiffLine>, usize, usize) {
use similar::{ChangeTag, TextDiff};
let diff = TextDiff::from_lines(before, after);
let mut lines = Vec::new();
let (mut added, mut removed) = (0usize, 0usize);
for (i, group) in diff.grouped_ops(CONTEXT_LINES).into_iter().enumerate() {
if let (Some(first), Some(last)) = (group.first(), group.last()) {
let (old, new) = (first.old_range().start, first.new_range().start);
let (old_end, new_end) = (last.old_range().end, last.new_range().end);
lines.push(DiffLine {
kind: DiffKind::Hunk,
text: format!(
"@@ -{},{} +{},{} @@",
old + 1,
old_end - old,
new + 1,
new_end - new
),
});
} else if i > 0 {
continue;
}
for op in group {
for change in diff.iter_changes(&op) {
let kind = match change.tag() {
ChangeTag::Equal => DiffKind::Context,
ChangeTag::Insert => {
added += 1;
DiffKind::Added
}
ChangeTag::Delete => {
removed += 1;
DiffKind::Removed
}
};
lines.push(DiffLine {
kind,
text: change.value().trim_end_matches('\n').to_string(),
});
}
}
}
(lines, added, removed)
}
pub fn revert(journal_dir: &Path, root: &str, rel: &str) -> Result<()> {
let journal = Journal::new(journal_dir.to_path_buf());
anyhow::ensure!(
journal.describes(root),
"this chat's change journal belongs to another project — nothing was written"
);
let entry = journal
.entries()
.into_iter()
.find(|e| e.path == rel)
.with_context(|| format!("{rel} is not in this chat's change journal"))?;
let path = resolve(root, rel).with_context(|| format!("{rel} is outside {root}"))?;
if entry.existed {
let baseline = journal
.baseline_of(rel)
.with_context(|| format!("the stored original of {rel} is missing"))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
std::fs::write(&path, &baseline)
.with_context(|| format!("restoring {}", path.display()))?;
} else if path.exists() {
std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
}
journal.forget(rel)
}
#[cfg(test)]
mod tests {
use super::*;
struct Fixture {
_dir: tempfile::TempDir,
root: PathBuf,
journal_dir: PathBuf,
}
impl Fixture {
fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("proj");
std::fs::create_dir_all(&root).unwrap();
let journal_dir = dir.path().join("journal");
Self {
_dir: dir,
root,
journal_dir,
}
}
fn root(&self) -> String {
self.root.to_string_lossy().into_owned()
}
fn journal(&self) -> Journal {
Journal::new(self.journal_dir.clone())
}
fn touched(&self, rel: &str, before: Option<&str>, after: Option<&str>) {
let path = self.root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
if let Some(before) = before {
std::fs::write(&path, before).unwrap();
}
self.journal()
.record(&self.root(), rel, before.map(str::as_bytes))
.unwrap();
match after {
Some(after) => std::fs::write(&path, after).unwrap(),
None => {
let _ = std::fs::remove_file(&path);
}
}
}
fn build(&self) -> ChangeSet {
build(&self.journal_dir, &self.root(), None)
}
}
fn touched_bytes(f: &Fixture, rel: &str, before: &[u8], after: &[u8]) {
let path = f.root.join(rel);
std::fs::write(&path, before).unwrap();
f.journal().record(&f.root(), rel, Some(before)).unwrap();
std::fs::write(&path, after).unwrap();
}
#[test]
fn a_legacy_file_is_diffed_in_its_own_encoding() {
let f = Fixture::new();
let cp1251 = |t: &str| encoding_rs::WINDOWS_1251.encode(t).0.into_owned();
let text = "// Скидка растёт с каждым десятым заказом покупателя.\nlet x = 10;\n";
touched_bytes(&f, "a.rs", &cp1251(text), &cp1251(&text.replace("10", "5")));
let set = build(&f.journal_dir, &f.root(), Some("ru"));
let file = &set.files[0];
assert_eq!(
(file.state, file.added, file.removed),
(FileState::Modified, 1, 1)
);
assert!(
file.lines.iter().any(|l| l.text.contains("Скидка растёт")),
"{:?}",
file.lines
);
}
#[test]
fn a_byte_change_the_text_cannot_show_is_still_a_change() {
let f = Fixture::new();
let text = "// Скидка ".as_bytes();
touched_bytes(
&f,
"a.rs",
&[text, &[0xFF], b"\n"].concat(),
&[text, &[0xFE], b"\n"].concat(),
);
assert_eq!(f.build().files[0].state, FileState::Modified);
}
#[test]
fn a_changed_file_is_diffed_with_counts() {
let f = Fixture::new();
f.touched(
"src/a.rs",
Some("one\ntwo\nthree\n"),
Some("one\n2\nthree\n"),
);
let set = f.build();
assert_eq!(set.files.len(), 1);
let file = &set.files[0];
assert_eq!(file.path, "src/a.rs");
assert_eq!(file.state, FileState::Modified);
assert_eq!((file.added, file.removed), (1, 1));
assert!(
file.lines.iter().any(|l| l.kind == DiffKind::Hunk),
"a hunk header is what says where in the file this is: {:?}",
file.lines
);
assert!(
file.lines
.iter()
.any(|l| l.kind == DiffKind::Removed && l.text == "two")
);
assert!(
file.lines
.iter()
.any(|l| l.kind == DiffKind::Added && l.text == "2")
);
assert!(
file.lines.iter().all(|l| !l.text.starts_with('+')),
"{:?}",
file.lines
);
}
#[test]
fn every_state_is_reported_as_itself() {
let f = Fixture::new();
f.touched("created.rs", None, Some("fn main() {}\n"));
f.touched("modified.rs", Some("a\n"), Some("b\n"));
f.touched("gone.rs", Some("a\n"), None);
f.touched("undone.rs", Some("same\n"), Some("same\n"));
std::fs::write(f.root.join("binary.rs"), [0u8, 1, 2]).unwrap();
f.journal()
.record(&f.root(), "binary.rs", Some(&[0u8, 9]))
.unwrap();
let set = f.build();
let state = |name: &str| {
set.files
.iter()
.find(|c| c.path == name)
.unwrap_or_else(|| panic!("{name} missing from {:?}", set.files))
.state
};
assert_eq!(state("created.rs"), FileState::Created);
assert_eq!(state("modified.rs"), FileState::Modified);
assert_eq!(state("gone.rs"), FileState::Gone);
assert_eq!(state("undone.rs"), FileState::Unchanged);
assert_eq!(state("binary.rs"), FileState::NotShown);
}
#[test]
fn line_endings_alone_are_not_a_change() {
let f = Fixture::new();
f.touched("crlf.rs", Some("a\r\nb\r\n"), Some("a\nb\n"));
let set = f.build();
assert_eq!(set.files[0].state, FileState::Unchanged);
assert_eq!((set.files[0].added, set.files[0].removed), (0, 0));
}
#[test]
fn reverting_restores_the_original_and_forgets_it() {
let f = Fixture::new();
f.touched("src/a.rs", Some("original\n"), Some("changed\n"));
revert(&f.journal_dir, &f.root(), "src/a.rs").unwrap();
assert_eq!(
std::fs::read_to_string(f.root.join("src/a.rs")).unwrap(),
"original\n"
);
assert!(f.build().is_empty(), "the row must be gone too");
}
#[test]
fn reverting_a_created_file_deletes_it() {
let f = Fixture::new();
f.touched("src/new.rs", None, Some("fn main() {}\n"));
revert(&f.journal_dir, &f.root(), "src/new.rs").unwrap();
assert!(!f.root.join("src/new.rs").exists());
assert!(f.build().is_empty());
}
#[test]
fn reverting_an_unjournaled_path_is_refused() {
let f = Fixture::new();
std::fs::write(f.root.join("untouched.rs"), "mine\n").unwrap();
assert!(revert(&f.journal_dir, &f.root(), "untouched.rs").is_err());
assert_eq!(
std::fs::read_to_string(f.root.join("untouched.rs")).unwrap(),
"mine\n"
);
}
#[test]
fn a_path_escaping_the_root_is_refused() {
let f = Fixture::new();
let outside = f.root.parent().unwrap().join("secret.txt");
std::fs::write(&outside, "not yours\n").unwrap();
assert!(resolve(&f.root(), "../secret.txt").is_none());
assert!(revert(&f.journal_dir, &f.root(), "../secret.txt").is_err());
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "not yours\n");
}
#[test]
fn an_empty_journal_is_an_empty_change_set() {
let f = Fixture::new();
assert!(f.build().is_empty());
}
#[test]
fn another_projects_journal_is_neither_shown_nor_reverted() {
let dir = tempfile::tempdir().unwrap();
let journal_dir = dir.path().join("journal");
let a = dir.path().join("a");
std::fs::create_dir_all(&a).unwrap();
std::fs::write(a.join("Cargo.toml"), "name = \"a\"\n").unwrap();
Journal::new(journal_dir.clone())
.record(&a.to_string_lossy(), "Cargo.toml", Some(b"name = \"a\"\n"))
.unwrap();
std::fs::write(a.join("Cargo.toml"), "name = \"a-edited\"\n").unwrap();
let b = dir.path().join("b");
std::fs::create_dir_all(&b).unwrap();
let b_root = b.to_string_lossy().into_owned();
std::fs::write(b.join("Cargo.toml"), "name = \"b\"\n").unwrap();
assert!(
build(&journal_dir, &b_root, None).is_empty(),
"project A's rows must not be listed against project B's root"
);
assert!(
revert(&journal_dir, &b_root, "Cargo.toml").is_err(),
"and a revert must refuse rather than write"
);
assert_eq!(
std::fs::read_to_string(b.join("Cargo.toml")).unwrap(),
"name = \"b\"\n",
"project B's file is untouched"
);
}
}