feat: Improve admin command reference generation

- Change xtasks to use `clap` for argument parsing
- Generate admin command reference manually instead of with `clap_markdown`
- Split admin command reference into multiple files
This commit is contained in:
Ginger 2026-01-09 17:05:34 -05:00
parent 60dd6baffd
commit 89be9d1efc
No known key found for this signature in database
31 changed files with 1297 additions and 5822 deletions

View file

@ -10,10 +10,12 @@ repository.workspace = true
version.workspace = true
[dependencies]
conduwuit-admin.workspace = true
conduwuit.workspace = true
clap.workspace = true
# Required for working with JSON output from cargo metadata
serde.workspace = true
serde_json = "1.0"
askama = "0.15.1"
cargo_metadata = "0.23.1"
[lints]
workspace = true

View file

@ -1,23 +0,0 @@
[package]
name = "xtask-generate-commands"
authors.workspace = true
description.workspace = true
edition.workspace = true
homepage.workspace = true
license.workspace = true
readme.workspace = true
repository.workspace = true
version.workspace = true
[dependencies]
clap-markdown = "0.1.5"
clap_builder = { version = "4.5.38", default-features = false }
clap_mangen = "0.2"
conduwuit-admin.workspace = true
# Hack to prevent rebuilds
conduwuit.workspace = true
[lints]
workspace = true

View file

@ -1,113 +0,0 @@
use std::{
fs::{self, File},
io::{self, Write},
path::Path,
};
use clap_builder::{Command, CommandFactory};
use conduwuit_admin::AdminCommand;
enum CommandType {
Admin,
Server,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = std::env::args().skip(1);
let command_type = args.next();
let task = args.next();
match (command_type, task) {
| (None, _) => {
return Err("Missing command type (admin or server)".into());
},
| (Some(cmd_type), None) => {
return Err(format!("Missing task for {cmd_type} command").into());
},
| (Some(cmd_type), Some(task)) => {
let command_type = match cmd_type.as_str() {
| "admin" => CommandType::Admin,
| "server" => CommandType::Server,
| _ => return Err(format!("Invalid command type: {cmd_type}").into()),
};
match task.as_str() {
| "man" => match command_type {
| CommandType::Admin => {
let dir = Path::new("./admin-man");
gen_admin_manpages(dir)?;
},
| CommandType::Server => {
let dir = Path::new("./server-man");
gen_server_manpages(dir)?;
},
},
| "md" => {
match command_type {
| CommandType::Admin => {
let command = AdminCommand::command().name("admin");
let res = clap_markdown::help_markdown_command_custom(
&command,
&clap_markdown::MarkdownOptions::default().show_footer(false),
)
.replace("\n\r", "\n")
.replace("\r\n", "\n")
.replace(" \n", "\n");
let mut file = File::create(Path::new("./docs/admin_reference.md"))?;
file.write_all(res.trim_end().as_bytes())?;
file.write_all(b"\n")?;
},
| CommandType::Server => {
// Get the server command from the conduwuit crate
let command = conduwuit::Args::command();
let res = clap_markdown::help_markdown_command_custom(
&command,
&clap_markdown::MarkdownOptions::default().show_footer(false),
)
.replace("\n\r", "\n")
.replace("\r\n", "\n")
.replace(" \n", "\n");
let mut file = File::create(Path::new("./docs/server_reference.md"))?;
file.write_all(res.trim_end().as_bytes())?;
file.write_all(b"\n")?;
},
}
},
| invalid => return Err(format!("Invalid task name: {invalid}").into()),
}
},
}
Ok(())
}
fn gen_manpage_common(dir: &Path, c: &Command, prefix: Option<&str>) -> Result<(), io::Error> {
fs::create_dir_all(dir)?;
let sub_name = c.get_display_name().unwrap_or_else(|| c.get_name());
let name = if let Some(prefix) = prefix {
format!("{prefix}-{sub_name}")
} else {
sub_name.to_owned()
};
let mut out = File::create(dir.join(format!("{name}.1")))?;
let clap_mangen = clap_mangen::Man::new(c.to_owned().disable_help_flag(true));
clap_mangen.render(&mut out)?;
for sub in c.get_subcommands() {
gen_manpage_common(&dir.join(sub_name), sub, Some(&name))?;
}
Ok(())
}
fn gen_admin_manpages(dir: &Path) -> Result<(), io::Error> {
gen_manpage_common(dir, &AdminCommand::command().name("admin"), None)
}
fn gen_server_manpages(dir: &Path) -> Result<(), io::Error> {
gen_manpage_common(dir, &conduwuit::Args::command(), None)
}

View file

@ -1,11 +0,0 @@
use std::{env, process::Command};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut child = Command::new("cargo").args(["run", "--package", "xtask-generate-commands", "--"].into_iter().map(ToOwned::to_owned).chain(env::args().skip(2)))
// .stdout(Stdio::piped())
// .stderr(Stdio::piped())
.spawn()
.expect("failed to execute child");
child.wait()?;
Ok(())
}

26
xtask/src/main.rs Normal file
View file

@ -0,0 +1,26 @@
mod tasks;
use clap::Parser;
use crate::tasks::Task;
#[derive(clap::Parser)]
struct BaseArgs {
#[command(subcommand)]
task: Task,
#[command(flatten)]
args: Args,
}
#[derive(clap::Args)]
struct Args {
/// Simulate without actually touching the filesystem
#[arg(long)]
dry_run: bool,
}
fn main() -> impl std::process::Termination {
let BaseArgs { task, args } = BaseArgs::parse();
task.invoke(args)
}

View file

