use std::path::Path;
#[cfg(feature = "lsp")]
use std::sync::Arc;
#[cfg(feature = "lsp")]
use std::time::{Duration, Instant};
use rig::completion::ToolDefinition;
use rig::tool::Tool;
use crate::agent::agent_loop::tool_input_repair::with_contract_hint;
use crate::agent::tools::cache::ToolCache;
use crate::agent::tools::{AskSender, PermCheck, ToolError, WriteArgs, require_and_resolve};
#[cfg(feature = "lsp")]
use crate::lsp::diagnostic;
#[cfg(feature = "lsp")]
use crate::lsp::manager::{LspManager, TouchMode};
#[cfg(feature = "lsp")]
const DIAGNOSTIC_WAIT: Duration = Duration::from_secs(10);
pub struct WriteTool {
pub permission: Option<PermCheck>,
pub ask_tx: Option<AskSender>,
cache: Option<ToolCache>,
#[cfg(feature = "lsp")]
lsp_manager: Option<Arc<LspManager>>,
}
impl WriteTool {
#[allow(dead_code)]
pub fn new(permission: Option<PermCheck>, ask_tx: Option<AskSender>) -> Self {
WriteTool {
permission,
ask_tx,
cache: None,
#[cfg(feature = "lsp")]
lsp_manager: None,
}
}
pub fn with_cache(
permission: Option<PermCheck>,
ask_tx: Option<AskSender>,
cache: ToolCache,
#[cfg(feature = "lsp")] lsp_manager: Option<Arc<LspManager>>,
) -> Self {
WriteTool {
permission,
ask_tx,
cache: Some(cache),
#[cfg(feature = "lsp")]
lsp_manager,
}
}
}
impl Tool for WriteTool {
const NAME: &'static str = "write";
type Error = ToolError;
type Args = WriteArgs;
type Output = String;
async fn definition(&self, _prompt: String) -> ToolDefinition {
ToolDefinition {
name: "write".to_string(),
description: with_contract_hint(
"write",
"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
),
parameters: serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "The absolute path to the file to write (must be absolute, not relative)" },
"content": { "type": "string", "description": "Content to write to the file" }
},
"required": ["path", "content"]
}),
}
}
async fn call(&self, args: WriteArgs) -> Result<String, ToolError> {
let resolved_path = require_and_resolve(
&self.permission,
&self.ask_tx,
"write",
&args.path,
"the write path",
)
.await?;
let path = Path::new(&resolved_path);
let (content, syntax_note) =
crate::agent::tools::syntax_gate(path, &args.content).map_err(ToolError::Msg)?;
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let bytes = content.len();
let line_count = content.lines().count();
let was_creation = !path.exists();
#[cfg(feature = "lsp")]
let repair_before: Option<Vec<u8>> = if syntax_note.is_some() && !was_creation {
tokio::fs::read(path).await.ok()
} else {
None
};
#[cfg(feature = "lsp")]
let write_at = Instant::now();
crate::agent::tools::snapshots::capture(path);
crate::fs_atomic::atomic_write(path, content.as_bytes()).await?;
crate::agent::tools::modified::mark_modified(path);
if let Some(ref cache) = self.cache {
cache.clear();
cache.mark_read(path);
}
let verb = if was_creation { "Created" } else { "Wrote" };
#[allow(unused_mut)]
let mut output = format!("{} {} bytes ({} lines)", verb, bytes, line_count);
#[cfg(feature = "lsp")]
{
let lsp_block = if syntax_note.is_some() {
match verify_repaired_write_or_rollback(
self.lsp_manager.as_ref(),
path,
repair_before,
was_creation,
write_at,
)
.await
{
Ok(block) => block,
Err(feedback) => {
if let Some(ref cache) = self.cache {
cache.clear();
}
return Err(ToolError::Msg(feedback));
}
}
} else {
append_lsp_block(self.lsp_manager.as_ref(), path, write_at).await
};
crate::agent::tools::append_repair_note(&mut output, syntax_note);
output.push_str(&lsp_block);
}
#[cfg(not(feature = "lsp"))]
crate::agent::tools::append_repair_note(&mut output, syntax_note);
Ok(output)
}
}
#[cfg(feature = "lsp")]
pub(crate) async fn append_lsp_block(
manager: Option<&Arc<LspManager>>,
path: &Path,
after: Instant,
) -> String {
let Some(manager) = manager else {
return String::new();
};
manager
.touch_file(
path,
TouchMode::AwaitPush {
after,
timeout: DIAGNOSTIC_WAIT,
},
)
.await;
let diagnostics = manager.all_diagnostics();
diagnostic::build_report_block(path, &diagnostics)
}
#[cfg(feature = "lsp")]
const MAX_ROLLBACK_DIAGS: usize = 8;
#[cfg(feature = "lsp")]
fn error_diagnostics(diags: &[lsp_types::Diagnostic]) -> Vec<&lsp_types::Diagnostic> {
use lsp_types::DiagnosticSeverity;
diags
.iter()
.filter(|d| matches!(d.severity, Some(DiagnosticSeverity::ERROR) | None))
.collect()
}
#[cfg(feature = "lsp")]
async fn revert_write(path: &Path, before: Option<&[u8]>, was_creation: bool) -> bool {
match before {
Some(orig) => {
let _ = crate::fs_atomic::atomic_write(path, orig).await;
true
}
None if was_creation => {
let _ = tokio::fs::remove_file(path).await;
true
}
None => false,
}
}
#[cfg(feature = "lsp")]
pub(crate) async fn verify_repaired_write_or_rollback(
manager: Option<&Arc<LspManager>>,
path: &Path,
before: Option<Vec<u8>>,
was_creation: bool,
after: Instant,
) -> Result<String, String> {
let Some(manager) = manager else {
return Ok(String::new());
};
manager
.touch_file(
path,
TouchMode::AwaitPush {
after,
timeout: DIAGNOSTIC_WAIT,
},
)
.await;
let diags = manager.diagnostics_for(path).unwrap_or_default();
let errors = error_diagnostics(&diags);
if errors.is_empty() {
return Ok(diagnostic::build_report_block(
path,
&manager.all_diagnostics(),
));
}
let reverted = revert_write(path, before.as_deref(), was_creation).await;
manager.touch_file(path, TouchMode::Notify).await;
let mut msg = String::from(if reverted {
"Auto-repair reverted: the file was restored to its previous state and NOT modified. \
Closing the unbalanced delimiters in your text produced these language-server errors — \
fix your original text and resend:\n"
} else {
"Auto-repair failed verification, but the file's prior content was unreadable so it could \
NOT be rolled back — the repaired (and likely wrong) content is still on disk. Closing the \
unbalanced delimiters in your text produced these language-server errors — fix and rewrite \
the file:\n"
});
for d in errors.iter().take(MAX_ROLLBACK_DIAGS) {
msg.push_str(" ");
msg.push_str(&diagnostic::pretty(d));
msg.push('\n');
}
if errors.len() > MAX_ROLLBACK_DIAGS {
msg.push_str(&format!(
" …and {} more\n",
errors.len() - MAX_ROLLBACK_DIAGS
));
}
Err(msg)
}
#[cfg(all(test, feature = "lsp"))]
mod tests {
use super::*;
use crate::agent::tools::cache::ToolCache;
use crate::lsp::manager::LspManager;
use crate::lsp::spawn::{Spawned, Spawner};
use futures::future::BoxFuture;
use std::path::PathBuf;
fn tempfile_in(dir: &Path, name: &str) -> PathBuf {
dir.join(name)
}
struct NopSpawner;
impl Spawner for NopSpawner {
fn spawn<'a>(
&'a self,
_server_id: &'a str,
_root: &'a Path,
) -> BoxFuture<'a, std::io::Result<Spawned>> {
Box::pin(async { Err(std::io::Error::other("not used")) })
}
}
#[tokio::test]
async fn regression_no_manager_preserves_existing_output() {
let dir = std::env::temp_dir().join(format!("dirge-write-no-mgr-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let path = tempfile_in(&dir, "no-mgr.txt");
let tool = WriteTool::with_cache(None, None, ToolCache::new(), None);
let out = tool
.call(WriteArgs {
path: path.to_string_lossy().into_owned(),
content: "hello".into(),
})
.await
.unwrap();
assert_eq!(
out, "Created 5 bytes (1 lines)",
"unexpected write summary: {out}",
);
assert!(!out.contains("LSP errors"));
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn manager_with_no_diagnostics_appends_nothing() {
let dir = std::env::temp_dir().join(format!("dirge-write-with-mgr-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let path = tempfile_in(&dir, "with-mgr.unknown_ext");
let manager = Arc::new(LspManager::new(Arc::new(NopSpawner), dir.clone()));
let tool = WriteTool::with_cache(None, None, ToolCache::new(), Some(manager));
let out = tool
.call(WriteArgs {
path: path.to_string_lossy().into_owned(),
content: "hi".into(),
})
.await
.unwrap();
assert!(
out.starts_with("Created 2 bytes") || out.starts_with("Wrote 2 bytes"),
"expected `Created`/`Wrote 2 bytes` prefix; got: {out}",
);
assert!(!out.contains("LSP errors"), "got: {out}");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "semantic")]
#[tokio::test]
async fn auto_repairs_truncated_delimiters_on_write() {
let dir = std::env::temp_dir().join(format!("dirge-write-repair-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let path = tempfile_in(&dir, "trunc.janet");
let tool = WriteTool::with_cache(None, None, ToolCache::new(), None);
let out = tool
.call(WriteArgs {
path: path.to_string_lossy().into_owned(),
content: "(defn f [x]\n (+ x 1".into(),
})
.await
.unwrap();
assert!(
out.contains("[auto-repair]"),
"the result must report the repair: {out}"
);
let on_disk = std::fs::read_to_string(&path).unwrap();
assert_eq!(
on_disk, "(defn f [x]\n (+ x 1))",
"the balanced (repaired) content must be what got written"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn error_diagnostics_keeps_errors_and_unspecified() {
use lsp_types::{Diagnostic, DiagnosticSeverity};
let d = |sev: Option<DiagnosticSeverity>| Diagnostic {
severity: sev,
message: "m".into(),
..Default::default()
};
let diags = vec![
d(Some(DiagnosticSeverity::ERROR)),
d(Some(DiagnosticSeverity::WARNING)),
d(Some(DiagnosticSeverity::INFORMATION)),
d(Some(DiagnosticSeverity::HINT)),
d(None), ];
let errs = error_diagnostics(&diags);
assert_eq!(
errs.len(),
2,
"ERROR and unspecified are kept; warning/info/hint are dropped",
);
}
#[tokio::test]
async fn revert_restores_overwrite_and_removes_new_file() {
let dir = std::env::temp_dir().join(format!("dirge-revert-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let p = dir.join("existing.rs");
std::fs::write(&p, b"original").unwrap();
std::fs::write(&p, b"broken repair").unwrap();
assert!(revert_write(&p, Some(b"original"), false).await);
assert_eq!(std::fs::read(&p).unwrap(), b"original");
let np = dir.join("new.rs");
std::fs::write(&np, b"broken new file").unwrap();
assert!(revert_write(&np, None, true).await);
assert!(!np.exists(), "a newly-created file is removed on revert");
let up = dir.join("unreadable.rs");
std::fs::write(&up, b"repaired but wrong").unwrap();
assert!(
!revert_write(&up, None, false).await,
"returns false: not reverted",
);
assert!(up.exists(), "an existing file is never deleted on revert");
assert_eq!(std::fs::read(&up).unwrap(), b"repaired but wrong");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn rejects_non_absolute_path() {
let tool = WriteTool::with_cache(None, None, ToolCache::new(), None);
for path in ["1", "file.txt", "src/main.rs"] {
let err = tool
.call(WriteArgs {
path: path.into(),
content: "hello".into(),
})
.await
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("absolute path"),
"path {path:?}: expected absolute-path rejection; got: {msg}",
);
}
}
}