use anyhow::Result;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use tokio::fs;
use tracing::{debug, trace};
use super::filesystem_manager::detect_language_id_opt;
use crate::db;
struct OpenDocument {
version: i32,
content: String,
mtime: SystemTime,
}
pub struct DocumentManager {
documents: HashMap<PathBuf, OpenDocument>,
session_id: String,
}
impl DocumentManager {
#[must_use]
pub fn new(session_id: String) -> Self {
Self {
documents: HashMap::new(),
session_id,
}
}
pub async fn ensure_open(&mut self, path: &Path) -> Result<Option<DocumentNotification>> {
let path = path.canonicalize()?;
let metadata = fs::metadata(&path).await?;
let mtime = metadata.modified()?;
if let Some(doc) = self.documents.get_mut(&path) {
if mtime > doc.mtime {
let content = fs::read_to_string(&path).await?;
if content != doc.content {
doc.version += 1;
doc.content.clone_from(&content);
doc.mtime = mtime;
debug!("Document changed on disk: {}", path.display());
return Ok(Some(DocumentNotification::Change {
uri: path_to_uri(&path),
version: doc.version,
text: content,
}));
}
}
trace!("Document already open: {}", path.display());
return Ok(None);
}
let content = fs::read_to_string(&path).await?;
let uri = path_to_uri(&path);
let language_id = detect_language_id_opt(&path).unwrap_or("plaintext");
let doc = OpenDocument {
version: 1,
content: content.clone(),
mtime,
};
self.documents.insert(path.clone(), doc);
debug!("Opening document: {} ({})", path.display(), language_id);
Ok(Some(DocumentNotification::Open {
uri,
language_id: language_id.to_string(),
version: 1,
text: content,
}))
}
pub fn close(&mut self, path: &Path) -> Result<Option<String>> {
let path = path.canonicalize()?;
if self.documents.remove(&path).is_some() {
debug!("Closing document: {}", path.display());
Ok(Some(path_to_uri(&path)))
} else {
Ok(None)
}
}
pub fn uri_for_path(&self, path: &Path) -> Result<String> {
Ok(path_to_uri(&path.canonicalize()?))
}
pub fn notify_external_write(
&mut self,
path: &Path,
content: &str,
mtime: SystemTime,
) -> Result<DocumentNotification> {
let path = path.canonicalize()?;
let uri = path_to_uri(&path);
if let Some(doc) = self.documents.get_mut(&path) {
doc.version += 1;
doc.content = content.to_string();
doc.mtime = mtime;
debug!("External write (change): {}", path.display());
Ok(DocumentNotification::Change {
uri,
version: doc.version,
text: content.to_string(),
})
} else {
let language_id = detect_language_id_opt(&path).unwrap_or("plaintext");
let doc = OpenDocument {
version: 1,
content: content.to_string(),
mtime,
};
self.documents.insert(path.clone(), doc);
debug!(
"External write (open): {} ({})",
path.display(),
language_id
);
Ok(DocumentNotification::Open {
uri,
language_id: language_id.to_string(),
version: 1,
text: content.to_string(),
})
}
}
pub fn start_editing(&self, agent_id: &str) -> Result<bool> {
let conn = db::open()?;
db::start_editing(&conn, &self.session_id, agent_id)
}
pub fn finish_editing(&self, agent_id: &str) -> Result<Vec<String>> {
let conn = db::open()?;
let files = db::drain_editing_files(&conn, &self.session_id, agent_id)?;
db::done_editing(&conn, &self.session_id, agent_id)?;
Ok(files)
}
pub fn is_agent_editing(&self, agent_id: &str) -> Result<bool> {
let conn = db::open()?;
db::is_agent_editing(&conn, &self.session_id, agent_id)
}
pub fn add_editing_file(&self, agent_id: &str, file_path: &str) -> Result<()> {
let conn = db::open()?;
db::add_editing_file(&conn, &self.session_id, agent_id, file_path)
}
}
pub enum DocumentNotification {
Open {
uri: String,
language_id: String,
version: i32,
text: String,
},
Change {
uri: String,
version: i32,
text: String,
},
}
fn path_to_uri(path: &Path) -> String {
format!("file://{}", path.display())
}
#[cfg(test)]
#[allow(
clippy::expect_used,
reason = "tests use expect for readable assertions"
)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[tokio::test]
async fn test_open_document() -> Result<()> {
let mut file = NamedTempFile::with_suffix(".rs")?;
writeln!(file, "fn main() {{}}")?;
let mut manager = DocumentManager::new(String::new());
let notification = manager.ensure_open(file.path()).await?;
assert!(notification.is_some());
if let Some(DocumentNotification::Open {
language_id,
version,
text,
..
}) = notification
{
assert_eq!(language_id, "rust");
assert_eq!(version, 1);
assert!(text.contains("fn main()"));
} else {
anyhow::bail!("Expected Open notification");
}
Ok(())
}
#[tokio::test]
async fn test_already_open_no_change() -> Result<()> {
let mut file = NamedTempFile::with_suffix(".py")?;
writeln!(file, "print('hello')")?;
let mut manager = DocumentManager::new(String::new());
let notification1 = manager.ensure_open(file.path()).await?;
assert!(notification1.is_some());
let notification2 = manager.ensure_open(file.path()).await?;
assert!(notification2.is_none());
Ok(())
}
#[tokio::test]
async fn test_document_changed_on_disk() -> Result<()> {
let file = NamedTempFile::with_suffix(".js")?;
let path = file.path().to_path_buf();
std::fs::write(&path, "const x = 1;")?;
let mut manager = DocumentManager::new(String::new());
let notification1 = manager.ensure_open(&path).await?;
assert!(matches!(
notification1,
Some(DocumentNotification::Open { .. })
));
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
std::fs::write(&path, "const x = 2;")?;
let notification2 = manager.ensure_open(&path).await?;
assert!(
matches!(notification2, Some(DocumentNotification::Change { .. })),
"Expected Change notification after file modification"
);
Ok(())
}
#[tokio::test]
async fn test_close_document() -> Result<()> {
let mut file = NamedTempFile::with_suffix(".go")?;
writeln!(file, "package main")?;
let mut manager = DocumentManager::new(String::new());
manager.ensure_open(file.path()).await?;
let close_params = manager.close(file.path())?;
assert!(close_params.is_some());
let close_params2 = manager.close(file.path())?;
assert!(close_params2.is_none());
Ok(())
}
#[test]
fn test_path_to_uri() {
let uri = path_to_uri(Path::new("/home/user/test.rs"));
assert!(uri.starts_with("file:///home/user/test.rs"));
}
}