mirror of
https://codeberg.org/JasterV/granc.git
synced 2026-04-26 18:40:05 +00:00
refactor: rename module to docgen & implement tests for package
This commit is contained in:
parent
e09713590f
commit
124bd94467
10 changed files with 541 additions and 285 deletions
24
Cargo.lock
generated
24
Cargo.lock
generated
|
|
@ -340,8 +340,10 @@ version = "0.7.0"
|
|||
dependencies = [
|
||||
"clap",
|
||||
"colored",
|
||||
"granc_core 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"granc_core",
|
||||
"prost-build",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
|
|
@ -364,26 +366,6 @@ dependencies = [
|
|||
"tonic-reflection",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "granc_core"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e87c4196f9cdf96a69381385c7029aab768d4d7bc6b1ff90654bf6c3eb19d2"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"prost",
|
||||
"prost-reflect",
|
||||
"prost-types",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tonic",
|
||||
"tonic-reflection",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ publish = false
|
|||
|
||||
[dependencies]
|
||||
bytes = "1"
|
||||
prost = "0.14"
|
||||
tonic = "0.14"
|
||||
prost-types = "0.14"
|
||||
tonic-prost = "0.14.3"
|
||||
prost = { workspace = true }
|
||||
tonic = { workspace = true }
|
||||
prost-types = { workspace = true }
|
||||
tonic-prost = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
tonic-prost-build = "0.14"
|
||||
tonic-prost-build = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ 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]
|
||||
prost-build = { workspace = true }
|
||||
tempfile = "3"
|
||||
|
|
|
|||
2
granc/src/docgen.rs
Normal file
2
granc/src/docgen.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod markdown;
|
||||
mod package;
|
||||
200
granc/src/docgen/markdown.rs
Normal file
200
granc/src/docgen/markdown.rs
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
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(&format!("# Documentation: `{}`\n\n", entry_service.name()));
|
||||
|
||||
let svc_package = entry_service.package_name();
|
||||
let svc_link = format!("{}.md#{}", svc_package, entry_service.name());
|
||||
|
||||
out.push_str("## Entry Point\n\n");
|
||||
out.push_str(&format!(
|
||||
"- [**Service: {}**]({})\n",
|
||||
entry_service.name(),
|
||||
svc_link
|
||||
));
|
||||
|
||||
out.push_str("\n## Namespaces\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();
|
||||
|
||||
out.push_str(&format!("# Namespace: `{}`\n\n", package.name));
|
||||
|
||||
// 1. Services (Always on top)
|
||||
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");
|
||||
}
|
||||
|
||||
// 2. Messages
|
||||
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");
|
||||
}
|
||||
|
||||
// 3. Enums
|
||||
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=\"{}\"></a>\n", name));
|
||||
}
|
||||
|
||||
fn write_service_content(out: &mut String, service: &ServiceDescriptor) {
|
||||
out.push_str("**Type**: `Service`\n\n");
|
||||
out.push_str(&format!("**Full Name**: `{}`\n\n", service.full_name()));
|
||||
|
||||
out.push_str("### Definition\n\n```protobuf\n");
|
||||
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("**Type**: `Message`\n\n");
|
||||
out.push_str(&format!("**Full Name**: `{}`\n\n", message.full_name()));
|
||||
|
||||
out.push_str("### Definition\n\n```protobuf\n");
|
||||
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("**Type**: `Enum`\n\n");
|
||||
out.push_str(&format!("**Full Name**: `{}`\n\n", enum_desc.full_name()));
|
||||
|
||||
out.push_str("### Definition\n\n```protobuf\n");
|
||||
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)
|
||||
}
|
||||
292
granc/src/docgen/package.rs
Normal file
292
granc/src/docgen/package.rs
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
use granc_core::{
|
||||
client::Descriptor,
|
||||
prost_reflect::{EnumDescriptor, Kind, MessageDescriptor, ServiceDescriptor},
|
||||
};
|
||||
use std::collections::{HashMap, hash_map::Keys};
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// Collect all reachable descriptors (Messages, Enums) from the Service methods
|
||||
let mut descriptors: HashMap<String, Descriptor> = value
|
||||
.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)
|
||||
});
|
||||
|
||||
// Insert the Service itself
|
||||
descriptors.insert(
|
||||
value.full_name().to_string(),
|
||||
Descriptor::ServiceDescriptor(value),
|
||||
);
|
||||
|
||||
// Group into Packages
|
||||
let packages: HashMap<_, Package> =
|
||||
descriptors
|
||||
.into_values()
|
||||
.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
|
||||
});
|
||||
|
||||
Packages(packages)
|
||||
}
|
||||
}
|
||||
|
||||
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 std::fs;
|
||||
|
||||
/// Helper to compile proto strings into a DescriptorPool at runtime.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `files` - A list of tuples (filename, content). E.g. `[("test.proto", "syntax=...")]`
|
||||
fn compile_protos(files: &[(&str, &str)]) -> DescriptorPool {
|
||||
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
|
||||
let proto_dir = temp_dir.path().join("protos");
|
||||
fs::create_dir(&proto_dir).expect("Failed to create protos dir");
|
||||
|
||||
let mut proto_paths = Vec::new();
|
||||
for (name, content) in files {
|
||||
let path = proto_dir.join(name);
|
||||
fs::write(&path, content).expect("Failed to write proto file");
|
||||
proto_paths.push(path);
|
||||
}
|
||||
|
||||
let descriptor_path = temp_dir.path().join("descriptor.bin");
|
||||
|
||||
// Compile using prost_build
|
||||
let mut config = prost_build::Config::new();
|
||||
config.file_descriptor_set_path(&descriptor_path);
|
||||
// We set out_dir to temp_dir because we don't care about the generated Rust code,
|
||||
// we only want the descriptor set.
|
||||
config.out_dir(temp_dir.path());
|
||||
|
||||
config
|
||||
.compile_protos(&proto_paths, &[proto_dir])
|
||||
.expect("Failed to compile protos");
|
||||
|
||||
let bytes = fs::read(descriptor_path).expect("Failed to read descriptor set");
|
||||
DescriptorPool::decode(bytes.as_slice()).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");
|
||||
|
||||
// --- Act ---
|
||||
let packages = Packages::from(service);
|
||||
|
||||
// --- Assert ---
|
||||
let test_package = packages.0.get("test").expect("Package 'test' missing");
|
||||
|
||||
// Verify Services
|
||||
assert_eq!(test_package.services.len(), 1);
|
||||
assert_eq!(test_package.services[0].name(), "MyService");
|
||||
|
||||
// Verify Messages
|
||||
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"));
|
||||
|
||||
// Verify Enums (Deduplication Check)
|
||||
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");
|
||||
|
||||
// --- Act ---
|
||||
let packages = Packages::from(service);
|
||||
|
||||
// --- Assert ---
|
||||
let pkg = packages.0.get("cycle").expect("Package 'cycle' missing");
|
||||
|
||||
assert_eq!(pkg.messages.len(), 2);
|
||||
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);
|
||||
|
||||
// Assert Package 'app'
|
||||
let app_pkg = packages.0.get("app").expect("Package 'app' missing");
|
||||
assert_eq!(app_pkg.services.len(), 1);
|
||||
|
||||
// Assert Package 'common' (Ensures traversal follows imports)
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
// granc/src/docs.rs
|
||||
use crate::formatter::FormattedString;
|
||||
use granc_core::client::Descriptor;
|
||||
use granc_core::prost_reflect::{EnumDescriptor, Kind, MessageDescriptor, ServiceDescriptor};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Package {
|
||||
services: Vec<ServiceDescriptor>,
|
||||
messages: Vec<MessageDescriptor>,
|
||||
enums: Vec<EnumDescriptor>,
|
||||
}
|
||||
|
||||
pub struct DocsGenerator {
|
||||
output_dir: PathBuf,
|
||||
visited: HashSet<String>,
|
||||
}
|
||||
|
||||
impl DocsGenerator {
|
||||
pub fn new(output_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
output_dir,
|
||||
visited: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Entry point for documentation generation.
|
||||
pub fn generate(&mut self, service: &ServiceDescriptor) -> std::io::Result<()> {
|
||||
// Force colored output OFF so we get plain text for the markdown files
|
||||
colored::control::set_override(false);
|
||||
|
||||
if !self.output_dir.exists() {
|
||||
fs::create_dir_all(&self.output_dir)?;
|
||||
}
|
||||
|
||||
let _packages = collect_descriptors(service.clone());
|
||||
|
||||
// 1. Generate the Service page and recursively all dependencies
|
||||
self.generate_service(service)?;
|
||||
|
||||
// 2. Generate the Index (Table of Contents)
|
||||
self.generate_index(service)?;
|
||||
|
||||
// Restore colored output for the CLI
|
||||
colored::control::unset_override();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_index(&self, service: &ServiceDescriptor) -> std::io::Result<()> {
|
||||
let path = self.output_dir.join("index.md");
|
||||
let mut out = String::new();
|
||||
|
||||
out.push_str(&format!("# Documentation: `{}`\n\n", service.name()));
|
||||
|
||||
out.push_str("## Entry Point\n\n");
|
||||
out.push_str(&format!(
|
||||
"- [**Service Definition: {}**]({}.md)\n",
|
||||
service.name(),
|
||||
service.full_name()
|
||||
));
|
||||
|
||||
out.push_str("\n## Messages & Enums\n\n");
|
||||
let mut types: Vec<_> = self.visited.iter().collect();
|
||||
types.sort();
|
||||
|
||||
if types.is_empty() {
|
||||
out.push_str("*None*\n");
|
||||
} else {
|
||||
for name in types {
|
||||
out.push_str(&format!("- [{}]({}.md)\n", name, name));
|
||||
}
|
||||
}
|
||||
|
||||
fs::write(path, out)?;
|
||||
println!("Generated: index.md");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_service(&mut self, service: &ServiceDescriptor) -> std::io::Result<()> {
|
||||
let filename = format!("{}.md", service.full_name());
|
||||
let path = self.output_dir.join(&filename);
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!("# Service: `{}`\n\n", service.name()));
|
||||
|
||||
// 1. Protobuf Definition
|
||||
out.push_str("## Definition\n\n```protobuf\n");
|
||||
out.push_str(&FormattedString::from(service.clone()).0);
|
||||
out.push_str("\n```\n\n");
|
||||
|
||||
// 2. Methods List
|
||||
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();
|
||||
|
||||
out.push_str(&format!("- Request: [{0}]({0}.md)\n", input.full_name()));
|
||||
out.push_str(&format!("- Response: [{0}]({0}.md)\n", output.full_name()));
|
||||
out.push('\n');
|
||||
|
||||
// Queue recursion for dependencies
|
||||
self.queue_message(input);
|
||||
self.queue_message(output);
|
||||
}
|
||||
|
||||
fs::write(path, out)?;
|
||||
println!("Generated: {}", filename);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_message(&mut self, message: MessageDescriptor) -> std::io::Result<()> {
|
||||
let filename = format!("{}.md", message.full_name());
|
||||
let path = self.output_dir.join(&filename);
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!("# Message: `{}`\n\n", message.name()));
|
||||
|
||||
// Definition
|
||||
out.push_str("## Definition\n\n```protobuf\n");
|
||||
out.push_str(&FormattedString::from(message.clone()).0);
|
||||
out.push_str("\n```\n\n");
|
||||
|
||||
// Dependencies
|
||||
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;
|
||||
out.push_str(&format!(
|
||||
"- Field `{0}`: [{1}]({1}.md)\n",
|
||||
field.name(),
|
||||
m.full_name()
|
||||
));
|
||||
self.queue_message(m);
|
||||
}
|
||||
Kind::Enum(e) => {
|
||||
has_deps = true;
|
||||
out.push_str(&format!(
|
||||
"- Field `{}`: [{}]({}.md)\n",
|
||||
field.name(),
|
||||
e.full_name(),
|
||||
e.full_name()
|
||||
));
|
||||
self.queue_enum(e);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !has_deps {
|
||||
out.push_str("*None*\n");
|
||||
}
|
||||
|
||||
fs::write(path, out)?;
|
||||
println!("Generated: {}", filename);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_enum(&mut self, enum_desc: EnumDescriptor) -> std::io::Result<()> {
|
||||
let filename = format!("{}.md", enum_desc.full_name());
|
||||
let path = self.output_dir.join(&filename);
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!("# Enum: `{}`\n\n", enum_desc.name()));
|
||||
|
||||
out.push_str("## Definition\n\n```protobuf\n");
|
||||
out.push_str(&FormattedString::from(enum_desc).0);
|
||||
out.push_str("\n```\n");
|
||||
|
||||
fs::write(path, out)?;
|
||||
println!("Generated: {}", filename);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn queue_message(&mut self, message: MessageDescriptor) {
|
||||
let name = message.full_name().to_string();
|
||||
if !self.visited.insert(name) {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = self.generate_message(message) {
|
||||
eprintln!("Failed to generate docs for message: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
fn queue_enum(&mut self, enum_desc: EnumDescriptor) {
|
||||
let name = enum_desc.full_name().to_string();
|
||||
if !self.visited.insert(name) {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = self.generate_enum(enum_desc) {
|
||||
eprintln!("Failed to generate docs for enum: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_descriptors(entrypoint: ServiceDescriptor) -> HashMap<String, Descriptor> {
|
||||
let descriptors: HashMap<_, _> = [].into();
|
||||
|
||||
let mut descriptors = collect_service_dependencies(descriptors, &entrypoint);
|
||||
|
||||
descriptors.insert(
|
||||
entrypoint.full_name().to_string(),
|
||||
Descriptor::ServiceDescriptor(entrypoint),
|
||||
);
|
||||
|
||||
descriptors
|
||||
}
|
||||
|
||||
fn collect_service_dependencies(
|
||||
descriptors: HashMap<String, Descriptor>,
|
||||
service: &ServiceDescriptor,
|
||||
) -> HashMap<String, Descriptor> {
|
||||
service
|
||||
.methods()
|
||||
.flat_map(|m| [m.input(), m.output()])
|
||||
.fold(descriptors, |acc, d| {
|
||||
let mut descriptors = collect_message_dependencies(acc, &d);
|
||||
descriptors.insert(d.full_name().to_string(), Descriptor::MessageDescriptor(d));
|
||||
descriptors
|
||||
})
|
||||
}
|
||||
|
||||
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 mut descriptors = collect_message_dependencies(acc, &m);
|
||||
descriptors.insert(m.full_name().to_string(), Descriptor::MessageDescriptor(m));
|
||||
descriptors
|
||||
}
|
||||
Kind::Enum(e) => {
|
||||
acc.insert(field.full_name().to_string(), Descriptor::EnumDescriptor(e));
|
||||
acc
|
||||
}
|
||||
_ => acc,
|
||||
})
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
//! 3. **Execution**: Delegates request processing to `GrancClient`.
|
||||
//! 4. **Presentation**: Formats and prints data.
|
||||
mod cli;
|
||||
mod docs;
|
||||
mod docgen;
|
||||
mod formatter;
|
||||
|
||||
use clap::Parser;
|
||||
|
|
@ -17,8 +17,6 @@ use formatter::{FormattedString, GenericError};
|
|||
use granc_core::client::{Descriptor, DynamicRequest, DynamicResponse, GrancClient};
|
||||
use std::process;
|
||||
|
||||
use crate::docs::DocsGenerator;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let args = Cli::parse();
|
||||
|
|
@ -55,15 +53,14 @@ async fn main() {
|
|||
output,
|
||||
} => {
|
||||
let descriptor = describe(symbol.clone(), source.value()).await;
|
||||
|
||||
let service_descriptor = descriptor
|
||||
.service_descriptor()
|
||||
.cloned()
|
||||
.ok_or_else(|| GenericError("The symbol must be a Service", symbol))
|
||||
.unwrap_or_exit();
|
||||
|
||||
let mut generator = DocsGenerator::new(output);
|
||||
|
||||
generator
|
||||
.generate(service_descriptor)
|
||||
docgen::markdown::generate(output, service_descriptor)
|
||||
.map_err(|e| GenericError("Failed to generate docs", e))
|
||||
.unwrap_or_exit();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue