generateRandomScores(final int count) {
+ return Stream.generate(() -> String.valueOf(FAKER.number().numberBetween(1, 6)))
+ .limit(count)
+ .toList();
+ }
+}
diff --git a/src/main/java/org/writer/writer/CsvWriterImpl.java b/src/main/java/org/writer/writer/CsvWriterImpl.java
new file mode 100644
index 0000000..dbc82b8
--- /dev/null
+++ b/src/main/java/org/writer/writer/CsvWriterImpl.java
@@ -0,0 +1,192 @@
+package org.writer.writer;
+
+import org.writer.annotation.CsvColumn;
+import org.writer.annotation.CsvEntity;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * Реализация интерфейса {@link Writable}, которая сохраняет
+ * список объектов в файл в формате CSV, используя аннотации и рефлексию.
+ */
+public class CsvWriterImpl implements Writable {
+ private static final String CSV_DELIMITER = ",";
+ private static final String COLLECTION_DELIMITER = ";";
+ private static final String KEY_VALUE_DELIMITER = ":";
+
+ /**
+ * Записывает данные в CSV-файл. Первой строкой всегда будет заголовок,
+ * сформированный из аннотаций {@link CsvColumn}.
+ *
+ * @param data список объектов для записи. Класс объектов должен быть помечен
+ * аннотацией {@link CsvEntity}.
+ * @param fileName имя файла, в который будут сохранены данные.
+ *
+ * @throws IllegalArgumentException если список data:
+ * а) {@code null},
+ * б) {@code data.isEmpty()},
+ * в) содержит объекты, не помеченные аннотацией {@link CsvEntity};
+ * если строка fileName:
+ * а) {@code null},
+ * б) {@code fileName.isBlank()}.
+ * @throws RuntimeException в случае ошибки ввода-вывода при записи файла.
+ */
+ @Override
+ public void writeToFile(final List> data, final String fileName) {
+ if (data == null || data.isEmpty()) {
+ throw new IllegalArgumentException("data не должен быть isEmpty или null");
+ }
+
+ if (fileName == null || fileName.isBlank()) {
+ throw new IllegalArgumentException("fileName не должен быть isBlank или null");
+ }
+
+ Class> csvEntityClass = data.get(0).getClass();
+ if (!csvEntityClass.isAnnotationPresent(CsvEntity.class)) {
+ throw new IllegalArgumentException(csvEntityClass.getSimpleName() + " не имеет аннотации @CsvEntity");
+ }
+
+ List sortedFields = getSortedCsvFields(csvEntityClass);
+
+ try (BufferedWriter writer = Files.newBufferedWriter(Path.of(fileName))) {
+ String header = generateHeader(sortedFields);
+ writer.write(header);
+ writer.newLine();
+
+ for (Object item : data) {
+ String row = convertObjectToCsvRow(item, sortedFields);
+ writer.write(row);
+ writer.newLine();
+ }
+ } catch (IOException e) {
+ throw new RuntimeException("Ошибка во время записи в файл: " + fileName, e);
+ }
+ }
+
+ /**
+ * Находит все поля, помеченные аннотацией {@link CsvColumn},
+ * проверяет уникальность их параметров {@link CsvColumn#name()} и {@link CsvColumn#order()},
+ * и сортирует их в соответствии с параметром {@code order}.
+ *
+ * @param csvEntityClass класс для сканирования.
+ *
+ * @return отсортированный список полей.
+ *
+ * @throws IllegalStateException если найдены дублирующиеся значения {@code name} или {@code order}
+ * в аннотациях {@link CsvColumn} в пределах класса.
+ */
+ private List getSortedCsvFields(final Class> csvEntityClass) {
+ List fields = Arrays.stream(csvEntityClass.getDeclaredFields())
+ .filter(field -> field.isAnnotationPresent(CsvColumn.class))
+ .toList();
+
+ List names = fields.stream()
+ .map(field -> field.getAnnotation(CsvColumn.class).name())
+ .toList();
+ if (new HashSet<>(names).size() < names.size()) {
+ throw new IllegalStateException("В классе " + csvEntityClass.getSimpleName() + " найдены дублирующиеся значения \"name\" в аннотациях @CsvColumn");
+ }
+
+ List orders = fields.stream()
+ .map(field -> field.getAnnotation(CsvColumn.class).order())
+ .toList();
+ if (new HashSet<>(orders).size() < orders.size()) {
+ throw new IllegalStateException("В классе " + csvEntityClass.getSimpleName() + " найдены дублирующиеся значения \"order\" в аннотациях @CsvColumn");
+ }
+
+ return fields.stream()
+ .sorted(Comparator.comparingInt(field -> field.getAnnotation(CsvColumn.class).order()))
+ .toList();
+ }
+
+ /**
+ * Генерирует строку заголовка CSV на основе сортированного списка полей.
+ *
+ * @param sortedFields отсортированный список полей.
+ *
+ * @return строка заголовка, например, "firstName,lastName,birthYear".
+ */
+ private String generateHeader(final List sortedFields) {
+ return sortedFields.stream()
+ .map(field -> field.getAnnotation(CsvColumn.class).name())
+ .map(this::escapeCsvValue)
+ .collect(Collectors.joining(CSV_DELIMITER));
+ }
+
+ /**
+ * Преобразует один объект в строку формата CSV.
+ *
+ * @param item объект для преобразования.
+ * @param sortedFields отсортированный список полей, значения которых нужно извлечь.
+ *
+ * @return строка в формате CSV, например, "John,Doe,1990".
+ *
+ * @throws RuntimeException если не удалось получить доступ к полю.
+ */
+ private String convertObjectToCsvRow(final Object item, final List sortedFields) {
+ return sortedFields.stream()
+ .map(field -> {
+ try {
+ field.setAccessible(true);
+ Object value = field.get(item);
+ String stringValue = convertFieldValueToString(value);
+ return escapeCsvValue(stringValue);
+ } catch (IllegalAccessException e) {
+ throw new RuntimeException("Не удалось получить доступ к полю: " + field.getName(), e);
+ }
+ })
+ .collect(Collectors.joining(CSV_DELIMITER));
+ }
+
+ /**
+ * Конвертирует значение поля в его строковое представление для CSV.
+ * Обрабатывает {@code Map}, разделяя ключ-значение {@code KEY_VALUE_DELIMITER},
+ * и объединяя все пары {@code COLLECTION_DELIMITER}.
+ * Обрабатывает {@code Collection}, объединяя их элементы {@code COLLECTION_DELIMITER}.
+ *
+ * @param value значение поля.
+ *
+ * @return строковое представление значения.
+ */
+ private String convertFieldValueToString(final Object value) {
+ if (value == null) {
+ return "";
+ }
+
+ if (value instanceof Map, ?> map) {
+ return map.entrySet().stream()
+ .map(entry -> entry.getKey().toString() + KEY_VALUE_DELIMITER + entry.getValue().toString())
+ .collect(Collectors.joining(COLLECTION_DELIMITER));
+ }
+
+ if (value instanceof Collection> collection) {
+ return collection.stream()
+ .map(String::valueOf)
+ .collect(Collectors.joining(COLLECTION_DELIMITER));
+ }
+
+ return value.toString();
+ }
+
+ /**
+ * Экранирует значение поля для CSV формата в соответствии с RFC 4180.
+ *
+ * @param value исходное строковое значение.
+ *
+ * @return экранированное значение, готовое к записи в CSV.
+ */
+ private String escapeCsvValue(final String value) {
+ if (value.contains(CSV_DELIMITER) || value.contains("\"") || value.contains("\n")) {
+ String escapedValue = value.replace("\"", "\"\"");
+ return "\"" + escapedValue + "\"";
+ }
+
+ return value;
+ }
+}
diff --git a/src/main/java/org/writer/writer/Writable.java b/src/main/java/org/writer/writer/Writable.java
new file mode 100644
index 0000000..052426c
--- /dev/null
+++ b/src/main/java/org/writer/writer/Writable.java
@@ -0,0 +1,25 @@
+package org.writer.writer;
+
+import java.util.List;
+
+/**
+ * Определяет контракт для классов, которые могут записывать
+ * списки объектов в файл в определённом формате.
+ */
+public interface Writable {
+ /**
+ * Записывает переданный список объектов в файл с указанным именем.
+ *
+ * Реализации этого метода должны чётко определять формат записи
+ * и требования к аннотациям и/или структуре объектов в списке {@code data}.
+ *
+ * @param data список объектов для записи.
+ * @param fileName полное имя файла, в который будут сохранены данные.
+ *
+ * @throws IllegalArgumentException если какой-либо из входных параметров невалиден.
+ * Например, если список {@code data} равен {@code null} или {@code data.isEmpty()},
+ * или если {@code fileName} равен {@code null} или {@code fileName.isBlank()}.
+ * @throws RuntimeException если в процессе записи в файл возникает ошибка ввода-вывода или другая критическая ошибка.
+ */
+ void writeToFile(List> data, String fileName);
+}
diff --git a/src/test/java/org/writer/writer/CsvWriterImplTest.java b/src/test/java/org/writer/writer/CsvWriterImplTest.java
new file mode 100644
index 0000000..b5ffe83
--- /dev/null
+++ b/src/test/java/org/writer/writer/CsvWriterImplTest.java
@@ -0,0 +1,341 @@
+package org.writer.writer;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.writer.annotation.CsvColumn;
+import org.writer.annotation.CsvEntity;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+public class CsvWriterImplTest {
+ private Writable csvWriter;
+
+ @TempDir
+ Path tempDir;
+
+ @BeforeEach
+ void setUp() {
+ csvWriter = new CsvWriterImpl();
+ }
+
+ @Test
+ @DisplayName("Должен выбросить исключение, если список data null")
+ void writeToFile_shouldThrowException_whenDataIsNull() {
+ // Arrange
+ String fileName = tempDir.resolve("test.csv").toString();
+
+ // Act & Assert
+ assertThatThrownBy(() -> csvWriter.writeToFile(null, fileName))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("data не должен быть isEmpty или null");
+ }
+
+ @Test
+ @DisplayName("Должен выбросить исключение, если список data isEmpty")
+ void writeToFile_shouldThrowException_whenDataIsEmpty() {
+ // Arrange
+ List> data = Collections.emptyList();
+ String fileName = tempDir.resolve("test.csv").toString();
+
+ // Act & Assert
+ assertThatThrownBy(() -> csvWriter.writeToFile(data, fileName))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("data не должен быть isEmpty или null");
+ }
+
+ @Test
+ @DisplayName("Должен выбросить исключение, если строка fileName null")
+ void writeToFile_shouldThrowException_whenFileNameIsNull() {
+ // Arrange
+ List data = List.of(1);
+
+ // Act & Assert
+ assertThatThrownBy(() -> csvWriter.writeToFile(data, null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("fileName не должен быть isBlank или null");
+ }
+
+ @Test
+ @DisplayName("Должен выбросить исключение, если строка fileName isBlank")
+ void writeToFile_shouldThrowException_whenFileNameIsBlank() {
+ // Arrange
+ List data = List.of(1);
+ String fileName = " ";
+
+ // Act & Assert
+ assertThatThrownBy(() -> csvWriter.writeToFile(data, fileName))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("fileName не должен быть isBlank или null");
+ }
+
+ @Test
+ @DisplayName("Должен выбросить исключение, если класс объектов в списке data не имеет аннотации @CsvEntity")
+ void writeToFile_shouldThrowException_whenClassIsNotCsvEntity() {
+ // Arrange
+ class InvalidModel {}
+ List invalidData = List.of(new InvalidModel());
+ String fileName = tempDir.resolve("test.csv").toString();
+
+ // Act & Assert
+ assertThatThrownBy(() -> csvWriter.writeToFile(invalidData, fileName))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage(InvalidModel.class.getSimpleName() + " не имеет аннотации @CsvEntity");
+ }
+
+ @Test
+ @DisplayName("Должен выбросить исключение, если в @CsvColumn есть дублирующийся \"name\"")
+ void writeToFile_shouldThrowException_whenCsvColumnHasDuplicateName() {
+ // Arrange
+ @CsvEntity
+ @SuppressWarnings("unused")
+ class DuplicateNameModel {
+ @CsvColumn(name = "field", order = 1)
+ private Object field1;
+
+ @CsvColumn(name = "field", order = 2)
+ private Object field2;
+ }
+ List data = List.of(new DuplicateNameModel());
+ String fileName = tempDir.resolve("test.csv").toString();
+
+ // Act & Assert
+ assertThatThrownBy(() -> csvWriter.writeToFile(data, fileName))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("В классе " + DuplicateNameModel.class.getSimpleName() + " найдены дублирующиеся значения \"name\" в аннотациях @CsvColumn");
+ }
+
+ @Test
+ @DisplayName("Должен выбросить исключение, если в CsvColumn есть дублирующийся \"order\"")
+ void writeToFile_shouldThrowException_whenCsvColumnHasDuplicateOrder() {
+ // Arrange
+ @CsvEntity
+ @SuppressWarnings("unused")
+ class DuplicateOrderModel {
+ @CsvColumn(name = "field1", order = 1)
+ private Object field1;
+
+ @CsvColumn(name = "field2", order = 1)
+ private Object field2;
+ }
+ List data = List.of(new DuplicateOrderModel());
+ String fileName = tempDir.resolve("test.csv").toString();
+
+ // Act & Assert
+ assertThatThrownBy(() -> csvWriter.writeToFile(data, fileName))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("В классе " + DuplicateOrderModel.class.getSimpleName() + " найдены дублирующиеся значения \"order\" в аннотациях @CsvColumn");
+ }
+
+ @Test
+ @DisplayName("Должен корректно записывать простой список объектов в CSV-файл")
+ void writeToFile_shouldWriteSimpleObjectsCorrectly() throws IOException {
+ // Arrange
+ @Data
+ @AllArgsConstructor
+ @CsvEntity
+ class SimpleModel {
+ @CsvColumn(name = "User ID", order = 1)
+ private int id;
+
+ @CsvColumn(name = "User Name", order = 2)
+ private String name;
+ }
+ List data = List.of(
+ new SimpleModel(101, "John Doe"),
+ new SimpleModel(202, "Jane Smith")
+ );
+ Path filePath = tempDir.resolve("test.csv");
+
+ String expectedHeader = "User ID,User Name";
+ String expectedRow1 = "101,John Doe";
+ String expectedRow2 = "202,Jane Smith";
+
+ // Act
+ csvWriter.writeToFile(data, filePath.toString());
+
+ // Assert
+ assertThat(filePath).exists().isRegularFile();
+
+ List actualRows = Files.readAllLines(filePath);
+ assertThat(actualRows)
+ .hasSize(3)
+ .containsExactly(
+ expectedHeader,
+ expectedRow1,
+ expectedRow2
+ );
+ }
+
+ @Test
+ @DisplayName("Должен корректно экранировать значение с запятой")
+ void writeToFile_shouldEscapeValueWithComma() throws IOException {
+ // Arrange
+ @Data
+ @AllArgsConstructor
+ @CsvEntity
+ class ModelWithCommaValue {
+ @CsvColumn(name = "commaField", order = 1)
+ private String commaField;
+ }
+
+ List data = List.of(
+ new ModelWithCommaValue("this is a comma,value")
+ );
+ Path filePath = tempDir.resolve("test.csv");
+
+ String expectedHeader = "commaField";
+ String expectedRow = "\"this is a comma,value\"";
+
+ // Act
+ csvWriter.writeToFile(data, filePath.toString());
+
+ // Assert
+ assertThat(filePath).exists().isRegularFile();
+
+ List actualRows = Files.readAllLines(filePath);
+ assertThat(actualRows)
+ .hasSize(2)
+ .containsExactly(
+ expectedHeader,
+ expectedRow
+ );
+ }
+
+ @Test
+ @DisplayName("Должен корректно экранировать значение с кавычками")
+ void writeToFile_shouldEscapeValueWithQuotes() throws IOException {
+ // Arrange
+ @Data
+ @AllArgsConstructor
+ @CsvEntity
+ class ModelWithQuotesValue {
+ @CsvColumn(name = "quotesField", order = 1)
+ private String quotesField;
+ }
+
+ List data = List.of(
+ new ModelWithQuotesValue("this is a \"quotes\" value")
+ );
+ Path filePath = tempDir.resolve("test.csv");
+
+ String expectedHeader = "quotesField";
+ String expectedRow = "\"this is a \"\"quotes\"\" value\"";
+
+ // Act
+ csvWriter.writeToFile(data, filePath.toString());
+
+ // Assert
+ assertThat(filePath).exists().isRegularFile();
+
+ List actualRows = Files.readAllLines(filePath);
+ assertThat(actualRows)
+ .hasSize(2)
+ .containsExactly(
+ expectedHeader,
+ expectedRow
+ );
+ }
+
+ @Test
+ @DisplayName("Должен корректно экранировать значение с новой строкой")
+ void writeToFile_shouldEscapeValueWithNewLine() throws IOException {
+ // Arrange
+ @Data
+ @AllArgsConstructor
+ @CsvEntity
+ class ModelWithNewLineValue {
+ @CsvColumn(name = "newLineField", order = 1)
+ private String newLineField;
+ }
+
+ List data = List.of(
+ new ModelWithNewLineValue("this is a\nnew line value")
+ );
+ Path filePath = tempDir.resolve("test.csv");
+
+ String expectedHeader = "newLineField";
+ String expectedRow = "\"this is a\nnew line value\"";
+ String expectedFileContent = expectedHeader + System.lineSeparator() + expectedRow + System.lineSeparator();
+
+ // Act
+ csvWriter.writeToFile(data, filePath.toString());
+
+ // Assert
+ assertThat(filePath).exists().isRegularFile();
+
+ String actualFileContent = Files.readString(filePath);
+ assertThat(actualFileContent).isEqualTo(expectedFileContent);
+ }
+
+ @Data
+ @Builder
+ @CsvEntity
+ private static class ComplexModel {
+ @CsvColumn(name = "nullField", order = 1)
+ private Object nullField;
+
+ @CsvColumn(name = "list", order = 2)
+ private Collection list;
+
+ @CsvColumn(name = "emptyCollection", order = 3)
+ private Collection> emptyCollection;
+
+ @CsvColumn(name = "map", order = 4)
+ private Map map;
+
+ @CsvColumn(name = "emptyMap", order = 5)
+ private Map, ?> emptyMap;
+ }
+
+ private List getComplexModels() {
+ Map map = new LinkedHashMap<>();
+ map.put("key1", "value1");
+ map.put("key2", "value2");
+
+ ComplexModel complexModel = ComplexModel.builder()
+ .nullField(null)
+ .list(List.of("listValue1", "listValue2"))
+ .emptyCollection(Collections.emptySet())
+ .map(map)
+ .emptyMap(Collections.emptyMap())
+ .build();
+
+ return List.of(complexModel);
+ }
+
+ @Test
+ @DisplayName("Должен корректно обрабатывать null, Collection и Map")
+ void writeToFile_shouldCorrectlyHandleNullsCollectionsAndMaps() throws IOException {
+ // Arrange
+ List data = getComplexModels();
+ Path filePath = tempDir.resolve("test.csv");
+
+ String expectedHeader = "nullField,list,emptyCollection,map,emptyMap";
+ String expectedRow = ",listValue1;listValue2,,key1:value1;key2:value2,";
+
+ // Act
+ csvWriter.writeToFile(data, filePath.toString());
+
+ // Assert
+ assertThat(filePath).exists().isRegularFile();
+
+ List actualRows = Files.readAllLines(filePath);
+ assertThat(actualRows)
+ .hasSize(2)
+ .containsExactly(
+ expectedHeader,
+ expectedRow
+ );
+ }
+}