refactor: move embedded tests into dedicated test directory
This commit is contained in:
@@ -29,8 +29,8 @@
|
|||||||
- preserve filename arguments, configured storage, and incompatible data; no MCP, agent replies, or automatic dispatch.
|
- preserve filename arguments, configured storage, and incompatible data; no MCP, agent replies, or automatic dispatch.
|
||||||
- [x] add an option for three pane left/base/right view for conflict resolution.
|
- [x] add an option for three pane left/base/right view for conflict resolution.
|
||||||
- alternative pane layout as left/right with base underneath (`config.resolve_history_layout = "base_below"`)
|
- alternative pane layout as left/right with base underneath (`config.resolve_history_layout = "base_below"`)
|
||||||
- [ ] pull out tests from implementation code, and place them in a dedicated test folder.
|
- [x] pull out tests from implementation code, and place them in a dedicated test folder.
|
||||||
- maybe a test folder sibling to `src` could make sense? 🤔
|
- private unit tests live in `tests/unit/`, alongside the existing `tests/*.rs` integration suites.
|
||||||
- [ ] diff view: GitHub code review integration after the local review UX is usable.
|
- [ ] diff view: GitHub code review integration after the local review UX is usable.
|
||||||
- publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes.
|
- publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes.
|
||||||
- preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission.
|
- preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission.
|
||||||
|
|||||||
+4
-1
@@ -12,6 +12,7 @@ cargo test -p crossterm color_scheme_notifications
|
|||||||
cargo test -p crossterm --doc
|
cargo test -p crossterm --doc
|
||||||
cargo clippy --all-targets -- -D warnings
|
cargo clippy --all-targets -- -D warnings
|
||||||
cargo fmt --package vamp --check
|
cargo fmt --package vamp --check
|
||||||
|
rustfmt --edition 2024 --check tests/unit/battery_macos.rs tests/unit/clipboard_macos.rs
|
||||||
for file in src/ruby_*.rb examples/config.rb examples/plugins/greeting.rb; do
|
for file in src/ruby_*.rb examples/config.rb examples/plugins/greeting.rb; do
|
||||||
ruby -c "$file" || exit
|
ruby -c "$file" || exit
|
||||||
done
|
done
|
||||||
@@ -34,7 +35,9 @@ Rust embeds the Ruby implementation at compile time: [`ruby_values.rb`](../src/r
|
|||||||
|
|
||||||
## Test organization
|
## Test organization
|
||||||
|
|
||||||
Rust tests live alongside their modules; native LSP integration checks are in [`tests/lsp.rs`](../tests/lsp.rs).
|
Rust tests live under [`tests/`](../tests/). Private unit tests are in [`tests/unit/`](../tests/unit/), linked from their owning modules with `#[cfg(test)]` and `#[path]`; nested macOS modules use `include!` to retain their private/platform scope. Root `tests/*.rs` files remain Cargo integration targets, including native LSP checks in [`tests/lsp.rs`](../tests/lsp.rs).
|
||||||
|
|
||||||
|
`cargo test` runs both groups; `cargo test --lib` and `cargo test --bin vamp` select the library and CLI unit suites. Do not name a unit file `tests/unit/main.rs`: Cargo would discover it as a separate integration target.
|
||||||
|
|
||||||
- Editor checks cover Unicode editing, multi-target state, undo/redo, saving failures, and modal paste acceptance and rejection atomicity.
|
- Editor checks cover Unicode editing, multi-target state, undo/redo, saving failures, and modal paste acceptance and rejection atomicity.
|
||||||
- Visual-command checks preserve directed characterwise/linewise ranges and multiple carets through command cancellation and plugin dispatch, exercise remapped command entry, and submit `:diff.comment` on either historical side. Live PTY smoke selects lines, cancels a prompt, then runs `:diff.comment` directly and verifies the saved original range without using the palette.
|
- Visual-command checks preserve directed characterwise/linewise ranges and multiple carets through command cancellation and plugin dispatch, exercise remapped command entry, and submit `:diff.comment` on either historical side. Live PTY smoke selects lines, cancels a prompt, then runs `:diff.comment` directly and verifies the saved original range without using the palette.
|
||||||
|
|||||||
+3
-229
@@ -137,8 +137,6 @@ mod macos {
|
|||||||
fn CFBooleanGetValue(boolean: Object) -> u8;
|
fn CFBooleanGetValue(boolean: Object) -> u8;
|
||||||
fn CFNumberGetTypeID() -> usize;
|
fn CFNumberGetTypeID() -> usize;
|
||||||
fn CFNumberGetValue(number: Object, kind: isize, value: *mut c_void) -> u8;
|
fn CFNumberGetValue(number: Object, kind: isize, value: *mut c_void) -> u8;
|
||||||
#[cfg(test)]
|
|
||||||
fn CFNumberCreate(allocator: Object, kind: isize, value: Object) -> Object;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only Copy/Create results belong here; power source descriptions are borrowed.
|
// Only Copy/Create results belong here; power source descriptions are borrowed.
|
||||||
@@ -219,34 +217,7 @@ mod macos {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
include!("../tests/unit/battery_macos.rs");
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn native_values_are_type_checked_and_numbers_must_be_exact_and_nonnegative() {
|
|
||||||
assert!(Owned::new(std::ptr::null()).is_none());
|
|
||||||
unsafe {
|
|
||||||
assert_eq!(number(std::ptr::null()), None);
|
|
||||||
let text = Owned::string(c"27").unwrap();
|
|
||||||
assert_eq!(number(text.0), None);
|
|
||||||
for value in [-1_i64, 0, 100] {
|
|
||||||
let object = Owned::new(CFNumberCreate(
|
|
||||||
std::ptr::null(),
|
|
||||||
4,
|
|
||||||
(&value as *const i64).cast(),
|
|
||||||
))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(number(object.0), u64::try_from(value).ok());
|
|
||||||
}
|
|
||||||
let fraction = 1.5_f64;
|
|
||||||
let object = Owned::new(CFNumberCreate(
|
|
||||||
std::ptr::null(),
|
|
||||||
6,
|
|
||||||
(&fraction as *const f64).cast(),
|
|
||||||
))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(number(object.0), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,202 +312,5 @@ mod windows {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "../tests/unit/battery.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
use std::{
|
|
||||||
fs,
|
|
||||||
path::PathBuf,
|
|
||||||
sync::atomic::{AtomicBool, AtomicU64, Ordering},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn percentages_reject_invalid_data_and_include_both_endpoints() {
|
|
||||||
assert_eq!(percentage(0, 100), Some(0));
|
|
||||||
assert_eq!(percentage(100, 100), Some(100));
|
|
||||||
assert_eq!(percentage(1, 3), Some(33));
|
|
||||||
assert_eq!(percentage(u64::MAX, u64::MAX), Some(100));
|
|
||||||
assert_eq!(percentage(0, 0), None);
|
|
||||||
assert_eq!(percentage(101, 100), None);
|
|
||||||
assert_eq!(windows_charge(0, 4), Some(0));
|
|
||||||
assert_eq!(windows_charge(100, 8), Some(100));
|
|
||||||
assert_eq!(windows_charge(101, 0), None);
|
|
||||||
assert_eq!(windows_charge(255, 0), None);
|
|
||||||
assert_eq!(windows_charge(50, 128), None);
|
|
||||||
assert_eq!(windows_charge(50, 255), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn worker(samples: Vec<Option<u8>>) -> (Battery, Receiver<()>, Arc<AtomicBool>) {
|
|
||||||
struct ReaderDropped(Arc<AtomicBool>);
|
|
||||||
impl Drop for ReaderDropped {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.0.store(true, Ordering::Release);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let dropped = Arc::new(AtomicBool::new(false));
|
|
||||||
let reader = ReaderDropped(Arc::clone(&dropped));
|
|
||||||
let mut samples = samples.into_iter();
|
|
||||||
let (ready, published) = mpsc::channel();
|
|
||||||
let battery = Battery::start_with(
|
|
||||||
move || {
|
|
||||||
let _ = &reader;
|
|
||||||
samples
|
|
||||||
.next()
|
|
||||||
.expect("test provides every requested sample")
|
|
||||||
},
|
|
||||||
move |wake| {
|
|
||||||
ready.send(()).unwrap();
|
|
||||||
// Explicit channel messages replace the production clock entirely.
|
|
||||||
wake.recv().is_ok()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
(battery, published, dropped)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn worker_reads_immediately_replaces_stale_samples_and_clears_invalid_data() {
|
|
||||||
let (mut battery, published, dropped) = worker(vec![Some(0), Some(100), None, Some(101)]);
|
|
||||||
published.recv().unwrap();
|
|
||||||
assert_eq!(battery.poll(), Some(Some(0)));
|
|
||||||
assert_eq!(battery.poll(), None);
|
|
||||||
battery.wake.as_ref().unwrap().send(()).unwrap();
|
|
||||||
published.recv().unwrap();
|
|
||||||
battery.wake.as_ref().unwrap().send(()).unwrap();
|
|
||||||
published.recv().unwrap();
|
|
||||||
assert_eq!(battery.poll(), Some(None));
|
|
||||||
assert_eq!(battery.poll(), None);
|
|
||||||
battery.wake.as_ref().unwrap().send(()).unwrap();
|
|
||||||
published.recv().unwrap();
|
|
||||||
assert_eq!(battery.poll(), Some(None));
|
|
||||||
drop(battery);
|
|
||||||
assert!(dropped.load(Ordering::Acquire));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dropping_interrupts_wait_and_joins_even_with_an_unread_sample() {
|
|
||||||
let (battery, published, dropped) = worker(vec![Some(50)]);
|
|
||||||
published.recv().unwrap();
|
|
||||||
drop(battery);
|
|
||||||
assert!(dropped.load(Ordering::Acquire));
|
|
||||||
}
|
|
||||||
|
|
||||||
static FIXTURE_ID: AtomicU64 = AtomicU64::new(0);
|
|
||||||
|
|
||||||
struct Fixture(PathBuf);
|
|
||||||
|
|
||||||
impl Fixture {
|
|
||||||
fn new() -> Self {
|
|
||||||
let path = std::env::temp_dir().join(format!(
|
|
||||||
"vamp-battery-{}-{}",
|
|
||||||
std::process::id(),
|
|
||||||
FIXTURE_ID.fetch_add(1, Ordering::Relaxed)
|
|
||||||
));
|
|
||||||
fs::create_dir(&path).unwrap();
|
|
||||||
Self(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn source(&self, name: &str, fields: &[(&str, &str)]) {
|
|
||||||
let path = self.0.join(name);
|
|
||||||
fs::create_dir_all(&path).unwrap();
|
|
||||||
for (key, value) in fields {
|
|
||||||
fs::write(path.join(key), value).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read(&self) -> Option<u8> {
|
|
||||||
linux::read_charge(&self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for Fixture {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
fs::remove_dir_all(&self.0).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sysfs_reads_present_system_batteries_but_not_external_or_absent_sources() {
|
|
||||||
let fixture = Fixture::new();
|
|
||||||
assert_eq!(fixture.read(), None);
|
|
||||||
fixture.source("AC", &[("type", "Mains"), ("capacity", "100")]);
|
|
||||||
fixture.source(
|
|
||||||
"mouse",
|
|
||||||
&[
|
|
||||||
("type", "Battery"),
|
|
||||||
("scope", "Device"),
|
|
||||||
("present", "1"),
|
|
||||||
("capacity", "100"),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
fixture.source(
|
|
||||||
"absent",
|
|
||||||
&[("type", "Battery"), ("present", "0"), ("capacity", "100")],
|
|
||||||
);
|
|
||||||
assert_eq!(fixture.read(), None);
|
|
||||||
fixture.source(
|
|
||||||
"BAT0",
|
|
||||||
&[
|
|
||||||
("type", "Battery\n"),
|
|
||||||
("scope", "System\n"),
|
|
||||||
("present", "1\n"),
|
|
||||||
("capacity", "27\n"),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
assert_eq!(fixture.read(), Some(27));
|
|
||||||
fixture.source("BAT0", &[("capacity", "0")]);
|
|
||||||
assert_eq!(fixture.read(), Some(0));
|
|
||||||
fixture.source("BAT0", &[("capacity", "100")]);
|
|
||||||
assert_eq!(fixture.read(), Some(100));
|
|
||||||
for invalid in ["101", "-1", "255", "garbage", ""] {
|
|
||||||
fixture.source("BAT0", &[("capacity", invalid)]);
|
|
||||||
assert_eq!(fixture.read(), None);
|
|
||||||
}
|
|
||||||
fs::remove_file(fixture.0.join("BAT0/capacity")).unwrap();
|
|
||||||
assert_eq!(fixture.read(), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sysfs_uses_matching_actual_energy_or_charge_totals_only() {
|
|
||||||
let energy = Fixture::new();
|
|
||||||
energy.source(
|
|
||||||
"BAT0",
|
|
||||||
&[
|
|
||||||
("type", "Battery"),
|
|
||||||
("present", "1"),
|
|
||||||
("energy_now", "27"),
|
|
||||||
("energy_full", "100"),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
assert_eq!(energy.read(), Some(27));
|
|
||||||
energy.source("BAT0", &[("energy_full", "0")]);
|
|
||||||
assert_eq!(energy.read(), None);
|
|
||||||
energy.source("BAT0", &[("energy_full", "26")]);
|
|
||||||
assert_eq!(energy.read(), None);
|
|
||||||
energy.source("BAT0", &[("energy_full", "garbage")]);
|
|
||||||
assert_eq!(energy.read(), None);
|
|
||||||
let charge = Fixture::new();
|
|
||||||
charge.source(
|
|
||||||
"BAT0",
|
|
||||||
&[
|
|
||||||
("type", "Battery"),
|
|
||||||
("present", "1"),
|
|
||||||
("charge_now", "15"),
|
|
||||||
("charge_full", "20"),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
assert_eq!(charge.read(), Some(75));
|
|
||||||
charge.source("BAT0", &[("energy_now", "5")]);
|
|
||||||
assert_eq!(charge.read(), None);
|
|
||||||
let design = Fixture::new();
|
|
||||||
design.source(
|
|
||||||
"BAT0",
|
|
||||||
&[
|
|
||||||
("type", "Battery"),
|
|
||||||
("present", "1"),
|
|
||||||
("energy_now", "20"),
|
|
||||||
("energy_full_design", "100"),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
assert_eq!(design.read(), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-374
@@ -505,377 +505,5 @@ fn parse(bytes: &[u8], rows: usize) -> Result<Vec<Option<Arc<LineBlame>>>, Strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(test, unix))]
|
#[cfg(all(test, unix))]
|
||||||
mod tests {
|
#[path = "../tests/unit/blame.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
fn command(root: &Path, program: &str, args: &[&str]) -> String {
|
|
||||||
let mut command = Command::new(program);
|
|
||||||
if program == "jj" {
|
|
||||||
command.args([
|
|
||||||
"--config=user.name=\"Jj Author\"",
|
|
||||||
"--config=user.email=\"jj@example.test\"",
|
|
||||||
]);
|
|
||||||
command.args([
|
|
||||||
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
|
|
||||||
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
let output = command
|
|
||||||
.args(args)
|
|
||||||
.current_dir(root)
|
|
||||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
|
||||||
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
|
||||||
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
|
|
||||||
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
|
|
||||||
.env("JJ_CONFIG", "/dev/null")
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
output.status.success(),
|
|
||||||
"{}",
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
String::from_utf8(output.stdout).unwrap().trim().into()
|
|
||||||
}
|
|
||||||
fn fixture() -> (Temporary, PathBuf, String) {
|
|
||||||
let temp = Temporary::new().unwrap();
|
|
||||||
command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
|
||||||
command(&temp.0, "git", &["config", "user.name", "Blame Author"]);
|
|
||||||
command(
|
|
||||||
&temp.0,
|
|
||||||
"git",
|
|
||||||
&["config", "user.email", "blame@example.test"],
|
|
||||||
);
|
|
||||||
let path = temp.0.join("-literal [file].txt");
|
|
||||||
fs::write(&path, "first\nsecond\n").unwrap();
|
|
||||||
command(&temp.0, "git", &["add", "--", "-literal [file].txt"]);
|
|
||||||
command(
|
|
||||||
&temp.0,
|
|
||||||
"git",
|
|
||||||
&["commit", "--quiet", "-m", "Original summary"],
|
|
||||||
);
|
|
||||||
let commit = command(&temp.0, "git", &["rev-parse", "HEAD"]);
|
|
||||||
(temp, path, commit)
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn committed_line_has_native_author_summary_and_commit() {
|
|
||||||
let (_temp, path, commit) = fixture();
|
|
||||||
let rows = read_file(&path, "first\nsecond\n", &|| false)
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(rows.len(), 2);
|
|
||||||
assert!(Arc::ptr_eq(
|
|
||||||
rows[0].as_ref().unwrap(),
|
|
||||||
rows[1].as_ref().unwrap()
|
|
||||||
));
|
|
||||||
assert_eq!(
|
|
||||||
**rows[1].as_ref().unwrap(),
|
|
||||||
LineBlame {
|
|
||||||
commit,
|
|
||||||
author: "Blame Author".into(),
|
|
||||||
summary: "Original summary".into(),
|
|
||||||
uncommitted: false
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn unsaved_changed_line_is_uncommitted_without_metadata() {
|
|
||||||
let (_temp, path, _) = fixture();
|
|
||||||
let rows = read_file(&path, "changed\nsecond\n", &|| false)
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
**rows[0].as_ref().unwrap(),
|
|
||||||
LineBlame {
|
|
||||||
commit: String::new(),
|
|
||||||
author: String::new(),
|
|
||||||
summary: String::new(),
|
|
||||||
uncommitted: true
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(fs::read_to_string(path).unwrap(), "first\nsecond\n");
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn inserted_line_preserves_following_original_attribution() {
|
|
||||||
let (_temp, path, commit) = fixture();
|
|
||||||
let contents = "inserted\nfirst\nsecond\n";
|
|
||||||
let rows = read_file(&path, contents, &|| false).unwrap().unwrap();
|
|
||||||
assert!(rows[0].as_ref().unwrap().uncommitted);
|
|
||||||
assert_eq!(rows[2].as_ref().unwrap().commit, commit);
|
|
||||||
assert_eq!(rows.len(), 3);
|
|
||||||
assert_eq!(read_file(&path, "", &|| false).unwrap(), None);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn unsaved_insertions_and_deletions_keep_shifted_commit_attribution() {
|
|
||||||
let (temp, path, original) = fixture();
|
|
||||||
fs::write(&path, "first\nmiddle\nsecond\ntail\n").unwrap();
|
|
||||||
command(&temp.0, "git", &["add", "--", "-literal [file].txt"]);
|
|
||||||
command(&temp.0, "git", &["commit", "--quiet", "-m", "Added lines"]);
|
|
||||||
let added = command(&temp.0, "git", &["rev-parse", "HEAD"]);
|
|
||||||
let rows = read_file(
|
|
||||||
&path,
|
|
||||||
"unsaved\nfirst\nsecond\nunsaved tail\ntail\n",
|
|
||||||
&|| false,
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
assert!(rows[0].as_ref().unwrap().uncommitted);
|
|
||||||
assert_eq!(rows[1].as_ref().unwrap().commit, original);
|
|
||||||
assert_eq!(rows[2].as_ref().unwrap().commit, original);
|
|
||||||
assert!(Arc::ptr_eq(
|
|
||||||
rows[1].as_ref().unwrap(),
|
|
||||||
rows[2].as_ref().unwrap()
|
|
||||||
));
|
|
||||||
assert!(rows[3].as_ref().unwrap().uncommitted);
|
|
||||||
assert!(Arc::ptr_eq(
|
|
||||||
rows[0].as_ref().unwrap(),
|
|
||||||
rows[3].as_ref().unwrap()
|
|
||||||
));
|
|
||||||
assert_eq!(rows[4].as_ref().unwrap().commit, added);
|
|
||||||
assert_eq!(rows[4].as_ref().unwrap().summary, "Added lines");
|
|
||||||
assert_eq!(
|
|
||||||
fs::read_to_string(path).unwrap(),
|
|
||||||
"first\nmiddle\nsecond\ntail\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn missing_untracked_unborn_nonrepo_and_symlink_have_no_blame() {
|
|
||||||
let temp = Temporary::new().unwrap();
|
|
||||||
let path = temp.0.join("file");
|
|
||||||
assert_eq!(read_file(&path, "text", &|| false).unwrap(), None);
|
|
||||||
fs::write(&path, "text").unwrap();
|
|
||||||
assert_eq!(read_file(&path, "text", &|| false).unwrap(), None);
|
|
||||||
command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
|
||||||
assert_eq!(read_file(&path, "text", &|| false).unwrap(), None);
|
|
||||||
let (_committed, tracked, _) = fixture();
|
|
||||||
let untracked = tracked.parent().unwrap().join("untracked");
|
|
||||||
fs::write(&untracked, "text").unwrap();
|
|
||||||
assert_eq!(read_file(&untracked, "text", &|| false).unwrap(), None);
|
|
||||||
let link = temp.0.join("link");
|
|
||||||
std::os::unix::fs::symlink(tracked, &link).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
read_file(&link, "first\nsecond\n", &|| false).unwrap(),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn noncolocated_jj_uses_parent_without_snapshotting_or_changing_refs() {
|
|
||||||
let temp = Temporary::new().unwrap();
|
|
||||||
command(&temp.0, "jj", &["git", "init", "--no-colocate", "."]);
|
|
||||||
let path = temp.0.join("file");
|
|
||||||
fs::write(&path, "original\n").unwrap();
|
|
||||||
assert_eq!(read_file(&path, "original\n", &|| false).unwrap(), None);
|
|
||||||
command(
|
|
||||||
&temp.0,
|
|
||||||
"jj",
|
|
||||||
&[
|
|
||||||
"--config=user.name=\"Jj Author\"",
|
|
||||||
"--config=user.email=\"jj@example.test\"",
|
|
||||||
"describe",
|
|
||||||
"-m",
|
|
||||||
"Jj summary",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
command(
|
|
||||||
&temp.0,
|
|
||||||
"jj",
|
|
||||||
&[
|
|
||||||
"--config=user.name=\"Jj Author\"",
|
|
||||||
"--config=user.email=\"jj@example.test\"",
|
|
||||||
"new",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
let parent = command(
|
|
||||||
&temp.0,
|
|
||||||
"jj",
|
|
||||||
&[
|
|
||||||
"--ignore-working-copy",
|
|
||||||
"log",
|
|
||||||
"--no-graph",
|
|
||||||
"-r",
|
|
||||||
"@-",
|
|
||||||
"-T",
|
|
||||||
"commit_id",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
let operation = command(
|
|
||||||
&temp.0,
|
|
||||||
"jj",
|
|
||||||
&[
|
|
||||||
"--ignore-working-copy",
|
|
||||||
"op",
|
|
||||||
"log",
|
|
||||||
"--no-graph",
|
|
||||||
"-n",
|
|
||||||
"1",
|
|
||||||
"-T",
|
|
||||||
"id",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
let rows = read_file(&path, "original\n", &|| false).unwrap().unwrap();
|
|
||||||
let line = rows[0].as_ref().unwrap();
|
|
||||||
assert_eq!(line.commit, parent);
|
|
||||||
assert_eq!(line.author, "Jj Author");
|
|
||||||
assert_eq!(line.summary, "Jj summary");
|
|
||||||
fs::write(&path, "dirty on disk\n").unwrap();
|
|
||||||
let shifted = read_file(&path, "inserted\noriginal\n", &|| false)
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
assert!(shifted[0].as_ref().unwrap().uncommitted);
|
|
||||||
assert_eq!(shifted[1].as_ref().unwrap().commit, parent);
|
|
||||||
assert!(
|
|
||||||
read_file(&path, "unsaved\n", &|| false).unwrap().unwrap()[0]
|
|
||||||
.as_ref()
|
|
||||||
.unwrap()
|
|
||||||
.uncommitted
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
command(
|
|
||||||
&temp.0,
|
|
||||||
"jj",
|
|
||||||
&[
|
|
||||||
"--ignore-working-copy",
|
|
||||||
"op",
|
|
||||||
"log",
|
|
||||||
"--no-graph",
|
|
||||||
"-n",
|
|
||||||
"1",
|
|
||||||
"-T",
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
),
|
|
||||||
operation
|
|
||||||
);
|
|
||||||
assert!(!temp.0.join(".git").exists());
|
|
||||||
command(&temp.0, "jj", &["new", "root()"]);
|
|
||||||
fs::write(temp.0.join("other"), "sibling\n").unwrap();
|
|
||||||
command(&temp.0, "jj", &["describe", "-m", "Sibling"]);
|
|
||||||
command(&temp.0, "jj", &["new", &parent, "@"]);
|
|
||||||
assert_eq!(read_file(&path, "original\n", &|| false).unwrap(), None);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn incremental_groups_share_metadata_across_ranges_and_repeated_commits() {
|
|
||||||
let first = "1111111111111111111111111111111111111111";
|
|
||||||
let second = "2222222222222222222222222222222222222222";
|
|
||||||
let output = format!(
|
|
||||||
"{first} 5 3 2\nauthor First\nsummary First summary\nfilename file\n\
|
|
||||||
{second} 8 2 1\nauthor Second\nsummary Second summary\nfilename file\n\
|
|
||||||
{first} 1 1 1\nfilename old-file\n"
|
|
||||||
);
|
|
||||||
let rows = parse(output.as_bytes(), 4).unwrap();
|
|
||||||
assert_eq!(rows[0].as_ref().unwrap().commit, first);
|
|
||||||
assert_eq!(rows[1].as_ref().unwrap().author, "Second");
|
|
||||||
assert_eq!(rows[1].as_ref().unwrap().commit, second);
|
|
||||||
assert!(Arc::ptr_eq(
|
|
||||||
rows[0].as_ref().unwrap(),
|
|
||||||
rows[2].as_ref().unwrap()
|
|
||||||
));
|
|
||||||
assert!(Arc::ptr_eq(
|
|
||||||
rows[2].as_ref().unwrap(),
|
|
||||||
rows[3].as_ref().unwrap()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn malformed_incremental_groups_never_echo_metadata() {
|
|
||||||
let commit = "1111111111111111111111111111111111111111";
|
|
||||||
let metadata = "author secret\nsummary secret\nfilename secret\n";
|
|
||||||
let valid = format!("{commit} 1 1 1\n{metadata}");
|
|
||||||
for output in [
|
|
||||||
"secret metadata".into(),
|
|
||||||
"\u{fffd}secret metadata".into(),
|
|
||||||
format!("{commit} 1 2 1\n{metadata}"),
|
|
||||||
format!("{commit} 1 1 2\n{metadata}"),
|
|
||||||
format!("{commit} 0 1 1\n{metadata}"),
|
|
||||||
format!("{commit} 1 1 0\n{metadata}"),
|
|
||||||
format!("{commit} 1 1 1 extra\n{metadata}"),
|
|
||||||
format!("{commit} 1 1 1\nauthor secret\nfilename secret\n"),
|
|
||||||
format!(
|
|
||||||
"{commit} 1 1 1\nauthor secret\nauthor secret\nsummary secret\nfilename secret\n"
|
|
||||||
),
|
|
||||||
format!("{commit} 1 1 1\nauthor secret\nsummary secret\n\tsecret source\n"),
|
|
||||||
format!("{valid}{commit} 2 1 1\nfilename secret\n"),
|
|
||||||
format!(
|
|
||||||
"{commit} 1 1 1\nauthor secret\nsummary secret\n{commit} 2 1 1\nfilename secret\n"
|
|
||||||
),
|
|
||||||
format!("{commit} {} 1 2\n{metadata}", usize::MAX),
|
|
||||||
] {
|
|
||||||
assert_eq!(
|
|
||||||
parse(output.as_bytes(), 1).unwrap_err(),
|
|
||||||
"Invalid Git blame metadata"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
parse(valid.as_bytes(), 2).unwrap_err(),
|
|
||||||
"Invalid Git blame metadata"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse(b"\xffsecret metadata", 1).unwrap_err(),
|
|
||||||
"Invalid Git blame metadata"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn cancelled_commands_kill_and_reap_pipe_holding_children() {
|
|
||||||
let temp = Temporary::new().unwrap();
|
|
||||||
let ready = temp.0.join("ready");
|
|
||||||
let cancellation = Arc::new(AtomicBool::new(false));
|
|
||||||
let stop = Arc::clone(&cancellation);
|
|
||||||
let gate = ready.clone();
|
|
||||||
let worker = thread::spawn(move || {
|
|
||||||
let mut command = Command::new("sh");
|
|
||||||
command
|
|
||||||
.args([
|
|
||||||
"-c",
|
|
||||||
"sleep 600 & printf ready > \"$1\"; wait",
|
|
||||||
"vamp-blame-test",
|
|
||||||
])
|
|
||||||
.arg(gate);
|
|
||||||
run(command, &[], &|| stop.load(Ordering::Acquire))
|
|
||||||
});
|
|
||||||
while !ready.exists() && !worker.is_finished() {
|
|
||||||
thread::yield_now();
|
|
||||||
}
|
|
||||||
assert!(ready.exists());
|
|
||||||
cancellation.store(true, Ordering::Release);
|
|
||||||
assert_eq!(worker.join().unwrap().unwrap_err(), "Blame cancelled");
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn worker_keeps_only_latest_request_and_bounds_input() {
|
|
||||||
let (_temp, path, commit) = fixture();
|
|
||||||
let mut worker = BlameWorker::start().unwrap();
|
|
||||||
for id in 0..20 {
|
|
||||||
worker
|
|
||||||
.request(id, path.clone(), "first\nsecond\n".into())
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
loop {
|
|
||||||
if let Some((id, result)) = worker.poll() {
|
|
||||||
assert_eq!(id, 19);
|
|
||||||
let rows = result.unwrap().unwrap();
|
|
||||||
assert_eq!(rows.len(), 2);
|
|
||||||
assert_eq!(rows[1].as_ref().unwrap().commit, commit);
|
|
||||||
assert!(Arc::ptr_eq(
|
|
||||||
rows[0].as_ref().unwrap(),
|
|
||||||
rows[1].as_ref().unwrap()
|
|
||||||
));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
thread::yield_now();
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
worker
|
|
||||||
.request(20, path, "x".repeat(LIMIT + 1))
|
|
||||||
.unwrap_err()
|
|
||||||
.kind(),
|
|
||||||
io::ErrorKind::InvalidInput
|
|
||||||
);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn subprocess_output_is_bounded() {
|
|
||||||
let mut command = Command::new("sh");
|
|
||||||
command.args(["-c", "dd if=/dev/zero bs=1048576 count=17 2>/dev/null"]);
|
|
||||||
assert_eq!(
|
|
||||||
run(command, &[], &|| false).unwrap_err(),
|
|
||||||
"Blame output exceeds 16 MiB"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-182
@@ -363,185 +363,5 @@ pub fn labels<'de, D: Deserializer<'de>>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "../tests/unit/chrome.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn popup_partial_options_keep_command_and_search_independent() {
|
|
||||||
let popup: PopupConfig = serde_json::from_value(serde_json::json!({
|
|
||||||
"command": {"anchor": "top_right"}
|
|
||||||
}))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(popup.command.anchor, PopupAnchor::TopRight);
|
|
||||||
assert_eq!(popup.search.anchor, PopupAnchor::BottomLeft);
|
|
||||||
let popup: PopupConfig = serde_json::from_value(serde_json::json!({
|
|
||||||
"command": {}, "search": {"anchor": "center_left"}
|
|
||||||
}))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(popup.command.anchor, PopupAnchor::BottomLeft);
|
|
||||||
assert_eq!(popup.search.anchor, PopupAnchor::CenterLeft);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn malformed_popup_options_are_rejected_at_the_boundary() {
|
|
||||||
for value in [
|
|
||||||
serde_json::json!(null),
|
|
||||||
serde_json::json!([]),
|
|
||||||
serde_json::json!(7),
|
|
||||||
serde_json::json!({"selector": {}}),
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
serde_json::from_value::<PopupConfig>(value.clone()).is_err(),
|
|
||||||
"{value}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for options in [
|
|
||||||
serde_json::json!(null),
|
|
||||||
serde_json::json!([]),
|
|
||||||
serde_json::json!("center"),
|
|
||||||
serde_json::json!({"anchor": null}),
|
|
||||||
serde_json::json!({"anchor": 7}),
|
|
||||||
serde_json::json!({"anchor": true}),
|
|
||||||
serde_json::json!({"anchor": []}),
|
|
||||||
serde_json::json!({"anchor": {}}),
|
|
||||||
serde_json::json!({"anchor": "middle"}),
|
|
||||||
serde_json::json!({"anchor": "TopLeft"}),
|
|
||||||
serde_json::json!({"width": 72}),
|
|
||||||
serde_json::json!({"anchor": "center", "callback": "run"}),
|
|
||||||
] {
|
|
||||||
for mode in ["command", "search"] {
|
|
||||||
let mut popup = serde_json::json!({});
|
|
||||||
popup[mode] = options.clone();
|
|
||||||
assert!(
|
|
||||||
serde_json::from_value::<PopupConfig>(popup).is_err(),
|
|
||||||
"{mode}: {options}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct Lines {
|
|
||||||
#[serde(deserialize_with = "top")]
|
|
||||||
top: ChromeLine,
|
|
||||||
#[serde(deserialize_with = "bottom")]
|
|
||||||
bottom: ChromeLine,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn omitted_options_inherit_and_explicit_groups_replace_defaults() {
|
|
||||||
let lines: Lines = serde_json::from_value(serde_json::json!({
|
|
||||||
"top": {"right": ["battery"], "tabs": {"title": "T{index}:{path}{modified}"}, "battery": {"padding": 0}},
|
|
||||||
"bottom": {"left": [], "center": ["workspace"]}
|
|
||||||
})).unwrap();
|
|
||||||
assert_eq!(lines.top.left, ["tabs"]);
|
|
||||||
assert_eq!(lines.top.segment("battery").padding, 0);
|
|
||||||
assert!(lines.bottom.left.is_empty());
|
|
||||||
assert_eq!(lines.bottom.right, ["language", "lsp", "position"]);
|
|
||||||
assert_eq!(lines.bottom.segment("workspace"), &SegmentConfig::default());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn both_positions_accept_all_builtins_and_custom_styles() {
|
|
||||||
let names = [
|
|
||||||
"tabs",
|
|
||||||
"mode",
|
|
||||||
"pending",
|
|
||||||
"buffer",
|
|
||||||
"diagnostics",
|
|
||||||
"path",
|
|
||||||
"selections",
|
|
||||||
"count",
|
|
||||||
"language",
|
|
||||||
"lsp",
|
|
||||||
"position",
|
|
||||||
"battery",
|
|
||||||
"workspace",
|
|
||||||
];
|
|
||||||
let options = serde_json::json!({
|
|
||||||
"left": names,
|
|
||||||
"center": ["language"],
|
|
||||||
"right": ["tabs"],
|
|
||||||
"tabs": {"title": "{index}:{name}:{path}{modified}", "padding": 0},
|
|
||||||
"mode": {"leading_edge": "<", "trailing_edge": ">"},
|
|
||||||
"workspace": {"first_leading_edge": ""}
|
|
||||||
});
|
|
||||||
let lines: Lines = serde_json::from_value(serde_json::json!({
|
|
||||||
"top": options,
|
|
||||||
"bottom": options,
|
|
||||||
}))
|
|
||||||
.unwrap();
|
|
||||||
for line in [&lines.top, &lines.bottom] {
|
|
||||||
assert_eq!(line.left, names);
|
|
||||||
assert_eq!(line.center, ["language"]);
|
|
||||||
assert_eq!(line.right, ["tabs"]);
|
|
||||||
assert_eq!(line.segment("tabs").padding, 0);
|
|
||||||
assert_eq!(line.segment("mode").leading_edge, "<");
|
|
||||||
assert_eq!(
|
|
||||||
line.segment("workspace").first_leading_edge.as_deref(),
|
|
||||||
Some("")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn view_placeholder_is_valid_in_every_segment_and_bar() {
|
|
||||||
for name in [
|
|
||||||
"tabs",
|
|
||||||
"mode",
|
|
||||||
"pending",
|
|
||||||
"buffer",
|
|
||||||
"diagnostics",
|
|
||||||
"path",
|
|
||||||
"selections",
|
|
||||||
"count",
|
|
||||||
"language",
|
|
||||||
"lsp",
|
|
||||||
"position",
|
|
||||||
"battery",
|
|
||||||
"workspace",
|
|
||||||
] {
|
|
||||||
let options = serde_json::json!({
|
|
||||||
"left": [name],
|
|
||||||
(name): {"title": "{index}:{view}{name}:{path}{modified}"}
|
|
||||||
});
|
|
||||||
let lines: Lines = serde_json::from_value(serde_json::json!({
|
|
||||||
"top": options,
|
|
||||||
"bottom": options,
|
|
||||||
}))
|
|
||||||
.unwrap();
|
|
||||||
for line in [&lines.top, &lines.bottom] {
|
|
||||||
assert_eq!(
|
|
||||||
line.segment(name).title,
|
|
||||||
"{index}:{view}{name}:{path}{modified}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn malformed_chrome_is_rejected_at_the_boundary() {
|
|
||||||
for options in [
|
|
||||||
serde_json::json!({"left": ["bad-name"]}),
|
|
||||||
serde_json::json!({"left": null}),
|
|
||||||
serde_json::json!({"tabs": {"padding": 65}}),
|
|
||||||
serde_json::json!({"tabs": {"padding": null}}),
|
|
||||||
serde_json::json!({"tabs": {"margin": 65}}),
|
|
||||||
serde_json::json!({"tabs": {"margin": -1}}),
|
|
||||||
serde_json::json!({"tabs": {"margin": null}}),
|
|
||||||
serde_json::json!({"tabs": {"margin": "1"}}),
|
|
||||||
serde_json::json!({"tabs": {"margin": 1.5}}),
|
|
||||||
serde_json::json!({"tabs": {"leading_edge": "\n"}}),
|
|
||||||
serde_json::json!({"tabs": {"title": "{unknown}"}}),
|
|
||||||
serde_json::json!({"tabs": {"title": "{name"}}),
|
|
||||||
serde_json::json!({"tabs": {"title": "name}"}}),
|
|
||||||
serde_json::json!({"tabs": {"colour": "red"}}),
|
|
||||||
] {
|
|
||||||
for position in ["top", "bottom"] {
|
|
||||||
let mut frame = serde_json::json!({"top": {}, "bottom": {}});
|
|
||||||
frame[position] = options.clone();
|
|
||||||
assert!(serde_json::from_value::<Lines>(frame).is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-279
@@ -324,91 +324,7 @@ mod macos {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
include!("../tests/unit/clipboard_macos.rs");
|
||||||
unsafe extern "C" {
|
|
||||||
fn PasteboardSynchronize(pasteboard: Object) -> u32;
|
|
||||||
fn PasteboardGetItemIdentifier(
|
|
||||||
pasteboard: Object,
|
|
||||||
index: isize,
|
|
||||||
item: *mut usize,
|
|
||||||
) -> i32;
|
|
||||||
fn PasteboardCopyItemFlavorData(
|
|
||||||
pasteboard: Object,
|
|
||||||
item: usize,
|
|
||||||
flavor: Object,
|
|
||||||
data: *mut Object,
|
|
||||||
) -> i32;
|
|
||||||
fn CFDataGetBytePtr(data: Object) -> *const u8;
|
|
||||||
fn CFDataGetLength(data: Object) -> isize;
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn native_publication_preserves_plain_utf8_bytes_on_an_isolated_pasteboard() {
|
|
||||||
// A private pasteboard leaves the user's clipboard untouched.
|
|
||||||
let _pool = unsafe { AutoreleasePool(objc_autoreleasePoolPush()) };
|
|
||||||
let name = unsafe {
|
|
||||||
Owned::new(CFStringCreateWithCString(
|
|
||||||
ptr::null(),
|
|
||||||
c"vamp-native-clipboard-regression".as_ptr(),
|
|
||||||
0x0800_0100,
|
|
||||||
))
|
|
||||||
.unwrap()
|
|
||||||
};
|
|
||||||
let board = unsafe {
|
|
||||||
let mut board = ptr::null();
|
|
||||||
check(PasteboardCreate(name.0, &mut board)).unwrap();
|
|
||||||
Owned::new(board).unwrap()
|
|
||||||
};
|
|
||||||
let reader = unsafe {
|
|
||||||
let send: unsafe extern "C" fn(Object, Object, Object) -> Object =
|
|
||||||
std::mem::transmute(objc_msgSend as unsafe extern "C" fn());
|
|
||||||
send(
|
|
||||||
objc_getClass(c"NSPasteboard".as_ptr()),
|
|
||||||
sel_registerName(c"pasteboardWithName:".as_ptr()),
|
|
||||||
name.0,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
for text in [
|
|
||||||
"",
|
|
||||||
"é e\u{301} 👨💻\n\0tail",
|
|
||||||
"{\\rtf1 plain text}",
|
|
||||||
"%!PS-Adobe-3.0\nplain",
|
|
||||||
] {
|
|
||||||
write_to(name.0, text).unwrap();
|
|
||||||
assert_eq!(read_from(reader).unwrap(), text);
|
|
||||||
unsafe {
|
|
||||||
PasteboardSynchronize(board.0);
|
|
||||||
let mut item = 0;
|
|
||||||
check(PasteboardGetItemIdentifier(board.0, 1, &mut item)).unwrap();
|
|
||||||
let flavor = Owned::new(CFStringCreateWithCString(
|
|
||||||
ptr::null(),
|
|
||||||
c"public.utf8-plain-text".as_ptr(),
|
|
||||||
0x0800_0100,
|
|
||||||
))
|
|
||||||
.unwrap();
|
|
||||||
let mut data = ptr::null();
|
|
||||||
check(PasteboardCopyItemFlavorData(
|
|
||||||
board.0, item, flavor.0, &mut data,
|
|
||||||
))
|
|
||||||
.unwrap();
|
|
||||||
let data = Owned::new(data).unwrap();
|
|
||||||
let length = CFDataGetLength(data.0) as usize;
|
|
||||||
let bytes = if length == 0 {
|
|
||||||
&[]
|
|
||||||
} else {
|
|
||||||
std::slice::from_raw_parts(CFDataGetBytePtr(data.0), length)
|
|
||||||
};
|
|
||||||
assert_eq!(bytes, text.as_bytes());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let maximum = "x".repeat(super::super::MAX_CLIPBOARD_BYTES);
|
|
||||||
write_to(name.0, &maximum).unwrap();
|
|
||||||
assert_eq!(read_from(reader).unwrap(), maximum);
|
|
||||||
write_to(name.0, &(maximum + "x")).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
read_from(reader).unwrap_err(),
|
|
||||||
"System clipboard text exceeds 4 MiB"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,197 +541,5 @@ unsafe extern "C" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(test, any(target_os = "macos", target_os = "linux")))]
|
#[cfg(all(test, any(target_os = "macos", target_os = "linux")))]
|
||||||
mod tests {
|
#[path = "../tests/unit/clipboard.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
use std::fs;
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
|
|
||||||
static FIXTURE_ID: AtomicU64 = AtomicU64::new(0);
|
|
||||||
|
|
||||||
struct Fixture(PathBuf);
|
|
||||||
|
|
||||||
impl Fixture {
|
|
||||||
fn new() -> Self {
|
|
||||||
let path = std::env::temp_dir().join(format!(
|
|
||||||
"vamp-clipboard-{}-{}",
|
|
||||||
std::process::id(),
|
|
||||||
FIXTURE_ID.fetch_add(1, Ordering::Relaxed)
|
|
||||||
));
|
|
||||||
fs::create_dir(&path).unwrap();
|
|
||||||
Self(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn command(&self, name: &str, body: &str) -> PathBuf {
|
|
||||||
let path = self.0.join(name);
|
|
||||||
fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
|
|
||||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
|
|
||||||
path
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clipboard(&self, text: &[u8]) -> SystemClipboard {
|
|
||||||
let state = self.0.join("state");
|
|
||||||
fs::write(&state, text).unwrap();
|
|
||||||
let state = format!("'{}'", state.to_str().unwrap().replace('\'', "'\\''"));
|
|
||||||
SystemClipboard::with_commands(
|
|
||||||
self.command("read", &format!("exec /bin/cat {state}")),
|
|
||||||
self.command("write", &format!("exec /bin/cat > {state}")),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for Fixture {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
fs::remove_dir_all(&self.0).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn construction_does_not_probe_or_spawn() {
|
|
||||||
assert!(SystemClipboard::new().commands.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trips_unicode_whitespace_and_large_pipes_exactly() {
|
|
||||||
let fixture = Fixture::new();
|
|
||||||
let mut clipboard = fixture.clipboard(b"");
|
|
||||||
for text in ["", " héllo\t世界🙂\r\n\n", &"é\n".repeat(300_000)] {
|
|
||||||
clipboard.write(text).unwrap();
|
|
||||||
assert_eq!(fs::read(fixture.0.join("state")).unwrap(), text.as_bytes());
|
|
||||||
assert_eq!(clipboard.read().unwrap(), text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_oversize_and_invalid_utf8_without_leaking_contents() {
|
|
||||||
let fixture = Fixture::new();
|
|
||||||
let mut clipboard = fixture.clipboard(&[0xff, b'\n']);
|
|
||||||
assert_eq!(
|
|
||||||
clipboard.read().unwrap_err(),
|
|
||||||
"System clipboard is not valid UTF-8"
|
|
||||||
);
|
|
||||||
let maximum = "é".repeat(MAX_CLIPBOARD_BYTES / 2);
|
|
||||||
clipboard.write(&maximum).unwrap();
|
|
||||||
assert_eq!(clipboard.read().unwrap(), maximum);
|
|
||||||
let too_large = format!("{maximum}x");
|
|
||||||
assert_eq!(
|
|
||||||
clipboard.write(&too_large).unwrap_err(),
|
|
||||||
"System clipboard text exceeds 4 MiB"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
fs::read(fixture.0.join("state")).unwrap(),
|
|
||||||
maximum.as_bytes()
|
|
||||||
);
|
|
||||||
fs::write(fixture.0.join("state"), &too_large).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
clipboard.read().unwrap_err(),
|
|
||||||
"System clipboard text exceeds 4 MiB"
|
|
||||||
);
|
|
||||||
let mut incomplete = vec![b'x'; MAX_CLIPBOARD_BYTES - 1];
|
|
||||||
incomplete.push(0xc3);
|
|
||||||
fs::write(fixture.0.join("state"), incomplete).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
clipboard.read().unwrap_err(),
|
|
||||||
"System clipboard is not valid UTF-8"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_failures_and_stderr_cannot_leak_clipboard_text() {
|
|
||||||
let fixture = Fixture::new();
|
|
||||||
let failed = fixture.command(
|
|
||||||
"failed",
|
|
||||||
"printf 'private clipboard payload' >&2\nprintf 'private clipboard payload'\nexit 23",
|
|
||||||
);
|
|
||||||
let mut clipboard = SystemClipboard::with_commands(failed.clone(), failed);
|
|
||||||
let error = clipboard.read().unwrap_err();
|
|
||||||
assert!(error.contains("failed"));
|
|
||||||
assert!(!error.contains("private clipboard payload"));
|
|
||||||
let error = clipboard.write("private clipboard payload").unwrap_err();
|
|
||||||
assert!(!error.contains("private clipboard payload"));
|
|
||||||
let missing = fixture.0.join("not-an-executable");
|
|
||||||
let mut clipboard = SystemClipboard::with_commands(missing.clone(), missing);
|
|
||||||
assert!(clipboard.read().unwrap_err().contains("Cannot start"));
|
|
||||||
assert!(
|
|
||||||
clipboard
|
|
||||||
.write("secret")
|
|
||||||
.unwrap_err()
|
|
||||||
.contains("Cannot start")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ignored_stderr_does_not_block_large_writes() {
|
|
||||||
let fixture = Fixture::new();
|
|
||||||
let mut clipboard = fixture.clipboard(b"");
|
|
||||||
let state = fixture.0.join("state");
|
|
||||||
let state = format!("'{}'", state.to_str().unwrap().replace('\'', "'\\''"));
|
|
||||||
clipboard
|
|
||||||
.commands
|
|
||||||
.as_mut()
|
|
||||||
.unwrap()
|
|
||||||
.as_mut()
|
|
||||||
.unwrap()
|
|
||||||
.writer
|
|
||||||
.as_mut()
|
|
||||||
.unwrap()
|
|
||||||
.program = fixture.command(
|
|
||||||
"noisy",
|
|
||||||
&format!("/bin/dd if=/dev/zero bs=65536 count=8 >&2\nexec /bin/cat > {state}"),
|
|
||||||
);
|
|
||||||
let text = "λ\n".repeat(300_000);
|
|
||||||
clipboard.write(&text).unwrap();
|
|
||||||
assert_eq!(clipboard.read().unwrap(), text);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn failed_process_cleanup_kills_and_reaps_a_running_child() {
|
|
||||||
use std::io::Read;
|
|
||||||
use std::os::unix::process::CommandExt;
|
|
||||||
use std::process::{Command, Stdio};
|
|
||||||
|
|
||||||
unsafe extern "C" {
|
|
||||||
fn waitpid(pid: i32, status: *mut i32, options: i32) -> i32;
|
|
||||||
}
|
|
||||||
|
|
||||||
let fixture = Fixture::new();
|
|
||||||
let hung = fixture.command("running", "printf R\nwhile :; do :; done");
|
|
||||||
let mut command = Command::new(hung);
|
|
||||||
command
|
|
||||||
.process_group(0)
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::null());
|
|
||||||
let mut child = command.spawn().unwrap();
|
|
||||||
let pid = child.id() as i32;
|
|
||||||
let mut ready = [0];
|
|
||||||
child
|
|
||||||
.stdout
|
|
||||||
.as_mut()
|
|
||||||
.unwrap()
|
|
||||||
.read_exact(&mut ready)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(ready, [b'R']);
|
|
||||||
drop(ClipboardProcess(Some(child)));
|
|
||||||
let mut status = 0;
|
|
||||||
// SAFETY: waitpid's output pointer is valid; WNOHANG cannot block this check.
|
|
||||||
assert_eq!(unsafe { waitpid(pid, &mut status, 1) }, -1);
|
|
||||||
assert_eq!(std::io::Error::last_os_error().raw_os_error(), Some(10));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn immediate_injected_timeout_terminates_nonterminating_commands() {
|
|
||||||
let fixture = Fixture::new();
|
|
||||||
let hung = fixture.command("hung", "while :; do :; done");
|
|
||||||
let mut clipboard = SystemClipboard::with_commands(hung.clone(), hung);
|
|
||||||
// No assertion depends on wall-clock speed or a deadline remaining in the future.
|
|
||||||
clipboard.timeout = Duration::ZERO;
|
|
||||||
assert_eq!(
|
|
||||||
clipboard.read().unwrap_err(),
|
|
||||||
"System clipboard command timed out"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
clipboard.write("text").unwrap_err(),
|
|
||||||
"System clipboard command timed out"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-2560
File diff suppressed because it is too large
Load Diff
+2
-15233
File diff suppressed because it is too large
Load Diff
+2
-988
@@ -1068,991 +1068,5 @@ impl FilePicker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "../tests/unit/files.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn virtual_catalog_compacts_and_retains_file_directory_collisions() {
|
|
||||||
let mut picker = FilePicker::from_paths(
|
|
||||||
PathBuf::new(),
|
|
||||||
["x", "x/y", "src/deep/deleted.rs", "src/deep/deleted.rs"]
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.into_iter()
|
|
||||||
.enumerate(),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
picker
|
|
||||||
.entries()
|
|
||||||
.map(|row| row.label.to_owned())
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
["src/deep", "x", "x"]
|
|
||||||
);
|
|
||||||
picker.reveal_path(Path::new("x"));
|
|
||||||
assert!(!picker.entries().nth(picker.selected).unwrap().is_dir);
|
|
||||||
assert_eq!(picker.accept(), Some(PathBuf::from("x")));
|
|
||||||
picker.reveal_path(Path::new("x/y"));
|
|
||||||
assert_eq!(picker.accept(), Some(PathBuf::from("x/y")));
|
|
||||||
picker.reveal_path(Path::new("src/deep/deleted.rs"));
|
|
||||||
assert_eq!(picker.accept(), Some(PathBuf::from("src/deep/deleted.rs")));
|
|
||||||
assert_eq!(picker.entries().filter(|row| !row.is_dir).count(), 3);
|
|
||||||
let expected_index = |row: &FileRow<'_>| {
|
|
||||||
if row.is_dir {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(match row.path.to_str().unwrap() {
|
|
||||||
"x" => 0,
|
|
||||||
"x/y" => 1,
|
|
||||||
"src/deep/deleted.rs" => 2,
|
|
||||||
path => panic!("unexpected path {path}"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
};
|
|
||||||
for view in [FileView::List, FileView::Tree] {
|
|
||||||
picker.set_view(view);
|
|
||||||
for query in ["", "x", "deleted"] {
|
|
||||||
picker.query = query.into();
|
|
||||||
picker.refilter();
|
|
||||||
let rows: Vec<_> = picker.entries().collect();
|
|
||||||
assert!(!rows.is_empty());
|
|
||||||
for row in rows {
|
|
||||||
assert_eq!(row.catalog_index, expected_index(&row));
|
|
||||||
assert!(row.status.is_none() && row.old_path.is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
|
|
||||||
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
|
|
||||||
struct Directory(PathBuf);
|
|
||||||
impl Directory {
|
|
||||||
fn new() -> Self {
|
|
||||||
let id = TEMP_ID.fetch_add(1, Ordering::Relaxed);
|
|
||||||
let path =
|
|
||||||
std::env::temp_dir().join(format!("vamp-files-test-{}-{id}", std::process::id()));
|
|
||||||
fs::create_dir(&path).unwrap();
|
|
||||||
Self(path)
|
|
||||||
}
|
|
||||||
fn put(&self, name: &str, text: &str) {
|
|
||||||
let path = self.0.join(name);
|
|
||||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
||||||
fs::write(path, text).unwrap();
|
|
||||||
}
|
|
||||||
fn scan(&self) -> FilePicker {
|
|
||||||
FilePicker::scan(self.0.clone(), FilePresentation::Floating, FileView::List).unwrap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl Drop for Directory {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = fs::remove_dir_all(&self.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn paths(picker: &FilePicker) -> Vec<PathBuf> {
|
|
||||||
picker
|
|
||||||
.entries()
|
|
||||||
.map(|entry| entry.path.to_owned())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fuzzy_path_tokens_preserve_tree_context_selection_and_open_paths() {
|
|
||||||
let directory = Directory::new();
|
|
||||||
let names = [
|
|
||||||
"src/fuzzy_parser.rs",
|
|
||||||
"src/über_parser.rs",
|
|
||||||
"src/other.rs",
|
|
||||||
"tests/fuzzy_parser.rs",
|
|
||||||
];
|
|
||||||
for name in names {
|
|
||||||
directory.put(name, "");
|
|
||||||
}
|
|
||||||
for mut picker in [
|
|
||||||
directory.scan(),
|
|
||||||
FilePicker::from_paths(
|
|
||||||
directory.0.clone(),
|
|
||||||
names.map(PathBuf::from).into_iter().enumerate(),
|
|
||||||
),
|
|
||||||
] {
|
|
||||||
for view in [FileView::Tree, FileView::List] {
|
|
||||||
picker.set_view(view);
|
|
||||||
for (query, expected) in [
|
|
||||||
("sr/fp RS", "src/fuzzy_parser.rs"),
|
|
||||||
("ÜPR SRC", "src/über_parser.rs"),
|
|
||||||
] {
|
|
||||||
picker.query = query.into();
|
|
||||||
picker.refilter();
|
|
||||||
assert_eq!(
|
|
||||||
picker
|
|
||||||
.entries()
|
|
||||||
.filter(|row| !row.is_dir)
|
|
||||||
.map(|row| row.path)
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
[Path::new(expected)]
|
|
||||||
);
|
|
||||||
assert_eq!(selected_path(&picker), Path::new(expected));
|
|
||||||
assert_eq!(picker.accept(), Some(directory.0.join(expected)));
|
|
||||||
if view == FileView::Tree {
|
|
||||||
assert_eq!(paths(&picker), [Path::new("src"), Path::new(expected)]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
picker.query = "rs/ü".into();
|
|
||||||
picker.refilter();
|
|
||||||
assert!(picker.entries().next().is_none());
|
|
||||||
picker.clear_filter();
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("src/über_parser.rs"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scanned_files_and_directories_have_no_catalog_index() {
|
|
||||||
let directory = Directory::new();
|
|
||||||
directory.put("nested/file.txt", "");
|
|
||||||
let mut picker = directory.scan();
|
|
||||||
for view in [FileView::List, FileView::Tree] {
|
|
||||||
picker.set_view(view);
|
|
||||||
picker.reveal_path(Path::new("nested/file.txt"));
|
|
||||||
assert!(picker.entries().all(|row| row.catalog_index.is_none()));
|
|
||||||
assert!(
|
|
||||||
picker
|
|
||||||
.entries()
|
|
||||||
.all(|row| row.status.is_none() && row.old_path.is_none())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn repository_change(
|
|
||||||
path: &str,
|
|
||||||
status: FileStatus,
|
|
||||||
old_path: Option<&str>,
|
|
||||||
) -> WorkingFileChange {
|
|
||||||
WorkingFileChange {
|
|
||||||
path: path.into(),
|
|
||||||
status,
|
|
||||||
old_path: old_path.map(PathBuf::from),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn repository_metadata_keeps_clean_files_selection_and_filtered_views() {
|
|
||||||
let directory = Directory::new();
|
|
||||||
for name in ["src/clean.rs", "src/modified.rs", "added.rs", "renamed.rs"] {
|
|
||||||
directory.put(name, "");
|
|
||||||
}
|
|
||||||
let mut picker = directory.scan();
|
|
||||||
picker.presentation = FilePresentation::Docked;
|
|
||||||
picker.set_view(FileView::Tree);
|
|
||||||
picker.reveal_path(Path::new("src/clean.rs"));
|
|
||||||
picker.query = "src".into();
|
|
||||||
picker.refilter();
|
|
||||||
picker.focused = false;
|
|
||||||
let changes = vec![
|
|
||||||
repository_change("src/modified.rs", FileStatus::Modified, None),
|
|
||||||
repository_change("added.rs", FileStatus::Added, None),
|
|
||||||
repository_change("renamed.rs", FileStatus::Renamed, Some("old.rs")),
|
|
||||||
repository_change("vanished/deep/deleted.rs", FileStatus::Removed, None),
|
|
||||||
];
|
|
||||||
assert!(picker.set_repository_changes(changes.clone()));
|
|
||||||
assert!(!picker.set_repository_changes(changes));
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("src/clean.rs"));
|
|
||||||
assert_eq!(picker.query, "src");
|
|
||||||
assert!(!picker.focused);
|
|
||||||
assert_eq!(picker.presentation, FilePresentation::Docked);
|
|
||||||
for view in [FileView::List, FileView::Tree] {
|
|
||||||
picker.set_view(view);
|
|
||||||
for query in ["", "src", "modified.rs", "deleted.rs"] {
|
|
||||||
picker.query = query.into();
|
|
||||||
picker.refilter();
|
|
||||||
let rows: Vec<_> = picker.entries().collect();
|
|
||||||
assert!(!rows.is_empty());
|
|
||||||
for row in rows {
|
|
||||||
assert!(row.catalog_index.is_none());
|
|
||||||
if row.is_dir || row.path == Path::new("src/clean.rs") {
|
|
||||||
assert!(row.status.is_none() && row.old_path.is_none());
|
|
||||||
} else {
|
|
||||||
let status = match row.path.to_str().unwrap() {
|
|
||||||
"src/modified.rs" => FileStatus::Modified,
|
|
||||||
"added.rs" => FileStatus::Added,
|
|
||||||
"renamed.rs" => FileStatus::Renamed,
|
|
||||||
"vanished/deep/deleted.rs" => FileStatus::Removed,
|
|
||||||
other => panic!("unexpected {other}"),
|
|
||||||
};
|
|
||||||
assert_eq!(row.status, Some(status));
|
|
||||||
assert_eq!(
|
|
||||||
row.old_path,
|
|
||||||
(status == FileStatus::Renamed).then(|| Path::new("old.rs"))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if query.is_empty() || query == "src" {
|
|
||||||
assert!(
|
|
||||||
picker
|
|
||||||
.entries()
|
|
||||||
.any(|row| row.path == Path::new("src/clean.rs"))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
picker.set_view(FileView::List);
|
|
||||||
picker.clear_filter();
|
|
||||||
picker.reveal_path(Path::new("src/clean.rs"));
|
|
||||||
assert!(picker.set_repository_changes(Vec::new()));
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("src/clean.rs"));
|
|
||||||
assert_eq!(picker.entries().len(), 4);
|
|
||||||
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
|
|
||||||
assert!(
|
|
||||||
picker
|
|
||||||
.entries()
|
|
||||||
.all(|row| row.status.is_none() && row.old_path.is_none())
|
|
||||||
);
|
|
||||||
assert!(!picker.set_repository_changes(Vec::new()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn deleted_catalog_leaves_survive_refresh_and_restored_files_survive_clear() {
|
|
||||||
let directory = Directory::new();
|
|
||||||
directory.put("keep.txt", "");
|
|
||||||
let mut picker = directory.scan();
|
|
||||||
let deleted = Path::new("lost/deep/deleted.txt");
|
|
||||||
picker.set_repository_changes(vec![repository_change(
|
|
||||||
"lost/deep/deleted.txt",
|
|
||||||
FileStatus::Removed,
|
|
||||||
None,
|
|
||||||
)]);
|
|
||||||
picker.set_view(FileView::Tree);
|
|
||||||
assert_eq!(
|
|
||||||
picker.entries().find(|row| row.is_dir).unwrap().label,
|
|
||||||
"lost/deep"
|
|
||||||
);
|
|
||||||
picker.reveal_path(deleted);
|
|
||||||
picker.query = "deleted".into();
|
|
||||||
picker.refilter();
|
|
||||||
picker.start_search();
|
|
||||||
picker.focused = false;
|
|
||||||
picker.refresh();
|
|
||||||
assert_eq!(selected_path(&picker), deleted);
|
|
||||||
assert!(picker.searching && !picker.focused);
|
|
||||||
assert_eq!(picker.query, "deleted");
|
|
||||||
assert_eq!(picker.accept(), Some(directory.0.join(deleted)));
|
|
||||||
assert!(!directory.0.join(deleted).exists());
|
|
||||||
assert_eq!(
|
|
||||||
picker.entries().find(|row| !row.is_dir).unwrap().status,
|
|
||||||
Some(FileStatus::Removed)
|
|
||||||
);
|
|
||||||
directory.put("lost/deep/deleted.txt", "restored");
|
|
||||||
assert!(picker.set_repository_changes(vec![repository_change(
|
|
||||||
"lost/deep/deleted.txt",
|
|
||||||
FileStatus::Removed,
|
|
||||||
None,
|
|
||||||
)]));
|
|
||||||
picker.refresh();
|
|
||||||
assert_eq!(selected_path(&picker), deleted);
|
|
||||||
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
|
|
||||||
assert!(picker.set_repository_changes(Vec::new()));
|
|
||||||
assert_eq!(selected_path(&picker), deleted);
|
|
||||||
assert_eq!(picker.accept(), Some(directory.0.join(deleted)));
|
|
||||||
assert_eq!(
|
|
||||||
fs::read_to_string(directory.0.join(deleted)).unwrap(),
|
|
||||||
"restored"
|
|
||||||
);
|
|
||||||
assert!(picker.entries().all(|row| row.status.is_none()));
|
|
||||||
fs::remove_dir_all(directory.0.join("lost")).unwrap();
|
|
||||||
picker.refresh();
|
|
||||||
picker.set_repository_changes(vec![repository_change(
|
|
||||||
"lost/deep/deleted.txt",
|
|
||||||
FileStatus::Removed,
|
|
||||||
None,
|
|
||||||
)]);
|
|
||||||
picker.reveal_path(deleted);
|
|
||||||
picker.set_repository_changes(Vec::new());
|
|
||||||
assert!(picker.entries().len() == 0);
|
|
||||||
picker.clear_filter();
|
|
||||||
assert_eq!(paths(&picker), [PathBuf::from("keep.txt")]);
|
|
||||||
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn repository_metadata_cannot_introduce_unsafe_or_existing_excluded_paths() {
|
|
||||||
let directory = Directory::new();
|
|
||||||
directory.put(".gitignore", "ignored/\n");
|
|
||||||
directory.put("ignored/hidden.txt", "");
|
|
||||||
directory.put("directory/keep.txt", "");
|
|
||||||
let mut picker = directory.scan();
|
|
||||||
let mut changes: Vec<_> = [
|
|
||||||
"",
|
|
||||||
".",
|
|
||||||
"../outside.txt",
|
|
||||||
"directory/../../outside.txt",
|
|
||||||
".git/secret",
|
|
||||||
"nested/.jj/secret",
|
|
||||||
"/outside.txt",
|
|
||||||
"directory",
|
|
||||||
"ignored/hidden.txt",
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.map(|path| repository_change(path, FileStatus::Removed, None))
|
|
||||||
.collect();
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
std::os::unix::fs::symlink(&directory.0, directory.0.join("link")).unwrap();
|
|
||||||
changes.push(repository_change(
|
|
||||||
"link/missing.txt",
|
|
||||||
FileStatus::Removed,
|
|
||||||
None,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
changes.push(repository_change("missing.txt", FileStatus::Removed, None));
|
|
||||||
picker.set_repository_changes(changes);
|
|
||||||
assert_eq!(
|
|
||||||
paths(&picker),
|
|
||||||
[".gitignore", "directory/keep.txt", "missing.txt"].map(PathBuf::from)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
picker.entries().filter(|row| row.status.is_some()).count(),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
picker.entries().last().unwrap().status,
|
|
||||||
Some(FileStatus::Removed)
|
|
||||||
);
|
|
||||||
assert!(!directory.0.join("missing.txt").exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn nested_ignore_negation_hidden_files_and_vcs_metadata() {
|
|
||||||
let directory = Directory::new();
|
|
||||||
directory.put(".gitignore", "*.log\nignored/\n");
|
|
||||||
directory.put("nested/.gitignore", "!keep.log\nlocal.txt\n");
|
|
||||||
for name in [
|
|
||||||
"gone.log",
|
|
||||||
"nested/gone.log",
|
|
||||||
"nested/keep.log",
|
|
||||||
"nested/local.txt",
|
|
||||||
"nested/ok.txt",
|
|
||||||
"ignored/hidden.txt",
|
|
||||||
".hidden",
|
|
||||||
".git/objects/secret",
|
|
||||||
"nested/.git/secret",
|
|
||||||
".jj/secret",
|
|
||||||
"nested/.jj/secret",
|
|
||||||
] {
|
|
||||||
directory.put(name, "text");
|
|
||||||
}
|
|
||||||
let picker = directory.scan();
|
|
||||||
assert_eq!(
|
|
||||||
paths(&picker),
|
|
||||||
[
|
|
||||||
".gitignore",
|
|
||||||
".hidden",
|
|
||||||
"nested/.gitignore",
|
|
||||||
"nested/keep.log",
|
|
||||||
"nested/ok.txt"
|
|
||||||
]
|
|
||||||
.map(PathBuf::from)
|
|
||||||
);
|
|
||||||
assert!(picker.error.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn literal_unicode_tokens_grapheme_deletion_and_navigation() {
|
|
||||||
let directory = Directory::new();
|
|
||||||
for name in ["a space 界[e\u{301}].txt", "b.txt", "c.txt"] {
|
|
||||||
directory.put(name, "text");
|
|
||||||
}
|
|
||||||
let mut picker = directory.scan();
|
|
||||||
picker.navigate(false);
|
|
||||||
assert_eq!(picker.selected, 2);
|
|
||||||
picker.navigate(true);
|
|
||||||
assert_eq!(picker.selected, 0);
|
|
||||||
picker.query = "界 SPACE [E\u{301}]".into();
|
|
||||||
picker.refilter();
|
|
||||||
assert_eq!(paths(&picker), [PathBuf::from("a space 界[e\u{301}].txt")]);
|
|
||||||
picker.query = "e\u{301}👩💻".into();
|
|
||||||
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
|
||||||
assert_eq!(picker.query, "e\u{301}");
|
|
||||||
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
|
||||||
assert_eq!(picker.query, "");
|
|
||||||
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
|
||||||
assert_eq!(picker.entries().len(), 3);
|
|
||||||
picker.query = "no match".into();
|
|
||||||
picker.refilter();
|
|
||||||
for forward in [true, false] {
|
|
||||||
picker.navigate(forward);
|
|
||||||
assert_eq!(picker.selected, 0);
|
|
||||||
assert_eq!(picker.entries().len(), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn word_and_line_deletion_refilter_only_editable_queries() {
|
|
||||||
use crate::editor::Input;
|
|
||||||
|
|
||||||
for view in [FileView::List, FileView::Tree] {
|
|
||||||
let mut picker = FilePicker::from_paths(
|
|
||||||
PathBuf::new(),
|
|
||||||
["alpha one.txt", "alpha two.txt", "beta.txt"]
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.into_iter()
|
|
||||||
.enumerate(),
|
|
||||||
);
|
|
||||||
picker.set_view(view);
|
|
||||||
picker.query = "alpha missing".into();
|
|
||||||
picker.refilter();
|
|
||||||
assert_eq!(picker.entries().len(), 0);
|
|
||||||
if view == FileView::Tree {
|
|
||||||
for input in [Input::Backspace, Input::AltBackspace, Input::SuperBackspace] {
|
|
||||||
assert!(!picker.handle_filter_input(&input));
|
|
||||||
assert_eq!(picker.query, "alpha missing");
|
|
||||||
}
|
|
||||||
picker.start_search();
|
|
||||||
}
|
|
||||||
assert!(picker.handle_filter_input(&Input::AltBackspace));
|
|
||||||
assert_eq!(picker.query, "alpha ");
|
|
||||||
assert_eq!(picker.entries().len(), 2);
|
|
||||||
assert!(picker.handle_filter_input(&Input::SuperBackspace));
|
|
||||||
assert_eq!(picker.query, "");
|
|
||||||
assert_eq!(picker.entries().len(), 3);
|
|
||||||
for input in [Input::Backspace, Input::AltBackspace, Input::SuperBackspace] {
|
|
||||||
assert!(picker.handle_filter_input(&input));
|
|
||||||
assert_eq!(picker.entries().len(), 3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_scan_refresh_and_failure_preserve_reachable_state() {
|
|
||||||
let directory = Directory::new();
|
|
||||||
let mut picker = directory.scan();
|
|
||||||
picker.navigate(false);
|
|
||||||
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
|
||||||
assert_eq!(picker.entries().len(), 0);
|
|
||||||
directory.put("new.txt", "text");
|
|
||||||
picker.query = "new".into();
|
|
||||||
picker.refresh();
|
|
||||||
assert_eq!(picker.query, "new");
|
|
||||||
assert_eq!(paths(&picker), [PathBuf::from("new.txt")]);
|
|
||||||
fs::remove_dir_all(&directory.0).unwrap();
|
|
||||||
picker.refresh();
|
|
||||||
assert_eq!(paths(&picker), [PathBuf::from("new.txt")]);
|
|
||||||
assert!(picker.error.as_ref().unwrap().contains("Refresh failed"));
|
|
||||||
assert!(
|
|
||||||
FilePicker::scan(
|
|
||||||
directory.0.clone(),
|
|
||||||
FilePresentation::Docked,
|
|
||||||
FileView::Tree
|
|
||||||
)
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn malformed_ignore_warning_survives_query_and_navigation() {
|
|
||||||
let directory = Directory::new();
|
|
||||||
directory.put(".gitignore", "[z-a]\n");
|
|
||||||
directory.put("ok.txt", "text");
|
|
||||||
let mut picker = directory.scan();
|
|
||||||
assert!(picker.error.as_ref().unwrap().contains("Incomplete scan"));
|
|
||||||
picker.query = "ok".into();
|
|
||||||
picker.refilter();
|
|
||||||
assert_eq!(paths(&picker), [PathBuf::from("ok.txt")]);
|
|
||||||
picker.navigate(true);
|
|
||||||
assert!(picker.error.as_ref().unwrap().contains("Incomplete scan"));
|
|
||||||
directory.put(".gitignore", "");
|
|
||||||
picker.refresh();
|
|
||||||
assert!(picker.error.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn only_project_ignore_rules_apply_and_non_directory_roots_fail() {
|
|
||||||
let directory = Directory::new();
|
|
||||||
directory.put(".gitignore", "*.txt\n");
|
|
||||||
directory.put("project/visible.txt", "text");
|
|
||||||
let root = directory.0.join("project");
|
|
||||||
let picker = FilePicker::scan(root, FilePresentation::Floating, FileView::List).unwrap();
|
|
||||||
assert_eq!(paths(&picker), [PathBuf::from("visible.txt")]);
|
|
||||||
assert!(
|
|
||||||
FilePicker::scan(
|
|
||||||
directory.0.join("project/visible.txt"),
|
|
||||||
FilePresentation::Docked,
|
|
||||||
FileView::Tree,
|
|
||||||
)
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
|
||||||
fn symlink_loops_and_file_links_are_not_followed_and_raw_paths_survive() {
|
|
||||||
use std::os::unix::fs::symlink;
|
|
||||||
let directory = Directory::new();
|
|
||||||
directory.put("real.txt", "text");
|
|
||||||
symlink(&directory.0, directory.0.join("loop")).unwrap();
|
|
||||||
symlink(directory.0.join("real.txt"), directory.0.join("alias.txt")).unwrap();
|
|
||||||
let raw = PathBuf::from("raw界\n.txt");
|
|
||||||
fs::write(directory.0.join(&raw), "text").unwrap();
|
|
||||||
let picker = directory.scan();
|
|
||||||
assert_eq!(picker.entries().len(), 2);
|
|
||||||
let entry = picker
|
|
||||||
.entries()
|
|
||||||
.find(|entry| entry.path == raw.as_path())
|
|
||||||
.unwrap();
|
|
||||||
assert!(!entry.label.chars().any(char::is_control));
|
|
||||||
assert_eq!(
|
|
||||||
fs::read_to_string(directory.0.join(entry.path)).unwrap(),
|
|
||||||
"text"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tree(directory: &Directory) -> FilePicker {
|
|
||||||
FilePicker::scan(
|
|
||||||
directory.0.clone(),
|
|
||||||
FilePresentation::Docked,
|
|
||||||
FileView::Tree,
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn select(picker: &mut FilePicker, path: &str) {
|
|
||||||
let selected = picker
|
|
||||||
.entries()
|
|
||||||
.position(|row| row.path == Path::new(path))
|
|
||||||
.unwrap();
|
|
||||||
picker.selected = selected;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn selected_path(picker: &FilePicker) -> &Path {
|
|
||||||
picker.entries().nth(picker.selected).unwrap().path
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tree_orders_folders_first_keeps_empty_dirs_and_compacts_only_directory_chains() {
|
|
||||||
let directory = Directory::new();
|
|
||||||
directory.put("a.txt", "");
|
|
||||||
directory.put("folder/only.txt", "");
|
|
||||||
directory.put("chain/one/two/a.txt", "");
|
|
||||||
directory.put("chain/one/two/z.txt", "");
|
|
||||||
fs::create_dir(directory.0.join("empty")).unwrap();
|
|
||||||
let mut picker = tree(&directory);
|
|
||||||
assert_eq!(picker.view, FileView::Tree);
|
|
||||||
assert!(!picker.searching);
|
|
||||||
assert_eq!(
|
|
||||||
picker
|
|
||||||
.entries()
|
|
||||||
.map(|row| (row.label, row.is_dir, row.depth))
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
[
|
|
||||||
("chain/one/two", true, 0),
|
|
||||||
("empty", true, 0),
|
|
||||||
("folder", true, 0),
|
|
||||||
("a.txt", false, 0)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("chain/one/two"));
|
|
||||||
picker.right();
|
|
||||||
assert_eq!(
|
|
||||||
picker
|
|
||||||
.entries()
|
|
||||||
.map(|row| (row.label, row.depth))
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
[
|
|
||||||
("chain/one/two", 0),
|
|
||||||
("a.txt", 1),
|
|
||||||
("z.txt", 1),
|
|
||||||
("empty", 0),
|
|
||||||
("folder", 0),
|
|
||||||
("a.txt", 0)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
picker.right();
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("chain/one/two/a.txt"));
|
|
||||||
let rows = paths(&picker);
|
|
||||||
picker.right();
|
|
||||||
assert_eq!(paths(&picker), rows);
|
|
||||||
picker.left();
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("chain/one/two"));
|
|
||||||
picker.left();
|
|
||||||
assert!(!picker.entries().next().unwrap().expanded);
|
|
||||||
picker.left();
|
|
||||||
assert_eq!(picker.selected, 0);
|
|
||||||
select(&mut picker, "folder");
|
|
||||||
assert!(picker.accept().is_none());
|
|
||||||
assert_eq!(
|
|
||||||
picker.entries().nth(picker.selected + 1).unwrap().label,
|
|
||||||
"only.txt"
|
|
||||||
);
|
|
||||||
picker.right();
|
|
||||||
assert_eq!(picker.accept(), Some(directory.0.join("folder/only.txt")));
|
|
||||||
select(&mut picker, "empty");
|
|
||||||
picker.right();
|
|
||||||
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
|
|
||||||
picker.right();
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("empty"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tree_search_reveals_ancestors_without_mutating_saved_expansion_or_compaction() {
|
|
||||||
let directory = Directory::new();
|
|
||||||
directory.put("src/module/deep/needle.txt", "");
|
|
||||||
directory.put("src/module/sibling.txt", "");
|
|
||||||
directory.put("src/root.txt", "");
|
|
||||||
directory.put("other/keep.txt", "");
|
|
||||||
let mut picker = tree(&directory);
|
|
||||||
select(&mut picker, "other");
|
|
||||||
picker.right();
|
|
||||||
select(&mut picker, "src");
|
|
||||||
let expanded = picker.expanded.clone();
|
|
||||||
picker.start_search();
|
|
||||||
picker.query = "NEEDLE".into();
|
|
||||||
picker.refilter();
|
|
||||||
assert_eq!(
|
|
||||||
picker
|
|
||||||
.entries()
|
|
||||||
.map(|row| (row.label, row.depth))
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
[("src", 0), ("module", 1), ("deep", 2), ("needle.txt", 3)]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
selected_path(&picker),
|
|
||||||
Path::new("src/module/deep/needle.txt")
|
|
||||||
);
|
|
||||||
assert_eq!(picker.expanded, expanded);
|
|
||||||
picker.left();
|
|
||||||
let filtered = paths(&picker);
|
|
||||||
picker.finish_search();
|
|
||||||
assert_eq!(picker.query, "NEEDLE");
|
|
||||||
assert_eq!(paths(&picker), filtered);
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("src/module/deep"));
|
|
||||||
assert_eq!(picker.expanded, expanded);
|
|
||||||
picker.start_search();
|
|
||||||
picker.query = "deep".into();
|
|
||||||
picker.refilter();
|
|
||||||
select(&mut picker, "src/module/deep");
|
|
||||||
assert!(picker.accept().is_none());
|
|
||||||
assert!(!picker.searching);
|
|
||||||
assert_eq!(picker.query, "deep");
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("src/module/deep"));
|
|
||||||
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
|
|
||||||
assert_eq!(picker.expanded, expanded);
|
|
||||||
picker.right();
|
|
||||||
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
|
|
||||||
assert_eq!(picker.query, "deep");
|
|
||||||
|
|
||||||
let chain = Directory::new();
|
|
||||||
chain.put("one/two/three/a.txt", "");
|
|
||||||
chain.put("one/two/three/b.txt", "");
|
|
||||||
let mut picker = tree(&chain);
|
|
||||||
picker.start_search();
|
|
||||||
picker.query = "a.txt".into();
|
|
||||||
picker.refilter();
|
|
||||||
assert_eq!(
|
|
||||||
picker.entries().map(|row| row.label).collect::<Vec<_>>(),
|
|
||||||
["one/two/three", "a.txt"]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn view_switches_restore_directory_choices_and_reveal_explicit_file_choices() {
|
|
||||||
let directory = Directory::new();
|
|
||||||
directory.put("src/a.txt", "");
|
|
||||||
directory.put("src/b.txt", "");
|
|
||||||
directory.put("z.txt", "");
|
|
||||||
let mut picker = tree(&directory);
|
|
||||||
picker.right();
|
|
||||||
let open = paths(&picker);
|
|
||||||
picker.set_view(FileView::List);
|
|
||||||
assert_eq!(
|
|
||||||
paths(&picker),
|
|
||||||
["src/a.txt", "src/b.txt", "z.txt"].map(PathBuf::from)
|
|
||||||
);
|
|
||||||
picker.set_view(FileView::Tree);
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("src"));
|
|
||||||
assert_eq!(paths(&picker), open);
|
|
||||||
picker.left();
|
|
||||||
picker.toggle_view();
|
|
||||||
picker.navigate(true);
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
|
|
||||||
picker.toggle_view();
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
|
|
||||||
assert!(picker.entries().next().unwrap().expanded);
|
|
||||||
picker.left();
|
|
||||||
picker.toggle_view();
|
|
||||||
picker.toggle_view();
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("src"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn filtering_and_refresh_preserve_raw_selection_and_surviving_expansion() {
|
|
||||||
let directory = Directory::new();
|
|
||||||
directory.put("src/a.txt", "");
|
|
||||||
directory.put("src/b.txt", "");
|
|
||||||
directory.put("z.txt", "");
|
|
||||||
let mut picker = directory.scan();
|
|
||||||
select(&mut picker, "src/b.txt");
|
|
||||||
picker.query = "absent".into();
|
|
||||||
picker.refilter();
|
|
||||||
assert_eq!(picker.entries().len(), 0);
|
|
||||||
picker.query.clear();
|
|
||||||
picker.refilter();
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
|
|
||||||
picker.set_view(FileView::Tree);
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
|
|
||||||
fs::remove_file(directory.0.join("src/b.txt")).unwrap();
|
|
||||||
directory.put("src/c.txt", "");
|
|
||||||
picker.refresh();
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("src"));
|
|
||||||
assert!(picker.entries().next().unwrap().expanded);
|
|
||||||
assert!(paths(&picker).contains(&PathBuf::from("src/c.txt")));
|
|
||||||
fs::remove_dir_all(directory.0.join("src")).unwrap();
|
|
||||||
picker.refresh();
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("z.txt"));
|
|
||||||
assert!(picker.expanded.is_empty());
|
|
||||||
picker.toggle_view();
|
|
||||||
picker.toggle_view();
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("z.txt"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tree_search_subfocus_and_expansion_survive_view_toggles_and_refresh() {
|
|
||||||
let directory = Directory::new();
|
|
||||||
directory.put("src/deep/a.txt", "");
|
|
||||||
directory.put("src/other.txt", "");
|
|
||||||
let mut picker = tree(&directory);
|
|
||||||
picker.start_search();
|
|
||||||
assert!(picker.searching);
|
|
||||||
assert_eq!(picker.entries().len(), 4);
|
|
||||||
assert!(picker.expanded.is_empty());
|
|
||||||
picker.toggle_view();
|
|
||||||
assert_eq!(picker.view, FileView::List);
|
|
||||||
picker.toggle_view();
|
|
||||||
assert!(picker.searching);
|
|
||||||
assert!(picker.expanded.is_empty());
|
|
||||||
picker.query = "a.txt".into();
|
|
||||||
picker.refilter();
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
|
|
||||||
picker.refresh();
|
|
||||||
assert!(picker.searching);
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
|
|
||||||
let filtered = paths(&picker);
|
|
||||||
picker.finish_search();
|
|
||||||
assert!(!picker.searching);
|
|
||||||
assert_eq!(picker.query, "a.txt");
|
|
||||||
assert_eq!(paths(&picker), filtered);
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
|
|
||||||
assert!(picker.expanded.is_empty());
|
|
||||||
picker.toggle_view();
|
|
||||||
picker.refresh();
|
|
||||||
picker.toggle_view();
|
|
||||||
assert!(!picker.searching);
|
|
||||||
assert_eq!(picker.query, "a.txt");
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
|
|
||||||
picker.start_search();
|
|
||||||
assert!(picker.searching);
|
|
||||||
assert_eq!(picker.query, "a.txt");
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clearing_filter_preserves_mode_focus_current_path_and_empty_result_fallback() {
|
|
||||||
let directory = Directory::new();
|
|
||||||
directory.put("src/deep/needle.txt", "");
|
|
||||||
directory.put("src/other.txt", "");
|
|
||||||
directory.put("other/keep.txt", "");
|
|
||||||
for (view, searching) in [
|
|
||||||
(FileView::Tree, false),
|
|
||||||
(FileView::Tree, true),
|
|
||||||
(FileView::List, false),
|
|
||||||
] {
|
|
||||||
let mut picker = tree(&directory);
|
|
||||||
select(&mut picker, "other");
|
|
||||||
picker.right();
|
|
||||||
let expanded = picker.expanded.clone();
|
|
||||||
picker.set_view(view);
|
|
||||||
if view == FileView::Tree {
|
|
||||||
picker.start_search();
|
|
||||||
}
|
|
||||||
picker.query = "needle".into();
|
|
||||||
picker.refilter();
|
|
||||||
if !searching && view == FileView::Tree {
|
|
||||||
picker.finish_search();
|
|
||||||
}
|
|
||||||
let expected = Path::new("src/deep/needle.txt");
|
|
||||||
assert_eq!(selected_path(&picker), expected);
|
|
||||||
picker.clear_filter();
|
|
||||||
assert!(picker.query.is_empty());
|
|
||||||
assert_eq!(picker.searching, searching);
|
|
||||||
assert!(picker.focused);
|
|
||||||
assert_eq!(selected_path(&picker), expected);
|
|
||||||
assert!(picker.expanded.is_superset(&expanded));
|
|
||||||
assert!(paths(&picker).contains(&PathBuf::from("other/keep.txt")));
|
|
||||||
let rows = paths(&picker);
|
|
||||||
let expanded = picker.expanded.clone();
|
|
||||||
picker.clear_filter();
|
|
||||||
assert_eq!(paths(&picker), rows);
|
|
||||||
assert_eq!(picker.expanded, expanded);
|
|
||||||
assert_eq!(selected_path(&picker), expected);
|
|
||||||
assert_eq!(picker.searching, searching);
|
|
||||||
|
|
||||||
picker.query = "absent".into();
|
|
||||||
picker.refilter();
|
|
||||||
picker.navigate(true);
|
|
||||||
picker.navigate(false);
|
|
||||||
assert!(picker.rows.is_empty());
|
|
||||||
picker.refresh();
|
|
||||||
assert!(picker.rows.is_empty());
|
|
||||||
picker.clear_filter();
|
|
||||||
assert_eq!(selected_path(&picker), expected);
|
|
||||||
assert_eq!(picker.searching, searching);
|
|
||||||
assert!(picker.focused);
|
|
||||||
}
|
|
||||||
|
|
||||||
let empty = Directory::new();
|
|
||||||
let mut picker = tree(&empty);
|
|
||||||
picker.start_search();
|
|
||||||
picker.query = "absent".into();
|
|
||||||
picker.refilter();
|
|
||||||
picker.finish_search();
|
|
||||||
picker.clear_filter();
|
|
||||||
assert!(picker.rows.is_empty());
|
|
||||||
assert_eq!(picker.selected, 0);
|
|
||||||
assert!(!picker.searching);
|
|
||||||
assert!(picker.focused);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn filtered_navigation_toggles_context_folders_and_query_edits_reveal_matches() {
|
|
||||||
let directory = Directory::new();
|
|
||||||
directory.put("src/deep/needle.txt", "");
|
|
||||||
directory.put("src/other.txt", "");
|
|
||||||
let mut picker = tree(&directory);
|
|
||||||
picker.start_search();
|
|
||||||
picker.query = "needle".into();
|
|
||||||
picker.refilter();
|
|
||||||
let expanded = picker.expanded.clone();
|
|
||||||
picker.left();
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("src/deep"));
|
|
||||||
picker.right();
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt"));
|
|
||||||
assert_eq!(picker.expanded, expanded);
|
|
||||||
picker.finish_search();
|
|
||||||
picker.left();
|
|
||||||
picker.left();
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("src/deep"));
|
|
||||||
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
|
|
||||||
assert_eq!(picker.query, "needle");
|
|
||||||
assert!(!paths(&picker).contains(&PathBuf::from("src/deep/needle.txt")));
|
|
||||||
picker.start_search();
|
|
||||||
picker.finish_search();
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("src/deep"));
|
|
||||||
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
|
|
||||||
picker.right();
|
|
||||||
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
|
|
||||||
picker.right();
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt"));
|
|
||||||
picker.left();
|
|
||||||
assert!(picker.accept().is_none());
|
|
||||||
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
|
|
||||||
assert!(picker.accept().is_none());
|
|
||||||
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
|
|
||||||
picker.start_search();
|
|
||||||
assert!(picker.accept().is_none());
|
|
||||||
assert!(!picker.searching);
|
|
||||||
assert_eq!(picker.query, "needle");
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("src/deep"));
|
|
||||||
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
|
|
||||||
picker.start_search();
|
|
||||||
picker.query.push_str(".txt");
|
|
||||||
picker.refilter();
|
|
||||||
assert!(picker.filter_collapsed.is_empty());
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt"));
|
|
||||||
picker.query.clear();
|
|
||||||
picker.refilter();
|
|
||||||
picker.finish_search();
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn filtered_collapse_survives_refresh_compaction_and_view_switching_by_raw_path() {
|
|
||||||
let directory = Directory::new();
|
|
||||||
directory.put("one/a.txt", "");
|
|
||||||
directory.put("one/two/needle.txt", "");
|
|
||||||
let mut picker = tree(&directory);
|
|
||||||
picker.start_search();
|
|
||||||
picker.query = "needle".into();
|
|
||||||
picker.refilter();
|
|
||||||
picker.finish_search();
|
|
||||||
picker.left();
|
|
||||||
picker.left();
|
|
||||||
picker.left();
|
|
||||||
picker.left();
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("one"));
|
|
||||||
assert_eq!(paths(&picker), [PathBuf::from("one")]);
|
|
||||||
picker.toggle_view();
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("one/two/needle.txt"));
|
|
||||||
picker.toggle_view();
|
|
||||||
assert_eq!(paths(&picker), [PathBuf::from("one")]);
|
|
||||||
assert!(!picker.searching);
|
|
||||||
fs::remove_file(directory.0.join("one/a.txt")).unwrap();
|
|
||||||
picker.focused = false;
|
|
||||||
picker.refresh();
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("one/two"));
|
|
||||||
assert_eq!(paths(&picker), [PathBuf::from("one/two")]);
|
|
||||||
assert!(!picker.searching && !picker.focused);
|
|
||||||
assert_eq!(picker.query, "needle");
|
|
||||||
directory.put("one/a.txt", "");
|
|
||||||
picker.refresh();
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("one"));
|
|
||||||
assert_eq!(paths(&picker), [PathBuf::from("one")]);
|
|
||||||
picker.right();
|
|
||||||
picker.right();
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("one/two"));
|
|
||||||
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
|
|
||||||
picker.right();
|
|
||||||
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
|
|
||||||
picker.right();
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("one/two/needle.txt"));
|
|
||||||
fs::remove_file(directory.0.join("one/two/needle.txt")).unwrap();
|
|
||||||
picker.refresh();
|
|
||||||
assert!(picker.entries().len() == 0);
|
|
||||||
picker.clear_filter();
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("one/two"));
|
|
||||||
assert!(!picker.searching && !picker.focused);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn refresh_preserves_expansion_when_directory_chains_merge_and_split() {
|
|
||||||
let directory = Directory::new();
|
|
||||||
directory.put("one/a.txt", "");
|
|
||||||
directory.put("one/two/b.txt", "");
|
|
||||||
let mut picker = tree(&directory);
|
|
||||||
picker.right();
|
|
||||||
assert_eq!(
|
|
||||||
paths(&picker),
|
|
||||||
["one", "one/two", "one/a.txt"].map(PathBuf::from)
|
|
||||||
);
|
|
||||||
fs::remove_file(directory.0.join("one/a.txt")).unwrap();
|
|
||||||
picker.refresh();
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("one/two"));
|
|
||||||
assert_eq!(
|
|
||||||
paths(&picker),
|
|
||||||
["one/two", "one/two/b.txt"].map(PathBuf::from)
|
|
||||||
);
|
|
||||||
assert!(picker.entries().next().unwrap().expanded);
|
|
||||||
directory.put("one/a.txt", "");
|
|
||||||
picker.refresh();
|
|
||||||
assert_eq!(selected_path(&picker), Path::new("one/two"));
|
|
||||||
assert_eq!(
|
|
||||||
paths(&picker),
|
|
||||||
["one", "one/two", "one/a.txt"].map(PathBuf::from)
|
|
||||||
);
|
|
||||||
assert!(picker.entries().next().unwrap().expanded);
|
|
||||||
assert!(!picker.entries().nth(1).unwrap().expanded);
|
|
||||||
fs::remove_file(directory.0.join("one/a.txt")).unwrap();
|
|
||||||
picker.refresh();
|
|
||||||
picker.left();
|
|
||||||
assert_eq!(paths(&picker), [PathBuf::from("one/two")]);
|
|
||||||
directory.put("one/a.txt", "");
|
|
||||||
picker.refresh();
|
|
||||||
assert_eq!(paths(&picker), [PathBuf::from("one")]);
|
|
||||||
assert!(!picker.entries().next().unwrap().expanded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-427
@@ -812,430 +812,5 @@ pub(crate) fn resolve(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "../tests/unit/forge.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
fn parse(url: &str, provider: Option<&str>) -> Result<Forge, String> {
|
|
||||||
Forge::parse(url, provider, |destination, _| {
|
|
||||||
Ok(destination.rsplit('@').next().unwrap().to_owned())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pages_are_bounded_newest_first_summaries_without_historical_metadata() {
|
|
||||||
for (provider, url, query) in [
|
|
||||||
(
|
|
||||||
Provider::Github,
|
|
||||||
"https://github.com/owner/repo",
|
|
||||||
"state=open&per_page=50&sort=created&direction=desc&page=3",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
Provider::Gitlab,
|
|
||||||
"https://gitlab.com/owner/repo",
|
|
||||||
"state=opened&scope=all&per_page=50&order_by=created_at&sort=desc&page=3",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
Provider::Gitea,
|
|
||||||
"https://codeberg.org/owner/repo",
|
|
||||||
"state=open&limit=50&page=3",
|
|
||||||
),
|
|
||||||
] {
|
|
||||||
let forge = parse(url, None).unwrap();
|
|
||||||
let mut calls = 0;
|
|
||||||
let page = forge.page_with(false, 3, |url| {
|
|
||||||
calls += 1;
|
|
||||||
assert_eq!(url, format!("{}?{query}", forge.endpoint));
|
|
||||||
Ok(json!([
|
|
||||||
{"number": 1, "iid": 1, "title": "Older", "state": if provider == Provider::Gitlab { "opened" } else { "open" },
|
|
||||||
"user": null, "author": null, "merged_at": null, "merged": false, "created_at": "2024-01-01T01:00:00+01:00"},
|
|
||||||
{"number": 2, "iid": 2, "title": "Newer", "state": if provider == Provider::Gitlab { "opened" } else { "open" },
|
|
||||||
"user": {"login": "author"}, "author": {"username": "author"}, "merged_at": null, "merged": false,
|
|
||||||
"created_at": "2024-01-01T00:00:00.001Z", "diff_refs": null, "head": {"sha": "not-a-commit"}}
|
|
||||||
]))
|
|
||||||
}).unwrap();
|
|
||||||
assert_eq!(calls, 1);
|
|
||||||
assert_eq!(
|
|
||||||
page.entries.iter().map(|p| p.number).collect::<Vec<_>>(),
|
|
||||||
[2, 1]
|
|
||||||
);
|
|
||||||
assert_eq!(page.entries[0].description, "author · open");
|
|
||||||
assert!(page.entries[1].description.contains("deleted user"));
|
|
||||||
assert!(
|
|
||||||
page.has_more,
|
|
||||||
"A short, server-capped page is not exhaustion"
|
|
||||||
);
|
|
||||||
let empty = forge.page_with(false, 4, |_| Ok(json!([]))).unwrap();
|
|
||||||
assert!(empty.entries.is_empty() && !empty.has_more);
|
|
||||||
assert!(
|
|
||||||
forge
|
|
||||||
.page_with(false, 0, |_| panic!("Page zero must not request"))
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn gitlab_closed_combines_closed_and_merged_pages_without_open_requests() {
|
|
||||||
let forge = parse("https://gitlab.com/group/repo", None).unwrap();
|
|
||||||
let mut all = Vec::new();
|
|
||||||
for page in 1..=3 {
|
|
||||||
let mut states = Vec::new();
|
|
||||||
let result = forge
|
|
||||||
.page_with(true, page, |url| {
|
|
||||||
assert!(url.ends_with(&format!("&page={page}")));
|
|
||||||
let state = if url.contains("?state=closed&") {
|
|
||||||
"closed"
|
|
||||||
} else {
|
|
||||||
assert!(url.contains("?state=merged&"));
|
|
||||||
"merged"
|
|
||||||
};
|
|
||||||
states.push(state);
|
|
||||||
let (id, date) = match (state, page) {
|
|
||||||
("closed", 1) => (1, "2024-01-01T00:00:00Z"),
|
|
||||||
("merged", 1) => (3, "2024-03-01T00:00:00Z"),
|
|
||||||
("merged", 2) => (2, "2024-02-01T00:00:00Z"),
|
|
||||||
_ => return Ok(json!([])),
|
|
||||||
};
|
|
||||||
Ok(json!([{"iid": id, "title": state, "state": state,
|
|
||||||
"author": null, "created_at": date, "diff_refs": null}]))
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(states, ["closed", "merged"]);
|
|
||||||
assert_eq!(result.has_more, page != 3);
|
|
||||||
assert!(result.entries.iter().all(|p| p.closed));
|
|
||||||
if page == 1 {
|
|
||||||
assert_eq!(
|
|
||||||
result.entries.iter().map(|p| p.number).collect::<Vec<_>>(),
|
|
||||||
[3, 1]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// The second merged page still precedes the first closed page.
|
|
||||||
all.extend(result.entries);
|
|
||||||
}
|
|
||||||
all.sort_unstable_by(|a, b| b.created_at.cmp(&a.created_at));
|
|
||||||
assert_eq!(all.iter().map(|p| p.number).collect::<Vec<_>>(), [3, 2, 1]);
|
|
||||||
let raced = forge.page_with(true, 1, |url| {
|
|
||||||
Ok(json!([{"iid": 7, "title": "Raced merge", "state": if url.contains("?state=merged&") { "merged" } else { "closed" },
|
|
||||||
"author": null, "created_at": "2024-01-01T00:00:00Z"}]))
|
|
||||||
}).unwrap();
|
|
||||||
assert_eq!(raced.entries.len(), 1);
|
|
||||||
assert!(raced.entries[0].description.contains("merged"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn timestamps_normalize_offsets_precision_and_calendar_boundaries() {
|
|
||||||
for (input, expected) in [
|
|
||||||
(
|
|
||||||
"2024-03-01T00:30:12.5+01:00",
|
|
||||||
"2024-02-29T23:30:12.500000000Z",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"2023-12-31T23:30:00-01:00",
|
|
||||||
"2024-01-01T00:30:00.000000000Z",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"2024-01-01T00:00:00.000000001Z",
|
|
||||||
"2024-01-01T00:00:00.000000001Z",
|
|
||||||
),
|
|
||||||
] {
|
|
||||||
assert_eq!(created_at(&json!({"created_at": input})).unwrap(), expected);
|
|
||||||
}
|
|
||||||
for input in [
|
|
||||||
"",
|
|
||||||
"not a date",
|
|
||||||
"2023-02-29T00:00:00Z",
|
|
||||||
"2024-13-01T00:00:00Z",
|
|
||||||
"2024-01-01T24:00:00Z",
|
|
||||||
"2024-01-01T00:00:00",
|
|
||||||
"2024-01-01T00:00:00+24:00",
|
|
||||||
"2024-01-01T00:00:00.Z",
|
|
||||||
"2024-01-01T00:00:00.1234567890Z",
|
|
||||||
"0001-01-01T00:00:00+01:00",
|
|
||||||
"9999-12-31T23:00:00-02:00",
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
created_at(&json!({"created_at": input})).is_err(),
|
|
||||||
"{input}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolution_reads_only_selected_detail_and_exact_preserved_head() {
|
|
||||||
for (url, provider) in [
|
|
||||||
("https://github.com/owner/repo", Provider::Github),
|
|
||||||
("https://gitlab.com/group/repo", Provider::Gitlab),
|
|
||||||
("https://codeberg.org/owner/repo", Provider::Gitea),
|
|
||||||
] {
|
|
||||||
let forge = parse(url, None).unwrap();
|
|
||||||
let mut calls = Vec::new();
|
|
||||||
let pr = forge.resolve_with(7, |url| {
|
|
||||||
calls.push(url.to_owned());
|
|
||||||
if url == format!("{}/7", forge.endpoint) {
|
|
||||||
Ok(json!({"number": 7, "iid": 7, "title": "Selected", "state": if provider == Provider::Gitlab { "merged" } else { "closed" },
|
|
||||||
"merged_at": "2024-01-01T00:00:00Z", "merged": true, "user": null, "author": null,
|
|
||||||
"base": {"ref": "main", "sha": "1".repeat(40)}, "head": {"ref": "topic", "sha": "2".repeat(40)},
|
|
||||||
"merge_base": "3".repeat(40), "target_branch": "main", "source_branch": "topic",
|
|
||||||
"diff_refs": {"start_sha": null, "base_sha": "4".repeat(40), "head_sha": "5".repeat(40)},
|
|
||||||
"url": "https://evil.example/detail", "references": "https://evil.example/refs"}))
|
|
||||||
} else {
|
|
||||||
assert_eq!(url, format!("{}/git/refs/pull/7/head", forge.endpoint.strip_suffix("/pulls").unwrap()));
|
|
||||||
Ok(json!([{"ref": "refs/pull/7/head", "object": {"type": "commit", "sha": "6".repeat(40)}}]))
|
|
||||||
}
|
|
||||||
}).unwrap();
|
|
||||||
assert_eq!(calls.len(), if provider == Provider::Gitea { 2 } else { 1 });
|
|
||||||
let revisions = pr.revisions.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
revisions.base_sha,
|
|
||||||
match provider {
|
|
||||||
Provider::Github => "1",
|
|
||||||
Provider::Gitlab => "4",
|
|
||||||
Provider::Gitea => "3",
|
|
||||||
}
|
|
||||||
.repeat(40)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
revisions.head_sha,
|
|
||||||
match provider {
|
|
||||||
Provider::Github => "2",
|
|
||||||
Provider::Gitlab => "5",
|
|
||||||
Provider::Gitea => "6",
|
|
||||||
}
|
|
||||||
.repeat(40)
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
forge
|
|
||||||
.resolve_with(0, |_| panic!("Zero must not request"))
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
let mut calls = 0;
|
|
||||||
assert!(
|
|
||||||
forge
|
|
||||||
.resolve_with(7, |_| {
|
|
||||||
calls += 1;
|
|
||||||
Ok(json!({"number": 8, "iid": 8}))
|
|
||||||
})
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
calls, 1,
|
|
||||||
"Reject wrong detail identity before reading historical refs"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn gitea_preserved_heads_reject_missing_ambiguous_or_foreign_refs() {
|
|
||||||
let reference =
|
|
||||||
json!({"ref": "refs/pull/7/head", "object": {"type": "commit", "sha": "a".repeat(40)}});
|
|
||||||
assert_eq!(gitea_head(&reference, 7).unwrap(), "a".repeat(40));
|
|
||||||
assert_eq!(
|
|
||||||
gitea_head(&json!([reference.clone()]), 7).unwrap(),
|
|
||||||
"a".repeat(40)
|
|
||||||
);
|
|
||||||
for bad in [
|
|
||||||
json!([]),
|
|
||||||
json!([reference.clone(), reference.clone()]),
|
|
||||||
json!({"ref": "refs/pull/8/head", "object": reference["object"]}),
|
|
||||||
json!({"ref": "refs/pull/7/head", "object": {"type": "tag", "sha": "a".repeat(40)}}),
|
|
||||||
json!({"ref": "refs/pull/7/head", "object": {"type": "commit", "sha": "0".repeat(40)}}),
|
|
||||||
] {
|
|
||||||
assert!(gitea_head(&bad, 7).is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ssh_aliases_use_effective_hostname_and_preserve_user_matching() {
|
|
||||||
use std::{fs, io::Write, path::PathBuf};
|
|
||||||
struct Config(PathBuf);
|
|
||||||
impl Drop for Config {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = fs::remove_file(&self.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let path = std::env::temp_dir().join(format!("vamp-forge-ssh-{}.conf", std::process::id()));
|
|
||||||
let mut file = fs::OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.create_new(true)
|
|
||||||
.open(&path)
|
|
||||||
.unwrap();
|
|
||||||
let config = Config(path);
|
|
||||||
file.write_all(b"Host gitea-personal\n HostName gitea.example.org\n Port 2222\nHost github-personal\n HostName ssh.github.com\nHost user-match\n Port 2222\nMatch originalhost user-match user git\n HostName gitlab.example.org\n").unwrap();
|
|
||||||
for (url, expected) in [
|
|
||||||
(
|
|
||||||
"git@gitea-personal:owner/repo.git",
|
|
||||||
"https://gitea.example.org/api/v1/repos/owner/repo/pulls",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"ssh://git@gitea-personal:2200/owner/repo.git",
|
|
||||||
"https://gitea.example.org/api/v1/repos/owner/repo/pulls",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"git@github-personal:owner/repo.git",
|
|
||||||
"https://api.github.com/repos/owner/repo/pulls",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"git@user-match:group/sub/repo.git",
|
|
||||||
"https://gitlab.example.org/api/v4/projects/group%2Fsub%2Frepo/merge_requests",
|
|
||||||
),
|
|
||||||
] {
|
|
||||||
let forge = Forge::parse(url, None, |destination, port| {
|
|
||||||
let mut command = Command::new("ssh");
|
|
||||||
command.arg("-F").arg(&config.0);
|
|
||||||
ssh_hostname(command, destination, port, &AtomicBool::new(false))
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(forge.endpoint, expected);
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
Forge::parse("git@alias:owner/repo", Some("gitea"), |_, _| Ok(
|
|
||||||
"evil.example/path".into()
|
|
||||||
))
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
Forge::parse(
|
|
||||||
"ssh://user:secret@alias/owner/repo",
|
|
||||||
Some("gitea"),
|
|
||||||
|_, _| { panic!("Password-bearing SSH URLs must be rejected before starting SSH") }
|
|
||||||
)
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
Forge::parse("https://github.com/owner/repo", None, |_, _| panic!(
|
|
||||||
"HTTPS must not invoke SSH"
|
|
||||||
))
|
|
||||||
.unwrap()
|
|
||||||
.host,
|
|
||||||
"github.com"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remote_urls_preserve_namespaces_and_keep_ssh_ports_out_of_https() {
|
|
||||||
let gitlab = parse("ssh://git@gitlab.example.org:2222/group/sub/repo.git", None).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
gitlab.endpoint,
|
|
||||||
"https://gitlab.example.org/api/v4/projects/group%2Fsub%2Frepo/merge_requests"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse("git@github.com:owner/repo.git", None)
|
|
||||||
.unwrap()
|
|
||||||
.endpoint,
|
|
||||||
"https://api.github.com/repos/owner/repo/pulls"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse(
|
|
||||||
"https://user:secret@forge.example:8443/owner/repo.git",
|
|
||||||
Some("gitea")
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
.endpoint,
|
|
||||||
"https://forge.example:8443/api/v1/repos/owner/repo/pulls"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
parse("https://forge.example/owner/repo", None)
|
|
||||||
.err()
|
|
||||||
.unwrap()
|
|
||||||
.contains("git config remote.<name>.vamp-forge github")
|
|
||||||
);
|
|
||||||
for url in [
|
|
||||||
"https://github.com/owner/repo?token=secret",
|
|
||||||
"https://github.com/owner/%2e%2e",
|
|
||||||
"https://github.com/owner/repo%0a",
|
|
||||||
"https://github.com:0/owner/repo",
|
|
||||||
"file:///owner/repo",
|
|
||||||
] {
|
|
||||||
assert!(parse(url, Some("github")).is_err(), "{url}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fork_pull_requests_pin_historical_shas_not_merge_commits_or_response_urls() {
|
|
||||||
let value = json!({"number": 7, "title": "Merged fork", "state": "closed", "merged_at": "2026-01-01T00:00:00Z", "merged": true,
|
|
||||||
"user": {"login": "author"}, "base": {"ref": "release/next", "sha": "1".repeat(40)},
|
|
||||||
"head": {"ref": "feature", "label": "fork:topic", "sha": "2".repeat(40), "repo": null}, "merge_base": "4".repeat(40), "merge_commit_sha": "3".repeat(40), "url": "https://evil.example/token"});
|
|
||||||
for provider in [Provider::Github, Provider::Gitea] {
|
|
||||||
let pr = pull_request(provider, &value, Some("5".repeat(40))).unwrap();
|
|
||||||
let revisions = pr.revisions.as_ref().unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
(revisions.base_ref.as_str(), revisions.head_ref.as_str()),
|
|
||||||
("refs/heads/release/next", "refs/pull/7/head")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
revisions.base_sha,
|
|
||||||
if provider == Provider::Gitea {
|
|
||||||
"4"
|
|
||||||
} else {
|
|
||||||
"1"
|
|
||||||
}
|
|
||||||
.repeat(40)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
revisions.head_sha,
|
|
||||||
if provider == Provider::Gitea {
|
|
||||||
"5"
|
|
||||||
} else {
|
|
||||||
"2"
|
|
||||||
}
|
|
||||||
.repeat(40)
|
|
||||||
);
|
|
||||||
if provider == Provider::Gitea {
|
|
||||||
let mut deleted = value.clone();
|
|
||||||
deleted["head"]["sha"] = Value::String(String::new());
|
|
||||||
assert_eq!(
|
|
||||||
pull_request(provider, &deleted, Some("5".repeat(40)))
|
|
||||||
.unwrap()
|
|
||||||
.revisions
|
|
||||||
.unwrap()
|
|
||||||
.head_sha,
|
|
||||||
"5".repeat(40)
|
|
||||||
);
|
|
||||||
let unavailable = pull_request(provider, &deleted, None).unwrap();
|
|
||||||
assert!(unavailable.revisions.is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn gitlab_uses_target_start_sha_and_rejects_unprepared_or_unsafe_metadata() {
|
|
||||||
let mut value = json!({"iid": 9, "title": "MR", "state": "merged", "author": {"username": "author"},
|
|
||||||
"target_branch": "main", "source_branch": "topic", "diff_refs": {"base_sha": "1".repeat(40), "start_sha": "2".repeat(40), "head_sha": "3".repeat(40)}});
|
|
||||||
let pr = pull_request(Provider::Gitlab, &value, None).unwrap();
|
|
||||||
let revisions = pr.revisions.unwrap();
|
|
||||||
assert_eq!(revisions.base_sha, "2".repeat(40));
|
|
||||||
assert_eq!(revisions.head_ref, "refs/merge-requests/9/head");
|
|
||||||
value["diff_refs"]["start_sha"] = Value::Null;
|
|
||||||
assert_eq!(
|
|
||||||
pull_request(Provider::Gitlab, &value, None)
|
|
||||||
.unwrap()
|
|
||||||
.revisions
|
|
||||||
.unwrap()
|
|
||||||
.base_sha,
|
|
||||||
"1".repeat(40)
|
|
||||||
);
|
|
||||||
value["target_branch"] = json!("main:refs/heads/injected");
|
|
||||||
assert!(
|
|
||||||
pull_request(Provider::Gitlab, &value, None)
|
|
||||||
.unwrap()
|
|
||||||
.revisions
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
value["target_branch"] = json!("main");
|
|
||||||
value["diff_refs"]["head_sha"] = json!("HEAD");
|
|
||||||
assert!(
|
|
||||||
pull_request(Provider::Gitlab, &value, None)
|
|
||||||
.unwrap()
|
|
||||||
.revisions
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
value["diff_refs"] = Value::Null;
|
|
||||||
assert!(
|
|
||||||
pull_request(Provider::Gitlab, &value, None)
|
|
||||||
.unwrap()
|
|
||||||
.revisions
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-427
@@ -456,430 +456,5 @@ fn changes(before: &str, after: &str) -> Vec<Option<Change>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(test, unix))]
|
#[cfg(all(test, unix))]
|
||||||
mod tests {
|
#[path = "../tests/unit/gutter.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
use std::ffi::OsString;
|
|
||||||
|
|
||||||
static NEXT_TEST: AtomicU64 = AtomicU64::new(0);
|
|
||||||
struct Temporary(PathBuf);
|
|
||||||
impl Temporary {
|
|
||||||
fn new() -> Self {
|
|
||||||
let path = std::env::temp_dir().join(format!(
|
|
||||||
"vamp-gutter-test-{}-{}",
|
|
||||||
std::process::id(),
|
|
||||||
NEXT_TEST.fetch_add(1, Ordering::Relaxed)
|
|
||||||
));
|
|
||||||
fs::create_dir(&path).unwrap();
|
|
||||||
Self(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl Drop for Temporary {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = fs::remove_dir_all(&self.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn command(root: &Path, program: &str, args: &[&str]) -> String {
|
|
||||||
let mut command = Command::new(program);
|
|
||||||
if program == "jj" {
|
|
||||||
command.args([
|
|
||||||
"--config=user.name=\"Gutter Author\"",
|
|
||||||
"--config=user.email=\"gutter@example.test\"",
|
|
||||||
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
|
|
||||||
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
let output = command
|
|
||||||
.args(args)
|
|
||||||
.current_dir(root)
|
|
||||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
|
||||||
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
|
||||||
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
|
|
||||||
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
|
|
||||||
.env("JJ_CONFIG", "/dev/null")
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
output.status.success(),
|
|
||||||
"{}",
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
String::from_utf8(output.stdout).unwrap().trim().into()
|
|
||||||
}
|
|
||||||
fn git_fixture() -> (Temporary, PathBuf) {
|
|
||||||
let temp = Temporary::new();
|
|
||||||
command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
|
||||||
command(&temp.0, "git", &["config", "user.name", "Gutter Author"]);
|
|
||||||
command(
|
|
||||||
&temp.0,
|
|
||||||
"git",
|
|
||||||
&["config", "user.email", "gutter@example.test"],
|
|
||||||
);
|
|
||||||
let path = temp.0.join("-literal [file]\n\t\".txt");
|
|
||||||
fs::write(&path, "first\nsecond\nthird\n").unwrap();
|
|
||||||
command(
|
|
||||||
&temp.0,
|
|
||||||
"git",
|
|
||||||
&["add", "--", path.file_name().unwrap().to_str().unwrap()],
|
|
||||||
);
|
|
||||||
command(&temp.0, "git", &["commit", "--quiet", "-m", "Baseline"]);
|
|
||||||
(temp, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn maps_replacements_surplus_deletions_empty_and_eof_rows() {
|
|
||||||
use Change::*;
|
|
||||||
for (before, after, expected) in [
|
|
||||||
("first\nsecond\n", "first\nsecond\n", vec![None, None, None]),
|
|
||||||
(
|
|
||||||
"first\r\nsecond\r\n",
|
|
||||||
"first\nsecond\n",
|
|
||||||
vec![None, None, None],
|
|
||||||
),
|
|
||||||
("first\rsecond\r", "first\nsecond\n", vec![None, None, None]),
|
|
||||||
(
|
|
||||||
"first\r\nsecond\r\nthird\r\n",
|
|
||||||
"first\nchanged\nthird\n",
|
|
||||||
vec![None, Some(Modified), None, None],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"first\nsecond\nthird\n",
|
|
||||||
"first\rchanged\rthird\r",
|
|
||||||
vec![None, Some(Modified), None, None],
|
|
||||||
),
|
|
||||||
("first\n", "first\nnew\n", vec![None, Some(Added), None]),
|
|
||||||
(
|
|
||||||
"old\n",
|
|
||||||
"changed\nsurplus\n",
|
|
||||||
vec![Some(Modified), Some(Added), None],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"old\nextra\nlast\n",
|
|
||||||
"changed\nlast\n",
|
|
||||||
vec![Some(Modified), None, None],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"first\nremoved\nlast\n",
|
|
||||||
"first\nlast\n",
|
|
||||||
vec![None, Some(Removed), None],
|
|
||||||
),
|
|
||||||
("first\nremoved\n", "first\n", vec![None, Some(Removed)]),
|
|
||||||
("first\nremoved", "first", vec![Some(Modified)]),
|
|
||||||
("first\nremoved", "first\n", vec![None, Some(Removed)]),
|
|
||||||
("removed\n", "", vec![Some(Removed)]),
|
|
||||||
("", "new\n", vec![Some(Added), None]),
|
|
||||||
("", "", vec![None]),
|
|
||||||
("same", "same\n", vec![Some(Modified), None]),
|
|
||||||
] {
|
|
||||||
assert_eq!(changes(before, after), expected, "{before:?} -> {after:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn git_crlf_baseline_matches_normalized_editor_rows() {
|
|
||||||
let (temp, _) = git_fixture();
|
|
||||||
let path = temp.0.join("crlf");
|
|
||||||
fs::write(&path, "first\r\nsecond\r\nthird\r\n").unwrap();
|
|
||||||
command(&temp.0, "git", &["add", "--", "crlf"]);
|
|
||||||
command(
|
|
||||||
&temp.0,
|
|
||||||
"git",
|
|
||||||
&["commit", "--quiet", "-m", "CRLF baseline"],
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
read_file(&path, "first\nsecond\nthird\n", &|| false).unwrap(),
|
|
||||||
vec![None, None, None, None]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
read_file(&path, "first\nchanged\nthird\n", &|| false).unwrap(),
|
|
||||||
vec![None, Some(Change::Modified), None, None]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn git_head_includes_staged_disk_and_unsaved_edits_without_writes() {
|
|
||||||
let (temp, path) = git_fixture();
|
|
||||||
fs::write(&path, "staged\nsecond\nthird\n").unwrap();
|
|
||||||
command(
|
|
||||||
&temp.0,
|
|
||||||
"git",
|
|
||||||
&["add", "--", path.file_name().unwrap().to_str().unwrap()],
|
|
||||||
);
|
|
||||||
fs::write(&path, "staged\ndisk\nthird\n").unwrap();
|
|
||||||
let index = fs::read(temp.0.join(".git/index")).unwrap();
|
|
||||||
let head = command(&temp.0, "git", &["rev-parse", "HEAD"]);
|
|
||||||
assert_eq!(
|
|
||||||
read_file(&path, "staged\ndisk\nunsaved\n", &|| false).unwrap(),
|
|
||||||
vec![
|
|
||||||
Some(Change::Modified),
|
|
||||||
Some(Change::Modified),
|
|
||||||
Some(Change::Modified),
|
|
||||||
None
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert_eq!(fs::read(temp.0.join(".git/index")).unwrap(), index);
|
|
||||||
assert_eq!(command(&temp.0, "git", &["rev-parse", "HEAD"]), head);
|
|
||||||
assert_eq!(fs::read_to_string(&path).unwrap(), "staged\ndisk\nthird\n");
|
|
||||||
fs::remove_file(&path).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
read_file(&path, "first\nsecond\nthird\n", &|| false).unwrap(),
|
|
||||||
vec![None, None, None, None]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
read_file(&temp.0.join("new/named"), "unsaved\n", &|| false).unwrap(),
|
|
||||||
vec![Some(Change::Added), None]
|
|
||||||
);
|
|
||||||
let untracked = temp.0.join("untracked");
|
|
||||||
fs::write(&untracked, "unsaved\n").unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
read_file(&untracked, "unsaved\n", &|| false).unwrap(),
|
|
||||||
vec![Some(Change::Added), None]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unborn_untracked_missing_ignored_metadata_binary_and_symlink_paths() {
|
|
||||||
let temp = Temporary::new();
|
|
||||||
let path = temp.0.join("new/named");
|
|
||||||
assert!(read_file(&path, "unsaved\n", &|| false).unwrap().is_empty());
|
|
||||||
command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
|
||||||
assert_eq!(
|
|
||||||
read_file(&path, "unsaved\n", &|| false).unwrap(),
|
|
||||||
vec![Some(Change::Added), None]
|
|
||||||
);
|
|
||||||
let disk = temp.0.join("untracked");
|
|
||||||
fs::write(&disk, "disk\n").unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
read_file(&disk, "unsaved\n", &|| false).unwrap(),
|
|
||||||
vec![Some(Change::Added), None]
|
|
||||||
);
|
|
||||||
fs::write(temp.0.join(".gitignore"), "new/\nuntracked\n").unwrap();
|
|
||||||
assert!(read_file(&path, "unsaved\n", &|| false).unwrap().is_empty());
|
|
||||||
assert!(read_file(&disk, "unsaved\n", &|| false).unwrap().is_empty());
|
|
||||||
assert!(
|
|
||||||
read_file(&temp.0.join(".git/config"), "text\n", &|| false)
|
|
||||||
.unwrap()
|
|
||||||
.is_empty()
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
read_file(&disk, "binary\0text", &|| false)
|
|
||||||
.unwrap()
|
|
||||||
.is_empty()
|
|
||||||
);
|
|
||||||
std::os::unix::fs::symlink(&disk, temp.0.join("link")).unwrap();
|
|
||||||
assert!(
|
|
||||||
read_file(&temp.0.join("link"), "text\n", &|| false)
|
|
||||||
.unwrap()
|
|
||||||
.is_empty()
|
|
||||||
);
|
|
||||||
std::os::unix::fs::symlink(&temp.0, temp.0.join("linked")).unwrap();
|
|
||||||
assert!(
|
|
||||||
read_file(&temp.0.join("linked/untracked"), "text\n", &|| false)
|
|
||||||
.unwrap()
|
|
||||||
.is_empty()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn git_non_utf8_filename_is_literal() {
|
|
||||||
use std::os::unix::ffi::OsStringExt;
|
|
||||||
let (temp, _) = git_fixture();
|
|
||||||
let name = OsString::from_vec(b"non-utf8-\xff".to_vec());
|
|
||||||
let path = temp.0.join(&name);
|
|
||||||
match fs::write(&path, "old\n") {
|
|
||||||
Ok(()) => {}
|
|
||||||
// APFS rejects non-UTF-8 names before repository code can read them.
|
|
||||||
Err(error) if cfg!(target_os = "macos") && error.raw_os_error() == Some(92) => return,
|
|
||||||
Err(error) => panic!("Cannot create non-UTF-8 fixture: {error}"),
|
|
||||||
}
|
|
||||||
let mut add = git(&temp.0);
|
|
||||||
add.args(["add", "--"]).arg(&name);
|
|
||||||
checked(add, &|| false).unwrap();
|
|
||||||
command(&temp.0, "git", &["commit", "--quiet", "-m", "Byte path"]);
|
|
||||||
assert_eq!(
|
|
||||||
read_file(&path, "changed\n", &|| false).unwrap(),
|
|
||||||
vec![Some(Change::Modified), None]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn jj_parent_root_colocation_ignore_and_merge_queries_are_read_only() {
|
|
||||||
for colocate in ["--colocate", "--no-colocate"] {
|
|
||||||
let temp = Temporary::new();
|
|
||||||
command(&temp.0, "jj", &["git", "init", colocate, "."]);
|
|
||||||
let name = "-literal [file]\n\t\".txt";
|
|
||||||
let path = temp.0.join(name);
|
|
||||||
fs::write(&path, "original\n").unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
read_file(&path, "unsaved\n", &|| false).unwrap(),
|
|
||||||
vec![Some(Change::Added), None]
|
|
||||||
);
|
|
||||||
fs::write(temp.0.join(".gitignore"), "ignored\n").unwrap();
|
|
||||||
assert!(
|
|
||||||
read_file(&temp.0.join("ignored"), "text\n", &|| false)
|
|
||||||
.unwrap()
|
|
||||||
.is_empty()
|
|
||||||
);
|
|
||||||
command(&temp.0, "jj", &["describe", "-m", "Baseline"]);
|
|
||||||
command(&temp.0, "jj", &["new"]);
|
|
||||||
let operation = command(
|
|
||||||
&temp.0,
|
|
||||||
"jj",
|
|
||||||
&[
|
|
||||||
"--ignore-working-copy",
|
|
||||||
"op",
|
|
||||||
"log",
|
|
||||||
"--no-graph",
|
|
||||||
"-n",
|
|
||||||
"1",
|
|
||||||
"-T",
|
|
||||||
"id",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
let store = store_for_jj(&temp.0).unwrap();
|
|
||||||
let refs = fs::read(store.join("HEAD")).unwrap();
|
|
||||||
fs::write(&path, "disk edit\n").unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
read_file(&path, "inserted\noriginal\n", &|| false).unwrap(),
|
|
||||||
vec![Some(Change::Added), None, None]
|
|
||||||
);
|
|
||||||
assert_eq!(fs::read(store.join("HEAD")).unwrap(), refs);
|
|
||||||
assert_eq!(
|
|
||||||
command(
|
|
||||||
&temp.0,
|
|
||||||
"jj",
|
|
||||||
&[
|
|
||||||
"--ignore-working-copy",
|
|
||||||
"op",
|
|
||||||
"log",
|
|
||||||
"--no-graph",
|
|
||||||
"-n",
|
|
||||||
"1",
|
|
||||||
"-T",
|
|
||||||
"id",
|
|
||||||
]
|
|
||||||
),
|
|
||||||
operation
|
|
||||||
);
|
|
||||||
let parent = command(
|
|
||||||
&temp.0,
|
|
||||||
"jj",
|
|
||||||
&[
|
|
||||||
"--ignore-working-copy",
|
|
||||||
"log",
|
|
||||||
"--no-graph",
|
|
||||||
"-r",
|
|
||||||
"@-",
|
|
||||||
"-T",
|
|
||||||
"commit_id",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
command(&temp.0, "jj", &["new", "root()"]);
|
|
||||||
fs::write(temp.0.join("sibling"), "sibling\n").unwrap();
|
|
||||||
command(&temp.0, "jj", &["describe", "-m", "Sibling"]);
|
|
||||||
command(&temp.0, "jj", &["new", &parent, "@"]);
|
|
||||||
assert!(
|
|
||||||
read_file(&path, "original\n", &|| false)
|
|
||||||
.unwrap()
|
|
||||||
.is_empty()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn worker_retains_only_latest_batch_and_file_errors_keep_good_siblings() {
|
|
||||||
let (_temp, path) = git_fixture();
|
|
||||||
let mut worker = Worker::start().unwrap();
|
|
||||||
let oversized: Arc<str> = "x".repeat(FILE_LIMIT + 1).into();
|
|
||||||
for id in 0..20 {
|
|
||||||
worker
|
|
||||||
.request(
|
|
||||||
id,
|
|
||||||
vec![
|
|
||||||
(5, path.clone(), "first\nchanged\nthird\n".into()),
|
|
||||||
(8, path.clone(), Arc::clone(&oversized)),
|
|
||||||
],
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
loop {
|
|
||||||
if let Some((id, result, picker)) = worker.poll() {
|
|
||||||
assert!(picker.is_none());
|
|
||||||
assert_eq!(id, 19);
|
|
||||||
assert_eq!(result[0].0, 5);
|
|
||||||
assert_eq!(
|
|
||||||
result[0].1.as_ref().unwrap(),
|
|
||||||
&vec![None, Some(Change::Modified), None, None]
|
|
||||||
);
|
|
||||||
assert_eq!(result[1].0, 8);
|
|
||||||
assert_eq!(
|
|
||||||
result[1].1.as_ref().unwrap_err(),
|
|
||||||
"Gutter file exceeds 16 MiB"
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
thread::yield_now();
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
worker
|
|
||||||
.request(
|
|
||||||
20,
|
|
||||||
vec![(0, path, "x".repeat(BATCH_LIMIT + 1).into())],
|
|
||||||
None
|
|
||||||
)
|
|
||||||
.unwrap_err()
|
|
||||||
.kind(),
|
|
||||||
io::ErrorKind::InvalidInput
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn picker_status_is_optional_and_errors_do_not_discard_line_results() {
|
|
||||||
let (temp, path) = git_fixture();
|
|
||||||
let mut worker = Worker::start().unwrap();
|
|
||||||
worker
|
|
||||||
.request(
|
|
||||||
1,
|
|
||||||
vec![],
|
|
||||||
Some((
|
|
||||||
temp.0.clone(),
|
|
||||||
vec![(path.clone(), "dirty buffer\n".into())],
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
loop {
|
|
||||||
if let Some((id, lines, picker)) = worker.poll() {
|
|
||||||
assert_eq!(id, 1);
|
|
||||||
assert!(lines.is_empty());
|
|
||||||
let picker = picker.unwrap();
|
|
||||||
assert_eq!(picker.root, temp.0);
|
|
||||||
let changes = picker.result.unwrap();
|
|
||||||
assert_eq!(changes.len(), 1);
|
|
||||||
assert_eq!(changes[0].path, path.strip_prefix(&temp.0).unwrap());
|
|
||||||
assert_eq!(changes[0].status, crate::diff::FileStatus::Modified);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
thread::yield_now();
|
|
||||||
}
|
|
||||||
worker
|
|
||||||
.request(
|
|
||||||
2,
|
|
||||||
vec![(4, path.clone(), "first\nchanged\nthird\n".into())],
|
|
||||||
Some((
|
|
||||||
temp.0.clone(),
|
|
||||||
vec![(path, "x".repeat(FILE_LIMIT + 1).into())],
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
loop {
|
|
||||||
if let Some((id, lines, picker)) = worker.poll() {
|
|
||||||
assert_eq!(id, 2);
|
|
||||||
assert_eq!(lines[0].0, 4);
|
|
||||||
assert!(lines[0].1.is_ok());
|
|
||||||
assert!(picker.unwrap().result.unwrap_err().contains("16 MiB"));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
thread::yield_now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-246
@@ -2993,249 +2993,5 @@ fn uri_path(uri: &str) -> Result<PathBuf, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "../tests/unit/lsp.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn native_definitions_preserve_snapshot_identity_and_utf16_until_navigation() {
|
|
||||||
let source = Source {
|
|
||||||
buffer_id: 7,
|
|
||||||
revision: 3,
|
|
||||||
row: 0,
|
|
||||||
column: 0,
|
|
||||||
request_id: 11,
|
|
||||||
};
|
|
||||||
let path = Path::new("/vamp-immutable-snapshot/Before/renamed/λ.rs");
|
|
||||||
let span = json!({
|
|
||||||
"start": {"line": 4, "character": 5},
|
|
||||||
"end": {"line": 4, "character": 8}
|
|
||||||
});
|
|
||||||
for location in [
|
|
||||||
json!({"uri": file_uri(path).unwrap(), "range": span}),
|
|
||||||
json!({"targetUri": file_uri(path).unwrap(),
|
|
||||||
"targetRange": span, "targetSelectionRange": span}),
|
|
||||||
] {
|
|
||||||
let effects = Runtime::definition_effects(&source, &location).unwrap();
|
|
||||||
let expected_target_range = location.get("targetUri").map(|_| Utf16Range {
|
|
||||||
start: (4, 5),
|
|
||||||
end: (4, 8),
|
|
||||||
});
|
|
||||||
assert!(matches!(
|
|
||||||
effects.as_slice(),
|
|
||||||
[UpdateEffect::NavigateUtf16 {
|
|
||||||
buffer_id: 7, revision: 3, path: target,
|
|
||||||
range: Utf16Range { start: (4, 5), end: (4, 8) }, target_range,
|
|
||||||
}] if target == path && *target_range == expected_target_range
|
|
||||||
));
|
|
||||||
}
|
|
||||||
for invalid in [
|
|
||||||
json!({"start": {"line": 4, "character": 8},
|
|
||||||
"end": {"line": 4, "character": 5}}),
|
|
||||||
json!({"start": {"line": -1, "character": 0},
|
|
||||||
"end": {"line": 0, "character": 0}}),
|
|
||||||
] {
|
|
||||||
for location in [
|
|
||||||
json!({"uri": file_uri(path).unwrap(), "range": invalid}),
|
|
||||||
json!({"targetUri": file_uri(path).unwrap(),
|
|
||||||
"targetRange": invalid, "targetSelectionRange": span}),
|
|
||||||
json!({"targetUri": file_uri(path).unwrap(),
|
|
||||||
"targetRange": span, "targetSelectionRange": invalid}),
|
|
||||||
] {
|
|
||||||
assert!(Runtime::definition_effects(&source, &location).is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
serde_json::from_value::<UpdateEffect>(json!({
|
|
||||||
"type": "navigate_utf16", "buffer_id": 7, "revision": 3,
|
|
||||||
"path": path, "range": {"start": [4,5], "end": [4,8]},
|
|
||||||
"target_range": null
|
|
||||||
}))
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn semantic_decoder_validates_raw_coordinates_before_grapheme_rounding() {
|
|
||||||
let options = semantic_options(Some(&json!({
|
|
||||||
"full": true,
|
|
||||||
"legend": {"tokenTypes": ["custom", "function"],
|
|
||||||
"tokenModifiers": ["custom", "readonly", "declaration", "async"]}
|
|
||||||
})))
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
let tokens = decode_semantic_tokens(
|
|
||||||
"A😀e\u{301}Z\r\n\r\nx",
|
|
||||||
&json!({"data": [
|
|
||||||
0, 1, 2, 1, 2,
|
|
||||||
0, 2, 1, 1, 12,
|
|
||||||
0, 1, 1, 1, 0,
|
|
||||||
0, 1, 999, 0, 0,
|
|
||||||
1, 0, 1, 1, 0,
|
|
||||||
1, 0, 0, 1, 0,
|
|
||||||
0, 0, 100, 1, 0
|
|
||||||
]}),
|
|
||||||
&options,
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(tokens.len(), 4);
|
|
||||||
assert_eq!(tokens[0].start, TextPosition { row: 0, column: 1 });
|
|
||||||
assert_eq!(tokens[0].end, TextPosition { row: 0, column: 2 });
|
|
||||||
assert_eq!(tokens[0].modifiers, SemanticTokenModifier::Readonly as u16);
|
|
||||||
assert_eq!(tokens[1].start, TextPosition { row: 0, column: 2 });
|
|
||||||
assert_eq!(tokens[1].end, TextPosition { row: 0, column: 3 });
|
|
||||||
assert_eq!(
|
|
||||||
tokens[1].modifiers,
|
|
||||||
SemanticTokenModifier::Declaration as u16 | SemanticTokenModifier::Async as u16
|
|
||||||
);
|
|
||||||
assert_eq!(tokens[2].start, tokens[1].start);
|
|
||||||
assert_eq!(tokens[3].end, TextPosition { row: 2, column: 1 });
|
|
||||||
for data in [
|
|
||||||
json!([0, 0, 1, 1]),
|
|
||||||
json!([-1, 0, 1, 1, 0]),
|
|
||||||
json!([0, 0.5, 1, 1, 0]),
|
|
||||||
json!([0, 0, 1, 1, 2147483648u64]),
|
|
||||||
json!([0, 0, 1, 65536, 0]),
|
|
||||||
json!([0, 0, 1, 2, 0]),
|
|
||||||
json!([0, 0, 1, 1, 16]),
|
|
||||||
json!([0, 2, 1, 0, 0]),
|
|
||||||
json!([0, 1, 1, 1, 0]),
|
|
||||||
json!([0, 7, 1, 1, 0]),
|
|
||||||
json!([2, 0, 1, 1, 0]),
|
|
||||||
json!([0, 1, 2, 1, 0, 0, 1, 1, 1, 0]),
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
decode_semantic_tokens("A😀e\u{301}Z\n", &json!({"data": data}), &options).is_err(),
|
|
||||||
"{data}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let count = MAX_BODY / std::mem::size_of::<SemanticToken>() + 1;
|
|
||||||
assert!(
|
|
||||||
decode_semantic_tokens("x", &json!({"data": vec![0; count * 5]}), &options).is_err()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unicode_positions_and_local_uri_boundaries() {
|
|
||||||
let text = "A😀e\u{301}Z\n";
|
|
||||||
let (start, end) = range(
|
|
||||||
text,
|
|
||||||
&json!({"start": {"line": 0, "character": 2}, "end": {"line": 0, "character": 4}}),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(start, TextPosition { row: 0, column: 1 });
|
|
||||||
assert_eq!(end, TextPosition { row: 0, column: 3 });
|
|
||||||
assert_eq!(
|
|
||||||
outbound_position(text, 0, 3).unwrap(),
|
|
||||||
json!({"line": 0, "character": 5})
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
position(text, &json!({"line": 1, "character": 999}), false).unwrap(),
|
|
||||||
TextPosition { row: 1, column: 0 }
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
range(
|
|
||||||
text,
|
|
||||||
&json!({"start": {"line": 0, "character": 3}, "end": {"line": 0, "character": 2}})
|
|
||||||
)
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
let path = Path::new("/tmp/source space λ😀.rs");
|
|
||||||
assert_eq!(uri_path(&file_uri(path).unwrap()).unwrap(), path);
|
|
||||||
assert_eq!(
|
|
||||||
uri_path("file://localhost/tmp/a%23b").unwrap(),
|
|
||||||
Path::new("/tmp/a#b")
|
|
||||||
);
|
|
||||||
for uri in [
|
|
||||||
"https://example.invalid/a",
|
|
||||||
"file://remote/tmp/a",
|
|
||||||
"file:///tmp/%",
|
|
||||||
"file:///tmp/%00",
|
|
||||||
"file:///tmp/%FF",
|
|
||||||
"file:///tmp/a?query",
|
|
||||||
] {
|
|
||||||
assert!(uri_path(uri).is_err(), "{uri}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hover_contents_keep_lines_and_reject_malformed_values() {
|
|
||||||
let source = "A😀e\u{301}Z\r\n";
|
|
||||||
for kind in ["plaintext", "markdown"] {
|
|
||||||
assert_eq!(
|
|
||||||
hover_text(
|
|
||||||
&json!({
|
|
||||||
"contents": {"kind": kind, "value": "😀e\u{301}\r\n\tcode\rnext\u{1b}\u{85}"},
|
|
||||||
"range": {"start": {"line": 0, "character": 2},
|
|
||||||
"end": {"line": 0, "character": 4}}
|
|
||||||
}),
|
|
||||||
source,
|
|
||||||
)
|
|
||||||
.unwrap(),
|
|
||||||
Some("😀e\u{301}\n code\nnext ".into()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for contents in [
|
|
||||||
json!("line\ncode"),
|
|
||||||
json!({"language": "rust", "value": "line\ncode"}),
|
|
||||||
] {
|
|
||||||
assert_eq!(
|
|
||||||
hover_text(&json!({"contents": contents}), source).unwrap(),
|
|
||||||
Some("line\ncode".into()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
hover_text(
|
|
||||||
&json!({"contents": ["first", {"language": "rust", "value": "fn main() {\n}"}]}),
|
|
||||||
source,
|
|
||||||
)
|
|
||||||
.unwrap(),
|
|
||||||
Some("first\n\nfn main() {\n}".into()),
|
|
||||||
);
|
|
||||||
for value in [
|
|
||||||
Value::Null,
|
|
||||||
json!({"contents": ""}),
|
|
||||||
json!({"contents": []}),
|
|
||||||
] {
|
|
||||||
assert_eq!(hover_text(&value, source).unwrap(), None);
|
|
||||||
}
|
|
||||||
for value in [
|
|
||||||
json!({}),
|
|
||||||
json!({"contents": {"kind": "html", "value": "x"}}),
|
|
||||||
json!({"contents": {"language": "rust", "value": false}}),
|
|
||||||
json!({"contents": [{"kind": "markdown", "value": "x"}]}),
|
|
||||||
json!({"contents": "x", "range": {"start": {"line": 1, "character": 0},
|
|
||||||
"end": {"line": 0, "character": 0}}}),
|
|
||||||
json!({"contents": "\t".repeat(MAX_BODY / 4 + 1)}),
|
|
||||||
] {
|
|
||||||
assert!(hover_text(&value, source).is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn completion_text_limit_counts_the_atomic_batch_not_sort_metadata() {
|
|
||||||
let source = Source {
|
|
||||||
buffer_id: 1,
|
|
||||||
revision: 0,
|
|
||||||
row: 0,
|
|
||||||
column: 0,
|
|
||||||
request_id: 1,
|
|
||||||
};
|
|
||||||
let at_limit = json!([{"label": "x", "insertText": "x".repeat(MAX_BODY - 1)}]);
|
|
||||||
assert_eq!(
|
|
||||||
decode_completion("", &at_limit, &source, None).unwrap()[0].edits[0]
|
|
||||||
.text
|
|
||||||
.len(),
|
|
||||||
MAX_BODY - 1
|
|
||||||
);
|
|
||||||
let over_limit = json!([
|
|
||||||
{"label": "x", "insertText": "x".repeat(MAX_BODY - 1)},
|
|
||||||
{"label": "y"}
|
|
||||||
]);
|
|
||||||
assert!(
|
|
||||||
decode_completion("", &over_limit, &source, None)
|
|
||||||
.unwrap_err()
|
|
||||||
.contains("4 MiB")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-48
@@ -254,51 +254,5 @@ fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "../tests/unit/cli_args.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
fn parse(args: &[&str]) -> Result<Option<Options>, String> {
|
|
||||||
parse_options(args.iter().map(std::ffi::OsString::from))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn review_commands_preserve_filenames_and_validate_arguments() {
|
|
||||||
assert_eq!(
|
|
||||||
parse(&["review"]).unwrap().unwrap().file,
|
|
||||||
Some("review".into())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse(&["--", "--review"]).unwrap().unwrap().file,
|
|
||||||
Some("--review".into())
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
parse(&["--no-config", "--review", "list", "--repo", ".", "--json"])
|
|
||||||
.unwrap()
|
|
||||||
.unwrap()
|
|
||||||
.review
|
|
||||||
.is_some()
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
parse(&["--review", "show", "1", "--all", "--no-config"])
|
|
||||||
.unwrap()
|
|
||||||
.unwrap()
|
|
||||||
.review
|
|
||||||
.is_some()
|
|
||||||
);
|
|
||||||
for args in [
|
|
||||||
vec!["--review"],
|
|
||||||
vec!["--review", "show"],
|
|
||||||
vec!["--review", "show", "0"],
|
|
||||||
vec!["--review", "show", "-1"],
|
|
||||||
vec!["--review", "show", "abc"],
|
|
||||||
vec!["--review", "list", "--all"],
|
|
||||||
vec!["--review", "list", "--repo"],
|
|
||||||
vec!["--review", "list", "--repo", ".", "--repo", "."],
|
|
||||||
vec!["--review", "list", "extra"],
|
|
||||||
vec!["--review", "list", "--", "extra"],
|
|
||||||
vec!["--review", "list", "--config", "x", "--no-config"],
|
|
||||||
] {
|
|
||||||
assert!(parse(&args).is_err(), "{args:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-145
@@ -560,148 +560,5 @@ fn slug(title: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "../tests/unit/markdown.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
fn at(text: &str, needle: &str) -> Result<LinkTarget, String> {
|
|
||||||
let byte = text.find(needle).unwrap();
|
|
||||||
let prefix = &text[..byte];
|
|
||||||
let row = prefix.bytes().filter(|byte| *byte == b'\n').count();
|
|
||||||
let column = prefix.rsplit('\n').next().unwrap().graphemes(true).count();
|
|
||||||
link_at(text, TextPosition { row, column })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn target(path: Option<&str>, fragment: Option<&str>) -> LinkTarget {
|
|
||||||
LinkTarget {
|
|
||||||
path: path.map(PathBuf::from),
|
|
||||||
fragment: fragment.map(str::to_owned),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn follows_inline_labels_destinations_and_grapheme_columns() {
|
|
||||||
let text = "👩💻 [go **there**](<../My%20Notes/%E6%97%A5%E6%9C%AC\\(1\\).md#caf%C3%A9>)";
|
|
||||||
let expected = target(Some("../My Notes/日本(1).md"), Some("café"));
|
|
||||||
assert_eq!(at(text, "there").unwrap(), expected);
|
|
||||||
assert_eq!(at(text, "My%20").unwrap(), expected);
|
|
||||||
assert_eq!(
|
|
||||||
at("[absolute](/Tmp/Note.md)", "absolute").unwrap(),
|
|
||||||
target(Some("/Tmp/Note.md"), None)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
at("[here](#title)", "here").unwrap(),
|
|
||||||
target(None, Some("title"))
|
|
||||||
);
|
|
||||||
assert_eq!(at("[top]()", "top").unwrap(), target(None, None));
|
|
||||||
assert_eq!(
|
|
||||||
at("[here](#title)", "[here").unwrap(),
|
|
||||||
target(None, Some("title"))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
at("[file](literal%23name.md)", "file").unwrap(),
|
|
||||||
target(Some("literal#name.md"), None)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
at("[file](My\\[Notes\\].md)", "file").unwrap(),
|
|
||||||
target(Some("My[Notes].md"), None)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolves_all_reference_forms_and_multiline_block_definitions() {
|
|
||||||
let text = "[full][ A B ] [a b][] [A B]\n\n> [a b]:\n> <My%20Notes.md#title>\n> \"A title\"\n\n[a b]: ignored.md\n";
|
|
||||||
let expected = target(Some("My Notes.md"), Some("title"));
|
|
||||||
for needle in ["full", " A B ", "a b][]", "A B]"] {
|
|
||||||
assert_eq!(at(text, needle).unwrap(), expected);
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
at("[Straße]\n\n[STRASSE]: file.md", "Straße").unwrap(),
|
|
||||||
target(Some("file.md"), None)
|
|
||||||
);
|
|
||||||
let text = "> [multi\n> line][target]\n\n[target]: other.md\n";
|
|
||||||
assert_eq!(at(text, "line]").unwrap(), target(Some("other.md"), None));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_images_code_plaintext_and_external_urls() {
|
|
||||||
for (text, needle) in [
|
|
||||||
("", "image"),
|
|
||||||
("`[code](file.md)`", "code"),
|
|
||||||
("```md\n[code](file.md)\n```", "file"),
|
|
||||||
(" [code](file.md)", "code"),
|
|
||||||
("plain file.md text", "file"),
|
|
||||||
("[web](https://example.com)", "web"),
|
|
||||||
("[web](//example.com/a)", "web"),
|
|
||||||
("[web](mailto:a@example.com)", "web"),
|
|
||||||
("<https://example.com>", "example"),
|
|
||||||
] {
|
|
||||||
assert!(at(text, needle).is_err(), "{text}");
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
at("[missing]", "missing")
|
|
||||||
.unwrap_err()
|
|
||||||
.contains("not defined")
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
at("[bad](file%ZZ.md)", "bad")
|
|
||||||
.unwrap_err()
|
|
||||||
.contains("percent")
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
at("[bad](file%FF.md)", "bad")
|
|
||||||
.unwrap_err()
|
|
||||||
.contains("UTF-8")
|
|
||||||
);
|
|
||||||
assert!(at("[bad](#)", "bad").unwrap_err().contains("empty"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn locates_visible_unicode_headings_setext_and_unique_slugs() {
|
|
||||||
let text = "# **Café** [日本](destination.md) `code` ###\n\n> ## Café 日本 code\n\nCafé 日本 code\n====\n\n# Café 日本 code-1\n\n# Cafe\u{301} _name_ \n\n```md\n# Hidden\n```\n";
|
|
||||||
assert_eq!(heading_row(text, "café-日本-code").unwrap(), 0);
|
|
||||||
assert_eq!(heading_row(text, "café-日本-code-1").unwrap(), 2);
|
|
||||||
assert_eq!(heading_row(text, "café-日本-code-2").unwrap(), 4);
|
|
||||||
assert_eq!(heading_row(text, "café-日本-code-1-1").unwrap(), 7);
|
|
||||||
assert_eq!(heading_row(text, "cafe\u{301}-name-alt").unwrap(), 9);
|
|
||||||
assert!(heading_row(text, "hidden").is_err());
|
|
||||||
assert!(heading_row(text, "destinationmd").is_err());
|
|
||||||
assert!(heading_row(text, "").is_err());
|
|
||||||
assert_eq!(
|
|
||||||
heading_row("# [Heading][ref]\n\n[ref]: ignored.md", "heading").unwrap(),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
heading_row("# ![Alt][ref]\n\n[ref]: ignored.md", "alt").unwrap(),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
assert_eq!(heading_row("# ![Alt][missing]", "altmissing").unwrap(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn renders_entities_or_reports_the_explicit_entity_ceiling() {
|
|
||||||
assert_eq!(
|
|
||||||
at("[file](a&b c.md)", "file").unwrap(),
|
|
||||||
target(Some("a&b c.md"), None)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
at("[file](<name>)", "file").unwrap(),
|
|
||||||
target(Some("<name>"), None)
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
at("[file](a©b.md)", "file")
|
|
||||||
.unwrap_err()
|
|
||||||
.contains("Unsupported Markdown HTML entity")
|
|
||||||
);
|
|
||||||
assert_eq!(heading_row("# A & B é\n", "a--b-é").unwrap(), 0);
|
|
||||||
assert!(
|
|
||||||
heading_row("# ©\n", "copy")
|
|
||||||
.unwrap_err()
|
|
||||||
.contains("Unsupported Markdown HTML entity")
|
|
||||||
);
|
|
||||||
assert_eq!(heading_row("# ©\n# Valid\n", "valid").unwrap(), 1);
|
|
||||||
assert_eq!(
|
|
||||||
heading_row("> First\n> Second\n> ------\n", "first-second").unwrap(),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-125
@@ -248,128 +248,5 @@ pub fn directional_neighbor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "../tests/unit/panes.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
fn rect(x: u16, y: u16, width: u16, height: u16) -> PaneRect {
|
|
||||||
PaneRect {
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn leaves(tree: &PaneTree, bounds: PaneRect) -> Vec<(usize, PaneRect)> {
|
|
||||||
let mut leaves = Vec::new();
|
|
||||||
tree.visit_rects(bounds, |id, rect| leaves.push((id, rect)));
|
|
||||||
leaves
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn nested_layout_partitions_bounds_and_preserves_ids() {
|
|
||||||
let bounds = rect(3, 5, 11, 9);
|
|
||||||
let mut tree = PaneTree::new(10);
|
|
||||||
assert!(tree.split(10, 20, SplitAxis::Vertical));
|
|
||||||
assert!(tree.split(20, 30, SplitAxis::Horizontal));
|
|
||||||
assert!(!tree.split(99, 40, SplitAxis::Horizontal));
|
|
||||||
assert!(!tree.split(10, 30, SplitAxis::Horizontal));
|
|
||||||
assert!(!tree.split(10, 10, SplitAxis::Horizontal));
|
|
||||||
assert_eq!(tree.len(), 3);
|
|
||||||
let panes = leaves(&tree, bounds);
|
|
||||||
assert_eq!(
|
|
||||||
panes,
|
|
||||||
vec![
|
|
||||||
(10, rect(3, 5, 5, 9)),
|
|
||||||
(20, rect(8, 5, 6, 4)),
|
|
||||||
(30, rect(8, 9, 6, 5))
|
|
||||||
]
|
|
||||||
);
|
|
||||||
let mut cells = std::collections::HashSet::new();
|
|
||||||
for (_, pane) in panes {
|
|
||||||
for y in pane.y..pane.y + pane.height {
|
|
||||||
for x in pane.x..pane.x + pane.width {
|
|
||||||
assert!(cells.insert((x, y)), "overlapping pane cell");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
cells.len(),
|
|
||||||
usize::from(bounds.width) * usize::from(bounds.height)
|
|
||||||
);
|
|
||||||
assert!(leaves(&tree, rect(0, 0, 0, 10)).is_empty());
|
|
||||||
assert!(leaves(&tree, rect(0, 0, 10, 0)).is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn closing_expands_nested_sibling_and_refuses_last() {
|
|
||||||
let bounds = rect(0, 0, 20, 12);
|
|
||||||
let mut tree = PaneTree::new(1);
|
|
||||||
tree.split(1, 2, SplitAxis::Vertical);
|
|
||||||
tree.split(2, 3, SplitAxis::Horizontal);
|
|
||||||
assert_eq!(tree.close(1), Some(2));
|
|
||||||
assert_eq!(
|
|
||||||
leaves(&tree, bounds),
|
|
||||||
vec![(2, rect(0, 0, 20, 6)), (3, rect(0, 6, 20, 6))]
|
|
||||||
);
|
|
||||||
assert_eq!(tree.close(99), None);
|
|
||||||
assert_eq!(tree.close(2), Some(3));
|
|
||||||
assert_eq!(leaves(&tree, bounds), vec![(3, bounds)]);
|
|
||||||
assert_eq!(tree.close(3), None);
|
|
||||||
assert_eq!(tree.len(), 1);
|
|
||||||
assert!(!tree.contains(1));
|
|
||||||
assert!(tree.contains(3));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn directional_focus_handles_unequal_nested_regions() {
|
|
||||||
let bounds = rect(0, 0, 20, 12);
|
|
||||||
let mut tree = PaneTree::new(1);
|
|
||||||
tree.split(1, 2, SplitAxis::Vertical);
|
|
||||||
tree.split(2, 3, SplitAxis::Horizontal);
|
|
||||||
tree.split(3, 4, SplitAxis::Horizontal);
|
|
||||||
assert_eq!(tree.neighbor(1, PaneDirection::Right, bounds), Some(3));
|
|
||||||
assert_eq!(tree.neighbor(2, PaneDirection::Down, bounds), Some(3));
|
|
||||||
assert_eq!(tree.neighbor(3, PaneDirection::Down, bounds), Some(4));
|
|
||||||
assert_eq!(tree.neighbor(4, PaneDirection::Up, bounds), Some(3));
|
|
||||||
assert_eq!(tree.neighbor(4, PaneDirection::Left, bounds), Some(1));
|
|
||||||
assert_eq!(tree.neighbor(1, PaneDirection::Left, bounds), None);
|
|
||||||
assert_eq!(tree.neighbor(99, PaneDirection::Right, bounds), None);
|
|
||||||
assert_eq!(
|
|
||||||
tree.neighbor(1, PaneDirection::Right, rect(0, 0, 0, 0)),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn navigation_prefers_overlap_then_edge_and_breaks_ties_by_id() {
|
|
||||||
let current = rect(10, 10, 10, 10);
|
|
||||||
let candidates = [
|
|
||||||
(1, rect(20, 0, 5, 5)),
|
|
||||||
(2, rect(25, 10, 5, 10)),
|
|
||||||
(4, rect(22, 10, 5, 10)),
|
|
||||||
(3, rect(22, 10, 5, 10)),
|
|
||||||
(0, rect(20, 10, 0, 10)),
|
|
||||||
];
|
|
||||||
assert_eq!(
|
|
||||||
directional_neighbor(current, PaneDirection::Right, candidates.into_iter()),
|
|
||||||
Some(3)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
directional_neighbor(current, PaneDirection::Right, candidates.into_iter().rev()),
|
|
||||||
Some(3)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
directional_neighbor(
|
|
||||||
current,
|
|
||||||
PaneDirection::Up,
|
|
||||||
[(7, rect(10, 0, 10, 5))].into_iter()
|
|
||||||
),
|
|
||||||
Some(7)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
directional_neighbor(current, PaneDirection::Left, candidates.into_iter()),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-506
@@ -527,15 +527,6 @@ fn working_conflict_baseline(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn working_file_changes(
|
|
||||||
root: &Path,
|
|
||||||
buffers: &[(PathBuf, Arc<str>)],
|
|
||||||
cancelled: &dyn Fn() -> bool,
|
|
||||||
) -> Result<Vec<WorkingFileChange>, String> {
|
|
||||||
WorkingFileReader::default().read(root, buffers, cancelled)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn working_file_changes_cached(
|
fn working_file_changes_cached(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
buffers: &[(PathBuf, Arc<str>)],
|
buffers: &[(PathBuf, Arc<str>)],
|
||||||
@@ -3032,500 +3023,5 @@ fn eof_label(file: Option<&FileData>) -> &'static str {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(test, unix))]
|
#[cfg(all(test, unix))]
|
||||||
mod tests {
|
#[path = "../tests/unit/repository.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
fn fixture_command(root: &Path, program: &str, args: &[&str]) -> String {
|
|
||||||
let mut command = Command::new(program);
|
|
||||||
if program == "jj" {
|
|
||||||
command.args([
|
|
||||||
"--config=user.name=\"Picker Author\"",
|
|
||||||
"--config=user.email=\"picker@example.test\"",
|
|
||||||
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
|
|
||||||
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
let output = command
|
|
||||||
.args(args)
|
|
||||||
.current_dir(root)
|
|
||||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
|
||||||
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
|
||||||
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
|
|
||||||
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
|
|
||||||
.env("JJ_CONFIG", "/dev/null")
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
output.status.success(),
|
|
||||||
"{}",
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
String::from_utf8(output.stdout).unwrap().trim().into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn picker_git_fixture() -> Temporary {
|
|
||||||
let temp = Temporary::new().unwrap();
|
|
||||||
fixture_command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
|
||||||
fixture_command(&temp.0, "git", &["config", "user.name", "Picker Author"]);
|
|
||||||
fixture_command(
|
|
||||||
&temp.0,
|
|
||||||
"git",
|
|
||||||
&["config", "user.email", "picker@example.test"],
|
|
||||||
);
|
|
||||||
temp
|
|
||||||
}
|
|
||||||
|
|
||||||
fn changes_by_path(
|
|
||||||
root: &Path,
|
|
||||||
buffers: &[(PathBuf, Arc<str>)],
|
|
||||||
) -> BTreeMap<PathBuf, WorkingFileChange> {
|
|
||||||
working_file_changes(root, buffers, &|| false)
|
|
||||||
.unwrap()
|
|
||||||
.into_iter()
|
|
||||||
.map(|change| (change.path.clone(), change))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn working_git_changes_cover_disk_index_buffers_binary_exact_renames_and_read_only_queries() {
|
|
||||||
use std::os::unix::{ffi::OsStringExt, fs::PermissionsExt};
|
|
||||||
let temp = picker_git_fixture();
|
|
||||||
let root = &temp.0;
|
|
||||||
for (name, contents) in [
|
|
||||||
(".gitignore", "ignored*\n"),
|
|
||||||
("modified", "original\r\n"),
|
|
||||||
("unchanged", "same\n"),
|
|
||||||
("removed", "deleted\n"),
|
|
||||||
("old", "rename content\n"),
|
|
||||||
("duplicate-one", "duplicate\n"),
|
|
||||||
("duplicate-two", "duplicate\n"),
|
|
||||||
("mode", "mode\n"),
|
|
||||||
("binary", "before\0binary"),
|
|
||||||
] {
|
|
||||||
fs::write(root.join(name), contents).unwrap();
|
|
||||||
}
|
|
||||||
let non_utf8 = PathBuf::from(OsString::from_vec(b"non-utf8-\xff".to_vec()));
|
|
||||||
let non_utf8 = match fs::write(root.join(&non_utf8), "before\n") {
|
|
||||||
Ok(()) => Some(non_utf8),
|
|
||||||
Err(error) if cfg!(target_os = "macos") && error.raw_os_error() == Some(92) => None,
|
|
||||||
Err(error) => panic!("cannot create byte filename: {error}"),
|
|
||||||
};
|
|
||||||
fixture_command(root, "git", &["add", "--all"]);
|
|
||||||
fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]);
|
|
||||||
fs::write(root.join("modified"), "staged\n").unwrap();
|
|
||||||
fixture_command(root, "git", &["add", "--", "modified"]);
|
|
||||||
fs::write(root.join("modified"), "unstaged\n").unwrap();
|
|
||||||
fs::write(root.join("binary"), "after\0binary").unwrap();
|
|
||||||
if let Some(name) = &non_utf8 {
|
|
||||||
fs::write(root.join(name), "after\n").unwrap();
|
|
||||||
}
|
|
||||||
fs::remove_file(root.join("removed")).unwrap();
|
|
||||||
let renamed = "-new [file]\n\t\".txt";
|
|
||||||
fs::rename(root.join("old"), root.join(renamed)).unwrap();
|
|
||||||
fs::remove_file(root.join("duplicate-one")).unwrap();
|
|
||||||
fs::remove_file(root.join("duplicate-two")).unwrap();
|
|
||||||
fs::write(root.join("new-duplicate-one"), "duplicate\n").unwrap();
|
|
||||||
fs::write(root.join("new-duplicate-two"), "duplicate\n").unwrap();
|
|
||||||
fs::set_permissions(root.join("mode"), fs::Permissions::from_mode(0o755)).unwrap();
|
|
||||||
fs::write(root.join("added"), "added\n").unwrap();
|
|
||||||
fs::write(root.join("ignored"), "ignored\n").unwrap();
|
|
||||||
fs::write(root.join("ignored-tracked"), "forced\n").unwrap();
|
|
||||||
fixture_command(root, "git", &["add", "-f", "--", "ignored-tracked"]);
|
|
||||||
std::os::unix::fs::symlink(root.join("modified"), root.join("link")).unwrap();
|
|
||||||
let index = fs::read(root.join(".git/index")).unwrap();
|
|
||||||
let head = fs::read(root.join(".git/HEAD")).unwrap();
|
|
||||||
let disk = changes_by_path(root, &[]);
|
|
||||||
for name in ["modified", "binary", "mode"] {
|
|
||||||
assert_eq!(disk[Path::new(name)].status, FileStatus::Modified);
|
|
||||||
}
|
|
||||||
if let Some(name) = &non_utf8 {
|
|
||||||
assert_eq!(disk[name].status, FileStatus::Modified);
|
|
||||||
}
|
|
||||||
assert_eq!(disk[Path::new("removed")].status, FileStatus::Removed);
|
|
||||||
assert_eq!(disk[Path::new(renamed)].status, FileStatus::Renamed);
|
|
||||||
assert_eq!(
|
|
||||||
disk[Path::new(renamed)].old_path.as_deref(),
|
|
||||||
Some(Path::new("old"))
|
|
||||||
);
|
|
||||||
for name in [
|
|
||||||
"added",
|
|
||||||
"ignored-tracked",
|
|
||||||
"new-duplicate-one",
|
|
||||||
"new-duplicate-two",
|
|
||||||
] {
|
|
||||||
assert_eq!(disk[Path::new(name)].status, FileStatus::Added);
|
|
||||||
}
|
|
||||||
for name in ["duplicate-one", "duplicate-two"] {
|
|
||||||
assert_eq!(disk[Path::new(name)].status, FileStatus::Removed);
|
|
||||||
}
|
|
||||||
for name in ["unchanged", "old", "ignored", "link"] {
|
|
||||||
assert!(!disk.contains_key(Path::new(name)), "{name}");
|
|
||||||
}
|
|
||||||
let overlays = changes_by_path(
|
|
||||||
root,
|
|
||||||
&[
|
|
||||||
(root.join("modified"), "original\n".into()),
|
|
||||||
(root.join("unchanged"), "dirty\n".into()),
|
|
||||||
(root.join("removed"), "deleted\n".into()),
|
|
||||||
(root.join(renamed), "dirty renamed\n".into()),
|
|
||||||
(root.join("mode"), "mode\n".into()),
|
|
||||||
(root.join("ignored-buffer"), "ignored\n".into()),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
assert!(!overlays.contains_key(Path::new("modified")));
|
|
||||||
assert!(!overlays.contains_key(Path::new("ignored-buffer")));
|
|
||||||
assert_eq!(
|
|
||||||
overlays[Path::new("unchanged")].status,
|
|
||||||
FileStatus::Modified
|
|
||||||
);
|
|
||||||
assert_eq!(overlays[Path::new("removed")].status, FileStatus::Removed);
|
|
||||||
assert_eq!(overlays[Path::new(renamed)].status, FileStatus::Renamed);
|
|
||||||
assert_eq!(overlays[Path::new("mode")].status, FileStatus::Modified);
|
|
||||||
let aliases = Temporary::new().unwrap();
|
|
||||||
let alias = aliases.0.join("checkout");
|
|
||||||
std::os::unix::fs::symlink(root, &alias).unwrap();
|
|
||||||
let aliased = changes_by_path(root, &[(alias.join("unchanged"), "dirty\n".into())]);
|
|
||||||
assert_eq!(aliased[Path::new("unchanged")].status, FileStatus::Modified);
|
|
||||||
fixture_command(root, "git", &["config", "core.filemode", "0"]);
|
|
||||||
assert!(
|
|
||||||
!changes_by_path(root, &[(root.join("mode"), "mode\n".into())])
|
|
||||||
.contains_key(Path::new("mode"))
|
|
||||||
);
|
|
||||||
fixture_command(root, "git", &["config", "core.autocrlf", "1"]);
|
|
||||||
fs::write(root.join("unchanged"), "same\r\n").unwrap();
|
|
||||||
assert!(!changes_by_path(root, &[]).contains_key(Path::new("unchanged")));
|
|
||||||
fixture_command(root, "git", &["config", "--unset", "core.autocrlf"]);
|
|
||||||
fixture_command(root, "git", &["config", "core.filemode", "true"]);
|
|
||||||
fs::write(root.join("unchanged"), "same\n").unwrap();
|
|
||||||
assert_eq!(fs::read(root.join(".git/index")).unwrap(), index);
|
|
||||||
assert_eq!(fs::read(root.join(".git/HEAD")).unwrap(), head);
|
|
||||||
assert_eq!(
|
|
||||||
fs::read_to_string(root.join("modified")).unwrap(),
|
|
||||||
"unstaged\n"
|
|
||||||
);
|
|
||||||
assert!(!root.join("removed").exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn working_git_subdirectory_cross_boundary_moves_worktrees_and_unsafe_roots() {
|
|
||||||
let temp = picker_git_fixture();
|
|
||||||
let root = &temp.0;
|
|
||||||
fs::create_dir(root.join("sub")).unwrap();
|
|
||||||
fs::write(root.join("sub/outgoing"), "out\n").unwrap();
|
|
||||||
fs::write(root.join("incoming"), "in\n").unwrap();
|
|
||||||
fixture_command(root, "git", &["add", "--all"]);
|
|
||||||
fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]);
|
|
||||||
fs::rename(root.join("sub/outgoing"), root.join("outgoing")).unwrap();
|
|
||||||
fs::rename(root.join("incoming"), root.join("sub/incoming")).unwrap();
|
|
||||||
let changes = changes_by_path(&root.join("sub"), &[]);
|
|
||||||
assert_eq!(changes.len(), 2);
|
|
||||||
assert_eq!(changes[Path::new("outgoing")].status, FileStatus::Removed);
|
|
||||||
assert_eq!(changes[Path::new("incoming")].status, FileStatus::Added);
|
|
||||||
assert_eq!(changes[Path::new("incoming")].old_path, None);
|
|
||||||
std::os::unix::fs::symlink(root.join("sub"), root.join("linked")).unwrap();
|
|
||||||
assert!(changes_by_path(&root.join("linked"), &[]).is_empty());
|
|
||||||
assert!(changes_by_path(&root.join(".git"), &[]).is_empty());
|
|
||||||
let unrelated = Temporary::new().unwrap();
|
|
||||||
assert!(changes_by_path(&unrelated.0, &[]).is_empty());
|
|
||||||
let worktree = temp.0.join("worktree");
|
|
||||||
fixture_command(
|
|
||||||
root,
|
|
||||||
"git",
|
|
||||||
&["worktree", "add", "--quiet", "--detach", "worktree", "HEAD"],
|
|
||||||
);
|
|
||||||
fs::write(worktree.join("sub/outgoing"), "worktree edit\n").unwrap();
|
|
||||||
let changes = changes_by_path(&worktree.join("sub"), &[]);
|
|
||||||
assert_eq!(changes[Path::new("outgoing")].status, FileStatus::Modified);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn working_unborn_git_never_runs_filters_external_diff_textconv_or_hooks() {
|
|
||||||
let temp = picker_git_fixture();
|
|
||||||
let root = &temp.0;
|
|
||||||
fs::write(root.join(".gitignore"), "ignored\n").unwrap();
|
|
||||||
fs::write(root.join("tracked"), "tracked\n").unwrap();
|
|
||||||
fs::write(root.join("untracked"), "untracked\n").unwrap();
|
|
||||||
fs::write(root.join("ignored"), "ignored\n").unwrap();
|
|
||||||
fixture_command(root, "git", &["add", "--", ".gitignore", "tracked"]);
|
|
||||||
let changes = changes_by_path(root, &[(root.join("named"), "not saved\n".into())]);
|
|
||||||
for name in [".gitignore", "tracked", "untracked", "named"] {
|
|
||||||
assert_eq!(changes[Path::new(name)].status, FileStatus::Added);
|
|
||||||
}
|
|
||||||
assert!(!changes.contains_key(Path::new("ignored")));
|
|
||||||
assert!(!root.join("named").exists());
|
|
||||||
fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]);
|
|
||||||
fs::write(
|
|
||||||
root.join(".gitattributes"),
|
|
||||||
"tracked filter=evil diff=evil\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let evil = "sh -c 'touch executed; cat'";
|
|
||||||
for key in [
|
|
||||||
"filter.evil.clean",
|
|
||||||
"filter.evil.process",
|
|
||||||
"diff.evil.command",
|
|
||||||
"diff.evil.textconv",
|
|
||||||
] {
|
|
||||||
fixture_command(root, "git", &["config", key, evil]);
|
|
||||||
}
|
|
||||||
fixture_command(root, "git", &["config", "filter.evil.required", "true"]);
|
|
||||||
fs::write(root.join("tracked"), "changed\n").unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
changes_by_path(root, &[])[Path::new("tracked")].status,
|
|
||||||
FileStatus::Modified
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
changes_by_path(root, &[(root.join("tracked"), "tracked\n".into())])
|
|
||||||
.get(Path::new("tracked")),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
assert!(!root.join("executed").exists());
|
|
||||||
assert!(
|
|
||||||
working_file_changes(root, &[], &|| true)
|
|
||||||
.unwrap_err()
|
|
||||||
.contains("cancelled")
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
working_file_changes(
|
|
||||||
root,
|
|
||||||
&[(root.join("tracked"), "x".repeat(FILE_LIMIT + 1).into())],
|
|
||||||
&|| false
|
|
||||||
)
|
|
||||||
.unwrap_err()
|
|
||||||
.contains("16 MiB")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn working_jj_root_parent_colocation_and_merge_queries_do_not_snapshot() {
|
|
||||||
for colocate in ["--colocate", "--no-colocate"] {
|
|
||||||
let temp = Temporary::new().unwrap();
|
|
||||||
let root = &temp.0;
|
|
||||||
fixture_command(root, "jj", &["git", "init", colocate, "."]);
|
|
||||||
fs::write(root.join("file"), "baseline\n").unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
changes_by_path(root, &[])[Path::new("file")].status,
|
|
||||||
FileStatus::Added
|
|
||||||
);
|
|
||||||
fs::write(root.join(".gitignore"), "ignored\n").unwrap();
|
|
||||||
fixture_command(root, "jj", &["describe", "-m", "Baseline"]);
|
|
||||||
fixture_command(root, "jj", &["new"]);
|
|
||||||
let operation = fixture_command(
|
|
||||||
root,
|
|
||||||
"jj",
|
|
||||||
&[
|
|
||||||
"--ignore-working-copy",
|
|
||||||
"op",
|
|
||||||
"log",
|
|
||||||
"--no-graph",
|
|
||||||
"-n",
|
|
||||||
"1",
|
|
||||||
"-T",
|
|
||||||
"id",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
let parent = fixture_command(
|
|
||||||
root,
|
|
||||||
"jj",
|
|
||||||
&[
|
|
||||||
"--ignore-working-copy",
|
|
||||||
"log",
|
|
||||||
"--no-graph",
|
|
||||||
"-r",
|
|
||||||
"@-",
|
|
||||||
"-T",
|
|
||||||
"commit_id",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
let store = store_for_jj(root).unwrap();
|
|
||||||
let head = fs::read(store.join("HEAD")).unwrap();
|
|
||||||
let index = fs::read(store.join("index")).ok();
|
|
||||||
fs::write(root.join("file"), "disk\n").unwrap();
|
|
||||||
fs::write(root.join("new"), "new\n").unwrap();
|
|
||||||
fs::write(root.join("ignored"), "ignored\n").unwrap();
|
|
||||||
let changes = changes_by_path(root, &[]);
|
|
||||||
assert_eq!(changes[Path::new("file")].status, FileStatus::Modified);
|
|
||||||
assert_eq!(changes[Path::new("new")].status, FileStatus::Added);
|
|
||||||
assert!(!changes.contains_key(Path::new("ignored")));
|
|
||||||
assert!(
|
|
||||||
!changes_by_path(root, &[(root.join("file"), "baseline\n".into())])
|
|
||||||
.contains_key(Path::new("file"))
|
|
||||||
);
|
|
||||||
assert_eq!(fs::read(store.join("HEAD")).unwrap(), head);
|
|
||||||
assert_eq!(fs::read(store.join("index")).ok(), index);
|
|
||||||
assert_eq!(
|
|
||||||
fixture_command(
|
|
||||||
root,
|
|
||||||
"jj",
|
|
||||||
&[
|
|
||||||
"--ignore-working-copy",
|
|
||||||
"op",
|
|
||||||
"log",
|
|
||||||
"--no-graph",
|
|
||||||
"-n",
|
|
||||||
"1",
|
|
||||||
"-T",
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
),
|
|
||||||
operation
|
|
||||||
);
|
|
||||||
fixture_command(root, "jj", &["new", "root()"]);
|
|
||||||
fs::write(root.join("sibling"), "sibling\n").unwrap();
|
|
||||||
fixture_command(root, "jj", &["describe", "-m", "Sibling"]);
|
|
||||||
fixture_command(root, "jj", &["new", &parent, "@"]);
|
|
||||||
assert!(changes_by_path(root, &[]).is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn working_jj_conflicted_parent_uses_cli_projection_without_snapshotting() {
|
|
||||||
for colocate in ["--colocate", "--no-colocate"] {
|
|
||||||
let temp = Temporary::new().unwrap();
|
|
||||||
let root = &temp.0;
|
|
||||||
fixture_command(root, "jj", &["git", "init", colocate, "."]);
|
|
||||||
fs::write(root.join("conflicted"), "base\n").unwrap();
|
|
||||||
fixture_command(root, "jj", &["describe", "-m", "Base"]);
|
|
||||||
let base = fixture_command(
|
|
||||||
root,
|
|
||||||
"jj",
|
|
||||||
&[
|
|
||||||
"--ignore-working-copy",
|
|
||||||
"log",
|
|
||||||
"--no-graph",
|
|
||||||
"-r",
|
|
||||||
"@",
|
|
||||||
"-T",
|
|
||||||
"commit_id",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
fixture_command(root, "jj", &["new"]);
|
|
||||||
fs::write(root.join("conflicted"), "left\n").unwrap();
|
|
||||||
fixture_command(root, "jj", &["describe", "-m", "Left"]);
|
|
||||||
let left = fixture_command(
|
|
||||||
root,
|
|
||||||
"jj",
|
|
||||||
&[
|
|
||||||
"--ignore-working-copy",
|
|
||||||
"log",
|
|
||||||
"--no-graph",
|
|
||||||
"-r",
|
|
||||||
"@",
|
|
||||||
"-T",
|
|
||||||
"commit_id",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
fixture_command(root, "jj", &["new", &base]);
|
|
||||||
fs::write(root.join("conflicted"), "right\n").unwrap();
|
|
||||||
fixture_command(root, "jj", &["describe", "-m", "Right"]);
|
|
||||||
let right = fixture_command(
|
|
||||||
root,
|
|
||||||
"jj",
|
|
||||||
&[
|
|
||||||
"--ignore-working-copy",
|
|
||||||
"log",
|
|
||||||
"--no-graph",
|
|
||||||
"-r",
|
|
||||||
"@",
|
|
||||||
"-T",
|
|
||||||
"commit_id",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
fixture_command(root, "jj", &["new", &left, &right]);
|
|
||||||
fixture_command(root, "jj", &["new"]);
|
|
||||||
let projected = fs::read_to_string(root.join("conflicted")).unwrap();
|
|
||||||
assert!(projected.contains("<<<<<<<"));
|
|
||||||
let operation = fixture_command(
|
|
||||||
root,
|
|
||||||
"jj",
|
|
||||||
&[
|
|
||||||
"--ignore-working-copy",
|
|
||||||
"op",
|
|
||||||
"log",
|
|
||||||
"--no-graph",
|
|
||||||
"-n",
|
|
||||||
"1",
|
|
||||||
"-T",
|
|
||||||
"id",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
let mut reader = WorkingFileReader::default();
|
|
||||||
assert!(reader.read(root, &[], &|| false).unwrap().is_empty());
|
|
||||||
fs::write(root.join("conflicted"), "resolved in working file\n").unwrap();
|
|
||||||
let changes = reader.read(root, &[], &|| false).unwrap();
|
|
||||||
assert_eq!(changes.len(), 1);
|
|
||||||
assert_eq!(changes[0].path, Path::new("conflicted"));
|
|
||||||
assert_eq!(changes[0].status, FileStatus::Modified);
|
|
||||||
assert!(
|
|
||||||
reader
|
|
||||||
.read(
|
|
||||||
root,
|
|
||||||
&[(root.join("conflicted"), projected.into())],
|
|
||||||
&|| false
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
.is_empty()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
fs::read_to_string(root.join("conflicted")).unwrap(),
|
|
||||||
"resolved in working file\n"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
fixture_command(
|
|
||||||
root,
|
|
||||||
"jj",
|
|
||||||
&[
|
|
||||||
"--ignore-working-copy",
|
|
||||||
"op",
|
|
||||||
"log",
|
|
||||||
"--no-graph",
|
|
||||||
"-n",
|
|
||||||
"1",
|
|
||||||
"-T",
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
),
|
|
||||||
operation
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cancellation_kills_pipe_holding_children_and_drains_both_streams() {
|
|
||||||
let temp = Temporary::new().unwrap();
|
|
||||||
let ready = temp.0.join("ready");
|
|
||||||
let cancelled = Arc::new(AtomicBool::new(false));
|
|
||||||
let worker_cancelled = Arc::clone(&cancelled);
|
|
||||||
let gate = ready.clone();
|
|
||||||
let (sent, received) = mpsc::channel();
|
|
||||||
let worker = thread::spawn(move || {
|
|
||||||
let mut command = Command::new("sh");
|
|
||||||
command
|
|
||||||
.args([
|
|
||||||
"-c",
|
|
||||||
"sleep 600 & printf ready > \"$1\"; wait",
|
|
||||||
"vamp-diff-test",
|
|
||||||
])
|
|
||||||
.arg(gate);
|
|
||||||
let result = run(command, vec![], &worker_cancelled).map(|_| ());
|
|
||||||
sent.send(result).unwrap();
|
|
||||||
});
|
|
||||||
// File handshake, not a wall-clock scheduling assumption.
|
|
||||||
while !ready.exists() {
|
|
||||||
if let Ok(result) = received.try_recv() {
|
|
||||||
panic!("Process exited before the child was ready: {result:?}");
|
|
||||||
}
|
|
||||||
thread::yield_now();
|
|
||||||
}
|
|
||||||
cancelled.store(true, Ordering::Release);
|
|
||||||
assert!(received.recv().unwrap().unwrap_err().contains("cancelled"));
|
|
||||||
worker.join().unwrap();
|
|
||||||
|
|
||||||
let mut command = Command::new("sh");
|
|
||||||
command.args(["-c", "i=0; while [ \"$i\" -lt 10000 ]; do printf 'line\\n'; printf 'error\\n' >&2; i=$((i+1)); done"]);
|
|
||||||
let result = run(command, vec![], &AtomicBool::new(false)).unwrap();
|
|
||||||
assert_eq!(result.code, Some(0));
|
|
||||||
assert_eq!(result.stdout.len(), 50_000);
|
|
||||||
assert_eq!(result.stderr.len(), 60_000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+4
-473
@@ -1050,478 +1050,9 @@ pub fn project(text: &str, source: &Source) -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(test, unix))]
|
#[cfg(all(test, unix))]
|
||||||
mod tests {
|
#[path = "../tests/unit/resolve.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
struct Fixture(PathBuf);
|
|
||||||
impl Fixture {
|
|
||||||
fn new() -> Self {
|
|
||||||
let path = std::env::temp_dir().join(format!(
|
|
||||||
"vamp-resolver-test-{}-{}",
|
|
||||||
std::process::id(),
|
|
||||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
|
||||||
));
|
|
||||||
fs::create_dir(&path).unwrap();
|
|
||||||
Self(path)
|
|
||||||
}
|
|
||||||
fn jj(&self, args: &[&str]) -> String {
|
|
||||||
let output = Command::new("jj")
|
|
||||||
.current_dir(&self.0)
|
|
||||||
.env("JJ_CONFIG", "/dev/null")
|
|
||||||
.args([
|
|
||||||
"--config=user.name=Resolver",
|
|
||||||
"--config=user.email=resolver@example.test",
|
|
||||||
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
|
|
||||||
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
|
|
||||||
])
|
|
||||||
.args(args)
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
output.status.success(),
|
|
||||||
"{}",
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
String::from_utf8(output.stdout).unwrap()
|
|
||||||
}
|
|
||||||
fn conflict(&self) {
|
|
||||||
self.jj(&["git", "init", "--no-colocate", "."]);
|
|
||||||
self.jj(&["config", "set", "--repo", "user.name", "Resolver"]);
|
|
||||||
self.jj(&[
|
|
||||||
"config",
|
|
||||||
"set",
|
|
||||||
"--repo",
|
|
||||||
"user.email",
|
|
||||||
"resolver@example.test",
|
|
||||||
]);
|
|
||||||
self.jj(&[
|
|
||||||
"config",
|
|
||||||
"set",
|
|
||||||
"--repo",
|
|
||||||
"debug.commit-timestamp",
|
|
||||||
"\"2001-01-01T00:00:00Z\"",
|
|
||||||
]);
|
|
||||||
self.jj(&[
|
|
||||||
"config",
|
|
||||||
"set",
|
|
||||||
"--repo",
|
|
||||||
"debug.operation-timestamp",
|
|
||||||
"\"2001-01-01T00:00:00Z\"",
|
|
||||||
]);
|
|
||||||
fs::write(self.0.join("file"), "base\n").unwrap();
|
|
||||||
self.jj(&["bookmark", "create", "base"]);
|
|
||||||
self.jj(&["new", "base"]);
|
|
||||||
fs::write(self.0.join("file"), "left\n").unwrap();
|
|
||||||
self.jj(&["bookmark", "create", "left"]);
|
|
||||||
self.jj(&["new", "base"]);
|
|
||||||
fs::write(self.0.join("file"), "right\n").unwrap();
|
|
||||||
self.jj(&["bookmark", "create", "right"]);
|
|
||||||
self.jj(&["new", "left", "right"]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl Drop for Fixture {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = fs::remove_dir_all(&self.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn discovery_is_readonly_and_save_uses_jj_authority_and_stale_guards() {
|
|
||||||
let fixture = Fixture::new();
|
|
||||||
fixture.conflict();
|
|
||||||
let operation = fixture.jj(&[
|
|
||||||
"--ignore-working-copy",
|
|
||||||
"op",
|
|
||||||
"log",
|
|
||||||
"--no-graph",
|
|
||||||
"-n",
|
|
||||||
"1",
|
|
||||||
"-T",
|
|
||||||
"id",
|
|
||||||
]);
|
|
||||||
let workspace = load(&fixture.0, &|| false).unwrap();
|
|
||||||
assert_eq!(workspace.files.len(), 1);
|
|
||||||
let file = &workspace.files[0];
|
|
||||||
assert!(file.sources.iter().any(|s| s.base && s.bytes == b"base\n"));
|
|
||||||
assert!(file.sources.iter().any(|s| !s.base && s.bytes == b"left\n"));
|
|
||||||
assert!(
|
|
||||||
file.sources
|
|
||||||
.iter()
|
|
||||||
.any(|s| !s.base && s.bytes == b"right\n")
|
|
||||||
);
|
|
||||||
for bookmark in ["base", "left", "right"] {
|
|
||||||
assert!(
|
|
||||||
file.sources
|
|
||||||
.iter()
|
|
||||||
.any(|s| s.label.contains(&format!("[{bookmark}]")))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
operation,
|
|
||||||
fixture.jj(&[
|
|
||||||
"--ignore-working-copy",
|
|
||||||
"op",
|
|
||||||
"log",
|
|
||||||
"--no-graph",
|
|
||||||
"-n",
|
|
||||||
"1",
|
|
||||||
"-T",
|
|
||||||
"id"
|
|
||||||
])
|
|
||||||
);
|
|
||||||
let blocks = regions(file.result.as_ref().unwrap());
|
|
||||||
assert_eq!(blocks.len(), 1);
|
|
||||||
for source in &file.sources {
|
|
||||||
assert_eq!(blocks[0].choose(source).unwrap().as_bytes(), source.bytes);
|
|
||||||
}
|
|
||||||
let mut changed = blocks[0].clone();
|
|
||||||
changed.sides.pop();
|
|
||||||
assert!(changed.choose(&file.sources[0]).is_err());
|
|
||||||
fs::write(fixture.0.join("file"), "external\n").unwrap();
|
|
||||||
assert!(
|
|
||||||
save(&workspace, 0, Resolution::Text("lost\n".into()), &|| false)
|
|
||||||
.unwrap_err()
|
|
||||||
.contains("externally")
|
|
||||||
);
|
|
||||||
fs::write(fixture.0.join("file"), file.result.as_ref().unwrap()).unwrap();
|
|
||||||
fixture.jj(&["new", "base"]);
|
|
||||||
let before = fs::read(fixture.0.join("file")).unwrap();
|
|
||||||
assert!(
|
|
||||||
save(
|
|
||||||
&workspace,
|
|
||||||
0,
|
|
||||||
Resolution::Text("wrong checkout\n".into()),
|
|
||||||
&|| false
|
|
||||||
)
|
|
||||||
.unwrap_err()
|
|
||||||
.contains("checkout changed")
|
|
||||||
);
|
|
||||||
assert_eq!(fs::read(fixture.0.join("file")).unwrap(), before);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn text_choice_is_confirmed_by_jj() {
|
|
||||||
let fixture = Fixture::new();
|
|
||||||
fixture.conflict();
|
|
||||||
let mut workspace = load(&fixture.0, &|| false).unwrap();
|
|
||||||
let cancelled_after_write =
|
|
||||||
|| fs::read(fixture.0.join("file")).is_ok_and(|bytes| bytes == b"draft\n");
|
|
||||||
let saved = save(
|
|
||||||
&workspace,
|
|
||||||
0,
|
|
||||||
Resolution::Text("draft\n".into()),
|
|
||||||
&cancelled_after_write,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(saved.file.status, Status::Unconfirmed);
|
|
||||||
assert!(saved.confirmation.is_some());
|
|
||||||
assert_eq!(fs::read(fixture.0.join("file")).unwrap(), b"draft\n");
|
|
||||||
assert_eq!(
|
|
||||||
entries(&fixture.0, &workspace.checkout.commit, &|| false).unwrap(),
|
|
||||||
[PathBuf::from("file")]
|
|
||||||
);
|
|
||||||
workspace.checkout = saved.checkout;
|
|
||||||
workspace.files[0] = saved.file;
|
|
||||||
let outcome = save(
|
|
||||||
&workspace,
|
|
||||||
0,
|
|
||||||
Resolution::Text("resolved\n".into()),
|
|
||||||
&|| false,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
outcome.file.status,
|
|
||||||
Status::Resolved,
|
|
||||||
"{:?}",
|
|
||||||
outcome.confirmation
|
|
||||||
);
|
|
||||||
assert_eq!(fs::read(fixture.0.join("file")).unwrap(), b"resolved\n");
|
|
||||||
assert!(
|
|
||||||
entries(&fixture.0, &outcome.checkout.commit, &|| false)
|
|
||||||
.unwrap()
|
|
||||||
.is_empty()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn all_three_sides_and_bases_are_loaded_and_directory_children_are_protected() {
|
|
||||||
let fixture = Fixture::new();
|
|
||||||
fixture.conflict();
|
|
||||||
fixture.jj(&["new", "base"]);
|
|
||||||
fs::write(fixture.0.join("file"), "third\n").unwrap();
|
|
||||||
fixture.jj(&["bookmark", "create", "third"]);
|
|
||||||
fixture.jj(&["new", "left", "right", "third"]);
|
|
||||||
let workspace = load(&fixture.0, &|| false).unwrap();
|
|
||||||
let file = &workspace.files[0];
|
|
||||||
assert_eq!(file.sources.iter().filter(|s| !s.base).count(), 3);
|
|
||||||
assert_eq!(file.sources.iter().filter(|s| s.base).count(), 2);
|
|
||||||
assert!(file.sources.iter().any(|s| s.bytes == b"third\n"));
|
|
||||||
let blocks = regions(file.result.as_ref().unwrap());
|
|
||||||
for source in &file.sources {
|
|
||||||
assert_eq!(blocks[0].choose(source).unwrap().as_bytes(), source.bytes);
|
|
||||||
}
|
|
||||||
fs::remove_file(fixture.0.join("file")).unwrap();
|
|
||||||
fs::create_dir(fixture.0.join("file")).unwrap();
|
|
||||||
fs::write(fixture.0.join("file/untracked"), b"keep my draft").unwrap();
|
|
||||||
let workspace = load(&fixture.0, &|| false).unwrap();
|
|
||||||
assert!(
|
|
||||||
save(&workspace, 0, Resolution::Source(0), &|| false)
|
|
||||||
.unwrap_err()
|
|
||||||
.contains("untracked or modified children")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
fs::read(fixture.0.join("file/untracked")).unwrap(),
|
|
||||||
b"keep my draft"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn binary_symlink_and_deleted_file_choices_preserve_types_and_bytes() {
|
|
||||||
let fixture = Fixture::new();
|
|
||||||
fixture.conflict();
|
|
||||||
fixture.jj(&["edit", "left"]);
|
|
||||||
fs::write(fixture.0.join("binary"), b"left\0\xff").unwrap();
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
fs::set_permissions(fixture.0.join("binary"), fs::Permissions::from_mode(0o755))
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
std::os::unix::fs::symlink("left-target", fixture.0.join("link")).unwrap();
|
|
||||||
fixture.jj(&["new", "right"]);
|
|
||||||
fs::write(fixture.0.join("binary"), b"right\0\xfe").unwrap();
|
|
||||||
std::os::unix::fs::symlink("right-target", fixture.0.join("link")).unwrap();
|
|
||||||
fixture.jj(&["bookmark", "create", "right-extra"]);
|
|
||||||
fixture.jj(&["new", "left", "right-extra"]);
|
|
||||||
let mut workspace = load(&fixture.0, &|| false).unwrap();
|
|
||||||
for (name, kind, bytes) in [
|
|
||||||
("binary", FileKind::Binary, b"left\0\xff".as_slice()),
|
|
||||||
("link", FileKind::Symlink, b"left-target".as_slice()),
|
|
||||||
] {
|
|
||||||
let file = workspace
|
|
||||||
.files
|
|
||||||
.iter()
|
|
||||||
.position(|f| f.path == Path::new(name))
|
|
||||||
.unwrap();
|
|
||||||
assert!(workspace.files[file].result.is_none());
|
|
||||||
let source = workspace.files[file]
|
|
||||||
.sources
|
|
||||||
.iter()
|
|
||||||
.position(|s| !s.base && s.kind == kind && s.bytes == bytes)
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
save(
|
|
||||||
&workspace,
|
|
||||||
file,
|
|
||||||
Resolution::Text("not allowed".into()),
|
|
||||||
&|| false
|
|
||||||
)
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
let outcome = save(&workspace, file, Resolution::Source(source), &|| false).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
outcome.file.status,
|
|
||||||
Status::Resolved,
|
|
||||||
"{:?}",
|
|
||||||
outcome.confirmation
|
|
||||||
);
|
|
||||||
workspace.checkout = outcome.checkout;
|
|
||||||
workspace.files[file] = outcome.file;
|
|
||||||
if kind == FileKind::Symlink {
|
|
||||||
assert_eq!(
|
|
||||||
fs::read_link(fixture.0.join(name)).unwrap(),
|
|
||||||
Path::new("left-target")
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
assert_eq!(fs::read(fixture.0.join(name)).unwrap(), bytes);
|
|
||||||
}
|
|
||||||
if kind == FileKind::Binary {
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
assert_eq!(
|
|
||||||
fs::metadata(fixture.0.join(name))
|
|
||||||
.unwrap()
|
|
||||||
.permissions()
|
|
||||||
.mode()
|
|
||||||
& 0o111,
|
|
||||||
0o111
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// add/add conflicts retain an absent base as an explicit deletion choice.
|
|
||||||
let file = workspace
|
|
||||||
.files
|
|
||||||
.iter()
|
|
||||||
.position(|f| f.path == Path::new("binary"))
|
|
||||||
.unwrap();
|
|
||||||
let source = workspace.files[file]
|
|
||||||
.sources
|
|
||||||
.iter()
|
|
||||||
.position(|s| s.kind == FileKind::Deleted)
|
|
||||||
.unwrap();
|
|
||||||
let outcome = save(&workspace, file, Resolution::Source(source), &|| false).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
outcome.file.status,
|
|
||||||
Status::Resolved,
|
|
||||||
"{:?}",
|
|
||||||
outcome.confirmation
|
|
||||||
);
|
|
||||||
assert!(!fixture.0.join("binary").exists());
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn marker_styles_widths_multisides_and_missing_eol() {
|
|
||||||
let snapshot = "before\n<<<<<<<<<<< conflict 1 of 1\n+++++++++++ a\nleft\n----------- base\nold\n+++++++++++ b\nright\n----------- base2\nold2\n+++++++++++ c\nthird\n>>>>>>>>>>> conflict 1 of 1 ends\nafter\n";
|
|
||||||
let parsed = regions(snapshot);
|
|
||||||
assert_eq!(parsed[0].lines, 1..13);
|
|
||||||
assert_eq!(parsed[0].sides, ["left\n", "right\n", "third\n"]);
|
|
||||||
assert_eq!(parsed[0].bases, ["old\n", "old2\n"]);
|
|
||||||
let prefixed = format!("<<<<<<< literal marker\n{snapshot}");
|
|
||||||
assert_eq!(regions(&prefixed).len(), 1);
|
|
||||||
assert_eq!(regions(&prefixed)[0].lines, 2..14);
|
|
||||||
let diff = "<<<<<<< conflict 1 of 1\n%%%%%%% diff from: base\n^^^^^^^ to: left\n-old\n+left\n+++++++ right\nright\n>>>>>>> conflict 1 of 1 ends\n";
|
|
||||||
assert_eq!(regions(diff)[0].sides, ["left\n", "right\n"]);
|
|
||||||
let git = "<<<<<<< left\nleft\n||||||| base\nold\n=======\nright\n>>>>>>> right";
|
|
||||||
assert_eq!(regions(git)[0].sides, ["left", "right"]);
|
|
||||||
assert!(regions("<<<<<<< unfinished\nleft\n").is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod projection_tests {
|
#[path = "../tests/unit/resolve_projection.rs"]
|
||||||
use super::*;
|
mod projection_tests;
|
||||||
|
|
||||||
fn test_sources(sides: usize) -> Vec<Source> {
|
|
||||||
(0..sides * 2 - 1)
|
|
||||||
.map(|index| Source {
|
|
||||||
label: format!("source {index}"),
|
|
||||||
base: index % 2 == 1,
|
|
||||||
kind: FileKind::Text,
|
|
||||||
bytes: b"historical full file; must never replace working text\n".to_vec(),
|
|
||||||
tree_id: String::new(),
|
|
||||||
term: index / 2,
|
|
||||||
sides,
|
|
||||||
bases: sides - 1,
|
|
||||||
node: Node::Absent,
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn snapshot(left: &str, base: &str, right: &str) -> String {
|
|
||||||
format!(
|
|
||||||
"<<<<<<< conflict\n+++++++ side 1\n{left}------- base\n{base}+++++++ side 2\n{right}>>>>>>> conflict ends\n"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn current_working_projections_keep_partial_resolutions_and_multiple_regions() {
|
|
||||||
let sources = test_sources(2);
|
|
||||||
let raw = format!(
|
|
||||||
"unsaved heading\nalready manually resolved\n{}auto-merged middle\n<<<<<<< conflict\n%%%%%%% Changes from base to side 1\n\\\\\\\\\\\\\\ continuation\n-old two\n+left two\n+++++++ side 2\nright two\n>>>>>>> conflict ends\nunsaved tail",
|
|
||||||
snapshot("left one\n", "old one\n", "right one\n")
|
|
||||||
);
|
|
||||||
for (index, first, second) in [
|
|
||||||
(0, "left one", "left two"),
|
|
||||||
(1, "old one", "old two"),
|
|
||||||
(2, "right one", "right two"),
|
|
||||||
] {
|
|
||||||
assert_eq!(
|
|
||||||
project(&raw, &sources[index]).unwrap(),
|
|
||||||
format!(
|
|
||||||
"unsaved heading\nalready manually resolved\n{first}\nauto-merged middle\n{second}\nunsaved tail"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn alternatives_are_frozen_from_the_same_working_text_as_the_seed() {
|
|
||||||
let sources = test_sources(2);
|
|
||||||
let raw = format!(
|
|
||||||
"unsaved heading\n{}auto-merged middle\n{}unsaved tail",
|
|
||||||
snapshot("left-one\n", "old-one\n", "right-one\n"),
|
|
||||||
snapshot("left-two\n", "old-two\n", "right-two\n")
|
|
||||||
);
|
|
||||||
let projection = Projection::new(&raw, &sources, 0).unwrap();
|
|
||||||
let mut draft = projection.seed().to_owned();
|
|
||||||
draft = draft.replace("unsaved heading", "later draft edit");
|
|
||||||
draft.push_str("\nnew draft ending\n");
|
|
||||||
assert_eq!(
|
|
||||||
projection.alternative(2).unwrap(),
|
|
||||||
"unsaved heading\nright-one\nauto-merged middle\nright-two\nunsaved tail"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
projection.alternative(1).unwrap(),
|
|
||||||
"unsaved heading\nold-one\nauto-merged middle\nold-two\nunsaved tail"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
projection.seed(),
|
|
||||||
"unsaved heading\nleft-one\nauto-merged middle\nleft-two\nunsaved tail"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
draft,
|
|
||||||
"later draft edit\nleft-one\nauto-merged middle\nleft-two\nunsaved tail\nnew draft ending\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn all_multiway_sides_and_bases_project_the_same_current_context() {
|
|
||||||
let sources = test_sources(3);
|
|
||||||
let raw = "unsaved α\n<<<<<<<<<<< conflict\n+++++++++++ side 1\none\n----------- base 1\nold one\n+++++++++++ side 2\ntwo\n----------- base 2\nold two\n+++++++++++ side 3\nthree\n>>>>>>>>>>> conflict ends\nunsaved ω";
|
|
||||||
let projection = Projection::new(raw, &sources, 0).unwrap();
|
|
||||||
for (index, choice) in ["one", "old one", "two", "old two", "three"]
|
|
||||||
.into_iter()
|
|
||||||
.enumerate()
|
|
||||||
{
|
|
||||||
let expected = format!("unsaved α\n{choice}\nunsaved ω");
|
|
||||||
assert_eq!(project(raw, &sources[index]).unwrap(), expected);
|
|
||||||
assert_eq!(projection.alternative(index).unwrap(), expected);
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
Projection::new(raw, &sources, 4).unwrap().seed(),
|
|
||||||
"unsaved α\nthree\nunsaved ω"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn projections_preserve_mixed_eof_and_crlf_terms() {
|
|
||||||
let sources = test_sources(2);
|
|
||||||
for eol in ["\n", "\r\n"] {
|
|
||||||
let choices = [
|
|
||||||
format!("left{eol}"),
|
|
||||||
"old".to_owned(),
|
|
||||||
format!("right{eol}{eol}"),
|
|
||||||
];
|
|
||||||
// jj appends one separator EOL to *each* term when any lacks an EOL,
|
|
||||||
// and omits the EOL on the final marker. Existing term EOLs remain.
|
|
||||||
let raw = format!(
|
|
||||||
"heading{eol}<<<<<<< conflict{eol}+++++++ side 1{eol}{}{eol}------- base{eol}{}{eol}+++++++ side 2{eol}{}{eol}>>>>>>> conflict ends",
|
|
||||||
choices[0], choices[1], choices[2]
|
|
||||||
);
|
|
||||||
let projection = Projection::new(&raw, &sources, 0).unwrap();
|
|
||||||
for (index, choice) in choices.iter().enumerate() {
|
|
||||||
assert_eq!(
|
|
||||||
project(&raw, &sources[index]).unwrap(),
|
|
||||||
format!("heading{eol}{choice}")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
projection.alternative(index).unwrap(),
|
|
||||||
format!("heading{eol}{choice}")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_choices_markerfree_text_and_invalid_choices_are_handled() {
|
|
||||||
let mut sources = test_sources(2);
|
|
||||||
sources[1].kind = FileKind::Deleted;
|
|
||||||
let raw = format!("before\n{}after\n", snapshot("", "", "new\n"));
|
|
||||||
let projection = Projection::new(&raw, &sources, 0).unwrap();
|
|
||||||
assert_eq!(projection.seed(), "before\nafter\n");
|
|
||||||
assert_eq!(projection.alternative(1).unwrap(), "before\nafter\n");
|
|
||||||
assert_eq!(projection.alternative(2).unwrap(), "before\nnew\nafter\n");
|
|
||||||
let resolved = Projection::new("manually resolved, no final EOL", &sources, 0).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
resolved.alternative(2).unwrap(),
|
|
||||||
"manually resolved, no final EOL"
|
|
||||||
);
|
|
||||||
assert!(resolved.alternative(sources.len()).is_err());
|
|
||||||
assert!(Projection::new(&raw, &sources, sources.len()).is_err());
|
|
||||||
assert!(Projection::new(&raw, &test_sources(3), 0).is_err());
|
|
||||||
sources[0].kind = FileKind::Binary;
|
|
||||||
assert!(project(&raw, &sources[0]).is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-520
@@ -579,523 +579,5 @@ fn check_file(path: &Path, expected: Option<&[u8]>) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "../tests/unit/review.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
struct Directory(PathBuf);
|
|
||||||
impl Drop for Directory {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = fs::remove_dir_all(&self.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn directory() -> Directory {
|
|
||||||
let directory = Directory(std::env::temp_dir().join(format!(
|
|
||||||
"vamp-review-test-{}-{}",
|
|
||||||
std::process::id(),
|
|
||||||
NEXT_TEMP.fetch_add(1, Ordering::Relaxed),
|
|
||||||
)));
|
|
||||||
fs::create_dir(&directory.0).unwrap();
|
|
||||||
directory
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn database_restarts_preserve_identity_and_exact_stale_anchors() {
|
|
||||||
let directory = directory();
|
|
||||||
let root = directory.0.canonicalize().unwrap();
|
|
||||||
let storage = root.join("storage");
|
|
||||||
let mut review = Review::new(root.clone());
|
|
||||||
review.summary = "Unicode review\nMore context".into();
|
|
||||||
review.verdict = Some(Verdict::ChangesRequested);
|
|
||||||
review.comments.push(Comment {
|
|
||||||
id: 7,
|
|
||||||
anchor: Anchor {
|
|
||||||
revision: "working:old-snapshot".into(),
|
|
||||||
path: "日本語.rs".into(),
|
|
||||||
side: Side::Before,
|
|
||||||
start: crate::plugin::TextPosition { row: 0, column: 1 },
|
|
||||||
end: crate::plugin::TextPosition { row: 1, column: 1 },
|
|
||||||
content: "a👩🔬e\u{301}\r\n界x\n".into(),
|
|
||||||
},
|
|
||||||
body: "Please preserve 👩🔬".into(),
|
|
||||||
resolved: true,
|
|
||||||
});
|
|
||||||
{
|
|
||||||
let mut store = Store::open(&storage).unwrap();
|
|
||||||
store.save(&mut review).unwrap();
|
|
||||||
assert!(review.storage_id.is_some());
|
|
||||||
assert_eq!(review.storage_revision, 1);
|
|
||||||
}
|
|
||||||
let mut store = Store::open(&storage).unwrap();
|
|
||||||
let id = review.storage_id.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
store.list(&root).unwrap(),
|
|
||||||
vec![(id, "Unicode review".into())]
|
|
||||||
);
|
|
||||||
let mut loaded = store.load(&root, id).unwrap();
|
|
||||||
assert_eq!(loaded.storage_id, Some(id));
|
|
||||||
assert_eq!(loaded.storage_revision, 1);
|
|
||||||
assert_eq!(loaded.comments, review.comments);
|
|
||||||
assert_eq!(loaded.verdict, review.verdict);
|
|
||||||
assert!(!loaded.comments[0].anchor.matches(
|
|
||||||
"working:new-snapshot",
|
|
||||||
Path::new("日本語.rs"),
|
|
||||||
Side::Before,
|
|
||||||
"changed\n",
|
|
||||||
));
|
|
||||||
let path = root.join("export.json");
|
|
||||||
loaded.save(&path).unwrap();
|
|
||||||
let exported = fs::read(&path).unwrap();
|
|
||||||
let json: serde_json::Value = serde_json::from_slice(&exported).unwrap();
|
|
||||||
assert_eq!(json["comments"][0]["resolved"], true);
|
|
||||||
assert_eq!(json["version"], 3);
|
|
||||||
assert!(json.get("storage_id").is_none());
|
|
||||||
assert!(json.get("storage_revision").is_none());
|
|
||||||
loaded.summary = "Database only".into();
|
|
||||||
store.save(&mut loaded).unwrap();
|
|
||||||
assert_eq!(loaded.storage_id, Some(id));
|
|
||||||
assert_eq!(loaded.storage_revision, 2);
|
|
||||||
assert_eq!(fs::read(&path).unwrap(), exported);
|
|
||||||
let mut imported = Review::load(&path, &root).unwrap();
|
|
||||||
assert_eq!(imported.storage_id, None);
|
|
||||||
assert_eq!(imported.storage_revision, 0);
|
|
||||||
store.save(&mut imported).unwrap();
|
|
||||||
assert_ne!(imported.storage_id, Some(id));
|
|
||||||
assert_eq!(store.list(&root).unwrap().len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn database_repository_identity_is_canonical_and_independent() {
|
|
||||||
let first = directory();
|
|
||||||
let second = directory();
|
|
||||||
let first_root = first.0.canonicalize().unwrap();
|
|
||||||
let second_root = second.0.canonicalize().unwrap();
|
|
||||||
let mut store = Store::open(&first_root.join("storage")).unwrap();
|
|
||||||
let mut a = Review::new(first_root.clone());
|
|
||||||
let mut b = Review::new(second_root.clone());
|
|
||||||
store.save(&mut a).unwrap();
|
|
||||||
store.save(&mut b).unwrap();
|
|
||||||
assert_ne!(a.storage_id, b.storage_id);
|
|
||||||
assert_eq!(store.list(&first_root).unwrap().len(), 1);
|
|
||||||
assert_eq!(store.list(&second_root).unwrap().len(), 1);
|
|
||||||
assert!(store.load(&second_root, a.storage_id.unwrap()).is_err());
|
|
||||||
let id = a.storage_id;
|
|
||||||
let revision = a.storage_revision;
|
|
||||||
a.root = second_root;
|
|
||||||
assert!(store.save(&mut a).is_err());
|
|
||||||
assert_eq!((a.storage_id, a.storage_revision), (id, revision));
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
let alias = first_root.join("alias");
|
|
||||||
std::os::unix::fs::symlink(&first_root, &alias).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
store.list(&alias).unwrap(),
|
|
||||||
store.list(&first_root).unwrap()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn database_conflicts_and_locked_writes_keep_drafts_and_identity() {
|
|
||||||
let directory = directory();
|
|
||||||
let root = directory.0.canonicalize().unwrap();
|
|
||||||
let storage = root.join("storage");
|
|
||||||
let mut first = Store::open(&storage).unwrap();
|
|
||||||
let mut second = Store::open(&storage).unwrap();
|
|
||||||
let mut review = Review::new(root.clone());
|
|
||||||
first.save(&mut review).unwrap();
|
|
||||||
let id = review.storage_id.unwrap();
|
|
||||||
let mut draft = second.load(&root, id).unwrap();
|
|
||||||
review.summary = "First writer".into();
|
|
||||||
first.save(&mut review).unwrap();
|
|
||||||
draft.summary = "Second writer draft".into();
|
|
||||||
assert!(
|
|
||||||
second
|
|
||||||
.save(&mut draft)
|
|
||||||
.unwrap_err()
|
|
||||||
.contains("another session")
|
|
||||||
);
|
|
||||||
assert_eq!(draft.summary, "Second writer draft");
|
|
||||||
assert_eq!((draft.storage_id, draft.storage_revision), (Some(id), 1));
|
|
||||||
assert_eq!(second.load(&root, id).unwrap().summary, "First writer");
|
|
||||||
first.connection.execute_batch("BEGIN IMMEDIATE").unwrap();
|
|
||||||
let mut fresh = Review::new(root.clone());
|
|
||||||
assert!(second.save(&mut fresh).unwrap_err().contains("locked"));
|
|
||||||
assert_eq!((fresh.storage_id, fresh.storage_revision), (None, 0));
|
|
||||||
first.connection.execute_batch("ROLLBACK").unwrap();
|
|
||||||
second.save(&mut fresh).unwrap();
|
|
||||||
assert!(fresh.storage_id.is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn incompatible_database_schemas_are_preserved() {
|
|
||||||
for schema in [
|
|
||||||
"CREATE TABLE other (value TEXT); PRAGMA user_version = 42",
|
|
||||||
"CREATE TABLE other (value TEXT); PRAGMA user_version = 1",
|
|
||||||
"",
|
|
||||||
] {
|
|
||||||
let directory = directory();
|
|
||||||
let path = directory.0.join("reviews.sqlite3");
|
|
||||||
let connection = Connection::open(&path).unwrap();
|
|
||||||
connection.execute_batch(schema).unwrap();
|
|
||||||
drop(connection);
|
|
||||||
let original = fs::read(&path).unwrap();
|
|
||||||
assert!(Store::open(&directory.0).err().unwrap().contains("schema"));
|
|
||||||
assert_eq!(fs::read(&path).unwrap(), original);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn malformed_or_mismatched_database_documents_are_rejected() {
|
|
||||||
let other = directory();
|
|
||||||
let directory = directory();
|
|
||||||
let root = directory.0.canonicalize().unwrap();
|
|
||||||
let mut store = Store::open(&root.join("storage")).unwrap();
|
|
||||||
let mut review = Review::new(root.clone());
|
|
||||||
store.save(&mut review).unwrap();
|
|
||||||
let id = review.storage_id.unwrap();
|
|
||||||
let mut wrong_root = serde_json::to_value(&review).unwrap();
|
|
||||||
wrong_root["root"] = serde_json::json!(other.0.canonicalize().unwrap());
|
|
||||||
let mut invalid_version = serde_json::to_value(&review).unwrap();
|
|
||||||
invalid_version["version"] = 1.into();
|
|
||||||
let mut inherited_identity = serde_json::to_value(&review).unwrap();
|
|
||||||
inherited_identity["storage_id"] = id.into();
|
|
||||||
for document in [
|
|
||||||
"{".to_owned(),
|
|
||||||
serde_json::to_string(&wrong_root).unwrap(),
|
|
||||||
serde_json::to_string(&invalid_version).unwrap(),
|
|
||||||
serde_json::to_string(&inherited_identity).unwrap(),
|
|
||||||
] {
|
|
||||||
store
|
|
||||||
.connection
|
|
||||||
.execute(
|
|
||||||
"UPDATE reviews SET document = ?1 WHERE id = ?2",
|
|
||||||
params![document, id],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(store.load(&root, id).is_err());
|
|
||||||
assert!(store.list(&root).is_err());
|
|
||||||
}
|
|
||||||
store
|
|
||||||
.connection
|
|
||||||
.execute_batch("PRAGMA ignore_check_constraints = ON")
|
|
||||||
.unwrap();
|
|
||||||
store
|
|
||||||
.connection
|
|
||||||
.execute(
|
|
||||||
"UPDATE reviews SET document = CAST(zeroblob(?1) AS TEXT) WHERE id = ?2",
|
|
||||||
params![LIMIT as i64 + 1, id],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(store.load(&root, id).unwrap_err().contains("64 MiB"));
|
|
||||||
assert!(store.list(&root).unwrap_err().contains("64 MiB"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn failed_database_and_export_writes_preserve_metadata_and_documents() {
|
|
||||||
let directory = directory();
|
|
||||||
let root = directory.0.canonicalize().unwrap();
|
|
||||||
let mut store = Store::open(&root.join("storage")).unwrap();
|
|
||||||
let mut review = Review::new(root.clone());
|
|
||||||
store.save(&mut review).unwrap();
|
|
||||||
let identity = (review.storage_id, review.storage_revision);
|
|
||||||
store
|
|
||||||
.connection
|
|
||||||
.execute_batch("PRAGMA query_only = ON")
|
|
||||||
.unwrap();
|
|
||||||
review.summary = "Unsaved draft".into();
|
|
||||||
assert!(store.save(&mut review).is_err());
|
|
||||||
assert_eq!((review.storage_id, review.storage_revision), identity);
|
|
||||||
assert_eq!(review.summary, "Unsaved draft");
|
|
||||||
assert_eq!(store.load(&root, identity.0.unwrap()).unwrap().summary, "");
|
|
||||||
let mut fresh = Review::new(root.clone());
|
|
||||||
assert!(store.save(&mut fresh).is_err());
|
|
||||||
assert_eq!((fresh.storage_id, fresh.storage_revision), (None, 0));
|
|
||||||
store
|
|
||||||
.connection
|
|
||||||
.execute_batch("PRAGMA query_only = OFF")
|
|
||||||
.unwrap();
|
|
||||||
review.summary = "x".repeat(LIMIT);
|
|
||||||
assert!(store.save(&mut review).unwrap_err().contains("64 MiB"));
|
|
||||||
assert!(
|
|
||||||
review
|
|
||||||
.save(&root.join("oversized.json"))
|
|
||||||
.unwrap_err()
|
|
||||||
.contains("64 MiB")
|
|
||||||
);
|
|
||||||
assert!(!root.join("oversized.json").exists());
|
|
||||||
assert_eq!((review.storage_id, review.storage_revision), identity);
|
|
||||||
assert_eq!(store.load(&root, identity.0.unwrap()).unwrap().summary, "");
|
|
||||||
let blocked = root.join("not-a-directory");
|
|
||||||
fs::write(&blocked, b"preserve").unwrap();
|
|
||||||
assert!(Store::open(&blocked).is_err());
|
|
||||||
assert_eq!(fs::read(&blocked).unwrap(), b"preserve");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
|
||||||
fn database_files_are_private_and_target_symlinks_are_refused() {
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
let directory = directory();
|
|
||||||
let storage = directory.0.join("storage");
|
|
||||||
drop(Store::open(&storage).unwrap());
|
|
||||||
let path = storage.join("reviews.sqlite3");
|
|
||||||
assert_eq!(
|
|
||||||
fs::metadata(&storage).unwrap().permissions().mode() & 0o777,
|
|
||||||
0o700
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
|
||||||
0o600
|
|
||||||
);
|
|
||||||
let original = fs::read(&path).unwrap();
|
|
||||||
let links = directory.0.join("links");
|
|
||||||
fs::create_dir(&links).unwrap();
|
|
||||||
std::os::unix::fs::symlink(&path, links.join("reviews.sqlite3")).unwrap();
|
|
||||||
assert!(Store::open(&links).err().unwrap().contains("symbolic link"));
|
|
||||||
assert_eq!(fs::read(&path).unwrap(), original);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_directory_uses_absolute_xdg_then_absolute_home() {
|
|
||||||
let resolve = |xdg: Option<&str>, home: Option<&str>| {
|
|
||||||
directory_from(xdg.map(OsStr::new), home.map(OsStr::new))
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
resolve(Some("/data"), Some("/home/me")).unwrap(),
|
|
||||||
Path::new("/data/vamp/reviews")
|
|
||||||
);
|
|
||||||
for xdg in [None, Some("relative"), Some("")] {
|
|
||||||
assert_eq!(
|
|
||||||
resolve(xdg, Some("/home/me")).unwrap(),
|
|
||||||
Path::new("/home/me/.local/share/vamp/reviews")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
resolve(Some("/data"), None).unwrap(),
|
|
||||||
Path::new("/data/vamp/reviews")
|
|
||||||
);
|
|
||||||
assert!(resolve(None, None).is_err());
|
|
||||||
assert!(resolve(None, Some("relative")).is_err());
|
|
||||||
assert!(resolve(None, Some("")).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn persistence_validates_anchors_and_preserves_files_on_failure() {
|
|
||||||
let directory = directory();
|
|
||||||
let root = directory.0.canonicalize().unwrap();
|
|
||||||
let path = root.join("review.json");
|
|
||||||
let mut review = Review::new(root.clone());
|
|
||||||
review.summary = "Local review".into();
|
|
||||||
review.verdict = Some(Verdict::ChangesRequested);
|
|
||||||
review.comments.push(Comment {
|
|
||||||
id: 1,
|
|
||||||
anchor: Anchor {
|
|
||||||
revision: "working:immutable".into(),
|
|
||||||
path: "source.rs".into(),
|
|
||||||
side: Side::After,
|
|
||||||
start: crate::plugin::TextPosition { row: 0, column: 0 },
|
|
||||||
end: crate::plugin::TextPosition { row: 2, column: 0 },
|
|
||||||
content: "first\nlast\n".into(),
|
|
||||||
},
|
|
||||||
body: "Please adjust this".into(),
|
|
||||||
resolved: false,
|
|
||||||
});
|
|
||||||
review.save(&path).unwrap();
|
|
||||||
let mut loaded = Review::load(&path, &root).unwrap();
|
|
||||||
assert_eq!(loaded.comments, review.comments);
|
|
||||||
assert_eq!(loaded.verdict, review.verdict);
|
|
||||||
let anchor = &loaded.comments[0].anchor;
|
|
||||||
assert!(anchor.matches(
|
|
||||||
"working:immutable",
|
|
||||||
Path::new("source.rs"),
|
|
||||||
Side::After,
|
|
||||||
"first\nlast\n"
|
|
||||||
));
|
|
||||||
assert!(!anchor.matches(
|
|
||||||
"working:other",
|
|
||||||
Path::new("source.rs"),
|
|
||||||
Side::After,
|
|
||||||
"first\nlast\n"
|
|
||||||
));
|
|
||||||
assert!(anchor.matches_lines(
|
|
||||||
"working:immutable",
|
|
||||||
Path::new("source.rs"),
|
|
||||||
Side::After,
|
|
||||||
&["first".into(), "last".into()],
|
|
||||||
true
|
|
||||||
));
|
|
||||||
assert!(!anchor.matches_lines(
|
|
||||||
"working:immutable",
|
|
||||||
Path::new("source.rs"),
|
|
||||||
Side::After,
|
|
||||||
&["first".into(), "last".into()],
|
|
||||||
false
|
|
||||||
));
|
|
||||||
loaded.summary = "Updated".into();
|
|
||||||
loaded.save(&path).unwrap();
|
|
||||||
assert_eq!(Review::load(&path, &root).unwrap().summary, "Updated");
|
|
||||||
assert!(Review::new(root.clone()).save(&path).is_err());
|
|
||||||
let valid = fs::read(&path).unwrap();
|
|
||||||
for (field, value) in [
|
|
||||||
("version", serde_json::json!(2)),
|
|
||||||
("root", serde_json::json!(root.join("other"))),
|
|
||||||
(
|
|
||||||
"comments",
|
|
||||||
serde_json::json!([loaded.comments[0], loaded.comments[0]]),
|
|
||||||
),
|
|
||||||
] {
|
|
||||||
let mut invalid = serde_json::to_value(&loaded).unwrap();
|
|
||||||
invalid[field] = value;
|
|
||||||
fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
|
|
||||||
assert!(Review::load(&path, &root).is_err());
|
|
||||||
}
|
|
||||||
for (field, value) in [
|
|
||||||
("path", serde_json::json!("../outside")),
|
|
||||||
("path", serde_json::json!("/absolute")),
|
|
||||||
("path", serde_json::json!(".git/config")),
|
|
||||||
("start", serde_json::json!({"row": 2, "column": 1})),
|
|
||||||
("start", serde_json::json!({"row": 3, "column": 0})),
|
|
||||||
("end", serde_json::json!({"row": 3, "column": 0})),
|
|
||||||
("revision", serde_json::json!("")),
|
|
||||||
] {
|
|
||||||
let mut invalid = serde_json::to_value(&loaded).unwrap();
|
|
||||||
invalid["comments"][0]["anchor"][field] = value;
|
|
||||||
fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
|
|
||||||
assert!(Review::load(&path, &root).is_err());
|
|
||||||
}
|
|
||||||
fs::create_dir(root.join("other")).unwrap();
|
|
||||||
fs::write(&path, &valid).unwrap();
|
|
||||||
assert!(Review::load(&path, &root.join("other")).is_err());
|
|
||||||
fs::write(&path, b"external edit").unwrap();
|
|
||||||
assert!(loaded.save(&path).is_err());
|
|
||||||
assert_eq!(fs::read(&path).unwrap(), b"external edit");
|
|
||||||
assert_eq!(loaded.summary, "Updated");
|
|
||||||
let invalid = root.join("invalid.json");
|
|
||||||
loaded.comments[0].anchor.start.row = usize::MAX;
|
|
||||||
assert!(loaded.save(&invalid).is_err());
|
|
||||||
assert!(!invalid.exists());
|
|
||||||
let large = root.join("large.json");
|
|
||||||
fs::File::create(&large)
|
|
||||||
.unwrap()
|
|
||||||
.set_len(LIMIT as u64 + 1)
|
|
||||||
.unwrap();
|
|
||||||
assert!(Review::load(&large, &root).is_err());
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
let link = root.join("link.json");
|
|
||||||
std::os::unix::fs::symlink(&path, &link).unwrap();
|
|
||||||
assert!(Review::load(&link, &root).is_err());
|
|
||||||
assert!(review.save(&link).is_err());
|
|
||||||
assert_eq!(fs::read(&path).unwrap(), b"external edit");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stored_version_two_is_rejected_without_rewriting_it() {
|
|
||||||
let directory = directory();
|
|
||||||
let root = directory.0.canonicalize().unwrap();
|
|
||||||
let mut store = Store::open(&root.join("storage")).unwrap();
|
|
||||||
let mut review = Review::new(root.clone());
|
|
||||||
store.save(&mut review).unwrap();
|
|
||||||
let mut old = serde_json::to_value(&review).unwrap();
|
|
||||||
old["version"] = 2.into();
|
|
||||||
let document = serde_json::to_string(&old).unwrap();
|
|
||||||
store
|
|
||||||
.connection
|
|
||||||
.execute(
|
|
||||||
"UPDATE reviews SET document = ?1 WHERE id = ?2",
|
|
||||||
params![document, review.storage_id.unwrap()],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
store
|
|
||||||
.load(&root, review.storage_id.unwrap())
|
|
||||||
.unwrap_err()
|
|
||||||
.contains("Unsupported review version 2")
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
store
|
|
||||||
.list(&root)
|
|
||||||
.unwrap_err()
|
|
||||||
.contains("Unsupported review version 2")
|
|
||||||
);
|
|
||||||
let preserved: String = store
|
|
||||||
.connection
|
|
||||||
.query_row(
|
|
||||||
"SELECT document FROM reviews WHERE id = ?1",
|
|
||||||
[review.storage_id.unwrap()],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(preserved, document);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn exact_grapheme_ranges_roundtrip_and_reject_invalid_coordinates() {
|
|
||||||
use crate::plugin::TextPosition;
|
|
||||||
let directory = directory();
|
|
||||||
let root = directory.0.canonicalize().unwrap();
|
|
||||||
let path = root.join("review.json");
|
|
||||||
let mut review = Review::new(root.clone());
|
|
||||||
review.comments.push(Comment {
|
|
||||||
id: 1,
|
|
||||||
anchor: Anchor {
|
|
||||||
revision: "working:immutable".into(),
|
|
||||||
path: "source.rs".into(),
|
|
||||||
side: Side::After,
|
|
||||||
start: TextPosition { row: 0, column: 1 },
|
|
||||||
end: TextPosition { row: 1, column: 1 },
|
|
||||||
content: "a👩🔬e\u{301}\n界x\n".into(),
|
|
||||||
},
|
|
||||||
body: "Exact text".into(),
|
|
||||||
resolved: false,
|
|
||||||
});
|
|
||||||
review.save(&path).unwrap();
|
|
||||||
let mut loaded = Review::load(&path, &root).unwrap();
|
|
||||||
assert_eq!(loaded.comments, review.comments);
|
|
||||||
assert_eq!(loaded.comments[0].anchor.rows(), 0..=1);
|
|
||||||
let valid = serde_json::to_value(&review).unwrap();
|
|
||||||
let mut missing_resolution = valid.clone();
|
|
||||||
missing_resolution["comments"][0]
|
|
||||||
.as_object_mut()
|
|
||||||
.unwrap()
|
|
||||||
.remove("resolved");
|
|
||||||
for version in [2, 3] {
|
|
||||||
missing_resolution["version"] = version.into();
|
|
||||||
let bytes = serde_json::to_vec(&missing_resolution).unwrap();
|
|
||||||
fs::write(&path, &bytes).unwrap();
|
|
||||||
let error = Review::load(&path, &root).unwrap_err();
|
|
||||||
assert!(error.contains(if version == 2 {
|
|
||||||
"Unsupported review version 2"
|
|
||||||
} else {
|
|
||||||
"missing field `resolved`"
|
|
||||||
}));
|
|
||||||
assert_eq!(fs::read(&path).unwrap(), bytes);
|
|
||||||
}
|
|
||||||
for (field, value) in [
|
|
||||||
("start", serde_json::json!({"row": 0, "column": 4})),
|
|
||||||
("end", serde_json::json!({"row": 1, "column": 3})),
|
|
||||||
("end", serde_json::json!({"row": 2, "column": 1})),
|
|
||||||
("end", serde_json::json!({"row": 3, "column": 0})),
|
|
||||||
("end", serde_json::json!({"row": 0, "column": 0})),
|
|
||||||
("start", serde_json::json!({"row": -1, "column": 0})),
|
|
||||||
(
|
|
||||||
"start",
|
|
||||||
serde_json::json!({"row": 0, "column": 0, "extra": 0}),
|
|
||||||
),
|
|
||||||
("start_line", serde_json::json!(1)),
|
|
||||||
] {
|
|
||||||
let mut invalid = valid.clone();
|
|
||||||
invalid["comments"][0]["anchor"][field] = value;
|
|
||||||
fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
|
|
||||||
assert!(Review::load(&path, &root).is_err(), "{field}");
|
|
||||||
}
|
|
||||||
let mut wrong_version = valid;
|
|
||||||
wrong_version["version"] = 1.into();
|
|
||||||
fs::write(&path, serde_json::to_vec(&wrong_version).unwrap()).unwrap();
|
|
||||||
assert!(Review::load(&path, &root).is_err());
|
|
||||||
loaded.comments[0].anchor.end = TextPosition { row: 0, column: 0 };
|
|
||||||
assert!(loaded.save(&root.join("backwards.json")).is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-110
@@ -194,113 +194,5 @@ fn print_review(review: &Value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "../tests/unit/review_cli.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
use vamp::{
|
|
||||||
plugin::TextPosition,
|
|
||||||
review::{Comment, Side},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn output_filters_resolution_and_extracts_exact_graphemes() {
|
|
||||||
let mut review = Review::new(PathBuf::new());
|
|
||||||
review.storage_id = Some(7);
|
|
||||||
review.storage_revision = 2;
|
|
||||||
review.comments = [false, true]
|
|
||||||
.into_iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(id, resolved)| Comment {
|
|
||||||
id: id as u64 + 1,
|
|
||||||
body: "fix".into(),
|
|
||||||
resolved,
|
|
||||||
anchor: Anchor {
|
|
||||||
revision: "commit".into(),
|
|
||||||
path: "a.rs".into(),
|
|
||||||
side: Side::After,
|
|
||||||
start: TextPosition { row: 0, column: 1 },
|
|
||||||
end: TextPosition { row: 1, column: 1 },
|
|
||||||
content: "xe\u{301}👩💻\n界z\n".into(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let output = envelope(&review, false, true);
|
|
||||||
assert_eq!(output["comments"].as_array().unwrap().len(), 1);
|
|
||||||
assert_eq!(output["comments"][0]["excerpt"], "e\u{301}👩💻\n界");
|
|
||||||
assert!(output["comments"][0]["anchor"].get("content").is_none());
|
|
||||||
assert_eq!(output["counts"]["resolved"], 1);
|
|
||||||
assert_eq!(
|
|
||||||
envelope(&review, true, true)["comments"]
|
|
||||||
.as_array()
|
|
||||||
.unwrap()
|
|
||||||
.len(),
|
|
||||||
2
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn absent_storage_is_not_created_and_incompatible_storage_is_preserved() {
|
|
||||||
let directory =
|
|
||||||
std::env::temp_dir().join(format!("vamp-review-cli-{}", std::process::id()));
|
|
||||||
std::fs::create_dir(&directory).unwrap();
|
|
||||||
let absent = directory.join("absent");
|
|
||||||
assert!(Store::open_read_only(&absent).unwrap().is_none());
|
|
||||||
assert!(!absent.exists());
|
|
||||||
let path = directory.join("reviews.sqlite3");
|
|
||||||
std::fs::write(&path, b"incompatible").unwrap();
|
|
||||||
assert!(Store::open_read_only(&directory).is_err());
|
|
||||||
assert_eq!(std::fs::read(&path).unwrap(), b"incompatible");
|
|
||||||
std::fs::remove_file(&path).unwrap();
|
|
||||||
let connection = rusqlite::Connection::open(&path).unwrap();
|
|
||||||
connection
|
|
||||||
.execute_batch("CREATE TABLE incompatible (value TEXT); PRAGMA user_version = 42")
|
|
||||||
.unwrap();
|
|
||||||
drop(connection);
|
|
||||||
let before = std::fs::read(&path).unwrap();
|
|
||||||
assert!(
|
|
||||||
Store::open_read_only(&directory)
|
|
||||||
.err()
|
|
||||||
.unwrap()
|
|
||||||
.contains("schema")
|
|
||||||
);
|
|
||||||
assert_eq!(std::fs::read(&path).unwrap(), before);
|
|
||||||
std::fs::remove_dir_all(directory).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn existing_storage_is_read_only_and_unchanged() {
|
|
||||||
let directory =
|
|
||||||
std::env::temp_dir().join(format!("vamp-review-cli-existing-{}", std::process::id()));
|
|
||||||
let mut store = Store::open(&directory).unwrap();
|
|
||||||
let mut review = Review::new(directory.clone());
|
|
||||||
review.summary = "stored".into();
|
|
||||||
store.save(&mut review).unwrap();
|
|
||||||
drop(store);
|
|
||||||
let path = directory.join("reviews.sqlite3");
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap();
|
|
||||||
}
|
|
||||||
let before = std::fs::read(&path).unwrap();
|
|
||||||
let mut store = Store::open_read_only(&directory).unwrap().unwrap();
|
|
||||||
let mut loaded = store.load(&directory, review.storage_id.unwrap()).unwrap();
|
|
||||||
assert_eq!(loaded.summary, "stored");
|
|
||||||
loaded.summary = "changed".into();
|
|
||||||
assert!(store.save(&mut loaded).is_err());
|
|
||||||
drop(store);
|
|
||||||
assert_eq!(std::fs::read(&path).unwrap(), before);
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
assert_eq!(
|
|
||||||
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
|
||||||
0o640
|
|
||||||
);
|
|
||||||
let links = directory.join("links");
|
|
||||||
std::fs::create_dir(&links).unwrap();
|
|
||||||
std::os::unix::fs::symlink(&path, links.join("reviews.sqlite3")).unwrap();
|
|
||||||
assert!(Store::open_read_only(&links).is_err());
|
|
||||||
}
|
|
||||||
std::fs::remove_dir_all(directory).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-2299
File diff suppressed because it is too large
Load Diff
+2
-3655
File diff suppressed because it is too large
Load Diff
+2
-121
@@ -362,124 +362,5 @@ impl Render<'_> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
// These fixtures intentionally contain ranges, not lists of indices.
|
// These fixtures intentionally contain ranges, not lists of indices.
|
||||||
#[allow(clippy::single_range_in_vec_init)]
|
#[allow(clippy::single_range_in_vec_init)]
|
||||||
mod tests {
|
#[path = "../tests/unit/snippet.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn grammar_expands_nested_mirrors_choices_variables_and_normalized_offsets() {
|
|
||||||
let (text, snippet) = expand(
|
|
||||||
"call($1, ${1:λ${2:x}}, ${3|a\\,b,c|}, ${UNKNOWN:${TM_LINE_NUMBER}})\r\n$0\\$",
|
|
||||||
|name| (name == "TM_LINE_NUMBER").then(|| "4".to_owned()),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(text, "call(λx, λx, a,b, 4)\n$");
|
|
||||||
assert_eq!(
|
|
||||||
snippet.tabstops,
|
|
||||||
vec![
|
|
||||||
SnippetTabstop {
|
|
||||||
index: 1,
|
|
||||||
ranges: vec![5..8, 10..13],
|
|
||||||
ancestors: vec![vec![], vec![]]
|
|
||||||
},
|
|
||||||
SnippetTabstop {
|
|
||||||
index: 2,
|
|
||||||
ranges: vec![7..8, 12..13],
|
|
||||||
ancestors: vec![vec![1], vec![1]]
|
|
||||||
},
|
|
||||||
SnippetTabstop {
|
|
||||||
index: 3,
|
|
||||||
ranges: vec![15..18],
|
|
||||||
ancestors: vec![vec![]]
|
|
||||||
},
|
|
||||||
SnippetTabstop {
|
|
||||||
index: 0,
|
|
||||||
ranges: vec![23..23],
|
|
||||||
ancestors: vec![vec![]]
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
let (text, snippet) = expand("call($1, ${2})", |_| None).unwrap();
|
|
||||||
assert_eq!(text, "call(, )");
|
|
||||||
assert_eq!(
|
|
||||||
snippet.tabstops,
|
|
||||||
vec![
|
|
||||||
SnippetTabstop {
|
|
||||||
index: 1,
|
|
||||||
ranges: vec![5..5],
|
|
||||||
ancestors: vec![vec![]]
|
|
||||||
},
|
|
||||||
SnippetTabstop {
|
|
||||||
index: 2,
|
|
||||||
ranges: vec![7..7],
|
|
||||||
ancestors: vec![vec![]]
|
|
||||||
},
|
|
||||||
SnippetTabstop {
|
|
||||||
index: 0,
|
|
||||||
ranges: vec![8..8],
|
|
||||||
ancestors: vec![vec![]]
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
expand("${KNOWN:${1:hidden}}$1", |name| {
|
|
||||||
(name == "KNOWN").then(|| "shown".to_owned())
|
|
||||||
})
|
|
||||||
.unwrap()
|
|
||||||
.0,
|
|
||||||
"shown"
|
|
||||||
);
|
|
||||||
let (text, snippet) = expand("call(${CTX})$1", |name| {
|
|
||||||
(name == "CTX").then(|| "a\r\nb\rc".to_owned())
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(text, "call(a\nb\nc)");
|
|
||||||
assert_eq!(snippet.tabstops[0].ranges, [11..11]);
|
|
||||||
assert_eq!(
|
|
||||||
expand("${1|a\\|b,c|}\\}\\\\", |_| None).unwrap().0,
|
|
||||||
"a|b}\\"
|
|
||||||
);
|
|
||||||
for input in ["${1", "${1:$1}", "${1:a}${1:b}$1", "${1:e}\u{301}"] {
|
|
||||||
if input == "${1:a}${1:b}$1" {
|
|
||||||
assert_eq!(expand(input, |_| None).unwrap().0, "aaa");
|
|
||||||
} else {
|
|
||||||
assert_eq!(expand(input, |_| None), Err(Error::Invalid));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let (text, snippet) = expand("${1}$1", |_| None).unwrap();
|
|
||||||
assert!(text.is_empty());
|
|
||||||
assert_eq!(
|
|
||||||
snippet.tabstops[0],
|
|
||||||
SnippetTabstop {
|
|
||||||
index: 1,
|
|
||||||
ranges: vec![0..0, 0..0],
|
|
||||||
ancestors: vec![vec![], vec![]],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
for input in [
|
|
||||||
"$UNKNOWN",
|
|
||||||
"${TM_FILENAME/(.*)/$1/}",
|
|
||||||
"${1/a/b/}",
|
|
||||||
"${1:a$0b}",
|
|
||||||
"$0$0",
|
|
||||||
"${0:arg}",
|
|
||||||
"${0:$1}",
|
|
||||||
] {
|
|
||||||
assert_eq!(expand(input, |_| None), Err(Error::Unsupported));
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
expand(&"$1".repeat(MAX_STOPS + 1), |_| None),
|
|
||||||
Err(Error::Limit)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
expand(
|
|
||||||
&format!(
|
|
||||||
"{}x{}",
|
|
||||||
"${1:".repeat(MAX_DEPTH + 1),
|
|
||||||
"}".repeat(MAX_DEPTH + 1)
|
|
||||||
),
|
|
||||||
|_| None
|
|
||||||
),
|
|
||||||
Err(Error::Limit)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-1130
File diff suppressed because it is too large
Load Diff
+2
-75
@@ -407,78 +407,5 @@ fn detect_shebang(line: &str) -> Option<&'static LanguageSpec> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "../../tests/unit/syntax_languages.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn selected_files_and_auxiliary_grammars_are_registered() {
|
|
||||||
for (filename, language) in [
|
|
||||||
("main.rs", "Rust"),
|
|
||||||
("Gemfile", "Ruby"),
|
|
||||||
("main.py", "Python"),
|
|
||||||
("package.json", "JSON"),
|
|
||||||
(".bashrc", "Bash"),
|
|
||||||
("GNUmakefile", "Makefile"),
|
|
||||||
("README.md", "Markdown"),
|
|
||||||
("main.jsx", "JavaScript"),
|
|
||||||
("main.ts", "TypeScript"),
|
|
||||||
("index.html", "HTML"),
|
|
||||||
("style.css", "CSS"),
|
|
||||||
("config.yml", "YAML"),
|
|
||||||
("Cargo.toml", "TOML"),
|
|
||||||
("Cargo.lock", "TOML"),
|
|
||||||
("main.c", "C"),
|
|
||||||
("main.cpp", "C++"),
|
|
||||||
("main.C", "C++"),
|
|
||||||
("main.go", "Go"),
|
|
||||||
("main.zig", "Zig"),
|
|
||||||
] {
|
|
||||||
assert_eq!(
|
|
||||||
detect(Some(Path::new(filename)), None).map(|spec| spec.name),
|
|
||||||
Some(language)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let ts = detect(Some(Path::new("main.ts")), None).unwrap();
|
|
||||||
let tsx = detect(Some(Path::new("main.tsx")), None).unwrap();
|
|
||||||
assert_eq!(ts.name, tsx.name);
|
|
||||||
assert!(!std::ptr::eq(ts, tsx));
|
|
||||||
assert_ne!(ts.language, tsx.language);
|
|
||||||
assert!(std::ptr::eq(tsx, by_name("TSX").unwrap()));
|
|
||||||
assert!(by_name("markdown_inline").is_some());
|
|
||||||
for filename in [
|
|
||||||
"notes.txt",
|
|
||||||
"main.hs",
|
|
||||||
"main.scala",
|
|
||||||
"main.lua",
|
|
||||||
"Dockerfile",
|
|
||||||
] {
|
|
||||||
assert!(detect(Some(Path::new(filename)), None).is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shebangs_support_bom_env_options_and_filename_precedence() {
|
|
||||||
for (line, language) in [
|
|
||||||
("#!/usr/bin/python3", "Python"),
|
|
||||||
("\u{feff}#!/usr/bin/env -S python3.12 -u", "Python"),
|
|
||||||
("#!/usr/bin/env -u PYTHONHOME FOO=bar python3", "Python"),
|
|
||||||
("#!/usr/bin/ruby3.3", "Ruby"),
|
|
||||||
("#!/bin/bash", "Bash"),
|
|
||||||
("#! /usr/bin/env node", "JavaScript"),
|
|
||||||
] {
|
|
||||||
assert_eq!(
|
|
||||||
detect(None, Some(line)).map(|spec| spec.name),
|
|
||||||
Some(language)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
detect(Some(Path::new("main.rs")), Some("#!/bin/bash"))
|
|
||||||
.unwrap()
|
|
||||||
.name,
|
|
||||||
"Rust"
|
|
||||||
);
|
|
||||||
assert!(detect(None, Some("#!/usr/bin/python-not-real")).is_none());
|
|
||||||
assert!(detect(None, Some("#!/usr/bin/perl")).is_none());
|
|
||||||
assert!(detect(None, Some("plain text")).is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-275
@@ -295,278 +295,5 @@ fn hex_value(component: &[u8]) -> Option<u32> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "../tests/unit/terminal_colors.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
use crossterm::event::{KeyEvent, MouseEvent, MouseEventKind};
|
|
||||||
|
|
||||||
fn key(code: KeyCode, modifiers: KeyModifiers) -> Event {
|
|
||||||
Event::Key(KeyEvent::new(code, modifiers))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bytes(collector: &mut TerminalColors, text: &str, now: Instant) {
|
|
||||||
for ch in text.chars() {
|
|
||||||
let modifiers = if ch.is_ascii_uppercase() {
|
|
||||||
KeyModifiers::SHIFT
|
|
||||||
} else {
|
|
||||||
KeyModifiers::NONE
|
|
||||||
};
|
|
||||||
collector.push(key(KeyCode::Char(ch), modifiers), now);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn report(collector: &mut TerminalColors, body: &str, now: Instant) {
|
|
||||||
collector.push(key(KeyCode::Char(']'), KeyModifiers::ALT), now);
|
|
||||||
bytes(collector, body, now);
|
|
||||||
collector.push(key(KeyCode::Char('g'), KeyModifiers::CONTROL), now);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn drain(collector: &mut TerminalColors) -> Vec<Event> {
|
|
||||||
std::iter::from_fn(|| collector.pop()).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fragmented_triple_accepts_bel_and_both_st_encodings() {
|
|
||||||
let now = Instant::now();
|
|
||||||
let mut collector = TerminalColors::default();
|
|
||||||
collector.query(&mut Vec::new(), now).unwrap();
|
|
||||||
collector.push(key(KeyCode::Esc, KeyModifiers::NONE), now);
|
|
||||||
collector.push(
|
|
||||||
key(KeyCode::Char(']'), KeyModifiers::NONE),
|
|
||||||
now + Duration::from_millis(1),
|
|
||||||
);
|
|
||||||
bytes(
|
|
||||||
&mut collector,
|
|
||||||
"11;rgb:0/12/345",
|
|
||||||
now + Duration::from_millis(2),
|
|
||||||
);
|
|
||||||
collector.push(
|
|
||||||
key(KeyCode::Esc, KeyModifiers::NONE),
|
|
||||||
now + Duration::from_millis(3),
|
|
||||||
);
|
|
||||||
collector.push(
|
|
||||||
key(KeyCode::Char('\\'), KeyModifiers::NONE),
|
|
||||||
now + Duration::from_millis(4),
|
|
||||||
);
|
|
||||||
assert_eq!(collector.colors, None);
|
|
||||||
report(
|
|
||||||
&mut collector,
|
|
||||||
"4;2;rgb:f/ffff/ABCD",
|
|
||||||
now + Duration::from_millis(5),
|
|
||||||
);
|
|
||||||
collector.push(
|
|
||||||
key(KeyCode::Char(']'), KeyModifiers::ALT),
|
|
||||||
now + Duration::from_millis(6),
|
|
||||||
);
|
|
||||||
bytes(
|
|
||||||
&mut collector,
|
|
||||||
"4;1;#FF1234",
|
|
||||||
now + Duration::from_millis(7),
|
|
||||||
);
|
|
||||||
collector.push(
|
|
||||||
key(KeyCode::Char('\\'), KeyModifiers::ALT),
|
|
||||||
now + Duration::from_millis(8),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
collector.colors,
|
|
||||||
Some(ThemeColors {
|
|
||||||
background: [0, 18, 52],
|
|
||||||
red: [255, 18, 52],
|
|
||||||
green: [255, 255, 171],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert!(drain(&mut collector).is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn focus_refresh_atomically_replaces_light_and_dark_triples() {
|
|
||||||
let now = Instant::now();
|
|
||||||
let mut collector = TerminalColors::default();
|
|
||||||
let palettes = [
|
|
||||||
("11;#FDF6E3", [253, 246, 227]),
|
|
||||||
("11;rgb:0000/0000/0000", [0, 0, 0]),
|
|
||||||
];
|
|
||||||
for (cycle, (background, expected)) in palettes.into_iter().enumerate() {
|
|
||||||
let time = now + Duration::from_secs(cycle as u64);
|
|
||||||
let focus = Event::FocusGained;
|
|
||||||
collector.push(focus.clone(), time);
|
|
||||||
assert_eq!(collector.pop(), Some(focus));
|
|
||||||
let previous = collector.colors;
|
|
||||||
collector.query(&mut Vec::new(), time).unwrap();
|
|
||||||
report(&mut collector, "4;1;rgb:ff/00/00", time);
|
|
||||||
report(&mut collector, background, time);
|
|
||||||
assert_eq!(collector.colors, previous);
|
|
||||||
report(&mut collector, "4;2;rgb:00/ff/00", time);
|
|
||||||
assert_eq!(
|
|
||||||
collector.colors,
|
|
||||||
Some(ThemeColors {
|
|
||||||
background: expected,
|
|
||||||
red: [255, 0, 0],
|
|
||||||
green: [0, 255, 0],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert!(drain(&mut collector).is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn periodic_refresh_detects_silent_change_without_overlapping_queries() {
|
|
||||||
let now = Instant::now();
|
|
||||||
let mut collector = TerminalColors::default();
|
|
||||||
let mut output = Vec::new();
|
|
||||||
assert!(collector.refresh_due(now));
|
|
||||||
collector.query(&mut output, now).unwrap();
|
|
||||||
report(&mut collector, "11;#101010", now);
|
|
||||||
report(&mut collector, "4;1;#FF0000", now);
|
|
||||||
report(&mut collector, "4;2;#00FF00", now);
|
|
||||||
let previous = collector.colors;
|
|
||||||
assert!(previous.is_some());
|
|
||||||
|
|
||||||
// The terminal changes its palette without emitting a notification.
|
|
||||||
let refresh = now + Duration::from_secs(1);
|
|
||||||
assert!(!collector.refresh_due(refresh - Duration::from_nanos(1)));
|
|
||||||
assert!(collector.refresh_due(refresh));
|
|
||||||
collector.query(&mut output, refresh).unwrap();
|
|
||||||
report(&mut collector, "11;#EEEEEE", refresh);
|
|
||||||
report(&mut collector, "4;1;#AA1122", refresh);
|
|
||||||
assert_eq!(collector.colors, previous);
|
|
||||||
let typed = key(KeyCode::Char('i'), KeyModifiers::NONE);
|
|
||||||
collector.push(typed.clone(), refresh);
|
|
||||||
assert_eq!(drain(&mut collector), vec![typed]);
|
|
||||||
|
|
||||||
// Even an overdue probe must wait until the incomplete query expires.
|
|
||||||
let retry = refresh + Duration::from_secs(1);
|
|
||||||
assert!(!collector.refresh_due(retry));
|
|
||||||
collector.expire(retry);
|
|
||||||
assert_eq!(collector.colors, None);
|
|
||||||
assert!(collector.refresh_due(retry));
|
|
||||||
collector.query(&mut output, retry).unwrap();
|
|
||||||
report(&mut collector, "11;#EEEEEE", retry);
|
|
||||||
report(&mut collector, "4;1;#AA1122", retry);
|
|
||||||
assert_eq!(collector.colors, None);
|
|
||||||
report(&mut collector, "4;2;#22BB33", retry);
|
|
||||||
assert_eq!(
|
|
||||||
collector.colors,
|
|
||||||
Some(ThemeColors {
|
|
||||||
background: [238, 238, 238],
|
|
||||||
red: [170, 17, 34],
|
|
||||||
green: [34, 187, 51],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert!(drain(&mut collector).is_empty());
|
|
||||||
assert!(!collector.refresh_due(retry + Duration::from_millis(999)));
|
|
||||||
assert!(collector.refresh_due(retry + Duration::from_secs(1)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn failed_refresh_clears_stale_colors_and_consumes_late_partial_reply() {
|
|
||||||
let now = Instant::now();
|
|
||||||
let mut collector = TerminalColors::default();
|
|
||||||
collector.query(&mut Vec::new(), now).unwrap();
|
|
||||||
report(&mut collector, "11;#101010", now);
|
|
||||||
report(&mut collector, "4;1;#FF0000", now);
|
|
||||||
report(&mut collector, "4;2;#00FF00", now);
|
|
||||||
assert!(collector.colors.is_some());
|
|
||||||
let refresh = now + Duration::from_secs(1);
|
|
||||||
collector.query(&mut Vec::new(), refresh).unwrap();
|
|
||||||
report(&mut collector, "11;#EEEEEE", refresh);
|
|
||||||
collector.push(key(KeyCode::Char(']'), KeyModifiers::ALT), refresh);
|
|
||||||
bytes(&mut collector, "4;1;rgb:ffff/", refresh);
|
|
||||||
collector.expire(refresh + QUERY_TIMEOUT);
|
|
||||||
assert_eq!(collector.colors, None);
|
|
||||||
let late = refresh + Duration::from_secs(1);
|
|
||||||
bytes(&mut collector, "0000/0000", late);
|
|
||||||
collector.push(key(KeyCode::Esc, KeyModifiers::NONE), late);
|
|
||||||
collector.expire(late + Duration::from_secs(1));
|
|
||||||
collector.push(
|
|
||||||
key(KeyCode::Char('\\'), KeyModifiers::NONE),
|
|
||||||
late + Duration::from_secs(1),
|
|
||||||
);
|
|
||||||
report(&mut collector, "4;2;#00FF00", late + Duration::from_secs(1));
|
|
||||||
assert_eq!(collector.colors, None);
|
|
||||||
assert!(drain(&mut collector).is_empty());
|
|
||||||
let typed = key(KeyCode::Char('i'), KeyModifiers::NONE);
|
|
||||||
collector.push(typed.clone(), late + Duration::from_secs(2));
|
|
||||||
assert_eq!(collector.pop(), Some(typed));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unrelated_events_and_rejected_candidates_replay_exactly() {
|
|
||||||
let now = Instant::now();
|
|
||||||
let mut collector = TerminalColors::default();
|
|
||||||
collector.query(&mut Vec::new(), now).unwrap();
|
|
||||||
let events = vec![
|
|
||||||
key(KeyCode::Char(']'), KeyModifiers::ALT),
|
|
||||||
key(KeyCode::Char('4'), KeyModifiers::NONE),
|
|
||||||
key(KeyCode::Char(';'), KeyModifiers::NONE),
|
|
||||||
key(KeyCode::Char('3'), KeyModifiers::NONE),
|
|
||||||
key(KeyCode::Char('g'), KeyModifiers::CONTROL),
|
|
||||||
key(KeyCode::Char(']'), KeyModifiers::ALT | KeyModifiers::SHIFT),
|
|
||||||
Event::Key(KeyEvent::new_with_kind(
|
|
||||||
KeyCode::Char(']'),
|
|
||||||
KeyModifiers::ALT,
|
|
||||||
KeyEventKind::Release,
|
|
||||||
)),
|
|
||||||
Event::Key(KeyEvent::new_with_kind(
|
|
||||||
KeyCode::Char('A'),
|
|
||||||
KeyModifiers::SHIFT,
|
|
||||||
KeyEventKind::Repeat,
|
|
||||||
)),
|
|
||||||
Event::Key(KeyEvent {
|
|
||||||
code: KeyCode::Char('a'),
|
|
||||||
modifiers: KeyModifiers::CONTROL,
|
|
||||||
kind: KeyEventKind::Press,
|
|
||||||
state: KeyEventState::CAPS_LOCK,
|
|
||||||
}),
|
|
||||||
Event::Mouse(MouseEvent {
|
|
||||||
kind: MouseEventKind::Moved,
|
|
||||||
column: 2,
|
|
||||||
row: 3,
|
|
||||||
modifiers: KeyModifiers::CONTROL,
|
|
||||||
}),
|
|
||||||
Event::FocusLost,
|
|
||||||
Event::Resize(80, 24),
|
|
||||||
key(KeyCode::Char(']'), KeyModifiers::ALT),
|
|
||||||
Event::Paste("\x1b]11;rgb:ff/ff/ff\x07".into()),
|
|
||||||
Event::FocusGained,
|
|
||||||
];
|
|
||||||
for event in &events {
|
|
||||||
collector.push(event.clone(), now);
|
|
||||||
}
|
|
||||||
assert_eq!(drain(&mut collector), events);
|
|
||||||
assert_eq!(collector.colors, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn malformed_or_oversized_reports_and_lone_escape_do_not_eat_input() {
|
|
||||||
let now = Instant::now();
|
|
||||||
let mut collector = TerminalColors::default();
|
|
||||||
for body in [
|
|
||||||
"11;rgb:12345/0/0",
|
|
||||||
"11;rgb:1//3",
|
|
||||||
"11;#12345",
|
|
||||||
"11;rgb:1/2/3/4",
|
|
||||||
] {
|
|
||||||
let mut expected = vec![key(KeyCode::Char(']'), KeyModifiers::ALT)];
|
|
||||||
expected.extend(
|
|
||||||
body.chars()
|
|
||||||
.map(|ch| key(KeyCode::Char(ch), KeyModifiers::NONE)),
|
|
||||||
);
|
|
||||||
expected.push(key(KeyCode::Char('g'), KeyModifiers::CONTROL));
|
|
||||||
for event in &expected {
|
|
||||||
collector.push(event.clone(), now);
|
|
||||||
}
|
|
||||||
assert_eq!(drain(&mut collector), expected);
|
|
||||||
}
|
|
||||||
let escape = key(KeyCode::Esc, KeyModifiers::NONE);
|
|
||||||
collector.push(escape.clone(), now);
|
|
||||||
assert_eq!(collector.pop(), None);
|
|
||||||
collector.expire(now + ESCAPE_TIMEOUT);
|
|
||||||
assert_eq!(collector.pop(), Some(escape));
|
|
||||||
assert_eq!(collector.pop(), None);
|
|
||||||
let start = key(KeyCode::Char(']'), KeyModifiers::ALT);
|
|
||||||
collector.push(start.clone(), now + Duration::from_secs(1));
|
|
||||||
collector.expire(now + Duration::from_secs(1) + QUERY_TIMEOUT);
|
|
||||||
assert_eq!(collector.pop(), Some(start));
|
|
||||||
assert_eq!(collector.pop(), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-15293
File diff suppressed because it is too large
Load Diff
+2
-135
@@ -92,138 +92,5 @@ pub(crate) fn caret_segment(segments: &[Segment], column: usize) -> usize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "../tests/unit/wrap.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
fn layout(text: &str, width: usize) -> Vec<Segment> {
|
|
||||||
let mut cache = WrapCache::default();
|
|
||||||
cache.prepare((1, 0), width);
|
|
||||||
cache.segments(0, text).to_vec()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn words_and_long_words_preserve_every_source_cell() {
|
|
||||||
assert_eq!(
|
|
||||||
layout("one two three", 8),
|
|
||||||
vec![Segment { start: 0, end: 8 }, Segment { start: 8, end: 13 },]
|
|
||||||
);
|
|
||||||
let segments = layout("abcdefgh", 3);
|
|
||||||
assert_eq!(
|
|
||||||
segments,
|
|
||||||
vec![
|
|
||||||
Segment { start: 0, end: 3 },
|
|
||||||
Segment { start: 3, end: 6 },
|
|
||||||
Segment { start: 6, end: 8 },
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert_eq!(caret_segment(&segments, 3), 1);
|
|
||||||
assert_eq!(caret_segment(&segments, 8), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn long_words_do_not_wrap_indentation_onto_an_empty_visual_row() {
|
|
||||||
for source in [
|
|
||||||
" abcdefghijk",
|
|
||||||
"\tabcdefghijk",
|
|
||||||
"head abcdefghijk",
|
|
||||||
] {
|
|
||||||
let expanded = source.replace('\t', " ");
|
|
||||||
let segments = layout(source, 8);
|
|
||||||
assert_eq!(segments.first().unwrap().start, 0);
|
|
||||||
assert_eq!(segments.last().unwrap().end, expanded.len());
|
|
||||||
assert!(segments.windows(2).all(|pair| pair[0].end == pair[1].start));
|
|
||||||
for segment in segments
|
|
||||||
.iter()
|
|
||||||
.filter(|segment| segment.start < segment.end)
|
|
||||||
{
|
|
||||||
assert!(
|
|
||||||
!expanded[segment.start..segment.end].trim().is_empty(),
|
|
||||||
"whitespace-only visual row {segment:?} in {source:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tabs_use_source_stops_and_unicode_graphemes_remain_intact() {
|
|
||||||
assert_eq!(
|
|
||||||
layout("ab\t界e\u{301} z", 4),
|
|
||||||
vec![
|
|
||||||
Segment { start: 0, end: 4 },
|
|
||||||
Segment { start: 4, end: 8 },
|
|
||||||
Segment { start: 8, end: 9 },
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
layout("界界界", 3),
|
|
||||||
vec![
|
|
||||||
Segment { start: 0, end: 2 },
|
|
||||||
Segment { start: 2, end: 4 },
|
|
||||||
Segment { start: 4, end: 6 },
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
layout(" \t ", 3),
|
|
||||||
vec![Segment { start: 0, end: 3 }, Segment { start: 3, end: 5 },]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn narrow_tabs_preserve_all_expanded_cells_at_original_stops() {
|
|
||||||
assert_eq!(
|
|
||||||
layout("\tX", 2),
|
|
||||||
[
|
|
||||||
Segment { start: 0, end: 2 },
|
|
||||||
Segment { start: 2, end: 4 },
|
|
||||||
Segment { start: 4, end: 5 },
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
layout("a\tX", 2),
|
|
||||||
[
|
|
||||||
Segment { start: 0, end: 1 },
|
|
||||||
Segment { start: 1, end: 3 },
|
|
||||||
Segment { start: 3, end: 5 },
|
|
||||||
Segment { start: 5, end: 5 },
|
|
||||||
]
|
|
||||||
);
|
|
||||||
for source in ["\t", "a\t", "ab\tX", "\t\tX"] {
|
|
||||||
let segments = layout(source, 1);
|
|
||||||
assert!(
|
|
||||||
segments
|
|
||||||
.iter()
|
|
||||||
.all(|segment| segment.end - segment.start <= 1)
|
|
||||||
);
|
|
||||||
assert!(segments.windows(2).all(|pair| pair[0].end == pair[1].start));
|
|
||||||
let end = source.graphemes(true).fold(0, |column, grapheme| {
|
|
||||||
column
|
|
||||||
+ if grapheme == "\t" {
|
|
||||||
TAB_WIDTH - column % TAB_WIDTH
|
|
||||||
} else {
|
|
||||||
1
|
|
||||||
}
|
|
||||||
});
|
|
||||||
assert_eq!(segments.last().unwrap(), &Segment { start: end, end });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn full_lines_have_an_insertion_cell_and_cache_invalidation_is_lazy() {
|
|
||||||
let mut cache = WrapCache::default();
|
|
||||||
cache.prepare((7, 2), 4);
|
|
||||||
assert_eq!(
|
|
||||||
cache.segments(90, "abcd"),
|
|
||||||
[Segment { start: 0, end: 4 }, Segment { start: 4, end: 4 },]
|
|
||||||
);
|
|
||||||
assert_eq!(caret_segment(cache.segments(90, "abcd"), 4), 1);
|
|
||||||
assert_eq!(cache.rows.len(), 1);
|
|
||||||
cache.prepare((7, 2), 4);
|
|
||||||
assert_eq!(cache.rows.len(), 1);
|
|
||||||
cache.prepare((7, 2), 8);
|
|
||||||
assert!(cache.rows.is_empty());
|
|
||||||
cache.segments(90, "abcd");
|
|
||||||
cache.prepare((7, 3), 8);
|
|
||||||
assert!(cache.rows.is_empty());
|
|
||||||
assert_eq!(layout("", 0), [Segment { start: 0, end: 0 }]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
use super::*;
|
||||||
|
use std::{
|
||||||
|
fs,
|
||||||
|
path::PathBuf,
|
||||||
|
sync::atomic::{AtomicBool, AtomicU64, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn percentages_reject_invalid_data_and_include_both_endpoints() {
|
||||||
|
assert_eq!(percentage(0, 100), Some(0));
|
||||||
|
assert_eq!(percentage(100, 100), Some(100));
|
||||||
|
assert_eq!(percentage(1, 3), Some(33));
|
||||||
|
assert_eq!(percentage(u64::MAX, u64::MAX), Some(100));
|
||||||
|
assert_eq!(percentage(0, 0), None);
|
||||||
|
assert_eq!(percentage(101, 100), None);
|
||||||
|
assert_eq!(windows_charge(0, 4), Some(0));
|
||||||
|
assert_eq!(windows_charge(100, 8), Some(100));
|
||||||
|
assert_eq!(windows_charge(101, 0), None);
|
||||||
|
assert_eq!(windows_charge(255, 0), None);
|
||||||
|
assert_eq!(windows_charge(50, 128), None);
|
||||||
|
assert_eq!(windows_charge(50, 255), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn worker(samples: Vec<Option<u8>>) -> (Battery, Receiver<()>, Arc<AtomicBool>) {
|
||||||
|
struct ReaderDropped(Arc<AtomicBool>);
|
||||||
|
impl Drop for ReaderDropped {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.store(true, Ordering::Release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let dropped = Arc::new(AtomicBool::new(false));
|
||||||
|
let reader = ReaderDropped(Arc::clone(&dropped));
|
||||||
|
let mut samples = samples.into_iter();
|
||||||
|
let (ready, published) = mpsc::channel();
|
||||||
|
let battery = Battery::start_with(
|
||||||
|
move || {
|
||||||
|
let _ = &reader;
|
||||||
|
samples
|
||||||
|
.next()
|
||||||
|
.expect("test provides every requested sample")
|
||||||
|
},
|
||||||
|
move |wake| {
|
||||||
|
ready.send(()).unwrap();
|
||||||
|
// Explicit channel messages replace the production clock entirely.
|
||||||
|
wake.recv().is_ok()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
(battery, published, dropped)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_reads_immediately_replaces_stale_samples_and_clears_invalid_data() {
|
||||||
|
let (mut battery, published, dropped) = worker(vec![Some(0), Some(100), None, Some(101)]);
|
||||||
|
published.recv().unwrap();
|
||||||
|
assert_eq!(battery.poll(), Some(Some(0)));
|
||||||
|
assert_eq!(battery.poll(), None);
|
||||||
|
battery.wake.as_ref().unwrap().send(()).unwrap();
|
||||||
|
published.recv().unwrap();
|
||||||
|
battery.wake.as_ref().unwrap().send(()).unwrap();
|
||||||
|
published.recv().unwrap();
|
||||||
|
assert_eq!(battery.poll(), Some(None));
|
||||||
|
assert_eq!(battery.poll(), None);
|
||||||
|
battery.wake.as_ref().unwrap().send(()).unwrap();
|
||||||
|
published.recv().unwrap();
|
||||||
|
assert_eq!(battery.poll(), Some(None));
|
||||||
|
drop(battery);
|
||||||
|
assert!(dropped.load(Ordering::Acquire));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dropping_interrupts_wait_and_joins_even_with_an_unread_sample() {
|
||||||
|
let (battery, published, dropped) = worker(vec![Some(50)]);
|
||||||
|
published.recv().unwrap();
|
||||||
|
drop(battery);
|
||||||
|
assert!(dropped.load(Ordering::Acquire));
|
||||||
|
}
|
||||||
|
|
||||||
|
static FIXTURE_ID: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
struct Fixture(PathBuf);
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
fn new() -> Self {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"vamp-battery-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
FIXTURE_ID.fetch_add(1, Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
fs::create_dir(&path).unwrap();
|
||||||
|
Self(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source(&self, name: &str, fields: &[(&str, &str)]) {
|
||||||
|
let path = self.0.join(name);
|
||||||
|
fs::create_dir_all(&path).unwrap();
|
||||||
|
for (key, value) in fields {
|
||||||
|
fs::write(path.join(key), value).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(&self) -> Option<u8> {
|
||||||
|
linux::read_charge(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Fixture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
fs::remove_dir_all(&self.0).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sysfs_reads_present_system_batteries_but_not_external_or_absent_sources() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
assert_eq!(fixture.read(), None);
|
||||||
|
fixture.source("AC", &[("type", "Mains"), ("capacity", "100")]);
|
||||||
|
fixture.source(
|
||||||
|
"mouse",
|
||||||
|
&[
|
||||||
|
("type", "Battery"),
|
||||||
|
("scope", "Device"),
|
||||||
|
("present", "1"),
|
||||||
|
("capacity", "100"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
fixture.source(
|
||||||
|
"absent",
|
||||||
|
&[("type", "Battery"), ("present", "0"), ("capacity", "100")],
|
||||||
|
);
|
||||||
|
assert_eq!(fixture.read(), None);
|
||||||
|
fixture.source(
|
||||||
|
"BAT0",
|
||||||
|
&[
|
||||||
|
("type", "Battery\n"),
|
||||||
|
("scope", "System\n"),
|
||||||
|
("present", "1\n"),
|
||||||
|
("capacity", "27\n"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(fixture.read(), Some(27));
|
||||||
|
fixture.source("BAT0", &[("capacity", "0")]);
|
||||||
|
assert_eq!(fixture.read(), Some(0));
|
||||||
|
fixture.source("BAT0", &[("capacity", "100")]);
|
||||||
|
assert_eq!(fixture.read(), Some(100));
|
||||||
|
for invalid in ["101", "-1", "255", "garbage", ""] {
|
||||||
|
fixture.source("BAT0", &[("capacity", invalid)]);
|
||||||
|
assert_eq!(fixture.read(), None);
|
||||||
|
}
|
||||||
|
fs::remove_file(fixture.0.join("BAT0/capacity")).unwrap();
|
||||||
|
assert_eq!(fixture.read(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sysfs_uses_matching_actual_energy_or_charge_totals_only() {
|
||||||
|
let energy = Fixture::new();
|
||||||
|
energy.source(
|
||||||
|
"BAT0",
|
||||||
|
&[
|
||||||
|
("type", "Battery"),
|
||||||
|
("present", "1"),
|
||||||
|
("energy_now", "27"),
|
||||||
|
("energy_full", "100"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(energy.read(), Some(27));
|
||||||
|
energy.source("BAT0", &[("energy_full", "0")]);
|
||||||
|
assert_eq!(energy.read(), None);
|
||||||
|
energy.source("BAT0", &[("energy_full", "26")]);
|
||||||
|
assert_eq!(energy.read(), None);
|
||||||
|
energy.source("BAT0", &[("energy_full", "garbage")]);
|
||||||
|
assert_eq!(energy.read(), None);
|
||||||
|
let charge = Fixture::new();
|
||||||
|
charge.source(
|
||||||
|
"BAT0",
|
||||||
|
&[
|
||||||
|
("type", "Battery"),
|
||||||
|
("present", "1"),
|
||||||
|
("charge_now", "15"),
|
||||||
|
("charge_full", "20"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(charge.read(), Some(75));
|
||||||
|
charge.source("BAT0", &[("energy_now", "5")]);
|
||||||
|
assert_eq!(charge.read(), None);
|
||||||
|
let design = Fixture::new();
|
||||||
|
design.source(
|
||||||
|
"BAT0",
|
||||||
|
&[
|
||||||
|
("type", "Battery"),
|
||||||
|
("present", "1"),
|
||||||
|
("energy_now", "20"),
|
||||||
|
("energy_full_design", "100"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(design.read(), None);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
unsafe extern "C" {
|
||||||
|
fn CFNumberCreate(allocator: Object, kind: isize, value: Object) -> Object;
|
||||||
|
}
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_values_are_type_checked_and_numbers_must_be_exact_and_nonnegative() {
|
||||||
|
assert!(Owned::new(std::ptr::null()).is_none());
|
||||||
|
unsafe {
|
||||||
|
assert_eq!(number(std::ptr::null()), None);
|
||||||
|
let text = Owned::string(c"27").unwrap();
|
||||||
|
assert_eq!(number(text.0), None);
|
||||||
|
for value in [-1_i64, 0, 100] {
|
||||||
|
let object = Owned::new(CFNumberCreate(
|
||||||
|
std::ptr::null(),
|
||||||
|
4,
|
||||||
|
(&value as *const i64).cast(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(number(object.0), u64::try_from(value).ok());
|
||||||
|
}
|
||||||
|
let fraction = 1.5_f64;
|
||||||
|
let object = Owned::new(CFNumberCreate(
|
||||||
|
std::ptr::null(),
|
||||||
|
6,
|
||||||
|
(&fraction as *const f64).cast(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(number(object.0), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn command(root: &Path, program: &str, args: &[&str]) -> String {
|
||||||
|
let mut command = Command::new(program);
|
||||||
|
if program == "jj" {
|
||||||
|
command.args([
|
||||||
|
"--config=user.name=\"Jj Author\"",
|
||||||
|
"--config=user.email=\"jj@example.test\"",
|
||||||
|
]);
|
||||||
|
command.args([
|
||||||
|
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||||
|
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
let output = command
|
||||||
|
.args(args)
|
||||||
|
.current_dir(root)
|
||||||
|
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||||
|
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
||||||
|
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
|
||||||
|
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
|
||||||
|
.env("JJ_CONFIG", "/dev/null")
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
String::from_utf8(output.stdout).unwrap().trim().into()
|
||||||
|
}
|
||||||
|
fn fixture() -> (Temporary, PathBuf, String) {
|
||||||
|
let temp = Temporary::new().unwrap();
|
||||||
|
command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
||||||
|
command(&temp.0, "git", &["config", "user.name", "Blame Author"]);
|
||||||
|
command(
|
||||||
|
&temp.0,
|
||||||
|
"git",
|
||||||
|
&["config", "user.email", "blame@example.test"],
|
||||||
|
);
|
||||||
|
let path = temp.0.join("-literal [file].txt");
|
||||||
|
fs::write(&path, "first\nsecond\n").unwrap();
|
||||||
|
command(&temp.0, "git", &["add", "--", "-literal [file].txt"]);
|
||||||
|
command(
|
||||||
|
&temp.0,
|
||||||
|
"git",
|
||||||
|
&["commit", "--quiet", "-m", "Original summary"],
|
||||||
|
);
|
||||||
|
let commit = command(&temp.0, "git", &["rev-parse", "HEAD"]);
|
||||||
|
(temp, path, commit)
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn committed_line_has_native_author_summary_and_commit() {
|
||||||
|
let (_temp, path, commit) = fixture();
|
||||||
|
let rows = read_file(&path, "first\nsecond\n", &|| false)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rows.len(), 2);
|
||||||
|
assert!(Arc::ptr_eq(
|
||||||
|
rows[0].as_ref().unwrap(),
|
||||||
|
rows[1].as_ref().unwrap()
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
**rows[1].as_ref().unwrap(),
|
||||||
|
LineBlame {
|
||||||
|
commit,
|
||||||
|
author: "Blame Author".into(),
|
||||||
|
summary: "Original summary".into(),
|
||||||
|
uncommitted: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn unsaved_changed_line_is_uncommitted_without_metadata() {
|
||||||
|
let (_temp, path, _) = fixture();
|
||||||
|
let rows = read_file(&path, "changed\nsecond\n", &|| false)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
**rows[0].as_ref().unwrap(),
|
||||||
|
LineBlame {
|
||||||
|
commit: String::new(),
|
||||||
|
author: String::new(),
|
||||||
|
summary: String::new(),
|
||||||
|
uncommitted: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(fs::read_to_string(path).unwrap(), "first\nsecond\n");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn inserted_line_preserves_following_original_attribution() {
|
||||||
|
let (_temp, path, commit) = fixture();
|
||||||
|
let contents = "inserted\nfirst\nsecond\n";
|
||||||
|
let rows = read_file(&path, contents, &|| false).unwrap().unwrap();
|
||||||
|
assert!(rows[0].as_ref().unwrap().uncommitted);
|
||||||
|
assert_eq!(rows[2].as_ref().unwrap().commit, commit);
|
||||||
|
assert_eq!(rows.len(), 3);
|
||||||
|
assert_eq!(read_file(&path, "", &|| false).unwrap(), None);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn unsaved_insertions_and_deletions_keep_shifted_commit_attribution() {
|
||||||
|
let (temp, path, original) = fixture();
|
||||||
|
fs::write(&path, "first\nmiddle\nsecond\ntail\n").unwrap();
|
||||||
|
command(&temp.0, "git", &["add", "--", "-literal [file].txt"]);
|
||||||
|
command(&temp.0, "git", &["commit", "--quiet", "-m", "Added lines"]);
|
||||||
|
let added = command(&temp.0, "git", &["rev-parse", "HEAD"]);
|
||||||
|
let rows = read_file(
|
||||||
|
&path,
|
||||||
|
"unsaved\nfirst\nsecond\nunsaved tail\ntail\n",
|
||||||
|
&|| false,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(rows[0].as_ref().unwrap().uncommitted);
|
||||||
|
assert_eq!(rows[1].as_ref().unwrap().commit, original);
|
||||||
|
assert_eq!(rows[2].as_ref().unwrap().commit, original);
|
||||||
|
assert!(Arc::ptr_eq(
|
||||||
|
rows[1].as_ref().unwrap(),
|
||||||
|
rows[2].as_ref().unwrap()
|
||||||
|
));
|
||||||
|
assert!(rows[3].as_ref().unwrap().uncommitted);
|
||||||
|
assert!(Arc::ptr_eq(
|
||||||
|
rows[0].as_ref().unwrap(),
|
||||||
|
rows[3].as_ref().unwrap()
|
||||||
|
));
|
||||||
|
assert_eq!(rows[4].as_ref().unwrap().commit, added);
|
||||||
|
assert_eq!(rows[4].as_ref().unwrap().summary, "Added lines");
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(path).unwrap(),
|
||||||
|
"first\nmiddle\nsecond\ntail\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn missing_untracked_unborn_nonrepo_and_symlink_have_no_blame() {
|
||||||
|
let temp = Temporary::new().unwrap();
|
||||||
|
let path = temp.0.join("file");
|
||||||
|
assert_eq!(read_file(&path, "text", &|| false).unwrap(), None);
|
||||||
|
fs::write(&path, "text").unwrap();
|
||||||
|
assert_eq!(read_file(&path, "text", &|| false).unwrap(), None);
|
||||||
|
command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
||||||
|
assert_eq!(read_file(&path, "text", &|| false).unwrap(), None);
|
||||||
|
let (_committed, tracked, _) = fixture();
|
||||||
|
let untracked = tracked.parent().unwrap().join("untracked");
|
||||||
|
fs::write(&untracked, "text").unwrap();
|
||||||
|
assert_eq!(read_file(&untracked, "text", &|| false).unwrap(), None);
|
||||||
|
let link = temp.0.join("link");
|
||||||
|
std::os::unix::fs::symlink(tracked, &link).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
read_file(&link, "first\nsecond\n", &|| false).unwrap(),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn noncolocated_jj_uses_parent_without_snapshotting_or_changing_refs() {
|
||||||
|
let temp = Temporary::new().unwrap();
|
||||||
|
command(&temp.0, "jj", &["git", "init", "--no-colocate", "."]);
|
||||||
|
let path = temp.0.join("file");
|
||||||
|
fs::write(&path, "original\n").unwrap();
|
||||||
|
assert_eq!(read_file(&path, "original\n", &|| false).unwrap(), None);
|
||||||
|
command(
|
||||||
|
&temp.0,
|
||||||
|
"jj",
|
||||||
|
&[
|
||||||
|
"--config=user.name=\"Jj Author\"",
|
||||||
|
"--config=user.email=\"jj@example.test\"",
|
||||||
|
"describe",
|
||||||
|
"-m",
|
||||||
|
"Jj summary",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
command(
|
||||||
|
&temp.0,
|
||||||
|
"jj",
|
||||||
|
&[
|
||||||
|
"--config=user.name=\"Jj Author\"",
|
||||||
|
"--config=user.email=\"jj@example.test\"",
|
||||||
|
"new",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let parent = command(
|
||||||
|
&temp.0,
|
||||||
|
"jj",
|
||||||
|
&[
|
||||||
|
"--ignore-working-copy",
|
||||||
|
"log",
|
||||||
|
"--no-graph",
|
||||||
|
"-r",
|
||||||
|
"@-",
|
||||||
|
"-T",
|
||||||
|
"commit_id",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let operation = command(
|
||||||
|
&temp.0,
|
||||||
|
"jj",
|
||||||
|
&[
|
||||||
|
"--ignore-working-copy",
|
||||||
|
"op",
|
||||||
|
"log",
|
||||||
|
"--no-graph",
|
||||||
|
"-n",
|
||||||
|
"1",
|
||||||
|
"-T",
|
||||||
|
"id",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let rows = read_file(&path, "original\n", &|| false).unwrap().unwrap();
|
||||||
|
let line = rows[0].as_ref().unwrap();
|
||||||
|
assert_eq!(line.commit, parent);
|
||||||
|
assert_eq!(line.author, "Jj Author");
|
||||||
|
assert_eq!(line.summary, "Jj summary");
|
||||||
|
fs::write(&path, "dirty on disk\n").unwrap();
|
||||||
|
let shifted = read_file(&path, "inserted\noriginal\n", &|| false)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(shifted[0].as_ref().unwrap().uncommitted);
|
||||||
|
assert_eq!(shifted[1].as_ref().unwrap().commit, parent);
|
||||||
|
assert!(
|
||||||
|
read_file(&path, "unsaved\n", &|| false).unwrap().unwrap()[0]
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.uncommitted
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
command(
|
||||||
|
&temp.0,
|
||||||
|
"jj",
|
||||||
|
&[
|
||||||
|
"--ignore-working-copy",
|
||||||
|
"op",
|
||||||
|
"log",
|
||||||
|
"--no-graph",
|
||||||
|
"-n",
|
||||||
|
"1",
|
||||||
|
"-T",
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
),
|
||||||
|
operation
|
||||||
|
);
|
||||||
|
assert!(!temp.0.join(".git").exists());
|
||||||
|
command(&temp.0, "jj", &["new", "root()"]);
|
||||||
|
fs::write(temp.0.join("other"), "sibling\n").unwrap();
|
||||||
|
command(&temp.0, "jj", &["describe", "-m", "Sibling"]);
|
||||||
|
command(&temp.0, "jj", &["new", &parent, "@"]);
|
||||||
|
assert_eq!(read_file(&path, "original\n", &|| false).unwrap(), None);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn incremental_groups_share_metadata_across_ranges_and_repeated_commits() {
|
||||||
|
let first = "1111111111111111111111111111111111111111";
|
||||||
|
let second = "2222222222222222222222222222222222222222";
|
||||||
|
let output = format!(
|
||||||
|
"{first} 5 3 2\nauthor First\nsummary First summary\nfilename file\n\
|
||||||
|
{second} 8 2 1\nauthor Second\nsummary Second summary\nfilename file\n\
|
||||||
|
{first} 1 1 1\nfilename old-file\n"
|
||||||
|
);
|
||||||
|
let rows = parse(output.as_bytes(), 4).unwrap();
|
||||||
|
assert_eq!(rows[0].as_ref().unwrap().commit, first);
|
||||||
|
assert_eq!(rows[1].as_ref().unwrap().author, "Second");
|
||||||
|
assert_eq!(rows[1].as_ref().unwrap().commit, second);
|
||||||
|
assert!(Arc::ptr_eq(
|
||||||
|
rows[0].as_ref().unwrap(),
|
||||||
|
rows[2].as_ref().unwrap()
|
||||||
|
));
|
||||||
|
assert!(Arc::ptr_eq(
|
||||||
|
rows[2].as_ref().unwrap(),
|
||||||
|
rows[3].as_ref().unwrap()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn malformed_incremental_groups_never_echo_metadata() {
|
||||||
|
let commit = "1111111111111111111111111111111111111111";
|
||||||
|
let metadata = "author secret\nsummary secret\nfilename secret\n";
|
||||||
|
let valid = format!("{commit} 1 1 1\n{metadata}");
|
||||||
|
for output in [
|
||||||
|
"secret metadata".into(),
|
||||||
|
"\u{fffd}secret metadata".into(),
|
||||||
|
format!("{commit} 1 2 1\n{metadata}"),
|
||||||
|
format!("{commit} 1 1 2\n{metadata}"),
|
||||||
|
format!("{commit} 0 1 1\n{metadata}"),
|
||||||
|
format!("{commit} 1 1 0\n{metadata}"),
|
||||||
|
format!("{commit} 1 1 1 extra\n{metadata}"),
|
||||||
|
format!("{commit} 1 1 1\nauthor secret\nfilename secret\n"),
|
||||||
|
format!("{commit} 1 1 1\nauthor secret\nauthor secret\nsummary secret\nfilename secret\n"),
|
||||||
|
format!("{commit} 1 1 1\nauthor secret\nsummary secret\n\tsecret source\n"),
|
||||||
|
format!("{valid}{commit} 2 1 1\nfilename secret\n"),
|
||||||
|
format!("{commit} 1 1 1\nauthor secret\nsummary secret\n{commit} 2 1 1\nfilename secret\n"),
|
||||||
|
format!("{commit} {} 1 2\n{metadata}", usize::MAX),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
parse(output.as_bytes(), 1).unwrap_err(),
|
||||||
|
"Invalid Git blame metadata"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
parse(valid.as_bytes(), 2).unwrap_err(),
|
||||||
|
"Invalid Git blame metadata"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse(b"\xffsecret metadata", 1).unwrap_err(),
|
||||||
|
"Invalid Git blame metadata"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn cancelled_commands_kill_and_reap_pipe_holding_children() {
|
||||||
|
let temp = Temporary::new().unwrap();
|
||||||
|
let ready = temp.0.join("ready");
|
||||||
|
let cancellation = Arc::new(AtomicBool::new(false));
|
||||||
|
let stop = Arc::clone(&cancellation);
|
||||||
|
let gate = ready.clone();
|
||||||
|
let worker = thread::spawn(move || {
|
||||||
|
let mut command = Command::new("sh");
|
||||||
|
command
|
||||||
|
.args([
|
||||||
|
"-c",
|
||||||
|
"sleep 600 & printf ready > \"$1\"; wait",
|
||||||
|
"vamp-blame-test",
|
||||||
|
])
|
||||||
|
.arg(gate);
|
||||||
|
run(command, &[], &|| stop.load(Ordering::Acquire))
|
||||||
|
});
|
||||||
|
while !ready.exists() && !worker.is_finished() {
|
||||||
|
thread::yield_now();
|
||||||
|
}
|
||||||
|
assert!(ready.exists());
|
||||||
|
cancellation.store(true, Ordering::Release);
|
||||||
|
assert_eq!(worker.join().unwrap().unwrap_err(), "Blame cancelled");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn worker_keeps_only_latest_request_and_bounds_input() {
|
||||||
|
let (_temp, path, commit) = fixture();
|
||||||
|
let mut worker = BlameWorker::start().unwrap();
|
||||||
|
for id in 0..20 {
|
||||||
|
worker
|
||||||
|
.request(id, path.clone(), "first\nsecond\n".into())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
if let Some((id, result)) = worker.poll() {
|
||||||
|
assert_eq!(id, 19);
|
||||||
|
let rows = result.unwrap().unwrap();
|
||||||
|
assert_eq!(rows.len(), 2);
|
||||||
|
assert_eq!(rows[1].as_ref().unwrap().commit, commit);
|
||||||
|
assert!(Arc::ptr_eq(
|
||||||
|
rows[0].as_ref().unwrap(),
|
||||||
|
rows[1].as_ref().unwrap()
|
||||||
|
));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
thread::yield_now();
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
worker
|
||||||
|
.request(20, path, "x".repeat(LIMIT + 1))
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
io::ErrorKind::InvalidInput
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn subprocess_output_is_bounded() {
|
||||||
|
let mut command = Command::new("sh");
|
||||||
|
command.args(["-c", "dd if=/dev/zero bs=1048576 count=17 2>/dev/null"]);
|
||||||
|
assert_eq!(
|
||||||
|
run(command, &[], &|| false).unwrap_err(),
|
||||||
|
"Blame output exceeds 16 MiB"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn popup_partial_options_keep_command_and_search_independent() {
|
||||||
|
let popup: PopupConfig = serde_json::from_value(serde_json::json!({
|
||||||
|
"command": {"anchor": "top_right"}
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(popup.command.anchor, PopupAnchor::TopRight);
|
||||||
|
assert_eq!(popup.search.anchor, PopupAnchor::BottomLeft);
|
||||||
|
let popup: PopupConfig = serde_json::from_value(serde_json::json!({
|
||||||
|
"command": {}, "search": {"anchor": "center_left"}
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(popup.command.anchor, PopupAnchor::BottomLeft);
|
||||||
|
assert_eq!(popup.search.anchor, PopupAnchor::CenterLeft);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_popup_options_are_rejected_at_the_boundary() {
|
||||||
|
for value in [
|
||||||
|
serde_json::json!(null),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!(7),
|
||||||
|
serde_json::json!({"selector": {}}),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<PopupConfig>(value.clone()).is_err(),
|
||||||
|
"{value}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for options in [
|
||||||
|
serde_json::json!(null),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!("center"),
|
||||||
|
serde_json::json!({"anchor": null}),
|
||||||
|
serde_json::json!({"anchor": 7}),
|
||||||
|
serde_json::json!({"anchor": true}),
|
||||||
|
serde_json::json!({"anchor": []}),
|
||||||
|
serde_json::json!({"anchor": {}}),
|
||||||
|
serde_json::json!({"anchor": "middle"}),
|
||||||
|
serde_json::json!({"anchor": "TopLeft"}),
|
||||||
|
serde_json::json!({"width": 72}),
|
||||||
|
serde_json::json!({"anchor": "center", "callback": "run"}),
|
||||||
|
] {
|
||||||
|
for mode in ["command", "search"] {
|
||||||
|
let mut popup = serde_json::json!({});
|
||||||
|
popup[mode] = options.clone();
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<PopupConfig>(popup).is_err(),
|
||||||
|
"{mode}: {options}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Lines {
|
||||||
|
#[serde(deserialize_with = "top")]
|
||||||
|
top: ChromeLine,
|
||||||
|
#[serde(deserialize_with = "bottom")]
|
||||||
|
bottom: ChromeLine,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn omitted_options_inherit_and_explicit_groups_replace_defaults() {
|
||||||
|
let lines: Lines = serde_json::from_value(serde_json::json!({
|
||||||
|
"top": {"right": ["battery"], "tabs": {"title": "T{index}:{path}{modified}"}, "battery": {"padding": 0}},
|
||||||
|
"bottom": {"left": [], "center": ["workspace"]}
|
||||||
|
})).unwrap();
|
||||||
|
assert_eq!(lines.top.left, ["tabs"]);
|
||||||
|
assert_eq!(lines.top.segment("battery").padding, 0);
|
||||||
|
assert!(lines.bottom.left.is_empty());
|
||||||
|
assert_eq!(lines.bottom.right, ["language", "lsp", "position"]);
|
||||||
|
assert_eq!(lines.bottom.segment("workspace"), &SegmentConfig::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn both_positions_accept_all_builtins_and_custom_styles() {
|
||||||
|
let names = [
|
||||||
|
"tabs",
|
||||||
|
"mode",
|
||||||
|
"pending",
|
||||||
|
"buffer",
|
||||||
|
"diagnostics",
|
||||||
|
"path",
|
||||||
|
"selections",
|
||||||
|
"count",
|
||||||
|
"language",
|
||||||
|
"lsp",
|
||||||
|
"position",
|
||||||
|
"battery",
|
||||||
|
"workspace",
|
||||||
|
];
|
||||||
|
let options = serde_json::json!({
|
||||||
|
"left": names,
|
||||||
|
"center": ["language"],
|
||||||
|
"right": ["tabs"],
|
||||||
|
"tabs": {"title": "{index}:{name}:{path}{modified}", "padding": 0},
|
||||||
|
"mode": {"leading_edge": "<", "trailing_edge": ">"},
|
||||||
|
"workspace": {"first_leading_edge": ""}
|
||||||
|
});
|
||||||
|
let lines: Lines = serde_json::from_value(serde_json::json!({
|
||||||
|
"top": options,
|
||||||
|
"bottom": options,
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
for line in [&lines.top, &lines.bottom] {
|
||||||
|
assert_eq!(line.left, names);
|
||||||
|
assert_eq!(line.center, ["language"]);
|
||||||
|
assert_eq!(line.right, ["tabs"]);
|
||||||
|
assert_eq!(line.segment("tabs").padding, 0);
|
||||||
|
assert_eq!(line.segment("mode").leading_edge, "<");
|
||||||
|
assert_eq!(
|
||||||
|
line.segment("workspace").first_leading_edge.as_deref(),
|
||||||
|
Some("")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn view_placeholder_is_valid_in_every_segment_and_bar() {
|
||||||
|
for name in [
|
||||||
|
"tabs",
|
||||||
|
"mode",
|
||||||
|
"pending",
|
||||||
|
"buffer",
|
||||||
|
"diagnostics",
|
||||||
|
"path",
|
||||||
|
"selections",
|
||||||
|
"count",
|
||||||
|
"language",
|
||||||
|
"lsp",
|
||||||
|
"position",
|
||||||
|
"battery",
|
||||||
|
"workspace",
|
||||||
|
] {
|
||||||
|
let options = serde_json::json!({
|
||||||
|
"left": [name],
|
||||||
|
(name): {"title": "{index}:{view}{name}:{path}{modified}"}
|
||||||
|
});
|
||||||
|
let lines: Lines = serde_json::from_value(serde_json::json!({
|
||||||
|
"top": options,
|
||||||
|
"bottom": options,
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
for line in [&lines.top, &lines.bottom] {
|
||||||
|
assert_eq!(
|
||||||
|
line.segment(name).title,
|
||||||
|
"{index}:{view}{name}:{path}{modified}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_chrome_is_rejected_at_the_boundary() {
|
||||||
|
for options in [
|
||||||
|
serde_json::json!({"left": ["bad-name"]}),
|
||||||
|
serde_json::json!({"left": null}),
|
||||||
|
serde_json::json!({"tabs": {"padding": 65}}),
|
||||||
|
serde_json::json!({"tabs": {"padding": null}}),
|
||||||
|
serde_json::json!({"tabs": {"margin": 65}}),
|
||||||
|
serde_json::json!({"tabs": {"margin": -1}}),
|
||||||
|
serde_json::json!({"tabs": {"margin": null}}),
|
||||||
|
serde_json::json!({"tabs": {"margin": "1"}}),
|
||||||
|
serde_json::json!({"tabs": {"margin": 1.5}}),
|
||||||
|
serde_json::json!({"tabs": {"leading_edge": "\n"}}),
|
||||||
|
serde_json::json!({"tabs": {"title": "{unknown}"}}),
|
||||||
|
serde_json::json!({"tabs": {"title": "{name"}}),
|
||||||
|
serde_json::json!({"tabs": {"title": "name}"}}),
|
||||||
|
serde_json::json!({"tabs": {"colour": "red"}}),
|
||||||
|
] {
|
||||||
|
for position in ["top", "bottom"] {
|
||||||
|
let mut frame = serde_json::json!({"top": {}, "bottom": {}});
|
||||||
|
frame[position] = options.clone();
|
||||||
|
assert!(serde_json::from_value::<Lines>(frame).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn parse(args: &[&str]) -> Result<Option<Options>, String> {
|
||||||
|
parse_options(args.iter().map(std::ffi::OsString::from))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn review_commands_preserve_filenames_and_validate_arguments() {
|
||||||
|
assert_eq!(
|
||||||
|
parse(&["review"]).unwrap().unwrap().file,
|
||||||
|
Some("review".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse(&["--", "--review"]).unwrap().unwrap().file,
|
||||||
|
Some("--review".into())
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
parse(&["--no-config", "--review", "list", "--repo", ".", "--json"])
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.review
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
parse(&["--review", "show", "1", "--all", "--no-config"])
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.review
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
for args in [
|
||||||
|
vec!["--review"],
|
||||||
|
vec!["--review", "show"],
|
||||||
|
vec!["--review", "show", "0"],
|
||||||
|
vec!["--review", "show", "-1"],
|
||||||
|
vec!["--review", "show", "abc"],
|
||||||
|
vec!["--review", "list", "--all"],
|
||||||
|
vec!["--review", "list", "--repo"],
|
||||||
|
vec!["--review", "list", "--repo", ".", "--repo", "."],
|
||||||
|
vec!["--review", "list", "extra"],
|
||||||
|
vec!["--review", "list", "--", "extra"],
|
||||||
|
vec!["--review", "list", "--config", "x", "--no-config"],
|
||||||
|
] {
|
||||||
|
assert!(parse(&args).is_err(), "{args:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
static FIXTURE_ID: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
struct Fixture(PathBuf);
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
fn new() -> Self {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"vamp-clipboard-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
FIXTURE_ID.fetch_add(1, Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
fs::create_dir(&path).unwrap();
|
||||||
|
Self(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command(&self, name: &str, body: &str) -> PathBuf {
|
||||||
|
let path = self.0.join(name);
|
||||||
|
fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
|
||||||
|
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clipboard(&self, text: &[u8]) -> SystemClipboard {
|
||||||
|
let state = self.0.join("state");
|
||||||
|
fs::write(&state, text).unwrap();
|
||||||
|
let state = format!("'{}'", state.to_str().unwrap().replace('\'', "'\\''"));
|
||||||
|
SystemClipboard::with_commands(
|
||||||
|
self.command("read", &format!("exec /bin/cat {state}")),
|
||||||
|
self.command("write", &format!("exec /bin/cat > {state}")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Fixture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
fs::remove_dir_all(&self.0).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn construction_does_not_probe_or_spawn() {
|
||||||
|
assert!(SystemClipboard::new().commands.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trips_unicode_whitespace_and_large_pipes_exactly() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut clipboard = fixture.clipboard(b"");
|
||||||
|
for text in ["", " héllo\t世界🙂\r\n\n", &"é\n".repeat(300_000)] {
|
||||||
|
clipboard.write(text).unwrap();
|
||||||
|
assert_eq!(fs::read(fixture.0.join("state")).unwrap(), text.as_bytes());
|
||||||
|
assert_eq!(clipboard.read().unwrap(), text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_oversize_and_invalid_utf8_without_leaking_contents() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut clipboard = fixture.clipboard(&[0xff, b'\n']);
|
||||||
|
assert_eq!(
|
||||||
|
clipboard.read().unwrap_err(),
|
||||||
|
"System clipboard is not valid UTF-8"
|
||||||
|
);
|
||||||
|
let maximum = "é".repeat(MAX_CLIPBOARD_BYTES / 2);
|
||||||
|
clipboard.write(&maximum).unwrap();
|
||||||
|
assert_eq!(clipboard.read().unwrap(), maximum);
|
||||||
|
let too_large = format!("{maximum}x");
|
||||||
|
assert_eq!(
|
||||||
|
clipboard.write(&too_large).unwrap_err(),
|
||||||
|
"System clipboard text exceeds 4 MiB"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(fixture.0.join("state")).unwrap(),
|
||||||
|
maximum.as_bytes()
|
||||||
|
);
|
||||||
|
fs::write(fixture.0.join("state"), &too_large).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
clipboard.read().unwrap_err(),
|
||||||
|
"System clipboard text exceeds 4 MiB"
|
||||||
|
);
|
||||||
|
let mut incomplete = vec![b'x'; MAX_CLIPBOARD_BYTES - 1];
|
||||||
|
incomplete.push(0xc3);
|
||||||
|
fs::write(fixture.0.join("state"), incomplete).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
clipboard.read().unwrap_err(),
|
||||||
|
"System clipboard is not valid UTF-8"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_failures_and_stderr_cannot_leak_clipboard_text() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let failed = fixture.command(
|
||||||
|
"failed",
|
||||||
|
"printf 'private clipboard payload' >&2\nprintf 'private clipboard payload'\nexit 23",
|
||||||
|
);
|
||||||
|
let mut clipboard = SystemClipboard::with_commands(failed.clone(), failed);
|
||||||
|
let error = clipboard.read().unwrap_err();
|
||||||
|
assert!(error.contains("failed"));
|
||||||
|
assert!(!error.contains("private clipboard payload"));
|
||||||
|
let error = clipboard.write("private clipboard payload").unwrap_err();
|
||||||
|
assert!(!error.contains("private clipboard payload"));
|
||||||
|
let missing = fixture.0.join("not-an-executable");
|
||||||
|
let mut clipboard = SystemClipboard::with_commands(missing.clone(), missing);
|
||||||
|
assert!(clipboard.read().unwrap_err().contains("Cannot start"));
|
||||||
|
assert!(
|
||||||
|
clipboard
|
||||||
|
.write("secret")
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("Cannot start")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignored_stderr_does_not_block_large_writes() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut clipboard = fixture.clipboard(b"");
|
||||||
|
let state = fixture.0.join("state");
|
||||||
|
let state = format!("'{}'", state.to_str().unwrap().replace('\'', "'\\''"));
|
||||||
|
clipboard
|
||||||
|
.commands
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.writer
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.program = fixture.command(
|
||||||
|
"noisy",
|
||||||
|
&format!("/bin/dd if=/dev/zero bs=65536 count=8 >&2\nexec /bin/cat > {state}"),
|
||||||
|
);
|
||||||
|
let text = "λ\n".repeat(300_000);
|
||||||
|
clipboard.write(&text).unwrap();
|
||||||
|
assert_eq!(clipboard.read().unwrap(), text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_process_cleanup_kills_and_reaps_a_running_child() {
|
||||||
|
use std::io::Read;
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn waitpid(pid: i32, status: *mut i32, options: i32) -> i32;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let hung = fixture.command("running", "printf R\nwhile :; do :; done");
|
||||||
|
let mut command = Command::new(hung);
|
||||||
|
command
|
||||||
|
.process_group(0)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::null());
|
||||||
|
let mut child = command.spawn().unwrap();
|
||||||
|
let pid = child.id() as i32;
|
||||||
|
let mut ready = [0];
|
||||||
|
child
|
||||||
|
.stdout
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.read_exact(&mut ready)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ready, [b'R']);
|
||||||
|
drop(ClipboardProcess(Some(child)));
|
||||||
|
let mut status = 0;
|
||||||
|
// SAFETY: waitpid's output pointer is valid; WNOHANG cannot block this check.
|
||||||
|
assert_eq!(unsafe { waitpid(pid, &mut status, 1) }, -1);
|
||||||
|
assert_eq!(std::io::Error::last_os_error().raw_os_error(), Some(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn immediate_injected_timeout_terminates_nonterminating_commands() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let hung = fixture.command("hung", "while :; do :; done");
|
||||||
|
let mut clipboard = SystemClipboard::with_commands(hung.clone(), hung);
|
||||||
|
// No assertion depends on wall-clock speed or a deadline remaining in the future.
|
||||||
|
clipboard.timeout = Duration::ZERO;
|
||||||
|
assert_eq!(
|
||||||
|
clipboard.read().unwrap_err(),
|
||||||
|
"System clipboard command timed out"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
clipboard.write("text").unwrap_err(),
|
||||||
|
"System clipboard command timed out"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
use super::*;
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn PasteboardSynchronize(pasteboard: Object) -> u32;
|
||||||
|
fn PasteboardGetItemIdentifier(pasteboard: Object, index: isize, item: *mut usize) -> i32;
|
||||||
|
fn PasteboardCopyItemFlavorData(
|
||||||
|
pasteboard: Object,
|
||||||
|
item: usize,
|
||||||
|
flavor: Object,
|
||||||
|
data: *mut Object,
|
||||||
|
) -> i32;
|
||||||
|
fn CFDataGetBytePtr(data: Object) -> *const u8;
|
||||||
|
fn CFDataGetLength(data: Object) -> isize;
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn native_publication_preserves_plain_utf8_bytes_on_an_isolated_pasteboard() {
|
||||||
|
// A private pasteboard leaves the user's clipboard untouched.
|
||||||
|
let _pool = unsafe { AutoreleasePool(objc_autoreleasePoolPush()) };
|
||||||
|
let name = unsafe {
|
||||||
|
Owned::new(CFStringCreateWithCString(
|
||||||
|
ptr::null(),
|
||||||
|
c"vamp-native-clipboard-regression".as_ptr(),
|
||||||
|
0x0800_0100,
|
||||||
|
))
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
let board = unsafe {
|
||||||
|
let mut board = ptr::null();
|
||||||
|
check(PasteboardCreate(name.0, &mut board)).unwrap();
|
||||||
|
Owned::new(board).unwrap()
|
||||||
|
};
|
||||||
|
let reader = unsafe {
|
||||||
|
let send: unsafe extern "C" fn(Object, Object, Object) -> Object =
|
||||||
|
std::mem::transmute(objc_msgSend as unsafe extern "C" fn());
|
||||||
|
send(
|
||||||
|
objc_getClass(c"NSPasteboard".as_ptr()),
|
||||||
|
sel_registerName(c"pasteboardWithName:".as_ptr()),
|
||||||
|
name.0,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
for text in [
|
||||||
|
"",
|
||||||
|
"é e\u{301} 👨💻\n\0tail",
|
||||||
|
"{\\rtf1 plain text}",
|
||||||
|
"%!PS-Adobe-3.0\nplain",
|
||||||
|
] {
|
||||||
|
write_to(name.0, text).unwrap();
|
||||||
|
assert_eq!(read_from(reader).unwrap(), text);
|
||||||
|
unsafe {
|
||||||
|
PasteboardSynchronize(board.0);
|
||||||
|
let mut item = 0;
|
||||||
|
check(PasteboardGetItemIdentifier(board.0, 1, &mut item)).unwrap();
|
||||||
|
let flavor = Owned::new(CFStringCreateWithCString(
|
||||||
|
ptr::null(),
|
||||||
|
c"public.utf8-plain-text".as_ptr(),
|
||||||
|
0x0800_0100,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let mut data = ptr::null();
|
||||||
|
check(PasteboardCopyItemFlavorData(
|
||||||
|
board.0, item, flavor.0, &mut data,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let data = Owned::new(data).unwrap();
|
||||||
|
let length = CFDataGetLength(data.0) as usize;
|
||||||
|
let bytes = if length == 0 {
|
||||||
|
&[]
|
||||||
|
} else {
|
||||||
|
std::slice::from_raw_parts(CFDataGetBytePtr(data.0), length)
|
||||||
|
};
|
||||||
|
assert_eq!(bytes, text.as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let maximum = "x".repeat(super::super::MAX_CLIPBOARD_BYTES);
|
||||||
|
write_to(name.0, &maximum).unwrap();
|
||||||
|
assert_eq!(read_from(reader).unwrap(), maximum);
|
||||||
|
write_to(name.0, &(maximum + "x")).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
read_from(reader).unwrap_err(),
|
||||||
|
"System clipboard text exceeds 4 MiB"
|
||||||
|
);
|
||||||
|
}
|
||||||
+2561
File diff suppressed because it is too large
Load Diff
+15220
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,982 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn virtual_catalog_compacts_and_retains_file_directory_collisions() {
|
||||||
|
let mut picker = FilePicker::from_paths(
|
||||||
|
PathBuf::new(),
|
||||||
|
["x", "x/y", "src/deep/deleted.rs", "src/deep/deleted.rs"]
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.into_iter()
|
||||||
|
.enumerate(),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
picker
|
||||||
|
.entries()
|
||||||
|
.map(|row| row.label.to_owned())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["src/deep", "x", "x"]
|
||||||
|
);
|
||||||
|
picker.reveal_path(Path::new("x"));
|
||||||
|
assert!(!picker.entries().nth(picker.selected).unwrap().is_dir);
|
||||||
|
assert_eq!(picker.accept(), Some(PathBuf::from("x")));
|
||||||
|
picker.reveal_path(Path::new("x/y"));
|
||||||
|
assert_eq!(picker.accept(), Some(PathBuf::from("x/y")));
|
||||||
|
picker.reveal_path(Path::new("src/deep/deleted.rs"));
|
||||||
|
assert_eq!(picker.accept(), Some(PathBuf::from("src/deep/deleted.rs")));
|
||||||
|
assert_eq!(picker.entries().filter(|row| !row.is_dir).count(), 3);
|
||||||
|
let expected_index = |row: &FileRow<'_>| {
|
||||||
|
if row.is_dir {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(match row.path.to_str().unwrap() {
|
||||||
|
"x" => 0,
|
||||||
|
"x/y" => 1,
|
||||||
|
"src/deep/deleted.rs" => 2,
|
||||||
|
path => panic!("unexpected path {path}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for view in [FileView::List, FileView::Tree] {
|
||||||
|
picker.set_view(view);
|
||||||
|
for query in ["", "x", "deleted"] {
|
||||||
|
picker.query = query.into();
|
||||||
|
picker.refilter();
|
||||||
|
let rows: Vec<_> = picker.entries().collect();
|
||||||
|
assert!(!rows.is_empty());
|
||||||
|
for row in rows {
|
||||||
|
assert_eq!(row.catalog_index, expected_index(&row));
|
||||||
|
assert!(row.status.is_none() && row.old_path.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
|
||||||
|
struct Directory(PathBuf);
|
||||||
|
impl Directory {
|
||||||
|
fn new() -> Self {
|
||||||
|
let id = TEMP_ID.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let path =
|
||||||
|
std::env::temp_dir().join(format!("vamp-files-test-{}-{id}", std::process::id()));
|
||||||
|
fs::create_dir(&path).unwrap();
|
||||||
|
Self(path)
|
||||||
|
}
|
||||||
|
fn put(&self, name: &str, text: &str) {
|
||||||
|
let path = self.0.join(name);
|
||||||
|
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
fs::write(path, text).unwrap();
|
||||||
|
}
|
||||||
|
fn scan(&self) -> FilePicker {
|
||||||
|
FilePicker::scan(self.0.clone(), FilePresentation::Floating, FileView::List).unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for Directory {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn paths(picker: &FilePicker) -> Vec<PathBuf> {
|
||||||
|
picker
|
||||||
|
.entries()
|
||||||
|
.map(|entry| entry.path.to_owned())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fuzzy_path_tokens_preserve_tree_context_selection_and_open_paths() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let names = [
|
||||||
|
"src/fuzzy_parser.rs",
|
||||||
|
"src/über_parser.rs",
|
||||||
|
"src/other.rs",
|
||||||
|
"tests/fuzzy_parser.rs",
|
||||||
|
];
|
||||||
|
for name in names {
|
||||||
|
directory.put(name, "");
|
||||||
|
}
|
||||||
|
for mut picker in [
|
||||||
|
directory.scan(),
|
||||||
|
FilePicker::from_paths(
|
||||||
|
directory.0.clone(),
|
||||||
|
names.map(PathBuf::from).into_iter().enumerate(),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
for view in [FileView::Tree, FileView::List] {
|
||||||
|
picker.set_view(view);
|
||||||
|
for (query, expected) in [
|
||||||
|
("sr/fp RS", "src/fuzzy_parser.rs"),
|
||||||
|
("ÜPR SRC", "src/über_parser.rs"),
|
||||||
|
] {
|
||||||
|
picker.query = query.into();
|
||||||
|
picker.refilter();
|
||||||
|
assert_eq!(
|
||||||
|
picker
|
||||||
|
.entries()
|
||||||
|
.filter(|row| !row.is_dir)
|
||||||
|
.map(|row| row.path)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[Path::new(expected)]
|
||||||
|
);
|
||||||
|
assert_eq!(selected_path(&picker), Path::new(expected));
|
||||||
|
assert_eq!(picker.accept(), Some(directory.0.join(expected)));
|
||||||
|
if view == FileView::Tree {
|
||||||
|
assert_eq!(paths(&picker), [Path::new("src"), Path::new(expected)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
picker.query = "rs/ü".into();
|
||||||
|
picker.refilter();
|
||||||
|
assert!(picker.entries().next().is_none());
|
||||||
|
picker.clear_filter();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src/über_parser.rs"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scanned_files_and_directories_have_no_catalog_index() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
directory.put("nested/file.txt", "");
|
||||||
|
let mut picker = directory.scan();
|
||||||
|
for view in [FileView::List, FileView::Tree] {
|
||||||
|
picker.set_view(view);
|
||||||
|
picker.reveal_path(Path::new("nested/file.txt"));
|
||||||
|
assert!(picker.entries().all(|row| row.catalog_index.is_none()));
|
||||||
|
assert!(
|
||||||
|
picker
|
||||||
|
.entries()
|
||||||
|
.all(|row| row.status.is_none() && row.old_path.is_none())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repository_change(path: &str, status: FileStatus, old_path: Option<&str>) -> WorkingFileChange {
|
||||||
|
WorkingFileChange {
|
||||||
|
path: path.into(),
|
||||||
|
status,
|
||||||
|
old_path: old_path.map(PathBuf::from),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repository_metadata_keeps_clean_files_selection_and_filtered_views() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
for name in ["src/clean.rs", "src/modified.rs", "added.rs", "renamed.rs"] {
|
||||||
|
directory.put(name, "");
|
||||||
|
}
|
||||||
|
let mut picker = directory.scan();
|
||||||
|
picker.presentation = FilePresentation::Docked;
|
||||||
|
picker.set_view(FileView::Tree);
|
||||||
|
picker.reveal_path(Path::new("src/clean.rs"));
|
||||||
|
picker.query = "src".into();
|
||||||
|
picker.refilter();
|
||||||
|
picker.focused = false;
|
||||||
|
let changes = vec![
|
||||||
|
repository_change("src/modified.rs", FileStatus::Modified, None),
|
||||||
|
repository_change("added.rs", FileStatus::Added, None),
|
||||||
|
repository_change("renamed.rs", FileStatus::Renamed, Some("old.rs")),
|
||||||
|
repository_change("vanished/deep/deleted.rs", FileStatus::Removed, None),
|
||||||
|
];
|
||||||
|
assert!(picker.set_repository_changes(changes.clone()));
|
||||||
|
assert!(!picker.set_repository_changes(changes));
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src/clean.rs"));
|
||||||
|
assert_eq!(picker.query, "src");
|
||||||
|
assert!(!picker.focused);
|
||||||
|
assert_eq!(picker.presentation, FilePresentation::Docked);
|
||||||
|
for view in [FileView::List, FileView::Tree] {
|
||||||
|
picker.set_view(view);
|
||||||
|
for query in ["", "src", "modified.rs", "deleted.rs"] {
|
||||||
|
picker.query = query.into();
|
||||||
|
picker.refilter();
|
||||||
|
let rows: Vec<_> = picker.entries().collect();
|
||||||
|
assert!(!rows.is_empty());
|
||||||
|
for row in rows {
|
||||||
|
assert!(row.catalog_index.is_none());
|
||||||
|
if row.is_dir || row.path == Path::new("src/clean.rs") {
|
||||||
|
assert!(row.status.is_none() && row.old_path.is_none());
|
||||||
|
} else {
|
||||||
|
let status = match row.path.to_str().unwrap() {
|
||||||
|
"src/modified.rs" => FileStatus::Modified,
|
||||||
|
"added.rs" => FileStatus::Added,
|
||||||
|
"renamed.rs" => FileStatus::Renamed,
|
||||||
|
"vanished/deep/deleted.rs" => FileStatus::Removed,
|
||||||
|
other => panic!("unexpected {other}"),
|
||||||
|
};
|
||||||
|
assert_eq!(row.status, Some(status));
|
||||||
|
assert_eq!(
|
||||||
|
row.old_path,
|
||||||
|
(status == FileStatus::Renamed).then(|| Path::new("old.rs"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if query.is_empty() || query == "src" {
|
||||||
|
assert!(
|
||||||
|
picker
|
||||||
|
.entries()
|
||||||
|
.any(|row| row.path == Path::new("src/clean.rs"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
picker.set_view(FileView::List);
|
||||||
|
picker.clear_filter();
|
||||||
|
picker.reveal_path(Path::new("src/clean.rs"));
|
||||||
|
assert!(picker.set_repository_changes(Vec::new()));
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src/clean.rs"));
|
||||||
|
assert_eq!(picker.entries().len(), 4);
|
||||||
|
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
|
||||||
|
assert!(
|
||||||
|
picker
|
||||||
|
.entries()
|
||||||
|
.all(|row| row.status.is_none() && row.old_path.is_none())
|
||||||
|
);
|
||||||
|
assert!(!picker.set_repository_changes(Vec::new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleted_catalog_leaves_survive_refresh_and_restored_files_survive_clear() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
directory.put("keep.txt", "");
|
||||||
|
let mut picker = directory.scan();
|
||||||
|
let deleted = Path::new("lost/deep/deleted.txt");
|
||||||
|
picker.set_repository_changes(vec![repository_change(
|
||||||
|
"lost/deep/deleted.txt",
|
||||||
|
FileStatus::Removed,
|
||||||
|
None,
|
||||||
|
)]);
|
||||||
|
picker.set_view(FileView::Tree);
|
||||||
|
assert_eq!(
|
||||||
|
picker.entries().find(|row| row.is_dir).unwrap().label,
|
||||||
|
"lost/deep"
|
||||||
|
);
|
||||||
|
picker.reveal_path(deleted);
|
||||||
|
picker.query = "deleted".into();
|
||||||
|
picker.refilter();
|
||||||
|
picker.start_search();
|
||||||
|
picker.focused = false;
|
||||||
|
picker.refresh();
|
||||||
|
assert_eq!(selected_path(&picker), deleted);
|
||||||
|
assert!(picker.searching && !picker.focused);
|
||||||
|
assert_eq!(picker.query, "deleted");
|
||||||
|
assert_eq!(picker.accept(), Some(directory.0.join(deleted)));
|
||||||
|
assert!(!directory.0.join(deleted).exists());
|
||||||
|
assert_eq!(
|
||||||
|
picker.entries().find(|row| !row.is_dir).unwrap().status,
|
||||||
|
Some(FileStatus::Removed)
|
||||||
|
);
|
||||||
|
directory.put("lost/deep/deleted.txt", "restored");
|
||||||
|
assert!(picker.set_repository_changes(vec![repository_change(
|
||||||
|
"lost/deep/deleted.txt",
|
||||||
|
FileStatus::Removed,
|
||||||
|
None,
|
||||||
|
)]));
|
||||||
|
picker.refresh();
|
||||||
|
assert_eq!(selected_path(&picker), deleted);
|
||||||
|
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
|
||||||
|
assert!(picker.set_repository_changes(Vec::new()));
|
||||||
|
assert_eq!(selected_path(&picker), deleted);
|
||||||
|
assert_eq!(picker.accept(), Some(directory.0.join(deleted)));
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(directory.0.join(deleted)).unwrap(),
|
||||||
|
"restored"
|
||||||
|
);
|
||||||
|
assert!(picker.entries().all(|row| row.status.is_none()));
|
||||||
|
fs::remove_dir_all(directory.0.join("lost")).unwrap();
|
||||||
|
picker.refresh();
|
||||||
|
picker.set_repository_changes(vec![repository_change(
|
||||||
|
"lost/deep/deleted.txt",
|
||||||
|
FileStatus::Removed,
|
||||||
|
None,
|
||||||
|
)]);
|
||||||
|
picker.reveal_path(deleted);
|
||||||
|
picker.set_repository_changes(Vec::new());
|
||||||
|
assert!(picker.entries().len() == 0);
|
||||||
|
picker.clear_filter();
|
||||||
|
assert_eq!(paths(&picker), [PathBuf::from("keep.txt")]);
|
||||||
|
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repository_metadata_cannot_introduce_unsafe_or_existing_excluded_paths() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
directory.put(".gitignore", "ignored/\n");
|
||||||
|
directory.put("ignored/hidden.txt", "");
|
||||||
|
directory.put("directory/keep.txt", "");
|
||||||
|
let mut picker = directory.scan();
|
||||||
|
let mut changes: Vec<_> = [
|
||||||
|
"",
|
||||||
|
".",
|
||||||
|
"../outside.txt",
|
||||||
|
"directory/../../outside.txt",
|
||||||
|
".git/secret",
|
||||||
|
"nested/.jj/secret",
|
||||||
|
"/outside.txt",
|
||||||
|
"directory",
|
||||||
|
"ignored/hidden.txt",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|path| repository_change(path, FileStatus::Removed, None))
|
||||||
|
.collect();
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
std::os::unix::fs::symlink(&directory.0, directory.0.join("link")).unwrap();
|
||||||
|
changes.push(repository_change(
|
||||||
|
"link/missing.txt",
|
||||||
|
FileStatus::Removed,
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
changes.push(repository_change("missing.txt", FileStatus::Removed, None));
|
||||||
|
picker.set_repository_changes(changes);
|
||||||
|
assert_eq!(
|
||||||
|
paths(&picker),
|
||||||
|
[".gitignore", "directory/keep.txt", "missing.txt"].map(PathBuf::from)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
picker.entries().filter(|row| row.status.is_some()).count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
picker.entries().last().unwrap().status,
|
||||||
|
Some(FileStatus::Removed)
|
||||||
|
);
|
||||||
|
assert!(!directory.0.join("missing.txt").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_ignore_negation_hidden_files_and_vcs_metadata() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
directory.put(".gitignore", "*.log\nignored/\n");
|
||||||
|
directory.put("nested/.gitignore", "!keep.log\nlocal.txt\n");
|
||||||
|
for name in [
|
||||||
|
"gone.log",
|
||||||
|
"nested/gone.log",
|
||||||
|
"nested/keep.log",
|
||||||
|
"nested/local.txt",
|
||||||
|
"nested/ok.txt",
|
||||||
|
"ignored/hidden.txt",
|
||||||
|
".hidden",
|
||||||
|
".git/objects/secret",
|
||||||
|
"nested/.git/secret",
|
||||||
|
".jj/secret",
|
||||||
|
"nested/.jj/secret",
|
||||||
|
] {
|
||||||
|
directory.put(name, "text");
|
||||||
|
}
|
||||||
|
let picker = directory.scan();
|
||||||
|
assert_eq!(
|
||||||
|
paths(&picker),
|
||||||
|
[
|
||||||
|
".gitignore",
|
||||||
|
".hidden",
|
||||||
|
"nested/.gitignore",
|
||||||
|
"nested/keep.log",
|
||||||
|
"nested/ok.txt"
|
||||||
|
]
|
||||||
|
.map(PathBuf::from)
|
||||||
|
);
|
||||||
|
assert!(picker.error.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn literal_unicode_tokens_grapheme_deletion_and_navigation() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
for name in ["a space 界[e\u{301}].txt", "b.txt", "c.txt"] {
|
||||||
|
directory.put(name, "text");
|
||||||
|
}
|
||||||
|
let mut picker = directory.scan();
|
||||||
|
picker.navigate(false);
|
||||||
|
assert_eq!(picker.selected, 2);
|
||||||
|
picker.navigate(true);
|
||||||
|
assert_eq!(picker.selected, 0);
|
||||||
|
picker.query = "界 SPACE [E\u{301}]".into();
|
||||||
|
picker.refilter();
|
||||||
|
assert_eq!(paths(&picker), [PathBuf::from("a space 界[e\u{301}].txt")]);
|
||||||
|
picker.query = "e\u{301}👩💻".into();
|
||||||
|
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
||||||
|
assert_eq!(picker.query, "e\u{301}");
|
||||||
|
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
||||||
|
assert_eq!(picker.query, "");
|
||||||
|
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
||||||
|
assert_eq!(picker.entries().len(), 3);
|
||||||
|
picker.query = "no match".into();
|
||||||
|
picker.refilter();
|
||||||
|
for forward in [true, false] {
|
||||||
|
picker.navigate(forward);
|
||||||
|
assert_eq!(picker.selected, 0);
|
||||||
|
assert_eq!(picker.entries().len(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn word_and_line_deletion_refilter_only_editable_queries() {
|
||||||
|
use crate::editor::Input;
|
||||||
|
|
||||||
|
for view in [FileView::List, FileView::Tree] {
|
||||||
|
let mut picker = FilePicker::from_paths(
|
||||||
|
PathBuf::new(),
|
||||||
|
["alpha one.txt", "alpha two.txt", "beta.txt"]
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.into_iter()
|
||||||
|
.enumerate(),
|
||||||
|
);
|
||||||
|
picker.set_view(view);
|
||||||
|
picker.query = "alpha missing".into();
|
||||||
|
picker.refilter();
|
||||||
|
assert_eq!(picker.entries().len(), 0);
|
||||||
|
if view == FileView::Tree {
|
||||||
|
for input in [Input::Backspace, Input::AltBackspace, Input::SuperBackspace] {
|
||||||
|
assert!(!picker.handle_filter_input(&input));
|
||||||
|
assert_eq!(picker.query, "alpha missing");
|
||||||
|
}
|
||||||
|
picker.start_search();
|
||||||
|
}
|
||||||
|
assert!(picker.handle_filter_input(&Input::AltBackspace));
|
||||||
|
assert_eq!(picker.query, "alpha ");
|
||||||
|
assert_eq!(picker.entries().len(), 2);
|
||||||
|
assert!(picker.handle_filter_input(&Input::SuperBackspace));
|
||||||
|
assert_eq!(picker.query, "");
|
||||||
|
assert_eq!(picker.entries().len(), 3);
|
||||||
|
for input in [Input::Backspace, Input::AltBackspace, Input::SuperBackspace] {
|
||||||
|
assert!(picker.handle_filter_input(&input));
|
||||||
|
assert_eq!(picker.entries().len(), 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_scan_refresh_and_failure_preserve_reachable_state() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let mut picker = directory.scan();
|
||||||
|
picker.navigate(false);
|
||||||
|
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
||||||
|
assert_eq!(picker.entries().len(), 0);
|
||||||
|
directory.put("new.txt", "text");
|
||||||
|
picker.query = "new".into();
|
||||||
|
picker.refresh();
|
||||||
|
assert_eq!(picker.query, "new");
|
||||||
|
assert_eq!(paths(&picker), [PathBuf::from("new.txt")]);
|
||||||
|
fs::remove_dir_all(&directory.0).unwrap();
|
||||||
|
picker.refresh();
|
||||||
|
assert_eq!(paths(&picker), [PathBuf::from("new.txt")]);
|
||||||
|
assert!(picker.error.as_ref().unwrap().contains("Refresh failed"));
|
||||||
|
assert!(
|
||||||
|
FilePicker::scan(
|
||||||
|
directory.0.clone(),
|
||||||
|
FilePresentation::Docked,
|
||||||
|
FileView::Tree
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_ignore_warning_survives_query_and_navigation() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
directory.put(".gitignore", "[z-a]\n");
|
||||||
|
directory.put("ok.txt", "text");
|
||||||
|
let mut picker = directory.scan();
|
||||||
|
assert!(picker.error.as_ref().unwrap().contains("Incomplete scan"));
|
||||||
|
picker.query = "ok".into();
|
||||||
|
picker.refilter();
|
||||||
|
assert_eq!(paths(&picker), [PathBuf::from("ok.txt")]);
|
||||||
|
picker.navigate(true);
|
||||||
|
assert!(picker.error.as_ref().unwrap().contains("Incomplete scan"));
|
||||||
|
directory.put(".gitignore", "");
|
||||||
|
picker.refresh();
|
||||||
|
assert!(picker.error.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_project_ignore_rules_apply_and_non_directory_roots_fail() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
directory.put(".gitignore", "*.txt\n");
|
||||||
|
directory.put("project/visible.txt", "text");
|
||||||
|
let root = directory.0.join("project");
|
||||||
|
let picker = FilePicker::scan(root, FilePresentation::Floating, FileView::List).unwrap();
|
||||||
|
assert_eq!(paths(&picker), [PathBuf::from("visible.txt")]);
|
||||||
|
assert!(
|
||||||
|
FilePicker::scan(
|
||||||
|
directory.0.join("project/visible.txt"),
|
||||||
|
FilePresentation::Docked,
|
||||||
|
FileView::Tree,
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn symlink_loops_and_file_links_are_not_followed_and_raw_paths_survive() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
let directory = Directory::new();
|
||||||
|
directory.put("real.txt", "text");
|
||||||
|
symlink(&directory.0, directory.0.join("loop")).unwrap();
|
||||||
|
symlink(directory.0.join("real.txt"), directory.0.join("alias.txt")).unwrap();
|
||||||
|
let raw = PathBuf::from("raw界\n.txt");
|
||||||
|
fs::write(directory.0.join(&raw), "text").unwrap();
|
||||||
|
let picker = directory.scan();
|
||||||
|
assert_eq!(picker.entries().len(), 2);
|
||||||
|
let entry = picker
|
||||||
|
.entries()
|
||||||
|
.find(|entry| entry.path == raw.as_path())
|
||||||
|
.unwrap();
|
||||||
|
assert!(!entry.label.chars().any(char::is_control));
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(directory.0.join(entry.path)).unwrap(),
|
||||||
|
"text"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tree(directory: &Directory) -> FilePicker {
|
||||||
|
FilePicker::scan(
|
||||||
|
directory.0.clone(),
|
||||||
|
FilePresentation::Docked,
|
||||||
|
FileView::Tree,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select(picker: &mut FilePicker, path: &str) {
|
||||||
|
let selected = picker
|
||||||
|
.entries()
|
||||||
|
.position(|row| row.path == Path::new(path))
|
||||||
|
.unwrap();
|
||||||
|
picker.selected = selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_path(picker: &FilePicker) -> &Path {
|
||||||
|
picker.entries().nth(picker.selected).unwrap().path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_orders_folders_first_keeps_empty_dirs_and_compacts_only_directory_chains() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
directory.put("a.txt", "");
|
||||||
|
directory.put("folder/only.txt", "");
|
||||||
|
directory.put("chain/one/two/a.txt", "");
|
||||||
|
directory.put("chain/one/two/z.txt", "");
|
||||||
|
fs::create_dir(directory.0.join("empty")).unwrap();
|
||||||
|
let mut picker = tree(&directory);
|
||||||
|
assert_eq!(picker.view, FileView::Tree);
|
||||||
|
assert!(!picker.searching);
|
||||||
|
assert_eq!(
|
||||||
|
picker
|
||||||
|
.entries()
|
||||||
|
.map(|row| (row.label, row.is_dir, row.depth))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[
|
||||||
|
("chain/one/two", true, 0),
|
||||||
|
("empty", true, 0),
|
||||||
|
("folder", true, 0),
|
||||||
|
("a.txt", false, 0)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("chain/one/two"));
|
||||||
|
picker.right();
|
||||||
|
assert_eq!(
|
||||||
|
picker
|
||||||
|
.entries()
|
||||||
|
.map(|row| (row.label, row.depth))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[
|
||||||
|
("chain/one/two", 0),
|
||||||
|
("a.txt", 1),
|
||||||
|
("z.txt", 1),
|
||||||
|
("empty", 0),
|
||||||
|
("folder", 0),
|
||||||
|
("a.txt", 0)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
picker.right();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("chain/one/two/a.txt"));
|
||||||
|
let rows = paths(&picker);
|
||||||
|
picker.right();
|
||||||
|
assert_eq!(paths(&picker), rows);
|
||||||
|
picker.left();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("chain/one/two"));
|
||||||
|
picker.left();
|
||||||
|
assert!(!picker.entries().next().unwrap().expanded);
|
||||||
|
picker.left();
|
||||||
|
assert_eq!(picker.selected, 0);
|
||||||
|
select(&mut picker, "folder");
|
||||||
|
assert!(picker.accept().is_none());
|
||||||
|
assert_eq!(
|
||||||
|
picker.entries().nth(picker.selected + 1).unwrap().label,
|
||||||
|
"only.txt"
|
||||||
|
);
|
||||||
|
picker.right();
|
||||||
|
assert_eq!(picker.accept(), Some(directory.0.join("folder/only.txt")));
|
||||||
|
select(&mut picker, "empty");
|
||||||
|
picker.right();
|
||||||
|
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
|
||||||
|
picker.right();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_search_reveals_ancestors_without_mutating_saved_expansion_or_compaction() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
directory.put("src/module/deep/needle.txt", "");
|
||||||
|
directory.put("src/module/sibling.txt", "");
|
||||||
|
directory.put("src/root.txt", "");
|
||||||
|
directory.put("other/keep.txt", "");
|
||||||
|
let mut picker = tree(&directory);
|
||||||
|
select(&mut picker, "other");
|
||||||
|
picker.right();
|
||||||
|
select(&mut picker, "src");
|
||||||
|
let expanded = picker.expanded.clone();
|
||||||
|
picker.start_search();
|
||||||
|
picker.query = "NEEDLE".into();
|
||||||
|
picker.refilter();
|
||||||
|
assert_eq!(
|
||||||
|
picker
|
||||||
|
.entries()
|
||||||
|
.map(|row| (row.label, row.depth))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[("src", 0), ("module", 1), ("deep", 2), ("needle.txt", 3)]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
selected_path(&picker),
|
||||||
|
Path::new("src/module/deep/needle.txt")
|
||||||
|
);
|
||||||
|
assert_eq!(picker.expanded, expanded);
|
||||||
|
picker.left();
|
||||||
|
let filtered = paths(&picker);
|
||||||
|
picker.finish_search();
|
||||||
|
assert_eq!(picker.query, "NEEDLE");
|
||||||
|
assert_eq!(paths(&picker), filtered);
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src/module/deep"));
|
||||||
|
assert_eq!(picker.expanded, expanded);
|
||||||
|
picker.start_search();
|
||||||
|
picker.query = "deep".into();
|
||||||
|
picker.refilter();
|
||||||
|
select(&mut picker, "src/module/deep");
|
||||||
|
assert!(picker.accept().is_none());
|
||||||
|
assert!(!picker.searching);
|
||||||
|
assert_eq!(picker.query, "deep");
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src/module/deep"));
|
||||||
|
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
|
||||||
|
assert_eq!(picker.expanded, expanded);
|
||||||
|
picker.right();
|
||||||
|
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
|
||||||
|
assert_eq!(picker.query, "deep");
|
||||||
|
|
||||||
|
let chain = Directory::new();
|
||||||
|
chain.put("one/two/three/a.txt", "");
|
||||||
|
chain.put("one/two/three/b.txt", "");
|
||||||
|
let mut picker = tree(&chain);
|
||||||
|
picker.start_search();
|
||||||
|
picker.query = "a.txt".into();
|
||||||
|
picker.refilter();
|
||||||
|
assert_eq!(
|
||||||
|
picker.entries().map(|row| row.label).collect::<Vec<_>>(),
|
||||||
|
["one/two/three", "a.txt"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn view_switches_restore_directory_choices_and_reveal_explicit_file_choices() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
directory.put("src/a.txt", "");
|
||||||
|
directory.put("src/b.txt", "");
|
||||||
|
directory.put("z.txt", "");
|
||||||
|
let mut picker = tree(&directory);
|
||||||
|
picker.right();
|
||||||
|
let open = paths(&picker);
|
||||||
|
picker.set_view(FileView::List);
|
||||||
|
assert_eq!(
|
||||||
|
paths(&picker),
|
||||||
|
["src/a.txt", "src/b.txt", "z.txt"].map(PathBuf::from)
|
||||||
|
);
|
||||||
|
picker.set_view(FileView::Tree);
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src"));
|
||||||
|
assert_eq!(paths(&picker), open);
|
||||||
|
picker.left();
|
||||||
|
picker.toggle_view();
|
||||||
|
picker.navigate(true);
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
|
||||||
|
picker.toggle_view();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
|
||||||
|
assert!(picker.entries().next().unwrap().expanded);
|
||||||
|
picker.left();
|
||||||
|
picker.toggle_view();
|
||||||
|
picker.toggle_view();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filtering_and_refresh_preserve_raw_selection_and_surviving_expansion() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
directory.put("src/a.txt", "");
|
||||||
|
directory.put("src/b.txt", "");
|
||||||
|
directory.put("z.txt", "");
|
||||||
|
let mut picker = directory.scan();
|
||||||
|
select(&mut picker, "src/b.txt");
|
||||||
|
picker.query = "absent".into();
|
||||||
|
picker.refilter();
|
||||||
|
assert_eq!(picker.entries().len(), 0);
|
||||||
|
picker.query.clear();
|
||||||
|
picker.refilter();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
|
||||||
|
picker.set_view(FileView::Tree);
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
|
||||||
|
fs::remove_file(directory.0.join("src/b.txt")).unwrap();
|
||||||
|
directory.put("src/c.txt", "");
|
||||||
|
picker.refresh();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src"));
|
||||||
|
assert!(picker.entries().next().unwrap().expanded);
|
||||||
|
assert!(paths(&picker).contains(&PathBuf::from("src/c.txt")));
|
||||||
|
fs::remove_dir_all(directory.0.join("src")).unwrap();
|
||||||
|
picker.refresh();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("z.txt"));
|
||||||
|
assert!(picker.expanded.is_empty());
|
||||||
|
picker.toggle_view();
|
||||||
|
picker.toggle_view();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("z.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_search_subfocus_and_expansion_survive_view_toggles_and_refresh() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
directory.put("src/deep/a.txt", "");
|
||||||
|
directory.put("src/other.txt", "");
|
||||||
|
let mut picker = tree(&directory);
|
||||||
|
picker.start_search();
|
||||||
|
assert!(picker.searching);
|
||||||
|
assert_eq!(picker.entries().len(), 4);
|
||||||
|
assert!(picker.expanded.is_empty());
|
||||||
|
picker.toggle_view();
|
||||||
|
assert_eq!(picker.view, FileView::List);
|
||||||
|
picker.toggle_view();
|
||||||
|
assert!(picker.searching);
|
||||||
|
assert!(picker.expanded.is_empty());
|
||||||
|
picker.query = "a.txt".into();
|
||||||
|
picker.refilter();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
|
||||||
|
picker.refresh();
|
||||||
|
assert!(picker.searching);
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
|
||||||
|
let filtered = paths(&picker);
|
||||||
|
picker.finish_search();
|
||||||
|
assert!(!picker.searching);
|
||||||
|
assert_eq!(picker.query, "a.txt");
|
||||||
|
assert_eq!(paths(&picker), filtered);
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
|
||||||
|
assert!(picker.expanded.is_empty());
|
||||||
|
picker.toggle_view();
|
||||||
|
picker.refresh();
|
||||||
|
picker.toggle_view();
|
||||||
|
assert!(!picker.searching);
|
||||||
|
assert_eq!(picker.query, "a.txt");
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
|
||||||
|
picker.start_search();
|
||||||
|
assert!(picker.searching);
|
||||||
|
assert_eq!(picker.query, "a.txt");
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clearing_filter_preserves_mode_focus_current_path_and_empty_result_fallback() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
directory.put("src/deep/needle.txt", "");
|
||||||
|
directory.put("src/other.txt", "");
|
||||||
|
directory.put("other/keep.txt", "");
|
||||||
|
for (view, searching) in [
|
||||||
|
(FileView::Tree, false),
|
||||||
|
(FileView::Tree, true),
|
||||||
|
(FileView::List, false),
|
||||||
|
] {
|
||||||
|
let mut picker = tree(&directory);
|
||||||
|
select(&mut picker, "other");
|
||||||
|
picker.right();
|
||||||
|
let expanded = picker.expanded.clone();
|
||||||
|
picker.set_view(view);
|
||||||
|
if view == FileView::Tree {
|
||||||
|
picker.start_search();
|
||||||
|
}
|
||||||
|
picker.query = "needle".into();
|
||||||
|
picker.refilter();
|
||||||
|
if !searching && view == FileView::Tree {
|
||||||
|
picker.finish_search();
|
||||||
|
}
|
||||||
|
let expected = Path::new("src/deep/needle.txt");
|
||||||
|
assert_eq!(selected_path(&picker), expected);
|
||||||
|
picker.clear_filter();
|
||||||
|
assert!(picker.query.is_empty());
|
||||||
|
assert_eq!(picker.searching, searching);
|
||||||
|
assert!(picker.focused);
|
||||||
|
assert_eq!(selected_path(&picker), expected);
|
||||||
|
assert!(picker.expanded.is_superset(&expanded));
|
||||||
|
assert!(paths(&picker).contains(&PathBuf::from("other/keep.txt")));
|
||||||
|
let rows = paths(&picker);
|
||||||
|
let expanded = picker.expanded.clone();
|
||||||
|
picker.clear_filter();
|
||||||
|
assert_eq!(paths(&picker), rows);
|
||||||
|
assert_eq!(picker.expanded, expanded);
|
||||||
|
assert_eq!(selected_path(&picker), expected);
|
||||||
|
assert_eq!(picker.searching, searching);
|
||||||
|
|
||||||
|
picker.query = "absent".into();
|
||||||
|
picker.refilter();
|
||||||
|
picker.navigate(true);
|
||||||
|
picker.navigate(false);
|
||||||
|
assert!(picker.rows.is_empty());
|
||||||
|
picker.refresh();
|
||||||
|
assert!(picker.rows.is_empty());
|
||||||
|
picker.clear_filter();
|
||||||
|
assert_eq!(selected_path(&picker), expected);
|
||||||
|
assert_eq!(picker.searching, searching);
|
||||||
|
assert!(picker.focused);
|
||||||
|
}
|
||||||
|
|
||||||
|
let empty = Directory::new();
|
||||||
|
let mut picker = tree(&empty);
|
||||||
|
picker.start_search();
|
||||||
|
picker.query = "absent".into();
|
||||||
|
picker.refilter();
|
||||||
|
picker.finish_search();
|
||||||
|
picker.clear_filter();
|
||||||
|
assert!(picker.rows.is_empty());
|
||||||
|
assert_eq!(picker.selected, 0);
|
||||||
|
assert!(!picker.searching);
|
||||||
|
assert!(picker.focused);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filtered_navigation_toggles_context_folders_and_query_edits_reveal_matches() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
directory.put("src/deep/needle.txt", "");
|
||||||
|
directory.put("src/other.txt", "");
|
||||||
|
let mut picker = tree(&directory);
|
||||||
|
picker.start_search();
|
||||||
|
picker.query = "needle".into();
|
||||||
|
picker.refilter();
|
||||||
|
let expanded = picker.expanded.clone();
|
||||||
|
picker.left();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src/deep"));
|
||||||
|
picker.right();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt"));
|
||||||
|
assert_eq!(picker.expanded, expanded);
|
||||||
|
picker.finish_search();
|
||||||
|
picker.left();
|
||||||
|
picker.left();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src/deep"));
|
||||||
|
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
|
||||||
|
assert_eq!(picker.query, "needle");
|
||||||
|
assert!(!paths(&picker).contains(&PathBuf::from("src/deep/needle.txt")));
|
||||||
|
picker.start_search();
|
||||||
|
picker.finish_search();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src/deep"));
|
||||||
|
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
|
||||||
|
picker.right();
|
||||||
|
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
|
||||||
|
picker.right();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt"));
|
||||||
|
picker.left();
|
||||||
|
assert!(picker.accept().is_none());
|
||||||
|
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
|
||||||
|
assert!(picker.accept().is_none());
|
||||||
|
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
|
||||||
|
picker.start_search();
|
||||||
|
assert!(picker.accept().is_none());
|
||||||
|
assert!(!picker.searching);
|
||||||
|
assert_eq!(picker.query, "needle");
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src/deep"));
|
||||||
|
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
|
||||||
|
picker.start_search();
|
||||||
|
picker.query.push_str(".txt");
|
||||||
|
picker.refilter();
|
||||||
|
assert!(picker.filter_collapsed.is_empty());
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt"));
|
||||||
|
picker.query.clear();
|
||||||
|
picker.refilter();
|
||||||
|
picker.finish_search();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filtered_collapse_survives_refresh_compaction_and_view_switching_by_raw_path() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
directory.put("one/a.txt", "");
|
||||||
|
directory.put("one/two/needle.txt", "");
|
||||||
|
let mut picker = tree(&directory);
|
||||||
|
picker.start_search();
|
||||||
|
picker.query = "needle".into();
|
||||||
|
picker.refilter();
|
||||||
|
picker.finish_search();
|
||||||
|
picker.left();
|
||||||
|
picker.left();
|
||||||
|
picker.left();
|
||||||
|
picker.left();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("one"));
|
||||||
|
assert_eq!(paths(&picker), [PathBuf::from("one")]);
|
||||||
|
picker.toggle_view();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("one/two/needle.txt"));
|
||||||
|
picker.toggle_view();
|
||||||
|
assert_eq!(paths(&picker), [PathBuf::from("one")]);
|
||||||
|
assert!(!picker.searching);
|
||||||
|
fs::remove_file(directory.0.join("one/a.txt")).unwrap();
|
||||||
|
picker.focused = false;
|
||||||
|
picker.refresh();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("one/two"));
|
||||||
|
assert_eq!(paths(&picker), [PathBuf::from("one/two")]);
|
||||||
|
assert!(!picker.searching && !picker.focused);
|
||||||
|
assert_eq!(picker.query, "needle");
|
||||||
|
directory.put("one/a.txt", "");
|
||||||
|
picker.refresh();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("one"));
|
||||||
|
assert_eq!(paths(&picker), [PathBuf::from("one")]);
|
||||||
|
picker.right();
|
||||||
|
picker.right();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("one/two"));
|
||||||
|
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
|
||||||
|
picker.right();
|
||||||
|
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
|
||||||
|
picker.right();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("one/two/needle.txt"));
|
||||||
|
fs::remove_file(directory.0.join("one/two/needle.txt")).unwrap();
|
||||||
|
picker.refresh();
|
||||||
|
assert!(picker.entries().len() == 0);
|
||||||
|
picker.clear_filter();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("one/two"));
|
||||||
|
assert!(!picker.searching && !picker.focused);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refresh_preserves_expansion_when_directory_chains_merge_and_split() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
directory.put("one/a.txt", "");
|
||||||
|
directory.put("one/two/b.txt", "");
|
||||||
|
let mut picker = tree(&directory);
|
||||||
|
picker.right();
|
||||||
|
assert_eq!(
|
||||||
|
paths(&picker),
|
||||||
|
["one", "one/two", "one/a.txt"].map(PathBuf::from)
|
||||||
|
);
|
||||||
|
fs::remove_file(directory.0.join("one/a.txt")).unwrap();
|
||||||
|
picker.refresh();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("one/two"));
|
||||||
|
assert_eq!(
|
||||||
|
paths(&picker),
|
||||||
|
["one/two", "one/two/b.txt"].map(PathBuf::from)
|
||||||
|
);
|
||||||
|
assert!(picker.entries().next().unwrap().expanded);
|
||||||
|
directory.put("one/a.txt", "");
|
||||||
|
picker.refresh();
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("one/two"));
|
||||||
|
assert_eq!(
|
||||||
|
paths(&picker),
|
||||||
|
["one", "one/two", "one/a.txt"].map(PathBuf::from)
|
||||||
|
);
|
||||||
|
assert!(picker.entries().next().unwrap().expanded);
|
||||||
|
assert!(!picker.entries().nth(1).unwrap().expanded);
|
||||||
|
fs::remove_file(directory.0.join("one/a.txt")).unwrap();
|
||||||
|
picker.refresh();
|
||||||
|
picker.left();
|
||||||
|
assert_eq!(paths(&picker), [PathBuf::from("one/two")]);
|
||||||
|
directory.put("one/a.txt", "");
|
||||||
|
picker.refresh();
|
||||||
|
assert_eq!(paths(&picker), [PathBuf::from("one")]);
|
||||||
|
assert!(!picker.entries().next().unwrap().expanded);
|
||||||
|
}
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn parse(url: &str, provider: Option<&str>) -> Result<Forge, String> {
|
||||||
|
Forge::parse(url, provider, |destination, _| {
|
||||||
|
Ok(destination.rsplit('@').next().unwrap().to_owned())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pages_are_bounded_newest_first_summaries_without_historical_metadata() {
|
||||||
|
for (provider, url, query) in [
|
||||||
|
(
|
||||||
|
Provider::Github,
|
||||||
|
"https://github.com/owner/repo",
|
||||||
|
"state=open&per_page=50&sort=created&direction=desc&page=3",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Provider::Gitlab,
|
||||||
|
"https://gitlab.com/owner/repo",
|
||||||
|
"state=opened&scope=all&per_page=50&order_by=created_at&sort=desc&page=3",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Provider::Gitea,
|
||||||
|
"https://codeberg.org/owner/repo",
|
||||||
|
"state=open&limit=50&page=3",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let forge = parse(url, None).unwrap();
|
||||||
|
let mut calls = 0;
|
||||||
|
let page = forge.page_with(false, 3, |url| {
|
||||||
|
calls += 1;
|
||||||
|
assert_eq!(url, format!("{}?{query}", forge.endpoint));
|
||||||
|
Ok(json!([
|
||||||
|
{"number": 1, "iid": 1, "title": "Older", "state": if provider == Provider::Gitlab { "opened" } else { "open" },
|
||||||
|
"user": null, "author": null, "merged_at": null, "merged": false, "created_at": "2024-01-01T01:00:00+01:00"},
|
||||||
|
{"number": 2, "iid": 2, "title": "Newer", "state": if provider == Provider::Gitlab { "opened" } else { "open" },
|
||||||
|
"user": {"login": "author"}, "author": {"username": "author"}, "merged_at": null, "merged": false,
|
||||||
|
"created_at": "2024-01-01T00:00:00.001Z", "diff_refs": null, "head": {"sha": "not-a-commit"}}
|
||||||
|
]))
|
||||||
|
}).unwrap();
|
||||||
|
assert_eq!(calls, 1);
|
||||||
|
assert_eq!(
|
||||||
|
page.entries.iter().map(|p| p.number).collect::<Vec<_>>(),
|
||||||
|
[2, 1]
|
||||||
|
);
|
||||||
|
assert_eq!(page.entries[0].description, "author · open");
|
||||||
|
assert!(page.entries[1].description.contains("deleted user"));
|
||||||
|
assert!(
|
||||||
|
page.has_more,
|
||||||
|
"A short, server-capped page is not exhaustion"
|
||||||
|
);
|
||||||
|
let empty = forge.page_with(false, 4, |_| Ok(json!([]))).unwrap();
|
||||||
|
assert!(empty.entries.is_empty() && !empty.has_more);
|
||||||
|
assert!(
|
||||||
|
forge
|
||||||
|
.page_with(false, 0, |_| panic!("Page zero must not request"))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gitlab_closed_combines_closed_and_merged_pages_without_open_requests() {
|
||||||
|
let forge = parse("https://gitlab.com/group/repo", None).unwrap();
|
||||||
|
let mut all = Vec::new();
|
||||||
|
for page in 1..=3 {
|
||||||
|
let mut states = Vec::new();
|
||||||
|
let result = forge
|
||||||
|
.page_with(true, page, |url| {
|
||||||
|
assert!(url.ends_with(&format!("&page={page}")));
|
||||||
|
let state = if url.contains("?state=closed&") {
|
||||||
|
"closed"
|
||||||
|
} else {
|
||||||
|
assert!(url.contains("?state=merged&"));
|
||||||
|
"merged"
|
||||||
|
};
|
||||||
|
states.push(state);
|
||||||
|
let (id, date) = match (state, page) {
|
||||||
|
("closed", 1) => (1, "2024-01-01T00:00:00Z"),
|
||||||
|
("merged", 1) => (3, "2024-03-01T00:00:00Z"),
|
||||||
|
("merged", 2) => (2, "2024-02-01T00:00:00Z"),
|
||||||
|
_ => return Ok(json!([])),
|
||||||
|
};
|
||||||
|
Ok(json!([{"iid": id, "title": state, "state": state,
|
||||||
|
"author": null, "created_at": date, "diff_refs": null}]))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(states, ["closed", "merged"]);
|
||||||
|
assert_eq!(result.has_more, page != 3);
|
||||||
|
assert!(result.entries.iter().all(|p| p.closed));
|
||||||
|
if page == 1 {
|
||||||
|
assert_eq!(
|
||||||
|
result.entries.iter().map(|p| p.number).collect::<Vec<_>>(),
|
||||||
|
[3, 1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The second merged page still precedes the first closed page.
|
||||||
|
all.extend(result.entries);
|
||||||
|
}
|
||||||
|
all.sort_unstable_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||||
|
assert_eq!(all.iter().map(|p| p.number).collect::<Vec<_>>(), [3, 2, 1]);
|
||||||
|
let raced = forge.page_with(true, 1, |url| {
|
||||||
|
Ok(json!([{"iid": 7, "title": "Raced merge", "state": if url.contains("?state=merged&") { "merged" } else { "closed" },
|
||||||
|
"author": null, "created_at": "2024-01-01T00:00:00Z"}]))
|
||||||
|
}).unwrap();
|
||||||
|
assert_eq!(raced.entries.len(), 1);
|
||||||
|
assert!(raced.entries[0].description.contains("merged"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timestamps_normalize_offsets_precision_and_calendar_boundaries() {
|
||||||
|
for (input, expected) in [
|
||||||
|
(
|
||||||
|
"2024-03-01T00:30:12.5+01:00",
|
||||||
|
"2024-02-29T23:30:12.500000000Z",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"2023-12-31T23:30:00-01:00",
|
||||||
|
"2024-01-01T00:30:00.000000000Z",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"2024-01-01T00:00:00.000000001Z",
|
||||||
|
"2024-01-01T00:00:00.000000001Z",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert_eq!(created_at(&json!({"created_at": input})).unwrap(), expected);
|
||||||
|
}
|
||||||
|
for input in [
|
||||||
|
"",
|
||||||
|
"not a date",
|
||||||
|
"2023-02-29T00:00:00Z",
|
||||||
|
"2024-13-01T00:00:00Z",
|
||||||
|
"2024-01-01T24:00:00Z",
|
||||||
|
"2024-01-01T00:00:00",
|
||||||
|
"2024-01-01T00:00:00+24:00",
|
||||||
|
"2024-01-01T00:00:00.Z",
|
||||||
|
"2024-01-01T00:00:00.1234567890Z",
|
||||||
|
"0001-01-01T00:00:00+01:00",
|
||||||
|
"9999-12-31T23:00:00-02:00",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
created_at(&json!({"created_at": input})).is_err(),
|
||||||
|
"{input}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolution_reads_only_selected_detail_and_exact_preserved_head() {
|
||||||
|
for (url, provider) in [
|
||||||
|
("https://github.com/owner/repo", Provider::Github),
|
||||||
|
("https://gitlab.com/group/repo", Provider::Gitlab),
|
||||||
|
("https://codeberg.org/owner/repo", Provider::Gitea),
|
||||||
|
] {
|
||||||
|
let forge = parse(url, None).unwrap();
|
||||||
|
let mut calls = Vec::new();
|
||||||
|
let pr = forge.resolve_with(7, |url| {
|
||||||
|
calls.push(url.to_owned());
|
||||||
|
if url == format!("{}/7", forge.endpoint) {
|
||||||
|
Ok(json!({"number": 7, "iid": 7, "title": "Selected", "state": if provider == Provider::Gitlab { "merged" } else { "closed" },
|
||||||
|
"merged_at": "2024-01-01T00:00:00Z", "merged": true, "user": null, "author": null,
|
||||||
|
"base": {"ref": "main", "sha": "1".repeat(40)}, "head": {"ref": "topic", "sha": "2".repeat(40)},
|
||||||
|
"merge_base": "3".repeat(40), "target_branch": "main", "source_branch": "topic",
|
||||||
|
"diff_refs": {"start_sha": null, "base_sha": "4".repeat(40), "head_sha": "5".repeat(40)},
|
||||||
|
"url": "https://evil.example/detail", "references": "https://evil.example/refs"}))
|
||||||
|
} else {
|
||||||
|
assert_eq!(url, format!("{}/git/refs/pull/7/head", forge.endpoint.strip_suffix("/pulls").unwrap()));
|
||||||
|
Ok(json!([{"ref": "refs/pull/7/head", "object": {"type": "commit", "sha": "6".repeat(40)}}]))
|
||||||
|
}
|
||||||
|
}).unwrap();
|
||||||
|
assert_eq!(calls.len(), if provider == Provider::Gitea { 2 } else { 1 });
|
||||||
|
let revisions = pr.revisions.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
revisions.base_sha,
|
||||||
|
match provider {
|
||||||
|
Provider::Github => "1",
|
||||||
|
Provider::Gitlab => "4",
|
||||||
|
Provider::Gitea => "3",
|
||||||
|
}
|
||||||
|
.repeat(40)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
revisions.head_sha,
|
||||||
|
match provider {
|
||||||
|
Provider::Github => "2",
|
||||||
|
Provider::Gitlab => "5",
|
||||||
|
Provider::Gitea => "6",
|
||||||
|
}
|
||||||
|
.repeat(40)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
forge
|
||||||
|
.resolve_with(0, |_| panic!("Zero must not request"))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let mut calls = 0;
|
||||||
|
assert!(
|
||||||
|
forge
|
||||||
|
.resolve_with(7, |_| {
|
||||||
|
calls += 1;
|
||||||
|
Ok(json!({"number": 8, "iid": 8}))
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
calls, 1,
|
||||||
|
"Reject wrong detail identity before reading historical refs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gitea_preserved_heads_reject_missing_ambiguous_or_foreign_refs() {
|
||||||
|
let reference =
|
||||||
|
json!({"ref": "refs/pull/7/head", "object": {"type": "commit", "sha": "a".repeat(40)}});
|
||||||
|
assert_eq!(gitea_head(&reference, 7).unwrap(), "a".repeat(40));
|
||||||
|
assert_eq!(
|
||||||
|
gitea_head(&json!([reference.clone()]), 7).unwrap(),
|
||||||
|
"a".repeat(40)
|
||||||
|
);
|
||||||
|
for bad in [
|
||||||
|
json!([]),
|
||||||
|
json!([reference.clone(), reference.clone()]),
|
||||||
|
json!({"ref": "refs/pull/8/head", "object": reference["object"]}),
|
||||||
|
json!({"ref": "refs/pull/7/head", "object": {"type": "tag", "sha": "a".repeat(40)}}),
|
||||||
|
json!({"ref": "refs/pull/7/head", "object": {"type": "commit", "sha": "0".repeat(40)}}),
|
||||||
|
] {
|
||||||
|
assert!(gitea_head(&bad, 7).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ssh_aliases_use_effective_hostname_and_preserve_user_matching() {
|
||||||
|
use std::{fs, io::Write, path::PathBuf};
|
||||||
|
struct Config(PathBuf);
|
||||||
|
impl Drop for Config {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_file(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let path = std::env::temp_dir().join(format!("vamp-forge-ssh-{}.conf", std::process::id()));
|
||||||
|
let mut file = fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(&path)
|
||||||
|
.unwrap();
|
||||||
|
let config = Config(path);
|
||||||
|
file.write_all(b"Host gitea-personal\n HostName gitea.example.org\n Port 2222\nHost github-personal\n HostName ssh.github.com\nHost user-match\n Port 2222\nMatch originalhost user-match user git\n HostName gitlab.example.org\n").unwrap();
|
||||||
|
for (url, expected) in [
|
||||||
|
(
|
||||||
|
"git@gitea-personal:owner/repo.git",
|
||||||
|
"https://gitea.example.org/api/v1/repos/owner/repo/pulls",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ssh://git@gitea-personal:2200/owner/repo.git",
|
||||||
|
"https://gitea.example.org/api/v1/repos/owner/repo/pulls",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"git@github-personal:owner/repo.git",
|
||||||
|
"https://api.github.com/repos/owner/repo/pulls",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"git@user-match:group/sub/repo.git",
|
||||||
|
"https://gitlab.example.org/api/v4/projects/group%2Fsub%2Frepo/merge_requests",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let forge = Forge::parse(url, None, |destination, port| {
|
||||||
|
let mut command = Command::new("ssh");
|
||||||
|
command.arg("-F").arg(&config.0);
|
||||||
|
ssh_hostname(command, destination, port, &AtomicBool::new(false))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(forge.endpoint, expected);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
Forge::parse("git@alias:owner/repo", Some("gitea"), |_, _| Ok(
|
||||||
|
"evil.example/path".into()
|
||||||
|
))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
Forge::parse(
|
||||||
|
"ssh://user:secret@alias/owner/repo",
|
||||||
|
Some("gitea"),
|
||||||
|
|_, _| { panic!("Password-bearing SSH URLs must be rejected before starting SSH") }
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Forge::parse("https://github.com/owner/repo", None, |_, _| panic!(
|
||||||
|
"HTTPS must not invoke SSH"
|
||||||
|
))
|
||||||
|
.unwrap()
|
||||||
|
.host,
|
||||||
|
"github.com"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_urls_preserve_namespaces_and_keep_ssh_ports_out_of_https() {
|
||||||
|
let gitlab = parse("ssh://git@gitlab.example.org:2222/group/sub/repo.git", None).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
gitlab.endpoint,
|
||||||
|
"https://gitlab.example.org/api/v4/projects/group%2Fsub%2Frepo/merge_requests"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse("git@github.com:owner/repo.git", None)
|
||||||
|
.unwrap()
|
||||||
|
.endpoint,
|
||||||
|
"https://api.github.com/repos/owner/repo/pulls"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse(
|
||||||
|
"https://user:secret@forge.example:8443/owner/repo.git",
|
||||||
|
Some("gitea")
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.endpoint,
|
||||||
|
"https://forge.example:8443/api/v1/repos/owner/repo/pulls"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
parse("https://forge.example/owner/repo", None)
|
||||||
|
.err()
|
||||||
|
.unwrap()
|
||||||
|
.contains("git config remote.<name>.vamp-forge github")
|
||||||
|
);
|
||||||
|
for url in [
|
||||||
|
"https://github.com/owner/repo?token=secret",
|
||||||
|
"https://github.com/owner/%2e%2e",
|
||||||
|
"https://github.com/owner/repo%0a",
|
||||||
|
"https://github.com:0/owner/repo",
|
||||||
|
"file:///owner/repo",
|
||||||
|
] {
|
||||||
|
assert!(parse(url, Some("github")).is_err(), "{url}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fork_pull_requests_pin_historical_shas_not_merge_commits_or_response_urls() {
|
||||||
|
let value = json!({"number": 7, "title": "Merged fork", "state": "closed", "merged_at": "2026-01-01T00:00:00Z", "merged": true,
|
||||||
|
"user": {"login": "author"}, "base": {"ref": "release/next", "sha": "1".repeat(40)},
|
||||||
|
"head": {"ref": "feature", "label": "fork:topic", "sha": "2".repeat(40), "repo": null}, "merge_base": "4".repeat(40), "merge_commit_sha": "3".repeat(40), "url": "https://evil.example/token"});
|
||||||
|
for provider in [Provider::Github, Provider::Gitea] {
|
||||||
|
let pr = pull_request(provider, &value, Some("5".repeat(40))).unwrap();
|
||||||
|
let revisions = pr.revisions.as_ref().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(revisions.base_ref.as_str(), revisions.head_ref.as_str()),
|
||||||
|
("refs/heads/release/next", "refs/pull/7/head")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
revisions.base_sha,
|
||||||
|
if provider == Provider::Gitea {
|
||||||
|
"4"
|
||||||
|
} else {
|
||||||
|
"1"
|
||||||
|
}
|
||||||
|
.repeat(40)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
revisions.head_sha,
|
||||||
|
if provider == Provider::Gitea {
|
||||||
|
"5"
|
||||||
|
} else {
|
||||||
|
"2"
|
||||||
|
}
|
||||||
|
.repeat(40)
|
||||||
|
);
|
||||||
|
if provider == Provider::Gitea {
|
||||||
|
let mut deleted = value.clone();
|
||||||
|
deleted["head"]["sha"] = Value::String(String::new());
|
||||||
|
assert_eq!(
|
||||||
|
pull_request(provider, &deleted, Some("5".repeat(40)))
|
||||||
|
.unwrap()
|
||||||
|
.revisions
|
||||||
|
.unwrap()
|
||||||
|
.head_sha,
|
||||||
|
"5".repeat(40)
|
||||||
|
);
|
||||||
|
let unavailable = pull_request(provider, &deleted, None).unwrap();
|
||||||
|
assert!(unavailable.revisions.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gitlab_uses_target_start_sha_and_rejects_unprepared_or_unsafe_metadata() {
|
||||||
|
let mut value = json!({"iid": 9, "title": "MR", "state": "merged", "author": {"username": "author"},
|
||||||
|
"target_branch": "main", "source_branch": "topic", "diff_refs": {"base_sha": "1".repeat(40), "start_sha": "2".repeat(40), "head_sha": "3".repeat(40)}});
|
||||||
|
let pr = pull_request(Provider::Gitlab, &value, None).unwrap();
|
||||||
|
let revisions = pr.revisions.unwrap();
|
||||||
|
assert_eq!(revisions.base_sha, "2".repeat(40));
|
||||||
|
assert_eq!(revisions.head_ref, "refs/merge-requests/9/head");
|
||||||
|
value["diff_refs"]["start_sha"] = Value::Null;
|
||||||
|
assert_eq!(
|
||||||
|
pull_request(Provider::Gitlab, &value, None)
|
||||||
|
.unwrap()
|
||||||
|
.revisions
|
||||||
|
.unwrap()
|
||||||
|
.base_sha,
|
||||||
|
"1".repeat(40)
|
||||||
|
);
|
||||||
|
value["target_branch"] = json!("main:refs/heads/injected");
|
||||||
|
assert!(
|
||||||
|
pull_request(Provider::Gitlab, &value, None)
|
||||||
|
.unwrap()
|
||||||
|
.revisions
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
value["target_branch"] = json!("main");
|
||||||
|
value["diff_refs"]["head_sha"] = json!("HEAD");
|
||||||
|
assert!(
|
||||||
|
pull_request(Provider::Gitlab, &value, None)
|
||||||
|
.unwrap()
|
||||||
|
.revisions
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
value["diff_refs"] = Value::Null;
|
||||||
|
assert!(
|
||||||
|
pull_request(Provider::Gitlab, &value, None)
|
||||||
|
.unwrap()
|
||||||
|
.revisions
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
use super::*;
|
||||||
|
use std::ffi::OsString;
|
||||||
|
|
||||||
|
static NEXT_TEST: AtomicU64 = AtomicU64::new(0);
|
||||||
|
struct Temporary(PathBuf);
|
||||||
|
impl Temporary {
|
||||||
|
fn new() -> Self {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"vamp-gutter-test-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
NEXT_TEST.fetch_add(1, Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
fs::create_dir(&path).unwrap();
|
||||||
|
Self(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for Temporary {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn command(root: &Path, program: &str, args: &[&str]) -> String {
|
||||||
|
let mut command = Command::new(program);
|
||||||
|
if program == "jj" {
|
||||||
|
command.args([
|
||||||
|
"--config=user.name=\"Gutter Author\"",
|
||||||
|
"--config=user.email=\"gutter@example.test\"",
|
||||||
|
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||||
|
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
let output = command
|
||||||
|
.args(args)
|
||||||
|
.current_dir(root)
|
||||||
|
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||||
|
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
||||||
|
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
|
||||||
|
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
|
||||||
|
.env("JJ_CONFIG", "/dev/null")
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
String::from_utf8(output.stdout).unwrap().trim().into()
|
||||||
|
}
|
||||||
|
fn git_fixture() -> (Temporary, PathBuf) {
|
||||||
|
let temp = Temporary::new();
|
||||||
|
command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
||||||
|
command(&temp.0, "git", &["config", "user.name", "Gutter Author"]);
|
||||||
|
command(
|
||||||
|
&temp.0,
|
||||||
|
"git",
|
||||||
|
&["config", "user.email", "gutter@example.test"],
|
||||||
|
);
|
||||||
|
let path = temp.0.join("-literal [file]\n\t\".txt");
|
||||||
|
fs::write(&path, "first\nsecond\nthird\n").unwrap();
|
||||||
|
command(
|
||||||
|
&temp.0,
|
||||||
|
"git",
|
||||||
|
&["add", "--", path.file_name().unwrap().to_str().unwrap()],
|
||||||
|
);
|
||||||
|
command(&temp.0, "git", &["commit", "--quiet", "-m", "Baseline"]);
|
||||||
|
(temp, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_replacements_surplus_deletions_empty_and_eof_rows() {
|
||||||
|
use Change::*;
|
||||||
|
for (before, after, expected) in [
|
||||||
|
("first\nsecond\n", "first\nsecond\n", vec![None, None, None]),
|
||||||
|
(
|
||||||
|
"first\r\nsecond\r\n",
|
||||||
|
"first\nsecond\n",
|
||||||
|
vec![None, None, None],
|
||||||
|
),
|
||||||
|
("first\rsecond\r", "first\nsecond\n", vec![None, None, None]),
|
||||||
|
(
|
||||||
|
"first\r\nsecond\r\nthird\r\n",
|
||||||
|
"first\nchanged\nthird\n",
|
||||||
|
vec![None, Some(Modified), None, None],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"first\nsecond\nthird\n",
|
||||||
|
"first\rchanged\rthird\r",
|
||||||
|
vec![None, Some(Modified), None, None],
|
||||||
|
),
|
||||||
|
("first\n", "first\nnew\n", vec![None, Some(Added), None]),
|
||||||
|
(
|
||||||
|
"old\n",
|
||||||
|
"changed\nsurplus\n",
|
||||||
|
vec![Some(Modified), Some(Added), None],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"old\nextra\nlast\n",
|
||||||
|
"changed\nlast\n",
|
||||||
|
vec![Some(Modified), None, None],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"first\nremoved\nlast\n",
|
||||||
|
"first\nlast\n",
|
||||||
|
vec![None, Some(Removed), None],
|
||||||
|
),
|
||||||
|
("first\nremoved\n", "first\n", vec![None, Some(Removed)]),
|
||||||
|
("first\nremoved", "first", vec![Some(Modified)]),
|
||||||
|
("first\nremoved", "first\n", vec![None, Some(Removed)]),
|
||||||
|
("removed\n", "", vec![Some(Removed)]),
|
||||||
|
("", "new\n", vec![Some(Added), None]),
|
||||||
|
("", "", vec![None]),
|
||||||
|
("same", "same\n", vec![Some(Modified), None]),
|
||||||
|
] {
|
||||||
|
assert_eq!(changes(before, after), expected, "{before:?} -> {after:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_crlf_baseline_matches_normalized_editor_rows() {
|
||||||
|
let (temp, _) = git_fixture();
|
||||||
|
let path = temp.0.join("crlf");
|
||||||
|
fs::write(&path, "first\r\nsecond\r\nthird\r\n").unwrap();
|
||||||
|
command(&temp.0, "git", &["add", "--", "crlf"]);
|
||||||
|
command(
|
||||||
|
&temp.0,
|
||||||
|
"git",
|
||||||
|
&["commit", "--quiet", "-m", "CRLF baseline"],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_file(&path, "first\nsecond\nthird\n", &|| false).unwrap(),
|
||||||
|
vec![None, None, None, None]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_file(&path, "first\nchanged\nthird\n", &|| false).unwrap(),
|
||||||
|
vec![None, Some(Change::Modified), None, None]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_head_includes_staged_disk_and_unsaved_edits_without_writes() {
|
||||||
|
let (temp, path) = git_fixture();
|
||||||
|
fs::write(&path, "staged\nsecond\nthird\n").unwrap();
|
||||||
|
command(
|
||||||
|
&temp.0,
|
||||||
|
"git",
|
||||||
|
&["add", "--", path.file_name().unwrap().to_str().unwrap()],
|
||||||
|
);
|
||||||
|
fs::write(&path, "staged\ndisk\nthird\n").unwrap();
|
||||||
|
let index = fs::read(temp.0.join(".git/index")).unwrap();
|
||||||
|
let head = command(&temp.0, "git", &["rev-parse", "HEAD"]);
|
||||||
|
assert_eq!(
|
||||||
|
read_file(&path, "staged\ndisk\nunsaved\n", &|| false).unwrap(),
|
||||||
|
vec![
|
||||||
|
Some(Change::Modified),
|
||||||
|
Some(Change::Modified),
|
||||||
|
Some(Change::Modified),
|
||||||
|
None
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(fs::read(temp.0.join(".git/index")).unwrap(), index);
|
||||||
|
assert_eq!(command(&temp.0, "git", &["rev-parse", "HEAD"]), head);
|
||||||
|
assert_eq!(fs::read_to_string(&path).unwrap(), "staged\ndisk\nthird\n");
|
||||||
|
fs::remove_file(&path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
read_file(&path, "first\nsecond\nthird\n", &|| false).unwrap(),
|
||||||
|
vec![None, None, None, None]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_file(&temp.0.join("new/named"), "unsaved\n", &|| false).unwrap(),
|
||||||
|
vec![Some(Change::Added), None]
|
||||||
|
);
|
||||||
|
let untracked = temp.0.join("untracked");
|
||||||
|
fs::write(&untracked, "unsaved\n").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
read_file(&untracked, "unsaved\n", &|| false).unwrap(),
|
||||||
|
vec![Some(Change::Added), None]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unborn_untracked_missing_ignored_metadata_binary_and_symlink_paths() {
|
||||||
|
let temp = Temporary::new();
|
||||||
|
let path = temp.0.join("new/named");
|
||||||
|
assert!(read_file(&path, "unsaved\n", &|| false).unwrap().is_empty());
|
||||||
|
command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
||||||
|
assert_eq!(
|
||||||
|
read_file(&path, "unsaved\n", &|| false).unwrap(),
|
||||||
|
vec![Some(Change::Added), None]
|
||||||
|
);
|
||||||
|
let disk = temp.0.join("untracked");
|
||||||
|
fs::write(&disk, "disk\n").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
read_file(&disk, "unsaved\n", &|| false).unwrap(),
|
||||||
|
vec![Some(Change::Added), None]
|
||||||
|
);
|
||||||
|
fs::write(temp.0.join(".gitignore"), "new/\nuntracked\n").unwrap();
|
||||||
|
assert!(read_file(&path, "unsaved\n", &|| false).unwrap().is_empty());
|
||||||
|
assert!(read_file(&disk, "unsaved\n", &|| false).unwrap().is_empty());
|
||||||
|
assert!(
|
||||||
|
read_file(&temp.0.join(".git/config"), "text\n", &|| false)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
read_file(&disk, "binary\0text", &|| false)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
std::os::unix::fs::symlink(&disk, temp.0.join("link")).unwrap();
|
||||||
|
assert!(
|
||||||
|
read_file(&temp.0.join("link"), "text\n", &|| false)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
std::os::unix::fs::symlink(&temp.0, temp.0.join("linked")).unwrap();
|
||||||
|
assert!(
|
||||||
|
read_file(&temp.0.join("linked/untracked"), "text\n", &|| false)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_non_utf8_filename_is_literal() {
|
||||||
|
use std::os::unix::ffi::OsStringExt;
|
||||||
|
let (temp, _) = git_fixture();
|
||||||
|
let name = OsString::from_vec(b"non-utf8-\xff".to_vec());
|
||||||
|
let path = temp.0.join(&name);
|
||||||
|
match fs::write(&path, "old\n") {
|
||||||
|
Ok(()) => {}
|
||||||
|
// APFS rejects non-UTF-8 names before repository code can read them.
|
||||||
|
Err(error) if cfg!(target_os = "macos") && error.raw_os_error() == Some(92) => return,
|
||||||
|
Err(error) => panic!("Cannot create non-UTF-8 fixture: {error}"),
|
||||||
|
}
|
||||||
|
let mut add = git(&temp.0);
|
||||||
|
add.args(["add", "--"]).arg(&name);
|
||||||
|
checked(add, &|| false).unwrap();
|
||||||
|
command(&temp.0, "git", &["commit", "--quiet", "-m", "Byte path"]);
|
||||||
|
assert_eq!(
|
||||||
|
read_file(&path, "changed\n", &|| false).unwrap(),
|
||||||
|
vec![Some(Change::Modified), None]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jj_parent_root_colocation_ignore_and_merge_queries_are_read_only() {
|
||||||
|
for colocate in ["--colocate", "--no-colocate"] {
|
||||||
|
let temp = Temporary::new();
|
||||||
|
command(&temp.0, "jj", &["git", "init", colocate, "."]);
|
||||||
|
let name = "-literal [file]\n\t\".txt";
|
||||||
|
let path = temp.0.join(name);
|
||||||
|
fs::write(&path, "original\n").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
read_file(&path, "unsaved\n", &|| false).unwrap(),
|
||||||
|
vec![Some(Change::Added), None]
|
||||||
|
);
|
||||||
|
fs::write(temp.0.join(".gitignore"), "ignored\n").unwrap();
|
||||||
|
assert!(
|
||||||
|
read_file(&temp.0.join("ignored"), "text\n", &|| false)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
command(&temp.0, "jj", &["describe", "-m", "Baseline"]);
|
||||||
|
command(&temp.0, "jj", &["new"]);
|
||||||
|
let operation = command(
|
||||||
|
&temp.0,
|
||||||
|
"jj",
|
||||||
|
&[
|
||||||
|
"--ignore-working-copy",
|
||||||
|
"op",
|
||||||
|
"log",
|
||||||
|
"--no-graph",
|
||||||
|
"-n",
|
||||||
|
"1",
|
||||||
|
"-T",
|
||||||
|
"id",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let store = store_for_jj(&temp.0).unwrap();
|
||||||
|
let refs = fs::read(store.join("HEAD")).unwrap();
|
||||||
|
fs::write(&path, "disk edit\n").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
read_file(&path, "inserted\noriginal\n", &|| false).unwrap(),
|
||||||
|
vec![Some(Change::Added), None, None]
|
||||||
|
);
|
||||||
|
assert_eq!(fs::read(store.join("HEAD")).unwrap(), refs);
|
||||||
|
assert_eq!(
|
||||||
|
command(
|
||||||
|
&temp.0,
|
||||||
|
"jj",
|
||||||
|
&[
|
||||||
|
"--ignore-working-copy",
|
||||||
|
"op",
|
||||||
|
"log",
|
||||||
|
"--no-graph",
|
||||||
|
"-n",
|
||||||
|
"1",
|
||||||
|
"-T",
|
||||||
|
"id",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
operation
|
||||||
|
);
|
||||||
|
let parent = command(
|
||||||
|
&temp.0,
|
||||||
|
"jj",
|
||||||
|
&[
|
||||||
|
"--ignore-working-copy",
|
||||||
|
"log",
|
||||||
|
"--no-graph",
|
||||||
|
"-r",
|
||||||
|
"@-",
|
||||||
|
"-T",
|
||||||
|
"commit_id",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
command(&temp.0, "jj", &["new", "root()"]);
|
||||||
|
fs::write(temp.0.join("sibling"), "sibling\n").unwrap();
|
||||||
|
command(&temp.0, "jj", &["describe", "-m", "Sibling"]);
|
||||||
|
command(&temp.0, "jj", &["new", &parent, "@"]);
|
||||||
|
assert!(
|
||||||
|
read_file(&path, "original\n", &|| false)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_retains_only_latest_batch_and_file_errors_keep_good_siblings() {
|
||||||
|
let (_temp, path) = git_fixture();
|
||||||
|
let mut worker = Worker::start().unwrap();
|
||||||
|
let oversized: Arc<str> = "x".repeat(FILE_LIMIT + 1).into();
|
||||||
|
for id in 0..20 {
|
||||||
|
worker
|
||||||
|
.request(
|
||||||
|
id,
|
||||||
|
vec![
|
||||||
|
(5, path.clone(), "first\nchanged\nthird\n".into()),
|
||||||
|
(8, path.clone(), Arc::clone(&oversized)),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
if let Some((id, result, picker)) = worker.poll() {
|
||||||
|
assert!(picker.is_none());
|
||||||
|
assert_eq!(id, 19);
|
||||||
|
assert_eq!(result[0].0, 5);
|
||||||
|
assert_eq!(
|
||||||
|
result[0].1.as_ref().unwrap(),
|
||||||
|
&vec![None, Some(Change::Modified), None, None]
|
||||||
|
);
|
||||||
|
assert_eq!(result[1].0, 8);
|
||||||
|
assert_eq!(
|
||||||
|
result[1].1.as_ref().unwrap_err(),
|
||||||
|
"Gutter file exceeds 16 MiB"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
thread::yield_now();
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
worker
|
||||||
|
.request(
|
||||||
|
20,
|
||||||
|
vec![(0, path, "x".repeat(BATCH_LIMIT + 1).into())],
|
||||||
|
None
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
io::ErrorKind::InvalidInput
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn picker_status_is_optional_and_errors_do_not_discard_line_results() {
|
||||||
|
let (temp, path) = git_fixture();
|
||||||
|
let mut worker = Worker::start().unwrap();
|
||||||
|
worker
|
||||||
|
.request(
|
||||||
|
1,
|
||||||
|
vec![],
|
||||||
|
Some((
|
||||||
|
temp.0.clone(),
|
||||||
|
vec![(path.clone(), "dirty buffer\n".into())],
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
loop {
|
||||||
|
if let Some((id, lines, picker)) = worker.poll() {
|
||||||
|
assert_eq!(id, 1);
|
||||||
|
assert!(lines.is_empty());
|
||||||
|
let picker = picker.unwrap();
|
||||||
|
assert_eq!(picker.root, temp.0);
|
||||||
|
let changes = picker.result.unwrap();
|
||||||
|
assert_eq!(changes.len(), 1);
|
||||||
|
assert_eq!(changes[0].path, path.strip_prefix(&temp.0).unwrap());
|
||||||
|
assert_eq!(changes[0].status, crate::diff::FileStatus::Modified);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
thread::yield_now();
|
||||||
|
}
|
||||||
|
worker
|
||||||
|
.request(
|
||||||
|
2,
|
||||||
|
vec![(4, path.clone(), "first\nchanged\nthird\n".into())],
|
||||||
|
Some((
|
||||||
|
temp.0.clone(),
|
||||||
|
vec![(path, "x".repeat(FILE_LIMIT + 1).into())],
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
loop {
|
||||||
|
if let Some((id, lines, picker)) = worker.poll() {
|
||||||
|
assert_eq!(id, 2);
|
||||||
|
assert_eq!(lines[0].0, 4);
|
||||||
|
assert!(lines[0].1.is_ok());
|
||||||
|
assert!(picker.unwrap().result.unwrap_err().contains("16 MiB"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
thread::yield_now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_definitions_preserve_snapshot_identity_and_utf16_until_navigation() {
|
||||||
|
let source = Source {
|
||||||
|
buffer_id: 7,
|
||||||
|
revision: 3,
|
||||||
|
row: 0,
|
||||||
|
column: 0,
|
||||||
|
request_id: 11,
|
||||||
|
};
|
||||||
|
let path = Path::new("/vamp-immutable-snapshot/Before/renamed/λ.rs");
|
||||||
|
let span = json!({
|
||||||
|
"start": {"line": 4, "character": 5},
|
||||||
|
"end": {"line": 4, "character": 8}
|
||||||
|
});
|
||||||
|
for location in [
|
||||||
|
json!({"uri": file_uri(path).unwrap(), "range": span}),
|
||||||
|
json!({"targetUri": file_uri(path).unwrap(),
|
||||||
|
"targetRange": span, "targetSelectionRange": span}),
|
||||||
|
] {
|
||||||
|
let effects = Runtime::definition_effects(&source, &location).unwrap();
|
||||||
|
let expected_target_range = location.get("targetUri").map(|_| Utf16Range {
|
||||||
|
start: (4, 5),
|
||||||
|
end: (4, 8),
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
effects.as_slice(),
|
||||||
|
[UpdateEffect::NavigateUtf16 {
|
||||||
|
buffer_id: 7, revision: 3, path: target,
|
||||||
|
range: Utf16Range { start: (4, 5), end: (4, 8) }, target_range,
|
||||||
|
}] if target == path && *target_range == expected_target_range
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for invalid in [
|
||||||
|
json!({"start": {"line": 4, "character": 8},
|
||||||
|
"end": {"line": 4, "character": 5}}),
|
||||||
|
json!({"start": {"line": -1, "character": 0},
|
||||||
|
"end": {"line": 0, "character": 0}}),
|
||||||
|
] {
|
||||||
|
for location in [
|
||||||
|
json!({"uri": file_uri(path).unwrap(), "range": invalid}),
|
||||||
|
json!({"targetUri": file_uri(path).unwrap(),
|
||||||
|
"targetRange": invalid, "targetSelectionRange": span}),
|
||||||
|
json!({"targetUri": file_uri(path).unwrap(),
|
||||||
|
"targetRange": span, "targetSelectionRange": invalid}),
|
||||||
|
] {
|
||||||
|
assert!(Runtime::definition_effects(&source, &location).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<UpdateEffect>(json!({
|
||||||
|
"type": "navigate_utf16", "buffer_id": 7, "revision": 3,
|
||||||
|
"path": path, "range": {"start": [4,5], "end": [4,8]},
|
||||||
|
"target_range": null
|
||||||
|
}))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn semantic_decoder_validates_raw_coordinates_before_grapheme_rounding() {
|
||||||
|
let options = semantic_options(Some(&json!({
|
||||||
|
"full": true,
|
||||||
|
"legend": {"tokenTypes": ["custom", "function"],
|
||||||
|
"tokenModifiers": ["custom", "readonly", "declaration", "async"]}
|
||||||
|
})))
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let tokens = decode_semantic_tokens(
|
||||||
|
"A😀e\u{301}Z\r\n\r\nx",
|
||||||
|
&json!({"data": [
|
||||||
|
0, 1, 2, 1, 2,
|
||||||
|
0, 2, 1, 1, 12,
|
||||||
|
0, 1, 1, 1, 0,
|
||||||
|
0, 1, 999, 0, 0,
|
||||||
|
1, 0, 1, 1, 0,
|
||||||
|
1, 0, 0, 1, 0,
|
||||||
|
0, 0, 100, 1, 0
|
||||||
|
]}),
|
||||||
|
&options,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(tokens.len(), 4);
|
||||||
|
assert_eq!(tokens[0].start, TextPosition { row: 0, column: 1 });
|
||||||
|
assert_eq!(tokens[0].end, TextPosition { row: 0, column: 2 });
|
||||||
|
assert_eq!(tokens[0].modifiers, SemanticTokenModifier::Readonly as u16);
|
||||||
|
assert_eq!(tokens[1].start, TextPosition { row: 0, column: 2 });
|
||||||
|
assert_eq!(tokens[1].end, TextPosition { row: 0, column: 3 });
|
||||||
|
assert_eq!(
|
||||||
|
tokens[1].modifiers,
|
||||||
|
SemanticTokenModifier::Declaration as u16 | SemanticTokenModifier::Async as u16
|
||||||
|
);
|
||||||
|
assert_eq!(tokens[2].start, tokens[1].start);
|
||||||
|
assert_eq!(tokens[3].end, TextPosition { row: 2, column: 1 });
|
||||||
|
for data in [
|
||||||
|
json!([0, 0, 1, 1]),
|
||||||
|
json!([-1, 0, 1, 1, 0]),
|
||||||
|
json!([0, 0.5, 1, 1, 0]),
|
||||||
|
json!([0, 0, 1, 1, 2147483648u64]),
|
||||||
|
json!([0, 0, 1, 65536, 0]),
|
||||||
|
json!([0, 0, 1, 2, 0]),
|
||||||
|
json!([0, 0, 1, 1, 16]),
|
||||||
|
json!([0, 2, 1, 0, 0]),
|
||||||
|
json!([0, 1, 1, 1, 0]),
|
||||||
|
json!([0, 7, 1, 1, 0]),
|
||||||
|
json!([2, 0, 1, 1, 0]),
|
||||||
|
json!([0, 1, 2, 1, 0, 0, 1, 1, 1, 0]),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
decode_semantic_tokens("A😀e\u{301}Z\n", &json!({"data": data}), &options).is_err(),
|
||||||
|
"{data}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let count = MAX_BODY / std::mem::size_of::<SemanticToken>() + 1;
|
||||||
|
assert!(decode_semantic_tokens("x", &json!({"data": vec![0; count * 5]}), &options).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unicode_positions_and_local_uri_boundaries() {
|
||||||
|
let text = "A😀e\u{301}Z\n";
|
||||||
|
let (start, end) = range(
|
||||||
|
text,
|
||||||
|
&json!({"start": {"line": 0, "character": 2}, "end": {"line": 0, "character": 4}}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(start, TextPosition { row: 0, column: 1 });
|
||||||
|
assert_eq!(end, TextPosition { row: 0, column: 3 });
|
||||||
|
assert_eq!(
|
||||||
|
outbound_position(text, 0, 3).unwrap(),
|
||||||
|
json!({"line": 0, "character": 5})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
position(text, &json!({"line": 1, "character": 999}), false).unwrap(),
|
||||||
|
TextPosition { row: 1, column: 0 }
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
range(
|
||||||
|
text,
|
||||||
|
&json!({"start": {"line": 0, "character": 3}, "end": {"line": 0, "character": 2}})
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let path = Path::new("/tmp/source space λ😀.rs");
|
||||||
|
assert_eq!(uri_path(&file_uri(path).unwrap()).unwrap(), path);
|
||||||
|
assert_eq!(
|
||||||
|
uri_path("file://localhost/tmp/a%23b").unwrap(),
|
||||||
|
Path::new("/tmp/a#b")
|
||||||
|
);
|
||||||
|
for uri in [
|
||||||
|
"https://example.invalid/a",
|
||||||
|
"file://remote/tmp/a",
|
||||||
|
"file:///tmp/%",
|
||||||
|
"file:///tmp/%00",
|
||||||
|
"file:///tmp/%FF",
|
||||||
|
"file:///tmp/a?query",
|
||||||
|
] {
|
||||||
|
assert!(uri_path(uri).is_err(), "{uri}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hover_contents_keep_lines_and_reject_malformed_values() {
|
||||||
|
let source = "A😀e\u{301}Z\r\n";
|
||||||
|
for kind in ["plaintext", "markdown"] {
|
||||||
|
assert_eq!(
|
||||||
|
hover_text(
|
||||||
|
&json!({
|
||||||
|
"contents": {"kind": kind, "value": "😀e\u{301}\r\n\tcode\rnext\u{1b}\u{85}"},
|
||||||
|
"range": {"start": {"line": 0, "character": 2},
|
||||||
|
"end": {"line": 0, "character": 4}}
|
||||||
|
}),
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
Some("😀e\u{301}\n code\nnext ".into()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for contents in [
|
||||||
|
json!("line\ncode"),
|
||||||
|
json!({"language": "rust", "value": "line\ncode"}),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
hover_text(&json!({"contents": contents}), source).unwrap(),
|
||||||
|
Some("line\ncode".into()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
hover_text(
|
||||||
|
&json!({"contents": ["first", {"language": "rust", "value": "fn main() {\n}"}]}),
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
Some("first\n\nfn main() {\n}".into()),
|
||||||
|
);
|
||||||
|
for value in [
|
||||||
|
Value::Null,
|
||||||
|
json!({"contents": ""}),
|
||||||
|
json!({"contents": []}),
|
||||||
|
] {
|
||||||
|
assert_eq!(hover_text(&value, source).unwrap(), None);
|
||||||
|
}
|
||||||
|
for value in [
|
||||||
|
json!({}),
|
||||||
|
json!({"contents": {"kind": "html", "value": "x"}}),
|
||||||
|
json!({"contents": {"language": "rust", "value": false}}),
|
||||||
|
json!({"contents": [{"kind": "markdown", "value": "x"}]}),
|
||||||
|
json!({"contents": "x", "range": {"start": {"line": 1, "character": 0},
|
||||||
|
"end": {"line": 0, "character": 0}}}),
|
||||||
|
json!({"contents": "\t".repeat(MAX_BODY / 4 + 1)}),
|
||||||
|
] {
|
||||||
|
assert!(hover_text(&value, source).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completion_text_limit_counts_the_atomic_batch_not_sort_metadata() {
|
||||||
|
let source = Source {
|
||||||
|
buffer_id: 1,
|
||||||
|
revision: 0,
|
||||||
|
row: 0,
|
||||||
|
column: 0,
|
||||||
|
request_id: 1,
|
||||||
|
};
|
||||||
|
let at_limit = json!([{"label": "x", "insertText": "x".repeat(MAX_BODY - 1)}]);
|
||||||
|
assert_eq!(
|
||||||
|
decode_completion("", &at_limit, &source, None).unwrap()[0].edits[0]
|
||||||
|
.text
|
||||||
|
.len(),
|
||||||
|
MAX_BODY - 1
|
||||||
|
);
|
||||||
|
let over_limit = json!([
|
||||||
|
{"label": "x", "insertText": "x".repeat(MAX_BODY - 1)},
|
||||||
|
{"label": "y"}
|
||||||
|
]);
|
||||||
|
assert!(
|
||||||
|
decode_completion("", &over_limit, &source, None)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("4 MiB")
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn at(text: &str, needle: &str) -> Result<LinkTarget, String> {
|
||||||
|
let byte = text.find(needle).unwrap();
|
||||||
|
let prefix = &text[..byte];
|
||||||
|
let row = prefix.bytes().filter(|byte| *byte == b'\n').count();
|
||||||
|
let column = prefix.rsplit('\n').next().unwrap().graphemes(true).count();
|
||||||
|
link_at(text, TextPosition { row, column })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn target(path: Option<&str>, fragment: Option<&str>) -> LinkTarget {
|
||||||
|
LinkTarget {
|
||||||
|
path: path.map(PathBuf::from),
|
||||||
|
fragment: fragment.map(str::to_owned),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn follows_inline_labels_destinations_and_grapheme_columns() {
|
||||||
|
let text = "👩💻 [go **there**](<../My%20Notes/%E6%97%A5%E6%9C%AC\\(1\\).md#caf%C3%A9>)";
|
||||||
|
let expected = target(Some("../My Notes/日本(1).md"), Some("café"));
|
||||||
|
assert_eq!(at(text, "there").unwrap(), expected);
|
||||||
|
assert_eq!(at(text, "My%20").unwrap(), expected);
|
||||||
|
assert_eq!(
|
||||||
|
at("[absolute](/Tmp/Note.md)", "absolute").unwrap(),
|
||||||
|
target(Some("/Tmp/Note.md"), None)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
at("[here](#title)", "here").unwrap(),
|
||||||
|
target(None, Some("title"))
|
||||||
|
);
|
||||||
|
assert_eq!(at("[top]()", "top").unwrap(), target(None, None));
|
||||||
|
assert_eq!(
|
||||||
|
at("[here](#title)", "[here").unwrap(),
|
||||||
|
target(None, Some("title"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
at("[file](literal%23name.md)", "file").unwrap(),
|
||||||
|
target(Some("literal#name.md"), None)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
at("[file](My\\[Notes\\].md)", "file").unwrap(),
|
||||||
|
target(Some("My[Notes].md"), None)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_all_reference_forms_and_multiline_block_definitions() {
|
||||||
|
let text = "[full][ A B ] [a b][] [A B]\n\n> [a b]:\n> <My%20Notes.md#title>\n> \"A title\"\n\n[a b]: ignored.md\n";
|
||||||
|
let expected = target(Some("My Notes.md"), Some("title"));
|
||||||
|
for needle in ["full", " A B ", "a b][]", "A B]"] {
|
||||||
|
assert_eq!(at(text, needle).unwrap(), expected);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
at("[Straße]\n\n[STRASSE]: file.md", "Straße").unwrap(),
|
||||||
|
target(Some("file.md"), None)
|
||||||
|
);
|
||||||
|
let text = "> [multi\n> line][target]\n\n[target]: other.md\n";
|
||||||
|
assert_eq!(at(text, "line]").unwrap(), target(Some("other.md"), None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_images_code_plaintext_and_external_urls() {
|
||||||
|
for (text, needle) in [
|
||||||
|
("", "image"),
|
||||||
|
("`[code](file.md)`", "code"),
|
||||||
|
("```md\n[code](file.md)\n```", "file"),
|
||||||
|
(" [code](file.md)", "code"),
|
||||||
|
("plain file.md text", "file"),
|
||||||
|
("[web](https://example.com)", "web"),
|
||||||
|
("[web](//example.com/a)", "web"),
|
||||||
|
("[web](mailto:a@example.com)", "web"),
|
||||||
|
("<https://example.com>", "example"),
|
||||||
|
] {
|
||||||
|
assert!(at(text, needle).is_err(), "{text}");
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
at("[missing]", "missing")
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("not defined")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
at("[bad](file%ZZ.md)", "bad")
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("percent")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
at("[bad](file%FF.md)", "bad")
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("UTF-8")
|
||||||
|
);
|
||||||
|
assert!(at("[bad](#)", "bad").unwrap_err().contains("empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn locates_visible_unicode_headings_setext_and_unique_slugs() {
|
||||||
|
let text = "# **Café** [日本](destination.md) `code` ###\n\n> ## Café 日本 code\n\nCafé 日本 code\n====\n\n# Café 日本 code-1\n\n# Cafe\u{301} _name_ \n\n```md\n# Hidden\n```\n";
|
||||||
|
assert_eq!(heading_row(text, "café-日本-code").unwrap(), 0);
|
||||||
|
assert_eq!(heading_row(text, "café-日本-code-1").unwrap(), 2);
|
||||||
|
assert_eq!(heading_row(text, "café-日本-code-2").unwrap(), 4);
|
||||||
|
assert_eq!(heading_row(text, "café-日本-code-1-1").unwrap(), 7);
|
||||||
|
assert_eq!(heading_row(text, "cafe\u{301}-name-alt").unwrap(), 9);
|
||||||
|
assert!(heading_row(text, "hidden").is_err());
|
||||||
|
assert!(heading_row(text, "destinationmd").is_err());
|
||||||
|
assert!(heading_row(text, "").is_err());
|
||||||
|
assert_eq!(
|
||||||
|
heading_row("# [Heading][ref]\n\n[ref]: ignored.md", "heading").unwrap(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
heading_row("# ![Alt][ref]\n\n[ref]: ignored.md", "alt").unwrap(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert_eq!(heading_row("# ![Alt][missing]", "altmissing").unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_entities_or_reports_the_explicit_entity_ceiling() {
|
||||||
|
assert_eq!(
|
||||||
|
at("[file](a&b c.md)", "file").unwrap(),
|
||||||
|
target(Some("a&b c.md"), None)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
at("[file](<name>)", "file").unwrap(),
|
||||||
|
target(Some("<name>"), None)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
at("[file](a©b.md)", "file")
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("Unsupported Markdown HTML entity")
|
||||||
|
);
|
||||||
|
assert_eq!(heading_row("# A & B é\n", "a--b-é").unwrap(), 0);
|
||||||
|
assert!(
|
||||||
|
heading_row("# ©\n", "copy")
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("Unsupported Markdown HTML entity")
|
||||||
|
);
|
||||||
|
assert_eq!(heading_row("# ©\n# Valid\n", "valid").unwrap(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
heading_row("> First\n> Second\n> ------\n", "first-second").unwrap(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn rect(x: u16, y: u16, width: u16, height: u16) -> PaneRect {
|
||||||
|
PaneRect {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn leaves(tree: &PaneTree, bounds: PaneRect) -> Vec<(usize, PaneRect)> {
|
||||||
|
let mut leaves = Vec::new();
|
||||||
|
tree.visit_rects(bounds, |id, rect| leaves.push((id, rect)));
|
||||||
|
leaves
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_layout_partitions_bounds_and_preserves_ids() {
|
||||||
|
let bounds = rect(3, 5, 11, 9);
|
||||||
|
let mut tree = PaneTree::new(10);
|
||||||
|
assert!(tree.split(10, 20, SplitAxis::Vertical));
|
||||||
|
assert!(tree.split(20, 30, SplitAxis::Horizontal));
|
||||||
|
assert!(!tree.split(99, 40, SplitAxis::Horizontal));
|
||||||
|
assert!(!tree.split(10, 30, SplitAxis::Horizontal));
|
||||||
|
assert!(!tree.split(10, 10, SplitAxis::Horizontal));
|
||||||
|
assert_eq!(tree.len(), 3);
|
||||||
|
let panes = leaves(&tree, bounds);
|
||||||
|
assert_eq!(
|
||||||
|
panes,
|
||||||
|
vec![
|
||||||
|
(10, rect(3, 5, 5, 9)),
|
||||||
|
(20, rect(8, 5, 6, 4)),
|
||||||
|
(30, rect(8, 9, 6, 5))
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let mut cells = std::collections::HashSet::new();
|
||||||
|
for (_, pane) in panes {
|
||||||
|
for y in pane.y..pane.y + pane.height {
|
||||||
|
for x in pane.x..pane.x + pane.width {
|
||||||
|
assert!(cells.insert((x, y)), "overlapping pane cell");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
cells.len(),
|
||||||
|
usize::from(bounds.width) * usize::from(bounds.height)
|
||||||
|
);
|
||||||
|
assert!(leaves(&tree, rect(0, 0, 0, 10)).is_empty());
|
||||||
|
assert!(leaves(&tree, rect(0, 0, 10, 0)).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn closing_expands_nested_sibling_and_refuses_last() {
|
||||||
|
let bounds = rect(0, 0, 20, 12);
|
||||||
|
let mut tree = PaneTree::new(1);
|
||||||
|
tree.split(1, 2, SplitAxis::Vertical);
|
||||||
|
tree.split(2, 3, SplitAxis::Horizontal);
|
||||||
|
assert_eq!(tree.close(1), Some(2));
|
||||||
|
assert_eq!(
|
||||||
|
leaves(&tree, bounds),
|
||||||
|
vec![(2, rect(0, 0, 20, 6)), (3, rect(0, 6, 20, 6))]
|
||||||
|
);
|
||||||
|
assert_eq!(tree.close(99), None);
|
||||||
|
assert_eq!(tree.close(2), Some(3));
|
||||||
|
assert_eq!(leaves(&tree, bounds), vec![(3, bounds)]);
|
||||||
|
assert_eq!(tree.close(3), None);
|
||||||
|
assert_eq!(tree.len(), 1);
|
||||||
|
assert!(!tree.contains(1));
|
||||||
|
assert!(tree.contains(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn directional_focus_handles_unequal_nested_regions() {
|
||||||
|
let bounds = rect(0, 0, 20, 12);
|
||||||
|
let mut tree = PaneTree::new(1);
|
||||||
|
tree.split(1, 2, SplitAxis::Vertical);
|
||||||
|
tree.split(2, 3, SplitAxis::Horizontal);
|
||||||
|
tree.split(3, 4, SplitAxis::Horizontal);
|
||||||
|
assert_eq!(tree.neighbor(1, PaneDirection::Right, bounds), Some(3));
|
||||||
|
assert_eq!(tree.neighbor(2, PaneDirection::Down, bounds), Some(3));
|
||||||
|
assert_eq!(tree.neighbor(3, PaneDirection::Down, bounds), Some(4));
|
||||||
|
assert_eq!(tree.neighbor(4, PaneDirection::Up, bounds), Some(3));
|
||||||
|
assert_eq!(tree.neighbor(4, PaneDirection::Left, bounds), Some(1));
|
||||||
|
assert_eq!(tree.neighbor(1, PaneDirection::Left, bounds), None);
|
||||||
|
assert_eq!(tree.neighbor(99, PaneDirection::Right, bounds), None);
|
||||||
|
assert_eq!(
|
||||||
|
tree.neighbor(1, PaneDirection::Right, rect(0, 0, 0, 0)),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn navigation_prefers_overlap_then_edge_and_breaks_ties_by_id() {
|
||||||
|
let current = rect(10, 10, 10, 10);
|
||||||
|
let candidates = [
|
||||||
|
(1, rect(20, 0, 5, 5)),
|
||||||
|
(2, rect(25, 10, 5, 10)),
|
||||||
|
(4, rect(22, 10, 5, 10)),
|
||||||
|
(3, rect(22, 10, 5, 10)),
|
||||||
|
(0, rect(20, 10, 0, 10)),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
directional_neighbor(current, PaneDirection::Right, candidates.into_iter()),
|
||||||
|
Some(3)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
directional_neighbor(current, PaneDirection::Right, candidates.into_iter().rev()),
|
||||||
|
Some(3)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
directional_neighbor(
|
||||||
|
current,
|
||||||
|
PaneDirection::Up,
|
||||||
|
[(7, rect(10, 0, 10, 5))].into_iter()
|
||||||
|
),
|
||||||
|
Some(7)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
directional_neighbor(current, PaneDirection::Left, candidates.into_iter()),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
fn working_file_changes(
|
||||||
|
root: &Path,
|
||||||
|
buffers: &[(PathBuf, Arc<str>)],
|
||||||
|
cancelled: &dyn Fn() -> bool,
|
||||||
|
) -> Result<Vec<WorkingFileChange>, String> {
|
||||||
|
WorkingFileReader::default().read(root, buffers, cancelled)
|
||||||
|
}
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn fixture_command(root: &Path, program: &str, args: &[&str]) -> String {
|
||||||
|
let mut command = Command::new(program);
|
||||||
|
if program == "jj" {
|
||||||
|
command.args([
|
||||||
|
"--config=user.name=\"Picker Author\"",
|
||||||
|
"--config=user.email=\"picker@example.test\"",
|
||||||
|
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||||
|
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
let output = command
|
||||||
|
.args(args)
|
||||||
|
.current_dir(root)
|
||||||
|
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||||
|
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
||||||
|
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
|
||||||
|
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
|
||||||
|
.env("JJ_CONFIG", "/dev/null")
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
String::from_utf8(output.stdout).unwrap().trim().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn picker_git_fixture() -> Temporary {
|
||||||
|
let temp = Temporary::new().unwrap();
|
||||||
|
fixture_command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
||||||
|
fixture_command(&temp.0, "git", &["config", "user.name", "Picker Author"]);
|
||||||
|
fixture_command(
|
||||||
|
&temp.0,
|
||||||
|
"git",
|
||||||
|
&["config", "user.email", "picker@example.test"],
|
||||||
|
);
|
||||||
|
temp
|
||||||
|
}
|
||||||
|
|
||||||
|
fn changes_by_path(
|
||||||
|
root: &Path,
|
||||||
|
buffers: &[(PathBuf, Arc<str>)],
|
||||||
|
) -> BTreeMap<PathBuf, WorkingFileChange> {
|
||||||
|
working_file_changes(root, buffers, &|| false)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|change| (change.path.clone(), change))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn working_git_changes_cover_disk_index_buffers_binary_exact_renames_and_read_only_queries() {
|
||||||
|
use std::os::unix::{ffi::OsStringExt, fs::PermissionsExt};
|
||||||
|
let temp = picker_git_fixture();
|
||||||
|
let root = &temp.0;
|
||||||
|
for (name, contents) in [
|
||||||
|
(".gitignore", "ignored*\n"),
|
||||||
|
("modified", "original\r\n"),
|
||||||
|
("unchanged", "same\n"),
|
||||||
|
("removed", "deleted\n"),
|
||||||
|
("old", "rename content\n"),
|
||||||
|
("duplicate-one", "duplicate\n"),
|
||||||
|
("duplicate-two", "duplicate\n"),
|
||||||
|
("mode", "mode\n"),
|
||||||
|
("binary", "before\0binary"),
|
||||||
|
] {
|
||||||
|
fs::write(root.join(name), contents).unwrap();
|
||||||
|
}
|
||||||
|
let non_utf8 = PathBuf::from(OsString::from_vec(b"non-utf8-\xff".to_vec()));
|
||||||
|
let non_utf8 = match fs::write(root.join(&non_utf8), "before\n") {
|
||||||
|
Ok(()) => Some(non_utf8),
|
||||||
|
Err(error) if cfg!(target_os = "macos") && error.raw_os_error() == Some(92) => None,
|
||||||
|
Err(error) => panic!("cannot create byte filename: {error}"),
|
||||||
|
};
|
||||||
|
fixture_command(root, "git", &["add", "--all"]);
|
||||||
|
fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]);
|
||||||
|
fs::write(root.join("modified"), "staged\n").unwrap();
|
||||||
|
fixture_command(root, "git", &["add", "--", "modified"]);
|
||||||
|
fs::write(root.join("modified"), "unstaged\n").unwrap();
|
||||||
|
fs::write(root.join("binary"), "after\0binary").unwrap();
|
||||||
|
if let Some(name) = &non_utf8 {
|
||||||
|
fs::write(root.join(name), "after\n").unwrap();
|
||||||
|
}
|
||||||
|
fs::remove_file(root.join("removed")).unwrap();
|
||||||
|
let renamed = "-new [file]\n\t\".txt";
|
||||||
|
fs::rename(root.join("old"), root.join(renamed)).unwrap();
|
||||||
|
fs::remove_file(root.join("duplicate-one")).unwrap();
|
||||||
|
fs::remove_file(root.join("duplicate-two")).unwrap();
|
||||||
|
fs::write(root.join("new-duplicate-one"), "duplicate\n").unwrap();
|
||||||
|
fs::write(root.join("new-duplicate-two"), "duplicate\n").unwrap();
|
||||||
|
fs::set_permissions(root.join("mode"), fs::Permissions::from_mode(0o755)).unwrap();
|
||||||
|
fs::write(root.join("added"), "added\n").unwrap();
|
||||||
|
fs::write(root.join("ignored"), "ignored\n").unwrap();
|
||||||
|
fs::write(root.join("ignored-tracked"), "forced\n").unwrap();
|
||||||
|
fixture_command(root, "git", &["add", "-f", "--", "ignored-tracked"]);
|
||||||
|
std::os::unix::fs::symlink(root.join("modified"), root.join("link")).unwrap();
|
||||||
|
let index = fs::read(root.join(".git/index")).unwrap();
|
||||||
|
let head = fs::read(root.join(".git/HEAD")).unwrap();
|
||||||
|
let disk = changes_by_path(root, &[]);
|
||||||
|
for name in ["modified", "binary", "mode"] {
|
||||||
|
assert_eq!(disk[Path::new(name)].status, FileStatus::Modified);
|
||||||
|
}
|
||||||
|
if let Some(name) = &non_utf8 {
|
||||||
|
assert_eq!(disk[name].status, FileStatus::Modified);
|
||||||
|
}
|
||||||
|
assert_eq!(disk[Path::new("removed")].status, FileStatus::Removed);
|
||||||
|
assert_eq!(disk[Path::new(renamed)].status, FileStatus::Renamed);
|
||||||
|
assert_eq!(
|
||||||
|
disk[Path::new(renamed)].old_path.as_deref(),
|
||||||
|
Some(Path::new("old"))
|
||||||
|
);
|
||||||
|
for name in [
|
||||||
|
"added",
|
||||||
|
"ignored-tracked",
|
||||||
|
"new-duplicate-one",
|
||||||
|
"new-duplicate-two",
|
||||||
|
] {
|
||||||
|
assert_eq!(disk[Path::new(name)].status, FileStatus::Added);
|
||||||
|
}
|
||||||
|
for name in ["duplicate-one", "duplicate-two"] {
|
||||||
|
assert_eq!(disk[Path::new(name)].status, FileStatus::Removed);
|
||||||
|
}
|
||||||
|
for name in ["unchanged", "old", "ignored", "link"] {
|
||||||
|
assert!(!disk.contains_key(Path::new(name)), "{name}");
|
||||||
|
}
|
||||||
|
let overlays = changes_by_path(
|
||||||
|
root,
|
||||||
|
&[
|
||||||
|
(root.join("modified"), "original\n".into()),
|
||||||
|
(root.join("unchanged"), "dirty\n".into()),
|
||||||
|
(root.join("removed"), "deleted\n".into()),
|
||||||
|
(root.join(renamed), "dirty renamed\n".into()),
|
||||||
|
(root.join("mode"), "mode\n".into()),
|
||||||
|
(root.join("ignored-buffer"), "ignored\n".into()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert!(!overlays.contains_key(Path::new("modified")));
|
||||||
|
assert!(!overlays.contains_key(Path::new("ignored-buffer")));
|
||||||
|
assert_eq!(
|
||||||
|
overlays[Path::new("unchanged")].status,
|
||||||
|
FileStatus::Modified
|
||||||
|
);
|
||||||
|
assert_eq!(overlays[Path::new("removed")].status, FileStatus::Removed);
|
||||||
|
assert_eq!(overlays[Path::new(renamed)].status, FileStatus::Renamed);
|
||||||
|
assert_eq!(overlays[Path::new("mode")].status, FileStatus::Modified);
|
||||||
|
let aliases = Temporary::new().unwrap();
|
||||||
|
let alias = aliases.0.join("checkout");
|
||||||
|
std::os::unix::fs::symlink(root, &alias).unwrap();
|
||||||
|
let aliased = changes_by_path(root, &[(alias.join("unchanged"), "dirty\n".into())]);
|
||||||
|
assert_eq!(aliased[Path::new("unchanged")].status, FileStatus::Modified);
|
||||||
|
fixture_command(root, "git", &["config", "core.filemode", "0"]);
|
||||||
|
assert!(
|
||||||
|
!changes_by_path(root, &[(root.join("mode"), "mode\n".into())])
|
||||||
|
.contains_key(Path::new("mode"))
|
||||||
|
);
|
||||||
|
fixture_command(root, "git", &["config", "core.autocrlf", "1"]);
|
||||||
|
fs::write(root.join("unchanged"), "same\r\n").unwrap();
|
||||||
|
assert!(!changes_by_path(root, &[]).contains_key(Path::new("unchanged")));
|
||||||
|
fixture_command(root, "git", &["config", "--unset", "core.autocrlf"]);
|
||||||
|
fixture_command(root, "git", &["config", "core.filemode", "true"]);
|
||||||
|
fs::write(root.join("unchanged"), "same\n").unwrap();
|
||||||
|
assert_eq!(fs::read(root.join(".git/index")).unwrap(), index);
|
||||||
|
assert_eq!(fs::read(root.join(".git/HEAD")).unwrap(), head);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(root.join("modified")).unwrap(),
|
||||||
|
"unstaged\n"
|
||||||
|
);
|
||||||
|
assert!(!root.join("removed").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn working_git_subdirectory_cross_boundary_moves_worktrees_and_unsafe_roots() {
|
||||||
|
let temp = picker_git_fixture();
|
||||||
|
let root = &temp.0;
|
||||||
|
fs::create_dir(root.join("sub")).unwrap();
|
||||||
|
fs::write(root.join("sub/outgoing"), "out\n").unwrap();
|
||||||
|
fs::write(root.join("incoming"), "in\n").unwrap();
|
||||||
|
fixture_command(root, "git", &["add", "--all"]);
|
||||||
|
fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]);
|
||||||
|
fs::rename(root.join("sub/outgoing"), root.join("outgoing")).unwrap();
|
||||||
|
fs::rename(root.join("incoming"), root.join("sub/incoming")).unwrap();
|
||||||
|
let changes = changes_by_path(&root.join("sub"), &[]);
|
||||||
|
assert_eq!(changes.len(), 2);
|
||||||
|
assert_eq!(changes[Path::new("outgoing")].status, FileStatus::Removed);
|
||||||
|
assert_eq!(changes[Path::new("incoming")].status, FileStatus::Added);
|
||||||
|
assert_eq!(changes[Path::new("incoming")].old_path, None);
|
||||||
|
std::os::unix::fs::symlink(root.join("sub"), root.join("linked")).unwrap();
|
||||||
|
assert!(changes_by_path(&root.join("linked"), &[]).is_empty());
|
||||||
|
assert!(changes_by_path(&root.join(".git"), &[]).is_empty());
|
||||||
|
let unrelated = Temporary::new().unwrap();
|
||||||
|
assert!(changes_by_path(&unrelated.0, &[]).is_empty());
|
||||||
|
let worktree = temp.0.join("worktree");
|
||||||
|
fixture_command(
|
||||||
|
root,
|
||||||
|
"git",
|
||||||
|
&["worktree", "add", "--quiet", "--detach", "worktree", "HEAD"],
|
||||||
|
);
|
||||||
|
fs::write(worktree.join("sub/outgoing"), "worktree edit\n").unwrap();
|
||||||
|
let changes = changes_by_path(&worktree.join("sub"), &[]);
|
||||||
|
assert_eq!(changes[Path::new("outgoing")].status, FileStatus::Modified);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn working_unborn_git_never_runs_filters_external_diff_textconv_or_hooks() {
|
||||||
|
let temp = picker_git_fixture();
|
||||||
|
let root = &temp.0;
|
||||||
|
fs::write(root.join(".gitignore"), "ignored\n").unwrap();
|
||||||
|
fs::write(root.join("tracked"), "tracked\n").unwrap();
|
||||||
|
fs::write(root.join("untracked"), "untracked\n").unwrap();
|
||||||
|
fs::write(root.join("ignored"), "ignored\n").unwrap();
|
||||||
|
fixture_command(root, "git", &["add", "--", ".gitignore", "tracked"]);
|
||||||
|
let changes = changes_by_path(root, &[(root.join("named"), "not saved\n".into())]);
|
||||||
|
for name in [".gitignore", "tracked", "untracked", "named"] {
|
||||||
|
assert_eq!(changes[Path::new(name)].status, FileStatus::Added);
|
||||||
|
}
|
||||||
|
assert!(!changes.contains_key(Path::new("ignored")));
|
||||||
|
assert!(!root.join("named").exists());
|
||||||
|
fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]);
|
||||||
|
fs::write(
|
||||||
|
root.join(".gitattributes"),
|
||||||
|
"tracked filter=evil diff=evil\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let evil = "sh -c 'touch executed; cat'";
|
||||||
|
for key in [
|
||||||
|
"filter.evil.clean",
|
||||||
|
"filter.evil.process",
|
||||||
|
"diff.evil.command",
|
||||||
|
"diff.evil.textconv",
|
||||||
|
] {
|
||||||
|
fixture_command(root, "git", &["config", key, evil]);
|
||||||
|
}
|
||||||
|
fixture_command(root, "git", &["config", "filter.evil.required", "true"]);
|
||||||
|
fs::write(root.join("tracked"), "changed\n").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
changes_by_path(root, &[])[Path::new("tracked")].status,
|
||||||
|
FileStatus::Modified
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
changes_by_path(root, &[(root.join("tracked"), "tracked\n".into())])
|
||||||
|
.get(Path::new("tracked")),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert!(!root.join("executed").exists());
|
||||||
|
assert!(
|
||||||
|
working_file_changes(root, &[], &|| true)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("cancelled")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
working_file_changes(
|
||||||
|
root,
|
||||||
|
&[(root.join("tracked"), "x".repeat(FILE_LIMIT + 1).into())],
|
||||||
|
&|| false
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("16 MiB")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn working_jj_root_parent_colocation_and_merge_queries_do_not_snapshot() {
|
||||||
|
for colocate in ["--colocate", "--no-colocate"] {
|
||||||
|
let temp = Temporary::new().unwrap();
|
||||||
|
let root = &temp.0;
|
||||||
|
fixture_command(root, "jj", &["git", "init", colocate, "."]);
|
||||||
|
fs::write(root.join("file"), "baseline\n").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
changes_by_path(root, &[])[Path::new("file")].status,
|
||||||
|
FileStatus::Added
|
||||||
|
);
|
||||||
|
fs::write(root.join(".gitignore"), "ignored\n").unwrap();
|
||||||
|
fixture_command(root, "jj", &["describe", "-m", "Baseline"]);
|
||||||
|
fixture_command(root, "jj", &["new"]);
|
||||||
|
let operation = fixture_command(
|
||||||
|
root,
|
||||||
|
"jj",
|
||||||
|
&[
|
||||||
|
"--ignore-working-copy",
|
||||||
|
"op",
|
||||||
|
"log",
|
||||||
|
"--no-graph",
|
||||||
|
"-n",
|
||||||
|
"1",
|
||||||
|
"-T",
|
||||||
|
"id",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let parent = fixture_command(
|
||||||
|
root,
|
||||||
|
"jj",
|
||||||
|
&[
|
||||||
|
"--ignore-working-copy",
|
||||||
|
"log",
|
||||||
|
"--no-graph",
|
||||||
|
"-r",
|
||||||
|
"@-",
|
||||||
|
"-T",
|
||||||
|
"commit_id",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let store = store_for_jj(root).unwrap();
|
||||||
|
let head = fs::read(store.join("HEAD")).unwrap();
|
||||||
|
let index = fs::read(store.join("index")).ok();
|
||||||
|
fs::write(root.join("file"), "disk\n").unwrap();
|
||||||
|
fs::write(root.join("new"), "new\n").unwrap();
|
||||||
|
fs::write(root.join("ignored"), "ignored\n").unwrap();
|
||||||
|
let changes = changes_by_path(root, &[]);
|
||||||
|
assert_eq!(changes[Path::new("file")].status, FileStatus::Modified);
|
||||||
|
assert_eq!(changes[Path::new("new")].status, FileStatus::Added);
|
||||||
|
assert!(!changes.contains_key(Path::new("ignored")));
|
||||||
|
assert!(
|
||||||
|
!changes_by_path(root, &[(root.join("file"), "baseline\n".into())])
|
||||||
|
.contains_key(Path::new("file"))
|
||||||
|
);
|
||||||
|
assert_eq!(fs::read(store.join("HEAD")).unwrap(), head);
|
||||||
|
assert_eq!(fs::read(store.join("index")).ok(), index);
|
||||||
|
assert_eq!(
|
||||||
|
fixture_command(
|
||||||
|
root,
|
||||||
|
"jj",
|
||||||
|
&[
|
||||||
|
"--ignore-working-copy",
|
||||||
|
"op",
|
||||||
|
"log",
|
||||||
|
"--no-graph",
|
||||||
|
"-n",
|
||||||
|
"1",
|
||||||
|
"-T",
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
),
|
||||||
|
operation
|
||||||
|
);
|
||||||
|
fixture_command(root, "jj", &["new", "root()"]);
|
||||||
|
fs::write(root.join("sibling"), "sibling\n").unwrap();
|
||||||
|
fixture_command(root, "jj", &["describe", "-m", "Sibling"]);
|
||||||
|
fixture_command(root, "jj", &["new", &parent, "@"]);
|
||||||
|
assert!(changes_by_path(root, &[]).is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn working_jj_conflicted_parent_uses_cli_projection_without_snapshotting() {
|
||||||
|
for colocate in ["--colocate", "--no-colocate"] {
|
||||||
|
let temp = Temporary::new().unwrap();
|
||||||
|
let root = &temp.0;
|
||||||
|
fixture_command(root, "jj", &["git", "init", colocate, "."]);
|
||||||
|
fs::write(root.join("conflicted"), "base\n").unwrap();
|
||||||
|
fixture_command(root, "jj", &["describe", "-m", "Base"]);
|
||||||
|
let base = fixture_command(
|
||||||
|
root,
|
||||||
|
"jj",
|
||||||
|
&[
|
||||||
|
"--ignore-working-copy",
|
||||||
|
"log",
|
||||||
|
"--no-graph",
|
||||||
|
"-r",
|
||||||
|
"@",
|
||||||
|
"-T",
|
||||||
|
"commit_id",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
fixture_command(root, "jj", &["new"]);
|
||||||
|
fs::write(root.join("conflicted"), "left\n").unwrap();
|
||||||
|
fixture_command(root, "jj", &["describe", "-m", "Left"]);
|
||||||
|
let left = fixture_command(
|
||||||
|
root,
|
||||||
|
"jj",
|
||||||
|
&[
|
||||||
|
"--ignore-working-copy",
|
||||||
|
"log",
|
||||||
|
"--no-graph",
|
||||||
|
"-r",
|
||||||
|
"@",
|
||||||
|
"-T",
|
||||||
|
"commit_id",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
fixture_command(root, "jj", &["new", &base]);
|
||||||
|
fs::write(root.join("conflicted"), "right\n").unwrap();
|
||||||
|
fixture_command(root, "jj", &["describe", "-m", "Right"]);
|
||||||
|
let right = fixture_command(
|
||||||
|
root,
|
||||||
|
"jj",
|
||||||
|
&[
|
||||||
|
"--ignore-working-copy",
|
||||||
|
"log",
|
||||||
|
"--no-graph",
|
||||||
|
"-r",
|
||||||
|
"@",
|
||||||
|
"-T",
|
||||||
|
"commit_id",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
fixture_command(root, "jj", &["new", &left, &right]);
|
||||||
|
fixture_command(root, "jj", &["new"]);
|
||||||
|
let projected = fs::read_to_string(root.join("conflicted")).unwrap();
|
||||||
|
assert!(projected.contains("<<<<<<<"));
|
||||||
|
let operation = fixture_command(
|
||||||
|
root,
|
||||||
|
"jj",
|
||||||
|
&[
|
||||||
|
"--ignore-working-copy",
|
||||||
|
"op",
|
||||||
|
"log",
|
||||||
|
"--no-graph",
|
||||||
|
"-n",
|
||||||
|
"1",
|
||||||
|
"-T",
|
||||||
|
"id",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let mut reader = WorkingFileReader::default();
|
||||||
|
assert!(reader.read(root, &[], &|| false).unwrap().is_empty());
|
||||||
|
fs::write(root.join("conflicted"), "resolved in working file\n").unwrap();
|
||||||
|
let changes = reader.read(root, &[], &|| false).unwrap();
|
||||||
|
assert_eq!(changes.len(), 1);
|
||||||
|
assert_eq!(changes[0].path, Path::new("conflicted"));
|
||||||
|
assert_eq!(changes[0].status, FileStatus::Modified);
|
||||||
|
assert!(
|
||||||
|
reader
|
||||||
|
.read(
|
||||||
|
root,
|
||||||
|
&[(root.join("conflicted"), projected.into())],
|
||||||
|
&|| false
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(root.join("conflicted")).unwrap(),
|
||||||
|
"resolved in working file\n"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fixture_command(
|
||||||
|
root,
|
||||||
|
"jj",
|
||||||
|
&[
|
||||||
|
"--ignore-working-copy",
|
||||||
|
"op",
|
||||||
|
"log",
|
||||||
|
"--no-graph",
|
||||||
|
"-n",
|
||||||
|
"1",
|
||||||
|
"-T",
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
),
|
||||||
|
operation
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancellation_kills_pipe_holding_children_and_drains_both_streams() {
|
||||||
|
let temp = Temporary::new().unwrap();
|
||||||
|
let ready = temp.0.join("ready");
|
||||||
|
let cancelled = Arc::new(AtomicBool::new(false));
|
||||||
|
let worker_cancelled = Arc::clone(&cancelled);
|
||||||
|
let gate = ready.clone();
|
||||||
|
let (sent, received) = mpsc::channel();
|
||||||
|
let worker = thread::spawn(move || {
|
||||||
|
let mut command = Command::new("sh");
|
||||||
|
command
|
||||||
|
.args([
|
||||||
|
"-c",
|
||||||
|
"sleep 600 & printf ready > \"$1\"; wait",
|
||||||
|
"vamp-diff-test",
|
||||||
|
])
|
||||||
|
.arg(gate);
|
||||||
|
let result = run(command, vec![], &worker_cancelled).map(|_| ());
|
||||||
|
sent.send(result).unwrap();
|
||||||
|
});
|
||||||
|
// File handshake, not a wall-clock scheduling assumption.
|
||||||
|
while !ready.exists() {
|
||||||
|
if let Ok(result) = received.try_recv() {
|
||||||
|
panic!("Process exited before the child was ready: {result:?}");
|
||||||
|
}
|
||||||
|
thread::yield_now();
|
||||||
|
}
|
||||||
|
cancelled.store(true, Ordering::Release);
|
||||||
|
assert!(received.recv().unwrap().unwrap_err().contains("cancelled"));
|
||||||
|
worker.join().unwrap();
|
||||||
|
|
||||||
|
let mut command = Command::new("sh");
|
||||||
|
command.args(["-c", "i=0; while [ \"$i\" -lt 10000 ]; do printf 'line\\n'; printf 'error\\n' >&2; i=$((i+1)); done"]);
|
||||||
|
let result = run(command, vec![], &AtomicBool::new(false)).unwrap();
|
||||||
|
assert_eq!(result.code, Some(0));
|
||||||
|
assert_eq!(result.stdout.len(), 50_000);
|
||||||
|
assert_eq!(result.stderr.len(), 60_000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
use super::*;
|
||||||
|
struct Fixture(PathBuf);
|
||||||
|
impl Fixture {
|
||||||
|
fn new() -> Self {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"vamp-resolver-test-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
fs::create_dir(&path).unwrap();
|
||||||
|
Self(path)
|
||||||
|
}
|
||||||
|
fn jj(&self, args: &[&str]) -> String {
|
||||||
|
let output = Command::new("jj")
|
||||||
|
.current_dir(&self.0)
|
||||||
|
.env("JJ_CONFIG", "/dev/null")
|
||||||
|
.args([
|
||||||
|
"--config=user.name=Resolver",
|
||||||
|
"--config=user.email=resolver@example.test",
|
||||||
|
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||||
|
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||||
|
])
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
String::from_utf8(output.stdout).unwrap()
|
||||||
|
}
|
||||||
|
fn conflict(&self) {
|
||||||
|
self.jj(&["git", "init", "--no-colocate", "."]);
|
||||||
|
self.jj(&["config", "set", "--repo", "user.name", "Resolver"]);
|
||||||
|
self.jj(&[
|
||||||
|
"config",
|
||||||
|
"set",
|
||||||
|
"--repo",
|
||||||
|
"user.email",
|
||||||
|
"resolver@example.test",
|
||||||
|
]);
|
||||||
|
self.jj(&[
|
||||||
|
"config",
|
||||||
|
"set",
|
||||||
|
"--repo",
|
||||||
|
"debug.commit-timestamp",
|
||||||
|
"\"2001-01-01T00:00:00Z\"",
|
||||||
|
]);
|
||||||
|
self.jj(&[
|
||||||
|
"config",
|
||||||
|
"set",
|
||||||
|
"--repo",
|
||||||
|
"debug.operation-timestamp",
|
||||||
|
"\"2001-01-01T00:00:00Z\"",
|
||||||
|
]);
|
||||||
|
fs::write(self.0.join("file"), "base\n").unwrap();
|
||||||
|
self.jj(&["bookmark", "create", "base"]);
|
||||||
|
self.jj(&["new", "base"]);
|
||||||
|
fs::write(self.0.join("file"), "left\n").unwrap();
|
||||||
|
self.jj(&["bookmark", "create", "left"]);
|
||||||
|
self.jj(&["new", "base"]);
|
||||||
|
fs::write(self.0.join("file"), "right\n").unwrap();
|
||||||
|
self.jj(&["bookmark", "create", "right"]);
|
||||||
|
self.jj(&["new", "left", "right"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for Fixture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn discovery_is_readonly_and_save_uses_jj_authority_and_stale_guards() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
fixture.conflict();
|
||||||
|
let operation = fixture.jj(&[
|
||||||
|
"--ignore-working-copy",
|
||||||
|
"op",
|
||||||
|
"log",
|
||||||
|
"--no-graph",
|
||||||
|
"-n",
|
||||||
|
"1",
|
||||||
|
"-T",
|
||||||
|
"id",
|
||||||
|
]);
|
||||||
|
let workspace = load(&fixture.0, &|| false).unwrap();
|
||||||
|
assert_eq!(workspace.files.len(), 1);
|
||||||
|
let file = &workspace.files[0];
|
||||||
|
assert!(file.sources.iter().any(|s| s.base && s.bytes == b"base\n"));
|
||||||
|
assert!(file.sources.iter().any(|s| !s.base && s.bytes == b"left\n"));
|
||||||
|
assert!(
|
||||||
|
file.sources
|
||||||
|
.iter()
|
||||||
|
.any(|s| !s.base && s.bytes == b"right\n")
|
||||||
|
);
|
||||||
|
for bookmark in ["base", "left", "right"] {
|
||||||
|
assert!(
|
||||||
|
file.sources
|
||||||
|
.iter()
|
||||||
|
.any(|s| s.label.contains(&format!("[{bookmark}]")))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
operation,
|
||||||
|
fixture.jj(&[
|
||||||
|
"--ignore-working-copy",
|
||||||
|
"op",
|
||||||
|
"log",
|
||||||
|
"--no-graph",
|
||||||
|
"-n",
|
||||||
|
"1",
|
||||||
|
"-T",
|
||||||
|
"id"
|
||||||
|
])
|
||||||
|
);
|
||||||
|
let blocks = regions(file.result.as_ref().unwrap());
|
||||||
|
assert_eq!(blocks.len(), 1);
|
||||||
|
for source in &file.sources {
|
||||||
|
assert_eq!(blocks[0].choose(source).unwrap().as_bytes(), source.bytes);
|
||||||
|
}
|
||||||
|
let mut changed = blocks[0].clone();
|
||||||
|
changed.sides.pop();
|
||||||
|
assert!(changed.choose(&file.sources[0]).is_err());
|
||||||
|
fs::write(fixture.0.join("file"), "external\n").unwrap();
|
||||||
|
assert!(
|
||||||
|
save(&workspace, 0, Resolution::Text("lost\n".into()), &|| false)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("externally")
|
||||||
|
);
|
||||||
|
fs::write(fixture.0.join("file"), file.result.as_ref().unwrap()).unwrap();
|
||||||
|
fixture.jj(&["new", "base"]);
|
||||||
|
let before = fs::read(fixture.0.join("file")).unwrap();
|
||||||
|
assert!(
|
||||||
|
save(
|
||||||
|
&workspace,
|
||||||
|
0,
|
||||||
|
Resolution::Text("wrong checkout\n".into()),
|
||||||
|
&|| false
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("checkout changed")
|
||||||
|
);
|
||||||
|
assert_eq!(fs::read(fixture.0.join("file")).unwrap(), before);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn text_choice_is_confirmed_by_jj() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
fixture.conflict();
|
||||||
|
let mut workspace = load(&fixture.0, &|| false).unwrap();
|
||||||
|
let cancelled_after_write =
|
||||||
|
|| fs::read(fixture.0.join("file")).is_ok_and(|bytes| bytes == b"draft\n");
|
||||||
|
let saved = save(
|
||||||
|
&workspace,
|
||||||
|
0,
|
||||||
|
Resolution::Text("draft\n".into()),
|
||||||
|
&cancelled_after_write,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(saved.file.status, Status::Unconfirmed);
|
||||||
|
assert!(saved.confirmation.is_some());
|
||||||
|
assert_eq!(fs::read(fixture.0.join("file")).unwrap(), b"draft\n");
|
||||||
|
assert_eq!(
|
||||||
|
entries(&fixture.0, &workspace.checkout.commit, &|| false).unwrap(),
|
||||||
|
[PathBuf::from("file")]
|
||||||
|
);
|
||||||
|
workspace.checkout = saved.checkout;
|
||||||
|
workspace.files[0] = saved.file;
|
||||||
|
let outcome = save(
|
||||||
|
&workspace,
|
||||||
|
0,
|
||||||
|
Resolution::Text("resolved\n".into()),
|
||||||
|
&|| false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
outcome.file.status,
|
||||||
|
Status::Resolved,
|
||||||
|
"{:?}",
|
||||||
|
outcome.confirmation
|
||||||
|
);
|
||||||
|
assert_eq!(fs::read(fixture.0.join("file")).unwrap(), b"resolved\n");
|
||||||
|
assert!(
|
||||||
|
entries(&fixture.0, &outcome.checkout.commit, &|| false)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn all_three_sides_and_bases_are_loaded_and_directory_children_are_protected() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
fixture.conflict();
|
||||||
|
fixture.jj(&["new", "base"]);
|
||||||
|
fs::write(fixture.0.join("file"), "third\n").unwrap();
|
||||||
|
fixture.jj(&["bookmark", "create", "third"]);
|
||||||
|
fixture.jj(&["new", "left", "right", "third"]);
|
||||||
|
let workspace = load(&fixture.0, &|| false).unwrap();
|
||||||
|
let file = &workspace.files[0];
|
||||||
|
assert_eq!(file.sources.iter().filter(|s| !s.base).count(), 3);
|
||||||
|
assert_eq!(file.sources.iter().filter(|s| s.base).count(), 2);
|
||||||
|
assert!(file.sources.iter().any(|s| s.bytes == b"third\n"));
|
||||||
|
let blocks = regions(file.result.as_ref().unwrap());
|
||||||
|
for source in &file.sources {
|
||||||
|
assert_eq!(blocks[0].choose(source).unwrap().as_bytes(), source.bytes);
|
||||||
|
}
|
||||||
|
fs::remove_file(fixture.0.join("file")).unwrap();
|
||||||
|
fs::create_dir(fixture.0.join("file")).unwrap();
|
||||||
|
fs::write(fixture.0.join("file/untracked"), b"keep my draft").unwrap();
|
||||||
|
let workspace = load(&fixture.0, &|| false).unwrap();
|
||||||
|
assert!(
|
||||||
|
save(&workspace, 0, Resolution::Source(0), &|| false)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("untracked or modified children")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(fixture.0.join("file/untracked")).unwrap(),
|
||||||
|
b"keep my draft"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn binary_symlink_and_deleted_file_choices_preserve_types_and_bytes() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
fixture.conflict();
|
||||||
|
fixture.jj(&["edit", "left"]);
|
||||||
|
fs::write(fixture.0.join("binary"), b"left\0\xff").unwrap();
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
fs::set_permissions(fixture.0.join("binary"), fs::Permissions::from_mode(0o755)).unwrap();
|
||||||
|
}
|
||||||
|
std::os::unix::fs::symlink("left-target", fixture.0.join("link")).unwrap();
|
||||||
|
fixture.jj(&["new", "right"]);
|
||||||
|
fs::write(fixture.0.join("binary"), b"right\0\xfe").unwrap();
|
||||||
|
std::os::unix::fs::symlink("right-target", fixture.0.join("link")).unwrap();
|
||||||
|
fixture.jj(&["bookmark", "create", "right-extra"]);
|
||||||
|
fixture.jj(&["new", "left", "right-extra"]);
|
||||||
|
let mut workspace = load(&fixture.0, &|| false).unwrap();
|
||||||
|
for (name, kind, bytes) in [
|
||||||
|
("binary", FileKind::Binary, b"left\0\xff".as_slice()),
|
||||||
|
("link", FileKind::Symlink, b"left-target".as_slice()),
|
||||||
|
] {
|
||||||
|
let file = workspace
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.position(|f| f.path == Path::new(name))
|
||||||
|
.unwrap();
|
||||||
|
assert!(workspace.files[file].result.is_none());
|
||||||
|
let source = workspace.files[file]
|
||||||
|
.sources
|
||||||
|
.iter()
|
||||||
|
.position(|s| !s.base && s.kind == kind && s.bytes == bytes)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
save(
|
||||||
|
&workspace,
|
||||||
|
file,
|
||||||
|
Resolution::Text("not allowed".into()),
|
||||||
|
&|| false
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let outcome = save(&workspace, file, Resolution::Source(source), &|| false).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
outcome.file.status,
|
||||||
|
Status::Resolved,
|
||||||
|
"{:?}",
|
||||||
|
outcome.confirmation
|
||||||
|
);
|
||||||
|
workspace.checkout = outcome.checkout;
|
||||||
|
workspace.files[file] = outcome.file;
|
||||||
|
if kind == FileKind::Symlink {
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_link(fixture.0.join(name)).unwrap(),
|
||||||
|
Path::new("left-target")
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert_eq!(fs::read(fixture.0.join(name)).unwrap(), bytes);
|
||||||
|
}
|
||||||
|
if kind == FileKind::Binary {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
assert_eq!(
|
||||||
|
fs::metadata(fixture.0.join(name))
|
||||||
|
.unwrap()
|
||||||
|
.permissions()
|
||||||
|
.mode()
|
||||||
|
& 0o111,
|
||||||
|
0o111
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// add/add conflicts retain an absent base as an explicit deletion choice.
|
||||||
|
let file = workspace
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.position(|f| f.path == Path::new("binary"))
|
||||||
|
.unwrap();
|
||||||
|
let source = workspace.files[file]
|
||||||
|
.sources
|
||||||
|
.iter()
|
||||||
|
.position(|s| s.kind == FileKind::Deleted)
|
||||||
|
.unwrap();
|
||||||
|
let outcome = save(&workspace, file, Resolution::Source(source), &|| false).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
outcome.file.status,
|
||||||
|
Status::Resolved,
|
||||||
|
"{:?}",
|
||||||
|
outcome.confirmation
|
||||||
|
);
|
||||||
|
assert!(!fixture.0.join("binary").exists());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn marker_styles_widths_multisides_and_missing_eol() {
|
||||||
|
let snapshot = "before\n<<<<<<<<<<< conflict 1 of 1\n+++++++++++ a\nleft\n----------- base\nold\n+++++++++++ b\nright\n----------- base2\nold2\n+++++++++++ c\nthird\n>>>>>>>>>>> conflict 1 of 1 ends\nafter\n";
|
||||||
|
let parsed = regions(snapshot);
|
||||||
|
assert_eq!(parsed[0].lines, 1..13);
|
||||||
|
assert_eq!(parsed[0].sides, ["left\n", "right\n", "third\n"]);
|
||||||
|
assert_eq!(parsed[0].bases, ["old\n", "old2\n"]);
|
||||||
|
let prefixed = format!("<<<<<<< literal marker\n{snapshot}");
|
||||||
|
assert_eq!(regions(&prefixed).len(), 1);
|
||||||
|
assert_eq!(regions(&prefixed)[0].lines, 2..14);
|
||||||
|
let diff = "<<<<<<< conflict 1 of 1\n%%%%%%% diff from: base\n^^^^^^^ to: left\n-old\n+left\n+++++++ right\nright\n>>>>>>> conflict 1 of 1 ends\n";
|
||||||
|
assert_eq!(regions(diff)[0].sides, ["left\n", "right\n"]);
|
||||||
|
let git = "<<<<<<< left\nleft\n||||||| base\nold\n=======\nright\n>>>>>>> right";
|
||||||
|
assert_eq!(regions(git)[0].sides, ["left", "right"]);
|
||||||
|
assert!(regions("<<<<<<< unfinished\nleft\n").is_empty());
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn test_sources(sides: usize) -> Vec<Source> {
|
||||||
|
(0..sides * 2 - 1)
|
||||||
|
.map(|index| Source {
|
||||||
|
label: format!("source {index}"),
|
||||||
|
base: index % 2 == 1,
|
||||||
|
kind: FileKind::Text,
|
||||||
|
bytes: b"historical full file; must never replace working text\n".to_vec(),
|
||||||
|
tree_id: String::new(),
|
||||||
|
term: index / 2,
|
||||||
|
sides,
|
||||||
|
bases: sides - 1,
|
||||||
|
node: Node::Absent,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot(left: &str, base: &str, right: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"<<<<<<< conflict\n+++++++ side 1\n{left}------- base\n{base}+++++++ side 2\n{right}>>>>>>> conflict ends\n"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_working_projections_keep_partial_resolutions_and_multiple_regions() {
|
||||||
|
let sources = test_sources(2);
|
||||||
|
let raw = format!(
|
||||||
|
"unsaved heading\nalready manually resolved\n{}auto-merged middle\n<<<<<<< conflict\n%%%%%%% Changes from base to side 1\n\\\\\\\\\\\\\\ continuation\n-old two\n+left two\n+++++++ side 2\nright two\n>>>>>>> conflict ends\nunsaved tail",
|
||||||
|
snapshot("left one\n", "old one\n", "right one\n")
|
||||||
|
);
|
||||||
|
for (index, first, second) in [
|
||||||
|
(0, "left one", "left two"),
|
||||||
|
(1, "old one", "old two"),
|
||||||
|
(2, "right one", "right two"),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
project(&raw, &sources[index]).unwrap(),
|
||||||
|
format!(
|
||||||
|
"unsaved heading\nalready manually resolved\n{first}\nauto-merged middle\n{second}\nunsaved tail"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alternatives_are_frozen_from_the_same_working_text_as_the_seed() {
|
||||||
|
let sources = test_sources(2);
|
||||||
|
let raw = format!(
|
||||||
|
"unsaved heading\n{}auto-merged middle\n{}unsaved tail",
|
||||||
|
snapshot("left-one\n", "old-one\n", "right-one\n"),
|
||||||
|
snapshot("left-two\n", "old-two\n", "right-two\n")
|
||||||
|
);
|
||||||
|
let projection = Projection::new(&raw, &sources, 0).unwrap();
|
||||||
|
let mut draft = projection.seed().to_owned();
|
||||||
|
draft = draft.replace("unsaved heading", "later draft edit");
|
||||||
|
draft.push_str("\nnew draft ending\n");
|
||||||
|
assert_eq!(
|
||||||
|
projection.alternative(2).unwrap(),
|
||||||
|
"unsaved heading\nright-one\nauto-merged middle\nright-two\nunsaved tail"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
projection.alternative(1).unwrap(),
|
||||||
|
"unsaved heading\nold-one\nauto-merged middle\nold-two\nunsaved tail"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
projection.seed(),
|
||||||
|
"unsaved heading\nleft-one\nauto-merged middle\nleft-two\nunsaved tail"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
draft,
|
||||||
|
"later draft edit\nleft-one\nauto-merged middle\nleft-two\nunsaved tail\nnew draft ending\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_multiway_sides_and_bases_project_the_same_current_context() {
|
||||||
|
let sources = test_sources(3);
|
||||||
|
let raw = "unsaved α\n<<<<<<<<<<< conflict\n+++++++++++ side 1\none\n----------- base 1\nold one\n+++++++++++ side 2\ntwo\n----------- base 2\nold two\n+++++++++++ side 3\nthree\n>>>>>>>>>>> conflict ends\nunsaved ω";
|
||||||
|
let projection = Projection::new(raw, &sources, 0).unwrap();
|
||||||
|
for (index, choice) in ["one", "old one", "two", "old two", "three"]
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let expected = format!("unsaved α\n{choice}\nunsaved ω");
|
||||||
|
assert_eq!(project(raw, &sources[index]).unwrap(), expected);
|
||||||
|
assert_eq!(projection.alternative(index).unwrap(), expected);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
Projection::new(raw, &sources, 4).unwrap().seed(),
|
||||||
|
"unsaved α\nthree\nunsaved ω"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn projections_preserve_mixed_eof_and_crlf_terms() {
|
||||||
|
let sources = test_sources(2);
|
||||||
|
for eol in ["\n", "\r\n"] {
|
||||||
|
let choices = [
|
||||||
|
format!("left{eol}"),
|
||||||
|
"old".to_owned(),
|
||||||
|
format!("right{eol}{eol}"),
|
||||||
|
];
|
||||||
|
// jj appends one separator EOL to *each* term when any lacks an EOL,
|
||||||
|
// and omits the EOL on the final marker. Existing term EOLs remain.
|
||||||
|
let raw = format!(
|
||||||
|
"heading{eol}<<<<<<< conflict{eol}+++++++ side 1{eol}{}{eol}------- base{eol}{}{eol}+++++++ side 2{eol}{}{eol}>>>>>>> conflict ends",
|
||||||
|
choices[0], choices[1], choices[2]
|
||||||
|
);
|
||||||
|
let projection = Projection::new(&raw, &sources, 0).unwrap();
|
||||||
|
for (index, choice) in choices.iter().enumerate() {
|
||||||
|
assert_eq!(
|
||||||
|
project(&raw, &sources[index]).unwrap(),
|
||||||
|
format!("heading{eol}{choice}")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
projection.alternative(index).unwrap(),
|
||||||
|
format!("heading{eol}{choice}")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_choices_markerfree_text_and_invalid_choices_are_handled() {
|
||||||
|
let mut sources = test_sources(2);
|
||||||
|
sources[1].kind = FileKind::Deleted;
|
||||||
|
let raw = format!("before\n{}after\n", snapshot("", "", "new\n"));
|
||||||
|
let projection = Projection::new(&raw, &sources, 0).unwrap();
|
||||||
|
assert_eq!(projection.seed(), "before\nafter\n");
|
||||||
|
assert_eq!(projection.alternative(1).unwrap(), "before\nafter\n");
|
||||||
|
assert_eq!(projection.alternative(2).unwrap(), "before\nnew\nafter\n");
|
||||||
|
let resolved = Projection::new("manually resolved, no final EOL", &sources, 0).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resolved.alternative(2).unwrap(),
|
||||||
|
"manually resolved, no final EOL"
|
||||||
|
);
|
||||||
|
assert!(resolved.alternative(sources.len()).is_err());
|
||||||
|
assert!(Projection::new(&raw, &sources, sources.len()).is_err());
|
||||||
|
assert!(Projection::new(&raw, &test_sources(3), 0).is_err());
|
||||||
|
sources[0].kind = FileKind::Binary;
|
||||||
|
assert!(project(&raw, &sources[0]).is_err());
|
||||||
|
}
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
struct Directory(PathBuf);
|
||||||
|
impl Drop for Directory {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn directory() -> Directory {
|
||||||
|
let directory = Directory(std::env::temp_dir().join(format!(
|
||||||
|
"vamp-review-test-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
NEXT_TEMP.fetch_add(1, Ordering::Relaxed),
|
||||||
|
)));
|
||||||
|
fs::create_dir(&directory.0).unwrap();
|
||||||
|
directory
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn database_restarts_preserve_identity_and_exact_stale_anchors() {
|
||||||
|
let directory = directory();
|
||||||
|
let root = directory.0.canonicalize().unwrap();
|
||||||
|
let storage = root.join("storage");
|
||||||
|
let mut review = Review::new(root.clone());
|
||||||
|
review.summary = "Unicode review\nMore context".into();
|
||||||
|
review.verdict = Some(Verdict::ChangesRequested);
|
||||||
|
review.comments.push(Comment {
|
||||||
|
id: 7,
|
||||||
|
anchor: Anchor {
|
||||||
|
revision: "working:old-snapshot".into(),
|
||||||
|
path: "日本語.rs".into(),
|
||||||
|
side: Side::Before,
|
||||||
|
start: crate::plugin::TextPosition { row: 0, column: 1 },
|
||||||
|
end: crate::plugin::TextPosition { row: 1, column: 1 },
|
||||||
|
content: "a👩🔬e\u{301}\r\n界x\n".into(),
|
||||||
|
},
|
||||||
|
body: "Please preserve 👩🔬".into(),
|
||||||
|
resolved: true,
|
||||||
|
});
|
||||||
|
{
|
||||||
|
let mut store = Store::open(&storage).unwrap();
|
||||||
|
store.save(&mut review).unwrap();
|
||||||
|
assert!(review.storage_id.is_some());
|
||||||
|
assert_eq!(review.storage_revision, 1);
|
||||||
|
}
|
||||||
|
let mut store = Store::open(&storage).unwrap();
|
||||||
|
let id = review.storage_id.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.list(&root).unwrap(),
|
||||||
|
vec![(id, "Unicode review".into())]
|
||||||
|
);
|
||||||
|
let mut loaded = store.load(&root, id).unwrap();
|
||||||
|
assert_eq!(loaded.storage_id, Some(id));
|
||||||
|
assert_eq!(loaded.storage_revision, 1);
|
||||||
|
assert_eq!(loaded.comments, review.comments);
|
||||||
|
assert_eq!(loaded.verdict, review.verdict);
|
||||||
|
assert!(!loaded.comments[0].anchor.matches(
|
||||||
|
"working:new-snapshot",
|
||||||
|
Path::new("日本語.rs"),
|
||||||
|
Side::Before,
|
||||||
|
"changed\n",
|
||||||
|
));
|
||||||
|
let path = root.join("export.json");
|
||||||
|
loaded.save(&path).unwrap();
|
||||||
|
let exported = fs::read(&path).unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&exported).unwrap();
|
||||||
|
assert_eq!(json["comments"][0]["resolved"], true);
|
||||||
|
assert_eq!(json["version"], 3);
|
||||||
|
assert!(json.get("storage_id").is_none());
|
||||||
|
assert!(json.get("storage_revision").is_none());
|
||||||
|
loaded.summary = "Database only".into();
|
||||||
|
store.save(&mut loaded).unwrap();
|
||||||
|
assert_eq!(loaded.storage_id, Some(id));
|
||||||
|
assert_eq!(loaded.storage_revision, 2);
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), exported);
|
||||||
|
let mut imported = Review::load(&path, &root).unwrap();
|
||||||
|
assert_eq!(imported.storage_id, None);
|
||||||
|
assert_eq!(imported.storage_revision, 0);
|
||||||
|
store.save(&mut imported).unwrap();
|
||||||
|
assert_ne!(imported.storage_id, Some(id));
|
||||||
|
assert_eq!(store.list(&root).unwrap().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn database_repository_identity_is_canonical_and_independent() {
|
||||||
|
let first = directory();
|
||||||
|
let second = directory();
|
||||||
|
let first_root = first.0.canonicalize().unwrap();
|
||||||
|
let second_root = second.0.canonicalize().unwrap();
|
||||||
|
let mut store = Store::open(&first_root.join("storage")).unwrap();
|
||||||
|
let mut a = Review::new(first_root.clone());
|
||||||
|
let mut b = Review::new(second_root.clone());
|
||||||
|
store.save(&mut a).unwrap();
|
||||||
|
store.save(&mut b).unwrap();
|
||||||
|
assert_ne!(a.storage_id, b.storage_id);
|
||||||
|
assert_eq!(store.list(&first_root).unwrap().len(), 1);
|
||||||
|
assert_eq!(store.list(&second_root).unwrap().len(), 1);
|
||||||
|
assert!(store.load(&second_root, a.storage_id.unwrap()).is_err());
|
||||||
|
let id = a.storage_id;
|
||||||
|
let revision = a.storage_revision;
|
||||||
|
a.root = second_root;
|
||||||
|
assert!(store.save(&mut a).is_err());
|
||||||
|
assert_eq!((a.storage_id, a.storage_revision), (id, revision));
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let alias = first_root.join("alias");
|
||||||
|
std::os::unix::fs::symlink(&first_root, &alias).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.list(&alias).unwrap(),
|
||||||
|
store.list(&first_root).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn database_conflicts_and_locked_writes_keep_drafts_and_identity() {
|
||||||
|
let directory = directory();
|
||||||
|
let root = directory.0.canonicalize().unwrap();
|
||||||
|
let storage = root.join("storage");
|
||||||
|
let mut first = Store::open(&storage).unwrap();
|
||||||
|
let mut second = Store::open(&storage).unwrap();
|
||||||
|
let mut review = Review::new(root.clone());
|
||||||
|
first.save(&mut review).unwrap();
|
||||||
|
let id = review.storage_id.unwrap();
|
||||||
|
let mut draft = second.load(&root, id).unwrap();
|
||||||
|
review.summary = "First writer".into();
|
||||||
|
first.save(&mut review).unwrap();
|
||||||
|
draft.summary = "Second writer draft".into();
|
||||||
|
assert!(
|
||||||
|
second
|
||||||
|
.save(&mut draft)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("another session")
|
||||||
|
);
|
||||||
|
assert_eq!(draft.summary, "Second writer draft");
|
||||||
|
assert_eq!((draft.storage_id, draft.storage_revision), (Some(id), 1));
|
||||||
|
assert_eq!(second.load(&root, id).unwrap().summary, "First writer");
|
||||||
|
first.connection.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||||
|
let mut fresh = Review::new(root.clone());
|
||||||
|
assert!(second.save(&mut fresh).unwrap_err().contains("locked"));
|
||||||
|
assert_eq!((fresh.storage_id, fresh.storage_revision), (None, 0));
|
||||||
|
first.connection.execute_batch("ROLLBACK").unwrap();
|
||||||
|
second.save(&mut fresh).unwrap();
|
||||||
|
assert!(fresh.storage_id.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn incompatible_database_schemas_are_preserved() {
|
||||||
|
for schema in [
|
||||||
|
"CREATE TABLE other (value TEXT); PRAGMA user_version = 42",
|
||||||
|
"CREATE TABLE other (value TEXT); PRAGMA user_version = 1",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
let directory = directory();
|
||||||
|
let path = directory.0.join("reviews.sqlite3");
|
||||||
|
let connection = Connection::open(&path).unwrap();
|
||||||
|
connection.execute_batch(schema).unwrap();
|
||||||
|
drop(connection);
|
||||||
|
let original = fs::read(&path).unwrap();
|
||||||
|
assert!(Store::open(&directory.0).err().unwrap().contains("schema"));
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), original);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_or_mismatched_database_documents_are_rejected() {
|
||||||
|
let other = directory();
|
||||||
|
let directory = directory();
|
||||||
|
let root = directory.0.canonicalize().unwrap();
|
||||||
|
let mut store = Store::open(&root.join("storage")).unwrap();
|
||||||
|
let mut review = Review::new(root.clone());
|
||||||
|
store.save(&mut review).unwrap();
|
||||||
|
let id = review.storage_id.unwrap();
|
||||||
|
let mut wrong_root = serde_json::to_value(&review).unwrap();
|
||||||
|
wrong_root["root"] = serde_json::json!(other.0.canonicalize().unwrap());
|
||||||
|
let mut invalid_version = serde_json::to_value(&review).unwrap();
|
||||||
|
invalid_version["version"] = 1.into();
|
||||||
|
let mut inherited_identity = serde_json::to_value(&review).unwrap();
|
||||||
|
inherited_identity["storage_id"] = id.into();
|
||||||
|
for document in [
|
||||||
|
"{".to_owned(),
|
||||||
|
serde_json::to_string(&wrong_root).unwrap(),
|
||||||
|
serde_json::to_string(&invalid_version).unwrap(),
|
||||||
|
serde_json::to_string(&inherited_identity).unwrap(),
|
||||||
|
] {
|
||||||
|
store
|
||||||
|
.connection
|
||||||
|
.execute(
|
||||||
|
"UPDATE reviews SET document = ?1 WHERE id = ?2",
|
||||||
|
params![document, id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(store.load(&root, id).is_err());
|
||||||
|
assert!(store.list(&root).is_err());
|
||||||
|
}
|
||||||
|
store
|
||||||
|
.connection
|
||||||
|
.execute_batch("PRAGMA ignore_check_constraints = ON")
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.connection
|
||||||
|
.execute(
|
||||||
|
"UPDATE reviews SET document = CAST(zeroblob(?1) AS TEXT) WHERE id = ?2",
|
||||||
|
params![LIMIT as i64 + 1, id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(store.load(&root, id).unwrap_err().contains("64 MiB"));
|
||||||
|
assert!(store.list(&root).unwrap_err().contains("64 MiB"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_database_and_export_writes_preserve_metadata_and_documents() {
|
||||||
|
let directory = directory();
|
||||||
|
let root = directory.0.canonicalize().unwrap();
|
||||||
|
let mut store = Store::open(&root.join("storage")).unwrap();
|
||||||
|
let mut review = Review::new(root.clone());
|
||||||
|
store.save(&mut review).unwrap();
|
||||||
|
let identity = (review.storage_id, review.storage_revision);
|
||||||
|
store
|
||||||
|
.connection
|
||||||
|
.execute_batch("PRAGMA query_only = ON")
|
||||||
|
.unwrap();
|
||||||
|
review.summary = "Unsaved draft".into();
|
||||||
|
assert!(store.save(&mut review).is_err());
|
||||||
|
assert_eq!((review.storage_id, review.storage_revision), identity);
|
||||||
|
assert_eq!(review.summary, "Unsaved draft");
|
||||||
|
assert_eq!(store.load(&root, identity.0.unwrap()).unwrap().summary, "");
|
||||||
|
let mut fresh = Review::new(root.clone());
|
||||||
|
assert!(store.save(&mut fresh).is_err());
|
||||||
|
assert_eq!((fresh.storage_id, fresh.storage_revision), (None, 0));
|
||||||
|
store
|
||||||
|
.connection
|
||||||
|
.execute_batch("PRAGMA query_only = OFF")
|
||||||
|
.unwrap();
|
||||||
|
review.summary = "x".repeat(LIMIT);
|
||||||
|
assert!(store.save(&mut review).unwrap_err().contains("64 MiB"));
|
||||||
|
assert!(
|
||||||
|
review
|
||||||
|
.save(&root.join("oversized.json"))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("64 MiB")
|
||||||
|
);
|
||||||
|
assert!(!root.join("oversized.json").exists());
|
||||||
|
assert_eq!((review.storage_id, review.storage_revision), identity);
|
||||||
|
assert_eq!(store.load(&root, identity.0.unwrap()).unwrap().summary, "");
|
||||||
|
let blocked = root.join("not-a-directory");
|
||||||
|
fs::write(&blocked, b"preserve").unwrap();
|
||||||
|
assert!(Store::open(&blocked).is_err());
|
||||||
|
assert_eq!(fs::read(&blocked).unwrap(), b"preserve");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn database_files_are_private_and_target_symlinks_are_refused() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let directory = directory();
|
||||||
|
let storage = directory.0.join("storage");
|
||||||
|
drop(Store::open(&storage).unwrap());
|
||||||
|
let path = storage.join("reviews.sqlite3");
|
||||||
|
assert_eq!(
|
||||||
|
fs::metadata(&storage).unwrap().permissions().mode() & 0o777,
|
||||||
|
0o700
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||||
|
0o600
|
||||||
|
);
|
||||||
|
let original = fs::read(&path).unwrap();
|
||||||
|
let links = directory.0.join("links");
|
||||||
|
fs::create_dir(&links).unwrap();
|
||||||
|
std::os::unix::fs::symlink(&path, links.join("reviews.sqlite3")).unwrap();
|
||||||
|
assert!(Store::open(&links).err().unwrap().contains("symbolic link"));
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_directory_uses_absolute_xdg_then_absolute_home() {
|
||||||
|
let resolve = |xdg: Option<&str>, home: Option<&str>| {
|
||||||
|
directory_from(xdg.map(OsStr::new), home.map(OsStr::new))
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
resolve(Some("/data"), Some("/home/me")).unwrap(),
|
||||||
|
Path::new("/data/vamp/reviews")
|
||||||
|
);
|
||||||
|
for xdg in [None, Some("relative"), Some("")] {
|
||||||
|
assert_eq!(
|
||||||
|
resolve(xdg, Some("/home/me")).unwrap(),
|
||||||
|
Path::new("/home/me/.local/share/vamp/reviews")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
resolve(Some("/data"), None).unwrap(),
|
||||||
|
Path::new("/data/vamp/reviews")
|
||||||
|
);
|
||||||
|
assert!(resolve(None, None).is_err());
|
||||||
|
assert!(resolve(None, Some("relative")).is_err());
|
||||||
|
assert!(resolve(None, Some("")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persistence_validates_anchors_and_preserves_files_on_failure() {
|
||||||
|
let directory = directory();
|
||||||
|
let root = directory.0.canonicalize().unwrap();
|
||||||
|
let path = root.join("review.json");
|
||||||
|
let mut review = Review::new(root.clone());
|
||||||
|
review.summary = "Local review".into();
|
||||||
|
review.verdict = Some(Verdict::ChangesRequested);
|
||||||
|
review.comments.push(Comment {
|
||||||
|
id: 1,
|
||||||
|
anchor: Anchor {
|
||||||
|
revision: "working:immutable".into(),
|
||||||
|
path: "source.rs".into(),
|
||||||
|
side: Side::After,
|
||||||
|
start: crate::plugin::TextPosition { row: 0, column: 0 },
|
||||||
|
end: crate::plugin::TextPosition { row: 2, column: 0 },
|
||||||
|
content: "first\nlast\n".into(),
|
||||||
|
},
|
||||||
|
body: "Please adjust this".into(),
|
||||||
|
resolved: false,
|
||||||
|
});
|
||||||
|
review.save(&path).unwrap();
|
||||||
|
let mut loaded = Review::load(&path, &root).unwrap();
|
||||||
|
assert_eq!(loaded.comments, review.comments);
|
||||||
|
assert_eq!(loaded.verdict, review.verdict);
|
||||||
|
let anchor = &loaded.comments[0].anchor;
|
||||||
|
assert!(anchor.matches(
|
||||||
|
"working:immutable",
|
||||||
|
Path::new("source.rs"),
|
||||||
|
Side::After,
|
||||||
|
"first\nlast\n"
|
||||||
|
));
|
||||||
|
assert!(!anchor.matches(
|
||||||
|
"working:other",
|
||||||
|
Path::new("source.rs"),
|
||||||
|
Side::After,
|
||||||
|
"first\nlast\n"
|
||||||
|
));
|
||||||
|
assert!(anchor.matches_lines(
|
||||||
|
"working:immutable",
|
||||||
|
Path::new("source.rs"),
|
||||||
|
Side::After,
|
||||||
|
&["first".into(), "last".into()],
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!anchor.matches_lines(
|
||||||
|
"working:immutable",
|
||||||
|
Path::new("source.rs"),
|
||||||
|
Side::After,
|
||||||
|
&["first".into(), "last".into()],
|
||||||
|
false
|
||||||
|
));
|
||||||
|
loaded.summary = "Updated".into();
|
||||||
|
loaded.save(&path).unwrap();
|
||||||
|
assert_eq!(Review::load(&path, &root).unwrap().summary, "Updated");
|
||||||
|
assert!(Review::new(root.clone()).save(&path).is_err());
|
||||||
|
let valid = fs::read(&path).unwrap();
|
||||||
|
for (field, value) in [
|
||||||
|
("version", serde_json::json!(2)),
|
||||||
|
("root", serde_json::json!(root.join("other"))),
|
||||||
|
(
|
||||||
|
"comments",
|
||||||
|
serde_json::json!([loaded.comments[0], loaded.comments[0]]),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let mut invalid = serde_json::to_value(&loaded).unwrap();
|
||||||
|
invalid[field] = value;
|
||||||
|
fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
|
||||||
|
assert!(Review::load(&path, &root).is_err());
|
||||||
|
}
|
||||||
|
for (field, value) in [
|
||||||
|
("path", serde_json::json!("../outside")),
|
||||||
|
("path", serde_json::json!("/absolute")),
|
||||||
|
("path", serde_json::json!(".git/config")),
|
||||||
|
("start", serde_json::json!({"row": 2, "column": 1})),
|
||||||
|
("start", serde_json::json!({"row": 3, "column": 0})),
|
||||||
|
("end", serde_json::json!({"row": 3, "column": 0})),
|
||||||
|
("revision", serde_json::json!("")),
|
||||||
|
] {
|
||||||
|
let mut invalid = serde_json::to_value(&loaded).unwrap();
|
||||||
|
invalid["comments"][0]["anchor"][field] = value;
|
||||||
|
fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
|
||||||
|
assert!(Review::load(&path, &root).is_err());
|
||||||
|
}
|
||||||
|
fs::create_dir(root.join("other")).unwrap();
|
||||||
|
fs::write(&path, &valid).unwrap();
|
||||||
|
assert!(Review::load(&path, &root.join("other")).is_err());
|
||||||
|
fs::write(&path, b"external edit").unwrap();
|
||||||
|
assert!(loaded.save(&path).is_err());
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), b"external edit");
|
||||||
|
assert_eq!(loaded.summary, "Updated");
|
||||||
|
let invalid = root.join("invalid.json");
|
||||||
|
loaded.comments[0].anchor.start.row = usize::MAX;
|
||||||
|
assert!(loaded.save(&invalid).is_err());
|
||||||
|
assert!(!invalid.exists());
|
||||||
|
let large = root.join("large.json");
|
||||||
|
fs::File::create(&large)
|
||||||
|
.unwrap()
|
||||||
|
.set_len(LIMIT as u64 + 1)
|
||||||
|
.unwrap();
|
||||||
|
assert!(Review::load(&large, &root).is_err());
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let link = root.join("link.json");
|
||||||
|
std::os::unix::fs::symlink(&path, &link).unwrap();
|
||||||
|
assert!(Review::load(&link, &root).is_err());
|
||||||
|
assert!(review.save(&link).is_err());
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), b"external edit");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stored_version_two_is_rejected_without_rewriting_it() {
|
||||||
|
let directory = directory();
|
||||||
|
let root = directory.0.canonicalize().unwrap();
|
||||||
|
let mut store = Store::open(&root.join("storage")).unwrap();
|
||||||
|
let mut review = Review::new(root.clone());
|
||||||
|
store.save(&mut review).unwrap();
|
||||||
|
let mut old = serde_json::to_value(&review).unwrap();
|
||||||
|
old["version"] = 2.into();
|
||||||
|
let document = serde_json::to_string(&old).unwrap();
|
||||||
|
store
|
||||||
|
.connection
|
||||||
|
.execute(
|
||||||
|
"UPDATE reviews SET document = ?1 WHERE id = ?2",
|
||||||
|
params![document, review.storage_id.unwrap()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.load(&root, review.storage_id.unwrap())
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("Unsupported review version 2")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.list(&root)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("Unsupported review version 2")
|
||||||
|
);
|
||||||
|
let preserved: String = store
|
||||||
|
.connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT document FROM reviews WHERE id = ?1",
|
||||||
|
[review.storage_id.unwrap()],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(preserved, document);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exact_grapheme_ranges_roundtrip_and_reject_invalid_coordinates() {
|
||||||
|
use crate::plugin::TextPosition;
|
||||||
|
let directory = directory();
|
||||||
|
let root = directory.0.canonicalize().unwrap();
|
||||||
|
let path = root.join("review.json");
|
||||||
|
let mut review = Review::new(root.clone());
|
||||||
|
review.comments.push(Comment {
|
||||||
|
id: 1,
|
||||||
|
anchor: Anchor {
|
||||||
|
revision: "working:immutable".into(),
|
||||||
|
path: "source.rs".into(),
|
||||||
|
side: Side::After,
|
||||||
|
start: TextPosition { row: 0, column: 1 },
|
||||||
|
end: TextPosition { row: 1, column: 1 },
|
||||||
|
content: "a👩🔬e\u{301}\n界x\n".into(),
|
||||||
|
},
|
||||||
|
body: "Exact text".into(),
|
||||||
|
resolved: false,
|
||||||
|
});
|
||||||
|
review.save(&path).unwrap();
|
||||||
|
let mut loaded = Review::load(&path, &root).unwrap();
|
||||||
|
assert_eq!(loaded.comments, review.comments);
|
||||||
|
assert_eq!(loaded.comments[0].anchor.rows(), 0..=1);
|
||||||
|
let valid = serde_json::to_value(&review).unwrap();
|
||||||
|
let mut missing_resolution = valid.clone();
|
||||||
|
missing_resolution["comments"][0]
|
||||||
|
.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.remove("resolved");
|
||||||
|
for version in [2, 3] {
|
||||||
|
missing_resolution["version"] = version.into();
|
||||||
|
let bytes = serde_json::to_vec(&missing_resolution).unwrap();
|
||||||
|
fs::write(&path, &bytes).unwrap();
|
||||||
|
let error = Review::load(&path, &root).unwrap_err();
|
||||||
|
assert!(error.contains(if version == 2 {
|
||||||
|
"Unsupported review version 2"
|
||||||
|
} else {
|
||||||
|
"missing field `resolved`"
|
||||||
|
}));
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), bytes);
|
||||||
|
}
|
||||||
|
for (field, value) in [
|
||||||
|
("start", serde_json::json!({"row": 0, "column": 4})),
|
||||||
|
("end", serde_json::json!({"row": 1, "column": 3})),
|
||||||
|
("end", serde_json::json!({"row": 2, "column": 1})),
|
||||||
|
("end", serde_json::json!({"row": 3, "column": 0})),
|
||||||
|
("end", serde_json::json!({"row": 0, "column": 0})),
|
||||||
|
("start", serde_json::json!({"row": -1, "column": 0})),
|
||||||
|
(
|
||||||
|
"start",
|
||||||
|
serde_json::json!({"row": 0, "column": 0, "extra": 0}),
|
||||||
|
),
|
||||||
|
("start_line", serde_json::json!(1)),
|
||||||
|
] {
|
||||||
|
let mut invalid = valid.clone();
|
||||||
|
invalid["comments"][0]["anchor"][field] = value;
|
||||||
|
fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
|
||||||
|
assert!(Review::load(&path, &root).is_err(), "{field}");
|
||||||
|
}
|
||||||
|
let mut wrong_version = valid;
|
||||||
|
wrong_version["version"] = 1.into();
|
||||||
|
fs::write(&path, serde_json::to_vec(&wrong_version).unwrap()).unwrap();
|
||||||
|
assert!(Review::load(&path, &root).is_err());
|
||||||
|
loaded.comments[0].anchor.end = TextPosition { row: 0, column: 0 };
|
||||||
|
assert!(loaded.save(&root.join("backwards.json")).is_err());
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
use super::*;
|
||||||
|
use vamp::{
|
||||||
|
plugin::TextPosition,
|
||||||
|
review::{Comment, Side},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn output_filters_resolution_and_extracts_exact_graphemes() {
|
||||||
|
let mut review = Review::new(PathBuf::new());
|
||||||
|
review.storage_id = Some(7);
|
||||||
|
review.storage_revision = 2;
|
||||||
|
review.comments = [false, true]
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(id, resolved)| Comment {
|
||||||
|
id: id as u64 + 1,
|
||||||
|
body: "fix".into(),
|
||||||
|
resolved,
|
||||||
|
anchor: Anchor {
|
||||||
|
revision: "commit".into(),
|
||||||
|
path: "a.rs".into(),
|
||||||
|
side: Side::After,
|
||||||
|
start: TextPosition { row: 0, column: 1 },
|
||||||
|
end: TextPosition { row: 1, column: 1 },
|
||||||
|
content: "xe\u{301}👩💻\n界z\n".into(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let output = envelope(&review, false, true);
|
||||||
|
assert_eq!(output["comments"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(output["comments"][0]["excerpt"], "e\u{301}👩💻\n界");
|
||||||
|
assert!(output["comments"][0]["anchor"].get("content").is_none());
|
||||||
|
assert_eq!(output["counts"]["resolved"], 1);
|
||||||
|
assert_eq!(
|
||||||
|
envelope(&review, true, true)["comments"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.len(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absent_storage_is_not_created_and_incompatible_storage_is_preserved() {
|
||||||
|
let directory = std::env::temp_dir().join(format!("vamp-review-cli-{}", std::process::id()));
|
||||||
|
std::fs::create_dir(&directory).unwrap();
|
||||||
|
let absent = directory.join("absent");
|
||||||
|
assert!(Store::open_read_only(&absent).unwrap().is_none());
|
||||||
|
assert!(!absent.exists());
|
||||||
|
let path = directory.join("reviews.sqlite3");
|
||||||
|
std::fs::write(&path, b"incompatible").unwrap();
|
||||||
|
assert!(Store::open_read_only(&directory).is_err());
|
||||||
|
assert_eq!(std::fs::read(&path).unwrap(), b"incompatible");
|
||||||
|
std::fs::remove_file(&path).unwrap();
|
||||||
|
let connection = rusqlite::Connection::open(&path).unwrap();
|
||||||
|
connection
|
||||||
|
.execute_batch("CREATE TABLE incompatible (value TEXT); PRAGMA user_version = 42")
|
||||||
|
.unwrap();
|
||||||
|
drop(connection);
|
||||||
|
let before = std::fs::read(&path).unwrap();
|
||||||
|
assert!(
|
||||||
|
Store::open_read_only(&directory)
|
||||||
|
.err()
|
||||||
|
.unwrap()
|
||||||
|
.contains("schema")
|
||||||
|
);
|
||||||
|
assert_eq!(std::fs::read(&path).unwrap(), before);
|
||||||
|
std::fs::remove_dir_all(directory).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn existing_storage_is_read_only_and_unchanged() {
|
||||||
|
let directory =
|
||||||
|
std::env::temp_dir().join(format!("vamp-review-cli-existing-{}", std::process::id()));
|
||||||
|
let mut store = Store::open(&directory).unwrap();
|
||||||
|
let mut review = Review::new(directory.clone());
|
||||||
|
review.summary = "stored".into();
|
||||||
|
store.save(&mut review).unwrap();
|
||||||
|
drop(store);
|
||||||
|
let path = directory.join("reviews.sqlite3");
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap();
|
||||||
|
}
|
||||||
|
let before = std::fs::read(&path).unwrap();
|
||||||
|
let mut store = Store::open_read_only(&directory).unwrap().unwrap();
|
||||||
|
let mut loaded = store.load(&directory, review.storage_id.unwrap()).unwrap();
|
||||||
|
assert_eq!(loaded.summary, "stored");
|
||||||
|
loaded.summary = "changed".into();
|
||||||
|
assert!(store.save(&mut loaded).is_err());
|
||||||
|
drop(store);
|
||||||
|
assert_eq!(std::fs::read(&path).unwrap(), before);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||||
|
0o640
|
||||||
|
);
|
||||||
|
let links = directory.join("links");
|
||||||
|
std::fs::create_dir(&links).unwrap();
|
||||||
|
std::os::unix::fs::symlink(&path, links.join("reviews.sqlite3")).unwrap();
|
||||||
|
assert!(Store::open_read_only(&links).is_err());
|
||||||
|
}
|
||||||
|
std::fs::remove_dir_all(directory).unwrap();
|
||||||
|
}
|
||||||
+2280
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,119 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grammar_expands_nested_mirrors_choices_variables_and_normalized_offsets() {
|
||||||
|
let (text, snippet) = expand(
|
||||||
|
"call($1, ${1:λ${2:x}}, ${3|a\\,b,c|}, ${UNKNOWN:${TM_LINE_NUMBER}})\r\n$0\\$",
|
||||||
|
|name| (name == "TM_LINE_NUMBER").then(|| "4".to_owned()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(text, "call(λx, λx, a,b, 4)\n$");
|
||||||
|
assert_eq!(
|
||||||
|
snippet.tabstops,
|
||||||
|
vec![
|
||||||
|
SnippetTabstop {
|
||||||
|
index: 1,
|
||||||
|
ranges: vec![5..8, 10..13],
|
||||||
|
ancestors: vec![vec![], vec![]]
|
||||||
|
},
|
||||||
|
SnippetTabstop {
|
||||||
|
index: 2,
|
||||||
|
ranges: vec![7..8, 12..13],
|
||||||
|
ancestors: vec![vec![1], vec![1]]
|
||||||
|
},
|
||||||
|
SnippetTabstop {
|
||||||
|
index: 3,
|
||||||
|
ranges: vec![15..18],
|
||||||
|
ancestors: vec![vec![]]
|
||||||
|
},
|
||||||
|
SnippetTabstop {
|
||||||
|
index: 0,
|
||||||
|
ranges: vec![23..23],
|
||||||
|
ancestors: vec![vec![]]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let (text, snippet) = expand("call($1, ${2})", |_| None).unwrap();
|
||||||
|
assert_eq!(text, "call(, )");
|
||||||
|
assert_eq!(
|
||||||
|
snippet.tabstops,
|
||||||
|
vec![
|
||||||
|
SnippetTabstop {
|
||||||
|
index: 1,
|
||||||
|
ranges: vec![5..5],
|
||||||
|
ancestors: vec![vec![]]
|
||||||
|
},
|
||||||
|
SnippetTabstop {
|
||||||
|
index: 2,
|
||||||
|
ranges: vec![7..7],
|
||||||
|
ancestors: vec![vec![]]
|
||||||
|
},
|
||||||
|
SnippetTabstop {
|
||||||
|
index: 0,
|
||||||
|
ranges: vec![8..8],
|
||||||
|
ancestors: vec![vec![]]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
expand("${KNOWN:${1:hidden}}$1", |name| {
|
||||||
|
(name == "KNOWN").then(|| "shown".to_owned())
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.0,
|
||||||
|
"shown"
|
||||||
|
);
|
||||||
|
let (text, snippet) = expand("call(${CTX})$1", |name| {
|
||||||
|
(name == "CTX").then(|| "a\r\nb\rc".to_owned())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(text, "call(a\nb\nc)");
|
||||||
|
assert_eq!(snippet.tabstops[0].ranges, [11..11]);
|
||||||
|
assert_eq!(
|
||||||
|
expand("${1|a\\|b,c|}\\}\\\\", |_| None).unwrap().0,
|
||||||
|
"a|b}\\"
|
||||||
|
);
|
||||||
|
for input in ["${1", "${1:$1}", "${1:a}${1:b}$1", "${1:e}\u{301}"] {
|
||||||
|
if input == "${1:a}${1:b}$1" {
|
||||||
|
assert_eq!(expand(input, |_| None).unwrap().0, "aaa");
|
||||||
|
} else {
|
||||||
|
assert_eq!(expand(input, |_| None), Err(Error::Invalid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (text, snippet) = expand("${1}$1", |_| None).unwrap();
|
||||||
|
assert!(text.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
snippet.tabstops[0],
|
||||||
|
SnippetTabstop {
|
||||||
|
index: 1,
|
||||||
|
ranges: vec![0..0, 0..0],
|
||||||
|
ancestors: vec![vec![], vec![]],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
for input in [
|
||||||
|
"$UNKNOWN",
|
||||||
|
"${TM_FILENAME/(.*)/$1/}",
|
||||||
|
"${1/a/b/}",
|
||||||
|
"${1:a$0b}",
|
||||||
|
"$0$0",
|
||||||
|
"${0:arg}",
|
||||||
|
"${0:$1}",
|
||||||
|
] {
|
||||||
|
assert_eq!(expand(input, |_| None), Err(Error::Unsupported));
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
expand(&"$1".repeat(MAX_STOPS + 1), |_| None),
|
||||||
|
Err(Error::Limit)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
expand(
|
||||||
|
&format!(
|
||||||
|
"{}x{}",
|
||||||
|
"${1:".repeat(MAX_DEPTH + 1),
|
||||||
|
"}".repeat(MAX_DEPTH + 1)
|
||||||
|
),
|
||||||
|
|_| None
|
||||||
|
),
|
||||||
|
Err(Error::Limit)
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selected_files_and_auxiliary_grammars_are_registered() {
|
||||||
|
for (filename, language) in [
|
||||||
|
("main.rs", "Rust"),
|
||||||
|
("Gemfile", "Ruby"),
|
||||||
|
("main.py", "Python"),
|
||||||
|
("package.json", "JSON"),
|
||||||
|
(".bashrc", "Bash"),
|
||||||
|
("GNUmakefile", "Makefile"),
|
||||||
|
("README.md", "Markdown"),
|
||||||
|
("main.jsx", "JavaScript"),
|
||||||
|
("main.ts", "TypeScript"),
|
||||||
|
("index.html", "HTML"),
|
||||||
|
("style.css", "CSS"),
|
||||||
|
("config.yml", "YAML"),
|
||||||
|
("Cargo.toml", "TOML"),
|
||||||
|
("Cargo.lock", "TOML"),
|
||||||
|
("main.c", "C"),
|
||||||
|
("main.cpp", "C++"),
|
||||||
|
("main.C", "C++"),
|
||||||
|
("main.go", "Go"),
|
||||||
|
("main.zig", "Zig"),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
detect(Some(Path::new(filename)), None).map(|spec| spec.name),
|
||||||
|
Some(language)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let ts = detect(Some(Path::new("main.ts")), None).unwrap();
|
||||||
|
let tsx = detect(Some(Path::new("main.tsx")), None).unwrap();
|
||||||
|
assert_eq!(ts.name, tsx.name);
|
||||||
|
assert!(!std::ptr::eq(ts, tsx));
|
||||||
|
assert_ne!(ts.language, tsx.language);
|
||||||
|
assert!(std::ptr::eq(tsx, by_name("TSX").unwrap()));
|
||||||
|
assert!(by_name("markdown_inline").is_some());
|
||||||
|
for filename in [
|
||||||
|
"notes.txt",
|
||||||
|
"main.hs",
|
||||||
|
"main.scala",
|
||||||
|
"main.lua",
|
||||||
|
"Dockerfile",
|
||||||
|
] {
|
||||||
|
assert!(detect(Some(Path::new(filename)), None).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shebangs_support_bom_env_options_and_filename_precedence() {
|
||||||
|
for (line, language) in [
|
||||||
|
("#!/usr/bin/python3", "Python"),
|
||||||
|
("\u{feff}#!/usr/bin/env -S python3.12 -u", "Python"),
|
||||||
|
("#!/usr/bin/env -u PYTHONHOME FOO=bar python3", "Python"),
|
||||||
|
("#!/usr/bin/ruby3.3", "Ruby"),
|
||||||
|
("#!/bin/bash", "Bash"),
|
||||||
|
("#! /usr/bin/env node", "JavaScript"),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
detect(None, Some(line)).map(|spec| spec.name),
|
||||||
|
Some(language)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
detect(Some(Path::new("main.rs")), Some("#!/bin/bash"))
|
||||||
|
.unwrap()
|
||||||
|
.name,
|
||||||
|
"Rust"
|
||||||
|
);
|
||||||
|
assert!(detect(None, Some("#!/usr/bin/python-not-real")).is_none());
|
||||||
|
assert!(detect(None, Some("#!/usr/bin/perl")).is_none());
|
||||||
|
assert!(detect(None, Some("plain text")).is_none());
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
use super::*;
|
||||||
|
use crossterm::event::{KeyEvent, MouseEvent, MouseEventKind};
|
||||||
|
|
||||||
|
fn key(code: KeyCode, modifiers: KeyModifiers) -> Event {
|
||||||
|
Event::Key(KeyEvent::new(code, modifiers))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes(collector: &mut TerminalColors, text: &str, now: Instant) {
|
||||||
|
for ch in text.chars() {
|
||||||
|
let modifiers = if ch.is_ascii_uppercase() {
|
||||||
|
KeyModifiers::SHIFT
|
||||||
|
} else {
|
||||||
|
KeyModifiers::NONE
|
||||||
|
};
|
||||||
|
collector.push(key(KeyCode::Char(ch), modifiers), now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn report(collector: &mut TerminalColors, body: &str, now: Instant) {
|
||||||
|
collector.push(key(KeyCode::Char(']'), KeyModifiers::ALT), now);
|
||||||
|
bytes(collector, body, now);
|
||||||
|
collector.push(key(KeyCode::Char('g'), KeyModifiers::CONTROL), now);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain(collector: &mut TerminalColors) -> Vec<Event> {
|
||||||
|
std::iter::from_fn(|| collector.pop()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fragmented_triple_accepts_bel_and_both_st_encodings() {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut collector = TerminalColors::default();
|
||||||
|
collector.query(&mut Vec::new(), now).unwrap();
|
||||||
|
collector.push(key(KeyCode::Esc, KeyModifiers::NONE), now);
|
||||||
|
collector.push(
|
||||||
|
key(KeyCode::Char(']'), KeyModifiers::NONE),
|
||||||
|
now + Duration::from_millis(1),
|
||||||
|
);
|
||||||
|
bytes(
|
||||||
|
&mut collector,
|
||||||
|
"11;rgb:0/12/345",
|
||||||
|
now + Duration::from_millis(2),
|
||||||
|
);
|
||||||
|
collector.push(
|
||||||
|
key(KeyCode::Esc, KeyModifiers::NONE),
|
||||||
|
now + Duration::from_millis(3),
|
||||||
|
);
|
||||||
|
collector.push(
|
||||||
|
key(KeyCode::Char('\\'), KeyModifiers::NONE),
|
||||||
|
now + Duration::from_millis(4),
|
||||||
|
);
|
||||||
|
assert_eq!(collector.colors, None);
|
||||||
|
report(
|
||||||
|
&mut collector,
|
||||||
|
"4;2;rgb:f/ffff/ABCD",
|
||||||
|
now + Duration::from_millis(5),
|
||||||
|
);
|
||||||
|
collector.push(
|
||||||
|
key(KeyCode::Char(']'), KeyModifiers::ALT),
|
||||||
|
now + Duration::from_millis(6),
|
||||||
|
);
|
||||||
|
bytes(
|
||||||
|
&mut collector,
|
||||||
|
"4;1;#FF1234",
|
||||||
|
now + Duration::from_millis(7),
|
||||||
|
);
|
||||||
|
collector.push(
|
||||||
|
key(KeyCode::Char('\\'), KeyModifiers::ALT),
|
||||||
|
now + Duration::from_millis(8),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
collector.colors,
|
||||||
|
Some(ThemeColors {
|
||||||
|
background: [0, 18, 52],
|
||||||
|
red: [255, 18, 52],
|
||||||
|
green: [255, 255, 171],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert!(drain(&mut collector).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn focus_refresh_atomically_replaces_light_and_dark_triples() {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut collector = TerminalColors::default();
|
||||||
|
let palettes = [
|
||||||
|
("11;#FDF6E3", [253, 246, 227]),
|
||||||
|
("11;rgb:0000/0000/0000", [0, 0, 0]),
|
||||||
|
];
|
||||||
|
for (cycle, (background, expected)) in palettes.into_iter().enumerate() {
|
||||||
|
let time = now + Duration::from_secs(cycle as u64);
|
||||||
|
let focus = Event::FocusGained;
|
||||||
|
collector.push(focus.clone(), time);
|
||||||
|
assert_eq!(collector.pop(), Some(focus));
|
||||||
|
let previous = collector.colors;
|
||||||
|
collector.query(&mut Vec::new(), time).unwrap();
|
||||||
|
report(&mut collector, "4;1;rgb:ff/00/00", time);
|
||||||
|
report(&mut collector, background, time);
|
||||||
|
assert_eq!(collector.colors, previous);
|
||||||
|
report(&mut collector, "4;2;rgb:00/ff/00", time);
|
||||||
|
assert_eq!(
|
||||||
|
collector.colors,
|
||||||
|
Some(ThemeColors {
|
||||||
|
background: expected,
|
||||||
|
red: [255, 0, 0],
|
||||||
|
green: [0, 255, 0],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert!(drain(&mut collector).is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn periodic_refresh_detects_silent_change_without_overlapping_queries() {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut collector = TerminalColors::default();
|
||||||
|
let mut output = Vec::new();
|
||||||
|
assert!(collector.refresh_due(now));
|
||||||
|
collector.query(&mut output, now).unwrap();
|
||||||
|
report(&mut collector, "11;#101010", now);
|
||||||
|
report(&mut collector, "4;1;#FF0000", now);
|
||||||
|
report(&mut collector, "4;2;#00FF00", now);
|
||||||
|
let previous = collector.colors;
|
||||||
|
assert!(previous.is_some());
|
||||||
|
|
||||||
|
// The terminal changes its palette without emitting a notification.
|
||||||
|
let refresh = now + Duration::from_secs(1);
|
||||||
|
assert!(!collector.refresh_due(refresh - Duration::from_nanos(1)));
|
||||||
|
assert!(collector.refresh_due(refresh));
|
||||||
|
collector.query(&mut output, refresh).unwrap();
|
||||||
|
report(&mut collector, "11;#EEEEEE", refresh);
|
||||||
|
report(&mut collector, "4;1;#AA1122", refresh);
|
||||||
|
assert_eq!(collector.colors, previous);
|
||||||
|
let typed = key(KeyCode::Char('i'), KeyModifiers::NONE);
|
||||||
|
collector.push(typed.clone(), refresh);
|
||||||
|
assert_eq!(drain(&mut collector), vec![typed]);
|
||||||
|
|
||||||
|
// Even an overdue probe must wait until the incomplete query expires.
|
||||||
|
let retry = refresh + Duration::from_secs(1);
|
||||||
|
assert!(!collector.refresh_due(retry));
|
||||||
|
collector.expire(retry);
|
||||||
|
assert_eq!(collector.colors, None);
|
||||||
|
assert!(collector.refresh_due(retry));
|
||||||
|
collector.query(&mut output, retry).unwrap();
|
||||||
|
report(&mut collector, "11;#EEEEEE", retry);
|
||||||
|
report(&mut collector, "4;1;#AA1122", retry);
|
||||||
|
assert_eq!(collector.colors, None);
|
||||||
|
report(&mut collector, "4;2;#22BB33", retry);
|
||||||
|
assert_eq!(
|
||||||
|
collector.colors,
|
||||||
|
Some(ThemeColors {
|
||||||
|
background: [238, 238, 238],
|
||||||
|
red: [170, 17, 34],
|
||||||
|
green: [34, 187, 51],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert!(drain(&mut collector).is_empty());
|
||||||
|
assert!(!collector.refresh_due(retry + Duration::from_millis(999)));
|
||||||
|
assert!(collector.refresh_due(retry + Duration::from_secs(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_refresh_clears_stale_colors_and_consumes_late_partial_reply() {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut collector = TerminalColors::default();
|
||||||
|
collector.query(&mut Vec::new(), now).unwrap();
|
||||||
|
report(&mut collector, "11;#101010", now);
|
||||||
|
report(&mut collector, "4;1;#FF0000", now);
|
||||||
|
report(&mut collector, "4;2;#00FF00", now);
|
||||||
|
assert!(collector.colors.is_some());
|
||||||
|
let refresh = now + Duration::from_secs(1);
|
||||||
|
collector.query(&mut Vec::new(), refresh).unwrap();
|
||||||
|
report(&mut collector, "11;#EEEEEE", refresh);
|
||||||
|
collector.push(key(KeyCode::Char(']'), KeyModifiers::ALT), refresh);
|
||||||
|
bytes(&mut collector, "4;1;rgb:ffff/", refresh);
|
||||||
|
collector.expire(refresh + QUERY_TIMEOUT);
|
||||||
|
assert_eq!(collector.colors, None);
|
||||||
|
let late = refresh + Duration::from_secs(1);
|
||||||
|
bytes(&mut collector, "0000/0000", late);
|
||||||
|
collector.push(key(KeyCode::Esc, KeyModifiers::NONE), late);
|
||||||
|
collector.expire(late + Duration::from_secs(1));
|
||||||
|
collector.push(
|
||||||
|
key(KeyCode::Char('\\'), KeyModifiers::NONE),
|
||||||
|
late + Duration::from_secs(1),
|
||||||
|
);
|
||||||
|
report(&mut collector, "4;2;#00FF00", late + Duration::from_secs(1));
|
||||||
|
assert_eq!(collector.colors, None);
|
||||||
|
assert!(drain(&mut collector).is_empty());
|
||||||
|
let typed = key(KeyCode::Char('i'), KeyModifiers::NONE);
|
||||||
|
collector.push(typed.clone(), late + Duration::from_secs(2));
|
||||||
|
assert_eq!(collector.pop(), Some(typed));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unrelated_events_and_rejected_candidates_replay_exactly() {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut collector = TerminalColors::default();
|
||||||
|
collector.query(&mut Vec::new(), now).unwrap();
|
||||||
|
let events = vec![
|
||||||
|
key(KeyCode::Char(']'), KeyModifiers::ALT),
|
||||||
|
key(KeyCode::Char('4'), KeyModifiers::NONE),
|
||||||
|
key(KeyCode::Char(';'), KeyModifiers::NONE),
|
||||||
|
key(KeyCode::Char('3'), KeyModifiers::NONE),
|
||||||
|
key(KeyCode::Char('g'), KeyModifiers::CONTROL),
|
||||||
|
key(KeyCode::Char(']'), KeyModifiers::ALT | KeyModifiers::SHIFT),
|
||||||
|
Event::Key(KeyEvent::new_with_kind(
|
||||||
|
KeyCode::Char(']'),
|
||||||
|
KeyModifiers::ALT,
|
||||||
|
KeyEventKind::Release,
|
||||||
|
)),
|
||||||
|
Event::Key(KeyEvent::new_with_kind(
|
||||||
|
KeyCode::Char('A'),
|
||||||
|
KeyModifiers::SHIFT,
|
||||||
|
KeyEventKind::Repeat,
|
||||||
|
)),
|
||||||
|
Event::Key(KeyEvent {
|
||||||
|
code: KeyCode::Char('a'),
|
||||||
|
modifiers: KeyModifiers::CONTROL,
|
||||||
|
kind: KeyEventKind::Press,
|
||||||
|
state: KeyEventState::CAPS_LOCK,
|
||||||
|
}),
|
||||||
|
Event::Mouse(MouseEvent {
|
||||||
|
kind: MouseEventKind::Moved,
|
||||||
|
column: 2,
|
||||||
|
row: 3,
|
||||||
|
modifiers: KeyModifiers::CONTROL,
|
||||||
|
}),
|
||||||
|
Event::FocusLost,
|
||||||
|
Event::Resize(80, 24),
|
||||||
|
key(KeyCode::Char(']'), KeyModifiers::ALT),
|
||||||
|
Event::Paste("\x1b]11;rgb:ff/ff/ff\x07".into()),
|
||||||
|
Event::FocusGained,
|
||||||
|
];
|
||||||
|
for event in &events {
|
||||||
|
collector.push(event.clone(), now);
|
||||||
|
}
|
||||||
|
assert_eq!(drain(&mut collector), events);
|
||||||
|
assert_eq!(collector.colors, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_or_oversized_reports_and_lone_escape_do_not_eat_input() {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut collector = TerminalColors::default();
|
||||||
|
for body in [
|
||||||
|
"11;rgb:12345/0/0",
|
||||||
|
"11;rgb:1//3",
|
||||||
|
"11;#12345",
|
||||||
|
"11;rgb:1/2/3/4",
|
||||||
|
] {
|
||||||
|
let mut expected = vec![key(KeyCode::Char(']'), KeyModifiers::ALT)];
|
||||||
|
expected.extend(
|
||||||
|
body.chars()
|
||||||
|
.map(|ch| key(KeyCode::Char(ch), KeyModifiers::NONE)),
|
||||||
|
);
|
||||||
|
expected.push(key(KeyCode::Char('g'), KeyModifiers::CONTROL));
|
||||||
|
for event in &expected {
|
||||||
|
collector.push(event.clone(), now);
|
||||||
|
}
|
||||||
|
assert_eq!(drain(&mut collector), expected);
|
||||||
|
}
|
||||||
|
let escape = key(KeyCode::Esc, KeyModifiers::NONE);
|
||||||
|
collector.push(escape.clone(), now);
|
||||||
|
assert_eq!(collector.pop(), None);
|
||||||
|
collector.expire(now + ESCAPE_TIMEOUT);
|
||||||
|
assert_eq!(collector.pop(), Some(escape));
|
||||||
|
assert_eq!(collector.pop(), None);
|
||||||
|
let start = key(KeyCode::Char(']'), KeyModifiers::ALT);
|
||||||
|
collector.push(start.clone(), now + Duration::from_secs(1));
|
||||||
|
collector.expire(now + Duration::from_secs(1) + QUERY_TIMEOUT);
|
||||||
|
assert_eq!(collector.pop(), Some(start));
|
||||||
|
assert_eq!(collector.pop(), None);
|
||||||
|
}
|
||||||
+15226
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn layout(text: &str, width: usize) -> Vec<Segment> {
|
||||||
|
let mut cache = WrapCache::default();
|
||||||
|
cache.prepare((1, 0), width);
|
||||||
|
cache.segments(0, text).to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn words_and_long_words_preserve_every_source_cell() {
|
||||||
|
assert_eq!(
|
||||||
|
layout("one two three", 8),
|
||||||
|
vec![Segment { start: 0, end: 8 }, Segment { start: 8, end: 13 },]
|
||||||
|
);
|
||||||
|
let segments = layout("abcdefgh", 3);
|
||||||
|
assert_eq!(
|
||||||
|
segments,
|
||||||
|
vec![
|
||||||
|
Segment { start: 0, end: 3 },
|
||||||
|
Segment { start: 3, end: 6 },
|
||||||
|
Segment { start: 6, end: 8 },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(caret_segment(&segments, 3), 1);
|
||||||
|
assert_eq!(caret_segment(&segments, 8), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn long_words_do_not_wrap_indentation_onto_an_empty_visual_row() {
|
||||||
|
for source in [
|
||||||
|
" abcdefghijk",
|
||||||
|
"\tabcdefghijk",
|
||||||
|
"head abcdefghijk",
|
||||||
|
] {
|
||||||
|
let expanded = source.replace('\t', " ");
|
||||||
|
let segments = layout(source, 8);
|
||||||
|
assert_eq!(segments.first().unwrap().start, 0);
|
||||||
|
assert_eq!(segments.last().unwrap().end, expanded.len());
|
||||||
|
assert!(segments.windows(2).all(|pair| pair[0].end == pair[1].start));
|
||||||
|
for segment in segments
|
||||||
|
.iter()
|
||||||
|
.filter(|segment| segment.start < segment.end)
|
||||||
|
{
|
||||||
|
assert!(
|
||||||
|
!expanded[segment.start..segment.end].trim().is_empty(),
|
||||||
|
"whitespace-only visual row {segment:?} in {source:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tabs_use_source_stops_and_unicode_graphemes_remain_intact() {
|
||||||
|
assert_eq!(
|
||||||
|
layout("ab\t界e\u{301} z", 4),
|
||||||
|
vec![
|
||||||
|
Segment { start: 0, end: 4 },
|
||||||
|
Segment { start: 4, end: 8 },
|
||||||
|
Segment { start: 8, end: 9 },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
layout("界界界", 3),
|
||||||
|
vec![
|
||||||
|
Segment { start: 0, end: 2 },
|
||||||
|
Segment { start: 2, end: 4 },
|
||||||
|
Segment { start: 4, end: 6 },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
layout(" \t ", 3),
|
||||||
|
vec![Segment { start: 0, end: 3 }, Segment { start: 3, end: 5 },]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn narrow_tabs_preserve_all_expanded_cells_at_original_stops() {
|
||||||
|
assert_eq!(
|
||||||
|
layout("\tX", 2),
|
||||||
|
[
|
||||||
|
Segment { start: 0, end: 2 },
|
||||||
|
Segment { start: 2, end: 4 },
|
||||||
|
Segment { start: 4, end: 5 },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
layout("a\tX", 2),
|
||||||
|
[
|
||||||
|
Segment { start: 0, end: 1 },
|
||||||
|
Segment { start: 1, end: 3 },
|
||||||
|
Segment { start: 3, end: 5 },
|
||||||
|
Segment { start: 5, end: 5 },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
for source in ["\t", "a\t", "ab\tX", "\t\tX"] {
|
||||||
|
let segments = layout(source, 1);
|
||||||
|
assert!(
|
||||||
|
segments
|
||||||
|
.iter()
|
||||||
|
.all(|segment| segment.end - segment.start <= 1)
|
||||||
|
);
|
||||||
|
assert!(segments.windows(2).all(|pair| pair[0].end == pair[1].start));
|
||||||
|
let end = source.graphemes(true).fold(0, |column, grapheme| {
|
||||||
|
column
|
||||||
|
+ if grapheme == "\t" {
|
||||||
|
TAB_WIDTH - column % TAB_WIDTH
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert_eq!(segments.last().unwrap(), &Segment { start: end, end });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_lines_have_an_insertion_cell_and_cache_invalidation_is_lazy() {
|
||||||
|
let mut cache = WrapCache::default();
|
||||||
|
cache.prepare((7, 2), 4);
|
||||||
|
assert_eq!(
|
||||||
|
cache.segments(90, "abcd"),
|
||||||
|
[Segment { start: 0, end: 4 }, Segment { start: 4, end: 4 },]
|
||||||
|
);
|
||||||
|
assert_eq!(caret_segment(cache.segments(90, "abcd"), 4), 1);
|
||||||
|
assert_eq!(cache.rows.len(), 1);
|
||||||
|
cache.prepare((7, 2), 4);
|
||||||
|
assert_eq!(cache.rows.len(), 1);
|
||||||
|
cache.prepare((7, 2), 8);
|
||||||
|
assert!(cache.rows.is_empty());
|
||||||
|
cache.segments(90, "abcd");
|
||||||
|
cache.prepare((7, 3), 8);
|
||||||
|
assert!(cache.rows.is_empty());
|
||||||
|
assert_eq!(layout("", 0), [Segment { start: 0, end: 0 }]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user