diff --git a/cmd/wfctl/docs.go b/cmd/wfctl/docs.go index ad0b4578..22c3c8c3 100644 --- a/cmd/wfctl/docs.go +++ b/cmd/wfctl/docs.go @@ -1,6 +1,7 @@ package main import ( + "context" "flag" "fmt" "os" @@ -10,6 +11,7 @@ import ( "github.com/GoCodeAlone/workflow/config" "github.com/GoCodeAlone/workflow/plugin" + "github.com/GoCodeAlone/workflow/schema" "gopkg.in/yaml.v3" ) @@ -69,11 +71,48 @@ Options: } configFile := fs.Arg(0) - cfg, err := config.LoadFromFile(configFile) + + // Read the raw bytes first so we can detect whether this is an + // ApplicationConfig (application.workflows[]) or a plain WorkflowConfig. + rawData, err := os.ReadFile(configFile) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + // Use the same code path as the core engine (config.FileSource) to load + // and merge the configuration. This guarantees docs reflect exactly what + // the engine would see, including ApplicationConfig multi-file merging and + // its conflict detection (duplicate module/pipeline/trigger names). + cfg, err := config.NewFileSource(configFile).Load(context.Background()) if err != nil { return fmt.Errorf("failed to load config: %w", err) } + // For ApplicationConfig inputs, also capture the application metadata + // (name, embedded workflow list) used for the README title and table. + var appInfo *config.ApplicationInfo + if config.IsApplicationConfig(rawData) { + appCfg, appErr := config.LoadApplicationConfig(configFile) + if appErr != nil { + return fmt.Errorf("failed to load application config metadata: %w", appErr) + } + appInfo = &appCfg.Application + } + + // Validate the merged config before generating docs so structural problems + // (missing names/types, etc.) are surfaced early. Type checks are skipped + // because the docs tool does not require all module/workflow/trigger types + // to be registered. + if err := schema.ValidateConfig(cfg, + schema.WithAllowEmptyModules(), + schema.WithAllowNoEntryPoints(), + schema.WithSkipModuleTypeCheck(), + schema.WithSkipWorkflowTypeCheck(), + schema.WithSkipTriggerTypeCheck(), + ); err != nil { + return fmt.Errorf("config validation failed: %w", err) + } + // Load external plugin manifests if a plugin directory is specified. var plugins []*plugin.PluginManifest if *pluginDir != "" { @@ -85,7 +124,11 @@ Options: appTitle := *title if appTitle == "" { - appTitle = deriveTitle(configFile) + if appInfo != nil && appInfo.Name != "" { + appTitle = appInfo.Name + } else { + appTitle = deriveTitle(configFile) + } } if err := os.MkdirAll(*output, 0750); err != nil { @@ -94,6 +137,7 @@ Options: gen := &docsGenerator{ cfg: cfg, + appInfo: appInfo, plugins: plugins, title: appTitle, outputDir: *output, @@ -186,6 +230,7 @@ func mermaidID(s string) string { // docsGenerator holds state for a single documentation generation run. type docsGenerator struct { cfg *config.WorkflowConfig + appInfo *config.ApplicationInfo // non-nil when loaded from an ApplicationConfig plugins []*plugin.PluginManifest title string outputDir string @@ -255,6 +300,22 @@ func (g *docsGenerator) writeOverview(path string) error { fmt.Fprintf(&b, "# %s\n\n", g.title) b.WriteString("> Auto-generated documentation from workflow configuration.\n\n") + // When loaded from an ApplicationConfig, show the workflow sources. + if g.appInfo != nil && len(g.appInfo.Workflows) > 0 { + b.WriteString("## Application Workflows\n\n") + b.WriteString("This application is composed of the following workflow files:\n\n") + b.WriteString("| Name | File |\n") + b.WriteString("|------|------|\n") + for _, ref := range g.appInfo.Workflows { + name := ref.Name + if name == "" { + name = ref.File + } + fmt.Fprintf(&b, "| `%s` | `%s` |\n", name, ref.File) + } + b.WriteString("\n") + } + // Quick stats b.WriteString("## Overview\n\n") fmt.Fprintf(&b, "| Metric | Count |\n") diff --git a/cmd/wfctl/docs_test.go b/cmd/wfctl/docs_test.go index 8d71f687..6b4b22e0 100644 --- a/cmd/wfctl/docs_test.go +++ b/cmd/wfctl/docs_test.go @@ -69,6 +69,7 @@ modules: type: auth.jwt dependsOn: [api-router] config: + secret: "$JWT_SECRET" issuer: "https://auth.example.com" - name: order-handler type: http.handler @@ -788,3 +789,857 @@ func TestDocsCustomTitle(t *testing.T) { t.Error("README.md should use custom title") } } + +// --- ApplicationConfig (multi-workflow) tests --- + +// docsAPIWorkflowConfig is a workflow config for the "api" service. +const docsAPIWorkflowConfig = ` +modules: + - name: api-server + type: http.server + config: + address: ":8080" + - name: api-router + type: http.router + dependsOn: [api-server] + - name: auth-middleware + type: auth.jwt + dependsOn: [api-router] + config: + secret: "$JWT_SECRET" + issuer: "https://auth.example.com" + - name: user-handler + type: http.handler + dependsOn: [api-router] + config: + contentType: application/json + +workflows: + http: + routes: + - method: GET + path: /api/users + handler: user-handler + middlewares: + - auth-middleware + - method: POST + path: /api/users + handler: user-handler + +triggers: + http: + server: api-server + +pipelines: + create-user: + trigger: + type: http + config: + path: /api/users + method: POST + steps: + - name: validate-input + type: step.validate + config: + strategy: required_fields + required_fields: + - username + - email + - name: save-user + type: step.http_call + config: + url: "http://user-store/save" + - name: respond + type: step.json_response + config: + status: 201 + timeout: 30s +` + +// docsJobsWorkflowConfig is a workflow config for the "jobs" service. +const docsJobsWorkflowConfig = ` +modules: + - name: job-broker + type: messaging.broker + - name: job-processor + type: messaging.handler + dependsOn: [job-broker] + - name: job-state + type: statemachine.engine + dependsOn: [job-broker] + +workflows: + messaging: + subscriptions: + - topic: job.submitted + handler: job-processor + - topic: job.completed + handler: job-processor + producers: + - name: job-processor + forwardTo: + - job.started + - job.completed + + statemachine: + engine: job-state + definitions: + - name: job-lifecycle + description: "Manages job state transitions" + initialState: submitted + states: + submitted: + description: "Job submitted" + isFinal: false + isError: false + running: + description: "Job running" + isFinal: false + isError: false + completed: + description: "Job completed" + isFinal: true + isError: false + failed: + description: "Job failed" + isFinal: true + isError: true + transitions: + start: + fromState: submitted + toState: running + complete: + fromState: running + toState: completed + fail: + fromState: running + toState: failed + +pipelines: + process-job: + trigger: + type: messaging + config: + topic: job.submitted + steps: + - name: validate-job + type: step.validate + config: + strategy: required_fields + required_fields: + - job_id + - payload + - name: run-job + type: step.http_call + config: + url: "http://job-runner/execute" + - name: notify + type: step.log + config: + level: info + message: "Job processed" + compensation: + - name: requeue-job + type: step.http_call + config: + url: "http://job-runner/requeue" + timeout: 120s +` + +// writeTempWorkflowFile writes a named workflow YAML file into dir and returns its path. +func writeTempWorkflowFile(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0640); err != nil { + t.Fatalf("failed to write workflow file %s: %v", name, err) + } + return path +} + +// appConfigFixture holds paths set up by buildAppConfigFixture. +type appConfigFixture struct { + appCfgPath string + outDir string +} + +// buildAppConfigFixture creates a temp directory tree with the chimera-platform +// ApplicationConfig fixture (api + jobs workflow files) and returns the paths +// needed by individual test cases. +func buildAppConfigFixture(t *testing.T) appConfigFixture { + t.Helper() + dir := t.TempDir() + + apiDir := filepath.Join(dir, "api") + jobsDir := filepath.Join(dir, "jobs") + if err := os.MkdirAll(apiDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(jobsDir, 0750); err != nil { + t.Fatal(err) + } + writeTempWorkflowFile(t, apiDir, "api.yaml", docsAPIWorkflowConfig) + writeTempWorkflowFile(t, jobsDir, "application.yaml", docsJobsWorkflowConfig) + + const appConfig = ` +application: + name: chimera-platform + workflows: + - file: ./api/api.yaml + name: api + - file: ./jobs/application.yaml + name: jobs +` + appCfgPath := filepath.Join(dir, "app.yaml") + if err := os.WriteFile(appCfgPath, []byte(appConfig), 0640); err != nil { + t.Fatal(err) + } + + return appConfigFixture{ + appCfgPath: appCfgPath, + outDir: filepath.Join(dir, "docs"), + } +} + +// TestDocsApplicationConfig verifies that docs can be generated from an +// ApplicationConfig that embeds multiple workflow YAML files, and that all +// content from those files appears in the generated documentation. +func TestDocsApplicationConfig(t *testing.T) { + f := buildAppConfigFixture(t) + + if err := runDocsGenerate([]string{"-output", f.outDir, f.appCfgPath}); err != nil { + t.Fatalf("docs generate failed for ApplicationConfig: %v", err) + } + + // All main doc files should be created + for _, name := range []string{"README.md", "modules.md", "pipelines.md", "workflows.md", "architecture.md"} { + path := filepath.Join(f.outDir, name) + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("expected %s to be created for ApplicationConfig", name) + } + } +} + +// TestDocsApplicationConfigReadme checks that the README lists the application +// name and the embedded workflow files. +func TestDocsApplicationConfigReadme(t *testing.T) { + f := buildAppConfigFixture(t) + + if err := runDocsGenerate([]string{"-output", f.outDir, f.appCfgPath}); err != nil { + t.Fatalf("docs generate failed: %v", err) + } + + data, err := os.ReadFile(filepath.Join(f.outDir, "README.md")) + if err != nil { + t.Fatalf("failed to read README.md: %v", err) + } + content := string(data) + + // Title should be the application name + if !strings.Contains(content, "# chimera-platform") { + t.Error("README.md should use application name as title") + } + // Should list workflow sources + if !strings.Contains(content, "Application Workflows") { + t.Error("README.md should have Application Workflows section") + } + // Check for specific table rows matching the markdown format: | `name` | `file` | + if !strings.Contains(content, "| `api` |") { + t.Error("README.md should have a table row with name 'api'") + } + if !strings.Contains(content, "| `jobs` |") { + t.Error("README.md should have a table row with name 'jobs'") + } + if !strings.Contains(content, "| `./api/api.yaml` |") { + t.Error("README.md should list the api workflow file path in the table") + } + if !strings.Contains(content, "| `./jobs/application.yaml` |") { + t.Error("README.md should list the jobs workflow file path in the table") + } +} + +// TestDocsApplicationConfigModules checks that modules from all embedded +// workflow files appear in modules.md. +func TestDocsApplicationConfigModules(t *testing.T) { + f := buildAppConfigFixture(t) + + if err := runDocsGenerate([]string{"-output", f.outDir, f.appCfgPath}); err != nil { + t.Fatalf("docs generate failed: %v", err) + } + + data, err := os.ReadFile(filepath.Join(f.outDir, "modules.md")) + if err != nil { + t.Fatalf("failed to read modules.md: %v", err) + } + content := string(data) + + // Modules from api workflow + for _, mod := range []string{"api-server", "api-router", "auth-middleware", "user-handler"} { + if !strings.Contains(content, mod) { + t.Errorf("modules.md should contain module %q from api workflow", mod) + } + } + // Modules from jobs workflow + for _, mod := range []string{"job-broker", "job-processor", "job-state"} { + if !strings.Contains(content, mod) { + t.Errorf("modules.md should contain module %q from jobs workflow", mod) + } + } +} + +// TestDocsApplicationConfigWorkflows checks that workflows from all embedded +// files appear in workflows.md. +func TestDocsApplicationConfigWorkflows(t *testing.T) { + f := buildAppConfigFixture(t) + + if err := runDocsGenerate([]string{"-output", f.outDir, f.appCfgPath}); err != nil { + t.Fatalf("docs generate failed: %v", err) + } + + data, err := os.ReadFile(filepath.Join(f.outDir, "workflows.md")) + if err != nil { + t.Fatalf("failed to read workflows.md: %v", err) + } + content := string(data) + + // HTTP workflow from api + if !strings.Contains(content, "/api/users") { + t.Error("workflows.md should contain HTTP routes from api workflow") + } + if !strings.Contains(content, "auth-middleware") { + t.Error("workflows.md should show middlewares from api workflow") + } + + // Messaging workflow from jobs + if !strings.Contains(content, "job.submitted") { + t.Error("workflows.md should contain messaging topics from jobs workflow") + } + + // State machine from jobs + if !strings.Contains(content, "stateDiagram-v2") { + t.Error("workflows.md should contain state machine diagram from jobs workflow") + } + if !strings.Contains(content, "submitted") { + t.Error("workflows.md should show state machine states from jobs workflow") + } +} + +// TestDocsApplicationConfigPipelines checks that pipelines from all embedded +// files appear in pipelines.md. +func TestDocsApplicationConfigPipelines(t *testing.T) { + f := buildAppConfigFixture(t) + + if err := runDocsGenerate([]string{"-output", f.outDir, f.appCfgPath}); err != nil { + t.Fatalf("docs generate failed: %v", err) + } + + data, err := os.ReadFile(filepath.Join(f.outDir, "pipelines.md")) + if err != nil { + t.Fatalf("failed to read pipelines.md: %v", err) + } + content := string(data) + + // Pipeline from api workflow + if !strings.Contains(content, "create-user") { + t.Error("pipelines.md should contain create-user pipeline from api workflow") + } + if !strings.Contains(content, "validate-input") { + t.Error("pipelines.md should list steps from api workflow pipeline") + } + + // Pipeline from jobs workflow + if !strings.Contains(content, "process-job") { + t.Error("pipelines.md should contain process-job pipeline from jobs workflow") + } + if !strings.Contains(content, "Compensation") { + t.Error("pipelines.md should document compensation steps from jobs workflow pipeline") + } +} + +// TestDocsApplicationConfigTitleOverride verifies that -title flag overrides +// the application name when both are present. +func TestDocsApplicationConfigTitleOverride(t *testing.T) { + dir := t.TempDir() + + apiDir := filepath.Join(dir, "api") + if err := os.MkdirAll(apiDir, 0750); err != nil { + t.Fatal(err) + } + writeTempWorkflowFile(t, apiDir, "api.yaml", docsAPIWorkflowConfig) + + const appConfig = ` +application: + name: chimera-platform + workflows: + - file: ./api/api.yaml + name: api +` + appCfgPath := filepath.Join(dir, "app.yaml") + if err := os.WriteFile(appCfgPath, []byte(appConfig), 0640); err != nil { + t.Fatal(err) + } + + outDir := filepath.Join(dir, "docs") + if err := runDocsGenerate([]string{"-output", outDir, "-title", "Override Title", appCfgPath}); err != nil { + t.Fatalf("docs generate failed: %v", err) + } + + data, err := os.ReadFile(filepath.Join(outDir, "README.md")) + if err != nil { + t.Fatalf("failed to read README.md: %v", err) + } + if !strings.Contains(string(data), "# Override Title") { + t.Error("README.md should use the -title flag value when specified") + } + if strings.Contains(string(data), "# chimera-platform") { + t.Error("README.md should NOT use the application name when -title is specified") + } +} + +// TestDocsApplicationConfigDuplicateWorkflowKey verifies that when two embedded +// workflow files both define the same workflow key (e.g. both have workflows.http), +// their list-bearing fields (routes, subscriptions, producers, definitions) are +// merged/appended rather than the second file silently overwriting the first. +func TestDocsApplicationConfigDuplicateWorkflowKey(t *testing.T) { + dir := t.TempDir() + + // Two workflow files that both contribute http routes + const apiV1Config = ` +modules: + - name: api-server + type: http.server + config: + address: ":8080" + - name: api-router + type: http.router + dependsOn: [api-server] + - name: v1-handler + type: http.handler + dependsOn: [api-router] + +workflows: + http: + routes: + - method: GET + path: /v1/resource + handler: v1-handler + +triggers: + http: + server: api-server +` + + const apiV2Config = ` +modules: + - name: v2-handler + type: http.handler + +workflows: + http: + routes: + - method: GET + path: /v2/resource + handler: v2-handler +` + + v1Dir := filepath.Join(dir, "v1") + v2Dir := filepath.Join(dir, "v2") + if err := os.MkdirAll(v1Dir, 0750); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(v2Dir, 0750); err != nil { + t.Fatal(err) + } + writeTempWorkflowFile(t, v1Dir, "v1.yaml", apiV1Config) + writeTempWorkflowFile(t, v2Dir, "v2.yaml", apiV2Config) + + const appConfig = ` +application: + name: multi-version-api + workflows: + - file: ./v1/v1.yaml + name: v1 + - file: ./v2/v2.yaml + name: v2 +` + appCfgPath := filepath.Join(dir, "app.yaml") + if err := os.WriteFile(appCfgPath, []byte(appConfig), 0640); err != nil { + t.Fatal(err) + } + + outDir := filepath.Join(dir, "docs") + if err := runDocsGenerate([]string{"-output", outDir, appCfgPath}); err != nil { + t.Fatalf("docs generate failed: %v", err) + } + + data, err := os.ReadFile(filepath.Join(outDir, "workflows.md")) + if err != nil { + t.Fatalf("failed to read workflows.md: %v", err) + } + content := string(data) + + // Routes from BOTH workflow files must be present + if !strings.Contains(content, "/v1/resource") { + t.Error("workflows.md should contain /v1/resource route from v1 workflow file") + } + if !strings.Contains(content, "/v2/resource") { + t.Error("workflows.md should contain /v2/resource route from v2 workflow file (deep merge)") + } +} + +// TestDocsApplicationConfigDuplicateModuleName verifies that when two embedded +// workflow files define a module with the same name, docs generation fails with +// a clear error (matching engine behaviour: module name conflicts are fatal). +func TestDocsApplicationConfigDuplicateModuleName(t *testing.T) { + dir := t.TempDir() + + const file1 = ` +modules: + - name: shared-server + type: http.server + config: + address: ":8080" +workflows: + http: + routes: + - method: GET + path: /v1/ping + handler: shared-server +` + const file2 = ` +modules: + - name: shared-server + type: http.server + config: + address: ":9090" +` + d1 := filepath.Join(dir, "svc1") + d2 := filepath.Join(dir, "svc2") + if err := os.MkdirAll(d1, 0750); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(d2, 0750); err != nil { + t.Fatal(err) + } + writeTempWorkflowFile(t, d1, "svc1.yaml", file1) + writeTempWorkflowFile(t, d2, "svc2.yaml", file2) + + const appCfg = ` +application: + name: conflict-test + workflows: + - file: ./svc1/svc1.yaml + name: svc1 + - file: ./svc2/svc2.yaml + name: svc2 +` + appCfgPath := filepath.Join(dir, "app.yaml") + if err := os.WriteFile(appCfgPath, []byte(appCfg), 0640); err != nil { + t.Fatal(err) + } + + outDir := filepath.Join(dir, "docs") + err := runDocsGenerate([]string{"-output", outDir, appCfgPath}) + if err == nil { + t.Fatal("expected error for duplicate module name, got nil") + } + if !strings.Contains(err.Error(), "shared-server") { + t.Errorf("error should mention conflicting module name, got: %v", err) + } +} + +// TestDocsApplicationConfigDuplicatePipelineName verifies that when two embedded +// workflow files define a pipeline with the same name, docs generation fails +// (matching engine behaviour: pipeline name conflicts are fatal). +func TestDocsApplicationConfigDuplicatePipelineName(t *testing.T) { + dir := t.TempDir() + + const file1 = ` +modules: + - name: handler-a + type: http.handler +pipelines: + process-request: + steps: + - name: step1 + type: step.log + config: + message: "from svc-a" +` + const file2 = ` +modules: + - name: handler-b + type: http.handler +pipelines: + process-request: + steps: + - name: step1 + type: step.log + config: + message: "from svc-b" +` + d1 := filepath.Join(dir, "svc1") + d2 := filepath.Join(dir, "svc2") + if err := os.MkdirAll(d1, 0750); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(d2, 0750); err != nil { + t.Fatal(err) + } + writeTempWorkflowFile(t, d1, "svc1.yaml", file1) + writeTempWorkflowFile(t, d2, "svc2.yaml", file2) + + const appCfg = ` +application: + name: pipeline-conflict-test + workflows: + - file: ./svc1/svc1.yaml + name: svc1 + - file: ./svc2/svc2.yaml + name: svc2 +` + appCfgPath := filepath.Join(dir, "app.yaml") + if err := os.WriteFile(appCfgPath, []byte(appCfg), 0640); err != nil { + t.Fatal(err) + } + + outDir := filepath.Join(dir, "docs") + err := runDocsGenerate([]string{"-output", outDir, appCfgPath}) + if err == nil { + t.Fatal("expected error for duplicate pipeline name, got nil") + } + if !strings.Contains(err.Error(), "process-request") { + t.Errorf("error should mention conflicting pipeline name, got: %v", err) + } +} + +// TestDocsApplicationConfigNullWorkflowSection verifies that when the first +// embedded workflow file defines a workflow key with a null value (e.g. +// `http:` with no body), the second file's non-null value is used rather than +// being silently dropped. +func TestDocsApplicationConfigNullWorkflowSection(t *testing.T) { + dir := t.TempDir() + + // First file: declares the http workflow key with no body (null) + const file1 = ` +modules: + - name: api-server + type: http.server + config: + address: ":8080" + - name: api-router + type: http.router + dependsOn: [api-server] +workflows: + http: +triggers: + http: + server: api-server +` + // Second file: adds concrete routes under the same http key + const file2 = ` +modules: + - name: ping-handler + type: http.handler + dependsOn: [api-router] +workflows: + http: + routes: + - method: GET + path: /ping + handler: ping-handler +` + d1 := filepath.Join(dir, "base") + d2 := filepath.Join(dir, "routes") + if err := os.MkdirAll(d1, 0750); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(d2, 0750); err != nil { + t.Fatal(err) + } + writeTempWorkflowFile(t, d1, "base.yaml", file1) + writeTempWorkflowFile(t, d2, "routes.yaml", file2) + + const appCfg = ` +application: + name: null-section-test + workflows: + - file: ./base/base.yaml + name: base + - file: ./routes/routes.yaml + name: routes +` + appCfgPath := filepath.Join(dir, "app.yaml") + if err := os.WriteFile(appCfgPath, []byte(appCfg), 0640); err != nil { + t.Fatal(err) + } + + outDir := filepath.Join(dir, "docs") + if err := runDocsGenerate([]string{"-output", outDir, appCfgPath}); err != nil { + t.Fatalf("docs generate failed: %v", err) + } + + data, err := os.ReadFile(filepath.Join(outDir, "workflows.md")) + if err != nil { + t.Fatalf("failed to read workflows.md: %v", err) + } + content := string(data) + + // Routes from the second file must be present even though first file + // had a null http workflow section + if !strings.Contains(content, "/ping") { + t.Error("workflows.md should contain /ping route from second workflow file (null section override)") + } +} + +// TestDocsApplicationConfigThreeFiles verifies that a more complex application +// with three embedded workflow files is fully merged and documented correctly. +func TestDocsApplicationConfigThreeFiles(t *testing.T) { + dir := t.TempDir() + + const apiConfig = ` +modules: + - name: api-server + type: http.server + config: + address: ":8080" + - name: api-router + type: http.router + dependsOn: [api-server] + - name: users-handler + type: http.handler + dependsOn: [api-router] + - name: orders-handler + type: http.handler + dependsOn: [api-router] +workflows: + http: + routes: + - method: GET + path: /users + handler: users-handler + - method: GET + path: /orders + handler: orders-handler +triggers: + http: + server: api-server +` + const eventsConfig = ` +modules: + - name: event-broker + type: messaging.broker + - name: order-event-handler + type: messaging.handler + dependsOn: [event-broker] +workflows: + messaging: + subscriptions: + - topic: order.created + handler: order-event-handler +` + const jobsConfig = ` +modules: + - name: scheduler + type: scheduler +pipelines: + daily-report: + steps: + - name: generate-report + type: step.log + config: + message: "generating daily report" +workflows: + scheduler: + jobs: + - name: daily-report-job + schedule: "0 8 * * *" + pipeline: daily-report +` + d1 := filepath.Join(dir, "api") + d2 := filepath.Join(dir, "events") + d3 := filepath.Join(dir, "jobs") + for _, d := range []string{d1, d2, d3} { + if err := os.MkdirAll(d, 0750); err != nil { + t.Fatal(err) + } + } + writeTempWorkflowFile(t, d1, "api.yaml", apiConfig) + writeTempWorkflowFile(t, d2, "events.yaml", eventsConfig) + writeTempWorkflowFile(t, d3, "jobs.yaml", jobsConfig) + + const appCfg = ` +application: + name: three-file-platform + workflows: + - file: ./api/api.yaml + name: api + - file: ./events/events.yaml + name: events + - file: ./jobs/jobs.yaml + name: jobs +` + appCfgPath := filepath.Join(dir, "app.yaml") + if err := os.WriteFile(appCfgPath, []byte(appCfg), 0640); err != nil { + t.Fatal(err) + } + + outDir := filepath.Join(dir, "docs") + if err := runDocsGenerate([]string{"-output", outDir, appCfgPath}); err != nil { + t.Fatalf("docs generate failed: %v", err) + } + + // All doc files should exist + for _, name := range []string{"README.md", "modules.md", "workflows.md", "pipelines.md", "architecture.md"} { + if _, statErr := os.Stat(filepath.Join(outDir, name)); os.IsNotExist(statErr) { + t.Errorf("expected %s to be generated for 3-file ApplicationConfig", name) + } + } + + // README should list all three workflow files + readmeData, err := os.ReadFile(filepath.Join(outDir, "README.md")) + if err != nil { + t.Fatalf("failed to read README.md: %v", err) + } + readme := string(readmeData) + for _, row := range []string{"| `api` |", "| `events` |", "| `jobs` |"} { + if !strings.Contains(readme, row) { + t.Errorf("README.md missing table row %q", row) + } + } + if !strings.Contains(readme, "# three-file-platform") { + t.Error("README.md should use application name as title") + } + + // modules.md should cover modules from all three files + modulesData, err := os.ReadFile(filepath.Join(outDir, "modules.md")) + if err != nil { + t.Fatalf("failed to read modules.md: %v", err) + } + modules := string(modulesData) + for _, mod := range []string{"api-server", "api-router", "event-broker", "scheduler"} { + if !strings.Contains(modules, mod) { + t.Errorf("modules.md should contain %q", mod) + } + } + + // workflows.md should contain routes, subscriptions, and scheduler section + wfData, err := os.ReadFile(filepath.Join(outDir, "workflows.md")) + if err != nil { + t.Fatalf("failed to read workflows.md: %v", err) + } + wf := string(wfData) + for _, expect := range []string{"/users", "/orders", "order.created"} { + if !strings.Contains(wf, expect) { + t.Errorf("workflows.md should contain %q", expect) + } + } + + // pipelines.md should include the daily-report pipeline + pipData, err := os.ReadFile(filepath.Join(outDir, "pipelines.md")) + if err != nil { + t.Fatalf("failed to read pipelines.md: %v", err) + } + if !strings.Contains(string(pipData), "daily-report") { + t.Error("pipelines.md should contain the daily-report pipeline from the jobs file") + } +} diff --git a/config/config.go b/config/config.go index d91cdea8..69af488f 100644 --- a/config/config.go +++ b/config/config.go @@ -379,6 +379,22 @@ func MergeApplicationConfig(appCfg *ApplicationConfig) (*WorkflowConfig, error) combined.Modules = append(combined.Modules, wfCfg.Modules...) for k, v := range wfCfg.Workflows { + if existing, exists := combined.Workflows[k]; exists { + // If the existing value is nil (e.g. `http:` with no body in YAML), + // treat it as absent and use the incoming value so data is not lost. + if existing == nil { + combined.Workflows[k] = v + continue + } + dstMap, dstOk := existing.(map[string]any) + srcMap, srcOk := v.(map[string]any) + if dstOk && srcOk { + mergeWorkflowSection(dstMap, srcMap) + continue + } + // Cannot deep-merge (not both maps) — first definition wins. + continue + } combined.Workflows[k] = v } for k, v := range wfCfg.Triggers { @@ -397,6 +413,42 @@ func MergeApplicationConfig(appCfg *ApplicationConfig) (*WorkflowConfig, error) return combined, nil } +// mergeWorkflowSection merges src workflow section fields into dst in-place. +// For known list-bearing keys (routes, subscriptions, producers, definitions), +// the source list is appended to the destination list so that routes/topics from +// multiple workflow files are all preserved. For all other keys, the first +// definition wins (dst is left unchanged). +func mergeWorkflowSection(dst, src map[string]any) { + listKeys := map[string]bool{ + "routes": true, + "subscriptions": true, + "producers": true, + "definitions": true, + } + for k, srcVal := range src { + if listKeys[k] { + srcList, ok := srcVal.([]any) + if !ok { + continue + } + switch existing := dst[k].(type) { + case []any: + // Append src items to the existing list. + dst[k] = append(existing, srcList...) + case nil: + // Key absent or explicitly null — use the src list. + dst[k] = srcVal + default: + // Key exists with a non-list value (unexpected YAML shape). + // Treat it as empty and replace so src items are not lost. + dst[k] = srcVal + } + } else if _, exists := dst[k]; !exists { + dst[k] = srcVal + } + } +} + // NewEmptyWorkflowConfig creates a new empty workflow configuration func NewEmptyWorkflowConfig() *WorkflowConfig { return &WorkflowConfig{ diff --git a/config/merge_test.go b/config/merge_test.go index d4494a2e..91fda4a1 100644 --- a/config/merge_test.go +++ b/config/merge_test.go @@ -1,7 +1,9 @@ package config import ( + "os" "reflect" + "strings" "testing" ) @@ -268,6 +270,76 @@ func TestDeepMergeConfigs_RequiresOverride(t *testing.T) { } } +func TestMergeWorkflowSection_NilDestinationList(t *testing.T) { + // When dst[k] is nil (e.g. `routes:` with no value in YAML), the src list + // should be used rather than being silently dropped. + dst := map[string]any{ + "routes": nil, + } + src := map[string]any{ + "routes": []any{ + map[string]any{"method": "GET", "path": "/v2/resource"}, + }, + } + mergeWorkflowSection(dst, src) + + routes, ok := dst["routes"].([]any) + if !ok { + t.Fatal("expected dst[routes] to be []any after merge") + } + if len(routes) != 1 { + t.Fatalf("expected 1 route, got %d", len(routes)) + } + route, _ := routes[0].(map[string]any) + if route["path"] != "/v2/resource" { + t.Errorf("expected path=/v2/resource, got %v", route["path"]) + } +} + +func TestMergeWorkflowSection_NonSliceDestinationReplaced(t *testing.T) { + // When dst[k] exists but holds a non-slice value (unexpected YAML shape), + // the src list should replace it so items are not lost. + dst := map[string]any{ + "routes": "not-a-list", + } + src := map[string]any{ + "routes": []any{ + map[string]any{"method": "POST", "path": "/v2/submit"}, + }, + } + mergeWorkflowSection(dst, src) + + routes, ok := dst["routes"].([]any) + if !ok { + t.Fatal("expected dst[routes] to be []any after merge") + } + if len(routes) != 1 { + t.Fatalf("expected 1 route, got %d", len(routes)) + } +} + +func TestMergeWorkflowSection_AppendsToExistingList(t *testing.T) { + dst := map[string]any{ + "routes": []any{ + map[string]any{"method": "GET", "path": "/v1/resource"}, + }, + } + src := map[string]any{ + "routes": []any{ + map[string]any{"method": "GET", "path": "/v2/resource"}, + }, + } + mergeWorkflowSection(dst, src) + + routes, ok := dst["routes"].([]any) + if !ok { + t.Fatal("expected dst[routes] to be []any after merge") + } + if len(routes) != 2 { + t.Fatalf("expected 2 routes, got %d", len(routes)) + } +} + func TestDeepMergeConfigs_ModuleTypeOverride(t *testing.T) { base := &WorkflowConfig{ Modules: []ModuleConfig{{Name: "svc", Type: "old-type"}}, @@ -281,3 +353,183 @@ func TestDeepMergeConfigs_ModuleTypeOverride(t *testing.T) { t.Errorf("expected type=new-type from override, got %q", result.Modules[0].Type) } } + +func TestMergeApplicationConfig_NilWorkflowKeyReplacedByLaterFile(t *testing.T) { + // If the first workflow file defines a workflow key with a null body (e.g. + // `http:` with no content), the second file's non-null value should be used + // rather than being silently dropped by the "first-definition-wins" logic. + dir := t.TempDir() + + const file1 = ` +modules: + - name: server + type: http.server + config: + address: ":8080" +workflows: + http: +triggers: + http: + server: server +` + const file2 = ` +modules: + - name: ping-handler + type: http.handler +workflows: + http: + routes: + - method: GET + path: /ping + handler: ping-handler +` + writeFile := func(name, content string) string { + path := dir + "/" + name + if err := writeFileContent(path, content); err != nil { + t.Fatalf("write %s: %v", name, err) + } + return path + } + file1Path := writeFile("base.yaml", file1) + file2Path := writeFile("routes.yaml", file2) + _ = file1Path + + appCfg := &ApplicationConfig{ + ConfigDir: dir, + Application: ApplicationInfo{ + Name: "null-key-test", + Workflows: []WorkflowRef{ + {File: "base.yaml", Name: "base"}, + {File: "routes.yaml", Name: "routes"}, + }, + }, + } + _ = file2Path + + combined, err := MergeApplicationConfig(appCfg) + if err != nil { + t.Fatalf("MergeApplicationConfig failed: %v", err) + } + + httpSection, ok := combined.Workflows["http"] + if !ok { + t.Fatal("expected 'http' key in merged Workflows") + } + if httpSection == nil { + t.Fatal("expected 'http' to be non-nil after merge with null first file") + } + httpMap, ok := httpSection.(map[string]any) + if !ok { + t.Fatalf("expected 'http' to be map[string]any, got %T", httpSection) + } + routes, ok := httpMap["routes"].([]any) + if !ok || len(routes) == 0 { + t.Error("expected routes from second file to be present after null-key merge") + } +} + +func TestMergeApplicationConfig_DuplicateModuleNameReturnsError(t *testing.T) { + dir := t.TempDir() + + const file1 = ` +modules: + - name: shared-module + type: http.server +` + const file2 = ` +modules: + - name: shared-module + type: http.handler +` + if err := writeFileContent(dir+"/svc1.yaml", file1); err != nil { + t.Fatal(err) + } + if err := writeFileContent(dir+"/svc2.yaml", file2); err != nil { + t.Fatal(err) + } + + appCfg := &ApplicationConfig{ + ConfigDir: dir, + Application: ApplicationInfo{ + Name: "conflict-test", + Workflows: []WorkflowRef{ + {File: "svc1.yaml", Name: "svc1"}, + {File: "svc2.yaml", Name: "svc2"}, + }, + }, + } + + _, err := MergeApplicationConfig(appCfg) + if err == nil { + t.Fatal("expected error for duplicate module name, got nil") + } + if !contains(err.Error(), "shared-module") { + t.Errorf("error should mention conflicting module name, got: %v", err) + } +} + +func TestMergeApplicationConfig_DuplicatePipelineNameReturnsError(t *testing.T) { + dir := t.TempDir() + + const file1 = ` +modules: + - name: handler-a + type: http.handler +pipelines: + my-pipeline: + steps: + - name: step1 + type: step.log +` + const file2 = ` +modules: + - name: handler-b + type: http.handler +pipelines: + my-pipeline: + steps: + - name: step1 + type: step.log +` + if err := writeFileContent(dir+"/svc1.yaml", file1); err != nil { + t.Fatal(err) + } + if err := writeFileContent(dir+"/svc2.yaml", file2); err != nil { + t.Fatal(err) + } + + appCfg := &ApplicationConfig{ + ConfigDir: dir, + Application: ApplicationInfo{ + Name: "pipeline-conflict", + Workflows: []WorkflowRef{ + {File: "svc1.yaml", Name: "svc1"}, + {File: "svc2.yaml", Name: "svc2"}, + }, + }, + } + + _, err := MergeApplicationConfig(appCfg) + if err == nil { + t.Fatal("expected error for duplicate pipeline name, got nil") + } + if !contains(err.Error(), "my-pipeline") { + t.Errorf("error should mention conflicting pipeline name, got: %v", err) + } +} + +// writeFileContent writes content to path (helper for MergeApplicationConfig tests). +func writeFileContent(path, content string) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + _, err = f.WriteString(content) + return err +} + +// contains is a helper wrapping strings.Contains for use in this test file. +func contains(s, substr string) bool { + return strings.Contains(s, substr) +}