diff --git a/pom.xml b/pom.xml index 4d179d3f..8191471f 100644 --- a/pom.xml +++ b/pom.xml @@ -39,6 +39,7 @@ tasks/argocd tasks/aws tasks/confluence + tasks/docs tasks/git tasks/gremlin tasks/hashivault diff --git a/tasks/docs/README.md b/tasks/docs/README.md new file mode 100644 index 00000000..24b57b56 --- /dev/null +++ b/tasks/docs/README.md @@ -0,0 +1,102 @@ +# Concord Plugin: `docs` + +The `docs` plugin generates Markdown documentation from Concord YAML flows. +It extracts flow descriptions, input/output parameters, +and produces a [mdBook](https://rust-lang.github.io/mdBook/) +compatible structure - ready for publishing. + +## Usage + +To use this plugin in your Concord flow: + +```yaml +flows: + generateDocs: + - task: docs + in: + output: "${workDir}/docs" + bookTitle: "My Project Documentation" +``` + +Or define it via a separate Concord profile: + +```yaml +profiles: + docs: + configuration: + extraDependencies: + - "mvn://com.walmartlabs.concord.plugins:docs-task:" + flows: + renderFlowDocs: + - task: docs + in: + bookTitle: "My Project Documentation" + output: "${workDir}/docs" +``` + +## Task input parameters + +| Input Key | Type | Required | Description | +|----------------------------|---------|----------|-----------------------------------------------------------------------------------------| +| `output` | string | ✓ | Output directory for generated files | +| `bookTitle` | string | ✓ | Title used in the `book.toml` metadata | +| `includeUndocumentedFlows` | boolean | | Generate docs for flows without description (default: true ) | +| `sourceBaseUrl` | string | | Base URL for linking to flow source files (e.g., https://github.com/ORG/REPO/blob/main) | +| `flowsDir` | string | | Directory containing external Concord flows | + +> **Note:** If `flowsDir` is specified, flow imports will not be resolved during documentation generation. + +## Example Output + +- `src/**/*.md`: flow documentation +- `src/SUMMARY.md`: mdBook-style summary with structure +- `book.toml`: book metadata file +- `flows.json`: flow descriptions in JSON format + +``` +docs/ +├── book.toml +└── src/ + ├── my-flow.yaml.md + ├── another-flow.yaml.md + └── SUMMARY.md +``` + +## Flow description format + +Flow descriptions are parsed from comments above each flow definition using the following format: + +```yaml +## +# +# in: +# myParam: , mandatory|optional, +# out: +# myParam: , mandatory|optional, +## +``` + +Where: + +``:A human-readable description of the flow (optional). + +``: +- Basic types: string | int | number | boolean | object | date | any +- Arrays: string[] | int[] | number[] | boolean[] | object[] | date[] | any[] + *int === number, just an alias* +- Custom types: any string value + +``: Description of the parameter (optional). + +### Describing Objects + +You can describe nested object parameters using dot notation: + +```yaml +## +# in: +# objectParam: object, mandatory|optional, +# objectParam.key1: , mandatory|optional, +# objectParam.key2: , mandatory|optional, +## +``` diff --git a/tasks/docs/example/concord.yaml b/tasks/docs/example/concord.yaml new file mode 100644 index 00000000..3c5b1078 --- /dev/null +++ b/tasks/docs/example/concord.yaml @@ -0,0 +1,29 @@ +configuration: + runtime: concord-v2 + +flows: + ## + # My flow description + # + # in: + # param1: string, mandatory, param1 description + # param2: object, optional + # param2.key: string, mandatory, key description + # out: + # out1: string, mandatory, out1 description + # out2: string, optional + ## + myFlow: + - log: "Hello" + +profiles: + docs: + configuration: + extraDependencies: + - "mvn://com.walmartlabs.concord.plugins:docs-task:2.7.1-SNAPSHOT" + flows: + renderFlowDocs: + - task: docs + in: + bookTitle: "My Project Documentation" + output: "${workDir}/docs" diff --git a/tasks/docs/pom.xml b/tasks/docs/pom.xml new file mode 100644 index 00000000..9dfdd405 --- /dev/null +++ b/tasks/docs/pom.xml @@ -0,0 +1,81 @@ + + + + + com.walmartlabs.concord.plugins + concord-plugins-parent + 2.7.1-SNAPSHOT + ../../pom.xml + + + 4.0.0 + + docs-task + takari-jar + + + + + + + com.walmartlabs.concord.runtime.v2 + concord-runtime-sdk-v2 + provided + + + com.walmartlabs.concord.runtime.v2 + concord-runtime-model-v2 + provided + + + com.walmartlabs.concord + concord-common + ${concord.version} + provided + + + com.walmartlabs.concord + concord-imports + ${concord.version} + provided + + + + javax.inject + javax.inject + provided + + + org.slf4j + slf4j-api + provided + + + + com.fasterxml.jackson.core + jackson-core + provided + + + com.fasterxml.jackson.core + jackson-databind + provided + + + com.fasterxml.jackson.core + jackson-annotations + provided + + + + org.junit.jupiter + junit-jupiter-api + test + + + ch.qos.logback + logback-classic + test + + + diff --git a/tasks/docs/src/main/java/com/walmartlabs/concord/plugins/docs/DocsTaskV2.java b/tasks/docs/src/main/java/com/walmartlabs/concord/plugins/docs/DocsTaskV2.java new file mode 100644 index 00000000..ccbc0aa6 --- /dev/null +++ b/tasks/docs/src/main/java/com/walmartlabs/concord/plugins/docs/DocsTaskV2.java @@ -0,0 +1,186 @@ +package com.walmartlabs.concord.plugins.docs; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart Inc., Concord Authors + * ----- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ===== + */ + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.walmartlabs.concord.imports.ImportsListener; +import com.walmartlabs.concord.imports.NoopImportManager; +import com.walmartlabs.concord.runtime.v2.NoopImportsNormalizer; +import com.walmartlabs.concord.runtime.v2.ProjectLoaderV2; +import com.walmartlabs.concord.runtime.v2.model.Flow; +import com.walmartlabs.concord.runtime.v2.model.ProcessDefinition; +import com.walmartlabs.concord.runtime.v2.sdk.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.inject.Inject; +import javax.inject.Named; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.stream.Collectors; + +import static com.walmartlabs.concord.plugins.docs.FlowDescriptionParser.FlowDescription; + +@Named("docs") +public class DocsTaskV2 implements Task { + + private static final Logger log = LoggerFactory.getLogger(DocsTaskV2.class); + + private final Context context; + + @Inject + public DocsTaskV2(Context context) { + this.context = context; + } + + @Override + public TaskResult.SimpleResult execute(Variables input) throws Exception { + var includeUndocumentedFlows = input.getBoolean("includeUndocumentedFlows", true); + var flowsDir = getPathOrNull(input, "flowsDir"); + + ProcessDefinition processDefinition; + if (flowsDir == null) { + log.info("Loading flows from current process"); + flowsDir = context.workingDirectory(); + processDefinition = context.execution().processDefinition(); + } else { + log.info("Loading flows from '{}'", flowsDir); + processDefinition = loadProcessDefinition(flowsDir); + } + + var flowLinesByFileName = collectFlowLineNumbers(processDefinition.flows()); + var flowDescriptionByFileName = new LinkedHashMap>(); + var undocumentedFlowsByFileName = new LinkedHashMap>(); + for (var entry : flowLinesByFileName.entrySet()) { + var sourcePath = flowsDir.resolve(entry.getKey()); + if (!Files.exists(sourcePath)) { + log.warn("Flows file '{}' does not exist", entry.getKey()); + continue; + } + + var lines = Files.readAllLines(sourcePath); + var descriptions = new ArrayList(); + var missing = new ArrayList(); + + for (var flowLineNum : entry.getValue()) { + var desc = FlowDescriptionParser.parse(flowLineNum.flowName, lines, flowLineNum.lineNum); + if (desc != null) { + descriptions.add(desc); + } else { + if (includeUndocumentedFlows) { + descriptions.add(new FlowDescription(flowLineNum.lineNum + 1, flowLineNum.flowName, null, List.of(), List.of(), List.of())); + } + missing.add(flowLineNum.flowName); + } + } + + if (!descriptions.isEmpty()) { + flowDescriptionByFileName.put(entry.getKey(), descriptions); + } + if (!missing.isEmpty()) { + undocumentedFlowsByFileName.put(entry.getKey(), missing); + } + } + + var outputDir = Path.of(input.assertString("output")); + MdBookGenerator.generate(input.assertString("bookTitle"), flowDescriptionByFileName, input.getString("sourceBaseUrl"), outputDir); + + logMissingFlowDocs(processDefinition.flows().size(), undocumentedFlowsByFileName); + + new ObjectMapper() + .setSerializationInclusion(JsonInclude.Include.NON_NULL) + .setSerializationInclusion(JsonInclude.Include.NON_EMPTY) + .writerWithDefaultPrettyPrinter() + .writeValue(outputDir.resolve("flows.json").toFile(), flowDescriptionByFileName); + + return TaskResult.success(); + } + + private Path getPathOrNull(Variables input, String key) { + var v = input.getString(key); + if (v == null) { + return null; + } + var p = normalize(context.workingDirectory(), v); + if (Files.notExists(p)) { + throw new UserDefinedException("'" + key + "' directory does not exists: " + p); + } + return p; + } + + private ProcessDefinition loadProcessDefinition(Path flowsDir) { + ProjectLoaderV2.Result loadResult; + try { + loadResult = new ProjectLoaderV2(new NoopImportManager()) + .load(flowsDir, new NoopImportsNormalizer(), ImportsListener.NOP_LISTENER); + } catch (Exception e) { + log.error("Error while loading flows: {}", e.getMessage(), e); + throw new RuntimeException("Error while loading flows: " + e.getMessage()); + } + + return loadResult.getProjectDefinition(); + } + + private static Map> collectFlowLineNumbers(Map flows) { + return flows.entrySet().stream() + .map(entry -> { + var loc = entry.getValue().location(); + var fileName = loc.fileName(); + int lineNum = loc.lineNum() - 1; // Concord line numbers are 1 based :) + if (fileName == null || lineNum < 0) { + return null; + } + return Map.entry(fileName, new FlowLineNum(entry.getKey(), lineNum)); + }) + .filter(Objects::nonNull) + .collect( + Collectors.groupingBy( + Map.Entry::getKey, + Collectors.mapping(Map.Entry::getValue, Collectors.toList()) + ) + ); + } + + private static Path normalize(Path workDir, String path) { + var p = Path.of(path); + if (p.startsWith(workDir)) { + return p.toAbsolutePath(); + } + return workDir.resolve(p).toAbsolutePath(); + } + + private static void logMissingFlowDocs(int total, Map> missing) { + if (missing.isEmpty()) { + return; + } + + log.warn("Flows without description ({}/{}):", missing.values().stream().mapToInt(List::size).sum(), total); + + for (var entry : missing.entrySet()) { + log.warn("{} ({}):\n{}", entry.getKey(), entry.getValue().size(), String.join("\n", entry.getValue())); + } + } + + record FlowLineNum (String flowName, int lineNum) { + } +} diff --git a/tasks/docs/src/main/java/com/walmartlabs/concord/plugins/docs/FlowDescriptionParser.java b/tasks/docs/src/main/java/com/walmartlabs/concord/plugins/docs/FlowDescriptionParser.java new file mode 100644 index 00000000..05936884 --- /dev/null +++ b/tasks/docs/src/main/java/com/walmartlabs/concord/plugins/docs/FlowDescriptionParser.java @@ -0,0 +1,182 @@ +package com.walmartlabs.concord.plugins.docs; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart Inc., Concord Authors + * ----- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ===== + */ + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public final class FlowDescriptionParser { + + public record FlowDescription (int lineNum, String name, String description, List in, List out, List tags) { + + record Parameter (String name, String type, boolean required, String description) { + } + } + + public static FlowDescription parse(String flowName, List lines, int flowLineNum) { + if (!lines.get(flowLineNum).contains(flowName)) { + throw new IllegalArgumentException("Invalid flowLineNum " + flowLineNum + ": expected flow name '" + flowName+ "' at line '" + lines.get(flowLineNum) + "'"); + } + + var descriptionStart = findDescriptionStart(lines, flowLineNum); + if (descriptionStart == -1) { + return null; + } + + return parseCommentBlock(flowName, lines, descriptionStart, flowLineNum); + } + + private static FlowDescription parseCommentBlock(String flowName, List lines, int startLine, int endLine) { + enum State { DESCRIPTION, INPUTS, OUTPUTS, OTHER } + + var state = State.DESCRIPTION; + var descriptionBuilder = new StringBuilder(); + var inputs = new ArrayList(); + var outputs = new ArrayList(); + var tags = List.of(); + + for (var i = startLine; i < endLine; i++) { + var line = lines.get(i).trim(); + if (!line.startsWith("#")) { + continue; + } + + var content = line.replaceFirst("#+", "").trim(); + + if ("in:".equalsIgnoreCase(content)) { + state = State.INPUTS; + continue; + } else if ("out:".equalsIgnoreCase(content)) { + state = State.OUTPUTS; + continue; + } else if (content.toLowerCase().startsWith("tags:")) { + var tagsContent = content.substring(5).trim(); + if (!tagsContent.isEmpty()) { + tags = Arrays.asList(tagsContent.split("\\s*,\\s*")); + } + state = State.OTHER; + continue; + } + + switch (state) { + case DESCRIPTION -> { + if (!content.isEmpty()) { + if (!descriptionBuilder.isEmpty()) { + descriptionBuilder.append(" "); + } + descriptionBuilder.append(content); + } + } + case INPUTS -> { + var param = parseParameterLine(content); + if (param != null) { + inputs.add(param); + } + } + case OUTPUTS -> { + var param = parseParameterLine(content); + if (param != null) { + outputs.add(param); + } + } + case OTHER -> { + // ignore other lines + } + } + } + + return new FlowDescription(endLine + 1, flowName, descriptionBuilder.isEmpty() ? null : descriptionBuilder.toString(), inputs, outputs, tags); + } + + private static int findDescriptionStart(List lines, int flowLineNum) { + if (flowLineNum >= lines.size()) { + return -1; + } + + int result = -1; + var expectedIndent = getIndent(lines.get(flowLineNum)); + for (var i = flowLineNum - 1; i >= 0; i--) { + var line = lines.get(i); + var trimmedLine = line.trim(); + if (trimmedLine.isEmpty()) { + continue; + } + var lineIndent = getIndent(line); + if (lineIndent != expectedIndent) { + break; + } + if (trimmedLine.startsWith("#")) { + result = i; + } else { + break; + } + } + return result; + } + + private static int getIndent(String line) { + var index = 0; + while (index < line.length() && Character.isWhitespace(line.charAt(index))) { + index++; + } + return index; + } + + private static FlowDescription.Parameter parseParameterLine(String content) { + int nameEnd = content.indexOf(':'); + if (nameEnd == -1) { + return null; + } + + int typeEnd = content.indexOf(',', nameEnd); + if (typeEnd == -1) { + return null; + } + + int requiredEnd = content.indexOf(',', typeEnd + 1); + if (requiredEnd == -1) { + requiredEnd = content.length(); + } + + var name = content.substring(0, nameEnd).trim(); + var type = content.substring(nameEnd + 1, typeEnd).trim(); + + var requiredString = content.substring(typeEnd + 1, requiredEnd).trim(); + var required = requiredToBoolean(requiredString); + String description = null; + if (requiredEnd < content.length()) { + description = content.substring(requiredEnd + 1).trim(); + if (description.isEmpty()) { + description = null; + } + } + + return new FlowDescription.Parameter(name, type, required, description); + } + + private static boolean requiredToBoolean(String str) { + return "mandatory".equalsIgnoreCase(str) || "required".equalsIgnoreCase(str); + } + + private FlowDescriptionParser() { + } +} diff --git a/tasks/docs/src/main/java/com/walmartlabs/concord/plugins/docs/MdBookGenerator.java b/tasks/docs/src/main/java/com/walmartlabs/concord/plugins/docs/MdBookGenerator.java new file mode 100644 index 00000000..c22341a4 --- /dev/null +++ b/tasks/docs/src/main/java/com/walmartlabs/concord/plugins/docs/MdBookGenerator.java @@ -0,0 +1,200 @@ +package com.walmartlabs.concord.plugins.docs; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart Inc., Concord Authors + * ----- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ===== + */ + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.*; + +import static com.walmartlabs.concord.plugins.docs.FlowDescriptionParser.FlowDescription; + +public final class MdBookGenerator { + + private static final Comparator SUMMARY_COMPARATOR = Comparator.comparing( + e -> Arrays.asList(e.title().split("/")), + (a, b) -> { + int len = Math.min(a.size(), b.size()); + for (int i = 0; i < len; i++) { + int cmp = a.get(i).compareTo(b.get(i)); + if (cmp != 0) { + return cmp; + } + } + return Integer.compare(a.size(), b.size()); + }); + + private record SummaryEntry (String title, String link) { + } + + public static void generate(String title, LinkedHashMap> flowDescriptionByFileName, String sourceBaseUrl, Path outputDir) throws IOException { + var outputSrcDir = outputDir.resolve("src"); + Files.createDirectories(outputSrcDir); + + var flowPagePath = new HashMap(); + for (var entry : flowDescriptionByFileName.entrySet()) { + var outFile = writeFlowsPage(entry.getKey(), entry.getValue(), sourceBaseUrl, outputSrcDir); + flowPagePath.put(entry.getKey(), outFile); + } + writeSummary(flowPagePath, outputSrcDir); + writeBook(title, outputDir); + } + + private static void writeSummary(Map filesToPaths, Path outputDir) throws IOException { + var sorted = filesToPaths.entrySet().stream() + .map(e -> { + var title = e.getKey(); + var link = "./" + outputDir.relativize(e.getValue().toAbsolutePath()); + return new SummaryEntry(title, link); + }) + .sorted(SUMMARY_COMPARATOR) + .toList(); + + var output = outputDir.resolve("SUMMARY.md"); + + try (var writer = Files.newBufferedWriter(output, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) { + writer.write("# Summary\n\n"); + + var unfoldedEntries = new ArrayList(); + String currentTopLevel = null; + String currentFolderPath = null; + for (var entry : sorted) { + var parts = entry.title().split("/"); + if (parts.length < 2) { + unfoldedEntries.add(entry); + continue; + } + + var topLevel = parts[0]; + var fileName = parts[parts.length - 1]; + var folderPath = String.join("/", Arrays.copyOfRange(parts, 1, parts.length - 1)); + + if (!topLevel.equals(currentTopLevel)) { + writer.write("\n# " + topLevel + "\n\n"); + currentTopLevel = topLevel; + currentFolderPath = null; + } + + if (!folderPath.equals(currentFolderPath)) { + writer.write("- [" + folderPath + "]()\n"); + currentFolderPath = folderPath; + } + + writer.write(" - [" + fileName + "](" + entry.link() + ")\n"); + } + + if (!unfoldedEntries.isEmpty()) { + if (unfoldedEntries.size() != filesToPaths.size()) { + writer.write("---\n"); + } + + for (var entry : unfoldedEntries) { + writer.write(" - [" + entry.title() + "](" + entry.link() + ")\n"); + } + } + } + } + + private static Path writeFlowsPage(String flowFileName, List flows, String sourceBaseUrl, Path outputDir) throws IOException { + var output = outputDir.resolve(flowFileName + ".md"); + Files.createDirectories(output.getParent()); + + var normalizedBaseUrl = normalizeUrl(sourceBaseUrl); + try (var writer = Files.newBufferedWriter(output, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) { + writer.write("# " + flowFileName + "\n\n"); + + for (var flow : flows) { + writer.write("## `" + flow.name() + "`\n\n"); + writer.write("**Description**: \n" + orNone(flow.description()) + "\n\n"); + + if (!flow.tags().isEmpty()) { + writer.write("**Tags**: `" + String.join("`, `", flow.tags()) + "`\n\n"); + } + + writeParamTable(writer, "Inputs", flow.in()); + writeParamTable(writer, "Outputs", flow.out()); + + if (sourceBaseUrl != null) { + var link = normalizedBaseUrl + flowFileName + "#L" + flow.lineNum(); + writer.write("View source code\n\n"); + } + + writer.write("---\n\n"); + } + } + return output.toAbsolutePath(); + } + + private static String normalizeUrl(String sourceBaseUrl) { + if (sourceBaseUrl == null || sourceBaseUrl.endsWith("/")) { + return sourceBaseUrl; + } + + return sourceBaseUrl + "/"; + } + + private static void writeBook(String title, Path outputDir) throws IOException { + var output = outputDir.resolve("book.toml"); + try (var writer = Files.newBufferedWriter(output, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) { + writer.write("[book]\n"); + writer.write("authors = [\"concord-docs\"]\n"); + writer.write("language = \"en\"\n"); + writer.write("src = \"src\"\n"); + writer.write("title = \""+ title + "\"\n"); + } + } + + private static void writeParamTable(Appendable writer, String label, List params) throws IOException { + if (params == null || params.isEmpty()) { + writer.append("**").append(label).append("**: \n_None_\n\n"); + return; + } + + writer.append("**").append(label).append("**:\n\n"); + writer.append("| Name | Type | Required | Description |\n"); + writer.append("|------|------|----------|-------------|\n"); + + for (var p : params) { + writer.append(String.format("| `%s` | %s | %s | %s |\n", + p.name(), + p.type(), + p.required() ? "✓" : "", + escapeMd(p.description()))); + } + + writer.append("\n"); + } + + private static String orNone(String s) { + return (s == null || s.isBlank()) ? "_None_" : s; + } + + private static String escapeMd(String text) { + if (text == null) { + return ""; + } + + return text.replace("|", "\\|") + .replace("_", "\\_") + .replace("*", "\\*"); + } +} diff --git a/tasks/docs/src/test/java/com/walmartlabs/concord/plugins/docs/FlowDescriptionParserTest.java b/tasks/docs/src/test/java/com/walmartlabs/concord/plugins/docs/FlowDescriptionParserTest.java new file mode 100644 index 00000000..91d8ba90 --- /dev/null +++ b/tasks/docs/src/test/java/com/walmartlabs/concord/plugins/docs/FlowDescriptionParserTest.java @@ -0,0 +1,175 @@ +package com.walmartlabs.concord.plugins.docs; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart Inc., Concord Authors + * ----- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ===== + */ + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +public final class FlowDescriptionParserTest { + + @Test + void testParseFullCommentBlock() { + var lines = """ + flows: + ## + # Creates a compute instance + # + # in: + # label: string, mandatory, label for the instance + # hostname: string, optional, hostname of the instance + # out: + # result: string, mandatory, Some result + # tags: compute, infra + ## + createInstance: + """.lines().toList(); + + var result = FlowDescriptionParser.parse("createInstance", lines, lines.size() - 1); + + assertNotNull(result); + assertEquals("createInstance", result.name()); + assertEquals("Creates a compute instance", result.description()); + + assertEquals(List.of("compute", "infra"), result.tags()); + + assertEquals(2, result.in().size()); + + var param1 = result.in().get(0); + assertEquals("label", param1.name()); + assertEquals("string", param1.type()); + assertTrue(param1.required()); + assertEquals("label for the instance", param1.description()); + + var param2 = result.in().get(1); + assertEquals("hostname", param2.name()); + assertEquals("string", param2.type()); + assertFalse(param2.required()); + assertEquals("hostname of the instance", param2.description()); + + assertEquals(1, result.out().size()); + var oparam1 = result.out().get(0); + assertEquals("result", oparam1.name()); + assertEquals("string", oparam1.type()); + assertTrue(oparam1.required()); + assertEquals("Some result", oparam1.description()); + } + + @Test + void testMissingCommentBlockReturnsNull() { + List lines = List.of( + "flows:", + " createInstance:" + ); + + var result = FlowDescriptionParser.parse("createInstance", lines, 1); + assertNull(result); + } + + @Test + void testOnlyDescription() { + List lines = List.of( + "flows:", + " ##", + " # Just a description without inputs", + " ##", + " simpleFlow:" + ); + + var result = FlowDescriptionParser.parse("simpleFlow", lines, 4); + assertNotNull(result); + assertEquals("Just a description without inputs", result.description()); + assertTrue(result.in().isEmpty()); + assertTrue(result.out().isEmpty()); + assertTrue(result.tags().isEmpty()); + } + + @Test + void testParsingWithoutDescription() { + var lines = """ + flows: + # + # in: + # label: string, mandatory, label for the instance + # hostname: string, optional, hostname of the instance + # out: + # result: string, mandatory, Some result + # tags: compute, infra + createInstance: + """.lines().toList(); + + var result = FlowDescriptionParser.parse("createInstance", lines, 8); + + assertNotNull(result); + assertEquals("createInstance", result.name()); + assertNull(result.description()); + + assertEquals(2, result.in().size()); + assertEquals(1, result.out().size()); + assertEquals(2, result.tags().size()); + } + + @Test + void testParamParsingWithoutDescription() { + var lines = """ + flows: + # + # in: + # label: string, mandatory + # hostname: string, optional, + # out: + # result: string, mandatory, + # tags: compute, infra + createInstance: + """.lines().toList(); + + var result = FlowDescriptionParser.parse("createInstance", lines, 8); + + assertNotNull(result); + assertEquals("createInstance", result.name()); + assertNull(result.description()); + + assertEquals(2, result.in().size()); + + var param1 = result.in().get(0); + assertEquals("label", param1.name()); + assertEquals("string", param1.type()); + assertTrue(param1.required()); + assertNull(param1.description()); + + var param2 = result.in().get(1); + assertEquals("hostname", param2.name()); + assertEquals("string", param2.type()); + assertFalse(param2.required()); + assertNull(param2.description()); + + assertEquals(1, result.out().size()); + var oparam1 = result.out().get(0); + assertEquals("result", oparam1.name()); + assertEquals("string", oparam1.type()); + assertTrue(oparam1.required()); + assertNull(oparam1.description()); + + assertEquals(2, result.tags().size()); + } +} diff --git a/tasks/docs/src/test/resources/logback.xml b/tasks/docs/src/test/resources/logback.xml new file mode 100644 index 00000000..ceaaef4b --- /dev/null +++ b/tasks/docs/src/test/resources/logback.xml @@ -0,0 +1,13 @@ + + + + %d{HH:mm:ss.SSS} [%thread] [%-5level] %logger{36} - %msg%n + + + + + + + + +