Skip to content

Commit 7763f7b

Browse files
authored
Merge pull request #527 from rudof-project/issue-418
Refactor CLI Architecture: Implement Command Pattern following Library-Centric Design
2 parents 9aec7ac + 82cf2fa commit 7763f7b

File tree

95 files changed

+6883
-4332
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

95 files changed

+6883
-4332
lines changed

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ base64 = "0.22.1"
254254
calamine = { version = "0.31" }
255255
chrono = { version = "0.4", features = ["serde"] }
256256
clap = { version = "4.5", features = ["derive"] }
257+
clap_complete_command.version = "0.6.1"
257258
clientele = { version = "0.3.8", default-features = false, features = [
258259
"dotenv",
259260
"subcommands",
@@ -273,6 +274,7 @@ indexmap = { version = "2", features = ["serde"] }
273274
indoc = "2"
274275
ipnetwork = "0.21"
275276
iri_s = { version = "0.1.90", path = "./iri_s" }
277+
is_ci = "1.0"
276278
itertools = "0.14"
277279
josekit = "0.10.3"
278280
lazy-regex = "3.4"

docs/src/SUMMARY.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
- [shacl-validate](./cli_usage/shacl_validate.md)
2323
- [validate](./cli_usage/validate.md)
2424
- [convert](./cli_usage/convert.md)
25+
- [completion](./cli_usage/completion.md)
2526
- [compare](./cli_usage/compare.md)
2627
- [rdf-config](./cli_usage/rdf-config.md)
2728
- [generate](./cli_usage/generate.md)
@@ -40,6 +41,7 @@
4041
- [Architecture](./internals/architecture.md)
4142
- [`iri_s`](./internals/crates/iri_s.md)
4243
- [`prefixmap`](./internals/crates/prefixmap.md)
44+
- [`rudof_cli`](./internals/crates/rudof_cli.md)
4345
- [`rudof_rdf`](./internals/crates/rudof_rdf.md)
4446
- [`rudof_mcp`](./internals/crates/rudof_mcp.md)
4547
- [ADRs](./internals/ADRs.md)

docs/src/cli_usage/completion.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# completion
2+
3+
## Overview
4+
5+
The `completion` command generates shell completion scripts for `rudof`, enabling tab completion for commands, subcommands, options, and arguments in your preferred shell.
6+
7+
Once installed, shell completion allows you to:
8+
9+
- **Tab-complete commands**: Type `rudof val<TAB>``rudof validate`
10+
- **Tab-complete options**: Type `rudof validate --sh<TAB>``rudof validate --schema`
11+
- **Tab-complete file paths**: Automatically suggest files and directories for file arguments
12+
- **View available options**: Press `TAB` twice to see all available options at any point
13+
- **Reduce typos**: Let the shell validate command names and options before execution
14+
15+
## Supported Shells
16+
17+
The `completion` command supports completion script generation for the following shells:
18+
19+
- **Bash** - The Bourne Again SHell, default on most Linux distributions
20+
- **Zsh** - Z shell, default on macOS (10.15+) and popular among advanced users
21+
- **Fish** - The friendly interactive shell with built-in completion support
22+
- **PowerShell** - Microsoft PowerShell for Windows, Linux, and macOS
23+
- **Elvish** - A modern shell with a unique approach to scripting
24+
25+
## Command Syntax
26+
27+
```bash
28+
rudof completion <SHELL> [OPTIONS]
29+
```
30+
31+
### Arguments
32+
33+
- `<SHELL>` - The shell for which to generate the completion script
34+
- Required argument
35+
- Possible values: `bash`, `zsh`, `fish`, `powershell`, `elvish`
36+
- Case-insensitive
37+
38+
### Options
39+
40+
- `-o, --output <FILE>` - Write completion script to a file instead of stdout
41+
- `--force-overwrite` - Overwrite the output file if it already exists
42+
43+
## Basic Usage
44+
45+
### Save to a file
46+
47+
You can save the completion script to a file for later installation:
48+
49+
```bash
50+
# Save bash completion to a file
51+
rudof completion bash -o rudof-completion.bash
52+
53+
# Save zsh completion to a file
54+
rudof completion zsh -o _rudof
55+
56+
# Save fish completion to a file
57+
rudof completion fish -o rudof.fish
58+
59+
# Save PowerShell completion to a file
60+
rudof completion powershell -o rudof-completion.ps1
61+
```
62+
63+
## Installation Instructions
64+
65+
After generating the completion script, you need to install it in the appropriate location for your shell. The installation process varies by shell.
66+
67+
> **Note**: For detailed information about completion systems, refer to the official documentation:
68+
> - [Bash Programmable Completion](https://www.gnu.org/software/bash/manual/html_node/Programmable-Completion.html)
69+
> - [Zsh Completion System](https://zsh.sourceforge.io/Doc/Release/Completion-System.html)
70+
> - [Fish Shell Completions](https://fishshell.com/docs/current/completions.html)
71+
> - [PowerShell Tab Completion](https://learn.microsoft.com/en-us/powershell/scripting/learn/shell/tab-completion)
72+
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
# `rudof_cli`
2+
3+
## Overview
4+
5+
The `rudof_cli` crate provides the command-line interface for the Rudof toolkit, that provides a comprehensive suite of tools for working with RDF data and schema languages. It enables users to inspect and validate RDF data using ShEx or SHACL, as well as convert between different RDF modeling languages such as ShEx, SHACL, and DCTAP.
6+
7+
## Architecture and Package Structure
8+
9+
The `rudof_cli` crate is organized around a **command pattern** with the following layers:
10+
11+
### 1. CLI Layer (`cli/`)
12+
13+
Handles command-line argument parsing using Clap, defining the user interface and available commands.
14+
15+
- **`parser.rs`**: Defines the complete CLI structure using Clap's derive macros, including all commands, subcommands, and their arguments. This module uses a declarative approach where commands are represented as enum variants, ensuring type safety and exhaustive pattern matching. Common arguments (configuration paths, output destinations, force overwrite flags) are abstracted into reusable structures like `CommonArgs`, `CommonArgsAll`, and `CommonArgsOutputForceOverWrite` to eliminate duplication. The parser exclusively uses CLI wrapper types (e.g., `DataFormatCli`) instead of library types directly, creating a clean decoupling between the presentation layer and core business logic.
16+
17+
- **`wrappers.rs`**: Provides CLI-friendly wrappers for core library types using a macro-based approach implemented through the `cli_wrapper!` macro. This macro automatically generates all necessary boilerplate code for creating enums with Clap's `ValueEnum` support, bidirectional conversion between CLI and library types, display formatting for help messages, and optional `MimeType` trait implementations.
18+
19+
### 2. Command Layer (`commands/`)
20+
21+
Implements each command using a unified trait-based approach defined in `commands/base.rs`.
22+
23+
- **`base.rs`**: The foundation of the command system, containing three key components:
24+
25+
- **`Command` trait**: The core interface that all commands must implement, defining an `execute(&self, ctx: &mut CommandContext)` method for command logic and a `name(&self)` method for identification. This enables polymorphic command execution where the main application works with trait objects without knowing concrete command types.
26+
27+
- **`CommandContext`**: The shared execution environment that acts as a dependency injection container. It provides commands with a `Box<dyn Write>` output writer (supporting stdout, files, or other destinations), a configured `Rudof` instance initialized with all settings, a debug level for verbosity control, and color support detection. The `from_cli()` factory method bridges CLI parsing and command execution by loading configuration files, initializing the Rudof library, creating appropriate output writers, and detecting terminal capabilities—shielding commands from initialization complexity.
28+
29+
- **`CommandFactory`**: A factory pattern implementation that centralizes command instantiation logic. The `create()` method maps CLI command enum variants to their corresponding `Command` trait objects through type erasure. This design follows the Open-Closed Principle: the system is open for extension (new commands can be added by adding a single match arm) but closed for modification (existing command handling remains unchanged).
30+
31+
- **Individual command modules** (`shex.rs`, `shacl.rs`, `validate.rs`, `data.rs`, `node.rs`, etc.): Each implements the `Command` trait to provide specific functionality. Commands follow a consistent execution pattern: (1) convert CLI wrapper types to library types, (2) execute core logic by calling Rudof library methods and (3) write results to the context's output writer.
32+
33+
### 3. Output Layer (`output/`)
34+
35+
Manages output formatting with automatic color support detection and configurable writers.
36+
37+
- **`color.rs`**: Detects terminal color capabilities through a three-state model (Always, Never, Auto). Respects explicit user preferences via `FORCE_COLOR` and `NO_COLOR` environment variables, automatically detects terminal capabilities using the `supports-color` crate, handles CI environment detection where colored output may not render correctly, and caches detection results to avoid repeated system calls for performance optimization.
38+
39+
- **`writer.rs`**: Creates appropriate output writers based on command-line arguments. When no output file is specified, returns stdout with automatic color detection. When an output file is specified, creates a file handle with overwrite protection and disables color output (since files don't support ANSI codes).
40+
41+
### Command Lifecycle
42+
43+
The `main.rs` orchestrates the complete command lifecycle through five distinct phases:
44+
45+
1. **Setup**: Initializes the application environment by loading `.env` files with environment variables, configuring the tracing subsystem for structured logging (writing to stderr with file and line number information), and setting necessary environment variables for dependent libraries. This establishes the foundational runtime environment.
46+
47+
2. **Parsing**: Safely handles command-line arguments using Clap, including non-UTF8 paths that can occur on some operating systems. Through `clientele::args_os()` and `Cli::parse_from()`, raw arguments are transformed into the strongly-typed `Cli` structure with all validation applied, ensuring only well-formed commands proceed to execution.
48+
49+
3. **Factory**: Uses `CommandFactory::create()` to instantiate the appropriate command implementation from the parsed CLI enum. The factory performs type erasure, returning a `Box<dyn Command>` trait object that enables polymorphic handling without the main function needing to know concrete command types.
50+
51+
4. **Context**: Builds the execution environment through `CommandContext::from_cli()`, which loads the configuration file (if specified), initializes the Rudof library with loaded settings, creates the appropriate output writer (stdout or file) based on CLI flags, and detects terminal color capabilities. Commands receive a fully prepared context with all dependencies resolved.
52+
53+
5. **Execution**: Invokes `command.execute(&mut ctx)` to run the command's business logic. Errors are automatically propagated up the call stack using the `?` operator and handled uniformly by the main function, where they're reported to the user with rich context information. This separation of phases makes the application's execution flow predictable and maintainable.
54+
55+
## Adding a New Command
56+
57+
This section provides a step-by-step guide to adding a new command to the CLI.
58+
59+
### Step 1: Define Command Arguments in `cli/parser.rs`
60+
61+
Add your command's arguments structure:
62+
63+
```rust
64+
#[derive(Args, Debug, Clone)]
65+
pub struct MyCommandArgs {
66+
/// Common arguments (config, output, etc.)
67+
#[command(flatten)]
68+
pub common: CommonArgsAll,
69+
70+
/// Input data file
71+
#[arg(short, long)]
72+
pub input: String,
73+
74+
/// Optional parameter with default
75+
#[arg(short, long, default_value = "default_value")]
76+
pub option: String,
77+
78+
/// Using a CLI wrapper type
79+
#[arg(short, long, value_enum)]
80+
pub format: DataFormatCli,
81+
}
82+
```
83+
84+
Add the command variant to the `Command` enum:
85+
86+
```rust
87+
#[derive(Subcommand, Debug, Clone)]
88+
pub enum Command {
89+
// ... existing commands ...
90+
91+
/// Brief description of your command
92+
MyCommand(MyCommandArgs),
93+
}
94+
```
95+
96+
### Step 2: Add Common Args Handling in `commands/base.rs`
97+
98+
Update the `extract_common()` function to handle your command:
99+
100+
```rust
101+
fn extract_common(cmd: &CliCommand) -> CommonArgs {
102+
match cmd {
103+
// ... existing matches ...
104+
105+
CliCommand::MyCommand(a) => CommonArgs::All(CommonArgsAll {
106+
config: a.common.config.clone(),
107+
output: a.common.output.clone(),
108+
force_overwrite: a.common.force_overwrite,
109+
}),
110+
}
111+
}
112+
```
113+
114+
### Step 3: Create Command Implementation File
115+
116+
Create `commands/my_command.rs`:
117+
118+
```rust
119+
use crate::cli::parser::MyCommandArgs;
120+
use crate::commands::base::{Command, CommandContext};
121+
use anyhow::Result;
122+
123+
/// Implementation of the `my-command` command.
124+
///
125+
/// Detailed description of what this command does.
126+
pub struct MyCommand {
127+
/// Arguments specific to this command.
128+
args: MyCommandArgs,
129+
}
130+
131+
impl MyCommand {
132+
pub fn new(args: MyCommandArgs) -> Self {
133+
Self { args }
134+
}
135+
}
136+
137+
impl Command for MyCommand {
138+
fn name(&self) -> &'static str {
139+
"my-command"
140+
}
141+
142+
fn execute(&self, ctx: &mut CommandContext) -> Result<()> {
143+
// 1. Convert CLI types to library types
144+
let format = (&self.args.format).into();
145+
146+
// 2. Execute your command logic
147+
// ... your implementation ...
148+
149+
// 3. Write output
150+
writeln!(ctx.writer, "Command executed successfully")?;
151+
152+
Ok(())
153+
}
154+
}
155+
```
156+
157+
### Step 4: Register Command in Module System
158+
159+
Update `commands/mod.rs`:
160+
161+
```rust
162+
mod my_command; // Add module declaration
163+
164+
pub use my_command::MyCommand; // Export the command
165+
```
166+
167+
### Step 5: Add to Command Factory
168+
169+
Update the factory in `commands/base.rs`:
170+
171+
```rust
172+
impl CommandFactory {
173+
pub fn create(cli_command: CliCommand) -> Result<Box<dyn Command>> {
174+
match cli_command {
175+
// ... existing matches ...
176+
177+
CliCommand::MyCommand(args) => Ok(Box::new(MyCommand::new(args))),
178+
}
179+
}
180+
}
181+
```
182+
183+
### Step 6: Update Documentation
184+
185+
Add usage documentation in appropriate places.
186+
187+
## Dependencies
188+
189+
### Main Dependencies
190+
191+
The CLI relies on the following external crates:
192+
193+
- **`clap`**: Command-line argument parsing with derive macros
194+
- **`clap_complete_command`**: Shell completion generation
195+
- **`tokio`**: Async runtime for concurrent operations
196+
- **`anyhow`** / **`thiserror`**: Error handling
197+
- **`tracing`** / **`tracing-subscriber`**: Structured logging
198+
- **`tabled`**: Table formatting for output
199+
- **`supports-color`**: Terminal color capability detection
200+
- **`clientele`**: CLI utility helpers

python/src/pyrudof_lib.rs

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ use crate::PyRudofConfig;
88
use pyo3::{Bound, Py, PyAny, PyErr, PyRef, PyRefMut, PyResult, Python, exceptions::PyValueError, pyclass, pymethods};
99
use pythonize::pythonize;
1010
use rudof_lib::{
11-
CoShaMo, ComparatorError, CompareSchemaFormat, CompareSchemaMode, DCTAP, DCTAPFormat, InputSpec, InputSpecError,
12-
InputSpecReader, Mie, MimeType, Object, QueryResultFormat, QueryShapeMap, QuerySolution, QuerySolutions, RDFFormat,
13-
RdfData, ReaderMode, ResultShapeMap, Rudof, RudofError, ServiceDescription, ServiceDescriptionFormat, ShExFormat,
11+
CoShaMo, CompareSchemaFormat, CompareSchemaMode, DCTAP, DCTAPFormat, InputSpec, InputSpecError, InputSpecReader,
12+
Mie, MimeType, Object, QueryResultFormat, QueryShapeMap, QuerySolution, QuerySolutions, RDFFormat, RdfData,
13+
ReaderMode, ResultShapeMap, Rudof, RudofError, ServiceDescription, ServiceDescriptionFormat, ShExFormat,
1414
ShExFormatter, ShExSchema, ShaCo, ShaclFormat, ShaclSchemaIR, ShaclValidationMode, ShapeLabel, ShapeMapFormat,
1515
ShapeMapFormatter, ShapesGraphSource, SortMode, UmlGenerationMode, ValidationReport, ValidationStatus, VarName,
16+
compare::{InputCompareFormat, InputCompareMode},
1617
node_info::{format_node_info_list, get_node_info},
1718
parse_node_selector,
1819
shacl_validation::validation_report::{report::SortModeReport, result::ValidationResult},
@@ -1092,8 +1093,8 @@ impl PyRudof {
10921093
let mode = mode.unwrap_or("shex");
10931094
let reader_mode = cnv_reader_mode(reader_mode);
10941095

1095-
let format = CompareSchemaFormat::from_str(format).map_err(cnv_comparator_err)?;
1096-
let mode = CompareSchemaMode::from_str(mode).map_err(cnv_comparator_err)?;
1096+
let format = InputCompareFormat::from_str(format).map_err(cnv_string_err)?;
1097+
let mode = InputCompareMode::from_str(mode).map_err(cnv_string_err)?;
10971098
let mut reader = schema.as_bytes();
10981099
let coshamo = self
10991100
.inner
@@ -1145,10 +1146,10 @@ impl PyRudof {
11451146
let format2 = format2.unwrap_or("turtle");
11461147
let reader_mode = cnv_reader_mode(reader_mode);
11471148

1148-
let format1 = CompareSchemaFormat::from_str(format1).map_err(cnv_comparator_err)?;
1149-
let format2 = CompareSchemaFormat::from_str(format2).map_err(cnv_comparator_err)?;
1150-
let mode1 = CompareSchemaMode::from_str(mode1).map_err(cnv_comparator_err)?;
1151-
let mode2 = CompareSchemaMode::from_str(mode2).map_err(cnv_comparator_err)?;
1149+
let format1 = InputCompareFormat::from_str(format1).map_err(cnv_string_err)?;
1150+
let format2 = InputCompareFormat::from_str(format2).map_err(cnv_string_err)?;
1151+
let mode1 = InputCompareMode::from_str(mode1).map_err(cnv_string_err)?;
1152+
let mode2 = InputCompareMode::from_str(mode2).map_err(cnv_string_err)?;
11521153
let mut reader1 = schema1.as_bytes();
11531154
let coshamo1 = self
11541155
.inner
@@ -2335,16 +2336,15 @@ pub(crate) fn cnv_err(e: RudofError) -> PyErr {
23352336
e
23362337
}
23372338

2338-
/// Converts a Rust `ComparatorError` into a Python exception, logging it.
2339+
/// Converts a String error into a Python exception.
23392340
///
23402341
/// Args:
2341-
/// e (ComparatorError): The comparator error to convert.
2342+
/// e (String): The error message string to convert.
23422343
///
23432344
/// Returns:
2344-
/// PyErr: Python exception wrapping the error.
2345-
fn cnv_comparator_err(e: ComparatorError) -> PyErr {
2346-
println!("ComparatorError: {e}");
2347-
let e: PyRudofError = PyRudofError::str(format!("{e}"));
2345+
/// PyErr: Python exception corresponding to the error message.
2346+
fn cnv_string_err(e: String) -> PyErr {
2347+
let e: PyRudofError = PyRudofError::str(e);
23482348
let e: PyErr = e.into();
23492349
e
23502350
}

rudof_cli/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ name = "rudof"
1616
[dependencies]
1717
anyhow.workspace = true
1818
clap.workspace = true
19+
clap_complete_command.workspace = true
1920
clientele.workspace = true
2021
dctap.workspace = true
2122
iri_s.workspace = true
23+
is_ci.workspace = true
2224
oxrdf.workspace = true
2325
pgschema.workspace = true
2426
prefixmap.workspace = true

0 commit comments

Comments
 (0)