[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:
Víctor Martínez 2026-02-06 13:16:19 +01:00 committed by GitHub
parent bc5a13cc79
commit c9ef611e07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1043 additions and 50 deletions

View file

@ -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
View file

@ -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",

View file

@ -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"

View file

@ -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.

View file

@ -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
View 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)

View 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
View 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)
---

View 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)
---

View 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;
}

View 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;
}

View 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);
}

View file

@ -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"] }

View file

@ -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 {

View file

@ -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;

View file

@ -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() {

View file

@ -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;

View file

@ -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;

View file

@ -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;

View 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 }

View 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")
}

View file

@ -0,0 +1,2 @@
pub mod compiler;
pub mod echo_service;

View file

@ -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" }

View file

@ -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
View file

@ -0,0 +1,2 @@
pub mod markdown;
mod package;

View 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
View 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);
}
}

View file

@ -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.");
}
}
}