diff --git a/app/src/main/kotlin/com/github/itmosoftwaredesign/cli/ApplicationEntry.kt b/app/src/main/kotlin/com/github/itmosoftwaredesign/cli/ApplicationEntry.kt index 9b88ebb..10310ed 100644 --- a/app/src/main/kotlin/com/github/itmosoftwaredesign/cli/ApplicationEntry.kt +++ b/app/src/main/kotlin/com/github/itmosoftwaredesign/cli/ApplicationEntry.kt @@ -27,7 +27,8 @@ object ApplicationEntry { val commandRegistry = CommandRegistry() commandRegistry.register("cat", CatCommand()) commandRegistry.register("pwd", PrintWorkingDirectoryCommand()) - commandRegistry.register("cd", ChangeDirectoryCommand()) + commandRegistry.register("cd", CdCommand()) + commandRegistry.register("ls", LsCommand()) commandRegistry.register("echo", EchoCommand()) commandRegistry.register("wc", WcCommand()) commandRegistry.register("exit", ExitCommand()) diff --git a/core/src/main/kotlin/com/github/itmosoftwaredesign/cli/command/impl/ChangeDirectoryCommand.kt b/core/src/main/kotlin/com/github/itmosoftwaredesign/cli/command/impl/CdCommand.kt similarity index 87% rename from core/src/main/kotlin/com/github/itmosoftwaredesign/cli/command/impl/ChangeDirectoryCommand.kt rename to core/src/main/kotlin/com/github/itmosoftwaredesign/cli/command/impl/CdCommand.kt index c7f2a79..a6266e7 100644 --- a/core/src/main/kotlin/com/github/itmosoftwaredesign/cli/command/impl/ChangeDirectoryCommand.kt +++ b/core/src/main/kotlin/com/github/itmosoftwaredesign/cli/command/impl/CdCommand.kt @@ -12,6 +12,7 @@ import java.io.OutputStream import kotlin.io.path.exists import kotlin.io.path.isDirectory + /** * Change working directory command. * @@ -20,10 +21,12 @@ import kotlin.io.path.isDirectory * 2. Passed path is not found * 3. Passed path is not a directory * - * @author sibmaks + * @author mk17ru * @since 0.0.1 */ -class ChangeDirectoryCommand : Command { +class CdCommand : Command { + private val HOME_DIR = System.getProperty("user.home"); + override fun execute( @Nonnull environment: Environment, @Nonnull inputStream: InputStream, @@ -31,11 +34,11 @@ class ChangeDirectoryCommand : Command { @Nonnull errorStream: OutputStream, @Nonnull arguments: List ): CommandResult { - if (arguments.size != 1) { - errorStream.writeLineUTF8("Change directory command except expect 1 argument") + if (arguments.size > 1) { + errorStream.writeLineUTF8("Change directory command except <= 1 argument") return ErrorResult(1) } - val move = arguments[0] + val move = if (arguments.isEmpty()) HOME_DIR else arguments[0] val newWorkingDirectory = environment.workingDirectory .resolve(move) .normalize() diff --git a/core/src/main/kotlin/com/github/itmosoftwaredesign/cli/command/impl/LsCommand.kt b/core/src/main/kotlin/com/github/itmosoftwaredesign/cli/command/impl/LsCommand.kt new file mode 100644 index 0000000..2226c98 --- /dev/null +++ b/core/src/main/kotlin/com/github/itmosoftwaredesign/cli/command/impl/LsCommand.kt @@ -0,0 +1,61 @@ +package com.github.itmosoftwaredesign.cli.command.impl + +import com.github.itmosoftwaredesign.cli.Environment +import com.github.itmosoftwaredesign.cli.command.Command +import com.github.itmosoftwaredesign.cli.command.CommandResult +import com.github.itmosoftwaredesign.cli.command.ErrorResult +import com.github.itmosoftwaredesign.cli.command.SuccessResult +import com.github.itmosoftwaredesign.cli.writeLineUTF8 +import jakarta.annotation.Nonnull +import java.io.File +import java.io.InputStream +import java.io.OutputStream +import kotlin.io.path.exists +import kotlin.io.path.isDirectory +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.name + +private const val CURRENT_DIR = "." + +/** + * `LsCommand` - команда для отображения содержимого каталога. + * + * Если аргументы отсутствуют, выводится содержимое текущего рабочего каталога. + * Если указан каталог в аргументах, выводится его содержимое. + * + * @author mk17ru + * @since 0.0.3 + */ +class LsCommand : Command { + override fun execute( + @Nonnull environment: Environment, + @Nonnull inputStream: InputStream, + @Nonnull outputStream: OutputStream, + @Nonnull errorStream: OutputStream, + @Nonnull arguments: List + ): CommandResult { + val move = if (arguments.isEmpty()) CURRENT_DIR else arguments[0] + val directory = environment.workingDirectory + .resolve(move) + .normalize() + .toAbsolutePath() + if (!directory.exists()) { + errorStream.writeLineUTF8("Directory '$directory' does not exist") + return ErrorResult(2) + } + if (!directory.isDirectory()) { + outputStream.writeLineUTF8(directory.name) + return SuccessResult() + } + try { + val files = directory.listDirectoryEntries() + files.sortedBy { it.name }.forEach { file -> + outputStream.writeLineUTF8(file.name) + } + } catch (e : Exception) { + errorStream.writeLineUTF8("ls: cannot read directory '${directory.toAbsolutePath()}'") + return ErrorResult(4) + } + return SuccessResult() + } +} diff --git a/core/src/test/kotlin/com/github/itmosoftwaredesign/cli/InterpreterTest.kt b/core/src/test/kotlin/com/github/itmosoftwaredesign/cli/InterpreterTest.kt index b874d41..956d3c1 100644 --- a/core/src/test/kotlin/com/github/itmosoftwaredesign/cli/InterpreterTest.kt +++ b/core/src/test/kotlin/com/github/itmosoftwaredesign/cli/InterpreterTest.kt @@ -95,6 +95,86 @@ class InterpreterTest { } } + @Test + fun `should execute several cd ls`() { + val cdCommandMock = mockk(relaxed = true) + val lsCommandMock = mockk(relaxed = true) + val cdParsedCommand = mockk(relaxed = true) + val lsParsedCommand = mockk(relaxed = true) + val parsedCommands = listOf(cdParsedCommand, lsParsedCommand) + val cdCommandTokens = listOf("cd", "..") + val lsCommandTokens = listOf("ls") + + inputStream = ByteArrayInputStream("cd .. | ls\nexit\n".toByteArray()) + every { cdParsedCommand.commandTokens } returns cdCommandTokens + every { lsParsedCommand.commandTokens } returns lsCommandTokens + every { commandsParser.parse("cd .. | ls") } returns parsedCommands + every { commandRegistry["cd"] } returns cdCommandMock + every { commandRegistry["ls"] } returns lsCommandMock + every { cdCommandMock.execute(any(), any(), any(), any(), any()) } returns SuccessResult() + + interpreter = Interpreter(environment, commandsParser, commandRegistry, inputStream) + interpreter.run() + + verify { + cdCommandMock.execute( + environment, + parsedCommands.first().inputStream, + parsedCommands.first().outputStream, + parsedCommands.first().errorStream, + listOf("..") + ) + + lsCommandMock.execute( + environment, + parsedCommands.last().inputStream, + parsedCommands.last().outputStream, + parsedCommands.last().errorStream, + listOf() + ) + } + } + + @Test + fun `should execute several cd git status`() { + val cdCommandMock = mockk(relaxed = true) + val lsCommandMock = mockk(relaxed = true) + val cdParsedCommand = mockk(relaxed = true) + val lsParsedCommand = mockk(relaxed = true) + val parsedCommands = listOf(cdParsedCommand, lsParsedCommand) + val cdCommandTokens = listOf("cd", "..") + val lsCommandTokens = listOf("git", "status") + + inputStream = ByteArrayInputStream("cd .. | git status\nexit\n".toByteArray()) + every { cdParsedCommand.commandTokens } returns cdCommandTokens + every { lsParsedCommand.commandTokens } returns lsCommandTokens + every { commandsParser.parse("cd .. | git status") } returns parsedCommands + every { commandRegistry["cd"] } returns cdCommandMock + every { commandRegistry["git"] } returns lsCommandMock + every { cdCommandMock.execute(any(), any(), any(), any(), any()) } returns SuccessResult() + + interpreter = Interpreter(environment, commandsParser, commandRegistry, inputStream) + interpreter.run() + + verify { + cdCommandMock.execute( + environment, + parsedCommands.first().inputStream, + parsedCommands.first().outputStream, + parsedCommands.first().errorStream, + listOf("..") + ) + + lsCommandMock.execute( + environment, + parsedCommands.last().inputStream, + parsedCommands.last().outputStream, + parsedCommands.last().errorStream, + listOf("status") + ) + } + } + @Test fun `should run external process on unknown command`() { inputStream = ByteArrayInputStream("ls\n".toByteArray()) diff --git a/core/src/test/kotlin/com/github/itmosoftwaredesign/cli/command/impl/ChangeDirectoryCommandTest.kt b/core/src/test/kotlin/com/github/itmosoftwaredesign/cli/command/impl/CdCommandTest.kt similarity index 64% rename from core/src/test/kotlin/com/github/itmosoftwaredesign/cli/command/impl/ChangeDirectoryCommandTest.kt rename to core/src/test/kotlin/com/github/itmosoftwaredesign/cli/command/impl/CdCommandTest.kt index 50c33d5..731f5e0 100644 --- a/core/src/test/kotlin/com/github/itmosoftwaredesign/cli/command/impl/ChangeDirectoryCommandTest.kt +++ b/core/src/test/kotlin/com/github/itmosoftwaredesign/cli/command/impl/CdCommandTest.kt @@ -14,17 +14,19 @@ import java.util.* import kotlin.io.path.createDirectory import kotlin.io.path.createFile -class ChangeDirectoryCommandTest { +class CdCommandTest { private lateinit var environment: Environment - private lateinit var changeDirectoryCommand: ChangeDirectoryCommand + private lateinit var changeDirectoryCommand: CdCommand private lateinit var outputStream: ByteArrayOutputStream private lateinit var errorStream: ByteArrayOutputStream + private val HOME_DIR = System.getProperty("user.home"); + @BeforeEach fun setUp() { environment = mockk(relaxed = true) - changeDirectoryCommand = ChangeDirectoryCommand() + changeDirectoryCommand = CdCommand() outputStream = ByteArrayOutputStream() errorStream = ByteArrayOutputStream() } @@ -69,18 +71,49 @@ class ChangeDirectoryCommandTest { } @Test - fun `should write error when no arguments provided`() { - changeDirectoryCommand.execute(environment, System.`in`, outputStream, errorStream, emptyList()) + fun `should change upper directory when not directory provided`() { + val currentDir = Files.createTempDirectory("current") - val errorMessage = "Change directory command except expect 1 argument" - assertEquals(errorMessage, errorStream.toString().trim()) + every { environment.workingDirectory } returns currentDir.toAbsolutePath() + + changeDirectoryCommand.execute(environment, System.`in`, outputStream, errorStream, listOf()) + + verify { environment.workingDirectory = Path.of(HOME_DIR) } + } + + @Test + fun `should change upper two dirs`() { + val currentDir = Files.createTempDirectory("current") + + val parentSecond = currentDir.parent.parent + + every { environment.workingDirectory } returns currentDir.toAbsolutePath() + + changeDirectoryCommand.execute(environment, System.`in`, outputStream, errorStream, listOf("../..")) + + verify { environment.workingDirectory = parentSecond } + } + + @Test + fun `should change upper directory when two points provided`() { + val currentDir = Files.createTempDirectory("current") + + every { environment.workingDirectory } returns currentDir.toAbsolutePath() + + val parent = currentDir.parent + + changeDirectoryCommand.execute(environment, System.`in`, outputStream, errorStream, listOf("..")) + + verify { environment.workingDirectory = parent } } @Test fun `should write error when too many arguments provided`() { changeDirectoryCommand.execute(environment, System.`in`, outputStream, errorStream, listOf("dir1", "dir2")) - val errorMessage = "Change directory command except expect 1 argument" + val errorMessage = "Change directory command except <= 1 argument" assertEquals(errorMessage, errorStream.toString().trim()) } + + } \ No newline at end of file diff --git a/core/src/test/kotlin/com/github/itmosoftwaredesign/cli/command/impl/LsCommandTest.kt b/core/src/test/kotlin/com/github/itmosoftwaredesign/cli/command/impl/LsCommandTest.kt new file mode 100644 index 0000000..19ce5c5 --- /dev/null +++ b/core/src/test/kotlin/com/github/itmosoftwaredesign/cli/command/impl/LsCommandTest.kt @@ -0,0 +1,124 @@ +import com.github.itmosoftwaredesign.cli.Environment +import com.github.itmosoftwaredesign.cli.command.impl.LsCommand +import com.github.itmosoftwaredesign.cli.command.ErrorResult +import com.github.itmosoftwaredesign.cli.command.SuccessResult +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.nio.file.Path + +class LsCommandTest { + + @TempDir + lateinit var tempDir: Path + + @Test + fun `should list files in current directory`() { + // Arrange + val testDir = tempDir.toFile() + File(testDir, "file1.txt").createNewFile() + File(testDir, "file2.txt").createNewFile() + File(testDir, "subdir").mkdir() + + val environment = Environment(workingDirectory = testDir.toPath()) + val lsCommand = LsCommand() + val outputStream = ByteArrayOutputStream() + val errorStream = ByteArrayOutputStream() + + // Act + val result = lsCommand.execute( + environment, + ByteArrayInputStream(ByteArray(0)), + outputStream, + errorStream, + emptyList() + ) + + // Assert + val output = outputStream.toString().trim().lines() + assertEquals(listOf("file1.txt", "file2.txt", "subdir"), output.sorted()) + assertTrue(result is SuccessResult) + } + + @Test + fun `should list files in specified directory`() { + // Arrange + val testDir = tempDir.toFile() + val subDir = File(testDir, "subdir") + subDir.mkdir() + File(subDir, "file1.txt").createNewFile() + File(subDir, "file2.txt").createNewFile() + + val environment = Environment(workingDirectory = testDir.toPath()) + val lsCommand = LsCommand() + val outputStream = ByteArrayOutputStream() + val errorStream = ByteArrayOutputStream() + + // Act + val result = lsCommand.execute( + environment, + ByteArrayInputStream(ByteArray(0)), + outputStream, + errorStream, + listOf("subDir") + ) + + // Assert + val output = outputStream.toString().trim().lines() + assertEquals(listOf("file1.txt", "file2.txt"), output.sorted()) + assertTrue(result is SuccessResult) + } + + @Test + fun `should handle non-existent directory`() { + // Arrange + val testDir = tempDir.toFile() + val environment = Environment(workingDirectory = testDir.toPath()) + val lsCommand = LsCommand() + val outputStream = ByteArrayOutputStream() + val errorStream = ByteArrayOutputStream() + + // Act + val result = lsCommand.execute( + environment, + ByteArrayInputStream(ByteArray(0)), + outputStream, + errorStream, + listOf("nonexistent") + ) + + // Assert + assertTrue(result is ErrorResult) + } + + @Test + fun `should handle a single file as argument`() { + // Arrange + val testDir = tempDir.toFile() + val testFile = File(testDir, "file1.txt") + testFile.createNewFile() + + val environment = Environment(workingDirectory = testDir.toPath()) + val lsCommand = LsCommand() + val outputStream = ByteArrayOutputStream() + val errorStream = ByteArrayOutputStream() + + // Act + val result = lsCommand.execute( + environment, + ByteArrayInputStream(ByteArray(0)), + outputStream, + errorStream, + listOf("file1.txt") + ) + + // Assert + val output = outputStream.toString().trim() + assertEquals("file1.txt", output) + assertTrue(result is SuccessResult) + } + +} diff --git a/core/src/test/kotlin/com/github/itmosoftwaredesign/cli/command/parser/CommandsParserTest.kt b/core/src/test/kotlin/com/github/itmosoftwaredesign/cli/command/parser/CommandsParserTest.kt index e613704..79b5e00 100644 --- a/core/src/test/kotlin/com/github/itmosoftwaredesign/cli/command/parser/CommandsParserTest.kt +++ b/core/src/test/kotlin/com/github/itmosoftwaredesign/cli/command/parser/CommandsParserTest.kt @@ -326,6 +326,51 @@ class CommandsParserTest { assertEquals(listOf("echo"), parsedCommands.commandTokens) } + @Test + fun `should parse cd upper directory`() { + val input = "cd .." + + val parsedCommands = commandsParser.parse(input).first() + + assertEquals(listOf("cd", ".."), parsedCommands.commandTokens) + } + + @Test + fun `should parse cd and empty`() { + val input = "cd " + + val parsedCommands = commandsParser.parse(input).first() + + assertEquals(listOf("cd"), parsedCommands.commandTokens) + } + + @Test + fun `should parse ls and empty`() { + val input = "ls " + + val parsedCommands = commandsParser.parse(input).first() + + assertEquals(listOf("ls"), parsedCommands.commandTokens) + } + + @Test + fun `should parse cd and directory`() { + val input = "cd src/main" + + val parsedCommands = commandsParser.parse(input).first() + + assertEquals(listOf("cd", "src/main"), parsedCommands.commandTokens) + } + + @Test + fun `should parse ls and directory`() { + val input = "ls src/main" + + val parsedCommands = commandsParser.parse(input).first() + + assertEquals(listOf("ls", "src/main"), parsedCommands.commandTokens) + } + @Test fun `should handle parsing several commands`() { val input = "echo \"input\" | wc | echo \"output\"" diff --git a/docs/classes.puml b/docs/classes.puml index d952208..369f63a 100644 --- a/docs/classes.puml +++ b/docs/classes.puml @@ -38,5 +38,7 @@ Command <|-- CatCommand Command <|-- WcCommand Command <|-- PwdCommand Command <|-- ExitCommand +Command <|-- ChangeDirectoryCommand +Command <|-- ListFilesCommand @enduml diff --git a/feedback.md b/feedback.md new file mode 100644 index 0000000..a0c6768 --- /dev/null +++ b/feedback.md @@ -0,0 +1,39 @@ +### Фидбек по архитектуре CLI-приложения + +#### Архитектура: +Команды реализованы как отдельные классы, что позволяет легко добавлять новые команды и поддерживать их независимость. + +1. **Реализация команд**: Каждая команда, реализует интерфейс `Command`,что гибко управлять командами и расширять функциональность через добавление новых классов команд. +2. **Использование потоков для ввода/вывода**: Каждая команда работает с потоками для ввода, вывода и ошибок. Это обеспечивает поддержку как файловых операций, так и обычных командных строк. +3. **Парсинг команд**: Класс `CommandsParser` эффективно управляет синтаксическим анализом команд, включая поддержку перенаправлений ввода/вывода, пайплайнов и подстановки переменных окружения. +4. **Хранение команд в реестре**: Использование `CommandRegistry` для регистрации команд позволяет централизованно управлять всеми доступными командами. Это хорошо для масштабируеиости системы. + + +#### Реализация: +1. **Модульность**: Код хорошо разделен на отдельные компоненты (команды, парсинг, обработка ошибок), что упрощает его поддержку и расширение. +2. **Обработка ошибок**: Ошибки обрабатываются через `CommandResult`, что упрощает работу с ними и делает код более читаемым. +3. **Гибкость с потоками**: Поддержка разных потоков (ввод, вывод, ошибки) позволяет перенаправлять данные, что полезно для работы с пайпами или редиректами. + +#### Удобные моменты: +1. **Гибкость команд**: Каждая команда реализует общий интерфейс, что облегчает добавление новых команд в систему. +2. **Переменные окружения**: Система переменных окружения позволяет гибко управлять настройками, такими как рабочий каталог или PID процесса. +3. **Пайпы и редиректы**: Поддержка этих операций делает приложение мощным инструментом для работы с текстовыми данными и файлами. + +#### Неудобные моменты и варианты улучшения: +1. **Обработка исключений**: + - **Неудобно**: В некоторых местах ошибки при чтении/записи в файлы обрабатываются слишком общо, что не всегда помогает понять, что пошло не так. + - **Улучшение**: Можно более точно обрабатывать различные ошибки, такие как отсутствие файла или проблемы с правами доступа, и давать более информативные сообщения. + +2. **Тестируемость**: + - **Неудобно**: Команды тесно связаны с потоками, что усложняет их тестирование в изоляции. + - **Улучшение**: Чтобы улучшить тестируемость, можно вынести логику команд в отдельные классы, которые не зависят от потоков, что позволит протестировать команды без необходимости имитировать ввод/вывод. + +3. **Управление зависимостями**: + - **Неудобно**: Множество компонентов, работающих с потоками и переменными окружения, может стать запутанным при добавлении новых команд. + - **Улучшение**: Стоит разделить управление переменными и потоками в отдельные классы, чтобы улучшить структуру и упростить добавление новых команд. + +4. **Ошибки синтаксиса**: + - **Неудобно**: В случае неправильного ввода команды ошибка может быть не совсем понятной. + - **Улучшение**: Стоит улучшить обработку ошибок при парсинге команд, давая пользователю более точные указания, что именно было введено неверно. + +В целом было достаточно просто добавить новые команды в код. И архитектура приложения по большей части написана хорошо.