use std::{ fs, path::{Path, PathBuf}, process::Command, sync::atomic::{AtomicU64, Ordering}, thread, time::{Duration, Instant}, }; use vamp::{ diff::{Category, Phase, RevisionTarget}, editor::Input, session::EditorSession, }; static NEXT: AtomicU64 = AtomicU64::new(0); struct Fixture { root: PathBuf, target: String, decoy: String, } impl Fixture { fn new() -> Self { let root = std::env::temp_dir().join(format!( "vamp-diff-search-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed), )); fs::create_dir_all(root.join("repo")).unwrap(); let mut fixture = Self { root, target: String::new(), decoy: String::new(), }; fixture.git(&["init", "-q", "--initial-branch=fixture-main"]); fixture.commit("foundation", "base"); fixture.target = fixture.commit("Über parser target", "target"); fixture.decoy = fixture.commit( &format!("release archaeology mentioning {}", fixture.target), "decoy", ); for name in [ "a-r_e_l_e_a_s_e", "release-candidate", "zrelease", "tie-a", "tie-b", ] { fixture.git(&["branch", name, &fixture.decoy]); fixture.git(&["tag", name, &fixture.decoy]); } for name in ["release", "feature/parser-rewrite", "feature/Über-parser"] { fixture.git(&["branch", name, &fixture.target]); fixture.git(&["tag", name, &fixture.target]); } fixture } fn git(&self, args: &[&str]) -> String { let output = Command::new("git") .args(args) .current_dir(self.root.join("repo")) .env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_CONFIG_GLOBAL", "/dev/null") .env("GIT_TERMINAL_PROMPT", "0") .env("GIT_AUTHOR_NAME", "Diff Search Fixture") .env("GIT_AUTHOR_EMAIL", "fixture@example.invalid") .env("GIT_COMMITTER_NAME", "Diff Search Fixture") .env("GIT_COMMITTER_EMAIL", "fixture@example.invalid") .env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z") .env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z") .output() .unwrap(); assert!( output.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&output.stderr) ); String::from_utf8(output.stdout).unwrap().trim().to_owned() } fn commit(&self, message: &str, content: &str) -> String { fs::write(self.root.join("repo/source.txt"), content).unwrap(); self.git(&["add", "source.txt"]); self.git(&["commit", "-qm", message]); self.git(&["rev-parse", "HEAD"]) } fn session(&self) -> EditorSession { let config = self.root.join("config.rb"); fs::write( &config, "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\nend\n", ) .unwrap(); let mut session = EditorSession::open( Some(self.root.join("repo/source.txt")), vec![], Some(&config), ) .unwrap(); assert!(session.editor().startup_diagnostic().is_none()); session.execute(":diff"); wait_until(&mut session, |session| { session .editor() .diff() .is_some_and(|diff| diff.catalog.is_some() && !diff.loading) }); session } } impl Drop for Fixture { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.root); } } fn wait_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> bool) { let deadline = Instant::now() + Duration::from_secs(10); while !ready(session) { session.poll(); assert!(Instant::now() < deadline, "{:?}", session.editor().diff()); if let Some(diff) = session.editor().diff() { assert!(diff.error.is_none(), "{:?}", diff.error); } thread::sleep(Duration::from_millis(1)); } } fn query(session: &mut EditorSession, text: &str) { session.handle(Input::Ctrl('u')); for ch in text.chars() { session.handle(Input::Char(ch)); } } fn category(session: &mut EditorSession, category: Category) { query(session, ""); for _ in 0..3 { let diff = session.editor().diff().unwrap(); if diff.catalog.as_ref().unwrap().kind.categories()[diff.category] == category { return; } session.handle(Input::Char('\t')); } panic!("category unavailable: {category:?}"); } fn labels(session: &EditorSession) -> Vec { let diff = session.editor().diff().unwrap(); assert_eq!( diff.entries().next().unwrap().target, RevisionTarget::Working ); diff.entries() .skip(1) .map(|entry| entry.label.clone()) .collect() } #[test] fn branch_and_tag_fuzzy_ranking_and_empty_order_match_in_both_selectors() { let fixture = Fixture::new(); let mut session = fixture.session(); for phase in [Phase::Before, Phase::After] { assert_eq!(session.editor().diff().unwrap().phase, phase); for kind in [Category::Branches, Category::Tags] { category(&mut session, kind); let catalogue_order = labels(&session); query(&mut session, " "); assert_eq!(labels(&session), catalogue_order); query(&mut session, "release"); assert_eq!( labels(&session), [ "release", "release-candidate", "zrelease", "a-r_e_l_e_a_s_e" ] ); query(&mut session, "ftprw"); assert_eq!(labels(&session), ["feature/parser-rewrite"]); query(&mut session, "tie"); let ties: Vec<_> = catalogue_order .iter() .filter(|label| label.starts_with("tie-")) .cloned() .collect(); assert_eq!(&labels(&session)[..ties.len()], ties); query(&mut session, "no-such-reference-anywhere"); assert!(labels(&session).is_empty()); assert_eq!(session.editor().diff().unwrap().selected, 0); } if phase == Phase::Before { // Even an unmatched query leaves Working selectable and pinned first. session.handle(Input::Enter); } } } #[test] fn unicode_tokens_and_commit_ids_search_fields_without_cross_field_subsequences() { let fixture = Fixture::new(); let mut session = fixture.session(); for phase in [Phase::Before, Phase::After] { assert_eq!(session.editor().diff().unwrap().phase, phase); category(&mut session, Category::Branches); for text in [ format!("{} ÜBPR", &fixture.target[..10]), format!("übpr {}", &fixture.target[..10]), ] { query(&mut session, &text); assert_eq!(labels(&session), ["feature/Über-parser"]); } category(&mut session, Category::Commits); for text in [&fixture.target[..], &fixture.target[..10]] { query(&mut session, text); assert_eq!( labels(&session), [fixture.target.clone(), fixture.decoy.clone()] ); } query(&mut session, "ÜB PRSR"); assert_eq!(labels(&session), [fixture.target.clone()]); // The ID and description can satisfy separate tokens, but not one token // that would require a subsequence crossing between unrelated fields. query(&mut session, &format!("{} übpr", &fixture.target[..10])); assert_eq!(labels(&session), [fixture.target.clone()]); query(&mut session, &format!("{}über", fixture.target)); assert!(labels(&session).is_empty()); if phase == Phase::Before { session.handle(Input::Enter); } } } #[test] fn selecting_ranked_branch_uses_its_actual_commit_in_before_and_after() { let fixture = Fixture::new(); for ranked_phase in [Phase::Before, Phase::After] { let mut session = fixture.session(); if ranked_phase == Phase::After { session.handle(Input::Enter); } category(&mut session, Category::Branches); query(&mut session, "release"); assert_eq!(labels(&session)[0], "release"); session.handle(Input::Down); session.handle(Input::Enter); if ranked_phase == Phase::Before { // Working selects snapshot mode rather than merge-base mode. session.handle(Input::Enter); } wait_until(&mut session, |session| { session .editor() .diff() .is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded()) }); let diff = session.editor().diff().unwrap(); let ranked = if ranked_phase == Phase::Before { diff.before.as_ref().unwrap() } else { diff.after.as_ref().unwrap() }; assert_eq!(ranked.label, "release"); assert_eq!( ranked.target, RevisionTarget::Commit(fixture.target.clone()) ); let comparison = diff.comparison.as_ref().unwrap(); assert_eq!(comparison.files.len(), 1); assert_eq!(comparison.files[0].path, Path::new("source.txt")); let (before, after) = if ranked_phase == Phase::Before { (["target"], ["decoy"]) } else { (["decoy"], ["target"]) }; assert_eq!(comparison.files[0].before, before); assert_eq!(comparison.files[0].after, after); } }