use std::collections::HashMap;
use std::sync::Mutex;
use tokio::sync::oneshot;
#[derive(Debug)]
pub(crate) enum Resolution {
Applied { line: usize },
Rejected,
Drifted,
Abandoned,
}
pub(crate) struct PendingEdit {
pub path: String,
pub old_string: String,
pub new_string: String,
pub line: usize,
pub resolver: Option<oneshot::Sender<Resolution>>,
}
#[derive(Default)]
pub(crate) struct PendingEditStore {
inner: Mutex<HashMap<String, PendingEdit>>,
}
impl PendingEditStore {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn insert(
&self,
id: String,
path: String,
old_string: String,
new_string: String,
line: usize,
) -> oneshot::Receiver<Resolution> {
let (tx, rx) = oneshot::channel();
let mut guard = self
.inner
.lock()
.expect("pending-edit store mutex poisoned");
guard.insert(
id,
PendingEdit {
path,
old_string,
new_string,
line,
resolver: Some(tx),
},
);
rx
}
pub(crate) fn snapshot(&self, id: &str) -> Option<PendingEditSnapshot> {
let guard = self
.inner
.lock()
.expect("pending-edit store mutex poisoned");
guard.get(id).map(|e| PendingEditSnapshot {
path: e.path.clone(),
old_string: e.old_string.clone(),
new_string: e.new_string.clone(),
line: e.line,
})
}
pub(crate) fn resolve(&self, id: &str, res: Resolution) -> Result<(), ResolveError> {
let mut guard = self
.inner
.lock()
.expect("pending-edit store mutex poisoned");
let entry = guard.remove(id).ok_or(ResolveError::Unknown)?;
let resolver = entry.resolver.ok_or(ResolveError::AlreadyResolved)?;
resolver.send(res).map_err(|_| ResolveError::ReceiverGone)?;
Ok(())
}
#[allow(dead_code)] pub(crate) fn abandon_all(&self) {
let mut guard = self
.inner
.lock()
.expect("pending-edit store mutex poisoned");
for (_, entry) in guard.drain() {
if let Some(tx) = entry.resolver {
let _ = tx.send(Resolution::Abandoned);
}
}
}
}
pub(crate) struct PendingEditSnapshot {
pub path: String,
pub old_string: String,
pub new_string: String,
#[allow(dead_code)] pub line: usize,
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum ResolveError {
#[error("no pending edit with that id")]
Unknown,
#[error("pending edit already resolved")]
AlreadyResolved,
#[error("awaiting tool dropped its receiver")]
ReceiverGone,
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn insert_then_resolve_delivers_decision() {
let store = PendingEditStore::new();
let rx = store.insert(
"edit-1".into(),
"docs/x.md".into(),
"old".into(),
"new".into(),
7,
);
store
.resolve("edit-1", Resolution::Applied { line: 7 })
.unwrap();
let res = rx.await.unwrap();
assert!(matches!(res, Resolution::Applied { line: 7 }));
}
#[tokio::test]
async fn resolve_unknown_id_is_error() {
let store = PendingEditStore::new();
let err = store.resolve("nope", Resolution::Rejected).unwrap_err();
assert!(matches!(err, ResolveError::Unknown));
}
#[tokio::test]
async fn double_resolve_is_error() {
let store = PendingEditStore::new();
let _rx = store.insert("e".into(), "p".into(), "o".into(), "n".into(), 1);
store.resolve("e", Resolution::Rejected).unwrap();
let err = store.resolve("e", Resolution::Rejected).unwrap_err();
assert!(matches!(err, ResolveError::Unknown));
}
#[tokio::test]
async fn abandon_all_wakes_awaiters() {
let store = PendingEditStore::new();
let rx = store.insert("e".into(), "p".into(), "o".into(), "n".into(), 1);
store.abandon_all();
let res = rx.await.unwrap();
assert!(matches!(res, Resolution::Abandoned));
}
#[tokio::test]
async fn snapshot_returns_inserted_data_without_removing() {
let store = PendingEditStore::new();
let _rx = store.insert(
"e".into(),
"docs/x.md".into(),
"old".into(),
"new".into(),
4,
);
let snap = store.snapshot("e").unwrap();
assert_eq!(snap.path, "docs/x.md");
assert_eq!(snap.old_string, "old");
assert_eq!(snap.new_string, "new");
assert_eq!(snap.line, 4);
store.resolve("e", Resolution::Rejected).unwrap();
}
}