use std::path::Path;
use std::sync::Arc;
use async_trait::async_trait;
use locode_host::{FsError, Host};
use locode_tools::{Tool, ToolCtx, ToolError, ToolKind, ToolOutput};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::state::ClaudeSessionState;
pub(crate) struct ClaudeWrite {
host: Arc<Host>,
state: Arc<ClaudeSessionState>,
}
impl ClaudeWrite {
pub(crate) fn new(host: Arc<Host>, state: Arc<ClaudeSessionState>) -> Self {
Self { host, state }
}
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct WriteArgs {
#[schemars(
description = "The absolute path to the file to write (must be absolute, not relative)"
)]
file_path: String,
#[schemars(description = "The content to write to the file")]
content: String,
}
#[derive(Debug, Serialize)]
pub(crate) struct WriteOutput {
path: String,
created: bool,
#[serde(skip)]
message: String,
}
impl ToolOutput for WriteOutput {
fn to_prompt_text(&self) -> String {
self.message.clone()
}
}
fn is_not_found(err: &FsError) -> bool {
matches!(err, FsError::Io { source, .. } if source.kind() == std::io::ErrorKind::NotFound)
}
#[async_trait]
impl Tool for ClaudeWrite {
type Args = WriteArgs;
type Output = WriteOutput;
fn kind(&self) -> ToolKind {
ToolKind::Write
}
#[allow(clippy::unnecessary_literal_bound)] fn description(&self) -> &str {
include_str!("descriptions/write.md")
}
async fn run(&self, ctx: &ToolCtx, args: WriteArgs) -> Result<Self::Output, ToolError> {
let path = Path::new(&args.file_path);
let resolved = self
.host
.resolve_in_jail(&ctx.cwd, path)
.await
.map_err(|e| ToolError::Respond(e.to_string()))?;
let existing = match self.host.stat(&ctx.cwd, path).await {
Ok(stat) => Some(stat),
Err(e) if is_not_found(&e) => None,
Err(e) => return Err(ToolError::Respond(e.to_string())),
};
let created = existing.is_none();
if let Some(stat) = &existing {
match self.state.check_fresh(&resolved, stat.modified) {
None => {
return Err(ToolError::Respond(
"File has not been read yet. Read it first before writing to it."
.to_string(),
));
}
Some(false) => {
return Err(ToolError::Respond(
"File has been modified since read, either by the user or by a linter. \
Read it again before attempting to write it."
.to_string(),
));
}
Some(true) => {}
}
}
if created && let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
self.host
.create_dir(&ctx.cwd, parent, true, true)
.await
.map_err(|e| ToolError::Respond(e.to_string()))?;
}
let stat = self
.host
.write_file(&ctx.cwd, path, &args.content)
.await
.map_err(|e| ToolError::Respond(e.to_string()))?;
self.state.record_write(resolved, stat.modified);
let message = if created {
format!("File created successfully at: {}", args.file_path)
} else {
format!("The file {} has been updated successfully.", args.file_path)
};
Ok(WriteOutput {
path: args.file_path.clone(),
created,
message,
})
}
}
#[cfg(test)]
mod tests {
#[test]
fn description_is_the_pinned_write_md() {
let desc = include_str!("descriptions/write.md");
assert_eq!(desc.len(), 620, "write.md byte length changed");
assert!(desc.starts_with("Writes a file to the local filesystem."));
assert!(desc.contains("you MUST use the Read tool first"));
}
}