use crate::config::{SettingMetadata, SettingType, SettingsSchema};
use std::collections::HashMap;
#[derive(Debug, Clone, Default)]
pub struct DocsConfig {
pub title: Option<String>,
pub description: Option<String>,
pub show_advanced: bool,
pub group_by_category: bool,
}
impl DocsConfig {
#[must_use]
pub fn new() -> Self {
Self {
show_advanced: true,
group_by_category: true,
..Default::default()
}
}
#[must_use]
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
#[must_use]
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
#[must_use]
pub fn hide_advanced(mut self) -> Self {
self.show_advanced = false;
self
}
}
#[must_use]
pub fn generate_docs<T: SettingsSchema>(config: DocsConfig) -> String {
let metadata = T::get_metadata();
generate_docs_from_metadata(&metadata, config)
}
#[must_use]
pub fn generate_docs_from_metadata<S: std::hash::BuildHasher>(
metadata: &HashMap<String, SettingMetadata, S>,
config: DocsConfig,
) -> String {
use std::fmt::Write;
let mut output = String::new();
let title = config
.title
.unwrap_or_else(|| "Settings Reference".to_string());
let _ = writeln!(output, "# {title}\n");
if let Some(desc) = config.description {
let _ = writeln!(output, "{desc}\n");
}
let mut settings: Vec<_> = metadata
.iter()
.filter(|(_, m)| config.show_advanced || !m.get_meta_bool("advanced").unwrap_or(false))
.collect();
settings.sort_by(|(k1, m1), (k2, m2)| {
let cat1 = m1.get_meta_str("category").unwrap_or("General");
let cat2 = m2.get_meta_str("category").unwrap_or("General");
match cat1.cmp(cat2) {
std::cmp::Ordering::Equal => {
let ord1 = m1.get_meta_num("order").unwrap_or(999.0);
let ord2 = m2.get_meta_num("order").unwrap_or(999.0);
match ord1.partial_cmp(&ord2).unwrap_or(std::cmp::Ordering::Equal) {
std::cmp::Ordering::Equal => k1.cmp(k2),
other => other,
}
}
other => other,
}
});
if config.group_by_category {
let mut current_category: Option<&str> = None;
for (key, meta) in &settings {
let category = meta.get_meta_str("category").unwrap_or("General");
if current_category != Some(category) {
let _ = writeln!(output, "\n## {}\n", capitalize(category));
current_category = Some(category);
}
format_setting(&mut output, key, meta);
}
} else {
output.push_str("## Settings\n\n");
for (key, meta) in &settings {
format_setting(&mut output, key, meta);
}
}
output
}
fn format_setting(out: &mut String, key: &str, meta: &SettingMetadata) {
use std::fmt::Write;
let _ = writeln!(out, "### `{key}`\n");
let mut badges = Vec::new();
if meta.get_meta_bool("advanced").unwrap_or(false) {
badges.push("Advanced");
}
if meta.get_meta_bool("requires_restart").unwrap_or(false) {
badges.push("Requires Restart");
}
if meta.get_meta_bool("secret").unwrap_or(false) {
badges.push("Secret");
}
if meta.get_meta_bool("disabled").unwrap_or(false) {
badges.push("Disabled");
}
if !badges.is_empty() {
let _ = writeln!(out, "{}\n", badges.join(" • "));
}
if let Some(desc) = meta.get_meta_str("description") {
let _ = writeln!(out, "{desc}\n");
}
out.push_str("| Property | Value |\n");
out.push_str("|----------|-------|\n");
let _ = writeln!(out, "| **Type** | {} |", format_type(&meta.setting_type));
let _ = writeln!(out, "| **Default** | `{}` |", format_value(&meta.default));
if meta.setting_type == SettingType::Number {
if let (Some(min), Some(max)) = (meta.constraints.number.min, meta.constraints.number.max) {
let _ = writeln!(out, "| **Range** | {min} - {max} |");
}
if let Some(step) = meta.constraints.number.step {
let _ = writeln!(out, "| **Step** | {step} |");
}
}
if let Some(ref pattern) = meta.constraints.text.pattern {
let _ = writeln!(out, "| **Pattern** | `{pattern}` |");
}
out.push('\n');
if let Some(ref options) = meta.constraints.options {
out.push_str("**Options:**\n\n");
for opt in options {
if let Some(ref desc) = opt.description {
let _ = writeln!(
out,
"- `{}` - {} ({})",
format_value(&opt.value),
opt.label,
desc
);
} else {
let _ = writeln!(out, "- `{}` - {}", format_value(&opt.value), opt.label);
}
}
out.push('\n');
}
out.push_str("---\n\n");
}
fn format_type(t: &SettingType) -> &'static str {
match t {
SettingType::Toggle => "Boolean",
SettingType::Text => "String",
SettingType::Number => "Number",
SettingType::Select => "Select",
SettingType::Info => "Info (Read-only)",
SettingType::List => "List (Strings)",
SettingType::Object => "Object (JSON)",
}
}
fn format_value(v: &serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => format!("\"{s}\""),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Null => "null".to_string(),
_ => v.to_string(),
}
}
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{SettingOption, SettingsSchema};
use serde::{Deserialize, Serialize};
#[derive(Default, Serialize, Deserialize)]
struct TestSettings {}
impl SettingsSchema for TestSettings {
fn get_metadata() -> HashMap<String, SettingMetadata> {
let mut m = HashMap::new();
m.insert(
"appearance.theme".into(),
SettingMetadata::select(
"system",
vec![
SettingOption::new("light", "Light"),
SettingOption::new("dark", "Dark"),
SettingOption::new("system", "System Default"),
],
)
.meta_str("label", "Theme")
.meta_str("category", "appearance")
.meta_str("description", "Choose your preferred color theme")
.meta_num("order", 1.0),
);
m.insert(
"network.port".into(),
SettingMetadata::number(8080.0)
.meta_str("label", "Port")
.meta_str("category", "network")
.min(1.0)
.max(65535.0)
.meta_str("description", "Server port number"),
);
m.insert("security.api_key".into(), {
let s = SettingMetadata::text("")
.meta_str("label", "API Key")
.meta_str("category", "security")
.meta_bool("advanced", true);
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
let s = s.secret();
s
});
m
}
}
#[test]
fn test_generate_docs() {
let docs = generate_docs::<TestSettings>(
DocsConfig::new()
.with_title("My App Settings")
.with_description("Configuration options for My App"),
);
assert!(docs.contains("# My App Settings"));
assert!(docs.contains("## Appearance"));
assert!(docs.contains("## Network"));
assert!(docs.contains("## Security"));
assert!(docs.contains("`appearance.theme`"));
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
assert!(docs.contains("Secret"));
assert!(docs.contains("Advanced"));
}
#[test]
fn test_hide_advanced() {
let docs = generate_docs::<TestSettings>(DocsConfig::new().hide_advanced());
assert!(!docs.contains("security.api_key"));
assert!(docs.contains("appearance.theme"));
}
}