From 859453ad2d682242d63ca546c3139defbec465c1 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 24 Feb 2026 09:42:33 +0100 Subject: [PATCH 1/2] feat: add --output flag to CLI commands for file output, enhancing usability --- ProjGraph.slnx | 73 +++++++-------- README.md | 4 +- .../checklists/requirements.md | 34 +++++++ specs/007-cli-file-output/data-model.md | 17 ++++ specs/007-cli-file-output/plan.md | 69 ++++++++++++++ specs/007-cli-file-output/quickstart.md | 30 +++++++ specs/007-cli-file-output/research.md | 33 +++++++ specs/007-cli-file-output/spec.md | 84 +++++++++++++++++ specs/007-cli-file-output/tasks.md | 90 +++++++++++++++++++ .../Commands/ClassDiagramCommand.cs | 34 ++++++- src/ProjGraph.Cli/Commands/ErdCommand.cs | 34 ++++++- .../Commands/VisualizeCommand.cs | 49 +++++++--- src/ProjGraph.Cli/Program.cs | 9 +- src/ProjGraph.Cli/README.md | 21 ++++- .../Abstractions/IFileSystem.cs | 15 ++++ .../Infrastructure/PhysicalFileSystem.cs | 22 +++++ .../ClassDiagramCommandTests.cs | 36 ++++++++ .../ErdCommandTests.cs | 41 ++++++++- .../VisualizeCommandTests.cs | 68 ++++++++++++++ .../PhysicalFileSystemTests.cs | 74 +++++++++++++++ 20 files changed, 776 insertions(+), 61 deletions(-) create mode 100644 specs/007-cli-file-output/checklists/requirements.md create mode 100644 specs/007-cli-file-output/data-model.md create mode 100644 specs/007-cli-file-output/plan.md create mode 100644 specs/007-cli-file-output/quickstart.md create mode 100644 specs/007-cli-file-output/research.md create mode 100644 specs/007-cli-file-output/spec.md create mode 100644 specs/007-cli-file-output/tasks.md create mode 100644 tests/ProjGraph.Tests.Unit.Core/PhysicalFileSystemTests.cs diff --git a/ProjGraph.slnx b/ProjGraph.slnx index 84dd841..ca87960 100644 --- a/ProjGraph.slnx +++ b/ProjGraph.slnx @@ -1,51 +1,52 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - + - + - + - - + + + - - - - - - - - + + + + + + + + - - - - - - - - + + + + + + + + diff --git a/README.md b/README.md index 28a3107..95e61d3 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ dotnet run --project src/ProjGraph.Mcp projgraph visualize ./MySolution.sln # Mermaid format for documentation -projgraph visualize ./MySolution.slnx --format mermaid > docs/dependencies.mmd +projgraph visualize ./MySolution.slnx --format mermaid --output docs/dependencies.mmd ``` ### Generate Database Diagrams @@ -100,7 +100,7 @@ projgraph erd ./Data/MyDbContext.cs projgraph erd ./Migrations/MyDbContextModelSnapshot.cs # Output to Markdown for documentation -projgraph erd ./Data/MyDbContext.cs > docs/database-schema.md +projgraph erd ./Data/MyDbContext.cs --output docs/database-schema.md ``` ### Visualize Class Hierarchies diff --git a/specs/007-cli-file-output/checklists/requirements.md b/specs/007-cli-file-output/checklists/requirements.md new file mode 100644 index 0000000..961d6cd --- /dev/null +++ b/specs/007-cli-file-output/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: File Output (`--output` flag) + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-02-24 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan` diff --git a/specs/007-cli-file-output/data-model.md b/specs/007-cli-file-output/data-model.md new file mode 100644 index 0000000..7a61812 --- /dev/null +++ b/specs/007-cli-file-output/data-model.md @@ -0,0 +1,17 @@ +# Data Model Summary: File Output (`--output` flag) + +## Entity Update: Command Settings + +Adding a new field to CLI command settings to handle the output file path. + +### Settings Entity (Used by `VisualizeCommand`, `ErdCommand`, `ClassDiagramCommand`) + +- **Field**: `OutputPath` +- **Type**: `string?` +- **CLI Flag**: `-o|--output` +- **Description**: The path to save the diagram output to disk. + +### DiagramOptions (No changes needed, existing fields) + +- `bool ShowTitle` +- `bool WrapInMarkdownFence` diff --git a/specs/007-cli-file-output/plan.md b/specs/007-cli-file-output/plan.md new file mode 100644 index 0000000..5ff9bc2 --- /dev/null +++ b/specs/007-cli-file-output/plan.md @@ -0,0 +1,69 @@ +# Implementation Plan: File Output (`--output` flag) + +**Branch**: `007-cli-file-output` | **Date**: 2026-02-24 | **Spec**: [007-cli-file-output/spec.md](spec.md) +**Input**: Feature specification from `/specs/007-cli-file-output/spec.md` + +## Summary + +This feature adds a `-o|--output ` option to the `visualize`, `class-diagram`, and `erd` CLI commands. This allows users to reliably save diagrams to a file, avoiding shell redirection issues on Windows PowerShell. Each command will read the `OutputPath` from its settings and use `IFileSystem` to write the rendered diagram to disk. If the output file has a `.md` extension (or others except `.mmd`), the diagram will be automatically wrapped in a Mermaid markdown code fence. + +## Technical Context + +**Language/Version**: .NET 10.0 (C# 14+) +**Primary Dependencies**: `Spectre.Console`, `ProjGraph.Lib.Core` (for `IFileSystem`, `IOutputConsole`) +**Storage**: Files (rendered diagrams) +**Testing**: xUnit, FluentAssertions, Moq +**Target Platform**: .NET Core (Windows, Linux, macOS) +**Project Type**: CLI (`ProjGraph.Cli`) +**Performance Goals**: N/A (minimal overhead) +**Constraints**: MCP 1.0 Compliance (functionality remains in Lib), Zero Warnings, Strict SemVer +**Scale/Scope**: Thin change across 3 commands. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- [x] **I. Modern .NET 10 Baseline**: Targeting .NET 10.0+? +- [x] **II. MCP Native Interoperability**: Tool functionality already in Lib/MCP. CLI-specific file writing doesn't affect MCP. +- [x] **III. Library-First Core**: File writing logic will use `IFileSystem` from `Lib.Core`. +- [x] **IV. Absolute Testing Requirement**: Integration tests planned for CLI output. + +## Project Structure + +### Documentation (this feature) + +```text +specs/[###-feature]/ +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output (/speckit.plan command) +├── data-model.md # Phase 1 output (/speckit.plan command) +├── quickstart.md # Phase 1 output (/speckit.plan command) +├── contracts/ # Phase 1 output (/speckit.plan command - MUST include MCP schema) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +``` + +### Source Code (repository root) + +```text +src/ +├── ProjGraph.Core/ # Shared Domain Models & Constants +├── ProjGraph.Lib/ # Principles III: Core Business Logic Libraries +├── ProjGraph.Cli/ # Thin CLI tool wrapper +└── ProjGraph.Mcp/ # Principles II: MCP Server interface +``` + +tests/ +├── contract/ # MCP Contact Tests +├── integration/ # CLI & MCP Integration +└── unit/ # Library Logic Unit Tests + +**Structure Decision**: [Document the selected structure] + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +| ----------- | ------------ | ------------------------------------- | +| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | diff --git a/specs/007-cli-file-output/quickstart.md b/specs/007-cli-file-output/quickstart.md new file mode 100644 index 0000000..6a5aa93 --- /dev/null +++ b/specs/007-cli-file-output/quickstart.md @@ -0,0 +1,30 @@ +# Quickstart: File Output (`--output` flag) + +## Usage: CLI Commands + +Save diagrams directly to disk using the `-o|--output` flag. + +### Save Mermaid Diagram to `.mmd` (No code fence) + +```bash +projgraph visualize ./MySolution.sln -o diagram.mmd +``` + +### Save Mermaid Diagram to `.md` (Wrapped in code fence) + +```bash +projgraph visualize ./MySolution.sln -o README.md +``` + +### ERD and Class Diagrams + +```bash +projgraph erd ./Data/AppDbContext.cs -o erd.mmd +projgraph classdiagram ./Models/User.cs -o class.mmd +``` + +## Troubleshooting + +If the output file cannot be written (permission denied or invalid path), the CLI will report a clear error message and return a non-zero exit code. +No diagrams will be written to `stdout` when the `--output` flag is used. +Success is confirmed with a "Saved to " message in the informational output (stderr-like). diff --git a/specs/007-cli-file-output/research.md b/specs/007-cli-file-output/research.md new file mode 100644 index 0000000..18673af --- /dev/null +++ b/specs/007-cli-file-output/research.md @@ -0,0 +1,33 @@ +# Research: File Output (`--output` flag) + +## Decision: File Writing Orchestration + +- **Chosen**: Logic stays in `Cli` commands but uses `IFileSystem` for execution. +- **Rationale**: Writing to a file is a transport-level concern. The commands already coordinate between analysis services and renderers. The `IFileSystem` provides abstraction for testing. +- **Alternatives Considered**: + - Creating a separate `IOutputFileService`. Rejected as it would be too much abstraction for a simple `WriteAllTextAsync` call. + +## Decision: Markdown Fencing Logic + +- **Chosen**: Automatically wrap in a code fence if the output file extension is `.md` or anything other than `.mmd`. +- **Rationale**: `.mmd` is a Mermaid-specific file format which should not contain markdown fences. `.md` files are intended for embedding in markdown environments where fences are required. +- **Implementation**: + - `visualize`: Check `settings.Output` extension. + - Set `DiagramOptions.WrapInMarkdownFence = !outputPath.EndsWith(".mmd", StringComparison.OrdinalIgnoreCase)`. + +## Best Practices: CLI File Output + +- Follow standard `-o|--output` flag naming. +- Use `System.IO.File` through `IFileSystem`. +- Default to UTF-8 without BOM (modern .NET default). +- Ensure output directory exists before writing (optional but good). I'll use `IFileSystem.GetDirectoryName` and `Directory.CreateDirectory`. Wait, `IFileSystem` doesn't have `CreateDirectory`. +- Let's check `IFileSystem` again. +- I might need to add `CreateDirectory` or `GetDirectoryName` is already there. + +## Dependency Check + +The `IFileSystem` should be used for testing. +I'll check `ProjGraph.Tests.Integration.Cli` to see how it's tested. + +- It might use a real file system or a mock. +- `ProjGraph.Cli` usually uses `PhysicalFileSystem`. diff --git a/specs/007-cli-file-output/spec.md b/specs/007-cli-file-output/spec.md new file mode 100644 index 0000000..ad0b44c --- /dev/null +++ b/specs/007-cli-file-output/spec.md @@ -0,0 +1,84 @@ +# Feature Specification: File Output (`--output` flag) + +**Feature Branch**: `007-cli-file-output` +**Created**: 2026-02-24 +**Status**: Draft +**Input**: User description: "Add -o|--output to all CLI commands to write the diagram directly to disk instead of stdout. The current workflow requires `projgraph visualize ... > diagram.mmd` which is fragile on Windows PowerShell (BOM issues, encoding). A built-in flag is more reliable and enables easier CI integration. Each `Command` reads `OutputPath` from settings and calls `File.WriteAllTextAsync` after rendering." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Save diagram to file (Priority: P1) + +As a user, I want to save the generated diagram directly to a file using a CLI flag so that I can avoid shell redirection issues like BOM or encoding errors on Windows PowerShell. + +**Why this priority**: Core value of the feature. Shell redirection is fragile on some platforms, and a built-in flag is the standard way to handle file output in CLI tools. + +**Independent Test**: Run `projgraph visualize --output diagram.mmd` and verify that `diagram.mmd` is created with the correct content and no unwanted encoding issues. + +**Acceptance Scenarios**: + +1. **Given** a valid project path, **When** I run the visualize command with `-o output.mmd`, **Then** the diagram is saved to `output.mmd` and not printed to stdout. +2. **Given** an invalid output path (e.g., read-only directory), **When** I run the command with `--output`, **Then** the CLI should report a clear error message. + +--- + +### User Story 2 - CI/CD Integration (Priority: P2) + +As a developer, I want to use the `--output` flag in my build scripts or CI pipelines to generate documentation artifacts reliably. + +**Why this priority**: Enables automation and improves the developer experience for documentation generation. + +**Independent Test**: Running the command in a github action or script and verifying the exit code is 0 and the file exists. + +**Acceptance Scenarios**: + +1. **Given** a CI script, **When** I execute `projgraph erd --output docs/erd.mmd`, **Then** the file is created in the specified directory. + +--- + +### User Story 3 - Feedback on success (Priority: P2) + +As a user, I want to receive confirmation that the file has been written successfully. + +**Why this priority**: Good UX; users should know exactly what happened and where the file is. + +**Independent Test**: Verify that a "Saved to " message appears in the console output (stderr/info) when using the flag. + +**Acceptance Scenarios**: + +1. **Given** the `--output` flag is used, **When** the operation completes, **Then** an informational message stating "Saved to " is displayed. + +### Edge Cases + +- **File already exists**: Should the CLI overwrite without asking (common for CLI tools) or should it warn? I'll assume it overwrites by default. +- **Directory doesn't exist**: Should the CLI create the directory or fail? I'll assume it should try to create it. +- **Permission denied**: The CLI should report it clearly. +- **Very large output**: Ensure the file writing is handled correctly without running out of memory (not expected to be an issue for Mermaid diagrams). + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST provide a `-o|--output ` option for `visualize`, `class-diagram`, and `erd` commands. +- **FR-002**: System MUST write the rendered diagram to the specified file path if the output option is provided. +- **FR-003**: System MUST NOT write the diagram to stdout when the output option is provided. +- **FR-004**: System MUST ensure the output file is written using consistent encoding (UTF-8 without BOM). +- **FR-005**: System MUST report an error if the specified output file cannot be written (e.g., permission denied, invalid path). +- **FR-006**: System MUST display a success confirmation message upon successfully writing the file. +- **FR-007**: System MUST automatically wrap diagram output in a Markdown code fence if the output file extension indicates a Markdown environment (e.g., `.md`), unless the extension is `.mmd`. +- **FR-008**: System MUST attempt to create the parent directory if it does not exist before writing the output file. + +### Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 100% of CLI commands (`visualize`, `class-diagram`, `erd`) correctly handle the `--output` flag. +- **SC-002**: Diagrams saved via `--output` contain the same diagram logic as stdout, with optional Markdown fencing as per `FR-007`. +- **SC-003**: Output files are consistently encoded as UTF-8 without BOM. +- **SC-004**: Command returns a non-zero exit code if file writing fails. + +## Assumptions + +- We will use `IFileSystem` for file writing to maintain testability. +- If the directory for the output file doesn't exist, we might or might not want to create it. (I'll assume we should try to create it or at least report it). +- We will use UTF-8 without BOM as it is the standard for most modern tools, especially for Mermaid diagrams. diff --git a/specs/007-cli-file-output/tasks.md b/specs/007-cli-file-output/tasks.md new file mode 100644 index 0000000..4ec2b22 --- /dev/null +++ b/specs/007-cli-file-output/tasks.md @@ -0,0 +1,90 @@ +# Tasks: File Output (`--output` flag) + +**Input**: Design documents from `/specs/007-cli-file-output/` +**Prerequisites**: [plan.md](plan.md), [spec.md](spec.md), [research.md](research.md), [data-model.md](data-model.md) + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- All descriptions include exact file paths + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Update core abstractions to support file writing + +- [X] T001 Add `WriteAllTextAsync` to `IFileSystem` in `src/ProjGraph.Lib.Core/Abstractions/IFileSystem.cs` +- [X] T002 Add `CreateDirectory` to `IFileSystem` in `src/ProjGraph.Lib.Core/Abstractions/IFileSystem.cs` +- [X] T003 Implement `WriteAllTextAsync` (ensuring UTF-8 without BOM) and `CreateDirectory` in `src/ProjGraph.Lib.Core/Infrastructure/PhysicalFileSystem.cs` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Ensure file system abstractions are working correctly + +- [X] T004 [P] Add unit tests for `IFileSystem.WriteAllTextAsync` (verifying UTF-8 without BOM) and `CreateDirectory` in `tests/ProjGraph.Tests.Unit.Core/PhysicalFileSystemTests.cs` + +--- + +## Phase 3: User Story 1 - Save diagram to file (Priority: P1) 🎯 MVP + +**Goal**: Enable saving the dependency graph, ERD, and class diagrams to a file. + +**Independent Test**: Run `projgraph visualize -o output.mmd` and verify the file exists with the mermaid diagram. + +### Implementation for User Story 1 + +- [X] T005 [P] [US1] Add `Output` property to `VisualizeCommand.Settings` in `src/ProjGraph.Cli/Commands/VisualizeCommand.cs` +- [X] T006 [US1] Update `VisualizeCommand.ExecuteAsync` to handle file output and markdown fencing in `src/ProjGraph.Cli/Commands/VisualizeCommand.cs` +- [X] T007 [P] [US1] Add `Output` property to `ErdCommand.Settings` in `src/ProjGraph.Cli/Commands/ErdCommand.cs` +- [X] T008 [US1] Update `ErdCommand.ExecuteAsync` to handle file output and markdown fencing in `src/ProjGraph.Cli/Commands/ErdCommand.cs` +- [X] T009 [P] [US1] Add `Output` property to `ClassDiagramCommand.Settings` in `src/ProjGraph.Cli/Commands/ClassDiagramCommand.cs` +- [X] T010 [US1] Update `ClassDiagramCommand.ExecuteAsync` to handle file output and markdown fencing in `src/ProjGraph.Cli/Commands/ClassDiagramCommand.cs` + +**Checkpoint**: Core commands now support `--output`. + +--- + +## Phase 4: User Story 2 - CI/CD Integration (Priority: P2) + +**Goal**: Ensure reliable file creation for automation. + +**Independent Test**: Use the `--output` flag with different file extensions (`.md` vs `.mmd`) and verify markdown fence presence/absence. + +### Tests for User Story 2 + +- [X] T011 [P] [US2] Create integration tests for file output in `tests/ProjGraph.Tests.Integration.Cli/VisualizeCliTests.cs` +- [X] T012 [P] [US2] Create integration tests for file output in `tests/ProjGraph.Tests.Integration.Cli/ErdCliTests.cs` +- [X] T013 [P] [US2] Create integration tests for file output in `tests/ProjGraph.Tests.Integration.Cli/ClassDiagramCliTests.cs` + +--- + +## Phase 5: User Story 3 - Feedback on success (Priority: P2) + +**Goal**: Give users clear feedback when a file is saved. + +**Independent Test**: Verify "Saved to " appears in the console when using `-o`. + +### Implementation for User Story 3 + +- [X] T014 [US3] Ensure success confirmation message is printed in all commands in `src/ProjGraph.Cli/Commands/` + +--- + +## Phase 6: Polish + +- [X] T015 Final verification of command help text for `-o|--output` flag. +- [X] T016 Manual validation of error handling (e.g., read-only file system). + +## Dependencies + +- Phase 1 must be completed before Phase 3 (needed for file writing). +- Phase 3 can run mostly in parallel (different commands). +- User stories depend on Phase 1 & 2 foundations. + +## Parallel Execution Opportunities + +- T005, T007, T009 (updating Settings across 3 files) +- T011, T012, T013 (writing integration tests for 3 different commands) +- Implementation tasks for different commands (T006, T008, T010) are independent. diff --git a/src/ProjGraph.Cli/Commands/ClassDiagramCommand.cs b/src/ProjGraph.Cli/Commands/ClassDiagramCommand.cs index 9d35850..0475be7 100644 --- a/src/ProjGraph.Cli/Commands/ClassDiagramCommand.cs +++ b/src/ProjGraph.Cli/Commands/ClassDiagramCommand.cs @@ -17,10 +17,12 @@ namespace ProjGraph.Cli.Commands; /// The class analysis service used to analyze C# files. /// The diagram renderer for producing Mermaid class diagram output. /// The output console for writing results and errors. +/// The file system abstraction for disk operations. internal sealed class ClassDiagramCommand( IClassAnalysisService analysisService, IDiagramRenderer mermaidRenderer, - IOutputConsole console) + IOutputConsole console, + IFileSystem fileSystem) : AsyncCommand { /// @@ -87,6 +89,14 @@ internal sealed class Settings : CommandSettings [DefaultValue(true)] public bool ShowTitle { get; init; } = true; + /// + /// Gets or sets the output file path. + /// If specified, the diagram will be written to this file instead of stdout. + /// + [CommandOption("-o|--output ")] + [Description("The output file path")] + public string? Output { get; init; } + /// /// Validates the settings provided by the user. /// Ensures the file path is valid, exists, and points to a .cs file. @@ -139,8 +149,26 @@ public override async Task ExecuteAsync( var model = await analysisService.AnalyzeFileAsync(settings.Path, options); - var mermaidOutput = mermaidRenderer.Render(model, new DiagramOptions(settings.ShowTitle)); - console.WriteLine(mermaidOutput); + var wrapInMarkdownFence = settings.Output?.EndsWith(".mmd", StringComparison.OrdinalIgnoreCase) is false; + + var mermaidOutput = + mermaidRenderer.Render(model, new DiagramOptions(settings.ShowTitle, wrapInMarkdownFence)); + + if (settings.Output is not null) + { + var directory = fileSystem.GetDirectoryName(settings.Output); + if (!string.IsNullOrEmpty(directory)) + { + fileSystem.CreateDirectory(directory); + } + + await fileSystem.WriteAllTextAsync(settings.Output, mermaidOutput, cancellationToken); + console.WriteInfo($"Saved to {settings.Output}"); + } + else + { + console.WriteLine(mermaidOutput); + } return 0; } diff --git a/src/ProjGraph.Cli/Commands/ErdCommand.cs b/src/ProjGraph.Cli/Commands/ErdCommand.cs index 49e405f..2ea75f5 100644 --- a/src/ProjGraph.Cli/Commands/ErdCommand.cs +++ b/src/ProjGraph.Cli/Commands/ErdCommand.cs @@ -22,10 +22,12 @@ namespace ProjGraph.Cli.Commands; /// The Entity Framework analysis service for discovering and analyzing contexts and snapshots. /// The diagram renderer for producing Mermaid ERD output. /// The output console for writing results and errors. +/// The file system abstraction for disk operations. internal sealed class ErdCommand( IEfAnalysisService efService, IDiagramRenderer mermaidRenderer, - IOutputConsole console) + IOutputConsole console, + IFileSystem fileSystem) : AsyncCommand { /// @@ -62,6 +64,14 @@ internal sealed class Settings : CommandSettings [DefaultValue(true)] public bool ShowTitle { get; init; } = true; + /// + /// Gets or sets the output file path. + /// If specified, the diagram will be written to this file instead of stdout. + /// + [CommandOption("-o|--output ")] + [Description("The output file path")] + public string? Output { get; init; } + /// /// Validates the settings provided for the command. /// Ensures that the specified path exists, is a .cs file, or is left empty to search the current directory. @@ -120,8 +130,26 @@ public override async Task ExecuteAsync( var model = await AnalyzeModelAsync(targetPath, settings.ContextName, cancellationToken); - var mermaidOutput = mermaidRenderer.Render(model, new DiagramOptions(settings.ShowTitle)); - console.WriteLine(mermaidOutput); + var wrapInMarkdownFence = settings.Output?.EndsWith(".mmd", StringComparison.OrdinalIgnoreCase) is false; + + var mermaidOutput = + mermaidRenderer.Render(model, new DiagramOptions(settings.ShowTitle, wrapInMarkdownFence)); + + if (settings.Output is not null) + { + var directory = fileSystem.GetDirectoryName(settings.Output); + if (!string.IsNullOrEmpty(directory)) + { + fileSystem.CreateDirectory(directory); + } + + await fileSystem.WriteAllTextAsync(settings.Output, mermaidOutput, cancellationToken); + console.WriteInfo($"Saved to {settings.Output}"); + } + else + { + console.WriteLine(mermaidOutput); + } return 0; } diff --git a/src/ProjGraph.Cli/Commands/VisualizeCommand.cs b/src/ProjGraph.Cli/Commands/VisualizeCommand.cs index d825f17..d172ac1 100644 --- a/src/ProjGraph.Cli/Commands/VisualizeCommand.cs +++ b/src/ProjGraph.Cli/Commands/VisualizeCommand.cs @@ -21,10 +21,12 @@ namespace ProjGraph.Cli.Commands; /// The graph service used to build the dependency graph. /// The collection of diagram renderers for different output formats. /// The output console for writing results and errors. +/// The file system abstraction for disk operations. internal sealed class VisualizeCommand( IGraphService graphService, IEnumerable> renderers, - IOutputConsole console) + IOutputConsole console, + IFileSystem fileSystem) : AsyncCommand { private const string FormatMermaid = "mermaid"; @@ -69,6 +71,14 @@ internal sealed class Settings : CommandSettings [DefaultValue(true)] public bool ShowTitle { get; init; } = true; + /// + /// Gets or sets the output file path. + /// If specified, the diagram will be written to this file instead of stdout. + /// + [CommandOption("-o|--output ")] + [Description("The output file path")] + public string? Output { get; init; } + /// /// Validates the settings provided for the command. /// Ensures that the specified path exists, is valid, and that the format is "flat", "tree" or "mermaid". @@ -122,29 +132,48 @@ public override async Task ExecuteAsync( { try { + SolutionGraph graph; if (settings.NormalizedFormat.Equals(FormatMermaid, StringComparison.OrdinalIgnoreCase)) { // For mermaid, we want clean stdout, so all status goes to stderr console.WriteInfo($"Analyzing {settings.Path}..."); - - var graph = await Task.Run(() => graphService.BuildGraph(settings.Path), cancellationToken); - console.WriteLine(GetRenderer(settings.NormalizedFormat) - .Render(graph, new DiagramOptions(settings.ShowTitle))); + graph = await Task.Run(() => graphService.BuildGraph(settings.Path), cancellationToken); } else { - SolutionGraph? graph = null; + SolutionGraph? result = null; await console.RunWithStatusAsync($"Analyzing [blue]{settings.Path}[/]...", - async () => graph = await Task.Run(() => graphService.BuildGraph(settings.Path), cancellationToken), + async () => result = + await Task.Run(() => graphService.BuildGraph(settings.Path), cancellationToken), cancellationToken); - if (graph is null) + if (result is null) { return 0; } - console.WriteLine(GetRenderer(settings.NormalizedFormat) - .Render(graph, new DiagramOptions(settings.ShowTitle))); + graph = result; + } + + var wrapInMarkdownFence = settings.Output?.EndsWith(".mmd", StringComparison.OrdinalIgnoreCase) is false; + + var rendered = GetRenderer(settings.NormalizedFormat) + .Render(graph, new DiagramOptions(settings.ShowTitle, wrapInMarkdownFence)); + + if (settings.Output is not null) + { + var directory = fileSystem.GetDirectoryName(settings.Output); + if (!string.IsNullOrEmpty(directory)) + { + fileSystem.CreateDirectory(directory); + } + + await fileSystem.WriteAllTextAsync(settings.Output, rendered, cancellationToken); + console.WriteInfo($"Saved to {settings.Output}"); + } + else + { + console.WriteLine(rendered); } return 0; diff --git a/src/ProjGraph.Cli/Program.cs b/src/ProjGraph.Cli/Program.cs index 1d6f011..4823517 100644 --- a/src/ProjGraph.Cli/Program.cs +++ b/src/ProjGraph.Cli/Program.cs @@ -25,17 +25,20 @@ public static int Main(string[] args) config.AddCommand("visualize") .WithDescription("Visualize the dependency graph of a solution or project") .WithExample("visualize", "MySolution.sln") - .WithExample("visualize", "MySolution.sln", "--format", "tree"); + .WithExample("visualize", "MySolution.sln", "--format", "tree") + .WithExample("visualize", "MySolution.slnx", "--output", "graph.mmd"); config.AddCommand("erd") .WithDescription("Generate a Mermaid ERD for an Entity Framework Core DbContext") .WithExample("erd", "Data/AppDbContext.cs") - .WithExample("erd", "Data/AppDbContext.cs", "--context", "BlogContext"); + .WithExample("erd", "Data/AppDbContext.cs", "--context", "BlogContext") + .WithExample("erd", "Data/AppDbContext.cs", "--output", "docs/erd.md"); config.AddCommand("classdiagram") .WithDescription("Generate a Mermaid Class Diagram for a C# file") .WithExample("classdiagram", "Services/UserService.cs") - .WithExample("classdiagram", "Models/User.cs", "--inheritance", "--dependencies", "--depth", "10"); + .WithExample("classdiagram", "Models/User.cs", "--inheritance", "--dependencies", "--depth", "10") + .WithExample("classdiagram", "Models/User.cs", "--output", "user-hierarchy.mmd"); }); return app.Run(args); diff --git a/src/ProjGraph.Cli/README.md b/src/ProjGraph.Cli/README.md index affccf9..eed91ec 100644 --- a/src/ProjGraph.Cli/README.md +++ b/src/ProjGraph.Cli/README.md @@ -19,9 +19,15 @@ Visualize solution/project dependencies as ASCII tree or Mermaid diagram. # ASCII tree (default) projgraph visualize ./MySolution.sln -# Mermaid diagram +# Mermaid diagram (redirecting stdout) projgraph visualize ./MySolution.slnx --format mermaid > graph.mmd +# Mermaid diagram (using output flag) +projgraph visualize ./MySolution.slnx --format mermaid --output graph.mmd + +# Save as fenced Markdown +projgraph visualize ./MySolution.slnx --format mermaid --output docs/diagram.md + # Mermaid diagram without title header projgraph visualize ./MySolution.slnx --format mermaid --show-title false ``` @@ -30,6 +36,7 @@ projgraph visualize ./MySolution.slnx --format mermaid --show-title false - `[path]`: Path to `.sln`, `.slnx`, or `.csproj` file. - `-f|--format`: Output format (`flat`, `tree`, `mermaid`). Default: `mermaid`. +- `-o|--output `: Write diagram directly to file. Auto-creates directories. - `--show-title `: Include diagram title. Default: `true`. **Supports**: `.sln`, `.slnx`, `.csproj` @@ -53,9 +60,12 @@ projgraph erd ./Data/MyDbContext.cs # Generate ERD from ModelSnapshot (useful if migrations already exist) projgraph erd ./Migrations/MyDbContextModelSnapshot.cs -# Save to file +# Save to file (stdout) projgraph erd ./Data/MyDbContext.cs > database-schema.md +# Save to file (flag) +projgraph erd ./Data/MyDbContext.cs --output ./docs/database-schema.md + # Generate without title header projgraph erd ./Data/MyDbContext.cs --show-title false ``` @@ -64,6 +74,7 @@ projgraph erd ./Data/MyDbContext.cs --show-title false - `[path]`: Optional path to `.cs` file. Searches current directory if not specified. - `-c|--context `: Optional context/snapshot name. +- `-o|--output `: Write diagram directly to file. Auto-creates directories. - `--show-title `: Include diagram title. Default: `true`. **Features**: @@ -110,8 +121,11 @@ projgraph classdiagram ./Models/Person.cs --depth 2 # Hide properties and functions projgraph classdiagram ./Models/Person.cs --properties false --functions false -# Save to file +# Save to file (stdout) projgraph classdiagram ./Models/Person.cs > person-hierarchy.md + +# Save to file (flag) +projgraph classdiagram ./Models/Person.cs --output docs/person.mmd ``` **Settings**: @@ -119,6 +133,7 @@ projgraph classdiagram ./Models/Person.cs > person-hierarchy.md - `[path]`: Required path to the `.cs` file to analyze. - `-i|--inheritance`: Search workspace for base classes and interfaces. Default: `false`. - `-d|--dependencies`: Search and include other classes used as properties/fields. Default: `false`. +- `-o|--output `: Write diagram directly to file. Auto-creates directories. - `--properties `: Display properties and fields in diagram. Default: `true`. - `--functions `: Display functions/methods in diagram. Default: `true`. - `--depth `: How many levels of relationships to follow. Default: `1`. diff --git a/src/ProjGraph.Lib.Core/Abstractions/IFileSystem.cs b/src/ProjGraph.Lib.Core/Abstractions/IFileSystem.cs index 4f442bd..4e0a15b 100644 --- a/src/ProjGraph.Lib.Core/Abstractions/IFileSystem.cs +++ b/src/ProjGraph.Lib.Core/Abstractions/IFileSystem.cs @@ -48,6 +48,21 @@ public interface IFileSystem /// The content of the file as a string. Task ReadAllTextAsync(string path, CancellationToken cancellationToken = default); + /// + /// Asynchronously writes all text to a file at the specified path. + /// + /// The path to the file. + /// The string content to write. + /// A token to monitor for cancellation requests. + /// A task representing the asynchronous operation. + Task WriteAllTextAsync(string path, string contents, CancellationToken cancellationToken = default); + + /// + /// Creates all directories in the specified path unless they already exist. + /// + /// The directory path to create. + void CreateDirectory(string path); + /// /// Gets the current working directory of the application. /// diff --git a/src/ProjGraph.Lib.Core/Infrastructure/PhysicalFileSystem.cs b/src/ProjGraph.Lib.Core/Infrastructure/PhysicalFileSystem.cs index 97754f4..f744709 100644 --- a/src/ProjGraph.Lib.Core/Infrastructure/PhysicalFileSystem.cs +++ b/src/ProjGraph.Lib.Core/Infrastructure/PhysicalFileSystem.cs @@ -68,6 +68,28 @@ public Task ReadAllTextAsync(string path, CancellationToken cancellation return File.ReadAllTextAsync(path, cancellationToken); } + /// + /// Asynchronously writes all text to a file at the specified path. + /// + /// The path to the file. + /// The string content to write. + /// A token to monitor for cancellation requests. + /// A task representing the asynchronous operation. + public Task WriteAllTextAsync(string path, string contents, CancellationToken cancellationToken = default) + { + // File.WriteAllTextAsync defaults to UTF-8 without BOM in .NET + return File.WriteAllTextAsync(path, contents, cancellationToken); + } + + /// + /// Creates all directories in the specified path unless they already exist. + /// + /// The directory path to create. + public void CreateDirectory(string path) + { + Directory.CreateDirectory(path); + } + /// /// Gets the current working directory of the application. /// diff --git a/tests/ProjGraph.Tests.Integration.Cli/ClassDiagramCommandTests.cs b/tests/ProjGraph.Tests.Integration.Cli/ClassDiagramCommandTests.cs index bdb5dcd..f5e41e0 100644 --- a/tests/ProjGraph.Tests.Integration.Cli/ClassDiagramCommandTests.cs +++ b/tests/ProjGraph.Tests.Integration.Cli/ClassDiagramCommandTests.cs @@ -297,4 +297,40 @@ public async Task ClassDiagramCommand_HideFunctions_WithMethods_ShouldExcludeMet capturedOutput.Should().Contain("[\"Svc\"]"); capturedOutput.Should().NotContain("DoWork()"); } + + [Fact] + public async Task ClassDiagramCommand_FileOutput_ShouldSaveToDisk() + { + // Arrange + var app = CliTestHelpers.CreateApp(); + var userPath = CliTestHelpers.GetSamplePath(@"classdiagram\simple-hierarchy\Models\User.cs"); + var outputPath = Path.Combine(Path.GetTempPath(), "classdiagram_" + Guid.NewGuid() + ".md"); + + try + { + // Act + var capturedOutput = CliTestHelpers.CaptureConsoleOutput(() => + { + var result = app.Run(["classdiagram", userPath, "--output", outputPath]); + result.Should().Be(0); + }); + + // Assert + capturedOutput.Should().Contain($"Saved to {outputPath}"); + capturedOutput.Should().NotContain("classDiagram"); + + File.Exists(outputPath).Should().BeTrue(); + var fileContent = await File.ReadAllTextAsync(outputPath); + fileContent.Should().Contain("```mermaid"); + fileContent.Should().Contain("classDiagram"); + fileContent.Should().Contain("User"); + } + finally + { + if (File.Exists(outputPath)) + { + File.Delete(outputPath); + } + } + } } diff --git a/tests/ProjGraph.Tests.Integration.Cli/ErdCommandTests.cs b/tests/ProjGraph.Tests.Integration.Cli/ErdCommandTests.cs index 0ff189a..6897f4d 100644 --- a/tests/ProjGraph.Tests.Integration.Cli/ErdCommandTests.cs +++ b/tests/ProjGraph.Tests.Integration.Cli/ErdCommandTests.cs @@ -22,7 +22,10 @@ public void ErdCommand_SimpleContext_ShouldGenerateCompleteErDiagram() var capturedOutput = CliTestHelpers.CaptureConsoleOutput(() => { // Debug: verify the command and path - var args = new[] { "erd", contextPath }; + var args = new[] + { + "erd", contextPath + }; resultCode = app.Run(args); }); @@ -202,6 +205,42 @@ public void ErdCommand_SimpleContext_WithShowTitleFalse_ShouldOmitTitle() capturedOutput.Should().NotContain("title:"); } + [Fact] + public async Task ErdCommand_FileOutput_ShouldSaveToDisk() + { + // Arrange + var app = CliTestHelpers.CreateApp(); + var contextPath = CliTestHelpers.GetSamplePath(@"erd\simple-context\EntityFramework\MyDbContext.cs"); + var outputPath = Path.Combine(Path.GetTempPath(), "erd_" + Guid.NewGuid() + ".md"); + + try + { + // Act + var capturedOutput = CliTestHelpers.CaptureConsoleOutput(() => + { + var result = app.Run(["erd", contextPath, "--output", outputPath]); + result.Should().Be(0); + }); + + // Assert + capturedOutput.Should().Contain($"Saved to {outputPath}"); + capturedOutput.Should().NotContain("erDiagram"); + + File.Exists(outputPath).Should().BeTrue(); + var fileContent = await File.ReadAllTextAsync(outputPath); + fileContent.Should().Contain("```mermaid"); + fileContent.Should().Contain("erDiagram"); + fileContent.Should().Contain("Author {"); + } + finally + { + if (File.Exists(outputPath)) + { + File.Delete(outputPath); + } + } + } + private static string ExtractMermaidBlock(string content) { var match = ExtractMermaidRegex().Match(content); diff --git a/tests/ProjGraph.Tests.Integration.Cli/VisualizeCommandTests.cs b/tests/ProjGraph.Tests.Integration.Cli/VisualizeCommandTests.cs index d055bd5..12a7539 100644 --- a/tests/ProjGraph.Tests.Integration.Cli/VisualizeCommandTests.cs +++ b/tests/ProjGraph.Tests.Integration.Cli/VisualizeCommandTests.cs @@ -97,6 +97,74 @@ public void VisualizeCommand_SimpleDependencies_FlatFormat_ShouldShowList() capturedOutput.Should().Contain("→ B"); } + [Fact] + public async Task VisualizeCommand_FileOutput_ShouldSaveToDiskAndWrapInFence() + { + // Arrange + var app = CliTestHelpers.CreateApp(); + var slnxPath = CliTestHelpers.GetSamplePath(@"visualize\simple-dependencies\simple-dependencies.slnx"); + var outputPath = Path.Combine(Path.GetTempPath(), "visualize_" + Guid.NewGuid() + ".md"); + + try + { + // Act + var capturedOutput = CliTestHelpers.CaptureConsoleOutput(() => + { + var result = app.Run(["visualize", slnxPath, "--output", outputPath]); + result.Should().Be(0); + }); + + // Assert + capturedOutput.Should().Contain($"Saved to {outputPath}"); + capturedOutput.Should().NotContain("graph TD"); + + File.Exists(outputPath).Should().BeTrue(); + var fileContent = await File.ReadAllTextAsync(outputPath); + fileContent.Should().Contain("```mermaid"); + fileContent.Should().Contain("graph TD"); + fileContent.Should().Contain("A --> B"); + } + finally + { + if (File.Exists(outputPath)) + { + File.Delete(outputPath); + } + } + } + + [Fact] + public async Task VisualizeCommand_FileOutput_MmdExtension_ShouldNotWrapInFence() + { + // Arrange + var app = CliTestHelpers.CreateApp(); + var slnxPath = CliTestHelpers.GetSamplePath(@"visualize\simple-dependencies\simple-dependencies.slnx"); + var outputPath = Path.Combine(Path.GetTempPath(), "visualize_" + Guid.NewGuid() + ".mmd"); + + try + { + // Act + CliTestHelpers.CaptureConsoleOutput(() => + { + var result = app.Run(["visualize", slnxPath, "--output", outputPath]); + result.Should().Be(0); + }); + + // Assert + File.Exists(outputPath).Should().BeTrue(); + var fileContent = await File.ReadAllTextAsync(outputPath); + fileContent.Should().NotContain("```mermaid"); + fileContent.Should().Contain("graph TD"); + } + finally + { + if (File.Exists(outputPath)) + { + File.Delete(outputPath); + } + } + } + [Fact] public void VisualizeCommand_SimpleDependencies_TreeFormat_ShouldShowHierarchy() { diff --git a/tests/ProjGraph.Tests.Unit.Core/PhysicalFileSystemTests.cs b/tests/ProjGraph.Tests.Unit.Core/PhysicalFileSystemTests.cs new file mode 100644 index 0000000..6f2e6e4 --- /dev/null +++ b/tests/ProjGraph.Tests.Unit.Core/PhysicalFileSystemTests.cs @@ -0,0 +1,74 @@ +using ProjGraph.Lib.Core.Infrastructure; + +namespace ProjGraph.Tests.Unit.Core; + +public sealed class PhysicalFileSystemTests : IDisposable +{ + private readonly string _testDirPath; + + public PhysicalFileSystemTests() + { + _testDirPath = Path.Combine(Path.GetTempPath(), "ProjGraphTests_" + Guid.NewGuid()); + Directory.CreateDirectory(_testDirPath); + } + + public void Dispose() + { + if (Directory.Exists(_testDirPath)) + { + Directory.Delete(_testDirPath, true); + } + } + + [Fact] + public void CreateDirectory_CreatesPath() + { + // Arrange + var fs = new PhysicalFileSystem(); + var newDir = Path.Combine(_testDirPath, "subdir", "nested"); + + // Act + fs.CreateDirectory(newDir); + + // Assert + Directory.Exists(newDir).Should().BeTrue(); + } + + [Fact] + public async Task WriteAllTextAsync_WritesContent_Utf8NoBom() + { + // Arrange + var fs = new PhysicalFileSystem(); + var filePath = Path.Combine(_testDirPath, "testfile.txt"); + const string content = "Hello World! 🚀"; + + // Act + await fs.WriteAllTextAsync(filePath, content); + + // Assert + File.Exists(filePath).Should().BeTrue(); + var readContent = await File.ReadAllTextAsync(filePath); + readContent.Should().Be(content); + + // Verify no BOM + var bytes = await File.ReadAllBytesAsync(filePath); + // UTF-8 BOM is EF BB BF + if (bytes.Length >= 3) + { + (bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF).Should().BeFalse("BOM should not be present"); + } + } + + [Fact] + public void FileExists_ReturnsCorrectResult() + { + // Arrange + var fs = new PhysicalFileSystem(); + var filePath = Path.Combine(_testDirPath, "exists.txt"); + File.WriteAllText(filePath, "test"); + + // Act & Assert + fs.FileExists(filePath).Should().BeTrue(); + fs.FileExists(Path.Combine(_testDirPath, "notexists.txt")).Should().BeFalse(); + } +} From 5bb0998648c9486db46b1403689f286ebce0a41a Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 24 Feb 2026 10:03:05 +0100 Subject: [PATCH 2/2] fix: correct command names in functional requirements for consistency --- specs/007-cli-file-output/spec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specs/007-cli-file-output/spec.md b/specs/007-cli-file-output/spec.md index ad0b44c..e870f69 100644 --- a/specs/007-cli-file-output/spec.md +++ b/specs/007-cli-file-output/spec.md @@ -59,7 +59,7 @@ As a user, I want to receive confirmation that the file has been written success ### Functional Requirements -- **FR-001**: System MUST provide a `-o|--output ` option for `visualize`, `class-diagram`, and `erd` commands. +- **FR-001**: System MUST provide a `-o|--output ` option for `visualize`, `classdiagram`, and `erd` commands. - **FR-002**: System MUST write the rendered diagram to the specified file path if the output option is provided. - **FR-003**: System MUST NOT write the diagram to stdout when the output option is provided. - **FR-004**: System MUST ensure the output file is written using consistent encoding (UTF-8 without BOM). @@ -72,7 +72,7 @@ As a user, I want to receive confirmation that the file has been written success ### Measurable Outcomes -- **SC-001**: 100% of CLI commands (`visualize`, `class-diagram`, `erd`) correctly handle the `--output` flag. +- **SC-001**: 100% of CLI commands (`visualize`, `classdiagram`, `erd`) correctly handle the `--output` flag. - **SC-002**: Diagrams saved via `--output` contain the same diagram logic as stdout, with optional Markdown fencing as per `FR-007`. - **SC-003**: Output files are consistently encoded as UTF-8 without BOM. - **SC-004**: Command returns a non-zero exit code if file writing fails.