1use crate::core::find_repo_root;
9use crate::errors::LitError;
10use crate::response::IntentResponse;
11use serde::{Deserialize, Serialize};
12use std::fs;
13use std::path::{Path, PathBuf};
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18pub enum IntentStatus {
19 Active,
20 Converged,
21 Abandoned,
22}
23
24impl std::fmt::Display for IntentStatus {
25 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26 match self {
27 IntentStatus::Active => write!(f, "active"),
28 IntentStatus::Converged => write!(f, "converged"),
29 IntentStatus::Abandoned => write!(f, "abandoned"),
30 }
31 }
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35pub enum IntentPriority {
36 Low,
37 Medium,
38 High,
39 Critical,
40}
41
42impl std::fmt::Display for IntentPriority {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 match self {
45 IntentPriority::Low => write!(f, "low"),
46 IntentPriority::Medium => write!(f, "medium"),
47 IntentPriority::High => write!(f, "high"),
48 IntentPriority::Critical => write!(f, "critical"),
49 }
50 }
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct ScopeConflict {
56 pub intent_id: String,
57 pub agent: String,
58 pub overlapping_paths: Vec<String>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct Intent {
64 pub id: String,
65 pub title: String,
66 pub agent: String,
67 pub scope: Vec<String>,
68 pub priority: IntentPriority,
69 pub status: IntentStatus,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub parent: Option<String>,
72 #[serde(default)]
73 pub commits: Vec<String>,
74 #[serde(default)]
75 pub children: Vec<String>,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub ucan_proof: Option<String>,
78 pub created: String,
79 pub updated: String,
80}
81
82fn intents_dir(repo_root: &Path) -> PathBuf {
85 repo_root.join(".lit").join("intents")
86}
87
88fn save_intent(repo_root: &Path, intent: &Intent) -> Result<(), LitError> {
89 let dir = intents_dir(repo_root);
90 fs::create_dir_all(&dir).map_err(|e| LitError::io(format!("Create intents dir: {}", e)))?;
91 let path = dir.join(format!("{}.json", intent.id));
92 let json = serde_json::to_string_pretty(intent)
93 .map_err(|e| LitError::general(format!("Serialize intent: {}", e)))?;
94 fs::write(&path, json).map_err(|e| LitError::io(format!("Write intent: {}", e)))?;
95 Ok(())
96}
97
98pub fn load_intent(repo_root: &Path, id: &str) -> Result<Intent, LitError> {
99 let path = intents_dir(repo_root).join(format!("{}.json", id));
100 if !path.exists() {
101 return Err(LitError::general(format!("Intent not found: {}", id)));
102 }
103 let json = fs::read_to_string(&path).map_err(|e| LitError::io(format!("Read: {}", e)))?;
104 serde_json::from_str(&json).map_err(|e| LitError::general(format!("Parse intent: {}", e)))
105}
106
107fn load_all_intents(repo_root: &Path) -> Result<Vec<Intent>, LitError> {
108 let dir = intents_dir(repo_root);
109 if !dir.exists() {
110 return Ok(Vec::new());
111 }
112 let mut intents = Vec::new();
113 for entry in fs::read_dir(&dir).map_err(|e| LitError::io(e.to_string()))? {
114 let entry = entry.map_err(|e| LitError::io(e.to_string()))?;
115 if entry
116 .path()
117 .extension()
118 .map(|e| e == "json")
119 .unwrap_or(false)
120 {
121 let json = fs::read_to_string(entry.path()).map_err(|e| LitError::io(e.to_string()))?;
122 if let Ok(intent) = serde_json::from_str::<Intent>(&json) {
123 intents.push(intent);
124 }
125 }
126 }
127 Ok(intents)
128}
129
130fn scopes_overlap(a: &[String], b: &[String]) -> Vec<String> {
133 let mut overlaps = Vec::new();
134 for pa in a {
135 let pa_base = pa.trim_end_matches("/**").trim_end_matches("/*");
136 for pb in b {
137 let pb_base = pb.trim_end_matches("/**").trim_end_matches("/*");
138 if pa_base.starts_with(pb_base) || pb_base.starts_with(pa_base) || pa == pb {
140 overlaps.push(format!("{} <-> {}", pa, pb));
141 }
142 }
143 }
144 overlaps
145}
146
147fn auto_acquire_leases(repo_root: &Path, agent: &str, scope: &[String]) {
149 let leases_dir = repo_root.join(".lit").join("swarm").join("leases");
150 let _ = fs::create_dir_all(&leases_dir);
151 let now = chrono::Utc::now().timestamp();
152 let duration = 3600i64; for path in scope {
154 let sanitized: String = path
155 .chars()
156 .map(|c| {
157 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
158 c
159 } else {
160 '_'
161 }
162 })
163 .collect();
164 let lease_file = leases_dir.join(format!("{}.json", sanitized));
165 if lease_file.exists() {
167 if let Ok(data) = fs::read_to_string(&lease_file) {
168 if let Ok(existing) = serde_json::from_str::<serde_json::Value>(&data) {
169 if existing.get("agent_id").and_then(|v| v.as_str()) != Some(agent)
170 && existing
171 .get("expires_at")
172 .and_then(|v| v.as_i64())
173 .unwrap_or(0)
174 > now
175 {
176 continue; }
178 }
179 }
180 }
181 let lease = serde_json::json!({
182 "agent_id": agent,
183 "path": path,
184 "acquired_at": now,
185 "expires_at": now + duration,
186 });
187 let _ = fs::write(
188 &lease_file,
189 serde_json::to_string_pretty(&lease).unwrap_or_default(),
190 );
191 }
192}
193
194fn auto_release_leases(repo_root: &Path, agent: &str, scope: &[String]) {
196 let leases_dir = repo_root.join(".lit").join("swarm").join("leases");
197 if !leases_dir.exists() {
198 return;
199 }
200 for path in scope {
201 let sanitized: String = path
202 .chars()
203 .map(|c| {
204 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
205 c
206 } else {
207 '_'
208 }
209 })
210 .collect();
211 let lease_file = leases_dir.join(format!("{}.json", sanitized));
212 if lease_file.exists() {
213 if let Ok(data) = fs::read_to_string(&lease_file) {
214 if let Ok(existing) = serde_json::from_str::<serde_json::Value>(&data) {
215 if existing.get("agent_id").and_then(|v| v.as_str()) == Some(agent) {
216 let _ = fs::remove_file(&lease_file);
217 }
218 }
219 }
220 }
221 }
222}
223
224pub fn execute_create(
228 title: String,
229 agent: String,
230 scope: Vec<String>,
231 priority: IntentPriority,
232 parent: Option<String>,
233 ucan_proof: Option<String>,
234) -> Result<IntentResponse, LitError> {
235 let repo_root = find_repo_root()?;
236 let now = chrono::Utc::now().to_rfc3339();
237 let id = format!("intent-{}", chrono::Utc::now().timestamp_millis());
238
239 let all = load_all_intents(&repo_root)?;
241 let mut conflicts = Vec::new();
242 for existing in &all {
243 if existing.status != IntentStatus::Active {
244 continue;
245 }
246 let overlaps = scopes_overlap(&scope, &existing.scope);
247 if !overlaps.is_empty() {
248 conflicts.push(ScopeConflict {
249 intent_id: existing.id.clone(),
250 agent: existing.agent.clone(),
251 overlapping_paths: overlaps,
252 });
253 }
254 }
255
256 if let Some(ref parent_id) = parent {
258 let mut parent_intent = load_intent(&repo_root, parent_id)?;
259 parent_intent.children.push(id.clone());
260 parent_intent.updated = now.clone();
261 save_intent(&repo_root, &parent_intent)?;
262 }
263
264 let intent = Intent {
265 id: id.clone(),
266 title: title.clone(),
267 agent: agent.clone(),
268 scope: scope.clone(),
269 priority,
270 status: IntentStatus::Active,
271 parent,
272 commits: Vec::new(),
273 children: Vec::new(),
274 ucan_proof,
275 created: now.clone(),
276 updated: now,
277 };
278
279 save_intent(&repo_root, &intent)?;
280
281 auto_acquire_leases(&repo_root, &agent, &scope);
283
284 let has_conflicts = !conflicts.is_empty();
285 let details = serde_json::json!({
286 "intent": intent,
287 "conflicts": conflicts,
288 });
289
290 Ok(IntentResponse {
291 action: "create".into(),
292 intent_id: Some(id),
293 message: if has_conflicts {
294 format!(
295 "Intent '{}' created with {} scope conflict(s) — lease negotiation may be required",
296 title,
297 conflicts.len()
298 )
299 } else {
300 format!("Intent '{}' created — scope clear, leases acquired", title)
301 },
302 details: Some(details),
303 })
304}
305
306pub fn execute_list(
308 status_filter: Option<String>,
309 agent_filter: Option<String>,
310) -> Result<IntentResponse, LitError> {
311 let repo_root = find_repo_root()?;
312 let mut intents = load_all_intents(&repo_root)?;
313
314 if let Some(ref status) = status_filter {
315 intents.retain(|i| i.status.to_string() == *status);
316 }
317 if let Some(ref agent) = agent_filter {
318 intents.retain(|i| i.agent == *agent);
319 }
320
321 let count = intents.len();
322 Ok(IntentResponse {
323 action: "list".into(),
324 intent_id: None,
325 message: format!("{} intent(s)", count),
326 details: Some(serde_json::to_value(&intents).unwrap_or_default()),
327 })
328}
329
330pub fn execute_show(intent_id: String) -> Result<IntentResponse, LitError> {
332 let repo_root = find_repo_root()?;
333 let intent = load_intent(&repo_root, &intent_id)?;
334
335 Ok(IntentResponse {
336 action: "show".into(),
337 intent_id: Some(intent.id.clone()),
338 message: format!(
339 "{} [{}] — {} commit(s), {} child(ren)",
340 intent.title,
341 intent.status,
342 intent.commits.len(),
343 intent.children.len()
344 ),
345 details: Some(serde_json::to_value(&intent).unwrap_or_default()),
346 })
347}
348
349pub fn execute_close(intent_id: String) -> Result<IntentResponse, LitError> {
351 let repo_root = find_repo_root()?;
352 let mut intent = load_intent(&repo_root, &intent_id)?;
353
354 if intent.status != IntentStatus::Active {
355 return Err(LitError::general(format!(
356 "Intent {} is already {}",
357 intent_id, intent.status
358 )));
359 }
360
361 intent.status = IntentStatus::Abandoned;
362 intent.updated = chrono::Utc::now().to_rfc3339();
363 save_intent(&repo_root, &intent)?;
364
365 auto_release_leases(&repo_root, &intent.agent, &intent.scope);
367
368 Ok(IntentResponse {
369 action: "close".into(),
370 intent_id: Some(intent_id),
371 message: format!("Intent '{}' abandoned, leases released", intent.title),
372 details: None,
373 })
374}
375
376pub fn attach_commit(repo_root: &Path, intent_id: &str, commit_hash: &str) -> Result<(), LitError> {
378 let mut intent = load_intent(repo_root, intent_id)?;
379 if intent.status != IntentStatus::Active {
380 return Err(LitError::general(format!(
381 "Cannot commit to intent {} — status is {}",
382 intent_id, intent.status
383 )));
384 }
385 intent.commits.push(commit_hash.to_string());
386 intent.updated = chrono::Utc::now().to_rfc3339();
387 save_intent(repo_root, &intent)?;
388 Ok(())
389}
390
391pub fn mark_converged(repo_root: &Path, intent_id: &str) -> Result<Intent, LitError> {
393 let mut intent = load_intent(repo_root, intent_id)?;
394 intent.status = IntentStatus::Converged;
395 intent.updated = chrono::Utc::now().to_rfc3339();
396 save_intent(repo_root, &intent)?;
397
398 auto_release_leases(repo_root, &intent.agent, &intent.scope);
400
401 Ok(intent)
402}