1use std::collections::BTreeSet;
24use std::fmt::Write as _;
25use std::path::Path;
26
27use serde::{Deserialize, Serialize};
28
29use crate::core::agent_lease::{
30 AGENT_LEASE_SCHEMA_VERSION, AgentLeaseAcquireV1, AgentLeaseRequestV1, AgentLeaseResourceKindV1,
31 AgentLeaseV1,
32};
33
34pub const UNIT_SCHEME: &str = "project-unit:";
36pub const WHOLE_FILE: &str = "*";
38pub const CLAIM_TTL_MS: u64 = 10 * 60 * 1_000;
40pub const CLAIMS_SCHEMA: &str = "leanctx.claims.v1";
42const MAX_REF_BYTES: usize = 256;
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub enum ClaimMode {
47 Off,
48 Warn,
49 Strict,
50}
51
52#[must_use]
54pub fn mode() -> ClaimMode {
55 match std::env::var("LEAN_CTX_CLAIM_MODE")
56 .unwrap_or_default()
57 .trim()
58 .to_ascii_lowercase()
59 .as_str()
60 {
61 "off" | "0" | "false" => ClaimMode::Off,
62 "strict" | "enforce" | "refuse" => ClaimMode::Strict,
63 _ => ClaimMode::Warn,
64 }
65}
66
67#[must_use]
72pub fn repo_scope(project_root: &str) -> String {
73 let identity = crate::core::project_hash::project_identity(project_root)
74 .unwrap_or_else(|| crate::core::project_hash::hash_project_root(project_root));
75 blake3::hash(identity.as_bytes()).to_hex()[..16].to_string()
76}
77
78fn encode(out: &mut String, text: &str) {
79 for byte in text.bytes() {
80 if byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'.' | b'-' | b'_' | b':') {
81 out.push(byte as char);
82 } else {
83 let _ = write!(out, "%{byte:02X}");
84 }
85 }
86}
87
88fn decode(text: &str) -> Option<String> {
89 let bytes = text.as_bytes();
90 let mut out = Vec::with_capacity(bytes.len());
91 let mut i = 0;
92 while i < bytes.len() {
93 if bytes[i] == b'%' {
94 let hex = text.get(i + 1..i + 3)?;
95 out.push(u8::from_str_radix(hex, 16).ok()?);
96 i += 3;
97 } else {
98 out.push(bytes[i]);
99 i += 1;
100 }
101 }
102 String::from_utf8(out).ok()
103}
104
105#[must_use]
108pub fn unit_resource(scope: &str, rel: &str, unit: &str) -> Option<String> {
109 let mut out = format!("{UNIT_SCHEME}{scope}/");
110 encode(&mut out, rel);
111 out.push('#');
112 encode(&mut out, unit);
113 (out.len() <= MAX_REF_BYTES).then_some(out)
114}
115
116#[must_use]
118pub fn parse_unit_resource(resource: &str) -> Option<(String, String, String)> {
119 let rest = resource.strip_prefix(UNIT_SCHEME)?;
120 let (scope, rest) = rest.split_once('/')?;
121 let (rel, unit) = rest.rsplit_once('#')?;
122 Some((scope.to_string(), decode(rel)?, decode(unit)?))
123}
124
125#[must_use]
127pub fn relative_path(project_root: &str, path: &Path) -> Option<String> {
128 let root = Path::new(project_root);
129 let rel = path
130 .strip_prefix(root)
131 .ok()
132 .map(Path::to_path_buf)
133 .or_else(|| {
134 let root = crate::core::pathutil::safe_canonicalize_or_self(root);
135 let path = path
136 .parent()
137 .map(crate::core::pathutil::safe_canonicalize_or_self)
138 .map(|p| p.join(path.file_name().unwrap_or_default()))?;
139 path.strip_prefix(root).ok().map(Path::to_path_buf)
140 })?;
141 let rel = rel.to_string_lossy().replace('\\', "/");
142 (!rel.is_empty() && !rel.split('/').any(|p| p == "..")).then_some(rel)
143}
144
145#[derive(Clone, Debug, PartialEq, Eq)]
149pub struct UnitSpan {
150 pub name: String,
151 pub start: usize,
152 pub end: usize,
153}
154
155fn markdown_sections(content: &str) -> Vec<UnitSpan> {
156 let lines: Vec<&str> = content.lines().collect();
157 let mut heads: Vec<(usize, usize, String)> = Vec::new();
158 let mut fence = false;
159 for (i, line) in lines.iter().enumerate() {
160 let trimmed = line.trim_start();
161 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
162 fence = !fence;
163 continue;
164 }
165 if fence {
166 continue;
167 }
168 let level = line.bytes().take_while(|b| *b == b'#').count();
169 if (1..=6).contains(&level) && line[level..].starts_with(' ') {
170 heads.push((i + 1, level, line[level..].trim().to_string()));
171 }
172 }
173 heads
174 .iter()
175 .enumerate()
176 .map(|(k, (start, level, name))| {
177 let end = heads[k + 1..]
178 .iter()
179 .find(|(_, l, _)| l <= level)
180 .map_or(lines.len(), |(s, _, _)| s - 1);
181 UnitSpan {
182 name: name.clone(),
183 start: *start,
184 end: end.max(*start),
185 }
186 })
187 .collect()
188}
189
190#[must_use]
193pub fn units_of(path: &str, content: &str) -> Vec<UnitSpan> {
194 let ext = Path::new(path)
195 .extension()
196 .and_then(|e| e.to_str())
197 .unwrap_or("")
198 .to_ascii_lowercase();
199 if matches!(ext.as_str(), "md" | "markdown" | "mdx") {
200 return markdown_sections(content);
201 }
202 crate::core::signatures::extract_signatures_counted(content, &ext)
204 .0
205 .into_iter()
206 .filter_map(|sig| {
207 let start = sig.start_line?;
208 let end = sig.end_line.unwrap_or(start).max(start);
209 (!sig.name.is_empty()).then_some(UnitSpan {
210 name: sig.name,
211 start,
212 end,
213 })
214 })
215 .collect()
216}
217
218#[must_use]
221pub fn changed_spans(before: &str, after: &str) -> (Vec<(usize, usize)>, Vec<(usize, usize)>) {
222 let diff = similar::TextDiff::from_lines(before, after);
223 let mut old = Vec::new();
224 let mut new = Vec::new();
225 for op in diff.ops() {
226 let (tag, o, n) = op.as_tag_tuple();
227 if tag == similar::DiffTag::Equal {
228 continue;
229 }
230 old.push((o.start + 1, o.end));
231 new.push((n.start + 1, n.end));
232 }
233 (old, new)
234}
235
236fn touches(unit: &UnitSpan, (start, end): (usize, usize)) -> bool {
237 if end < start {
238 unit.start < start && start <= unit.end
241 } else {
242 unit.start <= end && start <= unit.end
243 }
244}
245
246#[must_use]
248pub fn touched_units(units: &[UnitSpan], spans: &[(usize, usize)]) -> BTreeSet<String> {
249 let mut out = BTreeSet::new();
250 for &span in spans {
251 let hit: Vec<&UnitSpan> = units.iter().filter(|u| touches(u, span)).collect();
252 for u in &hit {
253 let inner = hit.iter().any(|v| {
254 v != u
255 && v.start >= u.start
256 && v.end <= u.end
257 && (v.end - v.start) < (u.end - u.start)
258 });
259 if !inner {
260 out.insert(u.name.clone());
261 }
262 }
263 }
264 out
265}
266
267#[must_use]
270pub fn edit_footprint(
271 path: &str,
272 before: &str,
273 after: &str,
274) -> (BTreeSet<String>, Vec<(usize, usize)>) {
275 let (old, new) = changed_spans(before, after);
276 let mut names = touched_units(&units_of(path, before), &old);
277 names.extend(touched_units(&units_of(path, after), &new));
278 (names, old)
279}
280
281#[derive(Clone, Debug, PartialEq, Eq)]
285pub struct Conflict {
286 pub holder: String,
287 pub path: String,
288 pub unit: String,
289 pub expires_at_epoch_ms: u64,
290}
291
292fn other_live(leases: Vec<AgentLeaseV1>, agent_id: &str) -> impl Iterator<Item = AgentLeaseV1> {
293 let agent_id = agent_id.to_string();
294 leases
295 .into_iter()
296 .filter(move |lease| lease.request.owner_agent_id != agent_id)
297}
298
299#[must_use]
304#[allow(clippy::too_many_arguments)]
305pub fn conflicts_in(
306 leases: Vec<AgentLeaseV1>,
307 project_root: &str,
308 scope: &str,
309 agent_id: &str,
310 rel: &str,
311 before: &str,
312 names: &BTreeSet<String>,
313 spans: &[(usize, usize)],
314) -> Vec<Conflict> {
315 let rel_folded = rel.to_lowercase();
316 let mut before_units: Option<Vec<UnitSpan>> = None;
317 let mut out = Vec::new();
318 for lease in other_live(leases, agent_id) {
319 let held = &lease.request.resource_ref;
320 let unit = match lease.request.resource_kind {
321 AgentLeaseResourceKindV1::Symbol => {
322 let Some((s, path, unit)) = parse_unit_resource(held) else {
323 continue;
324 };
325 if s != scope || path != rel {
326 continue;
327 }
328 let hit = unit == WHOLE_FILE
329 || names.contains(&unit)
330 || before_units
331 .get_or_insert_with(|| units_of(rel, before))
332 .iter()
333 .any(|u| u.name == unit && spans.iter().any(|s| touches(u, *s)));
334 if !hit {
335 continue;
336 }
337 unit
338 }
339 AgentLeaseResourceKindV1::Path => {
340 let Some(path) = crate::core::agent_lease::project_path_of(project_root, held)
341 else {
342 continue;
343 };
344 let path = decode(&path).unwrap_or(path).to_lowercase();
345 if rel_folded != path && !rel_folded.starts_with(&format!("{path}/")) {
346 continue;
347 }
348 WHOLE_FILE.to_string()
349 }
350 AgentLeaseResourceKindV1::WorkGraphNode => continue,
351 };
352 out.push(Conflict {
353 holder: lease.request.owner_agent_id.clone(),
354 path: rel.to_string(),
355 unit,
356 expires_at_epoch_ms: lease.expires_at_epoch_ms,
357 });
358 }
359 out.sort_by(|a, b| (&a.holder, &a.unit).cmp(&(&b.holder, &b.unit)));
360 out.dedup();
361 out
362}
363
364fn now_ms() -> u64 {
365 std::time::SystemTime::now()
366 .duration_since(std::time::UNIX_EPOCH)
367 .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
368}
369
370#[must_use]
372pub fn render_conflicts(conflicts: &[Conflict], refused: bool) -> String {
373 let now = now_ms();
374 let mut out = if refused {
375 "ERROR: claim conflict (LEAN_CTX_CLAIM_MODE=strict), nothing was written:\n".to_string()
376 } else {
377 "WARNING: claim conflict, another agent is editing what this edit touched:\n".to_string()
378 };
379 for c in conflicts {
380 let unit = if c.unit == WHOLE_FILE {
381 "the whole file".to_string()
382 } else {
383 format!("unit `{}`", c.unit)
384 };
385 let _ = writeln!(
386 out,
387 " {}: {unit} is claimed by {} for {}s more",
388 c.path,
389 c.holder,
390 c.expires_at_epoch_ms.saturating_sub(now) / 1_000
391 );
392 }
393 out.push_str(
394 " Alternative: edit a different unit, wait until the holder finishes \
395 (ctx_agent action=claims shows holders), or message the holder; \
396 ctx_agent action=plan_claims plans a task around held units.",
397 );
398 out
399}
400
401fn request(agent_id: &str, resource_ref: String) -> AgentLeaseRequestV1 {
404 AgentLeaseRequestV1 {
405 schema_version: AGENT_LEASE_SCHEMA_VERSION,
406 lease_request_ref: format!(
407 "lease-request:{}",
408 blake3::hash(format!("{agent_id}:{resource_ref}").as_bytes()).to_hex()
409 ),
410 resource_kind: AgentLeaseResourceKindV1::Symbol,
411 resource_ref,
412 owner_agent_id: agent_id.to_string(),
413 duration_ms: CLAIM_TTL_MS,
414 }
415}
416
417pub fn claim(scope: &str, agent_id: &str, rel: &str, units: &BTreeSet<String>) -> Vec<String> {
421 let mut held = Vec::new();
422 for unit in units {
423 let Some(resource) = unit_resource(scope, rel, unit) else {
424 continue;
425 };
426 match crate::core::agent_lease::acquire_shared(request(agent_id, resource.clone())) {
427 Ok(AgentLeaseAcquireV1::Granted(lease)) => {
428 if lease.expires_at_epoch_ms.saturating_sub(now_ms()) < CLAIM_TTL_MS / 2 {
429 let _ = crate::core::agent_lease::renew_shared(
430 AgentLeaseResourceKindV1::Symbol,
431 &resource,
432 agent_id,
433 &lease.lease_ref,
434 CLAIM_TTL_MS,
435 );
436 }
437 held.push(unit.clone());
438 }
439 Ok(AgentLeaseAcquireV1::HeldBy { .. }) | Err(_) => {}
440 }
441 }
442 held
443}
444
445pub fn release_agent(agent_id: &str) -> usize {
447 let Ok(leases) = crate::core::agent_lease::active_shared() else {
448 return 0;
449 };
450 leases
451 .into_iter()
452 .filter(|l| {
453 l.request.owner_agent_id == agent_id
454 && l.request.resource_kind == AgentLeaseResourceKindV1::Symbol
455 && l.request.resource_ref.starts_with(UNIT_SCHEME)
456 })
457 .filter(|l| {
458 crate::core::agent_lease::release_shared(
459 AgentLeaseResourceKindV1::Symbol,
460 &l.request.resource_ref,
461 agent_id,
462 &l.lease_ref,
463 )
464 .unwrap_or(false)
465 })
466 .count()
467}
468
469#[derive(Clone, Debug, PartialEq, Eq)]
473pub enum Gate {
474 Clear,
476 Warn(String),
478 Refuse(String),
480}
481
482#[must_use]
485pub fn gate(project_root: &str, agent_id: &str, path: &Path, before: &str, after: &str) -> Gate {
486 gate_with(mode(), project_root, agent_id, path, before, after)
487}
488
489#[must_use]
491pub fn gate_with(
492 mode: ClaimMode,
493 project_root: &str,
494 agent_id: &str,
495 path: &Path,
496 before: &str,
497 after: &str,
498) -> Gate {
499 if mode == ClaimMode::Off || project_root.is_empty() || agent_id.is_empty() {
500 return Gate::Clear;
501 }
502 let Some(rel) = relative_path(project_root, path) else {
503 return Gate::Clear;
504 };
505 let scope = repo_scope(project_root);
506 let (names, spans) = edit_footprint(&rel, before, after);
507 let leases = crate::core::agent_lease::active_shared().unwrap_or_default();
508 let found = conflicts_in(
509 leases,
510 project_root,
511 &scope,
512 agent_id,
513 &rel,
514 before,
515 &names,
516 &spans,
517 );
518 if !found.is_empty() && mode == ClaimMode::Strict {
519 return Gate::Refuse(render_conflicts(&found, true));
520 }
521 let _ = claim(&scope, agent_id, &rel, &names);
522 if found.is_empty() {
523 Gate::Clear
524 } else {
525 Gate::Warn(render_conflicts(&found, false))
526 }
527}
528
529#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
533pub struct ClaimEntry {
534 pub holder: String,
535 pub path: String,
536 pub unit: String,
537 pub expires_ms: Option<u64>,
538}
539
540#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
542pub struct ClaimsSnapshot {
543 pub schema: String,
544 pub claims: Vec<ClaimEntry>,
545}
546
547#[must_use]
549pub fn snapshot(project_root: &str) -> ClaimsSnapshot {
550 let scope = repo_scope(project_root);
551 let leases = crate::core::agent_lease::active_shared().unwrap_or_default();
552 let mut claims: Vec<ClaimEntry> = leases
553 .into_iter()
554 .filter_map(|lease| {
555 let (path, unit) = match lease.request.resource_kind {
556 AgentLeaseResourceKindV1::Symbol => {
557 let (s, path, unit) = parse_unit_resource(&lease.request.resource_ref)?;
558 (s == scope).then_some((path, unit))?
559 }
560 AgentLeaseResourceKindV1::Path => {
561 let path = crate::core::agent_lease::project_path_of(
562 project_root,
563 &lease.request.resource_ref,
564 )?;
565 (decode(&path).unwrap_or(path), WHOLE_FILE.to_string())
566 }
567 AgentLeaseResourceKindV1::WorkGraphNode => return None,
568 };
569 Some(ClaimEntry {
570 holder: lease.request.owner_agent_id,
571 path,
572 unit,
573 expires_ms: Some(lease.expires_at_epoch_ms),
574 })
575 })
576 .collect();
577 claims.sort_by(|a, b| (&a.path, &a.unit, &a.holder).cmp(&(&b.path, &b.unit, &b.holder)));
578 ClaimsSnapshot {
579 schema: CLAIMS_SCHEMA.to_string(),
580 claims,
581 }
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587
588 fn lease(owner: &str, kind: AgentLeaseResourceKindV1, resource: &str) -> AgentLeaseV1 {
589 AgentLeaseV1 {
590 schema_version: AGENT_LEASE_SCHEMA_VERSION,
591 lease_ref: format!("lease:{owner}"),
592 request: AgentLeaseRequestV1 {
593 schema_version: AGENT_LEASE_SCHEMA_VERSION,
594 lease_request_ref: "lease-request:x".into(),
595 resource_kind: kind,
596 resource_ref: resource.into(),
597 owner_agent_id: owner.into(),
598 duration_ms: CLAIM_TTL_MS,
599 },
600 expires_at_epoch_ms: u64::MAX,
601 }
602 }
603
604 const RUST: &str =
605 "fn alpha() {\n let a = 1;\n a\n}\n\nfn beta() {\n let b = 2;\n b\n}\n";
606
607 fn unit(scope: &str, rel: &str, name: &str) -> String {
608 unit_resource(scope, rel, name).expect("bounded")
609 }
610
611 #[test]
612 fn second_agent_on_the_same_unit_is_warned_refused_then_clear() {
613 let dir = tempfile::tempdir().expect("tempdir");
614 let root = dir.path().to_string_lossy().into_owned();
615 let file = dir.path().join("x.rs");
616 let a = format!("agent-a-{}", std::process::id());
617 let b = format!("agent-b-{}", std::process::id());
618 let beta = RUST.replace("let b = 2;", "let b = 3;");
619 let alpha = RUST.replace("let a = 1;", "let a = 5;");
620
621 assert_eq!(
622 gate_with(ClaimMode::Warn, &root, &a, &file, RUST, &beta),
623 Gate::Clear
624 );
625 assert_eq!(
627 gate_with(ClaimMode::Warn, &root, &b, &file, RUST, &alpha),
628 Gate::Clear
629 );
630 match gate_with(ClaimMode::Strict, &root, &b, &file, RUST, &beta) {
631 Gate::Refuse(text) => assert!(text.contains(&a) && text.contains("`beta`"), "{text}"),
632 other => panic!("expected a refusal, got {other:?}"),
633 }
634 match gate_with(ClaimMode::Warn, &root, &b, &file, RUST, &beta) {
635 Gate::Warn(text) => assert!(text.contains(&a), "{text}"),
636 other => panic!("expected a warning, got {other:?}"),
637 }
638 let held: Vec<(String, String)> = snapshot(&root)
639 .claims
640 .into_iter()
641 .map(|c| (c.holder, c.unit))
642 .collect();
643 assert!(held.contains(&(a.clone(), "beta".to_string())), "{held:?}");
644 assert!(held.contains(&(b.clone(), "alpha".to_string())), "{held:?}");
645
646 assert_eq!(release_agent(&a), 1);
647 assert_eq!(
648 gate_with(ClaimMode::Strict, &root, &b, &file, RUST, &beta),
649 Gate::Clear
650 );
651 assert_eq!(release_agent(&b), 2);
652 assert!(snapshot(&root).claims.is_empty());
653 }
654
655 #[test]
656 fn unit_resource_round_trips_and_is_bounded() {
657 let r = unit("0123456789abcdef", "src/a b.rs", "Foo::bar");
658 assert_eq!(r, "project-unit:0123456789abcdef/src/a%20b.rs#Foo::bar");
659 assert_eq!(
660 parse_unit_resource(&r),
661 Some((
662 "0123456789abcdef".into(),
663 "src/a b.rs".into(),
664 "Foo::bar".into()
665 ))
666 );
667 assert!(unit_resource("s", &"x".repeat(300), "f").is_none());
668 assert_eq!(
669 parse_unit_resource(&unit("s", "a.rs", "a#b")).unwrap().2,
670 "a#b"
671 );
672 }
673
674 #[test]
675 fn edit_touches_only_the_innermost_changed_unit() {
676 let after = RUST.replace("let b = 2;", "let b = 3;");
677 let (names, spans) = edit_footprint("x.rs", RUST, &after);
678 assert_eq!(
679 names.into_iter().collect::<Vec<_>>(),
680 vec!["beta".to_string()]
681 );
682 assert_eq!(spans, vec![(7, 7)]);
683 }
684
685 #[test]
686 fn insertion_between_units_touches_only_the_new_unit() {
687 let after = RUST.replace("}\n\nfn beta", "}\n\nfn gamma() {}\n\nfn beta");
688 let (names, _) = edit_footprint("x.rs", RUST, &after);
689 assert_eq!(
690 names.into_iter().collect::<Vec<_>>(),
691 vec!["gamma".to_string()]
692 );
693 }
694
695 #[test]
696 fn markdown_sections_nest_and_skip_fences() {
697 let md = "# A\nx\n## B\n```\n# not\n```\ny\n# C\nz\n";
698 let spans: Vec<(String, usize, usize)> = markdown_sections(md)
699 .into_iter()
700 .map(|u| (u.name, u.start, u.end))
701 .collect();
702 assert_eq!(
703 spans,
704 vec![
705 ("A".to_string(), 1, 7),
706 ("B".to_string(), 3, 7),
707 ("C".to_string(), 8, 9)
708 ]
709 );
710 let after = md.replace("y\n", "y2\n");
711 let (names, _) = edit_footprint("doc.md", md, &after);
712 assert_eq!(names.into_iter().collect::<Vec<_>>(), vec!["B".to_string()]);
713 }
714
715 #[test]
716 fn conflicts_name_other_holders_only() {
717 let scope = "0123456789abcdef";
718 let after = RUST.replace("let b = 2;", "let b = 3;");
719 let (names, spans) = edit_footprint("x.rs", RUST, &after);
720 let symbol = AgentLeaseResourceKindV1::Symbol;
721 let leases = vec![
722 lease("me", symbol, &unit(scope, "x.rs", "beta")),
723 lease("other", symbol, &unit(scope, "x.rs", "beta")),
724 lease("third", symbol, &unit(scope, "x.rs", "alpha")),
725 lease("fourth", symbol, &unit("ffffffffffffffff", "x.rs", "beta")),
726 lease("whole", symbol, &unit(scope, "x.rs", WHOLE_FILE)),
727 ];
728 let found = conflicts_in(
729 leases,
730 "/nonexistent",
731 scope,
732 "me",
733 "x.rs",
734 RUST,
735 &names,
736 &spans,
737 );
738 let holders: Vec<(&str, &str)> = found
739 .iter()
740 .map(|c| (c.holder.as_str(), c.unit.as_str()))
741 .collect();
742 assert_eq!(holders, vec![("other", "beta"), ("whole", "*")]);
743 let text = render_conflicts(&found, true);
744 assert!(text.contains("claimed by other"), "{text}");
745 assert!(text.starts_with("ERROR"), "{text}");
746 }
747
748 #[test]
749 fn path_lease_above_the_file_conflicts() {
750 let root = "/nonexistent/claims-root";
751 let dir = crate::core::agent_lease::project_path_resource(root, "src").unwrap();
752 let leases = vec![lease("other", AgentLeaseResourceKindV1::Path, &dir)];
753 let names = BTreeSet::new();
754 let found = conflicts_in(leases.clone(), root, "s", "me", "src/x.rs", "", &names, &[]);
755 assert_eq!(found.len(), 1);
756 assert_eq!(found[0].unit, WHOLE_FILE);
757 let none = conflicts_in(leases, root, "s", "me", "srcx/x.rs", "", &names, &[]);
758 assert!(none.is_empty());
759 }
760
761 #[test]
762 fn off_mode_and_outside_paths_are_clear() {
763 assert_eq!(
764 gate_with(ClaimMode::Off, "/r", "a", Path::new("/r/x.rs"), "", "x"),
765 Gate::Clear
766 );
767 assert_eq!(relative_path("/r", Path::new("/elsewhere/x.rs")), None);
768 assert_eq!(
769 relative_path("/r", Path::new("/r/src/x.rs")).as_deref(),
770 Some("src/x.rs")
771 );
772 }
773}