Skip to main content

lean_ctx/core/
unit_claims.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Unit claims: which agent is editing which function or section of a file.
3//!
4//! The edit tools take a short claim on the innermost units (function,
5//! method, type, markdown section) an edit touches, and check the claims of
6//! other agents before they write. Another agent's claim on a touched unit
7//! is a conflict: by default the edit goes ahead with a warning naming the
8//! holder (`warn`); `LEAN_CTX_CLAIM_MODE=strict` refuses it; `off` disables
9//! both the check and the claims.
10//!
11//! Claims are ordinary machine-wide leases ([`crate::core::agent_lease`]) of
12//! kind `Symbol` with a readable reference
13//! `project-unit:<repo>/<path>#<unit>`, so stores and processes that predate
14//! unit claims read them unchanged. `<repo>` names the repository, not the
15//! checkout: linked worktrees of one repository share it, so two agents in
16//! two worktrees meet on the same unit. A claim lives ten minutes from the
17//! last edit that touched the unit and is released when its agent finishes.
18//!
19//! This module detects, reports and hints only. Planning a task against the
20//! claims is an optional external planner's job (`weave plan-claims`), fed
21//! the [`snapshot`] in the `leanctx.claims.v1` contract.
22
23use 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
34/// Lease reference scheme of a unit claim.
35pub const UNIT_SCHEME: &str = "project-unit:";
36/// Unit name of a whole-file claim.
37pub const WHOLE_FILE: &str = "*";
38/// How long a claim lives after the last edit that touched its unit.
39pub const CLAIM_TTL_MS: u64 = 10 * 60 * 1_000;
40/// Schema of the snapshot handed to an external planner.
41pub const CLAIMS_SCHEMA: &str = "leanctx.claims.v1";
42const MAX_REF_BYTES: usize = 256;
43
44/// What the edit tools do with another agent's claim on a touched unit.
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub enum ClaimMode {
47    Off,
48    Warn,
49    Strict,
50}
51
52/// `LEAN_CTX_CLAIM_MODE` (`off`, `warn`, `strict`); `warn` when unset or unknown.
53#[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// ─── Resources ───────────────────────────────────────────────────────────────
68
69/// Repository scope of `project_root`: its project identity (a linked
70/// worktree follows its main checkout's origin), else the checkout itself.
71#[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/// Lease reference of unit `unit` of the repository-relative file `rel`;
106/// `None` when it would exceed the lease reference bound.
107#[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/// `(scope, rel, unit)` of a unit claim reference.
117#[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/// Repository-relative spelling of `path` under `project_root` (`/` separated).
126#[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// ─── Units of a file ─────────────────────────────────────────────────────────
146
147/// One named unit of a file: 1-based inclusive line span.
148#[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/// Named units of `content` (the file `path`): markdown sections, else the
191/// spans of the signatures tree-sitter (or the regex fallback) finds.
192#[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    // Uncounted: a claim check must not write grammar telemetry per edit.
203    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/// Line spans an edit touches, on each side: `(old, new)`, 1-based inclusive.
219/// A pure insertion before line `L` is the empty span `(L, L - 1)`.
220#[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        // Insertion between lines `start - 1` and `start`: inside only when
239        // both neighbours are.
240        unit.start < start && start <= unit.end
241    } else {
242        unit.start <= end && start <= unit.end
243    }
244}
245
246/// Innermost units each span touches (a span that touches none adds nothing).
247#[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/// What an edit of `path` from `before` to `after` touches: the innermost
268/// unit names on either side, and the old-side spans.
269#[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// ─── Conflicts ───────────────────────────────────────────────────────────────
282
283/// Another agent's claim on something an edit touches.
284#[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/// Claims of other agents that an edit of `rel` touching `names` (and the
300/// old-side `spans` of `before`) collides with: unit claims on the same
301/// unit, on a unit containing a touched line, or on the whole file, and
302/// path leases on the file or a directory above it.
303#[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/// One line per conflict for the agent, naming holder, unit and what to do.
371#[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
401// ─── Claiming and releasing ──────────────────────────────────────────────────
402
403fn 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
417/// Claims `units` of `rel` for `agent_id` (or extends its own claims to a
418/// full TTL). Units another agent holds are skipped. Returns the units now
419/// held.
420pub 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
445/// Releases every unit claim `agent_id` holds. Returns how many.
446pub 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// ─── The edit gate ───────────────────────────────────────────────────────────
470
471/// Outcome of [`gate`].
472#[derive(Clone, Debug, PartialEq, Eq)]
473pub enum Gate {
474    /// Nothing held by another agent (or claims are off).
475    Clear,
476    /// Write, then show this warning.
477    Warn(String),
478    /// Do not write; this is the error.
479    Refuse(String),
480}
481
482/// Checks an edit of `path` by `agent_id` against other agents' claims and,
483/// unless refused, claims the units it touches.
484#[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/// [`gate`] with an explicit mode.
490#[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// ─── Snapshot for planners ───────────────────────────────────────────────────
530
531/// One claim of the `leanctx.claims.v1` snapshot.
532#[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/// The `leanctx.claims.v1` snapshot.
541#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
542pub struct ClaimsSnapshot {
543    pub schema: String,
544    pub claims: Vec<ClaimEntry>,
545}
546
547/// Active unit claims and path leases of the repository at `project_root`.
548#[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        // Another unit of the same file: no conflict.
626        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}