diff --git a/README.md b/README.md index aac1f2b0..04513486 100644 --- a/README.md +++ b/README.md @@ -73,23 +73,24 @@ Exit code is non-zero if any errors are present, making it suitable for CI/CD pi ### Rendering -Render a SysML v2 workspace to SVG or PNG: +Render a SysML v2 workspace to SVG or PNG. `--output` names an output *directory* +(default: current directory); `--format` selects `svg` (default) or `png`: ```bash # Render to SVG (auto-selects the single view in the workspace) -sysml2tools render model.sysml --output diagram.svg +sysml2tools render model.sysml --output out --format svg # Render to PNG -sysml2tools render model.sysml --output diagram.png +sysml2tools render model.sysml --output out --format png # Render a named view from a multi-view workspace -sysml2tools render "src/**/*.sysml" --view SystemContext --output context.svg +sysml2tools render "src/**/*.sysml" --view SystemContext --output out --format svg # Auto-render the top-level part def when no view is defined -sysml2tools render model.sysml --auto --output diagram.svg +sysml2tools render model.sysml --auto --output out --format svg # Limit nesting depth (truncated parts show "+N more…") -sysml2tools render model.sysml --output diagram.svg --depth 3 +sysml2tools render model.sysml --output out --depth 3 ``` ### Querying @@ -160,8 +161,8 @@ sysml2tools [-v|--version] [-?|-h|--help] [--silent] sysml2tools help [lint|render|query []] ``` -`` is `lint`, `render`, or `query ` (11 query verbs — see -[Querying](#querying)). +`` is `lint`, `render`, or `query ` (11 query verbs — see the *Querying* +section above). ### Global Options @@ -186,8 +187,9 @@ sysml2tools help [lint|render|query []] | Option | Description | | --- | --- | | `` | One or more glob patterns for `.sysml` input files | -| `--output ` | Output file path; extension determines format (`.svg` or `.png`) | -| `--view ` | Name of the view to render (required when workspace has multiple views) | +| `--output ` | Output directory for rendered files (default: current directory) | +| `--format svg\|png` | Renderer format (default: `svg`) | +| `--view ` | Name of the view to render; omit to render every declared view (default) | | `--auto` | Auto-render the BDD of the top-level `part def` when no view is defined | | `--depth <#>` | Limit rendered nesting depth; truncated parts show `+N more…` | @@ -195,7 +197,7 @@ sysml2tools help [lint|render|query []] | Option | Description | | --- | --- | -| `` | One of the 11 supported query verbs — see [Querying](#querying) | +| `` | One of the 11 supported query verbs — see the *Querying* section above | | `` | One or more glob patterns for `.sysml` input files | | `--element `, `-e ` | Qualified name of the target element; required for every verb except `list`/`find` | | `--format markdown\|json` | Output format (default: `markdown`); distinct from `render`'s `--format` (`svg`/`png`) | @@ -218,9 +220,10 @@ sysml2tools help [lint|render|query []] | --- | --- | | Exactly one view in workspace | Render it | | Zero views, `--auto` specified | Auto-render BDD of top-level `part def` silently | -| Zero views, no `--auto` | Warn and auto-render | -| Multiple views, none specified | Error: lists available view names and exits non-zero | -| Multiple views, `--view ` | Render the named view | +| Zero views, no `--auto` | Informational message; no output files written | +| Multiple views, none specified | Render every declared view (one output file per view) | +| Multiple views, `--view ` | Render only the named view | +| `--view ` names a view that does not exist | Error: lists available view names and exits non-zero | ## NuGet Packages diff --git a/docs/design/introduction.md b/docs/design/introduction.md index 75cbc101..5b220f3b 100644 --- a/docs/design/introduction.md +++ b/docs/design/introduction.md @@ -159,7 +159,9 @@ reviewers an explicit navigation aid from design to code: - **DemaConsulting.SysML2Tools.Tool/** — dotnet tool CLI wrapper - **Cli/** — command-line interface subsystem - **Lint/** — lint command subsystem + - **Render/** — render command subsystem - **Help/** — help command subsystem + - **Query/** — query command subsystem - **SelfTest/** — self-validation subsystem - **Utilities/** — shared utilities subsystem - **Tools/StdlibGen/** — build-time stdlib pre-compiler tool @@ -172,6 +174,7 @@ reviewers an explicit navigation aid from design to code: - **lint/** — Lint subsystem design - **render/** — Render subsystem design (render.md) - **help.md** — Help subsystem design + - **query.md** — Query subsystem design - **self-test/** — SelfTest subsystem design - **utilities/** — Utilities subsystem design diff --git a/docs/design/sysml2-tools-tool.md b/docs/design/sysml2-tools-tool.md index 8b91a2ff..14d0fdf4 100644 --- a/docs/design/sysml2-tools-tool.md +++ b/docs/design/sysml2-tools-tool.md @@ -3,8 +3,8 @@ ## Architecture The `DemaConsulting.SysML2Tools.Tool` is a command-line application built on .NET. It is structured as one -system containing one top-level unit (`Program`) and three subsystems (`Cli`, `SelfTest`, -`Utilities`): +system containing one top-level unit (`Program`) and seven subsystems (`Cli`, `Lint`, `Render`, +`Help`, `Query`, `SelfTest`, `Utilities`): ```mermaid flowchart TD @@ -15,6 +15,12 @@ flowchart TD subgraph Lint LintCommand end + subgraph Render + RenderCommand + end + subgraph Help + HelpCommand + end subgraph Query QueryCommand end @@ -26,6 +32,8 @@ flowchart TD end Program --> Context Program --> LintCommand + Program --> RenderCommand + Program --> HelpCommand Program --> QueryCommand Program --> Validation Validation --> Program @@ -33,11 +41,12 @@ flowchart TD ``` `Program` is the entry point. It creates a `Context` from the `Cli` subsystem, dispatches to -`LintCommand` when the `lint` subcommand is passed, dispatches to `QueryCommand` when the -`query` subcommand is passed, dispatches to `Validation` when `--validate` is passed, and -returns the exit code from `Context`. `Validation` calls `Program.Run` recursively -to exercise the tool during self-testing, and uses `PathHelpers` to construct safe temporary file -paths. +`LintCommand` when the `lint` subcommand is passed, dispatches to `RenderCommand` when the +`render` subcommand is passed, dispatches to `HelpCommand` when the `help` subcommand (or +`--help`) is passed, dispatches to `QueryCommand` when the `query` subcommand is passed, +dispatches to `Validation` when `--validate` is passed, and returns the exit code from +`Context`. `Validation` calls `Program.Run` recursively to exercise the tool during +self-testing, and uses `PathHelpers` to construct safe temporary file paths. ## External Interfaces diff --git a/docs/design/sysml2-tools-tool/render.md b/docs/design/sysml2-tools-tool/render.md index ef0ae9a8..1bc1e16c 100644 --- a/docs/design/sysml2-tools-tool/render.md +++ b/docs/design/sysml2-tools-tool/render.md @@ -37,12 +37,17 @@ Entry point for the render command. Steps: `Program` only reaches here when `Context.Create` has already populated it) and validates that `options.Files` is non-empty; calls `context.WriteError` and returns when no patterns are supplied. -2. Calls `WorkspaceLoader.LoadAsync(options.Files)` to load the workspace. +2. Calls `StdlibProvider.GetSymbolTable()` to obtain the pre-resolved OMG stdlib symbol table, + then calls `WorkspaceLoader.LoadAsync(options.Files, stdlibTable)` to load the workspace, + seeded with the stdlib symbol table so stdlib elements resolve without re-parsing them. 3. Reports all diagnostics from `loadResult.Diagnostics`, writing errors via `context.WriteError` and other messages via `context.WriteLine`. 4. Calls `DiagramRenderer.GetViewNames(workspace)` to enumerate renderable views. -5. When `viewNames.Count > 1` and `options.ViewName` is null, calls `context.WriteError` - with a message listing the available names and returns early. +5. Calls `DiagramRenderer.GetViewNames(loadResult.Workspace)` again (via the same call at step + 4) to validate `options.ViewName` when supplied: when `options.ViewName` is not null and does + not match any declared view name, calls `context.WriteError` with a message listing the + available view names and returns early. When `options.ViewName` is null, no validation is + performed here — every declared view will be rendered in step 7. 6. Resolves `format = options.Format ?? "svg"` and eagerly rejects any value other than `"svg"`/`"png"` (case-insensitive) with `ArgumentException` naming the bad value — mirroring the `query` command's `--format` validation style. This is validated here, in `RunAsync`, not @@ -71,8 +76,14 @@ future-locale story, which applies identically here. - Missing file patterns: `context.WriteError` is called and the method returns early. - Load diagnostics: reported to the context; non-fatal; rendering proceeds regardless. -- Multiple views without `--view`: `context.WriteError` lists available view names and - returns early. +- Multiple views without `--view`: no error; every declared view is rendered (one output file + per view), supporting bulk "render everything" exports. +- Output file name collision: when rendering all views (`--view` not specified) with more than + one output, and two or more views' sanitized display names produce the same output file + name, `context.WriteError` reports every colliding group (listing the colliding qualified + view names and the shared file name) and the method returns before any file is written for + this run, rather than silently overwriting one view's output with another's. +- Unknown `--view` name: `context.WriteError` lists available view names and returns early. - Unsupported `--format` value: `ArgumentException` is thrown naming the bad value and the valid values (`svg`, `png`); propagates to `Program.Main`'s expected-exception handler. - No view declarations: informational message; no output files written; returns normally. @@ -81,8 +92,12 @@ future-locale story, which applies identically here. ##### Dependencies +- `StdlibProvider` (in `DemaConsulting.SysML2Tools.Stdlib`) — supplies the pre-resolved OMG + stdlib symbol table used to seed `WorkspaceLoader.LoadAsync` - `WorkspaceLoader` (in `DemaConsulting.SysML2Tools.Semantic`) — loads workspace -- `DiagramRenderer` (in `DemaConsulting.SysML2Tools.Rendering`) — renders views +- `DiagramRenderer` (in `DemaConsulting.SysML2Tools.Rendering`) — renders views; also exposes + `GetViewIdentities` used to attribute colliding output file names back to their originating + qualified view names - `SvgRenderer` (in `DemaConsulting.Rendering.Svg`) — produces SVG output - `PngRenderer` (in `DemaConsulting.Rendering.Skia`) — produces PNG output - `Themes.Light` (in `DemaConsulting.Rendering.Abstractions`) — default theme @@ -104,6 +119,8 @@ future-locale story, which applies identically here. | SysML2Tools-Tool-Render-Output | Output directory resolution in `RunAsync` | | SysML2Tools-Tool-Render-Empty | Empty-outputs message in `RunAsync` | | SysML2Tools-Tool-Render-DepthLimit | `DepthLimit` passed to `RenderOptions` in `RunAsync` | -| SysML2Tools-Tool-Render-MultipleViewError | Multi-view guard using `GetViewNames` in `RunAsync` | +| SysML2Tools-Tool-Render-AllViewsExport | Default render-all-views logic using `viewNames` in `RunAsync` | +| SysML2Tools-Tool-Render-UnknownViewError | Unknown `--view` name guard using `viewNames` in `RunAsync` | | SysML2Tools-Tool-Render-ViewSelection | `viewFilter` passed to `RenderWorkspace` in `RunAsync` | | SysML2Tools-Tool-Render-FormatValidation | Eager `--format` value guard in `RunAsync` | +| SysML2Tools-Tool-Render-FileNameCollision | Output file name collision guard in `RunAsync` | diff --git a/docs/reqstream/sysml2-tools-tool/render.yaml b/docs/reqstream/sysml2-tools-tool/render.yaml index 0740cee9..ec525936 100644 --- a/docs/reqstream/sysml2-tools-tool/render.yaml +++ b/docs/reqstream/sysml2-tools-tool/render.yaml @@ -71,25 +71,35 @@ sections: tests: - RenderSubsystem_WithDepth_LimitsNesting - - id: SysML2Tools-Tool-Render-MultipleViewError + - id: SysML2Tools-Tool-Render-AllViewsExport + title: >- + The render command shall render every declared view in the workspace, producing + one output file per view, when --view is not specified. + justification: | + Rendering every declared view by default supports bulk "render everything" + exports for CI pipelines and design-doc publishing without requiring the user + to invoke the command once per view. + tests: + - RenderSubsystem_MultipleViews_NoViewFlag_RendersAllViews + + - id: SysML2Tools-Tool-Render-UnknownViewError title: >- The render command shall report an error listing all available view names when - the workspace contains multiple renderable views and --view is not specified. + --view specifies a view name that does not exist in the workspace. justification: | - Requiring explicit view selection prevents rendering the wrong diagram when - a model contains multiple views, and the error lists choices so the user knows - what to supply. + Reporting an error with the list of available view names gives users an + actionable message when they mistype or misremember a view's display name. tests: - - RenderSubsystem_MultipleViews_NoViewFlag_ReportsError - - RenderSubsystem_MultipleViews_NoViewFlag_ListsAvailableViews + - RenderSubsystem_UnknownViewFlag_ReportsErrorWithAvailableViews - id: SysML2Tools-Tool-Render-ViewSelection title: >- The render command shall render only the view whose display name matches the - value supplied via --view. + value supplied via --view, when --view is specified. justification: | View selection enables targeting a single diagram for output in multi-view - models without generating every diagram in the workspace. + models without generating every diagram in the workspace; it is always + optional, narrowing the default render-all behavior to one view on request. tests: - RenderSubsystem_MultipleViews_WithViewFlag_RendersSelectedView @@ -106,6 +116,20 @@ sections: tests: - RenderSubsystem_UnsupportedFormat_ThrowsArgumentException + - id: SysML2Tools-Tool-Render-FileNameCollision + title: >- + The render command shall detect and report, before writing any output files, when + rendering all declared views (--view not specified) would produce two or more + output files with the same sanitized file name, naming the colliding views and the + shared file name, and shall abort without writing any files for that run. + justification: | + Two declared views in different packages that share the same simple name sanitize + to the same output file name. Without a collision guard, the second file written + silently overwrites the first while the final "Rendered N view(s)." message still + misreports both views as rendered, masking data loss from the user. + tests: + - RenderSubsystem_DuplicateViewFileNames_ReportsCollisionError + - id: SysML2Tools-Tool-Render-LocalizableHelpText title: >- The render command's help text (RenderCommand.PrintHelp) shall be sourced from diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index 5cee0bf1..61fa20b3 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -53,7 +53,7 @@ sysml2tools lint model.sysml sysml2tools lint "src/**/*.sysml" # Multiple patterns -sysml2tools render "common/**/*.sysml" "system/**/*.sysml" --output diagram.svg +sysml2tools render "common/**/*.sysml" "system/**/*.sysml" --output out ``` # Linting @@ -80,20 +80,21 @@ This structured output is suitable for: # Rendering The `render` command loads a workspace, resolves a view, and renders it to SVG or PNG. -The output format is determined by the file extension of `--output`. +`--output` names an output *directory* (default: current directory); `--format` selects +`svg` (default) or `png`. ```bash # Render to SVG -sysml2tools render model.sysml --output diagram.svg +sysml2tools render model.sysml --output out --format svg # Render to PNG -sysml2tools render model.sysml --output diagram.png +sysml2tools render model.sysml --output out --format png # Render a named view from a multi-view workspace -sysml2tools render "src/**/*.sysml" --view SystemContext --output context.svg +sysml2tools render "src/**/*.sysml" --view SystemContext --output out --format svg # Auto-render the top-level part def when no view is defined -sysml2tools render model.sysml --auto --output diagram.svg +sysml2tools render model.sysml --auto --output out --format svg ``` ## View Selection @@ -102,9 +103,10 @@ sysml2tools render model.sysml --auto --output diagram.svg | --- | --- | | Exactly one view in workspace | Render it | | Zero views, `--auto` specified | Auto-render BDD of top-level `part def` silently | -| Zero views, no `--auto` | Warn: "define a view or use --auto", then auto-render | -| Multiple views, none specified | Error: lists available view names, exits non-zero | -| Multiple views, `--view ` | Render the named view | +| Zero views, no `--auto` | Informational message; no output files written | +| Multiple views, none specified | Render every declared view (one output file per view) | +| Multiple views, `--view ` | Render only the named view | +| `--view ` names a view that does not exist | Error: lists available view names, exits non-zero | ## Depth Limiting @@ -113,7 +115,7 @@ with an ellipsis footer (`+N more…`). Silent omission is never used — trunca visible in the output. ```bash -sysml2tools render model.sysml --output diagram.svg --depth 3 +sysml2tools render model.sysml --output out --depth 3 ``` ## Output Formats diff --git a/docs/verification/sysml2-tools-tool/render.md b/docs/verification/sysml2-tools-tool/render.md index dac9b8a4..b258097f 100644 --- a/docs/verification/sysml2-tools-tool/render.md +++ b/docs/verification/sysml2-tools-tool/render.md @@ -1,6 +1,6 @@ -### DemaConsulting.SysML2Tools.Tool — Render Subsystem Verification +## DemaConsulting.SysML2Tools.Tool — Render Subsystem Verification -#### Verification Approach +### Verification Approach The Render subsystem is verified using unit tests in `test/DemaConsulting.SysML2Tools.Tool.Tests/Render/RenderSubsystemTests.cs`. @@ -8,16 +8,17 @@ Tests invoke `Program.RunAsync` with controlled `Context` instances and assert on context output and exit code. File-writing scenarios use a temporary directory (`Path.GetTempPath()`). Tests run against all three target frameworks. -#### Test Environment +### Test Environment - Framework: xUnit v3 - Target frameworks: net8.0, net9.0, net10.0 - Test project: `DemaConsulting.SysML2Tools.Tool.Tests` - Dependencies: `DemaConsulting.SysML2Tools.Tool` (internal access via `InternalsVisibleTo`) -#### Acceptance Criteria +### Acceptance Criteria -- No files supplied: `context.WriteError` is called and method returns without loading +- No files supplied: `context.WriteError` is called with a message containing "no input files + specified", and the method returns without loading - Workspace loads without errors for a valid SysML model file - SVG output produced for `--format svg` (or default) - PNG output produced for `--format png` @@ -25,80 +26,96 @@ on context output and exit code. File-writing scenarios use a temporary director - No output files written when workspace has no views - Informational message written when workspace has no views - `--depth 1` produces SVG output containing the ellipsis character `"…"` -- Multiple views without `--view` yields exit code 1 and an error message +- Multiple views without `--view` renders every declared view, producing one output file per + view, exit code 0 +- Two views whose display names sanitize to the same output file name (declared in different + packages) yield exit code 1, an error message naming both colliding qualified views and the + shared file name, and no output files written - `--view ` with a multi-view workspace renders exactly one file +- `--view ` naming a view that does not exist yields exit code 1 and an error message + listing available view names - Unsupported `--format` value throws `ArgumentException` when `RunAsync` executes (not at `Context.Create` parse time) - `render --help` prints render-specific usage and options (not the generic top-level command list), and is identical to `help render`'s output (see the Help subsystem verification document). -#### Test Scenarios +### Test Scenarios -##### RenderSubsystem_NoFiles_ReportsError +#### RenderSubsystem_NoFiles_ReportsError -Verifies that invoking the render command with zero file patterns results in an -error message written to the context and no workspace loading. +Verifies that invoking the render command with zero file patterns results in an error message +written to the context containing the "no input files specified" diagnostic text, and no +workspace loading. -##### RenderSubsystem_WithFiles_LoadsWorkspace +#### RenderSubsystem_WithFiles_LoadsWorkspace Verifies that supplying a valid SysML model file loads without errors, producing a non-null workspace in the context. -##### RenderSubsystem_FormatSvg_UsesSvgRenderer +#### RenderSubsystem_FormatSvg_UsesSvgRenderer Verifies that `--format svg` routes to the SVG renderer by confirming output file extension is `.svg`. -##### RenderSubsystem_FormatPng_UsesPngRenderer +#### RenderSubsystem_FormatPng_UsesPngRenderer Verifies that `--format png` routes to the PNG renderer by confirming output file extension is `.png`. -##### RenderSubsystem_NoOutputDir_UsesCurrentDirectory +#### RenderSubsystem_NoOutputDir_UsesCurrentDirectory Verifies that omitting `--output` causes files to be written to the current working directory. -##### RenderSubsystem_NoViews_ReportsNoOutput +#### RenderSubsystem_NoViews_ReportsNoOutput Verifies that a model with no view declarations produces no output files and an informational message. -##### RenderSubsystem_WithDepth_LimitsNesting +#### RenderSubsystem_WithDepth_LimitsNesting Verifies that `--depth 1` causes the SVG output to contain the ellipsis character `"…"`, confirming that child part-def boxes were replaced by the depth-limit indicator. -##### RenderSubsystem_MultipleViews_NoViewFlag_ReportsError +#### RenderSubsystem_MultipleViews_NoViewFlag_RendersAllViews -Verifies that rendering a workspace with two views and no `--view` flag yields exit code 1. +Verifies that rendering a workspace with two views and no `--view` flag renders every +declared view: exit code 0, and exactly two `.svg` output files produced (one per view). -##### RenderSubsystem_MultipleViews_NoViewFlag_ListsAvailableViews +#### RenderSubsystem_DuplicateViewFileNames_ReportsCollisionError -Verifies that the multi-view error path writes an error containing the available view names -to the log. +Verifies that rendering a workspace containing two views with the same simple name +(`SharedView`), declared in different packages (`PkgA`, `PkgB`), with no `--view` flag: exit +code 1, an error message naming both colliding qualified views (`PkgA::SharedView`, +`PkgB::SharedView`) and the shared output file name (`SharedView.svg`), and no output directory +or files are created. -##### RenderSubsystem_MultipleViews_WithViewFlag_RendersSelectedView +#### RenderSubsystem_UnknownViewFlag_ReportsErrorWithAvailableViews + +Verifies that `--view ` yields exit code 1 and an error message listing +the available view names (`ViewAlpha` and `ViewBeta`). + +#### RenderSubsystem_MultipleViews_WithViewFlag_RendersSelectedView Verifies that `--view ViewAlpha` selects exactly one view from a two-view workspace and produces a single `.svg` output file. -##### RenderSubsystem_UnsupportedFormat_ThrowsArgumentException +#### RenderSubsystem_UnsupportedFormat_ThrowsArgumentException Verifies that `--format xml` (an unsupported value) does not throw when `Context.Create` parses the arguments, but throws `ArgumentException` naming the bad value once `Program.RunAsync` actually runs the render command — mirroring the timing of the `query` command's `--format` validation. -##### RenderSubsystem_Help_PrintsRenderSpecificUsage +#### RenderSubsystem_Help_PrintsRenderSpecificUsage Verifies that `render --help` prints the render-specific usage line and its `--output`/ `--auto` flags, and does not print the generic top-level `"Commands:"` section — a regression-proofing test added alongside the `help` command's command-aware `--help` dispatch (see `docs/design/sysml2-tools-tool/help.md`). -##### ResxResource_EveryKey_ResolvesToNonEmptyText / ResxResource_KeysAndAccessorProperties_AreInBidirectionalParity (ResxResourceTests.cs) +#### ResxResource_EveryKey_ResolvesToNonEmptyText / ResxResource_KeysAndAccessorProperties_AreInBidirectionalParity (ResxResourceTests.cs) For the `RenderStrings` resource base name/accessor pair (one of four covered by these theory tests), every key discovered in `Render/RenderStrings.resx`'s invariant-culture resource set diff --git a/src/DemaConsulting.SysML2Tools.Core/Rendering/DiagramRenderer.cs b/src/DemaConsulting.SysML2Tools.Core/Rendering/DiagramRenderer.cs index db0e4ba0..d734f54f 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Rendering/DiagramRenderer.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Rendering/DiagramRenderer.cs @@ -37,6 +37,20 @@ public DiagramRenderer() { } + /// + /// Identifies a single renderable view by both its fully qualified name and its display + /// name (the name used to derive the output file name). + /// + /// + /// The view's fully qualified name (e.g. "PkgA::SharedView"), unique within the + /// workspace. + /// + /// + /// The view's display name: the simple Name when present, otherwise the qualified + /// name. Two views in different packages may share the same display name. + /// + public readonly record struct ViewIdentity(string QualifiedName, string DisplayName); + /// /// Returns the display names of all renderable user-defined views in the workspace, /// mirroring the filtering applied by . @@ -46,14 +60,34 @@ public DiagramRenderer() /// An ordered list of view display names. Returns an empty list when the workspace /// contains no renderable view declarations. /// - public static IReadOnlyList GetViewNames(SysmlWorkspace workspace) + public static IReadOnlyList GetViewNames(SysmlWorkspace workspace) => + GetViewIdentities(workspace).Select(identity => identity.DisplayName).ToList(); + + /// + /// Returns the qualified and display names of all renderable user-defined views in the + /// workspace, in the same order that produces its outputs. + /// + /// The SysML workspace whose view declarations are inspected. + /// + /// An ordered list of values, one per renderable view. Returns an + /// empty list when the workspace contains no renderable view declarations. + /// + /// + /// Callers that need to correlate each produced by + /// with the originating view's qualified name (for example, to + /// report a collision between two views that sanitize to the same output file name) may zip + /// this list by index against the result, because both methods + /// apply the identical filter/iteration over with no + /// intervening mutation. + /// + public static IReadOnlyList GetViewIdentities(SysmlWorkspace workspace) { // Validate input — null workspace would produce silent failures ArgumentNullException.ThrowIfNull(workspace); - var names = new List(); + var identities = new List(); - // Iterate over all declarations and collect each renderable view name + // Iterate over all declarations and collect each renderable view's identity foreach (var (qualifiedName, node) in workspace.Declarations) { // Skip non-view declarations @@ -79,10 +113,11 @@ public static IReadOnlyList GetViewNames(SysmlWorkspace workspace) _ = strategy; // Resolve the display name: prefer the simple name, fall back to qualified name - names.Add(viewNode.Name ?? qualifiedName); + var displayName = viewNode.Name ?? qualifiedName; + identities.Add(new ViewIdentity(qualifiedName, displayName)); } - return names; + return identities; } /// diff --git a/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommand.cs b/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommand.cs index acc6c309..3df4f97b 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommand.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommand.cs @@ -68,13 +68,15 @@ public static async Task RunAsync(Context context) return; } - // Enumerate renderable views; require --view when multiple views are present + // Enumerate renderable views. By default (no --view) every declared view is rendered, + // supporting bulk "render everything" exports for CI/design-doc publishing; --view narrows + // the run to a single named view. var viewNames = DiagramRenderer.GetViewNames(loadResult.Workspace); - if (viewNames.Count > 1 && options.ViewName is null) + if (options.ViewName is not null && !viewNames.Contains(options.ViewName, StringComparer.Ordinal)) { var available = string.Join(", ", viewNames); context.WriteError( - $"error: multiple views found; use --view to select one (available: {available})"); + $"error: view '{options.ViewName}' not found; use --view to select one (available: {available})"); return; } @@ -120,6 +122,16 @@ public static async Task RunAsync(Context context) return; } + // When rendering every view (no --view filter) with more than one output, guard against + // two views whose display names sanitize to the same output file name — without this + // check, the second file written below would silently overwrite the first while the + // final "Rendered N view(s)." message still reports both as rendered. + if (options.ViewName is null && outputs.Count > 1 && + ReportFileNameCollisions(context, loadResult.Workspace, outputs)) + { + return; + } + // Determine the output directory (default: current directory) var outputDir = options.OutputDirectory ?? Directory.GetCurrentDirectory(); Directory.CreateDirectory(outputDir); @@ -142,6 +154,72 @@ public static async Task RunAsync(Context context) context.WriteLine($"Rendered {outputs.Count} view(s)."); } + /// + /// Detects and reports output file name collisions among the given render outputs, sourcing + /// each output's originating view qualified name from . + /// + /// The CLI context used to write the error message. + /// The workspace that produced . + /// + /// The render outputs to check, in the same order that + /// enumerates renderable views for this workspace. + /// + /// + /// when at least one collision was found and reported (the caller + /// must abort without writing any files); when no collision exists. + /// + /// + /// (from the external DemaConsulting.Rendering.Abstractions + /// package) exposes only SuggestedFileName, MediaType, Data, and + /// Warnings — it carries no reference back to the originating view — so the qualified + /// name for each output must be sourced separately, by index, from + /// . Both that method and + /// apply the identical filter/iteration over + /// with no mutation in between (the same + /// invariant this command already relies on when calling GetViewNames once for + /// --view validation and RenderWorkspace separately), so the two lists line up + /// by index. + /// + private static bool ReportFileNameCollisions( + Context context, + SysmlWorkspace workspace, + IReadOnlyList outputs) + { + var identities = DiagramRenderer.GetViewIdentities(workspace); + if (identities.Count != outputs.Count) + { + // Defensive: the two enumerations should always line up 1:1 (see remarks); if this + // invariant is ever broken by a future change, fail loudly rather than mis-attribute + // qualified names to the wrong output. + throw new InvalidOperationException( + $"render: internal error — view identity count ({identities.Count}) does not " + + $"match render output count ({outputs.Count})."); + } + + // Group the colliding qualified names by shared output file name + var groups = identities + .Select((identity, index) => (identity.QualifiedName, outputs[index].SuggestedFileName)) + .GroupBy(entry => entry.SuggestedFileName, StringComparer.Ordinal) + .Where(group => group.Count() > 1) + .ToList(); + + if (groups.Count == 0) + { + return false; + } + + // Report every colliding group before aborting, so users see all collisions at once + foreach (var group in groups) + { + var qualifiedNames = string.Join(", ", group.Select(entry => entry.QualifiedName)); + context.WriteError( + $"render: output file name collision '{group.Key}' between views: {qualifiedNames}. " + + "Rename one of the views, or use --view to render a single view."); + } + + return true; + } + /// /// Prints help for the render command. /// diff --git a/src/DemaConsulting.SysML2Tools.Tool/Render/RenderStrings.resx b/src/DemaConsulting.SysML2Tools.Tool/Render/RenderStrings.resx index 89c8bd10..38056261 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Render/RenderStrings.resx +++ b/src/DemaConsulting.SysML2Tools.Tool/Render/RenderStrings.resx @@ -74,7 +74,7 @@ --format svg|png Renderer format (default: svg) - --view <name> Select a specific view to render, when multiple are defined + --view <name> Select a specific view to render, instead of rendering all views --auto Auto-generate a view when none are defined diff --git a/test/DemaConsulting.SysML2Tools.Tool.Tests/Render/RenderSubsystemTests.cs b/test/DemaConsulting.SysML2Tools.Tool.Tests/Render/RenderSubsystemTests.cs index 4a391aaf..3672f1bd 100644 --- a/test/DemaConsulting.SysML2Tools.Tool.Tests/Render/RenderSubsystemTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tool.Tests/Render/RenderSubsystemTests.cs @@ -57,7 +57,8 @@ public async Task RenderSubsystem_NoFiles_ReportsError() using var context = Context.Create(["render"]); await Program.RunAsync(context); - // Assert: error message written and exit code indicates failure + // Assert: expected diagnostic text written and exit code indicates failure + Assert.Contains("no input files specified", errWriter.ToString()); Assert.Equal(1, context.ExitCode); } finally @@ -331,11 +332,11 @@ view def ViewBeta {} """; /// - /// RenderCommand reports an error when the workspace contains multiple views and - /// --view is not specified. + /// RenderCommand renders every declared view when the workspace contains multiple + /// views and --view is not specified, producing one output file per view. /// [Fact] - public async Task RenderSubsystem_MultipleViews_NoViewFlag_ReportsError() + public async Task RenderSubsystem_MultipleViews_NoViewFlag_RendersAllViews() { // Arrange: write a SysML model with two views var tempDir = Path.Combine(Path.GetTempPath(), $"render_multi_{Guid.NewGuid():N}"); @@ -343,6 +344,57 @@ public async Task RenderSubsystem_MultipleViews_NoViewFlag_ReportsError() var tempFile = Path.Combine(tempDir, "model.sysml"); await File.WriteAllTextAsync(tempFile, SysmlWithTwoViews, TestContext.Current.CancellationToken); + var outputDir = Path.Combine(tempDir, "out"); + var originalOut = Console.Out; + try + { + using var outWriter = new StringWriter(); + Console.SetOut(outWriter); + + // Act: render without --view flag + using var context = Context.Create(["render", "--output", outputDir, tempFile]); + await Program.RunAsync(context); + + // Assert: exit code indicates success; exactly two .svg files were produced + Assert.Equal(0, context.ExitCode); + var svgFiles = Directory.GetFiles(outputDir, "*.svg"); + Assert.Equal(2, svgFiles.Length); + } + finally + { + Console.SetOut(originalOut); + Directory.Delete(tempDir, recursive: true); + } + } + + /// + /// RenderCommand reports a collision error and writes no output files when two views in + /// different packages share the same simple name (and therefore the same sanitized + /// output file name). + /// + [Fact] + public async Task RenderSubsystem_DuplicateViewFileNames_ReportsCollisionError() + { + // Arrange: write a SysML model with two packages each declaring a view of the same + // simple name ("SharedView"), producing qualified names "PkgA::SharedView" and + // "PkgB::SharedView" that both sanitize to the output file name "SharedView.svg" + const string sysmlWithDuplicateViewNames = """ + package PkgA { + part def BlockA {} + view def SharedView {} + } + package PkgB { + part def BlockB {} + view def SharedView {} + } + """; + + var tempDir = Path.Combine(Path.GetTempPath(), $"render_collision_{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + var tempFile = Path.Combine(tempDir, "model.sysml"); + await File.WriteAllTextAsync( + tempFile, sysmlWithDuplicateViewNames, TestContext.Current.CancellationToken); + var outputDir = Path.Combine(tempDir, "out"); var originalError = Console.Error; try @@ -350,12 +402,18 @@ public async Task RenderSubsystem_MultipleViews_NoViewFlag_ReportsError() using var errWriter = new StringWriter(); Console.SetError(errWriter); - // Act: render without --view flag + // Act: render without --view so both colliding views are considered for rendering using var context = Context.Create(["render", "--output", outputDir, tempFile]); await Program.RunAsync(context); - // Assert: exit code indicates failure; error was reported + // Assert: exit code indicates failure; error message names both colliding qualified + // views and the shared file name; no output directory/files were created Assert.Equal(1, context.ExitCode); + var errorText = errWriter.ToString(); + Assert.Contains("PkgA::SharedView", errorText); + Assert.Contains("PkgB::SharedView", errorText); + Assert.Contains("SharedView.svg", errorText); + Assert.False(Directory.Exists(outputDir), "Expected no output directory to be created"); } finally { @@ -365,14 +423,14 @@ public async Task RenderSubsystem_MultipleViews_NoViewFlag_ReportsError() } /// - /// RenderCommand error output lists available view names when multiple views are - /// present and --view is not specified. + /// RenderCommand reports an error listing available view names when --view names a + /// view that does not exist in the workspace. /// [Fact] - public async Task RenderSubsystem_MultipleViews_NoViewFlag_ListsAvailableViews() + public async Task RenderSubsystem_UnknownViewFlag_ReportsErrorWithAvailableViews() { // Arrange: write a SysML model with two named views - var tempDir = Path.Combine(Path.GetTempPath(), $"render_multi_views_{Guid.NewGuid():N}"); + var tempDir = Path.Combine(Path.GetTempPath(), $"render_unknown_view_{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDir); var tempFile = Path.Combine(tempDir, "model.sysml"); await File.WriteAllTextAsync(tempFile, SysmlWithTwoViews, TestContext.Current.CancellationToken); @@ -384,14 +442,22 @@ public async Task RenderSubsystem_MultipleViews_NoViewFlag_ListsAvailableViews() using var errWriter = new StringWriter(); Console.SetError(errWriter); - // Act: render without --view flag; use log to capture output for assertion + // Act: render with --view naming a view that does not exist; use log to capture + // output for assertion var logFile = Path.Combine(tempDir, "output.log"); - using var context = Context.Create( - ["render", "--silent", "--log", logFile, "--output", outputDir, tempFile]); - await Program.RunAsync(context); + using (var context = Context.Create( + ["render", "--silent", "--log", logFile, "--view", "NoSuchView", "--output", outputDir, tempFile])) + { + await Program.RunAsync(context); - // Assert: exit code indicates failure; error message in log lists view names - Assert.Equal(1, context.ExitCode); + // Assert: exit code indicates failure + Assert.Equal(1, context.ExitCode); + } + + // Assert: log content lists available view names + var logContent = await File.ReadAllTextAsync(logFile, TestContext.Current.CancellationToken); + Assert.Contains("ViewAlpha", logContent); + Assert.Contains("ViewBeta", logContent); } finally {