diff --git a/AGENTS.md b/AGENTS.md index d37efe4..432bf51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ This document provides essential context for LLMs performing development tasks i - Extract code examples and procedures from RST files - Search documentation for patterns - Analyze file dependencies and relationships +- Analyze composable definitions and usage across projects - Compare files across documentation versions - Count documentation pages and tested code examples @@ -31,13 +32,17 @@ audit-cli/ │ ├── analyze/ # Analyze RST structures │ │ ├── includes/ # Analyze include relationships │ │ ├── usage/ # Find file usages -│ │ └── procedures/ # Analyze procedure variations +│ │ ├── procedures/ # Analyze procedure variations +│ │ └── composables/ # Analyze composable definitions and usage │ ├── compare/ # Compare files across versions │ │ └── file-contents/ # Compare file contents │ └── count/ # Count documentation content │ ├── tested-examples/ # Count tested code examples │ └── pages/ # Count documentation pages ├── internal/ # Internal packages (not importable externally) +│ ├── config/ # Configuration management +│ │ ├── config.go # Config loading from file/env/args +│ │ └── config_test.go # Config tests │ ├── projectinfo/ # MongoDB docs project structure utilities │ │ ├── pathresolver.go # Path resolution │ │ ├── source_finder.go # Source directory detection @@ -47,7 +52,8 @@ audit-cli/ │ ├── directive_parser.go # Directive parsing │ ├── directive_regex.go # Regex patterns for directives │ ├── parse_procedures.go # Procedure parsing (core logic) -│ └── get_procedure_variations.go # Variation extraction +│ ├── get_procedure_variations.go # Variation extraction +│ └── rstspec.go # Fetch and parse canonical rstspec.toml ├── testdata/ # Test fixtures (auto-ignored by Go build) │ ├── input-files/source/ # Test RST files │ ├── expected-output/ # Expected extraction results @@ -66,6 +72,7 @@ audit-cli/ - **CLI Framework**: [spf13/cobra](https://github.com/spf13/cobra) - **Diff Library**: [aymanbagabas/go-udiff](https://github.com/aymanbagabas/go-udiff) - **YAML Parsing**: gopkg.in/yaml.vX +- **TOML Parsing**: [github.com/BurntSushi/toml](https://github.com/BurntSushi/toml) v1.5.0 - **Testing**: Go standard library (`testing` package) Refer to the `go.mod` for version info. @@ -93,6 +100,14 @@ Refer to the `go.mod` for version info. - `.. include::` - Include RST content from other files - `.. toctree::` - Table of contents (navigation, not content inclusion) +**Composables**: +- Defined in `snooty.toml` files at project/version root +- Canonical definitions also exist in `rstspec.toml` in the snooty-parser repository +- Used in `.. composable-tutorial::` directives with `:options:` parameter +- Enable context-specific documentation (e.g., different languages, deployment types) +- Each composable has an ID, title, default, and list of options +- The `internal/rst` module provides `FetchRstspec()` to retrieve canonical definitions + ### MongoDB Documentation Structure **Versioned Projects**: `content/{project}/{version}/source/` @@ -103,6 +118,83 @@ Refer to the `go.mod` for version info. **Tested Code Examples**: `content/code-examples/tested/{language}/{product}/` - Products: `pymongo`, `mongosh`, `go/driver`, `go/atlas-sdk`, `javascript/driver`, `java/driver-sync`, `csharp/driver` +## Configuration + +### Monorepo Path Configuration + +Some commands require a monorepo path (`analyze composables`, `count tested-examples`, `count pages`). The path can be configured in three ways, with the following priority (highest to lowest): + +1. **Command-line argument** - Passed directly to the command +2. **Environment variable** - `AUDIT_CLI_MONOREPO_PATH` +3. **Config file** - `.audit-cli.yaml` in current directory or home directory + +**Config File Format** (`.audit-cli.yaml`): +```yaml +monorepo_path: /path/to/docs-monorepo +``` + +**Config File Locations** (searched in order): +1. Current directory: `./.audit-cli.yaml` +2. Home directory: `~/.audit-cli.yaml` + +**Implementation**: +- Config loading is handled by `internal/config` package +- Commands use `config.GetMonorepoPath(cmdLineArg)` to resolve the path +- Commands accept 0 or 1 arguments using `cobra.MaximumNArgs(1)` +- If no path is configured, a helpful error message is displayed + +**Example Usage**: +```go +// In command RunE function +var cmdLineArg string +if len(args) > 0 { + cmdLineArg = args[0] +} +monorepoPath, err := config.GetMonorepoPath(cmdLineArg) +if err != nil { + return err +} +``` + +### File Path Resolution + +File-based commands support flexible path resolution through `config.ResolveFilePath()`. This allows users to specify paths in three ways: + +1. **Absolute path** - Used as-is +2. **Relative to monorepo root** - If monorepo is configured and path exists there +3. **Relative to current directory** - Fallback if not found in monorepo + +**Priority Order**: +1. If path is absolute → return as-is (after verifying it exists) +2. If monorepo is configured and path exists relative to monorepo → use monorepo-relative path +3. Otherwise → resolve relative to current directory + +**Implementation**: +- File path resolution is handled by `config.ResolveFilePath(pathArg)` in `internal/config` package +- Commands that take file paths should use this function in their `RunE` function +- The function returns an absolute path or an error if the path doesn't exist + +**Example Usage**: +```go +// In command RunE function for file-based commands +RunE: func(cmd *cobra.Command, args []string) error { + // Resolve file path (supports absolute, monorepo-relative, or cwd-relative) + filePath, err := config.ResolveFilePath(args[0]) + if err != nil { + return err + } + return runCommand(filePath, ...) +} +``` + +**Commands Using File Path Resolution**: +- `extract code-examples` +- `extract procedures` +- `analyze includes` +- `analyze usage` +- `search find-string` +- `compare file-contents` + ## Building and Running ### Build from Source diff --git a/CHANGELOG.md b/CHANGELOG.md index b6108c7..c31dea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,55 @@ All notable changes to audit-cli will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2025-12-12 + +### Added + +#### Analyze Commands +- `analyze composables` - Analyze composable definitions in snooty.toml files + - Inventory all composables across projects and versions + - Identify identical composables (same ID, title, and options) across different projects/versions + - Find similar composables with different IDs but overlapping option sets using Jaccard similarity (60% threshold) + - Track composable usage in RST files via `composable-tutorial` directives + - Identify unused composables that may be candidates for removal + - Flags: + - `--for-project` - Filter to a specific project + - `--current-only` - Only analyze current versions + - `--verbose` - Show full option details with titles + - `--find-similar` - Show identical and similar composables for consolidation + - `--find-usages` - Show where each composable is used in RST files with file paths + - `--with-rstspec` - Show canonical composable definitions from rstspec.toml + +#### Configuration System +- Monorepo path configuration via three methods (priority order): + 1. Command-line argument (highest priority) + 2. Environment variable `AUDIT_CLI_MONOREPO_PATH` + 3. Config file `.audit-cli.yaml` in current or home directory (lowest priority) +- Config file format: + ```yaml + monorepo_path: /path/to/docs-monorepo + ``` +- Applies to commands: `analyze composables`, `count tested-examples`, `count pages` + +#### File Path Resolution +- Flexible path resolution for all file-based commands +- Supports three path types (priority order): + 1. Absolute paths - Used as-is + 2. Relative to monorepo root - If monorepo configured and file exists there + 3. Relative to current directory - Fallback +- Applies to commands: `extract code-examples`, `extract procedures`, `analyze includes`, `analyze usage`, `search find-string`, `compare file-contents` +- Eliminates need to type full paths when working with monorepo files + +#### Internal Packages +- `internal/config` - Configuration management + - Config file loading from `.audit-cli.yaml` + - Environment variable support + - Monorepo path resolution with priority order + - File path resolution with flexible resolution +- `internal/rst` - Enhanced RST parsing + - `FetchRstspec()` - Fetches canonical composable definitions from snooty-parser rstspec.toml + - Provides standard composable IDs, titles, defaults, and options + ## [0.1.0] - 2025-12-10 ### Added diff --git a/README.md b/README.md index 974e138..b4667c8 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,110 @@ cd audit-cli go run main.go [command] [flags] ``` +## Configuration + +### Monorepo Path Configuration + +Some commands require a monorepo path (e.g., `analyze composables`, `count tested-examples`, `count pages`). You can configure the monorepo path in three ways, listed in order of priority: + +### 1. Command-Line Argument (Highest Priority) + +Pass the path directly to the command: + +```bash +./audit-cli analyze composables /path/to/docs-monorepo +./audit-cli count tested-examples /path/to/docs-monorepo +./audit-cli count pages /path/to/docs-monorepo +``` + +### 2. Environment Variable + +Set the `AUDIT_CLI_MONOREPO_PATH` environment variable: + +```bash +export AUDIT_CLI_MONOREPO_PATH=/path/to/docs-monorepo +./audit-cli analyze composables +./audit-cli count tested-examples +./audit-cli count pages +``` + +### 3. Config File (Lowest Priority) + +Create a `.audit-cli.yaml` file in either: +- Current directory: `./.audit-cli.yaml` +- Home directory: `~/.audit-cli.yaml` + +**Config file format:** + +```yaml +monorepo_path: /path/to/docs-monorepo +``` + +**Example:** + +```bash +# Create config file +cat > .audit-cli.yaml << EOF +monorepo_path: /Users/username/mongodb/docs-monorepo +EOF + +# Now you can run commands without specifying the path +./audit-cli analyze composables +./audit-cli count tested-examples --for-product pymongo +./audit-cli count pages --count-by-project +``` + +**Priority Example:** + +If you have all three configured, the command-line argument takes precedence: + +```bash +# Config file has: monorepo_path: /config/path +# Environment has: AUDIT_CLI_MONOREPO_PATH=/env/path +# Command-line argument: /cmd/path + +./audit-cli analyze composables /cmd/path # Uses /cmd/path +./audit-cli analyze composables # Uses /env/path (env overrides config) +``` + +### File Path Resolution + +File-based commands (e.g., `extract code-examples`, `analyze usage`, `compare file-contents`) support flexible path resolution. Paths can be specified in three ways: + +**1. Absolute Path** + +```bash +./audit-cli extract code-examples /full/path/to/file.rst +./audit-cli analyze usage /full/path/to/includes/fact.rst +``` + +**2. Relative to Monorepo Root** (if monorepo is configured) + +If you have a monorepo path configured (via config file or environment variable), you can use paths relative to the monorepo root: + +```bash +# With monorepo_path configured as /Users/username/mongodb/docs-monorepo +./audit-cli extract code-examples manual/manual/source/tutorial.rst +./audit-cli analyze usage manual/manual/source/includes/fact.rst +./audit-cli compare file-contents manual/manual/source/file.rst +``` + +**3. Relative to Current Directory** (fallback) + +If the path doesn't exist relative to the monorepo, it falls back to the current directory: + +```bash +./audit-cli extract code-examples ./local-file.rst +./audit-cli analyze includes ../other-dir/file.rst +``` + +**Priority Order:** +1. If path is absolute → use as-is +2. If monorepo is configured and path exists relative to monorepo → use monorepo-relative path +3. Otherwise → resolve relative to current directory + +This makes it convenient to work with files in the monorepo without typing full paths every time! + ## Usage The CLI is organized into parent commands with subcommands: @@ -65,7 +169,8 @@ audit-cli ├── analyze # Analyze RST file structures │ ├── includes │ ├── usage -│ └── procedures +│ ├── procedures +│ └── composables ├── compare # Compare files across versions │ └── file-contents └── count # Count code examples and documentation pages @@ -835,6 +940,214 @@ The parser ensures deterministic results by: For more details about procedure parsing logic, refer to [docs/PROCEDURE_PARSING.md](docs/PROCEDURE_PARSING.md). +#### `analyze composables` + +Analyze composable definitions in `snooty.toml` files across the MongoDB documentation monorepo. This command helps identify consolidation opportunities and track composable usage. + +Composables are configuration elements in `snooty.toml` that define content variations for different contexts (e.g., different programming languages, deployment types, or interfaces). They're used in `.. composable-tutorial::` directives to create context-specific documentation. + +**Use Cases:** + +This command helps writers: +- Inventory all composables across projects and versions +- Identify identical composables that could be consolidated across projects +- Find similar composables with different IDs but overlapping options (potential consolidation candidates) +- Track where composables are used in RST files +- Identify unused composables that may be candidates for removal +- Understand the scope of changes when updating a composable + +**Basic Usage:** + +```bash +# Analyze all composables in the monorepo +./audit-cli analyze composables /path/to/docs-monorepo + +# Use configured monorepo path (from config file or environment variable) +./audit-cli analyze composables + +# Analyze composables for a specific project +./audit-cli analyze composables --for-project atlas + +# Analyze only current versions +./audit-cli analyze composables --current-only + +# Show full option details with titles +./audit-cli analyze composables --verbose + +# Find consolidation candidates +./audit-cli analyze composables --find-similar + +# Find where composables are used +./audit-cli analyze composables --find-usages + +# Include canonical rstspec.toml composables +./audit-cli analyze composables --with-rstspec --find-similar + +# Combine flags for comprehensive analysis +./audit-cli analyze composables --for-project atlas --find-similar --find-usages --verbose +``` + +**Flags:** + +- `--for-project ` - Only analyze composables for a specific project +- `--current-only` - Only analyze composables in current versions (skips versioned directories) +- `-v, --verbose` - Show full option details with titles instead of just IDs +- `--find-similar` - Show identical and similar composables for consolidation +- `--find-usages` - Show where each composable is used in RST files +- `--with-rstspec` - Include composables from the canonical rstspec.toml file in the snooty-parser repository + +**Output:** + +**Default output (summary and table):** +``` +Composables Analysis +==================== + +Total composables found: 24 + +Composables by ID: + - deployment-type: 1 + - interface: 1 + - language: 1 + ... + +All Composables +=============== + +Project Version ID Title Options +------------------------------------------------------------------------------------------------------------------------ +atlas (none) deployment-type Deployment Type atlas, local, self, local-onprem +atlas (none) interface Interface compass, mongosh, atlas-ui, driver +atlas (none) language Language c, csharp, cpp, go, java-async, ... +``` + +**With `--find-similar`:** + +Shows two types of consolidation opportunities: + +1. **Identical Composables** - Same ID, title, and options across different projects/versions + ``` + Identical Composables (Consolidation Candidates) + ================================================ + + ID: connection-mechanism + Occurrences: 15 + Title: Connection Mechanism + Default: connection-string + Options: connection-string, mongocred + + Found in: + - java/current + - java/v5.1 + - kotlin/current + ... + ``` + +2. **Similar Composables** - Different IDs but similar option sets (60%+ overlap) + ``` + Similar Composables (Review Recommended) + ======================================== + + Similar Composables (100.0% similarity) + Composables: 2 + + Composables in this group: + + 1. ID: interface-atlas-only + Location: atlas + Title: Interface + Default: driver + Options: atlas-ui, driver, mongosh + + 2. ID: interface-local-only + Location: atlas + Title: Interface + Default: driver + Options: atlas-ui, driver, mongosh + ``` + +**With `--find-usages`:** + +Shows where each composable is used in `.. composable-tutorial::` directives: + +``` +Composable Usages +================= + +Composable ID: deployment-type +Total usages: 28 + + atlas: 28 usages + +Composable ID: interface +Total usages: 35 + + atlas: 35 usages + +Unused Composables +------------------ + + connection-type: + - atlas +``` + +**With `--verbose` and `--find-usages`:** + +Shows file paths where each composable is used: + +``` +Composable ID: interface-atlas-only +Total usages: 1 + + atlas: 1 usages + - content/atlas/source/atlas-vector-search/tutorials/vector-search-quick-start.txt +``` + +**Understanding Composables:** + +Composables are defined in `snooty.toml` files: +```toml +[[composables]] +id = "language" +title = "Language" +default = "nodejs" + +[[composables.options]] +id = "python" +title = "Python" + +[[composables.options]] +id = "nodejs" +title = "Node.js" +``` + +They're used in RST files with `.. composable-tutorial::` directives: +```rst +.. composable-tutorial:: + :options: language, interface + :defaults: nodejs, driver + + .. procedure:: + .. step:: Install dependencies + .. selected-content:: + :selections: language=nodejs + npm install mongodb + .. selected-content:: + :selections: language=python + pip install pymongo +``` + +**Consolidation Analysis:** + +The command uses Jaccard similarity (intersection / union) to compare option sets between composables with different IDs. A 60% similarity threshold is used to identify potential consolidation candidates. + +For example, if you have: +- `language` with 15 options +- `language-atlas-only` with 14 options (13 in common with `language`) +- `language-local-only` with 14 options (13 in common with `language`) + +These would be flagged as similar composables (93.3% similarity) and potential consolidation candidates. + ### Compare Commands #### `compare file-contents` @@ -1025,14 +1338,17 @@ This command helps writers and maintainers: # Get total count of all tested code examples ./audit-cli count tested-examples /path/to/docs-monorepo +# Use configured monorepo path (from config file or environment variable) +./audit-cli count tested-examples + # Count examples for a specific product -./audit-cli count tested-examples /path/to/docs-monorepo --for-product pymongo +./audit-cli count tested-examples --for-product pymongo # Show counts broken down by product -./audit-cli count tested-examples /path/to/docs-monorepo --count-by-product +./audit-cli count tested-examples --count-by-product # Count only source files (exclude .txt and .sh output files) -./audit-cli count tested-examples /path/to/docs-monorepo --exclude-output +./audit-cli count tested-examples --exclude-output ``` **Flags:** @@ -1088,20 +1404,23 @@ The command automatically excludes: # Get total count of all documentation pages ./audit-cli count pages /path/to/docs-monorepo +# Use configured monorepo path (from config file or environment variable) +./audit-cli count pages + # Count pages for a specific project -./audit-cli count pages /path/to/docs-monorepo --for-project manual +./audit-cli count pages --for-project manual # Show counts broken down by project -./audit-cli count pages /path/to/docs-monorepo --count-by-project +./audit-cli count pages --count-by-project # Exclude specific directories from counting -./audit-cli count pages /path/to/docs-monorepo --exclude-dirs api-reference,generated +./audit-cli count pages --exclude-dirs api-reference,generated # Count only current versions (for versioned projects) -./audit-cli count pages /path/to/docs-monorepo --current-only +./audit-cli count pages --current-only # Show counts by project and version -./audit-cli count pages /path/to/docs-monorepo --by-version +./audit-cli count pages --by-version # Combine flags: count pages for a specific project, excluding certain directories ./audit-cli count pages /path/to/docs-monorepo --for-project atlas --exclude-dirs deprecated @@ -1217,6 +1536,16 @@ audit-cli/ │ │ └── report.go # Report generation │ ├── analyze/ # Analyze parent command │ │ ├── analyze.go # Parent command definition +│ │ ├── composables/ # Composables analysis subcommand +│ │ │ ├── composables.go # Command logic +│ │ │ ├── composables_test.go # Tests +│ │ │ ├── analyzer.go # Composable analysis logic +│ │ │ ├── parser.go # Snooty.toml parsing +│ │ │ ├── rstspec_adapter.go # Rstspec.toml adapter +│ │ │ ├── rstspec_adapter_test.go # Rstspec adapter tests +│ │ │ ├── usage_finder.go # Usage finding logic +│ │ │ ├── output.go # Output formatting +│ │ │ └── types.go # Type definitions │ │ ├── includes/ # Includes analysis subcommand │ │ │ ├── includes.go # Command logic │ │ │ ├── analyzer.go # Include tree building @@ -1259,6 +1588,9 @@ audit-cli/ │ ├── output.go # Output formatting │ └── types.go # Type definitions ├── internal/ # Internal packages +│ ├── config/ # Configuration management +│ │ ├── config.go # Config loading and path resolution +│ │ └── config_test.go # Config tests │ ├── projectinfo/ # Project structure and info utilities │ │ ├── pathresolver.go # Core path resolution │ │ ├── pathresolver_test.go # Tests @@ -1275,6 +1607,8 @@ audit-cli/ │ ├── get_procedure_variations.go # Variation extraction logic │ ├── get_procedure_variations_test.go # Variation tests │ ├── procedure_types.go # Procedure type definitions +│ ├── rstspec.go # Rstspec.toml fetching and parsing +│ ├── rstspec_test.go # Rstspec tests │ └── file_utils.go # File utilities └── testdata/ # Test fixtures ├── input-files/ # Test RST files @@ -1283,14 +1617,17 @@ audit-cli/ │ ├── includes/ # Included RST files │ └── code-examples/ # Code files for literalinclude ├── expected-output/ # Expected extraction results + ├── composables-test/ # Composables analysis test data + │ └── content/ # Test monorepo structure ├── compare/ # Compare command test data │ ├── product/ # Version structure tests │ │ ├── manual/ # Manual version │ │ ├── upcoming/ # Upcoming version │ │ └── v8.0/ # v8.0 version │ └── *.txt # Direct comparison tests - └── count-test-monorepo/ # Count command test data - └── content/code-examples/tested/ # Tested examples structure + ├── count-test-monorepo/ # Count command test data + │ └── content/code-examples/tested/ # Tested examples structure + └── search-test-files/ # Search command test data ``` ### Adding New Commands @@ -1629,6 +1966,30 @@ func traverseDirectory(rootPath string, recursive bool) ([]string, error) { } ``` +**Path Resolution for File-Based Commands:** + +Commands that accept file paths should use `config.ResolveFilePath()` to support flexible path resolution: + +```go +import "github.com/grove-platform/audit-cli/internal/config" + +RunE: func(cmd *cobra.Command, args []string) error { + // Resolve file path (supports absolute, monorepo-relative, or cwd-relative) + filePath, err := config.ResolveFilePath(args[0]) + if err != nil { + return err + } + + // Use the resolved absolute path + return processFile(filePath) +} +``` + +This allows users to specify paths as: +- Absolute: `/full/path/to/file.rst` +- Monorepo-relative: `manual/manual/source/file.rst` (if monorepo configured) +- Current directory-relative: `./file.rst` + #### 5. Testing Pattern Use table-driven tests where appropriate: @@ -1863,6 +2224,32 @@ used as the base for resolving relative include paths. ## Internal Packages +### `internal/config` + +Provides configuration management for the CLI tool: + +- **Config file loading** - Loads `.audit-cli.yaml` from current or home directory +- **Environment variable support** - Reads `AUDIT_CLI_MONOREPO_PATH` environment variable +- **Monorepo path resolution** - Resolves monorepo path with priority: CLI arg > env var > config file +- **File path resolution** - Resolves file paths as absolute, monorepo-relative, or cwd-relative + +**Key Functions:** +- `LoadConfig()` - Loads configuration from file or environment +- `GetMonorepoPath(cmdLineArg string)` - Resolves monorepo path with priority order +- `ResolveFilePath(pathArg string)` - Resolves file paths with flexible resolution + +**Priority Order for Monorepo Path:** +1. Command-line argument (highest priority) +2. Environment variable `AUDIT_CLI_MONOREPO_PATH` +3. Config file `.audit-cli.yaml` (lowest priority) + +**Priority Order for File Paths:** +1. Absolute path (used as-is) +2. Relative to monorepo root (if monorepo configured and file exists there) +3. Relative to current directory (fallback) + +See the code in `internal/config/` for implementation details. + ### `internal/projectinfo` Provides centralized utilities for understanding MongoDB documentation project structure: @@ -1889,9 +2276,28 @@ Provides reusable utilities for parsing and processing RST files: - **Include resolution** - Handles all include directive patterns - **Directory traversal** - Recursive file scanning - **Directive parsing** - Extracts structured data from RST directives +- **Procedure parsing** - Parses procedure directives, ordered lists, and variations +- **Procedure variations** - Extracts variations from composable tutorials and tabs +- **Rstspec.toml fetching** - Fetches and parses canonical composable definitions from snooty-parser - **Template variable resolution** - Resolves YAML-based template variables - **Source directory detection** - Finds the documentation root +**Key Functions:** +- `ParseFileWithIncludes(filePath string)` - Parses RST file with include expansion +- `ParseDirectives(content string)` - Extracts directive information from RST content +- `ParseProcedures(filePath string, expandIncludes bool)` - Parses procedures from RST file +- `GetProcedureVariations(filePath string)` - Extracts procedure variations +- `FetchRstspec()` - Fetches and parses canonical rstspec.toml from snooty-parser repository + +**Rstspec.toml Support:** +The `FetchRstspec()` function retrieves the canonical composable definitions from the snooty-parser repository. This provides: +- Standard composable IDs (e.g., `interface`, `language`, `deployment-type`) +- Composable titles and descriptions +- Default values for each composable +- Available options for each composable + +This is used by the `analyze composables` command to show canonical definitions alongside project-specific ones. + See the code in `internal/rst/` for implementation details. ## Language Normalization diff --git a/commands/analyze/analyze.go b/commands/analyze/analyze.go index bd0881f..89c7e96 100644 --- a/commands/analyze/analyze.go +++ b/commands/analyze/analyze.go @@ -5,11 +5,13 @@ // - includes: Analyze include directive relationships in RST files // - usage: Find all files that use a target file // - procedures: Analyze procedure variations and statistics +// - composables: Analyze composables in snooty.toml files // // Future subcommands could include analyzing cross-references, broken links, or content metrics. package analyze import ( + "github.com/grove-platform/audit-cli/commands/analyze/composables" "github.com/grove-platform/audit-cli/commands/analyze/includes" "github.com/grove-platform/audit-cli/commands/analyze/procedures" "github.com/grove-platform/audit-cli/commands/analyze/usage" @@ -30,6 +32,7 @@ Currently supports: - includes: Analyze include directive relationships (forward dependencies) - usage: Find all files that use a target file (reverse dependencies) - procedures: Analyze procedure variations and statistics + - composables: Analyze composables in snooty.toml files Future subcommands may support analyzing cross-references, broken links, or content metrics.`, } @@ -38,6 +41,7 @@ Future subcommands may support analyzing cross-references, broken links, or cont cmd.AddCommand(includes.NewIncludesCommand()) cmd.AddCommand(usage.NewUsageCommand()) cmd.AddCommand(procedures.NewProceduresCommand()) + cmd.AddCommand(composables.NewComposablesCommand()) return cmd } diff --git a/commands/analyze/composables/analyzer.go b/commands/analyze/composables/analyzer.go new file mode 100644 index 0000000..3ce55a7 --- /dev/null +++ b/commands/analyze/composables/analyzer.go @@ -0,0 +1,256 @@ +// Package composables provides functionality for analyzing composables in snooty.toml files. +package composables + +import ( + "sort" +) + +// AnalyzeComposables analyzes composables and groups them by similarity. +// +// This function identifies: +// 1. Identical composables across projects (same ID, same options) - consolidation candidates +// 2. Similar composables with different IDs but overlapping options - potential consolidation +// +// Parameters: +// - locations: All composable locations found in the monorepo +// +// Returns: +// - *AnalysisResult: Analysis results with grouped composables +func AnalyzeComposables(locations []ComposableLocation) *AnalysisResult { + result := &AnalysisResult{ + AllComposables: locations, + IdenticalGroups: []ComposableGroup{}, + SimilarGroups: []ComposableGroup{}, + } + + // Group composables by ID + groupsByID := make(map[string][]ComposableLocation) + for _, loc := range locations { + id := loc.Composable.ID + groupsByID[id] = append(groupsByID[id], loc) + } + + // Find identical composables (same ID appearing in multiple projects) + for id, locs := range groupsByID { + if len(locs) <= 1 { + continue + } + + // Check if all composables with this ID are identical + if areComposablesIdentical(locs) { + result.IdenticalGroups = append(result.IdenticalGroups, ComposableGroup{ + ID: id, + Locations: locs, + Similarity: 1.0, + }) + } + } + + // Find similar composables (different IDs but similar option sets) + result.SimilarGroups = findSimilarComposables(locations, groupsByID) + + // Sort groups by ID for consistent output + sort.Slice(result.IdenticalGroups, func(i, j int) bool { + return result.IdenticalGroups[i].ID < result.IdenticalGroups[j].ID + }) + sort.Slice(result.SimilarGroups, func(i, j int) bool { + // Sort by similarity (descending), then by first ID + if result.SimilarGroups[i].Similarity != result.SimilarGroups[j].Similarity { + return result.SimilarGroups[i].Similarity > result.SimilarGroups[j].Similarity + } + return result.SimilarGroups[i].ID < result.SimilarGroups[j].ID + }) + + return result +} + +// areComposablesIdentical checks if all composables in a group are identical. +func areComposablesIdentical(locs []ComposableLocation) bool { + if len(locs) <= 1 { + return true + } + + first := locs[0].Composable + for i := 1; i < len(locs); i++ { + if !composablesEqual(first, locs[i].Composable) { + return false + } + } + return true +} + +// composablesEqual checks if two composables are identical. +func composablesEqual(a, b Composable) bool { + // Compare basic fields + if a.ID != b.ID || a.Title != b.Title || a.Default != b.Default { + return false + } + + // Compare options + if len(a.Options) != len(b.Options) { + return false + } + + // Create sorted option strings for comparison + aOpts := optionsToSortedStrings(a.Options) + bOpts := optionsToSortedStrings(b.Options) + + for i := range aOpts { + if aOpts[i] != bOpts[i] { + return false + } + } + + return true +} + +// optionsToSortedStrings converts options to sorted strings for comparison. +func optionsToSortedStrings(options []ComposableOption) []string { + var strs []string + for _, opt := range options { + strs = append(strs, opt.ID+":"+opt.Title) + } + sort.Strings(strs) + return strs +} + +// findSimilarComposables finds composables with different IDs but similar option sets. +// This helps identify potential consolidation opportunities across different composable IDs. +func findSimilarComposables(locations []ComposableLocation, groupsByID map[string][]ComposableLocation) []ComposableGroup { + const similarityThreshold = 0.6 // At least 60% option overlap to be considered similar + + var similarGroups []ComposableGroup + + // Get unique composables (one per ID, preferring the one with most options) + uniqueComposables := make(map[string]ComposableLocation) + for id, locs := range groupsByID { + // Pick the composable with the most options as the representative + representative := locs[0] + for _, loc := range locs { + if len(loc.Composable.Options) > len(representative.Composable.Options) { + representative = loc + } + } + uniqueComposables[id] = representative + } + + // Get sorted list of IDs for deterministic iteration + var ids []string + for id := range uniqueComposables { + ids = append(ids, id) + } + sort.Strings(ids) + + // Compare each pair of composables with different IDs + processed := make(map[string]bool) + for i := 0; i < len(ids); i++ { + id1 := ids[i] + if processed[id1] { + continue + } + + loc1 := uniqueComposables[id1] + var similarLocs []ComposableLocation + similarLocs = append(similarLocs, loc1) + + for j := i + 1; j < len(ids); j++ { + id2 := ids[j] + if processed[id2] { + continue + } + + loc2 := uniqueComposables[id2] + similarity := calculateOptionSimilarity(loc1.Composable, loc2.Composable) + + if similarity >= similarityThreshold { + similarLocs = append(similarLocs, loc2) + processed[id2] = true + } + } + + // If we found similar composables, create a group + if len(similarLocs) > 1 { + processed[id1] = true + + // Calculate average similarity across all in the group + avgSimilarity := calculateGroupSimilarity(similarLocs) + + // Create a combined ID showing all the IDs in the group + var combinedIDs []string + for _, loc := range similarLocs { + combinedIDs = append(combinedIDs, loc.Composable.ID) + } + sort.Strings(combinedIDs) + + similarGroups = append(similarGroups, ComposableGroup{ + ID: combinedIDs[0], // Use first ID for sorting + Locations: similarLocs, + Similarity: avgSimilarity, + }) + } + } + + return similarGroups +} + +// calculateOptionSimilarity calculates the Jaccard similarity between two composables' option sets. +// Returns a value between 0 and 1, where 1 means identical option sets. +func calculateOptionSimilarity(a, b Composable) float64 { + // Get option IDs for both composables + aOptions := make(map[string]bool) + for _, opt := range a.Options { + aOptions[opt.ID] = true + } + + bOptions := make(map[string]bool) + for _, opt := range b.Options { + bOptions[opt.ID] = true + } + + // Calculate intersection and union + intersection := 0 + union := make(map[string]bool) + + for opt := range aOptions { + union[opt] = true + if bOptions[opt] { + intersection++ + } + } + + for opt := range bOptions { + union[opt] = true + } + + if len(union) == 0 { + return 0.0 + } + + // Jaccard similarity = intersection / union + return float64(intersection) / float64(len(union)) +} + +// calculateGroupSimilarity calculates the average pairwise similarity within a group. +func calculateGroupSimilarity(locs []ComposableLocation) float64 { + if len(locs) <= 1 { + return 1.0 + } + + totalSimilarity := 0.0 + comparisons := 0 + + for i := 0; i < len(locs); i++ { + for j := i + 1; j < len(locs); j++ { + similarity := calculateOptionSimilarity(locs[i].Composable, locs[j].Composable) + totalSimilarity += similarity + comparisons++ + } + } + + if comparisons == 0 { + return 1.0 + } + + return totalSimilarity / float64(comparisons) +} + diff --git a/commands/analyze/composables/composables.go b/commands/analyze/composables/composables.go new file mode 100644 index 0000000..1393047 --- /dev/null +++ b/commands/analyze/composables/composables.go @@ -0,0 +1,174 @@ +// Package composables provides functionality for analyzing composables in snooty.toml files. +// +// This package implements the "analyze composables" subcommand, which scans the MongoDB +// documentation monorepo for snooty.toml files and analyzes the composables defined in them. +// It helps identify opportunities for consolidation by finding identical or similar composables +// across projects and versions. +package composables + +import ( + "fmt" + + "github.com/grove-platform/audit-cli/internal/config" + "github.com/spf13/cobra" +) + +// NewComposablesCommand creates the composables subcommand for analysis. +// +// This command analyzes composables defined in snooty.toml files across the MongoDB +// documentation monorepo. It identifies: +// - All composables and their locations +// - Identical composables that could be consolidated +// - Similar composables that should be reviewed +// +// Usage: +// +// analyze composables /path/to/docs-monorepo +// analyze composables /path/to/docs-monorepo --for-project manual +// analyze composables /path/to/docs-monorepo --current-only +// +// Flags: +// - --for-project: Only analyze composables for a specific project +// - --current-only: Only analyze composables in current versions +// - --verbose: Show full option details with titles +// - --find-similar: Show identical and similar composables for consolidation +// - --find-usages: Show where each composable is used in RST files +// - --with-rstspec: Include composables from the canonical rstspec.toml file +func NewComposablesCommand() *cobra.Command { + var ( + forProject string + currentOnly bool + verbose bool + findSimilar bool + findUsages bool + withRstspec bool + ) + + cmd := &cobra.Command{ + Use: "composables [monorepo-path]", + Short: "Analyze composables in snooty.toml files", + Long: `Analyze composables defined in snooty.toml files across the MongoDB documentation monorepo. + +This command scans all snooty.toml files in the monorepo and analyzes the composables +defined in them. + +Composables are configuration elements in snooty.toml that define content variations +for different contexts (e.g., different programming languages, deployment types, or interfaces). + +By default, the output includes: + - A summary of all composables grouped by ID + - A detailed table of all composables found + +With --find-similar, the output also includes: + - Identical composables (same ID, title, and options) across different projects/versions + - Similar composables (different IDs but similar option sets) that may be consolidation candidates + +With --find-usages, the output also includes: + - Usage count for each composable + - File paths where each composable is used in composable-tutorial directives + +With --with-rstspec, the analysis also includes: + - Composables from the canonical rstspec.toml file in the snooty-parser repository + - Helps identify duplication between local snooty.toml files and the canonical definitions + +Monorepo Path Configuration: + The monorepo path can be specified in three ways (in order of priority): + 1. Command-line argument: analyze composables /path/to/monorepo + 2. Environment variable: export AUDIT_CLI_MONOREPO_PATH=/path/to/monorepo + 3. Config file (.audit-cli.yaml): + monorepo_path: /path/to/monorepo + +Examples: + # Analyze all composables in the monorepo + analyze composables /path/to/docs-monorepo + + # Use configured monorepo path + analyze composables + + # Analyze composables for a specific project + analyze composables --for-project manual + + # Analyze only current versions + analyze composables --current-only + + # Show full option details + analyze composables --verbose + + # Find consolidation candidates + analyze composables --find-similar + + # Find where composables are used + analyze composables --find-usages + + # Include canonical rstspec.toml composables + analyze composables --with-rstspec --find-similar + + # Combine flags + analyze composables --for-project atlas --find-similar --find-usages --verbose`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // Resolve monorepo path from args, env, or config + var cmdLineArg string + if len(args) > 0 { + cmdLineArg = args[0] + } + monorepoPath, err := config.GetMonorepoPath(cmdLineArg) + if err != nil { + return err + } + return runComposables(monorepoPath, forProject, currentOnly, verbose, findSimilar, findUsages, withRstspec) + }, + } + + cmd.Flags().StringVar(&forProject, "for-project", "", "Only analyze composables for a specific project") + cmd.Flags().BoolVar(¤tOnly, "current-only", false, "Only analyze composables in current versions") + cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Show full option details with titles") + cmd.Flags().BoolVar(&findSimilar, "find-similar", false, "Show identical and similar composables for consolidation") + cmd.Flags().BoolVar(&findUsages, "find-usages", false, "Show where each composable is used in RST files") + cmd.Flags().BoolVar(&withRstspec, "with-rstspec", false, "Include composables from the canonical rstspec.toml file") + + return cmd +} + +// runComposables executes the composables analysis operation. +func runComposables(monorepoPath string, forProject string, currentOnly bool, verbose bool, findSimilar bool, findUsages bool, withRstspec bool) error { + // Find all snooty.toml files and extract composables + locations, err := FindSnootyTOMLFiles(monorepoPath, forProject, currentOnly) + if err != nil { + return fmt.Errorf("failed to find snooty.toml files: %w", err) + } + + // Fetch rstspec.toml composables if requested + if withRstspec { + fmt.Println("Fetching composables from rstspec.toml...") + rstspecLocations, err := FetchRstspecComposables() + if err != nil { + return fmt.Errorf("failed to fetch rstspec.toml composables: %w", err) + } + fmt.Printf("Found %d composables in rstspec.toml\n", len(rstspecLocations)) + locations = append(locations, rstspecLocations...) + } + + if len(locations) == 0 { + fmt.Println("No composables found in the monorepo.") + return nil + } + + // Analyze the composables + result := AnalyzeComposables(locations) + + // Find usages if requested + var usages map[string]*ComposableUsage + if findUsages { + usages, err = FindComposableUsages(monorepoPath, result.AllComposables, forProject, currentOnly) + if err != nil { + return fmt.Errorf("failed to find composable usages: %w", err) + } + } + + // Print the results + PrintResults(result, verbose, findSimilar, findUsages, usages) + + return nil +} + diff --git a/commands/analyze/composables/composables_test.go b/commands/analyze/composables/composables_test.go new file mode 100644 index 0000000..6722f8d --- /dev/null +++ b/commands/analyze/composables/composables_test.go @@ -0,0 +1,388 @@ +// Package composables provides tests for the composables analysis functionality. +package composables + +import ( + "path/filepath" + "testing" +) + +// TestFindSnootyTOMLFiles tests finding snooty.toml files in the test monorepo. +func TestFindSnootyTOMLFiles(t *testing.T) { + testDataDir := filepath.Join("..", "..", "..", "testdata", "composables-test") + + locations, err := FindSnootyTOMLFiles(testDataDir, "", false) + if err != nil { + t.Fatalf("FindSnootyTOMLFiles failed: %v", err) + } + + // Expected: project1 (2 composables) + project2/current (2) + project2/v1.0 (2) = 6 total + expectedTotal := 6 + if len(locations) != expectedTotal { + t.Errorf("Expected %d composables, got %d", expectedTotal, len(locations)) + } + + // Check that we have composables from both projects + projectCounts := make(map[string]int) + for _, loc := range locations { + projectCounts[loc.Project]++ + } + + if projectCounts["project1"] != 2 { + t.Errorf("Expected 2 composables from project1, got %d", projectCounts["project1"]) + } + + if projectCounts["project2"] != 4 { + t.Errorf("Expected 4 composables from project2, got %d", projectCounts["project2"]) + } +} + +// TestFindSnootyTOMLFilesForProject tests filtering by project. +func TestFindSnootyTOMLFilesForProject(t *testing.T) { + testDataDir := filepath.Join("..", "..", "..", "testdata", "composables-test") + + locations, err := FindSnootyTOMLFiles(testDataDir, "project1", false) + if err != nil { + t.Fatalf("FindSnootyTOMLFiles failed: %v", err) + } + + // Expected: only project1 composables (2) + expectedTotal := 2 + if len(locations) != expectedTotal { + t.Errorf("Expected %d composables, got %d", expectedTotal, len(locations)) + } + + // All should be from project1 + for _, loc := range locations { + if loc.Project != "project1" { + t.Errorf("Expected all composables from project1, got %s", loc.Project) + } + } +} + +// TestFindSnootyTOMLFilesCurrentOnly tests filtering to current versions only. +func TestFindSnootyTOMLFilesCurrentOnly(t *testing.T) { + testDataDir := filepath.Join("..", "..", "..", "testdata", "composables-test") + + locations, err := FindSnootyTOMLFiles(testDataDir, "", true) + if err != nil { + t.Fatalf("FindSnootyTOMLFiles failed: %v", err) + } + + // Expected: project1 (2, non-versioned) + project2/current (2) = 4 total + // Should NOT include project2/v1.0 + expectedTotal := 4 + if len(locations) != expectedTotal { + t.Errorf("Expected %d composables, got %d", expectedTotal, len(locations)) + } + + // Check that we don't have v1.0 + for _, loc := range locations { + if loc.Version == "v1.0" { + t.Errorf("Expected no v1.0 composables with --current-only, got one from %s", loc.Project) + } + } +} + +// TestParseSnootyTOML tests parsing a snooty.toml file. +func TestParseSnootyTOML(t *testing.T) { + testDataDir := filepath.Join("..", "..", "..", "testdata", "composables-test") + snootyPath := filepath.Join(testDataDir, "content", "project1", "snooty.toml") + + composables, err := ParseSnootyTOML(snootyPath) + if err != nil { + t.Fatalf("ParseSnootyTOML failed: %v", err) + } + + // Expected: 2 composables (interface and language) + expectedCount := 2 + if len(composables) != expectedCount { + t.Errorf("Expected %d composables, got %d", expectedCount, len(composables)) + } + + // Check interface composable + var interfaceComp *Composable + for i := range composables { + if composables[i].ID == "interface" { + interfaceComp = &composables[i] + break + } + } + + if interfaceComp == nil { + t.Fatal("Expected to find 'interface' composable") + } + + if interfaceComp.Title != "Interface" { + t.Errorf("Expected interface title 'Interface', got '%s'", interfaceComp.Title) + } + + if interfaceComp.Default != "driver" { + t.Errorf("Expected interface default 'driver', got '%s'", interfaceComp.Default) + } + + // Check options + expectedOptions := 3 // atlas-ui, driver, mongosh + if len(interfaceComp.Options) != expectedOptions { + t.Errorf("Expected %d options, got %d", expectedOptions, len(interfaceComp.Options)) + } +} + +// TestAnalyzeComposables tests the analysis functionality. +func TestAnalyzeComposables(t *testing.T) { + testDataDir := filepath.Join("..", "..", "..", "testdata", "composables-test") + + locations, err := FindSnootyTOMLFiles(testDataDir, "", false) + if err != nil { + t.Fatalf("FindSnootyTOMLFiles failed: %v", err) + } + + result := AnalyzeComposables(locations) + + // Check total composables + if len(result.AllComposables) != 6 { + t.Errorf("Expected 6 total composables, got %d", len(result.AllComposables)) + } +} + +// TestIdenticalComposables tests detection of identical composables. +func TestIdenticalComposables(t *testing.T) { + testDataDir := filepath.Join("..", "..", "..", "testdata", "composables-test") + + locations, err := FindSnootyTOMLFiles(testDataDir, "", false) + if err != nil { + t.Fatalf("FindSnootyTOMLFiles failed: %v", err) + } + + result := AnalyzeComposables(locations) + + // Expected: "interface" composable appears 3 times identically + // (project1, project2/current, project2/v1.0) + if len(result.IdenticalGroups) != 1 { + t.Errorf("Expected 1 identical group, got %d", len(result.IdenticalGroups)) + } + + if len(result.IdenticalGroups) > 0 { + interfaceGroup := result.IdenticalGroups[0] + if interfaceGroup.ID != "interface" { + t.Errorf("Expected identical group ID 'interface', got '%s'", interfaceGroup.ID) + } + + if len(interfaceGroup.Locations) != 3 { + t.Errorf("Expected 3 locations for interface composable, got %d", len(interfaceGroup.Locations)) + } + } +} + +// TestSimilarComposables tests detection of similar composables with different IDs. +// Note: The current test data doesn't have composables with different IDs but similar options, +// so we don't expect any similar groups. This test verifies the analysis runs without error. +func TestSimilarComposables(t *testing.T) { + testDataDir := filepath.Join("..", "..", "..", "testdata", "composables-test") + + locations, err := FindSnootyTOMLFiles(testDataDir, "", false) + if err != nil { + t.Fatalf("FindSnootyTOMLFiles failed: %v", err) + } + + result := AnalyzeComposables(locations) + + // With current test data, we don't expect similar groups + // (no composables with different IDs but similar option sets) + if len(result.SimilarGroups) != 0 { + t.Errorf("Expected 0 similar groups, got %d", len(result.SimilarGroups)) + } + + // Verify we still have the expected identical groups + if len(result.IdenticalGroups) != 1 { + t.Errorf("Expected 1 identical group, got %d", len(result.IdenticalGroups)) + } + + if len(result.IdenticalGroups) > 0 { + interfaceGroup := result.IdenticalGroups[0] + if interfaceGroup.ID != "interface" { + t.Errorf("Expected identical group ID 'interface', got '%s'", interfaceGroup.ID) + } + + if len(interfaceGroup.Locations) != 3 { + t.Errorf("Expected 3 locations for interface composable, got %d", len(interfaceGroup.Locations)) + } + } +} + +// TestCalculateOptionSimilarity tests the Jaccard similarity calculation. +func TestCalculateOptionSimilarity(t *testing.T) { + // Test identical option sets + comp1 := Composable{ + ID: "test1", + Title: "Test 1", + Options: []ComposableOption{ + {ID: "a", Title: "A"}, + {ID: "b", Title: "B"}, + {ID: "c", Title: "C"}, + }, + } + + comp2 := Composable{ + ID: "test2", + Title: "Test 2", + Options: []ComposableOption{ + {ID: "a", Title: "A"}, + {ID: "b", Title: "B"}, + {ID: "c", Title: "C"}, + }, + } + + similarity := calculateOptionSimilarity(comp1, comp2) + if similarity != 1.0 { + t.Errorf("Expected similarity 1.0 for identical options, got %f", similarity) + } + + // Test partial overlap + comp3 := Composable{ + ID: "test3", + Title: "Test 3", + Options: []ComposableOption{ + {ID: "a", Title: "A"}, + {ID: "b", Title: "B"}, + }, + } + + // comp1 has {a, b, c}, comp3 has {a, b} + // intersection = 2, union = 3, similarity = 2/3 = 0.667 + similarity = calculateOptionSimilarity(comp1, comp3) + expected := 2.0 / 3.0 + tolerance := 0.01 + if similarity < expected-tolerance || similarity > expected+tolerance { + t.Errorf("Expected similarity %.3f, got %.3f", expected, similarity) + } + + // Test no overlap + comp4 := Composable{ + ID: "test4", + Title: "Test 4", + Options: []ComposableOption{ + {ID: "x", Title: "X"}, + {ID: "y", Title: "Y"}, + }, + } + + similarity = calculateOptionSimilarity(comp1, comp4) + if similarity != 0.0 { + t.Errorf("Expected similarity 0.0 for no overlap, got %f", similarity) + } +} + +// TestComposablesEqual tests the composable equality function. +func TestComposablesEqual(t *testing.T) { + comp1 := Composable{ + ID: "test", + Title: "Test", + Default: "option1", + Options: []ComposableOption{ + {ID: "option1", Title: "Option 1"}, + {ID: "option2", Title: "Option 2"}, + }, + } + + comp2 := Composable{ + ID: "test", + Title: "Test", + Default: "option1", + Options: []ComposableOption{ + {ID: "option1", Title: "Option 1"}, + {ID: "option2", Title: "Option 2"}, + }, + } + + comp3 := Composable{ + ID: "test", + Title: "Test", + Default: "option1", + Options: []ComposableOption{ + {ID: "option1", Title: "Option 1"}, + {ID: "option3", Title: "Option 3"}, + }, + } + + if !composablesEqual(comp1, comp2) { + t.Error("Expected comp1 and comp2 to be equal") + } + + if composablesEqual(comp1, comp3) { + t.Error("Expected comp1 and comp3 to be different") + } +} + +// TestExtractProjectAndVersion tests the project and version extraction. +func TestExtractProjectAndVersion(t *testing.T) { + tests := []struct { + path string + expectedProject string + expectedVersion string + }{ + { + path: "project1/snooty.toml", + expectedProject: "project1", + expectedVersion: "", + }, + { + path: "project2/v1.0/snooty.toml", + expectedProject: "project2", + expectedVersion: "v1.0", + }, + { + path: "project2/current/snooty.toml", + expectedProject: "project2", + expectedVersion: "current", + }, + } + + for _, tt := range tests { + project, version := extractProjectAndVersion(tt.path) + if project != tt.expectedProject { + t.Errorf("For path %s, expected project '%s', got '%s'", tt.path, tt.expectedProject, project) + } + if version != tt.expectedVersion { + t.Errorf("For path %s, expected version '%s', got '%s'", tt.path, tt.expectedVersion, version) + } + } +} + +// TestFormatOptionsAsBullets tests the bullet formatting function. +func TestFormatOptionsAsBullets(t *testing.T) { + options := []ComposableOption{ + {ID: "option1", Title: "Option 1"}, + {ID: "option2", Title: "Option 2"}, + {ID: "option3", Title: "Option 3"}, + } + + lines := formatOptionsAsBullets(options) + + expectedCount := 3 + if len(lines) != expectedCount { + t.Errorf("Expected %d lines, got %d", expectedCount, len(lines)) + } + + // Check format of first line + expected := "• option1: Option 1" + if lines[0] != expected { + t.Errorf("Expected first line '%s', got '%s'", expected, lines[0]) + } +} + +// TestFormatOptions tests the comma-separated formatting function. +func TestFormatOptions(t *testing.T) { + options := []ComposableOption{ + {ID: "option1", Title: "Option 1"}, + {ID: "option2", Title: "Option 2"}, + {ID: "option3", Title: "Option 3"}, + } + + result := formatOptions(options) + + expected := "option1, option2, option3" + if result != expected { + t.Errorf("Expected '%s', got '%s'", expected, result) + } +} + diff --git a/commands/analyze/composables/output.go b/commands/analyze/composables/output.go new file mode 100644 index 0000000..5ab6173 --- /dev/null +++ b/commands/analyze/composables/output.go @@ -0,0 +1,462 @@ +// Package composables provides functionality for analyzing composables in snooty.toml files. +package composables + +import ( + "fmt" + "sort" + "strings" +) + +// PrintResults prints the analysis results in a formatted table. +func PrintResults(result *AnalysisResult, verbose bool, findSimilar bool, findUsages bool, usages map[string]*ComposableUsage) { + fmt.Printf("Composables Analysis\n") + fmt.Printf("====================\n\n") + + fmt.Printf("Total composables found: %d\n\n", len(result.AllComposables)) + + // Print summary by ID + printSummaryByID(result) + + // Print identical and similar groups only if requested + if findSimilar { + // Print identical groups + if len(result.IdenticalGroups) > 0 { + fmt.Printf("\nIdentical Composables (Consolidation Candidates)\n") + fmt.Printf("================================================\n\n") + for i, group := range result.IdenticalGroups { + printComposableGroup(group, true, verbose) + // Add separator between groups (but not after the last one) + if i < len(result.IdenticalGroups)-1 { + fmt.Printf("\n%s\n\n", strings.Repeat("-", 80)) + } + } + } + + // Print similar groups + if len(result.SimilarGroups) > 0 { + fmt.Printf("\nSimilar Composables (Review Recommended)\n") + fmt.Printf("========================================\n\n") + for i, group := range result.SimilarGroups { + printComposableGroup(group, false, verbose) + // Add separator between groups (but not after the last one) + if i < len(result.SimilarGroups)-1 { + fmt.Printf("\n%s\n\n", strings.Repeat("-", 80)) + } + } + } + } + + // Print usage information if requested + if findUsages && usages != nil { + fmt.Printf("\nComposable Usages\n") + fmt.Printf("=================\n\n") + printUsageInformation(result.AllComposables, usages, verbose) + } + + // Print all composables table + fmt.Printf("\nAll Composables\n") + fmt.Printf("===============\n\n") + printAllComposablesTable(result.AllComposables, verbose) +} + +// printSummaryByID prints a summary of composables grouped by ID. +func printSummaryByID(result *AnalysisResult) { + // Group by ID + countByID := make(map[string]int) + for _, loc := range result.AllComposables { + countByID[loc.Composable.ID]++ + } + + // Sort IDs + var ids []string + for id := range countByID { + ids = append(ids, id) + } + sort.Strings(ids) + + fmt.Printf("Composables by ID:\n") + for _, id := range ids { + count := countByID[id] + status := "" + if count > 1 { + status = " (multiple instances)" + } + fmt.Printf(" - %s: %d%s\n", id, count, status) + } +} + +// printComposableGroup prints a group of composables. +func printComposableGroup(group ComposableGroup, isIdentical bool, verbose bool) { + if isIdentical { + // For identical composables, they all have the same ID + fmt.Printf("ID: %s\n", group.ID) + fmt.Printf("Occurrences: %d\n", len(group.Locations)) + + // Get the first composable as reference + ref := group.Locations[0].Composable + fmt.Printf("Title: %s\n", ref.Title) + fmt.Printf("Default: %s\n", ref.Default) + + if verbose { + fmt.Printf("Options:\n") + printOptionsVerbose(ref.Options, " ") + } else { + fmt.Printf("Options: %s\n", formatOptions(ref.Options)) + } + + fmt.Printf("\nFound in:\n") + for _, loc := range group.Locations { + location := loc.Project + if loc.Version != "" { + location += "/" + loc.Version + } + fmt.Printf(" - %s (%s)\n", location, loc.Source) + } + } else { + // For similar composables with different IDs, show each one + fmt.Printf("Group: %.1f%% Similarity\n", group.Similarity*100) + fmt.Printf("%s\n", strings.Repeat("=", 40)) + fmt.Printf("Composables: %d\n", len(group.Locations)) + + fmt.Printf("\nComposables in this group:\n") + for i, loc := range group.Locations { + location := loc.Project + if loc.Version != "" { + location += "/" + loc.Version + } + + fmt.Printf("\n %d. ID: %s\n", i+1, loc.Composable.ID) + fmt.Printf(" Location: %s (%s)\n", location, loc.Source) + fmt.Printf(" Title: %s\n", loc.Composable.Title) + if loc.Composable.Default != "" { + fmt.Printf(" Default: %s\n", loc.Composable.Default) + } + + if verbose { + fmt.Printf(" Options:\n") + printOptionsVerbose(loc.Composable.Options, " ") + } else { + fmt.Printf(" Options: %s\n", formatOptions(loc.Composable.Options)) + } + } + + // Show common options + if verbose { + commonOptions := findCommonOptions(group.Locations) + if len(commonOptions) > 0 { + fmt.Printf("\n Common options across all:\n") + printOptionsVerbose(commonOptions, " ") + } + } + } + +} + +// printAllComposablesTable prints all composables in a table format. +func printAllComposablesTable(locations []ComposableLocation, verbose bool) { + // Sort by project, version, then ID + sorted := make([]ComposableLocation, len(locations)) + copy(sorted, locations) + sort.Slice(sorted, func(i, j int) bool { + if sorted[i].Project != sorted[j].Project { + return sorted[i].Project < sorted[j].Project + } + if sorted[i].Version != sorted[j].Version { + return sorted[i].Version < sorted[j].Version + } + return sorted[i].Composable.ID < sorted[j].Composable.ID + }) + + if verbose { + // Verbose table format with multi-line options + fmt.Printf("%-20s %-15s %-15s %-30s %-30s %-15s %s\n", "Project", "Version", "Source", "ID", "Title", "Default", "Options") + fmt.Printf("%s\n", strings.Repeat("-", 155)) + + for i, loc := range sorted { + version := loc.Version + if version == "" { + version = "(none)" + } + + // Format options as bullet list + optionLines := formatOptionsAsBullets(loc.Composable.Options) + + // Print first line with all columns + if len(optionLines) > 0 { + fmt.Printf("%-20s %-15s %-15s %-30s %-30s %-15s %s\n", + truncate(loc.Project, 20), + truncate(version, 15), + truncate(loc.Source, 15), + truncate(loc.Composable.ID, 30), + truncate(loc.Composable.Title, 30), + truncate(loc.Composable.Default, 15), + optionLines[0]) + + // Print continuation lines with options only + for j := 1; j < len(optionLines); j++ { + fmt.Printf("%-20s %-15s %-15s %-30s %-30s %-15s %s\n", "", "", "", "", "", "", optionLines[j]) + } + } else { + fmt.Printf("%-20s %-15s %-15s %-30s %-30s %-15s\n", + truncate(loc.Project, 20), + truncate(version, 15), + truncate(loc.Source, 15), + truncate(loc.Composable.ID, 30), + truncate(loc.Composable.Title, 30), + truncate(loc.Composable.Default, 15)) + } + + // Add separator line between rows (but not after the last row) + if i < len(sorted)-1 { + fmt.Printf("%s\n", strings.Repeat("-", 155)) + } + } + } else { + // Compact table format + fmt.Printf("%-20s %-15s %-15s %-30s %-30s %s\n", "Project", "Version", "Source", "ID", "Title", "Options") + fmt.Printf("%s\n", strings.Repeat("-", 135)) + + for _, loc := range sorted { + version := loc.Version + if version == "" { + version = "(none)" + } + options := formatOptions(loc.Composable.Options) + fmt.Printf("%-20s %-15s %-15s %-30s %-30s %s\n", + truncate(loc.Project, 20), + truncate(version, 15), + truncate(loc.Source, 15), + truncate(loc.Composable.ID, 30), + truncate(loc.Composable.Title, 30), + truncate(options, 40)) + } + } +} + +// formatOptions formats options as a comma-separated list of IDs. +func formatOptions(options []ComposableOption) string { + var ids []string + for _, opt := range options { + ids = append(ids, opt.ID) + } + return strings.Join(ids, ", ") +} + +// formatOptionsAsBullets formats options as bullet points for table display. +func formatOptionsAsBullets(options []ComposableOption) []string { + var lines []string + for _, opt := range options { + lines = append(lines, fmt.Sprintf("• %s: %s", opt.ID, opt.Title)) + } + return lines +} + +// printOptionsVerbose prints options in verbose format with wrapping. +func printOptionsVerbose(options []ComposableOption, indent string) { + const maxWidth = 100 // Maximum width for wrapped text + + for _, opt := range options { + optText := fmt.Sprintf("%s- %s: %s", indent, opt.ID, opt.Title) + + // Wrap text if it exceeds maxWidth + if len(optText) > maxWidth { + // Print the first line up to maxWidth + fmt.Println(optText[:maxWidth]) + + // Print continuation lines + remaining := optText[maxWidth:] + continuationIndent := indent + " " + for len(remaining) > 0 { + if len(remaining) <= maxWidth-len(continuationIndent) { + fmt.Printf("%s%s\n", continuationIndent, remaining) + break + } + // Find a good break point (space or comma) + breakPoint := findBreakPoint(remaining, maxWidth-len(continuationIndent)) + fmt.Printf("%s%s\n", continuationIndent, remaining[:breakPoint]) + remaining = strings.TrimSpace(remaining[breakPoint:]) + } + } else { + fmt.Println(optText) + } + } +} + +// findBreakPoint finds a good place to break a line (at a space or comma). +func findBreakPoint(s string, maxLen int) int { + if len(s) <= maxLen { + return len(s) + } + + // Look for a space or comma near the end + for i := maxLen; i > maxLen-20 && i > 0; i-- { + if s[i] == ' ' || s[i] == ',' { + return i + } + } + + // If no good break point found, just break at maxLen + return maxLen +} + +// truncate truncates a string to the specified length. +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} + +// findCommonOptions finds options that appear in all composables in the group. +func findCommonOptions(locations []ComposableLocation) []ComposableOption { + if len(locations) == 0 { + return nil + } + + // Start with options from the first composable + commonMap := make(map[string]ComposableOption) + for _, opt := range locations[0].Composable.Options { + commonMap[opt.ID] = opt + } + + // Remove options that don't appear in all composables + for i := 1; i < len(locations); i++ { + optionsInThis := make(map[string]bool) + for _, opt := range locations[i].Composable.Options { + optionsInThis[opt.ID] = true + } + + // Remove options from commonMap that aren't in this composable + for optID := range commonMap { + if !optionsInThis[optID] { + delete(commonMap, optID) + } + } + } + + // Convert map to sorted slice + var common []ComposableOption + for _, opt := range commonMap { + common = append(common, opt) + } + + sort.Slice(common, func(i, j int) bool { + return common[i].ID < common[j].ID + }) + + return common +} + +// printUsageInformation prints usage information for composables. +func printUsageInformation(composables []ComposableLocation, usages map[string]*ComposableUsage, verbose bool) { + // Group usages by composable ID + usagesByID := make(map[string][]*ComposableUsage) + for _, usage := range usages { + usagesByID[usage.ComposableID] = append(usagesByID[usage.ComposableID], usage) + } + + // Get sorted list of composable IDs + var ids []string + for id := range usagesByID { + ids = append(ids, id) + } + sort.Strings(ids) + + // Print usage for each composable + for _, id := range ids { + usageList := usagesByID[id] + + // Calculate total usage count + totalCount := 0 + for _, usage := range usageList { + totalCount += usage.UsageCount + } + + fmt.Printf("Composable ID: %s\n", id) + fmt.Printf("Total usages: %d\n", totalCount) + + // Sort usages by project/version + sort.Slice(usageList, func(i, j int) bool { + if usageList[i].Project != usageList[j].Project { + return usageList[i].Project < usageList[j].Project + } + return usageList[i].Version < usageList[j].Version + }) + + // Print usage by project/version + for _, usage := range usageList { + location := usage.Project + if usage.Version != "" { + location += "/" + usage.Version + } + + fmt.Printf("\n %s: %d usages\n", location, usage.UsageCount) + + if verbose { + // Print file paths + for _, filePath := range usage.FilePaths { + fmt.Printf(" - %s\n", filePath) + } + } + } + + fmt.Printf("\n") + } + + // Print composables with no usages + unusedComposables := findUnusedComposables(composables, usagesByID) + if len(unusedComposables) > 0 { + fmt.Printf("Unused Composables\n") + fmt.Printf("------------------\n\n") + + // Group by ID + unusedByID := make(map[string][]ComposableLocation) + for _, loc := range unusedComposables { + unusedByID[loc.Composable.ID] = append(unusedByID[loc.Composable.ID], loc) + } + + // Sort IDs + var unusedIDs []string + for id := range unusedByID { + unusedIDs = append(unusedIDs, id) + } + sort.Strings(unusedIDs) + + for _, id := range unusedIDs { + locations := unusedByID[id] + fmt.Printf(" %s:\n", id) + for _, loc := range locations { + location := loc.Project + if loc.Version != "" { + location += "/" + loc.Version + } + fmt.Printf(" - %s\n", location) + } + } + fmt.Printf("\n") + } +} + +// findUnusedComposables finds composables that have no usages. +func findUnusedComposables(composables []ComposableLocation, usagesByID map[string][]*ComposableUsage) []ComposableLocation { + var unused []ComposableLocation + + for _, loc := range composables { + // Check if this composable has any usages in the same project/version + hasUsage := false + if usageList, exists := usagesByID[loc.Composable.ID]; exists { + for _, usage := range usageList { + if usage.Project == loc.Project && usage.Version == loc.Version { + hasUsage = true + break + } + } + } + + if !hasUsage { + unused = append(unused, loc) + } + } + + return unused +} diff --git a/commands/analyze/composables/parser.go b/commands/analyze/composables/parser.go new file mode 100644 index 0000000..7cce212 --- /dev/null +++ b/commands/analyze/composables/parser.go @@ -0,0 +1,167 @@ +// Package composables provides functionality for analyzing composables in snooty.toml files. +package composables + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/BurntSushi/toml" + "github.com/grove-platform/audit-cli/internal/projectinfo" +) + +// ParseSnootyTOML parses a snooty.toml file and extracts composables. +// +// Parameters: +// - filePath: Path to the snooty.toml file +// +// Returns: +// - []Composable: Slice of composables found in the file +// - error: Any error encountered during parsing +func ParseSnootyTOML(filePath string) ([]Composable, error) { + var config SnootyConfig + _, err := toml.DecodeFile(filePath, &config) + if err != nil { + return nil, fmt.Errorf("failed to parse TOML file: %w", err) + } + + return config.Composables, nil +} + +// FindSnootyTOMLFiles finds all snooty.toml files in the monorepo. +// +// Parameters: +// - monorepoPath: Path to the MongoDB documentation monorepo +// - forProject: If non-empty, only find files for this project +// - currentOnly: If true, only find files in current versions +// +// Returns: +// - []ComposableLocation: Slice of all composables found with their locations +// - error: Any error encountered during discovery +func FindSnootyTOMLFiles(monorepoPath string, forProject string, currentOnly bool) ([]ComposableLocation, error) { + // Get absolute path + absPath, err := filepath.Abs(monorepoPath) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path: %w", err) + } + + // Find the content directory + contentDir, err := findContentDirectory(absPath) + if err != nil { + return nil, err + } + + var locations []ComposableLocation + + // Walk through the content directory + err = filepath.Walk(contentDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip directories + if info.IsDir() { + return nil + } + + // Only process snooty.toml files + if info.Name() != "snooty.toml" { + return nil + } + + // Extract project and version information + relPath, err := filepath.Rel(contentDir, path) + if err != nil { + return err + } + + projectName, versionName := extractProjectAndVersion(relPath) + if projectName == "" { + return nil + } + + // Filter by project if specified + if forProject != "" && projectName != forProject { + return nil + } + + // Filter by current version if specified + if currentOnly && versionName != "" { + if !projectinfo.IsCurrentVersion(versionName) { + return nil + } + } + + // Parse the snooty.toml file + composables, err := ParseSnootyTOML(path) + if err != nil { + // Skip files that can't be parsed + return nil + } + + // Add each composable to the locations + for _, comp := range composables { + locations = append(locations, ComposableLocation{ + Project: projectName, + Version: versionName, + Composable: comp, + FilePath: path, + Source: "snooty.toml", + }) + } + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to walk content directory: %w", err) + } + + return locations, nil +} + +// findContentDirectory finds the content directory from the given path. +func findContentDirectory(dirPath string) (string, error) { + // Check if this is already a content directory + if filepath.Base(dirPath) == "content" { + return dirPath, nil + } + + // Check if there's a content subdirectory + contentDir := filepath.Join(dirPath, "content") + if _, err := os.Stat(contentDir); err == nil { + return contentDir, nil + } + + return "", fmt.Errorf("content directory not found in: %s", dirPath) +} + +// extractProjectAndVersion extracts project and version from a relative path. +// Returns (project, version) where version is empty for non-versioned projects. +// +// Examples: +// - "manual/v8.0/snooty.toml" -> ("manual", "v8.0") +// - "atlas/snooty.toml" -> ("atlas", "") +func extractProjectAndVersion(relPath string) (string, string) { + parts := strings.Split(relPath, string(filepath.Separator)) + if len(parts) < 2 { + return "", "" + } + + projectName := parts[0] + + // Check if this is a versioned project + // Pattern: project/version/snooty.toml (3 parts) + // Pattern: project/snooty.toml (2 parts) + if len(parts) == 3 && parts[2] == "snooty.toml" { + // Versioned project: project/version/snooty.toml + return projectName, parts[1] + } else if len(parts) == 2 && parts[1] == "snooty.toml" { + // Non-versioned project: project/snooty.toml + return projectName, "" + } + + return "", "" +} + diff --git a/commands/analyze/composables/rstspec_adapter.go b/commands/analyze/composables/rstspec_adapter.go new file mode 100644 index 0000000..8a2c1db --- /dev/null +++ b/commands/analyze/composables/rstspec_adapter.go @@ -0,0 +1,56 @@ +// Package composables provides functionality for analyzing composables in snooty.toml files. +package composables + +import ( + "fmt" + + "github.com/grove-platform/audit-cli/internal/rst" +) + +// FetchRstspecComposables fetches and parses composables from the canonical rstspec.toml file. +// +// This function downloads the rstspec.toml file from the snooty-parser repository +// and extracts the composables defined in it. These are the "canonical" or universal +// composables that may be duplicated in local snooty.toml files. +// +// Returns: +// - A slice of ComposableLocation objects with Source set to "rstspec.toml" +// - An error if the fetch or parse fails +func FetchRstspecComposables() ([]ComposableLocation, error) { + // Fetch the rstspec.toml file using the internal/rst package + config, err := rst.FetchRstspec() + if err != nil { + return nil, fmt.Errorf("failed to fetch rstspec.toml: %w", err) + } + + // Convert rstspec composables to ComposableLocation objects + locations := make([]ComposableLocation, 0, len(config.Composables)) + for _, rstspecComp := range config.Composables { + // Convert RstspecComposable to Composable + composable := Composable{ + ID: rstspecComp.ID, + Title: rstspecComp.Title, + Default: rstspecComp.Default, + Options: make([]ComposableOption, 0, len(rstspecComp.Options)), + } + + // Convert options + for _, rstspecOpt := range rstspecComp.Options { + composable.Options = append(composable.Options, ComposableOption{ + ID: rstspecOpt.ID, + Title: rstspecOpt.Title, + }) + } + + locations = append(locations, ComposableLocation{ + Project: "rstspec", + Version: "", + Composable: composable, + FilePath: rst.RstspecURL, + Source: "rstspec.toml", + }) + } + + return locations, nil +} + diff --git a/commands/analyze/composables/rstspec_adapter_test.go b/commands/analyze/composables/rstspec_adapter_test.go new file mode 100644 index 0000000..3f9c064 --- /dev/null +++ b/commands/analyze/composables/rstspec_adapter_test.go @@ -0,0 +1,151 @@ +package composables + +import ( + "testing" +) + +// TestFetchRstspecComposables tests fetching and converting rstspec composables. +// This is an integration test that makes a real HTTP request to GitHub. +func TestFetchRstspecComposables(t *testing.T) { + locations, err := FetchRstspecComposables() + if err != nil { + t.Fatalf("Failed to fetch rstspec composables: %v", err) + } + + if len(locations) == 0 { + t.Fatal("Expected at least one composable location") + } + + t.Logf("Successfully fetched %d composable locations from rstspec.toml", len(locations)) +} + +// TestRstspecComposableLocationsStructure tests the structure of converted composables. +func TestRstspecComposableLocationsStructure(t *testing.T) { + locations, err := FetchRstspecComposables() + if err != nil { + t.Fatalf("Failed to fetch rstspec composables: %v", err) + } + + for _, loc := range locations { + // Verify project is set to "rstspec" + if loc.Project != "rstspec" { + t.Errorf("Expected project 'rstspec', got '%s'", loc.Project) + } + + // Verify version is empty + if loc.Version != "" { + t.Errorf("Expected empty version, got '%s'", loc.Version) + } + + // Verify source is set to "rstspec.toml" + if loc.Source != "rstspec.toml" { + t.Errorf("Expected source 'rstspec.toml', got '%s'", loc.Source) + } + + // Verify FilePath is set to the URL + if loc.FilePath == "" { + t.Error("Expected non-empty FilePath") + } + + // Verify composable has required fields + if loc.Composable.ID == "" { + t.Error("Composable has empty ID") + } + + if loc.Composable.Title == "" { + t.Errorf("Composable %s has empty title", loc.Composable.ID) + } + + if len(loc.Composable.Options) == 0 { + t.Errorf("Composable %s has no options", loc.Composable.ID) + } + + // Verify options are properly converted + for _, opt := range loc.Composable.Options { + if opt.ID == "" { + t.Errorf("Composable %s has option with empty ID", loc.Composable.ID) + } + if opt.Title == "" { + t.Errorf("Composable %s has option %s with empty title", loc.Composable.ID, opt.ID) + } + } + } + + t.Logf("All %d composable locations have valid structure", len(locations)) +} + +// TestRstspecComposableIDs tests that expected composables are present. +func TestRstspecComposableIDs(t *testing.T) { + locations, err := FetchRstspecComposables() + if err != nil { + t.Fatalf("Failed to fetch rstspec composables: %v", err) + } + + // Check for well-known composables + expectedIDs := map[string]bool{ + "interface": false, + "language": false, + "deployment-type": false, + "cluster-topology": false, + "cloud-provider": false, + "operating-system": false, + } + + for _, loc := range locations { + if _, exists := expectedIDs[loc.Composable.ID]; exists { + expectedIDs[loc.Composable.ID] = true + } + } + + // Verify all expected IDs were found + for id, found := range expectedIDs { + if !found { + t.Errorf("Expected composable ID %s not found", id) + } + } + + t.Logf("All expected composable IDs found") +} + +// TestRstspecInterfaceComposableConversion tests the interface composable conversion. +func TestRstspecInterfaceComposableConversion(t *testing.T) { + locations, err := FetchRstspecComposables() + if err != nil { + t.Fatalf("Failed to fetch rstspec composables: %v", err) + } + + // Find the interface composable + var interfaceLoc *ComposableLocation + for i, loc := range locations { + if loc.Composable.ID == "interface" { + interfaceLoc = &locations[i] + break + } + } + + if interfaceLoc == nil { + t.Fatal("Interface composable not found") + } + + // Verify it has expected options + expectedOptions := []string{"atlas-ui", "driver", "mongosh"} + foundOptions := make(map[string]bool) + + for _, opt := range interfaceLoc.Composable.Options { + foundOptions[opt.ID] = true + } + + for _, expected := range expectedOptions { + if !foundOptions[expected] { + t.Errorf("Expected option %s not found in interface composable", expected) + } + } + + // Verify the composable has a title + if interfaceLoc.Composable.Title == "" { + t.Error("Interface composable has empty title") + } + + t.Logf("Interface composable converted correctly with %d options", len(interfaceLoc.Composable.Options)) +} + diff --git a/commands/analyze/composables/types.go b/commands/analyze/composables/types.go new file mode 100644 index 0000000..3de2a86 --- /dev/null +++ b/commands/analyze/composables/types.go @@ -0,0 +1,59 @@ +// Package composables provides functionality for analyzing composables in snooty.toml files. +package composables + +// Composable represents a composable definition from a snooty.toml file. +type Composable struct { + ID string `toml:"id"` + Title string `toml:"title"` + Default string `toml:"default"` + Dependencies []map[string]string `toml:"dependencies"` + Options []ComposableOption `toml:"options"` +} + +// ComposableOption represents an option within a composable. +type ComposableOption struct { + ID string `toml:"id"` + Title string `toml:"title"` +} + +// SnootyConfig represents the structure of a snooty.toml file. +type SnootyConfig struct { + Composables []Composable `toml:"composables"` +} + +// ComposableLocation tracks where a composable was found. +type ComposableLocation struct { + Project string + Version string // Empty for non-versioned projects + Composable Composable + FilePath string + Source string // "snooty.toml" or "rstspec.toml" +} + +// ComposableGroup represents a group of similar composables. +type ComposableGroup struct { + ID string + Locations []ComposableLocation + // Similarity score (1.0 = identical, < 1.0 = similar) + Similarity float64 +} + +// AnalysisResult contains the results of analyzing composables. +type AnalysisResult struct { + // All composables found + AllComposables []ComposableLocation + // Groups of identical composables (same ID, same options) + IdenticalGroups []ComposableGroup + // Groups of similar composables (same ID, different options) + SimilarGroups []ComposableGroup +} + +// ComposableUsage tracks where a composable is used in RST files. +type ComposableUsage struct { + ComposableID string + Project string + Version string + UsageCount int + FilePaths []string // Relative paths from monorepo root +} + diff --git a/commands/analyze/composables/usage_finder.go b/commands/analyze/composables/usage_finder.go new file mode 100644 index 0000000..6184d40 --- /dev/null +++ b/commands/analyze/composables/usage_finder.go @@ -0,0 +1,169 @@ +// Package composables provides functionality for analyzing composables in snooty.toml files. +package composables + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/grove-platform/audit-cli/internal/projectinfo" +) + +// composableTutorialRegex matches .. composable-tutorial:: directives +var composableTutorialRegex = regexp.MustCompile(`^\.\.\s+composable-tutorial::`) + +// optionsRegex matches :options: lines in composable-tutorial directives +var optionsRegex = regexp.MustCompile(`^\s*:options:\s+(.+)$`) + +// FindComposableUsages finds all usages of composables in RST files. +// It scans all .txt and .rst files in the monorepo and looks for composable-tutorial directives. +func FindComposableUsages(monorepoPath string, composables []ComposableLocation, forProject string, currentOnly bool) (map[string]*ComposableUsage, error) { + // Create a map to track usages by composable ID + project + version + usageMap := make(map[string]*ComposableUsage) + + // Walk through the content directory + contentDir := filepath.Join(monorepoPath, "content") + err := filepath.Walk(contentDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip directories + if info.IsDir() { + return nil + } + + // Only process .txt and .rst files + ext := filepath.Ext(path) + if ext != ".txt" && ext != ".rst" { + return nil + } + + // Extract project and version from path + project, version := extractProjectAndVersionFromPath(path, monorepoPath) + if project == "" { + return nil + } + + // Apply filters + if forProject != "" && project != forProject { + return nil + } + + if currentOnly && !projectinfo.IsCurrentVersion(version) { + return nil + } + + // Parse the file for composable-tutorial directives + composableIDs, err := extractComposableIDsFromFile(path) + if err != nil { + // Skip files that can't be read + return nil + } + + // Track usages + for _, composableID := range composableIDs { + key := fmt.Sprintf("%s::%s::%s", project, version, composableID) + if usage, exists := usageMap[key]; exists { + usage.UsageCount++ + usage.FilePaths = append(usage.FilePaths, getRelativePath(path, monorepoPath)) + } else { + usageMap[key] = &ComposableUsage{ + ComposableID: composableID, + Project: project, + Version: version, + UsageCount: 1, + FilePaths: []string{getRelativePath(path, monorepoPath)}, + } + } + } + + return nil + }) + + if err != nil { + return nil, err + } + + return usageMap, nil +} + +// extractProjectAndVersionFromPath extracts project and version from a file path. +// Example: /path/to/content/atlas/source/file.txt -> project: atlas, version: "" +// Example: /path/to/content/manual/v7.0/source/file.txt -> project: manual, version: v7.0 +func extractProjectAndVersionFromPath(filePath string, monorepoPath string) (string, string) { + // Get relative path from monorepo root + relPath, err := filepath.Rel(monorepoPath, filePath) + if err != nil { + return "", "" + } + + // Split path into parts + parts := strings.Split(relPath, string(filepath.Separator)) + if len(parts) < 3 || parts[0] != "content" { + return "", "" + } + + project := parts[1] + + // Check if there's a version directory + if len(parts) >= 4 && parts[2] != "source" { + // Versioned project: content/{project}/{version}/source/... + return project, parts[2] + } + + // Non-versioned project: content/{project}/source/... + return project, "" +} + +// extractComposableIDsFromFile parses an RST file and extracts composable IDs from composable-tutorial directives. +func extractComposableIDsFromFile(filePath string) ([]string, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer file.Close() + + var composableIDs []string + scanner := bufio.NewScanner(file) + inComposableTutorial := false + + for scanner.Scan() { + line := scanner.Text() + trimmedLine := strings.TrimSpace(line) + + // Check for composable-tutorial directive + if composableTutorialRegex.MatchString(trimmedLine) { + inComposableTutorial = true + continue + } + + // If we're in a composable-tutorial, look for :options: line + if inComposableTutorial { + if matches := optionsRegex.FindStringSubmatch(line); len(matches) > 1 { + // Parse the comma-separated list of composable IDs + optionsStr := strings.TrimSpace(matches[1]) + ids := strings.Split(optionsStr, ",") + for _, id := range ids { + composableIDs = append(composableIDs, strings.TrimSpace(id)) + } + inComposableTutorial = false + } + } + } + + return composableIDs, scanner.Err() +} + +// getRelativePath returns the path relative to the monorepo root. +func getRelativePath(path string, monorepoPath string) string { + relPath, err := filepath.Rel(monorepoPath, path) + if err != nil { + return path + } + return relPath +} + diff --git a/commands/analyze/includes/includes.go b/commands/analyze/includes/includes.go index a5f3a9b..fba9373 100644 --- a/commands/analyze/includes/includes.go +++ b/commands/analyze/includes/includes.go @@ -12,6 +12,7 @@ package includes import ( "fmt" + "github.com/grove-platform/audit-cli/internal/config" "github.com/spf13/cobra" ) @@ -44,10 +45,20 @@ Output formats: --tree: Show hierarchical tree structure of includes --list: Show flat list of all included files -If neither flag is specified, shows a summary with basic statistics.`, +If neither flag is specified, shows a summary with basic statistics. + +File Path Resolution: + Paths can be specified as: + 1. Absolute path: /full/path/to/file.rst + 2. Relative to monorepo root (if configured): manual/manual/source/file.rst + 3. Relative to current directory: ./file.rst`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - filePath := args[0] + // Resolve file path (supports absolute, monorepo-relative, or cwd-relative) + filePath, err := config.ResolveFilePath(args[0]) + if err != nil { + return err + } return runAnalyze(filePath, showTree, showList, verbose) }, } diff --git a/commands/analyze/usage/usage.go b/commands/analyze/usage/usage.go index a644562..fcb67ed 100644 --- a/commands/analyze/usage/usage.go +++ b/commands/analyze/usage/usage.go @@ -16,6 +16,7 @@ package usage import ( "fmt" + "github.com/grove-platform/audit-cli/internal/config" "github.com/spf13/cobra" ) @@ -76,6 +77,16 @@ This is useful for: - Finding all usages of an include file - Tracking code example references +File Path Resolution: + Paths can be specified as: + 1. Absolute path: /full/path/to/file.rst + 2. Relative to monorepo root (if configured): manual/manual/source/file.rst + 3. Relative to current directory: ./file.rst + + If a monorepo path is configured (via config file or environment variable), + relative paths are first tried relative to the monorepo root, then fall back + to the current directory. + Examples: # Find what uses an include file analyze usage /path/to/includes/fact.rst @@ -108,10 +119,18 @@ Examples: analyze usage /path/to/file.rst --directive-type include # Recursively follow usage tree to find all .txt documentation pages - analyze usage /path/to/includes/fact.rst --recursive`, + analyze usage /path/to/includes/fact.rst --recursive + + # Use relative path from monorepo (if configured) + analyze usage manual/manual/source/includes/fact.rst`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runUsage(args[0], format, verbose, countOnly, pathsOnly, summaryOnly, directiveType, includeToctree, excludePattern, recursive) + // Resolve file path (supports absolute, monorepo-relative, or cwd-relative) + filePath, err := config.ResolveFilePath(args[0]) + if err != nil { + return err + } + return runUsage(filePath, format, verbose, countOnly, pathsOnly, summaryOnly, directiveType, includeToctree, excludePattern, recursive) }, } diff --git a/commands/compare/file-contents/file_contents.go b/commands/compare/file-contents/file_contents.go index 9ad6118..bce6cac 100644 --- a/commands/compare/file-contents/file_contents.go +++ b/commands/compare/file-contents/file_contents.go @@ -19,6 +19,7 @@ import ( "path/filepath" "strings" + "github.com/grove-platform/audit-cli/internal/config" "github.com/grove-platform/audit-cli/internal/projectinfo" "github.com/spf13/cobra" ) @@ -82,10 +83,25 @@ The command provides progressive output detail: - --show-diff: Include unified diffs (implies --show-paths) Files that don't exist in certain versions are reported separately and -do not cause errors.`, +do not cause errors. + +File Path Resolution: + Paths can be specified as: + 1. Absolute path: /full/path/to/file.rst + 2. Relative to monorepo root (if configured): manual/manual/source/file.rst + 3. Relative to current directory: ./file.rst`, Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { - return runCompare(args, versions, showPaths, showDiff, verbose) + // Resolve file paths (supports absolute, monorepo-relative, or cwd-relative) + resolvedArgs := make([]string, len(args)) + for i, arg := range args { + resolved, err := config.ResolveFilePath(arg) + if err != nil { + return err + } + resolvedArgs[i] = resolved + } + return runCompare(resolvedArgs, versions, showPaths, showDiff, verbose) }, } diff --git a/commands/count/pages/pages.go b/commands/count/pages/pages.go index d7ed726..6af8b9c 100644 --- a/commands/count/pages/pages.go +++ b/commands/count/pages/pages.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/grove-platform/audit-cli/internal/config" "github.com/spf13/cobra" ) @@ -53,30 +54,49 @@ Each directory under content/ represents a different product/project. By default, returns only a total count of all pages. +Monorepo Path Configuration: + The monorepo path can be specified in three ways (in order of priority): + 1. Command-line argument: count pages /path/to/monorepo + 2. Environment variable: export AUDIT_CLI_MONOREPO_PATH=/path/to/monorepo + 3. Config file (.audit-cli.yaml): + monorepo_path: /path/to/monorepo + Examples: # Get total count of all documentation pages count pages /path/to/docs-monorepo + # Use configured monorepo path + count pages + # Count pages for a specific project - count pages /path/to/docs-monorepo --for-project manual + count pages --for-project manual # Show counts broken down by project - count pages /path/to/docs-monorepo --count-by-project + count pages --count-by-project # Exclude specific directories from counting - count pages /path/to/docs-monorepo --exclude-dirs api-reference,generated + count pages --exclude-dirs api-reference,generated # Combine flags: count pages for a specific project, excluding certain directories - count pages /path/to/docs-monorepo --for-project atlas --exclude-dirs deprecated + count pages --for-project atlas --exclude-dirs deprecated # Count only current versions - count pages /path/to/docs-monorepo --current-only + count pages --current-only # Show counts by version - count pages /path/to/docs-monorepo --by-version`, - Args: cobra.ExactArgs(1), + count pages --by-version`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runPages(args[0], forProject, countByProject, excludeDirs, currentOnly, byVersion) + // Resolve monorepo path from args, env, or config + var cmdLineArg string + if len(args) > 0 { + cmdLineArg = args[0] + } + monorepoPath, err := config.GetMonorepoPath(cmdLineArg) + if err != nil { + return err + } + return runPages(monorepoPath, forProject, countByProject, excludeDirs, currentOnly, byVersion) }, } diff --git a/commands/count/tested-examples/tested_examples.go b/commands/count/tested-examples/tested_examples.go index 7f5e465..8bb25b5 100644 --- a/commands/count/tested-examples/tested_examples.go +++ b/commands/count/tested-examples/tested_examples.go @@ -4,6 +4,7 @@ package tested_examples import ( "fmt" + "github.com/grove-platform/audit-cli/internal/config" "github.com/spf13/cobra" ) @@ -44,24 +45,43 @@ By default, returns only a total count of all files. ` + GetProductList() + ` +Monorepo Path Configuration: + The monorepo path can be specified in three ways (in order of priority): + 1. Command-line argument: count tested-examples /path/to/monorepo + 2. Environment variable: export AUDIT_CLI_MONOREPO_PATH=/path/to/monorepo + 3. Config file (.audit-cli.yaml): + monorepo_path: /path/to/monorepo + Examples: # Get total count of all tested code examples count tested-examples /path/to/docs-monorepo + # Use configured monorepo path + count tested-examples + # Count examples for a specific product - count tested-examples /path/to/docs-monorepo --for-product pymongo + count tested-examples --for-product pymongo # Show counts broken down by product - count tested-examples /path/to/docs-monorepo --count-by-product + count tested-examples --count-by-product # Count only source files (exclude .txt and .sh output files) - count tested-examples /path/to/docs-monorepo --exclude-output + count tested-examples --exclude-output # Combine flags: count source files for a specific product - count tested-examples /path/to/docs-monorepo --for-product pymongo --exclude-output`, - Args: cobra.ExactArgs(1), + count tested-examples --for-product pymongo --exclude-output`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runTestedExamples(args[0], forProduct, countByProduct, excludeOutput) + // Resolve monorepo path from args, env, or config + var cmdLineArg string + if len(args) > 0 { + cmdLineArg = args[0] + } + monorepoPath, err := config.GetMonorepoPath(cmdLineArg) + if err != nil { + return err + } + return runTestedExamples(monorepoPath, forProduct, countByProduct, excludeOutput) }, } diff --git a/commands/extract/code-examples/code_examples.go b/commands/extract/code-examples/code_examples.go index 1d8c568..932d1f3 100644 --- a/commands/extract/code-examples/code_examples.go +++ b/commands/extract/code-examples/code_examples.go @@ -17,6 +17,7 @@ import ( "fmt" "os" + "github.com/grove-platform/audit-cli/internal/config" "github.com/spf13/cobra" ) @@ -44,10 +45,20 @@ func NewCodeExamplesCommand() *cobra.Command { Use: "code-examples [filepath]", Short: "Extract code examples from reStructuredText files", Long: `Extract code examples from reStructuredText directives (code-block, literalinclude, io-code-block) -and output them as individual files.`, +and output them as individual files. + +File Path Resolution: + Paths can be specified as: + 1. Absolute path: /full/path/to/file.rst + 2. Relative to monorepo root (if configured): manual/manual/source/file.rst + 3. Relative to current directory: ./file.rst`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - filePath := args[0] + // Resolve file path (supports absolute, monorepo-relative, or cwd-relative) + filePath, err := config.ResolveFilePath(args[0]) + if err != nil { + return err + } return runExtract(filePath, recursive, followIncludes, outputDir, dryRun, verbose, preserveDirs) }, } diff --git a/commands/extract/procedures/procedures.go b/commands/extract/procedures/procedures.go index f34ef66..2d143e6 100644 --- a/commands/extract/procedures/procedures.go +++ b/commands/extract/procedures/procedures.go @@ -19,6 +19,7 @@ import ( "os" "strings" + "github.com/grove-platform/audit-cli/internal/config" "github.com/spf13/cobra" ) @@ -59,10 +60,20 @@ The output files are named using the format: {heading}-{selection}.rst For example: "connect-to-cluster-python.rst", "create-index-drivers.rst" By default, include directives are preserved in the output. Use --expand-includes -to inline the content of included files.`, +to inline the content of included files. + +File Path Resolution: + Paths can be specified as: + 1. Absolute path: /full/path/to/file.rst + 2. Relative to monorepo root (if configured): manual/manual/source/file.rst + 3. Relative to current directory: ./file.rst`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - filePath := args[0] + // Resolve file path (supports absolute, monorepo-relative, or cwd-relative) + filePath, err := config.ResolveFilePath(args[0]) + if err != nil { + return err + } return runExtract(filePath, selection, outputDir, dryRun, verbose, expandIncludes, showSteps, showSubProcedures) }, } diff --git a/commands/search/find-string/find_string.go b/commands/search/find-string/find_string.go index 651050c..22110ba 100644 --- a/commands/search/find-string/find_string.go +++ b/commands/search/find-string/find_string.go @@ -23,6 +23,7 @@ import ( "path/filepath" "strings" + "github.com/grove-platform/audit-cli/internal/config" "github.com/grove-platform/audit-cli/internal/rst" "github.com/spf13/cobra" ) @@ -58,10 +59,20 @@ and scope maintenance work related to specific changes. By default, the search is case-insensitive and matches exact words only. Use --case-sensitive to make the search case-sensitive, or --partial-match to allow matching the substring as part -of larger words (e.g., "curl" matching "libcurl").`, +of larger words (e.g., "curl" matching "libcurl"). + +File Path Resolution: + Paths can be specified as: + 1. Absolute path: /full/path/to/file.rst + 2. Relative to monorepo root (if configured): manual/manual/source/file.rst + 3. Relative to current directory: ./file.rst`, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - filePath := args[0] + // Resolve file path (supports absolute, monorepo-relative, or cwd-relative) + filePath, err := config.ResolveFilePath(args[0]) + if err != nil { + return err + } substring := args[1] return runSearch(filePath, substring, recursive, followIncludes, verbose, caseSensitive, partialMatch) }, diff --git a/go.mod b/go.mod index 1b82fab..eb55341 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( ) require ( + github.com/BurntSushi/toml v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.10 // indirect ) diff --git a/go.sum b/go.sum index 8b4bbd4..319e13f 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..dc77a2c --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,211 @@ +// Package config provides configuration management for audit-cli. +// +// This package handles loading configuration from multiple sources: +// - Config file (.audit-cli.yaml in current directory or home directory) +// - Environment variables (AUDIT_CLI_MONOREPO_PATH) +// - Command-line arguments (highest priority) +// +// The monorepo path is resolved in the following order (highest to lowest priority): +// 1. Command-line argument (if provided) +// 2. Environment variable AUDIT_CLI_MONOREPO_PATH +// 3. Config file .audit-cli.yaml (current directory) +// 4. Config file .audit-cli.yaml (home directory) +package config + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// Config represents the audit-cli configuration. +type Config struct { + MonorepoPath string `yaml:"monorepo_path"` +} + +// configFileName is the name of the config file. +const configFileName = ".audit-cli.yaml" + +// envVarMonorepoPath is the environment variable name for monorepo path. +const envVarMonorepoPath = "AUDIT_CLI_MONOREPO_PATH" + +// LoadConfig loads configuration from file and environment variables. +// Returns a Config struct with values populated from available sources. +func LoadConfig() (*Config, error) { + config := &Config{} + + // Try to load from config file + if err := loadFromFile(config); err != nil { + // Config file is optional, so we don't return error if it doesn't exist + // Only return error if file exists but can't be parsed + if !os.IsNotExist(err) { + return nil, fmt.Errorf("failed to load config file: %w", err) + } + } + + // Override with environment variable if set + if envPath := os.Getenv(envVarMonorepoPath); envPath != "" { + config.MonorepoPath = envPath + } + + return config, nil +} + +// loadFromFile loads configuration from a YAML file. +// Searches in the following order: +// 1. .audit-cli.yaml in current directory +// 2. .audit-cli.yaml in home directory +func loadFromFile(config *Config) error { + // Try current directory first + if _, err := os.Stat(configFileName); err == nil { + return parseConfigFile(configFileName, config) + } + + // Try home directory + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get home directory: %w", err) + } + + homeConfigPath := filepath.Join(homeDir, configFileName) + if _, err := os.Stat(homeConfigPath); err == nil { + return parseConfigFile(homeConfigPath, config) + } + + // No config file found + return os.ErrNotExist +} + +// parseConfigFile parses a YAML config file. +func parseConfigFile(path string, config *Config) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read config file %s: %w", path, err) + } + + if err := yaml.Unmarshal(data, config); err != nil { + return fmt.Errorf("failed to parse config file %s: %w", path, err) + } + + return nil +} + +// GetMonorepoPath resolves the monorepo path from multiple sources. +// Priority order (highest to lowest): +// 1. cmdLineArg (if non-empty) +// 2. Environment variable AUDIT_CLI_MONOREPO_PATH +// 3. Config file .audit-cli.yaml +// +// Returns: +// - string: The resolved monorepo path +// - error: Error if no path is configured +func GetMonorepoPath(cmdLineArg string) (string, error) { + // Command-line argument has highest priority + if cmdLineArg != "" { + return cmdLineArg, nil + } + + // Load config from file and environment + config, err := LoadConfig() + if err != nil { + return "", err + } + + // Check if we got a path from config or environment + if config.MonorepoPath != "" { + return config.MonorepoPath, nil + } + + // No path configured + return "", fmt.Errorf("no monorepo path configured\n\n" + + "Please configure the monorepo path using one of the following methods:\n" + + " 1. Pass as command-line argument: audit-cli /path/to/monorepo\n" + + " 2. Set environment variable: export AUDIT_CLI_MONOREPO_PATH=/path/to/monorepo\n" + + " 3. Create config file .audit-cli.yaml with:\n" + + " monorepo_path: /path/to/monorepo\n\n" + + "The config file can be placed in:\n" + + " - Current directory: ./.audit-cli.yaml\n" + + " - Home directory: ~/.audit-cli.yaml") +} + +// CreateSampleConfig creates a sample config file in the current directory. +func CreateSampleConfig(monorepoPath string) error { + config := &Config{ + MonorepoPath: monorepoPath, + } + + data, err := yaml.Marshal(config) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + if err := os.WriteFile(configFileName, data, 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + return nil +} + +// ResolveFilePath resolves a file path that could be: +// 1. An absolute path (used as-is) +// 2. A relative path from monorepo root (if monorepo is configured) +// 3. A relative path from current directory (fallback) +// +// Priority order: +// 1. If path is absolute, return it as-is +// 2. If monorepo is configured and path exists relative to monorepo, use that +// 3. Otherwise, resolve relative to current directory +// +// Parameters: +// - pathArg: The path argument from the command line +// +// Returns: +// - string: The resolved absolute path +// - error: Error if path cannot be resolved or doesn't exist +func ResolveFilePath(pathArg string) (string, error) { + // If path is absolute, use it as-is + if filepath.IsAbs(pathArg) { + // Verify it exists + if _, err := os.Stat(pathArg); err != nil { + return "", fmt.Errorf("path does not exist: %s", pathArg) + } + return pathArg, nil + } + + // Try to load config to get monorepo path + config, err := LoadConfig() + if err == nil && config.MonorepoPath != "" { + // Try relative to monorepo + monorepoRelative := filepath.Join(config.MonorepoPath, pathArg) + if _, err := os.Stat(monorepoRelative); err == nil { + // Path exists relative to monorepo, use it + absPath, err := filepath.Abs(monorepoRelative) + if err != nil { + return "", fmt.Errorf("failed to get absolute path: %w", err) + } + return absPath, nil + } + } + + // Fallback to relative from current directory + absPath, err := filepath.Abs(pathArg) + if err != nil { + return "", fmt.Errorf("failed to resolve path: %w", err) + } + + // Verify it exists + if _, err := os.Stat(absPath); err != nil { + // Provide helpful error message showing what we tried + if config != nil && config.MonorepoPath != "" { + return "", fmt.Errorf("path not found: %s\n\nTried:\n - Relative to monorepo: %s\n - Relative to current directory: %s", + pathArg, + filepath.Join(config.MonorepoPath, pathArg), + absPath) + } + return "", fmt.Errorf("path does not exist: %s (resolved to: %s)", pathArg, absPath) + } + + return absPath, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..dd8eda3 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,368 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "testing" +) + +// TestGetMonorepoPath_CommandLineArg tests that command-line argument has highest priority. +func TestGetMonorepoPath_CommandLineArg(t *testing.T) { + // Set environment variable + os.Setenv(envVarMonorepoPath, "/env/path") + defer os.Unsetenv(envVarMonorepoPath) + + // Command-line argument should override environment + path, err := GetMonorepoPath("/cmd/path") + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if path != "/cmd/path" { + t.Errorf("Expected '/cmd/path', got '%s'", path) + } +} + +// TestGetMonorepoPath_EnvironmentVariable tests environment variable fallback. +func TestGetMonorepoPath_EnvironmentVariable(t *testing.T) { + // Set environment variable + os.Setenv(envVarMonorepoPath, "/env/path") + defer os.Unsetenv(envVarMonorepoPath) + + // No command-line argument, should use environment + path, err := GetMonorepoPath("") + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if path != "/env/path" { + t.Errorf("Expected '/env/path', got '%s'", path) + } +} + +// TestGetMonorepoPath_ConfigFile tests config file fallback. +func TestGetMonorepoPath_ConfigFile(t *testing.T) { + // Create temporary directory for test + tempDir := t.TempDir() + originalWd, _ := os.Getwd() + defer os.Chdir(originalWd) + + // Change to temp directory + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + // Create config file + configPath := filepath.Join(tempDir, configFileName) + configContent := "monorepo_path: /config/path\n" + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + // Ensure environment variable is not set + os.Unsetenv(envVarMonorepoPath) + + // No command-line argument or environment, should use config file + path, err := GetMonorepoPath("") + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if path != "/config/path" { + t.Errorf("Expected '/config/path', got '%s'", path) + } +} + +// TestGetMonorepoPath_NoConfig tests error when no configuration is provided. +func TestGetMonorepoPath_NoConfig(t *testing.T) { + // Create temporary directory for test + tempDir := t.TempDir() + originalWd, _ := os.Getwd() + defer os.Chdir(originalWd) + + // Change to temp directory (no config file) + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + // Ensure environment variable is not set + os.Unsetenv(envVarMonorepoPath) + + // No configuration should return error + _, err := GetMonorepoPath("") + if err == nil { + t.Error("Expected error when no configuration is provided") + } +} + +// TestCreateSampleConfig tests creating a sample config file. +func TestCreateSampleConfig(t *testing.T) { + // Create temporary directory for test + tempDir := t.TempDir() + originalWd, _ := os.Getwd() + defer os.Chdir(originalWd) + + // Change to temp directory + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + // Create sample config + if err := CreateSampleConfig("/sample/path"); err != nil { + t.Fatalf("Failed to create sample config: %v", err) + } + + // Verify file was created + configPath := filepath.Join(tempDir, configFileName) + if _, err := os.Stat(configPath); os.IsNotExist(err) { + t.Error("Config file was not created") + } + + // Verify content + config := &Config{} + if err := parseConfigFile(configPath, config); err != nil { + t.Fatalf("Failed to parse created config: %v", err) + } + + if config.MonorepoPath != "/sample/path" { + t.Errorf("Expected '/sample/path', got '%s'", config.MonorepoPath) + } +} + +// TestLoadConfig_InvalidYAML tests handling of invalid YAML. +func TestLoadConfig_InvalidYAML(t *testing.T) { + // Create temporary directory for test + tempDir := t.TempDir() + originalWd, _ := os.Getwd() + defer os.Chdir(originalWd) + + // Change to temp directory + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + // Create invalid config file + configPath := filepath.Join(tempDir, configFileName) + invalidContent := "monorepo_path: [invalid: yaml\n" + if err := os.WriteFile(configPath, []byte(invalidContent), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + // Should return error for invalid YAML + _, err := LoadConfig() + if err == nil { + t.Error("Expected error for invalid YAML") + } +} + +func TestResolveFilePath_AbsolutePath(t *testing.T) { + // Create a temp file + tempFile, err := os.CreateTemp("", "test-file-*.txt") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tempFile.Name()) + tempFile.Close() + + // Test with absolute path + resolved, err := ResolveFilePath(tempFile.Name()) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + if resolved != tempFile.Name() { + t.Errorf("Expected %s, got %s", tempFile.Name(), resolved) + } +} + +func TestResolveFilePath_AbsolutePath_NotExists(t *testing.T) { + // Test with non-existent absolute path + nonExistentPath := "/tmp/this-file-does-not-exist-12345.txt" + _, err := ResolveFilePath(nonExistentPath) + if err == nil { + t.Error("Expected error for non-existent absolute path, got nil") + } +} + +func TestResolveFilePath_RelativeToMonorepo(t *testing.T) { + // Create temp directory structure + tempDir, err := os.MkdirTemp("", "config-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Create a file in the temp directory + testFile := filepath.Join(tempDir, "test-file.txt") + if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + // Change to a different directory + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer os.Chdir(originalDir) + + otherDir, err := os.MkdirTemp("", "other-dir-*") + if err != nil { + t.Fatalf("Failed to create other dir: %v", err) + } + defer os.RemoveAll(otherDir) + + if err := os.Chdir(otherDir); err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + // Create config file pointing to temp directory + configContent := fmt.Sprintf("monorepo_path: %s\n", tempDir) + if err := os.WriteFile(".audit-cli.yaml", []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + // Test resolving relative path (should resolve relative to monorepo) + resolved, err := ResolveFilePath("test-file.txt") + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + expectedPath, _ := filepath.Abs(testFile) + if resolved != expectedPath { + t.Errorf("Expected %s, got %s", expectedPath, resolved) + } +} + +func TestResolveFilePath_RelativeToCurrentDir(t *testing.T) { + // Create temp directory + tempDir, err := os.MkdirTemp("", "config-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Resolve symlinks in temp dir (macOS uses /private/var instead of /var) + tempDir, err = filepath.EvalSymlinks(tempDir) + if err != nil { + t.Fatalf("Failed to resolve symlinks: %v", err) + } + + // Change to temp directory + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer os.Chdir(originalDir) + + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + // Create a file in current directory + testFile := "test-file.txt" + if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + // Test resolving relative path (no monorepo configured) + resolved, err := ResolveFilePath(testFile) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + expectedPath := filepath.Join(tempDir, testFile) + if resolved != expectedPath { + t.Errorf("Expected %s, got %s", expectedPath, resolved) + } +} + +func TestResolveFilePath_NotFound(t *testing.T) { + // Create temp directory + tempDir, err := os.MkdirTemp("", "config-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Change to temp directory + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer os.Chdir(originalDir) + + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + // Test with non-existent relative path + _, err = ResolveFilePath("non-existent-file.txt") + if err == nil { + t.Error("Expected error for non-existent file, got nil") + } +} + +func TestResolveFilePath_PriorityOrder(t *testing.T) { + // Create temp directories + monorepoDir, err := os.MkdirTemp("", "monorepo-*") + if err != nil { + t.Fatalf("Failed to create monorepo dir: %v", err) + } + defer os.RemoveAll(monorepoDir) + + currentDir, err := os.MkdirTemp("", "current-*") + if err != nil { + t.Fatalf("Failed to create current dir: %v", err) + } + defer os.RemoveAll(currentDir) + + // Create files with same name in both directories + monorepoFile := filepath.Join(monorepoDir, "test.txt") + if err := os.WriteFile(monorepoFile, []byte("monorepo"), 0644); err != nil { + t.Fatalf("Failed to write monorepo file: %v", err) + } + + currentFile := filepath.Join(currentDir, "test.txt") + if err := os.WriteFile(currentFile, []byte("current"), 0644); err != nil { + t.Fatalf("Failed to write current file: %v", err) + } + + // Change to current directory + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer os.Chdir(originalDir) + + if err := os.Chdir(currentDir); err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + // Create config file pointing to monorepo + configContent := fmt.Sprintf("monorepo_path: %s\n", monorepoDir) + if err := os.WriteFile(".audit-cli.yaml", []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + // Test resolving relative path - should prefer monorepo over current dir + resolved, err := ResolveFilePath("test.txt") + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + expectedPath, _ := filepath.Abs(monorepoFile) + if resolved != expectedPath { + t.Errorf("Expected monorepo path %s, got %s", expectedPath, resolved) + } + + // Read the file to verify it's the monorepo version + content, err := os.ReadFile(resolved) + if err != nil { + t.Fatalf("Failed to read resolved file: %v", err) + } + + if string(content) != "monorepo" { + t.Errorf("Expected 'monorepo' content, got '%s'", string(content)) + } +} diff --git a/internal/rst/rstspec.go b/internal/rst/rstspec.go new file mode 100644 index 0000000..c402807 --- /dev/null +++ b/internal/rst/rstspec.go @@ -0,0 +1,84 @@ +// Package rst provides utilities for parsing reStructuredText files and related configuration. +package rst + +import ( + "fmt" + "io" + "net/http" + + "github.com/BurntSushi/toml" +) + +// RstspecURL is the URL to the canonical rstspec.toml file in the snooty-parser repository. +const RstspecURL = "https://raw.githubusercontent.com/mongodb/snooty-parser/refs/heads/main/snooty/rstspec.toml" + +// RstspecComposable represents a composable definition from rstspec.toml. +type RstspecComposable struct { + ID string `toml:"id"` + Title string `toml:"title"` + Default string `toml:"default"` + Dependencies []map[string]string `toml:"dependencies"` + Options []RstspecComposableOption `toml:"options"` +} + +// RstspecComposableOption represents an option within a composable. +type RstspecComposableOption struct { + ID string `toml:"id"` + Title string `toml:"title"` +} + +// RstspecConfig represents the structure of the rstspec.toml file. +// This includes all sections, though most commands will only use specific parts. +type RstspecConfig struct { + Composables []RstspecComposable `toml:"composables"` + // Additional sections can be added here as needed: + // Directives map[string]interface{} `toml:"directive"` + // Roles map[string]interface{} `toml:"role"` + // etc. +} + +// FetchRstspec fetches and parses the canonical rstspec.toml file. +// +// This function downloads the rstspec.toml file from the snooty-parser repository +// and parses it into an RstspecConfig structure. This file contains canonical +// definitions for RST directives, roles, composables, and other configuration +// that may be duplicated or extended in local project files. +// +// Returns: +// - *RstspecConfig: The parsed rstspec configuration +// - error: Any error encountered during fetch or parse +// +// Example: +// +// config, err := rst.FetchRstspec() +// if err != nil { +// return fmt.Errorf("failed to fetch rstspec: %w", err) +// } +// fmt.Printf("Found %d composables\n", len(config.Composables)) +func FetchRstspec() (*RstspecConfig, error) { + // Fetch the rstspec.toml file + resp, err := http.Get(RstspecURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch rstspec.toml: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch rstspec.toml: HTTP %d", resp.StatusCode) + } + + // Read the response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read rstspec.toml: %w", err) + } + + // Parse the TOML + var config RstspecConfig + if err := toml.Unmarshal(body, &config); err != nil { + return nil, fmt.Errorf("failed to parse rstspec.toml: %w", err) + } + + return &config, nil +} + diff --git a/internal/rst/rstspec_test.go b/internal/rst/rstspec_test.go new file mode 100644 index 0000000..da7c542 --- /dev/null +++ b/internal/rst/rstspec_test.go @@ -0,0 +1,156 @@ +package rst + +import ( + "testing" +) + +// TestFetchRstspec tests fetching and parsing the canonical rstspec.toml file. +// This is an integration test that makes a real HTTP request to GitHub. +func TestFetchRstspec(t *testing.T) { + config, err := FetchRstspec() + if err != nil { + t.Fatalf("Failed to fetch rstspec.toml: %v", err) + } + + if config == nil { + t.Fatal("Expected non-nil config") + } + + // Verify we got composables + if len(config.Composables) == 0 { + t.Error("Expected at least one composable in rstspec.toml") + } + + t.Logf("Successfully fetched rstspec.toml with %d composables", len(config.Composables)) +} + +// TestRstspecComposablesStructure tests that the composables have the expected structure. +func TestRstspecComposablesStructure(t *testing.T) { + config, err := FetchRstspec() + if err != nil { + t.Fatalf("Failed to fetch rstspec.toml: %v", err) + } + + // Check that we have some well-known composables + expectedComposables := map[string]bool{ + "interface": false, + "language": false, + "deployment-type": false, + "cluster-topology": false, + "cloud-provider": false, + "operating-system": false, + } + + for _, comp := range config.Composables { + // Verify required fields are present + if comp.ID == "" { + t.Error("Found composable with empty ID") + } + if comp.Title == "" { + t.Errorf("Composable %s has empty title", comp.ID) + } + + // Mark expected composables as found + if _, exists := expectedComposables[comp.ID]; exists { + expectedComposables[comp.ID] = true + } + + // Verify options structure + if len(comp.Options) == 0 { + t.Errorf("Composable %s has no options", comp.ID) + } + + for _, opt := range comp.Options { + if opt.ID == "" { + t.Errorf("Composable %s has option with empty ID", comp.ID) + } + if opt.Title == "" { + t.Errorf("Composable %s has option %s with empty title", comp.ID, opt.ID) + } + } + } + + // Verify we found all expected composables + for id, found := range expectedComposables { + if !found { + t.Errorf("Expected composable %s not found in rstspec.toml", id) + } + } + + t.Logf("All expected composables found with valid structure") +} + +// TestRstspecInterfaceComposable tests the interface composable specifically. +func TestRstspecInterfaceComposable(t *testing.T) { + config, err := FetchRstspec() + if err != nil { + t.Fatalf("Failed to fetch rstspec.toml: %v", err) + } + + // Find the interface composable + var interfaceComp *RstspecComposable + for i, comp := range config.Composables { + if comp.ID == "interface" { + interfaceComp = &config.Composables[i] + break + } + } + + if interfaceComp == nil { + t.Fatal("Interface composable not found") + } + + // Verify it has expected options + expectedOptions := []string{"atlas-ui", "driver", "mongosh"} + foundOptions := make(map[string]bool) + + for _, opt := range interfaceComp.Options { + foundOptions[opt.ID] = true + } + + for _, expected := range expectedOptions { + if !foundOptions[expected] { + t.Errorf("Expected option %s not found in interface composable", expected) + } + } + + t.Logf("Interface composable has %d options", len(interfaceComp.Options)) +} + +// TestRstspecLanguageComposable tests the language composable specifically. +func TestRstspecLanguageComposable(t *testing.T) { + config, err := FetchRstspec() + if err != nil { + t.Fatalf("Failed to fetch rstspec.toml: %v", err) + } + + // Find the language composable + var languageComp *RstspecComposable + for i, comp := range config.Composables { + if comp.ID == "language" { + languageComp = &config.Composables[i] + break + } + } + + if languageComp == nil { + t.Fatal("Language composable not found") + } + + // Verify it has expected options (using actual IDs from rstspec.toml) + expectedOptions := []string{"python", "nodejs", "go", "csharp", "java-sync"} + foundOptions := make(map[string]bool) + + for _, opt := range languageComp.Options { + foundOptions[opt.ID] = true + } + + for _, expected := range expectedOptions { + if !foundOptions[expected] { + t.Errorf("Expected option %s not found in language composable", expected) + } + } + + t.Logf("Language composable has %d options", len(languageComp.Options)) +} + diff --git a/main.go b/main.go index 8a0c608..966d293 100644 --- a/main.go +++ b/main.go @@ -25,7 +25,7 @@ import ( // version is the current version of audit-cli. // Update this when releasing new versions following semantic versioning. -const version = "0.1.0" +const version = "0.2.0" func main() { var rootCmd = &cobra.Command{ diff --git a/testdata/composables-test/content/project1/snooty.toml b/testdata/composables-test/content/project1/snooty.toml new file mode 100644 index 0000000..1d41dfd --- /dev/null +++ b/testdata/composables-test/content/project1/snooty.toml @@ -0,0 +1,23 @@ +name = "project1" +title = "Test Project 1" + +[[composables]] +id = "interface" +title = "Interface" +default = "driver" +options = [ + {id = "atlas-ui", title = "Atlas UI"}, + {id = "driver", title = "Driver"}, + {id = "mongosh", title = "MongoDB Shell"}, +] + +[[composables]] +id = "language" +title = "Language" +default = "python" +options = [ + {id = "python", title = "Python"}, + {id = "java", title = "Java"}, + {id = "nodejs", title = "Node.js"}, +] + diff --git a/testdata/composables-test/content/project2/current/snooty.toml b/testdata/composables-test/content/project2/current/snooty.toml new file mode 100644 index 0000000..309331f --- /dev/null +++ b/testdata/composables-test/content/project2/current/snooty.toml @@ -0,0 +1,23 @@ +name = "project2" +title = "Test Project 2 Current" + +[[composables]] +id = "interface" +title = "Interface" +default = "driver" +options = [ + {id = "atlas-ui", title = "Atlas UI"}, + {id = "driver", title = "Driver"}, + {id = "mongosh", title = "MongoDB Shell"}, +] + +[[composables]] +id = "deployment-type" +title = "Deployment Type" +default = "atlas" +options = [ + {id = "atlas", title = "Atlas"}, + {id = "local", title = "Local"}, + {id = "self-managed", title = "Self-Managed"}, +] + diff --git a/testdata/composables-test/content/project2/v1.0/snooty.toml b/testdata/composables-test/content/project2/v1.0/snooty.toml new file mode 100644 index 0000000..c67f937 --- /dev/null +++ b/testdata/composables-test/content/project2/v1.0/snooty.toml @@ -0,0 +1,22 @@ +name = "project2" +title = "Test Project 2 v1.0" + +[[composables]] +id = "interface" +title = "Interface" +default = "driver" +options = [ + {id = "atlas-ui", title = "Atlas UI"}, + {id = "driver", title = "Driver"}, + {id = "mongosh", title = "MongoDB Shell"}, +] + +[[composables]] +id = "deployment-type" +title = "Deployment Type" +default = "atlas" +options = [ + {id = "atlas", title = "Atlas"}, + {id = "local", title = "Local"}, +] +