1pub mod merge;
18
19use std::env;
20use std::fs;
21use std::io::{self, IsTerminal, Write};
22use std::path::{Path, PathBuf};
23use std::process::{Command, Stdio};
24use std::time::{Duration, SystemTime};
25
26use anyhow::{Context, Result, bail};
27
28use crate::store;
29
30const LIBRARY: &str = "commands.yaml";
32
33const DEFAULT_BRANCH: &str = "main";
35
36const BRANCH_KEY: &str = "lore.branch";
38
39const SYNCED_KEY: &str = "lore.synced";
45
46const REPOSITORY_NAME: &str = "lore-library";
48
49const COMMIT_NAME: &str = "lore";
55const COMMIT_EMAIL: &str = "lore@invalid";
56
57const LOCK: &str = ".lore-sync.lock";
59
60const STALE_LOCK: Duration = Duration::from_secs(120);
62
63const BACKGROUND_WAIT: Duration = Duration::from_secs(30);
65
66const LAST_SYNC: &str = ".lore-last-sync";
68
69const LAST_ERROR: &str = ".lore-sync-error";
71
72const REFRESH: Duration = Duration::from_secs(15 * 60);
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum Mode {
81 Interactive,
83 Background,
86}
87
88#[derive(Debug, Default)]
90pub struct Report {
91 pub received: usize,
92 pub sent: usize,
93 pub kept_both: Vec<(String, String)>,
94}
95
96impl Report {
97 pub fn summary(&self) -> String {
98 let mut lines = Vec::new();
99 lines.push(match (self.received, self.sent) {
100 (0, 0) => "Already up to date".to_string(),
101 (received, sent) => format!(
102 "Synced: {} from other machines, {} from this one",
103 count(received, "change"),
104 count(sent, "change")
105 ),
106 });
107 for (id, copy) in &self.kept_both {
108 lines.push(format!(
109 "{id} was changed on two machines. The one synced first kept the id, \
110 this machine's version is now {copy}"
111 ));
112 }
113 lines.join("\n")
114 }
115}
116
117fn count(n: usize, noun: &str) -> String {
118 if n == 1 {
119 format!("1 {noun}")
120 } else {
121 format!("{n} {noun}s")
122 }
123}
124
125pub fn is_configured() -> bool {
127 store::sync_dir().is_ok_and(|dir| dir.join(".git").is_dir())
128}
129
130pub fn init(url: Option<String>) -> Result<Report> {
133 require_git()?;
134 let dir = store::sync_dir()?;
135
136 if dir.join(".git").is_dir() {
137 let current = remote_url(&dir)?;
138 match &url {
139 Some(url) if url != ¤t => bail!(
140 "this machine already syncs with {current}. \
141 Run `lore sync disconnect` first to switch"
142 ),
143 _ => {
144 println!("Already syncing with {current}");
145 return run(Mode::Interactive);
146 }
147 }
148 }
149
150 let addresses = match url {
151 Some(url) => vec![url],
152 None => addresses_of(&create_repository()?)?,
153 };
154
155 if let Some(parent) = dir.parent() {
156 fs::create_dir_all(parent)
157 .with_context(|| format!("failed to create {}", parent.display()))?;
158 }
159
160 let url = connect(&dir, &addresses)?;
161
162 let branch = git(
163 &dir,
164 Mode::Interactive,
165 &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
166 )
167 .ok()
168 .and_then(|reference| reference.strip_prefix("origin/").map(str::to_string))
169 .unwrap_or_else(|| DEFAULT_BRANCH.to_string());
170 git(&dir, Mode::Interactive, &["config", BRANCH_KEY, &branch])?;
171
172 warn_if_public(&url);
173 run(Mode::Interactive)
174}
175
176pub fn run(mode: Mode) -> Result<Report> {
178 let dir = store::sync_dir()?;
179 if !dir.join(".git").is_dir() {
180 bail!("sync is not set up on this machine. Run `lore sync init` first");
181 }
182 require_git()?;
183
184 let _lock = match mode {
191 Mode::Background => match Lock::wait(&dir, BACKGROUND_WAIT)? {
192 Some(lock) => lock,
193 None => return Ok(Report::default()),
194 },
195 Mode::Interactive => match Lock::wait(&dir, STALE_LOCK)? {
196 Some(lock) => lock,
197 None => bail!("another sync has been running for two minutes, try again later"),
198 },
199 };
200
201 let result = sync_once(&dir, mode).or_else(|error| {
202 if is_rejected_push(&error) {
205 sync_once(&dir, mode)
206 } else {
207 Err(error)
208 }
209 });
210
211 match &result {
212 Ok(_) => {
213 let _ = fs::write(dir.join(LAST_SYNC), "");
214 let _ = fs::remove_file(dir.join(LAST_ERROR));
215 }
216 Err(error) if mode == Mode::Background => {
217 let _ = fs::write(dir.join(LAST_ERROR), format!("{error:#}"));
218 }
219 Err(_) => {}
220 }
221
222 result
223}
224
225fn sync_once(dir: &Path, mode: Mode) -> Result<Report> {
226 let branch = git(dir, mode, &["config", "--get", BRANCH_KEY])
227 .unwrap_or_else(|_| DEFAULT_BRANCH.to_string());
228 let remote = format!("refs/remotes/origin/{branch}");
229
230 git(dir, mode, &["fetch", "--quiet", "origin"])
231 .context("could not fetch from the repository")?;
232
233 let has_remote = resolves(dir, mode, &remote);
234
235 let theirs = if has_remote {
236 file_at(dir, mode, &remote)?
237 } else {
238 None
239 };
240
241 let base = match git(dir, mode, &["config", "--get", SYNCED_KEY]) {
245 Ok(commit) => file_at(dir, mode, &commit)?,
246 Err(_) => None,
247 };
248
249 let library = store::user_library()?;
250 let ours = match fs::read_to_string(&library) {
251 Ok(text) => text,
252 Err(error) if error.kind() == io::ErrorKind::NotFound => String::new(),
253 Err(error) => {
254 return Err(error).with_context(|| format!("failed to read {}", library.display()));
255 }
256 };
257
258 let merged = merge::merge(base.as_deref(), &ours, theirs.as_deref())?;
259
260 if theirs.is_none() && merged.text.trim().is_empty() {
262 return Ok(Report::default());
263 }
264
265 if merged.text != ours {
266 if let Some(parent) = library.parent() {
267 fs::create_dir_all(parent)
268 .with_context(|| format!("failed to create {}", parent.display()))?;
269 }
270 fs::write(&library, &merged.text)
271 .with_context(|| format!("failed to write {}", library.display()))?;
272 }
273
274 if has_remote {
277 git(dir, mode, &["reset", "--quiet", "--soft", &remote])?;
278 }
279
280 fs::write(dir.join(LIBRARY), &merged.text)
281 .with_context(|| format!("failed to write {}", dir.join(LIBRARY).display()))?;
282 git(dir, mode, &["add", LIBRARY])?;
283
284 let staged = !git_in(Some(dir), mode)
285 .args(["diff", "--cached", "--quiet"])
286 .status()
287 .context("failed to run git")?
288 .success();
289 if staged {
290 let message = format!("Sync from {}", machine_name());
291 let mut commit = git_in(Some(dir), mode);
292 if !has_identity(dir, mode) {
293 commit.args([
294 "-c",
295 &format!("user.name={COMMIT_NAME}"),
296 "-c",
297 &format!("user.email={COMMIT_EMAIL}"),
298 ]);
299 }
300 commit.args(["commit", "--quiet", "--no-verify", "-m", &message]);
301 checked(commit, "commit")?;
302 }
303
304 let ahead = match (has_remote, resolves(dir, mode, "HEAD")) {
305 (_, false) => false,
306 (false, true) => true,
307 (true, true) => {
308 git(dir, mode, &["rev-parse", "HEAD"])? != git(dir, mode, &["rev-parse", &remote])?
309 }
310 };
311 if ahead {
312 let target = format!("HEAD:refs/heads/{branch}");
313 git(dir, mode, &["push", "--quiet", "origin", &target])
314 .context("could not push to the repository")?;
315 }
316
317 if let Ok(agreed) = git(dir, mode, &["rev-parse", "HEAD"]) {
318 git(dir, mode, &["config", SYNCED_KEY, &agreed])?;
319 }
320
321 Ok(Report {
322 received: merged.received,
323 sent: merged.sent,
324 kept_both: merged.kept_both,
325 })
326}
327
328pub fn status() -> Result<()> {
330 let dir = store::sync_dir()?;
331 if !dir.join(".git").is_dir() {
332 println!("Sync is not set up on this machine. Run `lore sync init` to start");
333 return Ok(());
334 }
335
336 println!("Syncing with {}", remote_url(&dir)?);
337 match fs::metadata(dir.join(LAST_SYNC)).and_then(|meta| meta.modified()) {
338 Ok(at) => println!("Last synced {}", ago(at)),
339 Err(_) => println!("Not synced yet"),
340 }
341 if let Some(error) = last_error() {
342 println!("The last automatic sync failed: {error}");
343 println!("Run `lore sync` to try again and see the whole message");
344 }
345 Ok(())
346}
347
348pub fn disconnect() -> Result<()> {
351 let dir = store::sync_dir()?;
352 if !dir.join(".git").is_dir() {
353 println!("Sync is not set up on this machine");
354 return Ok(());
355 }
356
357 let url = remote_url(&dir).unwrap_or_default();
358 fs::remove_dir_all(&dir).with_context(|| format!("failed to remove {}", dir.display()))?;
359 println!("Stopped syncing with {url}. Your library stays where it is");
360 Ok(())
361}
362
363pub fn last_error() -> Option<String> {
365 let dir = store::sync_dir().ok()?;
366 let text = fs::read_to_string(dir.join(LAST_ERROR)).ok()?;
367 let first = text.lines().next()?.trim();
368 (!first.is_empty()).then(|| first.to_string())
369}
370
371pub fn spawn() {
376 if !is_configured() || automatic_sync_is_off() {
377 return;
378 }
379 let Ok(exe) = env::current_exe() else {
380 return;
381 };
382
383 let mut command = Command::new(exe);
384 command
385 .args(["sync", "--background"])
386 .stdin(Stdio::null())
387 .stdout(Stdio::null())
388 .stderr(Stdio::null());
389
390 #[cfg(unix)]
391 {
392 use std::os::unix::process::CommandExt;
393 command.process_group(0);
396 }
397 #[cfg(windows)]
398 {
399 use std::os::windows::process::CommandExt;
400 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
401 const DETACHED_PROCESS: u32 = 0x0000_0008;
402 command.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS);
403 }
404
405 let _ = command.spawn();
406}
407
408pub fn refresh_if_stale() {
411 let Ok(dir) = store::sync_dir() else {
412 return;
413 };
414 let fresh = fs::metadata(dir.join(LAST_SYNC))
415 .and_then(|meta| meta.modified())
416 .is_ok_and(|at| at.elapsed().is_ok_and(|age| age < REFRESH));
417 if !fresh {
418 spawn();
419 }
420}
421
422const NO_AUTO_SYNC: &str = "LORE_NO_AUTO_SYNC";
424
425fn automatic_sync_is_off() -> bool {
426 env::var_os(NO_AUTO_SYNC).is_some_and(|value| !value.is_empty())
427}
428
429fn connect(dir: &Path, addresses: &[String]) -> Result<String> {
438 let mut failures = Vec::new();
439
440 for (attempt, url) in addresses.iter().enumerate() {
441 println!("Connecting to {url}");
442 match clone(dir, url) {
443 Ok(()) => return Ok(url.clone()),
444 Err(error) => {
445 println!(" that address did not work");
446 failures.push(format!("{url}: {error}"));
447 }
448 }
449
450 if attempt + 1 == addresses.len() && setup_git_credentials() {
453 for url in addresses {
454 println!("Connecting to {url}");
455 if clone(dir, url).is_ok() {
456 return Ok(url.clone());
457 }
458 }
459 }
460 }
461
462 bail!(
463 "could not reach the repository.\n {}\n\n\
464 If you use ssh with GitHub, check that `ssh -T git@github.com` greets you. \
465 For https, `gh auth login` or a credential helper has to be set up first.",
466 failures.join("\n ")
467 )
468}
469
470fn clone(dir: &Path, url: &str) -> Result<()> {
471 if dir.exists() {
472 fs::remove_dir_all(dir).with_context(|| format!("failed to clear {}", dir.display()))?;
473 }
474
475 let output = git_in(None, Mode::Background)
478 .arg("clone")
479 .arg("--quiet")
480 .arg(url)
481 .arg(dir)
482 .output()
483 .context("failed to run git")?;
484
485 if !output.status.success() {
486 let _ = fs::remove_dir_all(dir);
487 let stderr = String::from_utf8_lossy(&output.stderr);
488 let reason = stderr
489 .lines()
490 .find(|line| !line.trim().is_empty())
491 .unwrap_or("git clone failed");
492 bail!("{}", reason.trim().trim_start_matches("fatal: "));
493 }
494 Ok(())
495}
496
497fn setup_git_credentials() -> bool {
500 let done = Command::new("gh")
501 .args(["auth", "setup-git"])
502 .stdout(Stdio::null())
503 .stderr(Stdio::null())
504 .status()
505 .is_ok_and(|status| status.success());
506 if done {
507 println!("Set up git to use your GitHub CLI login");
508 }
509 done
510}
511
512fn addresses_of(name: &str) -> Result<Vec<String>> {
514 let protocol = gh_output(&["config", "get", "git_protocol"]).unwrap_or_default();
515
516 let https = gh_output(&["repo", "view", name, "--json", "url", "--jq", ".url"]);
517 let ssh = gh_output(&["repo", "view", name, "--json", "sshUrl", "--jq", ".sshUrl"]);
518
519 let mut addresses: Vec<String> = if protocol == "ssh" {
520 vec![ssh, https]
521 } else {
522 vec![https, ssh]
523 }
524 .into_iter()
525 .flatten()
526 .collect();
527 addresses.dedup();
528
529 if addresses.is_empty() {
530 bail!("could not find the address of {name}");
531 }
532 Ok(addresses)
533}
534
535fn gh_output(arguments: &[&str]) -> Option<String> {
536 let output = Command::new("gh").args(arguments).output().ok()?;
537 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
538 (output.status.success() && !value.is_empty()).then_some(value)
539}
540
541fn create_repository() -> Result<String> {
544 let instructions = format!(
545 "Create an empty private repository, for example {REPOSITORY_NAME} on GitHub, \
546 then run:\n\n lore sync init <its address>\n\n\
547 With the GitHub CLI installed and logged in, `lore sync init` makes it for you"
548 );
549
550 let signed_in = Command::new("gh")
551 .args(["auth", "status"])
552 .stdout(Stdio::null())
553 .stderr(Stdio::null())
554 .status()
555 .is_ok_and(|status| status.success());
556 if !signed_in {
557 bail!("{instructions}");
558 }
559
560 let exists = Command::new("gh")
561 .args(["repo", "view", REPOSITORY_NAME, "--json", "name"])
562 .stdout(Stdio::null())
563 .stderr(Stdio::null())
564 .status()
565 .is_ok_and(|status| status.success());
566
567 if exists {
568 println!("Using your existing private repository {REPOSITORY_NAME}");
569 } else {
570 if !confirm(&format!(
571 "Create a private GitHub repository named {REPOSITORY_NAME} for your library?"
572 ))? {
573 bail!("{instructions}");
574 }
575 let created = Command::new("gh")
576 .args([
577 "repo",
578 "create",
579 REPOSITORY_NAME,
580 "--private",
581 "--description",
582 "My lore command library",
583 ])
584 .stdout(Stdio::null())
585 .output()
586 .context("failed to run gh")?;
587 if !created.status.success() {
588 bail!(
589 "could not create the repository: {}",
590 String::from_utf8_lossy(&created.stderr).trim()
591 );
592 }
593 println!("Created the private repository {REPOSITORY_NAME}");
594 }
595
596 Ok(REPOSITORY_NAME.to_string())
597}
598
599fn warn_if_public(url: &str) {
602 let public = Command::new("gh")
603 .args([
604 "repo",
605 "view",
606 url,
607 "--json",
608 "isPrivate",
609 "--jq",
610 ".isPrivate",
611 ])
612 .stderr(Stdio::null())
613 .output()
614 .is_ok_and(|out| {
615 out.status.success() && String::from_utf8_lossy(&out.stdout).trim() == "false"
616 });
617 if public {
618 println!(
619 "Warning: this repository is public. Saved commands often contain server \
620 names and addresses, so consider making it private"
621 );
622 }
623}
624
625fn confirm(question: &str) -> Result<bool> {
626 if !io::stdin().is_terminal() {
627 return Ok(false);
628 }
629 print!("{question} [y/N] ");
630 io::stdout().flush()?;
631 let mut answer = String::new();
632 io::stdin().read_line(&mut answer)?;
633 Ok(matches!(answer.trim().to_lowercase().as_str(), "y" | "yes"))
634}
635
636fn require_git() -> Result<()> {
637 let found = Command::new("git")
638 .arg("--version")
639 .stdout(Stdio::null())
640 .stderr(Stdio::null())
641 .status()
642 .is_ok_and(|status| status.success());
643 if !found {
644 bail!("sync needs git, and git was not found on PATH");
645 }
646 Ok(())
647}
648
649fn git_in(dir: Option<&Path>, mode: Mode) -> Command {
657 let mut command = Command::new("git");
658 if let Some(dir) = dir {
659 command.arg("-C").arg(dir);
660 }
661 command.args(["-c", "core.autocrlf=false", "-c", "commit.gpgsign=false"]);
662 command.env("GIT_TERMINAL_PROMPT", "0");
663
664 if mode == Mode::Background {
665 command.stdin(Stdio::null());
666 let custom_ssh = env::var_os("GIT_SSH_COMMAND").is_some()
667 || dir.is_some_and(|dir| {
668 Command::new("git")
669 .arg("-C")
670 .arg(dir)
671 .args(["config", "--get", "core.sshCommand"])
672 .output()
673 .is_ok_and(|out| out.status.success())
674 });
675 if !custom_ssh {
676 command.env("GIT_SSH_COMMAND", "ssh -o BatchMode=yes");
677 }
678 }
679 command
680}
681
682fn git(dir: &Path, mode: Mode, args: &[&str]) -> Result<String> {
684 let mut command = git_in(Some(dir), mode);
685 command.args(args);
686 checked(command, args.first().copied().unwrap_or("git"))
687}
688
689fn checked(mut command: Command, what: &str) -> Result<String> {
690 let output = command.output().context("failed to run git")?;
691 if !output.status.success() {
692 let stderr = String::from_utf8_lossy(&output.stderr);
693 let message = stderr.trim();
694 bail!(GitError {
695 what: what.to_string(),
696 message: if message.is_empty() {
697 format!("git {what} failed")
698 } else {
699 message.to_string()
700 },
701 });
702 }
703 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
704}
705
706#[derive(Debug)]
708struct GitError {
709 what: String,
710 message: String,
711}
712
713impl std::fmt::Display for GitError {
714 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
715 write!(f, "{}", self.message)
716 }
717}
718
719impl std::error::Error for GitError {}
720
721fn is_rejected_push(error: &anyhow::Error) -> bool {
722 error.chain().any(|cause| {
723 cause.downcast_ref::<GitError>().is_some_and(|git| {
724 git.what == "push"
725 && (git.message.contains("rejected")
726 || git.message.contains("fetch first")
727 || git.message.contains("non-fast-forward"))
728 })
729 })
730}
731
732fn resolves(dir: &Path, mode: Mode, reference: &str) -> bool {
733 git(dir, mode, &["rev-parse", "--verify", "--quiet", reference]).is_ok()
734}
735
736fn file_at(dir: &Path, mode: Mode, revision: &str) -> Result<Option<String>> {
738 let path = format!("{revision}:{LIBRARY}");
739 if !resolves(dir, mode, &path) {
740 return Ok(None);
741 }
742 let output = git_in(Some(dir), mode)
743 .args(["show", &path])
744 .output()
745 .context("failed to run git")?;
746 if !output.status.success() {
747 bail!("could not read {LIBRARY} at {revision}");
748 }
749 Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()))
750}
751
752fn remote_url(dir: &Path) -> Result<String> {
753 git(dir, Mode::Interactive, &["remote", "get-url", "origin"])
754}
755
756fn has_identity(dir: &Path, mode: Mode) -> bool {
765 let local = |key: &str| {
766 git(dir, mode, &["config", "--local", "--get", key]).is_ok_and(|value| !value.is_empty())
767 };
768 local("user.email") && local("user.name")
769}
770
771fn machine_name() -> String {
774 env::var("COMPUTERNAME")
775 .ok()
776 .or_else(|| {
777 Command::new("hostname")
778 .output()
779 .ok()
780 .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
781 })
782 .filter(|name| !name.is_empty())
783 .unwrap_or_else(|| "a machine".to_string())
784}
785
786fn ago(at: SystemTime) -> String {
787 let seconds = at.elapsed().map(|age| age.as_secs()).unwrap_or(0);
788 match seconds {
789 0..60 => "just now".to_string(),
790 60..3600 => format!("{} ago", count((seconds / 60) as usize, "minute")),
791 3600..86400 => format!("{} ago", count((seconds / 3600) as usize, "hour")),
792 _ => format!("{} ago", count((seconds / 86400) as usize, "day")),
793 }
794}
795
796struct Lock(PathBuf);
798
799impl Lock {
800 fn take(dir: &Path) -> Result<Option<Self>> {
802 let path = dir.join(LOCK);
803 for _ in 0..2 {
804 match fs::OpenOptions::new()
805 .write(true)
806 .create_new(true)
807 .open(&path)
808 {
809 Ok(_) => return Ok(Some(Self(path))),
810 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
811 let stale = fs::metadata(&path)
812 .and_then(|meta| meta.modified())
813 .is_ok_and(|at| at.elapsed().is_ok_and(|age| age > STALE_LOCK));
814 if !stale {
815 return Ok(None);
816 }
817 let _ = fs::remove_file(&path);
818 }
819 Err(error) => {
820 return Err(error)
821 .with_context(|| format!("failed to lock {}", path.display()));
822 }
823 }
824 }
825 Ok(None)
826 }
827}
828
829impl Lock {
830 fn wait(dir: &Path, patience: Duration) -> Result<Option<Self>> {
832 let started = SystemTime::now();
833 loop {
834 if let Some(lock) = Self::take(dir)? {
835 return Ok(Some(lock));
836 }
837 if started.elapsed().is_ok_and(|waited| waited > patience) {
838 return Ok(None);
839 }
840 std::thread::sleep(Duration::from_millis(200));
841 }
842 }
843}
844
845impl Drop for Lock {
846 fn drop(&mut self) {
847 let _ = fs::remove_file(&self.0);
848 }
849}
850
851#[cfg(test)]
852mod tests {
853 use super::*;
854
855 #[test]
856 fn the_summary_says_what_moved() {
857 assert_eq!(Report::default().summary(), "Already up to date");
858
859 let report = Report {
860 received: 1,
861 sent: 2,
862 kept_both: vec![("a".to_string(), "a-2".to_string())],
863 };
864 let summary = report.summary();
865 assert!(
866 summary.contains("1 change from other machines, 2 changes from this one"),
867 "{summary}"
868 );
869 assert!(summary.contains("now a-2"), "{summary}");
870 }
871
872 #[test]
873 fn a_rejected_push_is_recognised_and_nothing_else_is() {
874 let rejected = anyhow::Error::new(GitError {
875 what: "push".to_string(),
876 message: "! [rejected] HEAD -> main (fetch first)".to_string(),
877 })
878 .context("could not push to the repository");
879 assert!(is_rejected_push(&rejected));
880
881 let unreachable = anyhow::Error::new(GitError {
882 what: "fetch".to_string(),
883 message: "Could not resolve host".to_string(),
884 });
885 assert!(!is_rejected_push(&unreachable));
886 }
887
888 #[test]
889 fn waiting_for_a_held_lock_gives_up_rather_than_hanging() {
890 let dir = env::temp_dir().join(format!("lore-lock-wait-{}", std::process::id()));
891 fs::create_dir_all(&dir).unwrap();
892
893 let held = Lock::take(&dir).unwrap();
894 assert!(held.is_some());
895
896 let waited = Lock::wait(&dir, Duration::from_millis(300)).unwrap();
897 assert!(waited.is_none(), "took a lock somebody else was holding");
898
899 drop(held);
900 assert!(
901 Lock::wait(&dir, Duration::from_millis(300))
902 .unwrap()
903 .is_some()
904 );
905
906 let _ = fs::remove_dir_all(&dir);
907 }
908
909 #[test]
910 fn a_lock_is_exclusive_until_it_is_dropped() {
911 let dir = env::temp_dir().join(format!("lore-lock-{}", std::process::id()));
912 fs::create_dir_all(&dir).unwrap();
913
914 let first = Lock::take(&dir).unwrap();
915 assert!(first.is_some());
916 assert!(Lock::take(&dir).unwrap().is_none());
917 drop(first);
918 assert!(Lock::take(&dir).unwrap().is_some());
919
920 let _ = fs::remove_dir_all(&dir);
921 }
922}