Files
QodeAssist/tools/EditFileTool.cpp
2025-11-03 18:06:51 +01:00

241 lines
8.9 KiB
C++

/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#include "EditFileTool.hpp"
#include "ToolExceptions.hpp"
#include <context/ChangesManager.h>
#include <context/ProjectUtils.hpp>
#include <logger/Logger.hpp>
#include <settings/GeneralSettings.hpp>
#include <settings/ToolsSettings.hpp>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QUuid>
#include <QtConcurrent>
namespace QodeAssist::Tools {
EditFileTool::EditFileTool(QObject *parent)
: BaseTool(parent)
, m_ignoreManager(new Context::IgnoreManager(this))
{}
QString EditFileTool::name() const
{
return "edit_file";
}
QString EditFileTool::stringName() const
{
return {"Editing file"};
}
QString EditFileTool::description() const
{
return "Edit a file by replacing old content with new content. "
"Provide the filename (or absolute path), old_content to find and replace, "
"and new_content to replace it with. Changes are applied immediately if auto-apply "
"is enabled in settings. The user can undo or reapply changes at any time. "
"\n\nIMPORTANT:"
"\n- For EMPTY files: use empty old_content (empty string or omit parameter)."
"\n- To append at the END of file: use empty old_content."
"\n- To insert at the BEGINNING of a file (e.g., copyright header), you MUST provide "
"the EXACT first few lines of the file as old_content (at least 3-5 lines), "
"then put those lines + new header in new_content."
"\n- For replacements in the middle, provide EXACT matching text with sufficient "
"context (at least 5-10 lines) to ensure correct placement."
"\n- The system requires 85% similarity for first-time edits. Provide accurate "
"old_content to avoid incorrect placement.";
}
QJsonObject EditFileTool::getDefinition(LLMCore::ToolSchemaFormat format) const
{
QJsonObject properties;
QJsonObject filenameProperty;
filenameProperty["type"] = "string";
filenameProperty["description"]
= "The filename or absolute path of the file to edit. If only filename is provided, "
"it will be searched in the project";
properties["filename"] = filenameProperty;
QJsonObject oldContentProperty;
oldContentProperty["type"] = "string";
oldContentProperty["description"]
= "The exact content to find and replace. Must match exactly (including whitespace). "
"If empty, new_content will be appended to the end of the file";
properties["old_content"] = oldContentProperty;
QJsonObject newContentProperty;
newContentProperty["type"] = "string";
newContentProperty["description"] = "The new content to replace the old content with";
properties["new_content"] = newContentProperty;
QJsonObject definition;
definition["type"] = "object";
definition["properties"] = properties;
QJsonArray required;
required.append("filename");
required.append("new_content");
definition["required"] = required;
switch (format) {
case LLMCore::ToolSchemaFormat::OpenAI:
return customizeForOpenAI(definition);
case LLMCore::ToolSchemaFormat::Claude:
return customizeForClaude(definition);
case LLMCore::ToolSchemaFormat::Ollama:
return customizeForOllama(definition);
case LLMCore::ToolSchemaFormat::Google:
return customizeForGoogle(definition);
}
return definition;
}
LLMCore::ToolPermissions EditFileTool::requiredPermissions() const
{
return LLMCore::ToolPermission::FileSystemWrite;
}
QFuture<QString> EditFileTool::executeAsync(const QJsonObject &input)
{
return QtConcurrent::run([this, input]() -> QString {
QString filename = input["filename"].toString().trimmed();
QString oldContent = input["old_content"].toString();
QString newContent = input["new_content"].toString();
QString requestId = input["_request_id"].toString();
if (filename.isEmpty()) {
throw ToolInvalidArgument("'filename' parameter is required and cannot be empty");
}
if (newContent.isEmpty()) {
throw ToolInvalidArgument("'new_content' parameter is required and cannot be empty");
}
QString filePath;
QFileInfo fileInfo(filename);
if (fileInfo.isAbsolute() && fileInfo.exists()) {
filePath = filename;
} else {
FileSearchUtils::FileMatch match = FileSearchUtils::findBestMatch(
filename, QString(), 10, m_ignoreManager);
if (match.absolutePath.isEmpty()) {
throw ToolRuntimeError(
QString("File '%1' not found in project. "
"Please provide a valid filename or absolute path.")
.arg(filename));
}
filePath = match.absolutePath;
LOG_MESSAGE(QString("EditFileTool: Found file '%1' at '%2'")
.arg(filename, filePath));
}
QFile file(filePath);
if (!file.exists()) {
throw ToolRuntimeError(QString("File does not exist: %1").arg(filePath));
}
QFileInfo finalFileInfo(filePath);
if (!finalFileInfo.isWritable()) {
throw ToolRuntimeError(
QString("File is not writable (read-only or permission denied): %1").arg(filePath));
}
bool isInProject = Context::ProjectUtils::isFileInProject(filePath);
if (!isInProject) {
const auto &settings = Settings::toolsSettings();
if (!settings.allowAccessOutsideProject()) {
throw ToolRuntimeError(
QString("File path '%1' is not within the current project. "
"Enable 'Allow file access outside project' in settings to edit files outside the project.")
.arg(filePath));
}
LOG_MESSAGE(QString("Editing file outside project scope: %1").arg(filePath));
}
QString editId = QUuid::createUuid().toString(QUuid::WithoutBraces);
bool autoApply = Settings::toolsSettings().autoApplyFileEdits();
LOG_MESSAGE(QString("EditFileTool: Edit details for %1:").arg(filePath));
LOG_MESSAGE(QString(" oldContent length: %1 chars").arg(oldContent.length()));
LOG_MESSAGE(QString(" newContent length: %1 chars").arg(newContent.length()));
if (oldContent.length() <= 200) {
LOG_MESSAGE(QString(" oldContent: '%1'").arg(oldContent));
} else {
LOG_MESSAGE(QString(" oldContent (first 200 chars): '%1...'")
.arg(oldContent.left(200)));
}
if (newContent.length() <= 200) {
LOG_MESSAGE(QString(" newContent: '%1'").arg(newContent));
} else {
LOG_MESSAGE(QString(" newContent (first 200 chars): '%1...'")
.arg(newContent.left(200)));
}
Context::ChangesManager::instance().addFileEdit(
editId,
filePath,
oldContent,
newContent,
autoApply,
false,
requestId
);
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
QString status = "pending";
if (edit.status == Context::ChangesManager::Applied) {
status = "applied";
} else if (edit.status == Context::ChangesManager::Rejected) {
status = "rejected";
} else if (edit.status == Context::ChangesManager::Archived) {
status = "archived";
}
QString statusMessage = edit.statusMessage;
QJsonObject result;
result["edit_id"] = editId;
result["file"] = filePath;
result["old_content"] = oldContent;
result["new_content"] = newContent;
result["status"] = status;
result["status_message"] = statusMessage;
LOG_MESSAGE(QString("File edit created: %1 (ID: %2, Status: %3, Deferred: %4)")
.arg(filePath, editId, status, requestId.isEmpty() ? QString("no") : QString("yes")));
QString resultStr = "QODEASSIST_FILE_EDIT:"
+ QString::fromUtf8(QJsonDocument(result).toJson(QJsonDocument::Compact));
return resultStr;
});
}
} // namespace QodeAssist::Tools