@ -0,0 +1,112 @@
//! Generates documentation for the various commands that may be used in the admin room and server console.
//!
//! This generates one index page and several category pages, one for each of the direct subcommands of the top-level
//! `!admin` command. Those category pages then list all of the sub-subcommands.
use std::path::Path;
use askama::Template;
use clap::{Command, CommandFactory};
use conduwuit_admin::AdminCommand;
use crate::tasks::{TaskResult, generate_docs::FileOutput};
#[derive(askama::Template)]
#[template(path = "admin/index.md")]
/// The template for the index page, which links to all of the category pages.
struct Index {
categories: Vec<Category>
}
/// A direct subcommand of the top-level `!admin` command.
#[derive(askama::Template)]
#[template(path = "admin/category.md")]
struct Category {
name: String,
description: String,
commands: Vec<Subcommand>,
}
/// A second-or-deeper level subcommand of the `!admin` command.
struct Subcommand {
name: String,
description: String,
/// How deeply nested this command was in the original command tree.
/// This determines the header size used for it in the documentation.
depth: usize,
}
fn flatten_subcommands(command: &Command) -> Vec<Subcommand> {
let mut subcommands = Vec::new();
let mut name_stack = Vec::new();
fn flatten(
subcommands: &mut Vec<Subcommand>,
stack: &mut Vec<String>,
command: &Command
) {
let depth = stack.len();
stack.push(command.get_name().to_owned());
// do not include the root command
if depth > 0 {
let name = stack.join(" ");
let description = command
.get_long_about()
.or_else(|| command.get_about())
.map(|about| about.to_string())
.unwrap_or("_(no description)_".to_owned());
subcommands.push(
Subcommand {
name,
description,
depth,
}
);
}
for command in command.get_subcommands() {
flatten(subcommands, stack, command);
}
stack.pop();
}
flatten(&mut subcommands, &mut name_stack, command);
subcommands
}
pub(super) fn generate(out: &mut impl FileOutput) -> TaskResult<()> {
let admin_commands = AdminCommand::command();
let categories: Vec<_> = admin_commands
.get_subcommands()
.map(|command| {
Category {
name: command.get_name().to_owned(),
description: command.get_about().expect("categories should have a docstring").to_string(),
commands: flatten_subcommands(command),
}
})
.collect();
let root = Path::new("reference/admin/");
for category in &categories {
out.create_file(
root.join(&category.name).with_extension("md"),
category.render()?
);
}
out.create_file(
root.join("index.md"),
Index { categories }.render()?,
);
Ok(())
}

View file

@ -0,0 +1,67 @@
mod admin_commands;
use std::{collections::HashMap, path::{Path, PathBuf}};
use cargo_metadata::MetadataCommand;
use crate::tasks::TaskResult;
trait FileOutput {
fn create_file(&mut self, path: PathBuf, contents: String);
}
#[derive(Default)]
struct FileQueue {
queue: HashMap<PathBuf, String>,
}
impl FileQueue {
fn write(self, root: &Path, dry_run: bool) -> std::io::Result<()> {
for (path, contents) in self.queue.into_iter() {
let path = root.join(&path);
eprintln!("Writing {}", path.display());
if !dry_run {
std::fs::write(path, contents)?;
}
}
Ok(())
}
}
impl FileOutput for FileQueue {
fn create_file(&mut self, path: PathBuf, contents: String) {
assert!(path.is_relative(), "path must be relative");
assert!(path.extension().is_some(), "path must not point to a directory");
if self.queue.contains_key(&path) {
panic!("attempted to create an already created file {}", path.display());
}
self.queue.insert(path, contents);
}
}
#[derive(clap::Args)]
pub(crate) struct Args {
/// The base path of the documentation. Defaults to `docs/` in the crate root.
root: Option<PathBuf>,
}
pub(super) fn run(common_args: crate::Args, task_args: Args) -> TaskResult<()> {
let mut queue = FileQueue::default();
let metadata = MetadataCommand::new()
.no_deps()
.exec()
.expect("should have been able to run cargo");
let root = task_args.root.unwrap_or_else(|| metadata.workspace_root.join_os("docs/"));
admin_commands::generate(&mut queue)?;
queue.write(&root, common_args.dry_run)?;
Ok(())
}

37
xtask/src/tasks/mod.rs Normal file
View file

@ -0,0 +1,37 @@
type TaskResult<T> = Result<T, Box<dyn std::error::Error>>;
#[macro_export]
macro_rules! tasks {
(
$(
$module:ident: $desc:literal
),*
) => {
$(pub(super) mod $module;)*
#[derive(clap::Subcommand)]
#[allow(non_camel_case_types)]
pub(super) enum Task {
$(
#[clap(about = $desc, long_about = None)]
$module($module::Args),
)*
}
impl Task {
pub(super) fn invoke(self, common_args: $crate::Args) -> TaskResult<impl std::process::Termination> {
match self {
$(
Self::$module(task_args) => {
$module::run(common_args, task_args)
},
)*
}
}
}
};
}
tasks! {
generate_docs: "Generate various documentation files. This is run automatically when compiling the website."
}

View file

@ -0,0 +1,10 @@
# `!admin {{ name }}`
{{ description }}
{% for command in commands %}
{% let header = "#".repeat((command.depth + 1).min(3)) -%}
{{ header }} `!admin {{ command.name }}`
{{ command.description }}
{% endfor %}

View file

@ -0,0 +1,7 @@
# Admin Commands
These are all the admin commands. TODO fill me out
{%~ for category in categories %}
- [`!admin {{ category.name }}`]({{ category.name }}/) {{ category.description }}
{%- endfor %}