use serde::{Deserialize, Serialize};
use std::{collections::HashMap, fs::File, io::Read, path::Path};
#[derive(Debug)]
pub enum ConfigError {
FileReadError(std::io::Error),
JsonParseError(serde_json::Error),
TomlParseError(toml::de::Error),
ValidationError(String),
UnsupportedFormat(String),
}
impl std::error::Error for ConfigError {
fn description(&self) -> &str {
match self {
ConfigError::FileReadError(_) => "Failed to read config file",
ConfigError::JsonParseError(_) => "Failed to parse JSON config",
ConfigError::TomlParseError(_) => "Failed to parse TOML config",
ConfigError::ValidationError(_) => "Config validation error",
ConfigError::UnsupportedFormat(_) => "Unsupported config file format",
}
}
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigError::FileReadError(err) => write!(f, "Failed to read config file: {}", err),
ConfigError::JsonParseError(err) => write!(f, "Failed to parse JSON config: {}", err),
ConfigError::TomlParseError(err) => write!(f, "Failed to parse TOML config: {}", err),
ConfigError::ValidationError(msg) => write!(f, "Config validation error: {}", msg),
ConfigError::UnsupportedFormat(fmt) => write!(f, "Unsupported config file format: {}", fmt),
}
}
}
impl From<std::io::Error> for ConfigError {
fn from(err: std::io::Error) -> Self {
ConfigError::FileReadError(err)
}
}
impl From<serde_json::Error> for ConfigError {
fn from(err: serde_json::Error) -> Self {
ConfigError::JsonParseError(err)
}
}
impl From<toml::de::Error> for ConfigError {
fn from(err: toml::de::Error) -> Self {
ConfigError::TomlParseError(err)
}
}
pub trait ConfigValidation {
fn validate(&self) -> Result<(), ConfigError>;
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Config {
pub title: Option<String>,
pub description: Option<String>,
pub base: Option<String>,
pub locales: HashMap<String, LocaleConfig>,
pub theme: ThemeConfig,
pub plugins: Vec<PluginConfig>,
pub markdown: MarkdownConfig,
pub build: BuildConfig,
}
impl Config {
pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
let path = path.as_ref();
let content = std::fs::read_to_string(path)?;
match path.extension().and_then(|ext| ext.to_str()) {
Some("json") => Self::load_from_json_str(&content),
Some("toml") => Self::load_from_toml_str(&content),
Some(ext) => Err(ConfigError::UnsupportedFormat(ext.to_string())),
None => Err(ConfigError::UnsupportedFormat("no extension".to_string())),
}
}
pub fn load_from_json_str(json_str: &str) -> Result<Self, ConfigError> {
let config: Self = serde_json::from_str(json_str)?;
config.validate()?;
Ok(config)
}
pub fn load_from_toml_str(toml_str: &str) -> Result<Self, ConfigError> {
let config: Self = toml::from_str(toml_str)?;
config.validate()?;
Ok(config)
}
pub fn load_from_dir<P: AsRef<Path>>(dir: P) -> Result<Self, ConfigError> {
let dir = dir.as_ref();
let toml_path = dir.join("nargodoc.config.toml");
if toml_path.exists() {
return Self::load_from_file(toml_path);
}
let json_path = dir.join("nargodoc.config.json");
if json_path.exists() {
return Self::load_from_file(json_path);
}
let vutex_toml_path = dir.join("vutex.config.toml");
if vutex_toml_path.exists() {
return Self::load_from_file(vutex_toml_path);
}
let vutex_json_path = dir.join("vutex.config.json");
if vutex_json_path.exists() {
return Self::load_from_file(vutex_json_path);
}
Ok(Self::default())
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
pub fn to_toml(&self) -> Result<String, toml::ser::Error> {
toml::to_string_pretty(self)
}
pub fn new() -> Self {
Self::default()
}
pub fn with_title(mut self, title: String) -> Self {
self.title = Some(title);
self
}
pub fn with_description(mut self, description: String) -> Self {
self.description = Some(description);
self
}
pub fn add_locale(mut self, lang: String, config: LocaleConfig) -> Self {
self.locales.insert(lang, config);
self
}
}
impl ConfigValidation for Config {
fn validate(&self) -> Result<(), ConfigError> {
let default_count = self.locales.iter().filter(|(_, cfg)| cfg.default.unwrap_or(false)).count();
if default_count > 1 {
return Err(ConfigError::ValidationError(format!("Multiple default locales specified: found {} default locales", default_count)));
}
for (lang_code, locale) in &self.locales {
if lang_code.is_empty() {
return Err(ConfigError::ValidationError("Locale code cannot be empty".to_string()));
}
locale.validate()?;
}
self.theme.validate()?;
for (i, plugin) in self.plugins.iter().enumerate() {
if plugin.name.is_empty() {
return Err(ConfigError::ValidationError(format!("Plugin at index {} has empty name", i)));
}
}
self.markdown.validate()?;
self.build.validate()?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct LocaleConfig {
pub label: String,
pub description: Option<String>,
pub link: Option<String>,
pub default: Option<bool>,
pub nav: Option<Vec<NavItem>>,
pub sidebar: Option<HashMap<String, Vec<SidebarItem>>>,
}
impl LocaleConfig {
pub fn new(label: String) -> Self {
Self { label, description: None, link: None, default: None, nav: None, sidebar: None }
}
pub fn with_default(mut self, is_default: bool) -> Self {
self.default = Some(is_default);
self
}
pub fn with_nav(mut self, nav: Vec<NavItem>) -> Self {
self.nav = Some(nav);
self
}
pub fn with_sidebar(mut self, sidebar: HashMap<String, Vec<SidebarItem>>) -> Self {
self.sidebar = Some(sidebar);
self
}
}
impl ConfigValidation for LocaleConfig {
fn validate(&self) -> Result<(), ConfigError> {
if self.label.is_empty() {
return Err(ConfigError::ValidationError("Locale label cannot be empty".to_string()));
}
if let Some(nav) = &self.nav {
for (i, item) in nav.iter().enumerate() {
item.validate().map_err(|e| ConfigError::ValidationError(format!("Nav item at index {}: {}", i, e)))?;
}
}
if let Some(sidebar) = &self.sidebar {
for (group_key, items) in sidebar {
for (i, item) in items.iter().enumerate() {
item.validate().map_err(|e| ConfigError::ValidationError(format!("Sidebar item in group '{}' at index {}: {}", group_key, i, e)))?;
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ThemeConfig {
pub nav: Vec<NavItem>,
pub sidebar: HashMap<String, Vec<SidebarItem>>,
pub social_links: Vec<SocialLink>,
pub footer: Option<FooterConfig>,
pub custom: HashMap<String, serde_json::Value>,
}
impl ThemeConfig {
pub fn new() -> Self {
Self::default()
}
pub fn add_nav_item(mut self, item: NavItem) -> Self {
self.nav.push(item);
self
}
}
impl ConfigValidation for ThemeConfig {
fn validate(&self) -> Result<(), ConfigError> {
for (i, item) in self.nav.iter().enumerate() {
item.validate().map_err(|e| ConfigError::ValidationError(format!("Theme nav item at index {}: {}", i, e)))?;
}
for (group_key, items) in &self.sidebar {
for (i, item) in items.iter().enumerate() {
item.validate().map_err(|e| ConfigError::ValidationError(format!("Theme sidebar item in group '{}' at index {}: {}", group_key, i, e)))?;
}
}
for (i, link) in self.social_links.iter().enumerate() {
if link.platform.is_empty() {
return Err(ConfigError::ValidationError(format!("Social link at index {} has empty platform name", i)));
}
if link.link.is_empty() {
return Err(ConfigError::ValidationError(format!("Social link at index {} has empty URL", i)));
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct NavItem {
pub text: String,
pub link: Option<String>,
pub items: Option<Vec<NavItem>>,
}
impl NavItem {
pub fn new(text: String) -> Self {
Self { text, link: None, items: None }
}
pub fn with_link(mut self, link: String) -> Self {
self.link = Some(link);
self
}
pub fn add_item(mut self, item: NavItem) -> Self {
if self.items.is_none() {
self.items = Some(Vec::new());
}
if let Some(items) = &mut self.items {
items.push(item);
}
self
}
}
impl ConfigValidation for NavItem {
fn validate(&self) -> Result<(), ConfigError> {
if self.text.is_empty() {
return Err(ConfigError::ValidationError("Nav item text cannot be empty".to_string()));
}
if let Some(items) = &self.items {
for (i, item) in items.iter().enumerate() {
item.validate().map_err(|e| ConfigError::ValidationError(format!("Sub-item at index {}: {}", i, e)))?;
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SidebarItem {
pub text: String,
pub link: Option<String>,
pub items: Option<Vec<SidebarItem>>,
pub collapsed: Option<bool>,
}
impl SidebarItem {
pub fn new(text: String) -> Self {
Self { text, link: None, items: None, collapsed: None }
}
pub fn with_link(mut self, link: String) -> Self {
self.link = Some(link);
self
}
}
impl ConfigValidation for SidebarItem {
fn validate(&self) -> Result<(), ConfigError> {
if self.text.is_empty() {
return Err(ConfigError::ValidationError("Sidebar item text cannot be empty".to_string()));
}
if let Some(items) = &self.items {
for (i, item) in items.iter().enumerate() {
item.validate().map_err(|e| ConfigError::ValidationError(format!("Sub-item at index {}: {}", i, e)))?;
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SocialLink {
pub platform: String,
pub link: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct FooterConfig {
pub copyright: Option<String>,
pub message: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PluginConfig {
pub name: String,
pub options: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct MarkdownConfig {
pub line_numbers: bool,
pub code_theme: Option<String>,
pub custom: HashMap<String, serde_json::Value>,
}
impl ConfigValidation for MarkdownConfig {
fn validate(&self) -> Result<(), ConfigError> {
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct BuildConfig {
pub out_dir: Option<String>,
pub src_dir: Option<String>,
pub clean: bool,
pub minify: bool,
}
impl ConfigValidation for BuildConfig {
fn validate(&self) -> Result<(), ConfigError> {
Ok(())
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct LegacyLocale {
pub label: String,
pub lang: String,
pub link: String,
pub theme_config: Option<ThemeConfig>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct LegacyFooter {
pub message: String,
pub copyright: String,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct LegacyMarkdownTheme {
pub light: String,
pub dark: String,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct LegacyMarkdownConfig {
pub theme: Option<LegacyMarkdownTheme>,
pub shiki_setup: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct LegacyBuildConfig {
pub out_dir: Option<String>,
pub base: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct LegacyConfig {
pub title: String,
pub description: String,
pub locales: Option<Vec<LegacyLocale>>,
pub theme: Option<String>,
pub theme_config: Option<ThemeConfig>,
pub markdown: Option<LegacyMarkdownConfig>,
pub build: Option<LegacyBuildConfig>,
}