mirror of
https://codeberg.org/JasterV/granc.git
synced 2026-04-26 18:40:05 +00:00
[feat] Generate markdown documentation for gRPC services (#46)
This PR implements a new subcommand `doc` that generates markdown documentation for a given gRPC service! **Description** For the most part, the inner logic of this subcommand is the same as the `describe`, the only thing that changes is the way that the found descriptor is transformed to a final output. In this case, a `Packages` type has been implemented to transform a `ServiceDescriptor` into a map of `Package`s. Each package groups all the file descriptors with the same package name (or namespace). A `Package` contains all the necessary information for a file of documentation to be generated (All its contained services, messages and enum descriptors and its name). The output of this command is a folder with all the generated documentation, which contains a file per protobuf package. **Introduced the `granc-test-support` crate** * Renamed the `echo_service` crate as `granc-test-support`, providing both the definition of a protobuf service for integration testing and a function to compile protobuffer at runtime into a file descriptor (Potentially this could be used to let users pass a folder to a proto project in addition to the server reflection and the local file descriptor options. For example, the `call` command could compile a file descriptor on the fly from a folder containing a protobuffer project before making the call to the gRPC server. **Descriptor API Enhancements:** * Added `name`, `full_name`, and `package_name` methods to the `Descriptor` enum to simplify access to descriptor metadata. (`granc-core/src/client/types.rs`) **Dependency Management Improvements:** * Added grouping for gRPC-related dependencies in `dependabot.yml` for improved automated dependency updates. (`.github/dependabot.yml`)
This commit is contained in:
parent
bc5a13cc79
commit
c9ef611e07
31 changed files with 1043 additions and 50 deletions
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
|
|
@ -5,6 +5,14 @@ updates:
|
|||
schedule:
|
||||
interval: "daily"
|
||||
time: "09:00"
|
||||
groups:
|
||||
grpc:
|
||||
patterns:
|
||||
- "tonic"
|
||||
- "tonic-*"
|
||||
- "prost"
|
||||
- "prost-*"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
|
|
|
|||
37
Cargo.lock
generated
37
Cargo.lock
generated
|
|
@ -212,18 +212,6 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "echo-service"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost",
|
||||
"prost-types",
|
||||
"tonic",
|
||||
"tonic-prost",
|
||||
"tonic-prost-build",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
|
|
@ -340,37 +328,32 @@ version = "0.7.0"
|
|||
dependencies = [
|
||||
"clap",
|
||||
"colored",
|
||||
"granc_core 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"granc-test-support",
|
||||
"granc_core",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "granc_core"
|
||||
version = "0.6.0"
|
||||
name = "granc-test-support"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"echo-service",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"bytes",
|
||||
"prost",
|
||||
"prost-reflect",
|
||||
"prost-build",
|
||||
"prost-types",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tempfile",
|
||||
"tonic",
|
||||
"tonic-reflection",
|
||||
"tonic-prost",
|
||||
"tonic-prost-build",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "granc_core"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e87c4196f9cdf96a69381385c7029aab768d4d7bc6b1ff90654bf6c3eb19d2"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"granc-test-support",
|
||||
"http",
|
||||
"http-body",
|
||||
"prost",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
[workspace]
|
||||
members = ["granc", "granc-core", "echo-service"]
|
||||
members = ["granc", "granc-core", "granc-test-support"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
|
@ -19,5 +19,8 @@ tokio = { version = "1.49.0" }
|
|||
prost = "0.14"
|
||||
prost-reflect = "0.16.3"
|
||||
prost-types = "0.14"
|
||||
prost-build = "0.14"
|
||||
tonic = "0.14"
|
||||
tonic-prost = "0.14.3"
|
||||
tonic-reflection = "0.14"
|
||||
tonic-prost-build = "0.14"
|
||||
|
|
|
|||
31
README.md
31
README.md
|
|
@ -22,6 +22,7 @@ It is heavily inspired by tools like `grpcurl` but built to leverage the safety
|
|||
* **Server Reflection**: Can fetch schemas directly from the server, removing the need to pass a local file descriptor set file (`.bin` or `.pb`).
|
||||
* **Introspection Tools**: Commands to list services and describe services, messages, and enums.
|
||||
* **Local Introspection**: In addition to making network requests, Granc can also be used as a local introspection tool for file descriptor binary files. You can load a local `.bin` file to inspect services, messages, and enums without needing to fetch the schema from a server.
|
||||
* **Documentation Generator**: Generate static, cross-linked Markdown documentation for your services and types directly from the schema. [See a real example](./examples/docs/index.md) generated from this repo's [example protos](./examples/proto/library).
|
||||
* **Zero Compilation Dependencies**: Does not require generating Rust code for your protos. Just point to a descriptor file.
|
||||
* **Tonic 0.14**: Built on the latest stable Rust gRPC stack.
|
||||
|
||||
|
|
@ -204,6 +205,36 @@ enum Status {
|
|||
|
||||
```
|
||||
|
||||
#### 4. `doc` (Documentation Generator)
|
||||
|
||||
Generates static Markdown documentation for a specific service and its dependencies. This is useful for creating browseable documentation for your gRPC APIs.
|
||||
|
||||
```bash
|
||||
granc doc <SYMBOL> --output <DIR> [OPTIONS]
|
||||
```
|
||||
|
||||
| Argument/Flag | Short | Description |
|
||||
| --- | --- | --- |
|
||||
| `<SYMBOL>` | | Fully qualified name of the Service (e.g., `library.LibraryService`). |
|
||||
| `--output` | `-o` | Directory where the markdown files will be generated. |
|
||||
| `--uri` | `-u` | Use Server Reflection to resolve the schema. |
|
||||
| `--file-descriptor-set` | `-f` | Use a local file to resolve the schema (offline). |
|
||||
|
||||
**Generating docs via Reflection:**
|
||||
|
||||
```bash
|
||||
granc doc helloworld.Greeter --uri http://localhost:50051 --output ./docs
|
||||
```
|
||||
|
||||
**Generating docs from a file:**
|
||||
|
||||
```bash
|
||||
granc doc library.LibraryService --file-descriptor-set examples/library.bin --output ./docs
|
||||
```
|
||||
|
||||
Check out the full [generated documentation example](./examples/docs/index.md) included in this repository.
|
||||
These documents were generated directly from the [library example protos](./examples/proto/library) using the command above.
|
||||
|
||||
## 🔮 Roadmap
|
||||
|
||||
* **Interactive Mode**: A REPL for streaming requests interactively.
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
[package]
|
||||
name = "echo-service"
|
||||
edition = { workspace = true }
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
bytes = "1"
|
||||
prost = "0.14"
|
||||
tonic = "0.14"
|
||||
prost-types = "0.14"
|
||||
tonic-prost = "0.14.3"
|
||||
|
||||
[build-dependencies]
|
||||
tonic-prost-build = "0.14"
|
||||
12
examples/docs/index.md
Normal file
12
examples/docs/index.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Documentation Index
|
||||
|
||||
|
||||
## Service
|
||||
|
||||
- [**LibraryService**](library.md#LibraryService)
|
||||
|
||||
## Packages
|
||||
|
||||
- [library](library.md)
|
||||
- [library.domain](library.domain.md)
|
||||
- [library.rpc](library.rpc.md)
|
||||
86
examples/docs/library.domain.md
Normal file
86
examples/docs/library.domain.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<a id="Author"></a>
|
||||
## Author
|
||||
|
||||
### Definition
|
||||
|
||||
```protobuf
|
||||
package library.domain;
|
||||
|
||||
message Author {
|
||||
string id = 1;
|
||||
string full_name = 2;
|
||||
repeated library.domain.Book bibliography = 3;
|
||||
}
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Field `bibliography`: [Book](library.domain.md#Book)
|
||||
|
||||
---
|
||||
|
||||
<a id="Book"></a>
|
||||
## Book
|
||||
|
||||
### Definition
|
||||
|
||||
```protobuf
|
||||
package library.domain;
|
||||
|
||||
message Book {
|
||||
string isbn = 1;
|
||||
string title = 2;
|
||||
library.domain.Author author = 3;
|
||||
library.domain.Publisher publisher = 4;
|
||||
library.domain.Genre genre = 5;
|
||||
}
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Field `author`: [Author](library.domain.md#Author)
|
||||
- Field `publisher`: [Publisher](library.domain.md#Publisher)
|
||||
- Field `genre`: [Genre](library.domain.md#Genre)
|
||||
|
||||
---
|
||||
|
||||
<a id="Publisher"></a>
|
||||
## Publisher
|
||||
|
||||
### Definition
|
||||
|
||||
```protobuf
|
||||
package library.domain;
|
||||
|
||||
message Publisher {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string address = 3;
|
||||
}
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
*None*
|
||||
|
||||
---
|
||||
|
||||
<a id="Genre"></a>
|
||||
## Genre
|
||||
|
||||
### Definition
|
||||
|
||||
```protobuf
|
||||
package library.domain;
|
||||
|
||||
enum Genre {
|
||||
UNKNOWN = 0;
|
||||
FICTION = 1;
|
||||
NON_FICTION = 2;
|
||||
SCI_FI = 3;
|
||||
HISTORY = 4;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
44
examples/docs/library.md
Normal file
44
examples/docs/library.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<a id="LibraryService"></a>
|
||||
## LibraryService
|
||||
|
||||
### Definition
|
||||
|
||||
```protobuf
|
||||
package library;
|
||||
|
||||
service LibraryService {
|
||||
rpc GetBook(library.rpc.GetBookRequest) returns (library.domain.Book);
|
||||
|
||||
rpc QueryBooks(library.rpc.QueryBooksRequest) returns (stream library.domain.Book);
|
||||
|
||||
rpc Checkout(stream library.rpc.CheckoutRequest) returns (library.rpc.CheckoutResponse);
|
||||
|
||||
rpc SupportChat(stream library.rpc.ChatMessage) returns (stream library.rpc.ChatMessage);
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
### Methods
|
||||
|
||||
#### `GetBook`
|
||||
|
||||
- Request: [GetBookRequest](library.rpc.md#GetBookRequest)
|
||||
- Response: [Book](library.domain.md#Book)
|
||||
|
||||
#### `QueryBooks`
|
||||
|
||||
- Request: [QueryBooksRequest](library.rpc.md#QueryBooksRequest)
|
||||
- Response: [Book](library.domain.md#Book)
|
||||
|
||||
#### `Checkout`
|
||||
|
||||
- Request: [CheckoutRequest](library.rpc.md#CheckoutRequest)
|
||||
- Response: [CheckoutResponse](library.rpc.md#CheckoutResponse)
|
||||
|
||||
#### `SupportChat`
|
||||
|
||||
- Request: [ChatMessage](library.rpc.md#ChatMessage)
|
||||
- Response: [ChatMessage](library.rpc.md#ChatMessage)
|
||||
|
||||
---
|
||||
|
||||
100
examples/docs/library.rpc.md
Normal file
100
examples/docs/library.rpc.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<a id="ChatMessage"></a>
|
||||
## ChatMessage
|
||||
|
||||
### Definition
|
||||
|
||||
```protobuf
|
||||
package library.rpc;
|
||||
|
||||
message ChatMessage {
|
||||
string user_id = 1;
|
||||
string text = 2;
|
||||
int64 timestamp = 3;
|
||||
}
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
*None*
|
||||
|
||||
---
|
||||
|
||||
<a id="CheckoutRequest"></a>
|
||||
## CheckoutRequest
|
||||
|
||||
### Definition
|
||||
|
||||
```protobuf
|
||||
package library.rpc;
|
||||
|
||||
message CheckoutRequest {
|
||||
string isbn = 1;
|
||||
}
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
*None*
|
||||
|
||||
---
|
||||
|
||||
<a id="CheckoutResponse"></a>
|
||||
## CheckoutResponse
|
||||
|
||||
### Definition
|
||||
|
||||
```protobuf
|
||||
package library.rpc;
|
||||
|
||||
message CheckoutResponse {
|
||||
repeated library.domain.Book checked_out_books = 1;
|
||||
int32 total_items = 2;
|
||||
string due_date = 3;
|
||||
}
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Field `checked_out_books`: [Book](library.domain.md#Book)
|
||||
|
||||
---
|
||||
|
||||
<a id="GetBookRequest"></a>
|
||||
## GetBookRequest
|
||||
|
||||
### Definition
|
||||
|
||||
```protobuf
|
||||
package library.rpc;
|
||||
|
||||
message GetBookRequest {
|
||||
string isbn = 1;
|
||||
}
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
*None*
|
||||
|
||||
---
|
||||
|
||||
<a id="QueryBooksRequest"></a>
|
||||
## QueryBooksRequest
|
||||
|
||||
### Definition
|
||||
|
||||
```protobuf
|
||||
package library.rpc;
|
||||
|
||||
message QueryBooksRequest {
|
||||
string title_prefix = 1;
|
||||
library.domain.Genre genre_filter = 2;
|
||||
}
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Field `genre_filter`: [Genre](library.domain.md#Genre)
|
||||
|
||||
---
|
||||
|
||||
38
examples/proto/library/domain.proto
Normal file
38
examples/proto/library/domain.proto
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
syntax = "proto3";
|
||||
|
||||
package library.domain;
|
||||
|
||||
enum Genre {
|
||||
UNKNOWN = 0;
|
||||
FICTION = 1;
|
||||
NON_FICTION = 2;
|
||||
SCI_FI = 3;
|
||||
HISTORY = 4;
|
||||
}
|
||||
|
||||
message Publisher {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string address = 3;
|
||||
}
|
||||
|
||||
// Represents a Book in the collection.
|
||||
message Book {
|
||||
string isbn = 1;
|
||||
string title = 2;
|
||||
|
||||
// Circular Dependency: Book references Author (valid since they are in the same file)
|
||||
Author author = 3;
|
||||
|
||||
Publisher publisher = 4;
|
||||
|
||||
Genre genre = 5;
|
||||
}
|
||||
|
||||
message Author {
|
||||
string id = 1;
|
||||
string full_name = 2;
|
||||
|
||||
// Circular Dependency: Author references Book
|
||||
repeated Book bibliography = 3;
|
||||
}
|
||||
32
examples/proto/library/rpc.proto
Normal file
32
examples/proto/library/rpc.proto
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
syntax = "proto3";
|
||||
|
||||
package library.rpc;
|
||||
|
||||
import "library/domain.proto";
|
||||
|
||||
message GetBookRequest {
|
||||
string isbn = 1;
|
||||
}
|
||||
|
||||
message QueryBooksRequest {
|
||||
string title_prefix = 1;
|
||||
// Reusing the Genre enum from domain.proto
|
||||
library.domain.Genre genre_filter = 2;
|
||||
}
|
||||
|
||||
message CheckoutRequest {
|
||||
string isbn = 1;
|
||||
}
|
||||
|
||||
message CheckoutResponse {
|
||||
// Reusing Book type
|
||||
repeated library.domain.Book checked_out_books = 1;
|
||||
int32 total_items = 2;
|
||||
string due_date = 3;
|
||||
}
|
||||
|
||||
message ChatMessage {
|
||||
string user_id = 1;
|
||||
string text = 2;
|
||||
int64 timestamp = 3;
|
||||
}
|
||||
21
examples/proto/library/service.proto
Normal file
21
examples/proto/library/service.proto
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
syntax = "proto3";
|
||||
|
||||
package library;
|
||||
|
||||
import "library/domain.proto";
|
||||
import "library/rpc.proto";
|
||||
|
||||
// Service for managing a collection of books and authors.
|
||||
service LibraryService {
|
||||
// Unary
|
||||
rpc GetBook(library.rpc.GetBookRequest) returns (library.domain.Book);
|
||||
|
||||
// Server Streaming
|
||||
rpc QueryBooks(library.rpc.QueryBooksRequest) returns (stream library.domain.Book);
|
||||
|
||||
// Client Streaming
|
||||
rpc Checkout(stream library.rpc.CheckoutRequest) returns (library.rpc.CheckoutResponse);
|
||||
|
||||
// Bidirectional
|
||||
rpc SupportChat(stream library.rpc.ChatMessage) returns (stream library.rpc.ChatMessage);
|
||||
}
|
||||
|
|
@ -32,5 +32,5 @@ tonic = { workspace = true }
|
|||
tonic-reflection = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
echo-service = { path = "../echo-service" }
|
||||
granc-test-support = { path = "../granc-test-support" }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
|
|
|
|||
|
|
@ -37,6 +37,33 @@ pub enum Descriptor {
|
|||
}
|
||||
|
||||
impl Descriptor {
|
||||
/// Returns the name (e.g.,`MyMessage`) of the inner descriptor
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
Descriptor::MessageDescriptor(v) => v.name(),
|
||||
Descriptor::ServiceDescriptor(v) => v.name(),
|
||||
Descriptor::EnumDescriptor(v) => v.name(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the full_name (e.g.,`my.package.v1.MyMessage`) of the inner descriptor
|
||||
pub fn full_name(&self) -> &str {
|
||||
match self {
|
||||
Descriptor::MessageDescriptor(v) => v.full_name(),
|
||||
Descriptor::ServiceDescriptor(v) => v.full_name(),
|
||||
Descriptor::EnumDescriptor(v) => v.full_name(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the package name (e.g.,`my.package.v1`) of the inner descriptor
|
||||
pub fn package_name(&self) -> &str {
|
||||
match self {
|
||||
Descriptor::MessageDescriptor(v) => v.package_name(),
|
||||
Descriptor::ServiceDescriptor(v) => v.package_name(),
|
||||
Descriptor::EnumDescriptor(v) => v.package_name(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the inner [`MessageDescriptor`] if this variant is `MessageDescriptor`.
|
||||
pub fn message_descriptor(&self) -> Option<&MessageDescriptor> {
|
||||
match self {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use echo_service::EchoService;
|
||||
use echo_service::pb::{EchoRequest, EchoResponse};
|
||||
use futures_util::Stream;
|
||||
use futures_util::StreamExt;
|
||||
use granc_test_support::echo_service::EchoService;
|
||||
use granc_test_support::echo_service::pb::{EchoRequest, EchoResponse};
|
||||
use std::pin::Pin;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use echo_service::FILE_DESCRIPTOR_SET;
|
||||
use granc_core::client::{Descriptor, GrancClient};
|
||||
use granc_test_support::echo_service::FILE_DESCRIPTOR_SET;
|
||||
|
||||
#[test]
|
||||
fn test_offline_list_services() {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use echo_service::{EchoServiceServer, FILE_DESCRIPTOR_SET};
|
||||
use echo_service_impl::EchoServiceImpl;
|
||||
use granc_core::client::{DynamicRequest, DynamicResponse, GrancClient, Online, online};
|
||||
use granc_core::reflection::client::ReflectionResolveError;
|
||||
use granc_test_support::echo_service::{EchoServiceServer, FILE_DESCRIPTOR_SET};
|
||||
use tonic::Code;
|
||||
use tonic::service::Routes;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
use echo_service::{EchoServiceServer, FILE_DESCRIPTOR_SET};
|
||||
use echo_service_impl::EchoServiceImpl;
|
||||
use granc_core::client::{
|
||||
DynamicRequest, DynamicResponse, GrancClient, OnlineWithoutReflection,
|
||||
online_without_reflection,
|
||||
};
|
||||
use granc_test_support::echo_service::{EchoServiceServer, FILE_DESCRIPTOR_SET};
|
||||
use tonic::Code;
|
||||
|
||||
mod echo_service_impl;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use echo_service::{EchoServiceServer, FILE_DESCRIPTOR_SET};
|
||||
use echo_service_impl::EchoServiceImpl;
|
||||
use granc_core::reflection::client::{ReflectionClient, ReflectionResolveError};
|
||||
use granc_test_support::echo_service::{EchoServiceServer, FILE_DESCRIPTOR_SET};
|
||||
use prost_reflect::DescriptorPool;
|
||||
use tonic::Code;
|
||||
use tonic_reflection::server::v1::ServerReflectionServer;
|
||||
|
|
|
|||
16
granc-test-support/Cargo.toml
Normal file
16
granc-test-support/Cargo.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[package]
|
||||
name = "granc-test-support"
|
||||
edition = { workspace = true }
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
bytes = "1"
|
||||
prost = { workspace = true }
|
||||
tonic = { workspace = true }
|
||||
prost-types = { workspace = true }
|
||||
tonic-prost = { workspace = true }
|
||||
prost-build = { workspace = true }
|
||||
tempfile = "3"
|
||||
|
||||
[build-dependencies]
|
||||
tonic-prost-build = { workspace = true }
|
||||
36
granc-test-support/src/compiler.rs
Normal file
36
granc-test-support/src/compiler.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
//! This module provides tools to compile protobuffer files at runtime.
|
||||
use prost::Message;
|
||||
use prost_types::FileDescriptorSet;
|
||||
use std::fs;
|
||||
|
||||
/// Compiles inline proto strings into a FileDescriptorSet at runtime.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `files` - A list of tuples (filename, content). E.g. `[("test.proto", "syntax=...")]`
|
||||
pub fn compile_protos(files: &[(&str, &str)]) -> FileDescriptorSet {
|
||||
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
|
||||
let descriptor_path = temp_dir.path().join("descriptor.bin");
|
||||
let proto_dir = temp_dir.path().join("protos");
|
||||
|
||||
fs::create_dir(&proto_dir).expect("Failed to create protos dir");
|
||||
|
||||
let paths: Vec<_> = files
|
||||
.iter()
|
||||
.map(|(name, content)| {
|
||||
let path = proto_dir.join(name);
|
||||
fs::write(&path, content).expect("Failed to write proto file");
|
||||
path
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut config = prost_build::Config::new();
|
||||
config.file_descriptor_set_path(&descriptor_path);
|
||||
config.out_dir(temp_dir.path());
|
||||
config
|
||||
.compile_protos(&paths, &[proto_dir])
|
||||
.expect("Failed to compile protos");
|
||||
|
||||
let bytes = fs::read(descriptor_path).expect("Failed to read descriptor set");
|
||||
|
||||
FileDescriptorSet::decode(bytes.as_slice()).expect("Failed to decode File descriptor set")
|
||||
}
|
||||
2
granc-test-support/src/lib.rs
Normal file
2
granc-test-support/src/lib.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod compiler;
|
||||
pub mod echo_service;
|
||||
|
|
@ -16,6 +16,9 @@ version = "0.7.0"
|
|||
[dependencies]
|
||||
clap = { version = "4.5.56", features = ["derive"] }
|
||||
colored = "3.1.1"
|
||||
granc_core = "0.6.0"
|
||||
granc_core = { path = "../granc-core" }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
|
||||
[dev-dependencies]
|
||||
granc-test-support = { path = "../granc-test-support" }
|
||||
|
|
|
|||
|
|
@ -57,6 +57,19 @@ pub enum Commands {
|
|||
/// Fully qualified name (e.g. my.package.Service)
|
||||
symbol: String,
|
||||
},
|
||||
|
||||
/// Generate Markdown documentation for a service.
|
||||
Doc {
|
||||
#[command(flatten)]
|
||||
source: SourceSelection,
|
||||
|
||||
/// Fully qualified service name (e.g. my.package.MyService)
|
||||
symbol: String,
|
||||
|
||||
/// Output directory for the generated markdown files
|
||||
#[arg(long, short = 'o')]
|
||||
output: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
|
|
@ -272,6 +285,63 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_doc_command_reflection() {
|
||||
let args = vec![
|
||||
"granc",
|
||||
"doc",
|
||||
"my.package.Service",
|
||||
"--uri",
|
||||
"http://localhost:50051",
|
||||
"--output",
|
||||
"./docs",
|
||||
];
|
||||
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
|
||||
|
||||
match cli.command {
|
||||
Commands::Doc {
|
||||
symbol,
|
||||
source,
|
||||
output,
|
||||
} => {
|
||||
assert_eq!(symbol, "my.package.Service");
|
||||
assert_eq!(source.uri.unwrap(), "http://localhost:50051");
|
||||
assert_eq!(output.to_str().unwrap(), "./docs");
|
||||
}
|
||||
_ => panic!("Expected Doc command"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_doc_command_offline() {
|
||||
let args = vec![
|
||||
"granc",
|
||||
"doc",
|
||||
"my.package.Service",
|
||||
"--file-descriptor-set",
|
||||
"descriptors.bin",
|
||||
"-o",
|
||||
"./docs",
|
||||
];
|
||||
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
|
||||
|
||||
match cli.command {
|
||||
Commands::Doc {
|
||||
symbol,
|
||||
source,
|
||||
output,
|
||||
} => {
|
||||
assert_eq!(symbol, "my.package.Service");
|
||||
assert_eq!(
|
||||
source.file_descriptor_set.unwrap().to_str().unwrap(),
|
||||
"descriptors.bin"
|
||||
);
|
||||
assert_eq!(output.to_str().unwrap(), "./docs");
|
||||
}
|
||||
_ => panic!("Expected Doc command"),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Failure Cases ---
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
2
granc/src/docgen.rs
Normal file
2
granc/src/docgen.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod markdown;
|
||||
mod package;
|
||||
185
granc/src/docgen/markdown.rs
Normal file
185
granc/src/docgen/markdown.rs
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
use super::package::{Package, Packages};
|
||||
use crate::formatter::FormattedString;
|
||||
use granc_core::prost_reflect::{EnumDescriptor, Kind, MessageDescriptor, ServiceDescriptor};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn generate(output_dir: PathBuf, service: ServiceDescriptor) -> std::io::Result<()> {
|
||||
// Disable colors for plain text generation
|
||||
colored::control::set_override(false);
|
||||
|
||||
if !output_dir.exists() {
|
||||
fs::create_dir_all(&output_dir)?;
|
||||
}
|
||||
|
||||
let packages = Packages::from(service.clone());
|
||||
|
||||
for package in packages.values() {
|
||||
let filename = format!("{}.md", package.name);
|
||||
let path = output_dir.join(&filename);
|
||||
|
||||
let out = generate_package_file(package)?;
|
||||
|
||||
fs::write(path, out)?;
|
||||
println!("Generated: {}", filename);
|
||||
}
|
||||
|
||||
let path = output_dir.join("index.md");
|
||||
let out = generate_index(&service, &packages)?;
|
||||
fs::write(path, out)?;
|
||||
println!("Generated: index.md");
|
||||
|
||||
// Restore colors
|
||||
colored::control::unset_override();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_index(
|
||||
entry_service: &ServiceDescriptor,
|
||||
packages: &Packages,
|
||||
) -> std::io::Result<String> {
|
||||
let mut out = String::new();
|
||||
|
||||
out.push_str("# Documentation Index\n\n\n");
|
||||
|
||||
let svc_package = entry_service.package_name();
|
||||
let svc_link = format!("{}.md#{}", svc_package, entry_service.name());
|
||||
|
||||
out.push_str("## Service\n\n");
|
||||
out.push_str(&format!("- [**{}**]({})\n", entry_service.name(), svc_link));
|
||||
|
||||
out.push_str("\n## Packages\n\n");
|
||||
|
||||
// Collect package names (Google packages included)
|
||||
let mut package_names: Vec<_> = packages.names().collect();
|
||||
package_names.sort();
|
||||
|
||||
if package_names.is_empty() {
|
||||
out.push_str("*None*\n");
|
||||
} else {
|
||||
for name in package_names {
|
||||
out.push_str(&format!("- [{}]({}.md)\n", name, name));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn generate_package_file(package: &Package) -> std::io::Result<String> {
|
||||
let mut out = String::new();
|
||||
|
||||
let mut services = package.services.clone();
|
||||
services.sort_by(|a, b| a.name().cmp(b.name()));
|
||||
|
||||
for service in services {
|
||||
write_anchor(&mut out, service.name());
|
||||
out.push_str(&format!("## {}\n\n", service.name()));
|
||||
write_service_content(&mut out, &service);
|
||||
out.push_str("---\n\n");
|
||||
}
|
||||
|
||||
let mut messages = package.messages.clone();
|
||||
messages.sort_by(|a, b| a.name().cmp(b.name()));
|
||||
|
||||
for message in messages {
|
||||
write_anchor(&mut out, message.name());
|
||||
out.push_str(&format!("## {}\n\n", message.name()));
|
||||
write_message_content(&mut out, &message);
|
||||
out.push_str("---\n\n");
|
||||
}
|
||||
|
||||
let mut enums = package.enums.clone();
|
||||
enums.sort_by(|a, b| a.name().cmp(b.name()));
|
||||
|
||||
for enum_desc in enums {
|
||||
write_anchor(&mut out, enum_desc.name());
|
||||
out.push_str(&format!("## {}\n\n", enum_desc.name()));
|
||||
write_enum_content(&mut out, &enum_desc);
|
||||
out.push_str("---\n\n");
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn write_anchor(out: &mut String, name: &str) {
|
||||
out.push_str(&format!("<a id=\"{name}\"></a>\n"));
|
||||
}
|
||||
|
||||
fn write_service_content(out: &mut String, service: &ServiceDescriptor) {
|
||||
out.push_str("### Definition\n\n```protobuf\n");
|
||||
out.push_str(&format!("package {};\n\n", service.package_name()));
|
||||
out.push_str(&FormattedString::from(service.clone()).0);
|
||||
out.push_str("\n```\n\n");
|
||||
|
||||
out.push_str("### Methods\n\n");
|
||||
for method in service.methods() {
|
||||
out.push_str(&format!("#### `{}`\n\n", method.name()));
|
||||
|
||||
let input = method.input();
|
||||
let output = method.output();
|
||||
|
||||
let input_link = resolve_link(input.package_name(), input.name());
|
||||
let output_link = resolve_link(output.package_name(), output.name());
|
||||
|
||||
out.push_str(&format!("- Request: [{}]({})\n", input.name(), input_link));
|
||||
out.push_str(&format!(
|
||||
"- Response: [{}]({})\n",
|
||||
output.name(),
|
||||
output_link
|
||||
));
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
fn write_message_content(out: &mut String, message: &MessageDescriptor) {
|
||||
out.push_str("### Definition\n\n```protobuf\n");
|
||||
out.push_str(&format!("package {};\n\n", message.package_name()));
|
||||
out.push_str(&FormattedString::from(message.clone()).0);
|
||||
out.push_str("\n```\n\n");
|
||||
|
||||
out.push_str("### Dependencies\n\n");
|
||||
let mut has_deps = false;
|
||||
|
||||
for field in message.fields() {
|
||||
match field.kind() {
|
||||
Kind::Message(m) => {
|
||||
has_deps = true;
|
||||
let link = resolve_link(m.package_name(), m.name());
|
||||
out.push_str(&format!(
|
||||
"- Field `{}`: [{}]({})\n",
|
||||
field.name(),
|
||||
m.name(),
|
||||
link
|
||||
));
|
||||
}
|
||||
Kind::Enum(e) => {
|
||||
has_deps = true;
|
||||
let link = resolve_link(e.package_name(), e.name());
|
||||
out.push_str(&format!(
|
||||
"- Field `{}`: [{}]({})\n",
|
||||
field.name(),
|
||||
e.name(),
|
||||
link
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !has_deps {
|
||||
out.push_str("*None*\n");
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
fn write_enum_content(out: &mut String, enum_desc: &EnumDescriptor) {
|
||||
out.push_str("### Definition\n\n```protobuf\n");
|
||||
out.push_str(&format!("package {};\n\n", enum_desc.package_name()));
|
||||
out.push_str(&FormattedString::from(enum_desc.clone()).0);
|
||||
out.push_str("\n```\n\n");
|
||||
}
|
||||
|
||||
fn resolve_link(package: &str, name: &str) -> String {
|
||||
// Always link to local file + anchor
|
||||
format!("{}.md#{}", package, name)
|
||||
}
|
||||
286
granc/src/docgen/package.rs
Normal file
286
granc/src/docgen/package.rs
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
//! # Package
|
||||
//!
|
||||
//! This module defines two types that provide all the information needed to generate documentation about a protobuffer project:
|
||||
//!
|
||||
//! + [`Package`]: Contains the required data for other modules to be able to generate documentation about a single package.
|
||||
//! + [`Packages`]: A collection of packages. It can be constructed from a single Service descriptor.
|
||||
use granc_core::{
|
||||
client::Descriptor,
|
||||
prost_reflect::{EnumDescriptor, Kind, MessageDescriptor, ServiceDescriptor},
|
||||
};
|
||||
use std::collections::{HashMap, hash_map::Keys};
|
||||
|
||||
/// Represents a single protobuffer package.
|
||||
///
|
||||
/// It contains all the services, messages and enums described in the package.
|
||||
pub(crate) struct Package {
|
||||
pub name: String,
|
||||
pub services: Vec<ServiceDescriptor>,
|
||||
pub messages: Vec<MessageDescriptor>,
|
||||
pub enums: Vec<EnumDescriptor>,
|
||||
}
|
||||
|
||||
impl Package {
|
||||
fn new(name: String) -> Self {
|
||||
Package {
|
||||
name,
|
||||
services: vec![],
|
||||
messages: vec![],
|
||||
enums: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn push_descriptor(&mut self, descriptor: Descriptor) {
|
||||
match descriptor {
|
||||
Descriptor::MessageDescriptor(v) => self.messages.push(v),
|
||||
Descriptor::ServiceDescriptor(v) => self.services.push(v),
|
||||
Descriptor::EnumDescriptor(v) => self.enums.push(v),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Descriptor> for Package {
|
||||
fn from(value: Descriptor) -> Self {
|
||||
let package_name = value.package_name().to_string();
|
||||
let mut package = Package::new(package_name);
|
||||
package.push_descriptor(value);
|
||||
package
|
||||
}
|
||||
}
|
||||
|
||||
/// A collection of protobuffer packages.
|
||||
/// It can be constructed from a `ServiceDescriptor`.
|
||||
/// Packages are constructed after building a graph of all the descriptor dependencies.
|
||||
/// This graph removes duplication of dependencies and ensures the quality of the information provided by each `Package`.
|
||||
pub(crate) struct Packages(HashMap<String, Package>);
|
||||
|
||||
impl Packages {
|
||||
pub fn values(&self) -> std::collections::hash_map::Values<'_, String, Package> {
|
||||
self.0.values()
|
||||
}
|
||||
|
||||
pub fn names(&self) -> Keys<'_, String, Package> {
|
||||
self.0.keys()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ServiceDescriptor> for Packages {
|
||||
fn from(value: ServiceDescriptor) -> Self {
|
||||
let mut descriptors = collect_service_dependencies(&value);
|
||||
|
||||
descriptors.insert(
|
||||
value.full_name().to_string(),
|
||||
Descriptor::ServiceDescriptor(value),
|
||||
);
|
||||
|
||||
let packages = group_descriptors_by_package(descriptors.into_values());
|
||||
Packages(packages)
|
||||
}
|
||||
}
|
||||
|
||||
fn group_descriptors_by_package(
|
||||
descriptors: impl IntoIterator<Item = Descriptor>,
|
||||
) -> HashMap<String, Package> {
|
||||
descriptors
|
||||
.into_iter()
|
||||
.fold(HashMap::new(), |mut acc, descriptor| {
|
||||
let package_name = descriptor.package_name();
|
||||
|
||||
match acc.get_mut(package_name) {
|
||||
Some(package) => package.push_descriptor(descriptor),
|
||||
None => {
|
||||
let _ = acc.insert(package_name.to_string(), Package::from(descriptor));
|
||||
}
|
||||
}
|
||||
|
||||
acc
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_service_dependencies(service: &ServiceDescriptor) -> HashMap<String, Descriptor> {
|
||||
service
|
||||
.methods()
|
||||
.flat_map(|m| [m.input(), m.output()])
|
||||
.fold(HashMap::new(), |mut acc, d| {
|
||||
let message_name = d.full_name().to_string();
|
||||
|
||||
if acc.contains_key(&message_name) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.insert(message_name, Descriptor::MessageDescriptor(d.clone()));
|
||||
|
||||
collect_message_dependencies(acc, &d)
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_message_dependencies(
|
||||
descriptors: HashMap<String, Descriptor>,
|
||||
message: &MessageDescriptor,
|
||||
) -> HashMap<String, Descriptor> {
|
||||
message
|
||||
.fields()
|
||||
.fold(descriptors, |mut acc, field| match field.kind() {
|
||||
Kind::Message(m) => {
|
||||
let message_name = m.full_name().to_string();
|
||||
|
||||
if acc.contains_key(&message_name) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.insert(message_name, Descriptor::MessageDescriptor(m.clone()));
|
||||
|
||||
collect_message_dependencies(acc, &m)
|
||||
}
|
||||
Kind::Enum(e) => {
|
||||
acc.insert(e.full_name().to_string(), Descriptor::EnumDescriptor(e));
|
||||
acc
|
||||
}
|
||||
_ => acc,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use granc_core::prost_reflect::DescriptorPool;
|
||||
use granc_test_support::compiler;
|
||||
|
||||
fn compile_protos(files: &[(&str, &str)]) -> DescriptorPool {
|
||||
let file_descriptor_set = compiler::compile_protos(files);
|
||||
DescriptorPool::from_file_descriptor_set(file_descriptor_set)
|
||||
.expect("Failed to decode descriptor pool")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_package_collection_with_deduplication() {
|
||||
let proto = r#"
|
||||
syntax = "proto3";
|
||||
package test;
|
||||
|
||||
enum Status {
|
||||
UNKNOWN = 0;
|
||||
OK = 1;
|
||||
}
|
||||
|
||||
message Request {
|
||||
Status status = 1;
|
||||
}
|
||||
|
||||
message Response {
|
||||
Status status = 1;
|
||||
}
|
||||
|
||||
service MyService {
|
||||
rpc DoSomething(Request) returns (Response);
|
||||
}
|
||||
"#;
|
||||
|
||||
let pool = compile_protos(&[("test.proto", proto)]);
|
||||
let service = pool
|
||||
.get_service_by_name("test.MyService")
|
||||
.expect("Service not found");
|
||||
|
||||
let packages = Packages::from(service);
|
||||
|
||||
let test_package = packages.0.get("test").expect("Package 'test' missing");
|
||||
|
||||
assert_eq!(test_package.services.len(), 1);
|
||||
assert_eq!(test_package.services[0].name(), "MyService");
|
||||
|
||||
assert_eq!(test_package.messages.len(), 2);
|
||||
|
||||
let msg_names: Vec<_> = test_package.messages.iter().map(|m| m.name()).collect();
|
||||
assert!(msg_names.contains(&"Request"));
|
||||
assert!(msg_names.contains(&"Response"));
|
||||
|
||||
assert_eq!(
|
||||
test_package.enums.len(),
|
||||
1,
|
||||
"Enum should appear exactly once"
|
||||
);
|
||||
assert_eq!(test_package.enums[0].name(), "Status");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_circular_dependency_handling() {
|
||||
let proto = r#"
|
||||
syntax = "proto3";
|
||||
package cycle;
|
||||
|
||||
message NodeA {
|
||||
NodeB child = 1;
|
||||
}
|
||||
|
||||
message NodeB {
|
||||
NodeA parent = 1;
|
||||
}
|
||||
|
||||
service Cycler {
|
||||
rpc Cycle(NodeA) returns (NodeA);
|
||||
}
|
||||
"#;
|
||||
|
||||
let pool = compile_protos(&[("cycle.proto", proto)]);
|
||||
let service = pool
|
||||
.get_service_by_name("cycle.Cycler")
|
||||
.expect("Service not found");
|
||||
|
||||
let packages = Packages::from(service);
|
||||
|
||||
let pkg = packages.0.get("cycle").expect("Package 'cycle' missing");
|
||||
|
||||
assert_eq!(pkg.messages.len(), 2);
|
||||
assert_eq!(pkg.services.len(), 1);
|
||||
assert_eq!(pkg.enums.len(), 0);
|
||||
|
||||
let names: Vec<_> = pkg.messages.iter().map(|m| m.name()).collect();
|
||||
|
||||
assert!(names.contains(&"NodeA"));
|
||||
assert!(names.contains(&"NodeB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_file_imports() {
|
||||
let common_proto = r#"
|
||||
syntax = "proto3";
|
||||
package common;
|
||||
|
||||
message Shared {
|
||||
string id = 1;
|
||||
}
|
||||
"#;
|
||||
|
||||
let app_proto = r#"
|
||||
syntax = "proto3";
|
||||
package app;
|
||||
|
||||
import "common.proto";
|
||||
|
||||
service AppService {
|
||||
rpc Get(common.Shared) returns (common.Shared);
|
||||
}
|
||||
"#;
|
||||
|
||||
let pool = compile_protos(&[("common.proto", common_proto), ("app.proto", app_proto)]);
|
||||
|
||||
let service = pool
|
||||
.get_service_by_name("app.AppService")
|
||||
.expect("Service not found");
|
||||
|
||||
let packages = Packages::from(service);
|
||||
|
||||
let app_pkg = packages.0.get("app").expect("Package 'app' missing");
|
||||
|
||||
assert_eq!(app_pkg.services.len(), 1);
|
||||
assert_eq!(app_pkg.messages.len(), 0);
|
||||
assert_eq!(app_pkg.enums.len(), 0);
|
||||
|
||||
let common_pkg = packages.0.get("common").expect("Package 'common' missing");
|
||||
|
||||
assert_eq!(common_pkg.messages.len(), 1);
|
||||
assert_eq!(common_pkg.messages[0].name(), "Shared");
|
||||
assert_eq!(common_pkg.services.len(), 0);
|
||||
assert_eq!(common_pkg.enums.len(), 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
//! 3. **Execution**: Delegates request processing to `GrancClient`.
|
||||
//! 4. **Presentation**: Formats and prints data.
|
||||
mod cli;
|
||||
mod docgen;
|
||||
mod formatter;
|
||||
|
||||
use clap::Parser;
|
||||
|
|
@ -44,6 +45,27 @@ async fn main() {
|
|||
let descriptor = describe(symbol, source.value()).await;
|
||||
println!("{}", FormattedString::from(descriptor))
|
||||
}
|
||||
|
||||
// Add the Doc handler
|
||||
Commands::Doc {
|
||||
symbol,
|
||||
source,
|
||||
output,
|
||||
} => {
|
||||
let descriptor = describe(symbol.clone(), source.value()).await;
|
||||
|
||||
let service_descriptor = descriptor
|
||||
.service_descriptor()
|
||||
.cloned()
|
||||
.ok_or(GenericError("The symbol must be a Service", symbol))
|
||||
.unwrap_or_exit();
|
||||
|
||||
docgen::markdown::generate(output, service_descriptor)
|
||||
.map_err(|e| GenericError("Failed to generate docs", e))
|
||||
.unwrap_or_exit();
|
||||
|
||||
println!("Documentation generated successfully.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue