/* * 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 . */ #include "EditFileTool.hpp" #include "ToolExceptions.hpp" #include #include #include #include #include #include #include #include #include #include #include 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- 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- To append at the END of file, use empty old_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 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