Skip to main content

intent_ir/
lock.rs

1//! Multi-agent collaboration via spec-section locking.
2//!
3//! Provides a simple file-based locking mechanism that allows multiple agents
4//! to claim ownership of spec items (entities, actions, invariants) to avoid
5//! conflicting edits.
6
7use std::collections::HashMap;
8
9use serde::{Deserialize, Serialize};
10
11use crate::audit::SpecItemKind;
12
13/// A lock file tracking which agent owns which spec items.
14#[derive(Debug, Clone, Serialize, Deserialize, Default)]
15pub struct LockFile {
16    pub module: String,
17    pub claims: HashMap<String, Claim>,
18}
19
20/// A single agent's claim on a spec item.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct Claim {
23    pub agent: String,
24    pub kind: SpecItemKind,
25    pub claimed_at: String,
26}
27
28/// Errors from lock operations.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum LockError {
31    /// The item is already claimed by a different agent.
32    AlreadyClaimed {
33        item: String,
34        owner: String,
35        requester: String,
36    },
37    /// The item is not claimed (cannot unlock).
38    NotClaimed { item: String },
39    /// The item is claimed by a different agent (cannot unlock).
40    NotOwner {
41        item: String,
42        owner: String,
43        requester: String,
44    },
45    /// The requested item does not exist in the spec.
46    UnknownItem { item: String },
47}
48
49impl std::fmt::Display for LockError {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        match self {
52            LockError::AlreadyClaimed {
53                item,
54                owner,
55                requester,
56            } => write!(
57                f,
58                "'{item}' is already claimed by agent '{owner}' (requested by '{requester}')"
59            ),
60            LockError::NotClaimed { item } => write!(f, "'{item}' is not claimed"),
61            LockError::NotOwner {
62                item,
63                owner,
64                requester,
65            } => write!(f, "'{item}' is claimed by '{owner}', not '{requester}'"),
66            LockError::UnknownItem { item } => {
67                write!(f, "no spec item named '{item}' found")
68            }
69        }
70    }
71}
72
73/// Known items in a spec (name → kind), used to validate lock targets.
74pub type SpecItems = HashMap<String, SpecItemKind>;
75
76/// Extract the set of lockable item names from an audit report.
77pub fn extract_spec_items(report: &crate::audit::AuditReport) -> SpecItems {
78    report
79        .entries
80        .iter()
81        .map(|e| (e.name.clone(), e.kind))
82        .collect()
83}
84
85/// Attempt to claim a spec item for an agent.
86pub fn lock_item(
87    lockfile: &mut LockFile,
88    items: &SpecItems,
89    item: &str,
90    agent: &str,
91    timestamp: &str,
92) -> Result<(), LockError> {
93    // Validate item exists.
94    let kind = items.get(item).ok_or_else(|| LockError::UnknownItem {
95        item: item.to_string(),
96    })?;
97
98    // Check if already claimed by a different agent.
99    if let Some(claim) = lockfile.claims.get(item)
100        && claim.agent != agent
101    {
102        return Err(LockError::AlreadyClaimed {
103            item: item.to_string(),
104            owner: claim.agent.clone(),
105            requester: agent.to_string(),
106        });
107    }
108
109    lockfile.claims.insert(
110        item.to_string(),
111        Claim {
112            agent: agent.to_string(),
113            kind: *kind,
114            claimed_at: timestamp.to_string(),
115        },
116    );
117    Ok(())
118}
119
120/// Release a claim on a spec item.
121pub fn unlock_item(lockfile: &mut LockFile, item: &str, agent: &str) -> Result<(), LockError> {
122    match lockfile.claims.get(item) {
123        None => Err(LockError::NotClaimed {
124            item: item.to_string(),
125        }),
126        Some(claim) if claim.agent != agent => Err(LockError::NotOwner {
127            item: item.to_string(),
128            owner: claim.agent.clone(),
129            requester: agent.to_string(),
130        }),
131        Some(_) => {
132            lockfile.claims.remove(item);
133            Ok(())
134        }
135    }
136}
137
138/// Format lock status for human display.
139pub fn format_status(lockfile: &LockFile, items: &SpecItems) -> String {
140    let mut out = format!("Lock status for module: {}\n\n", lockfile.module);
141
142    if lockfile.claims.is_empty() {
143        out.push_str("  No items are currently claimed.\n");
144        return out;
145    }
146
147    // Sort by item name for stable output.
148    let mut claims: Vec<_> = lockfile.claims.iter().collect();
149    claims.sort_by_key(|(name, _)| (*name).clone());
150
151    for (name, claim) in &claims {
152        out.push_str(&format!(
153            "  {} {} — claimed by '{}' at {}\n",
154            claim.kind, name, claim.agent, claim.claimed_at,
155        ));
156    }
157
158    // Show unclaimed items.
159    let mut unclaimed: Vec<_> = items
160        .iter()
161        .filter(|(name, _)| !lockfile.claims.contains_key(*name))
162        .collect();
163    unclaimed.sort_by_key(|(name, _)| (*name).clone());
164
165    if !unclaimed.is_empty() {
166        out.push_str("\n  Unclaimed:\n");
167        for (name, kind) in &unclaimed {
168            out.push_str(&format!("    {} {}\n", kind, name));
169        }
170    }
171
172    out
173}