diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 45b791a..9b29cd0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,6 +5,14 @@ updates: schedule: interval: "daily" time: "09:00" + groups: + grpc: + patterns: + - "tonic" + - "tonic-*" + - "prost" + - "prost-*" + - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/Cargo.lock b/Cargo.lock index b6fe252..5d07966 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 17944f4..8752008 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index b95491c..f58cd53 100644 --- a/README.md +++ b/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 --output [OPTIONS] +``` + +| Argument/Flag | Short | Description | +| --- | --- | --- | +| `` | | 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. diff --git a/echo-service/Cargo.toml b/echo-service/Cargo.toml deleted file mode 100644 index d4e1d4f..0000000 --- a/echo-service/Cargo.toml +++ /dev/null @@ -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" diff --git a/examples/docs/index.md b/examples/docs/index.md new file mode 100644 index 0000000..9c540c3 --- /dev/null +++ b/examples/docs/index.md @@ -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) diff --git a/examples/docs/library.domain.md b/examples/docs/library.domain.md new file mode 100644 index 0000000..e102870 --- /dev/null +++ b/examples/docs/library.domain.md @@ -0,0 +1,86 @@ + +## 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) + +--- + + +## 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) + +--- + + +## Publisher + +### Definition + +```protobuf +package library.domain; + +message Publisher { + string id = 1; + string name = 2; + string address = 3; +} +``` + +### Dependencies + +*None* + +--- + + +## Genre + +### Definition + +```protobuf +package library.domain; + +enum Genre { + UNKNOWN = 0; + FICTION = 1; + NON_FICTION = 2; + SCI_FI = 3; + HISTORY = 4; +} +``` + +--- + diff --git a/examples/docs/library.md b/examples/docs/library.md new file mode 100644 index 0000000..d19e6ec --- /dev/null +++ b/examples/docs/library.md @@ -0,0 +1,44 @@ + +## 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) + +--- + diff --git a/examples/docs/library.rpc.md b/examples/docs/library.rpc.md new file mode 100644 index 0000000..06388d1 --- /dev/null +++ b/examples/docs/library.rpc.md @@ -0,0 +1,100 @@ + +## ChatMessage + +### Definition + +```protobuf +package library.rpc; + +message ChatMessage { + string user_id = 1; + string text = 2; + int64 timestamp = 3; +} +``` + +### Dependencies + +*None* + +--- + + +## CheckoutRequest + +### Definition + +```protobuf +package library.rpc; + +message CheckoutRequest { + string isbn = 1; +} +``` + +### Dependencies + +*None* + +--- + + +## 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) + +--- + + +## GetBookRequest + +### Definition + +```protobuf +package library.rpc; + +message GetBookRequest { + string isbn = 1; +} +``` + +### Dependencies + +*None* + +--- + + +## 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) + +--- + diff --git a/examples/proto/library/domain.proto b/examples/proto/library/domain.proto new file mode 100644 index 0000000..a1a1d9a --- /dev/null +++ b/examples/proto/library/domain.proto @@ -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; +} diff --git a/examples/proto/library/rpc.proto b/examples/proto/library/rpc.proto new file mode 100644 index 0000000..03c10ad --- /dev/null +++ b/examples/proto/library/rpc.proto @@ -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; +} diff --git a/examples/proto/library/service.proto b/examples/proto/library/service.proto new file mode 100644 index 0000000..1ed9430 --- /dev/null +++ b/examples/proto/library/service.proto @@ -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); +} diff --git a/granc-core/Cargo.toml b/granc-core/Cargo.toml index 0e8a011..1e1e111 100644 --- a/granc-core/Cargo.toml +++ b/granc-core/Cargo.toml @@ -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"] } diff --git a/granc-core/src/client/types.rs b/granc-core/src/client/types.rs index 74381c7..eae387a 100644 --- a/granc-core/src/client/types.rs +++ b/granc-core/src/client/types.rs @@ -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 { diff --git a/granc-core/tests/echo_service_impl.rs b/granc-core/tests/echo_service_impl.rs index b064bf7..ca897e8 100644 --- a/granc-core/tests/echo_service_impl.rs +++ b/granc-core/tests/echo_service_impl.rs @@ -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; diff --git a/granc-core/tests/granc_client_offline_test.rs b/granc-core/tests/granc_client_offline_test.rs index 8ce71d6..2dd9759 100644 --- a/granc-core/tests/granc_client_offline_test.rs +++ b/granc-core/tests/granc_client_offline_test.rs @@ -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() { diff --git a/granc-core/tests/granc_client_online_test.rs b/granc-core/tests/granc_client_online_test.rs index 4c74117..912d4ed 100644 --- a/granc-core/tests/granc_client_online_test.rs +++ b/granc-core/tests/granc_client_online_test.rs @@ -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; diff --git a/granc-core/tests/granc_client_online_without_reflection_test.rs b/granc-core/tests/granc_client_online_without_reflection_test.rs index f4385de..07ba4fc 100644 --- a/granc-core/tests/granc_client_online_without_reflection_test.rs +++ b/granc-core/tests/granc_client_online_without_reflection_test.rs @@ -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; diff --git a/granc-core/tests/reflection_client_test.rs b/granc-core/tests/reflection_client_test.rs index 9d3da4a..30d6867 100644 --- a/granc-core/tests/reflection_client_test.rs +++ b/granc-core/tests/reflection_client_test.rs @@ -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; diff --git a/granc-test-support/Cargo.toml b/granc-test-support/Cargo.toml new file mode 100644 index 0000000..c15b15e --- /dev/null +++ b/granc-test-support/Cargo.toml @@ -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 } diff --git a/echo-service/build.rs b/granc-test-support/build.rs similarity index 100% rename from echo-service/build.rs rename to granc-test-support/build.rs diff --git a/echo-service/proto/echo.proto b/granc-test-support/proto/echo.proto similarity index 100% rename from echo-service/proto/echo.proto rename to granc-test-support/proto/echo.proto diff --git a/granc-test-support/src/compiler.rs b/granc-test-support/src/compiler.rs new file mode 100644 index 0000000..9dc239f --- /dev/null +++ b/granc-test-support/src/compiler.rs @@ -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") +} diff --git a/echo-service/src/lib.rs b/granc-test-support/src/echo_service.rs similarity index 100% rename from echo-service/src/lib.rs rename to granc-test-support/src/echo_service.rs diff --git a/granc-test-support/src/lib.rs b/granc-test-support/src/lib.rs new file mode 100644 index 0000000..5e64a90 --- /dev/null +++ b/granc-test-support/src/lib.rs @@ -0,0 +1,2 @@ +pub mod compiler; +pub mod echo_service; diff --git a/granc/Cargo.toml b/granc/Cargo.toml index 8415620..6a532c2 100644 --- a/granc/Cargo.toml +++ b/granc/Cargo.toml @@ -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" } diff --git a/granc/src/cli.rs b/granc/src/cli.rs index 29498f2..c46c97a 100644 --- a/granc/src/cli.rs +++ b/granc/src/cli.rs @@ -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] diff --git a/granc/src/docgen.rs b/granc/src/docgen.rs new file mode 100644 index 0000000..32240b8 --- /dev/null +++ b/granc/src/docgen.rs @@ -0,0 +1,2 @@ +pub mod markdown; +mod package; diff --git a/granc/src/docgen/markdown.rs b/granc/src/docgen/markdown.rs new file mode 100644 index 0000000..b5e750a --- /dev/null +++ b/granc/src/docgen/markdown.rs @@ -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 { + 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 { + 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!("\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) +} diff --git a/granc/src/docgen/package.rs b/granc/src/docgen/package.rs new file mode 100644 index 0000000..f39ddc8 --- /dev/null +++ b/granc/src/docgen/package.rs @@ -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, + pub messages: Vec, + pub enums: Vec, +} + +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 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); + +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 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, +) -> HashMap { + 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 { + 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, + message: &MessageDescriptor, +) -> HashMap { + 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); + } +} diff --git a/granc/src/main.rs b/granc/src/main.rs index bf2c12b..53f1ff4 100644 --- a/granc/src/main.rs +++ b/granc/src/main.rs @@ -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."); + } } }