1use serde::{Deserialize, Serialize};
2use std::{collections::HashMap, fs::File, io::Read, path::Path};
3
4#[derive(Debug)]
6pub enum ConfigError {
7 FileReadError(std::io::Error),
9 JsonParseError(serde_json::Error),
11 TomlParseError(toml::de::Error),
13 ValidationError(String),
15 UnsupportedFormat(String),
17}
18
19impl std::error::Error for ConfigError {
20 fn description(&self) -> &str {
21 match self {
22 ConfigError::FileReadError(_) => "Failed to read config file",
23 ConfigError::JsonParseError(_) => "Failed to parse JSON config",
24 ConfigError::TomlParseError(_) => "Failed to parse TOML config",
25 ConfigError::ValidationError(_) => "Config validation error",
26 ConfigError::UnsupportedFormat(_) => "Unsupported config file format",
27 }
28 }
29}
30
31impl std::fmt::Display for ConfigError {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 match self {
34 ConfigError::FileReadError(err) => write!(f, "Failed to read config file: {}", err),
35 ConfigError::JsonParseError(err) => write!(f, "Failed to parse JSON config: {}", err),
36 ConfigError::TomlParseError(err) => write!(f, "Failed to parse TOML config: {}", err),
37 ConfigError::ValidationError(msg) => write!(f, "Config validation error: {}", msg),
38 ConfigError::UnsupportedFormat(fmt) => write!(f, "Unsupported config file format: {}", fmt),
39 }
40 }
41}
42
43impl From<std::io::Error> for ConfigError {
44 fn from(err: std::io::Error) -> Self {
45 ConfigError::FileReadError(err)
46 }
47}
48
49impl From<serde_json::Error> for ConfigError {
50 fn from(err: serde_json::Error) -> Self {
51 ConfigError::JsonParseError(err)
52 }
53}
54
55impl From<toml::de::Error> for ConfigError {
56 fn from(err: toml::de::Error) -> Self {
57 ConfigError::TomlParseError(err)
58 }
59}
60
61pub trait ConfigValidation {
63 fn validate(&self) -> Result<(), ConfigError>;
69}
70
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
73pub struct Config {
74 pub title: Option<String>,
76 pub description: Option<String>,
78 pub base: Option<String>,
80 pub locales: HashMap<String, LocaleConfig>,
82 pub theme: ThemeConfig,
84 pub plugins: Vec<PluginConfig>,
86 pub markdown: MarkdownConfig,
88 pub build: BuildConfig,
90}
91
92impl Config {
93 pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
103 let path = path.as_ref();
104 let content = std::fs::read_to_string(path)?;
105
106 match path.extension().and_then(|ext| ext.to_str()) {
107 Some("json") => Self::load_from_json_str(&content),
108 Some("toml") => Self::load_from_toml_str(&content),
109 Some(ext) => Err(ConfigError::UnsupportedFormat(ext.to_string())),
110 None => Err(ConfigError::UnsupportedFormat("no extension".to_string())),
111 }
112 }
113
114 pub fn load_from_json_str(json_str: &str) -> Result<Self, ConfigError> {
124 let config: Self = serde_json::from_str(json_str)?;
125 config.validate()?;
126 Ok(config)
127 }
128
129 pub fn load_from_toml_str(toml_str: &str) -> Result<Self, ConfigError> {
139 let config: Self = toml::from_str(toml_str)?;
140 config.validate()?;
141 Ok(config)
142 }
143
144 pub fn load_from_dir<P: AsRef<Path>>(dir: P) -> Result<Self, ConfigError> {
160 let dir = dir.as_ref();
161
162 let toml_path = dir.join("nargodoc.config.toml");
163 if toml_path.exists() {
164 return Self::load_from_file(toml_path);
165 }
166
167 let json_path = dir.join("nargodoc.config.json");
168 if json_path.exists() {
169 return Self::load_from_file(json_path);
170 }
171
172 let vutex_toml_path = dir.join("vutex.config.toml");
173 if vutex_toml_path.exists() {
174 return Self::load_from_file(vutex_toml_path);
175 }
176
177 let vutex_json_path = dir.join("vutex.config.json");
178 if vutex_json_path.exists() {
179 return Self::load_from_file(vutex_json_path);
180 }
181
182 Ok(Self::default())
183 }
184
185 pub fn to_json(&self) -> Result<String, serde_json::Error> {
191 serde_json::to_string_pretty(self)
192 }
193
194 pub fn to_toml(&self) -> Result<String, toml::ser::Error> {
200 toml::to_string_pretty(self)
201 }
202
203 pub fn new() -> Self {
205 Self::default()
206 }
207
208 pub fn with_title(mut self, title: String) -> Self {
210 self.title = Some(title);
211 self
212 }
213
214 pub fn with_description(mut self, description: String) -> Self {
216 self.description = Some(description);
217 self
218 }
219
220 pub fn add_locale(mut self, lang: String, config: LocaleConfig) -> Self {
222 self.locales.insert(lang, config);
223 self
224 }
225}
226
227impl ConfigValidation for Config {
228 fn validate(&self) -> Result<(), ConfigError> {
229 let default_count = self.locales.iter().filter(|(_, cfg)| cfg.default.unwrap_or(false)).count();
230 if default_count > 1 {
231 return Err(ConfigError::ValidationError(format!("Multiple default locales specified: found {} default locales", default_count)));
232 }
233
234 for (lang_code, locale) in &self.locales {
235 if lang_code.is_empty() {
236 return Err(ConfigError::ValidationError("Locale code cannot be empty".to_string()));
237 }
238 locale.validate()?;
239 }
240
241 self.theme.validate()?;
242
243 for (i, plugin) in self.plugins.iter().enumerate() {
244 if plugin.name.is_empty() {
245 return Err(ConfigError::ValidationError(format!("Plugin at index {} has empty name", i)));
246 }
247 }
248
249 self.markdown.validate()?;
250 self.build.validate()?;
251
252 Ok(())
253 }
254}
255
256#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
258pub struct LocaleConfig {
259 pub label: String,
261 pub description: Option<String>,
263 pub link: Option<String>,
265 pub default: Option<bool>,
267 pub nav: Option<Vec<NavItem>>,
269 pub sidebar: Option<HashMap<String, Vec<SidebarItem>>>,
271}
272
273impl LocaleConfig {
274 pub fn new(label: String) -> Self {
276 Self { label, description: None, link: None, default: None, nav: None, sidebar: None }
277 }
278
279 pub fn with_default(mut self, is_default: bool) -> Self {
281 self.default = Some(is_default);
282 self
283 }
284
285 pub fn with_nav(mut self, nav: Vec<NavItem>) -> Self {
287 self.nav = Some(nav);
288 self
289 }
290
291 pub fn with_sidebar(mut self, sidebar: HashMap<String, Vec<SidebarItem>>) -> Self {
293 self.sidebar = Some(sidebar);
294 self
295 }
296}
297
298impl ConfigValidation for LocaleConfig {
299 fn validate(&self) -> Result<(), ConfigError> {
300 if self.label.is_empty() {
301 return Err(ConfigError::ValidationError("Locale label cannot be empty".to_string()));
302 }
303
304 if let Some(nav) = &self.nav {
305 for (i, item) in nav.iter().enumerate() {
306 item.validate().map_err(|e| ConfigError::ValidationError(format!("Nav item at index {}: {}", i, e)))?;
307 }
308 }
309
310 if let Some(sidebar) = &self.sidebar {
311 for (group_key, items) in sidebar {
312 for (i, item) in items.iter().enumerate() {
313 item.validate().map_err(|e| ConfigError::ValidationError(format!("Sidebar item in group '{}' at index {}: {}", group_key, i, e)))?;
314 }
315 }
316 }
317
318 Ok(())
319 }
320}
321
322#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
324pub struct ThemeConfig {
325 pub nav: Vec<NavItem>,
327 pub sidebar: HashMap<String, Vec<SidebarItem>>,
329 pub social_links: Vec<SocialLink>,
331 pub footer: Option<FooterConfig>,
333 pub custom: HashMap<String, serde_json::Value>,
335}
336
337impl ThemeConfig {
338 pub fn new() -> Self {
340 Self::default()
341 }
342
343 pub fn add_nav_item(mut self, item: NavItem) -> Self {
345 self.nav.push(item);
346 self
347 }
348}
349
350impl ConfigValidation for ThemeConfig {
351 fn validate(&self) -> Result<(), ConfigError> {
352 for (i, item) in self.nav.iter().enumerate() {
353 item.validate().map_err(|e| ConfigError::ValidationError(format!("Theme nav item at index {}: {}", i, e)))?;
354 }
355
356 for (group_key, items) in &self.sidebar {
357 for (i, item) in items.iter().enumerate() {
358 item.validate().map_err(|e| ConfigError::ValidationError(format!("Theme sidebar item in group '{}' at index {}: {}", group_key, i, e)))?;
359 }
360 }
361
362 for (i, link) in self.social_links.iter().enumerate() {
363 if link.platform.is_empty() {
364 return Err(ConfigError::ValidationError(format!("Social link at index {} has empty platform name", i)));
365 }
366 if link.link.is_empty() {
367 return Err(ConfigError::ValidationError(format!("Social link at index {} has empty URL", i)));
368 }
369 }
370
371 Ok(())
372 }
373}
374
375#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
377pub struct NavItem {
378 pub text: String,
380 pub link: Option<String>,
382 pub items: Option<Vec<NavItem>>,
384}
385
386impl NavItem {
387 pub fn new(text: String) -> Self {
389 Self { text, link: None, items: None }
390 }
391
392 pub fn with_link(mut self, link: String) -> Self {
394 self.link = Some(link);
395 self
396 }
397
398 pub fn add_item(mut self, item: NavItem) -> Self {
400 if self.items.is_none() {
401 self.items = Some(Vec::new());
402 }
403 if let Some(items) = &mut self.items {
404 items.push(item);
405 }
406 self
407 }
408}
409
410impl ConfigValidation for NavItem {
411 fn validate(&self) -> Result<(), ConfigError> {
412 if self.text.is_empty() {
413 return Err(ConfigError::ValidationError("Nav item text cannot be empty".to_string()));
414 }
415
416 if let Some(items) = &self.items {
417 for (i, item) in items.iter().enumerate() {
418 item.validate().map_err(|e| ConfigError::ValidationError(format!("Sub-item at index {}: {}", i, e)))?;
419 }
420 }
421
422 Ok(())
423 }
424}
425
426#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
428pub struct SidebarItem {
429 pub text: String,
431 pub link: Option<String>,
433 pub items: Option<Vec<SidebarItem>>,
435 pub collapsed: Option<bool>,
437}
438
439impl SidebarItem {
440 pub fn new(text: String) -> Self {
442 Self { text, link: None, items: None, collapsed: None }
443 }
444
445 pub fn with_link(mut self, link: String) -> Self {
447 self.link = Some(link);
448 self
449 }
450}
451
452impl ConfigValidation for SidebarItem {
453 fn validate(&self) -> Result<(), ConfigError> {
454 if self.text.is_empty() {
455 return Err(ConfigError::ValidationError("Sidebar item text cannot be empty".to_string()));
456 }
457
458 if let Some(items) = &self.items {
459 for (i, item) in items.iter().enumerate() {
460 item.validate().map_err(|e| ConfigError::ValidationError(format!("Sub-item at index {}: {}", i, e)))?;
461 }
462 }
463
464 Ok(())
465 }
466}
467
468#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
470pub struct SocialLink {
471 pub platform: String,
473 pub link: String,
475}
476
477#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
479pub struct FooterConfig {
480 pub copyright: Option<String>,
482 pub message: Option<String>,
484}
485
486#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
488pub struct PluginConfig {
489 pub name: String,
491 pub options: HashMap<String, serde_json::Value>,
493}
494
495#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
497pub struct MarkdownConfig {
498 pub line_numbers: bool,
500 pub code_theme: Option<String>,
502 pub custom: HashMap<String, serde_json::Value>,
504}
505
506impl ConfigValidation for MarkdownConfig {
507 fn validate(&self) -> Result<(), ConfigError> {
508 Ok(())
509 }
510}
511
512#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
514pub struct BuildConfig {
515 pub out_dir: Option<String>,
517 pub src_dir: Option<String>,
519 pub clean: bool,
521 pub minify: bool,
523}
524
525impl ConfigValidation for BuildConfig {
526 fn validate(&self) -> Result<(), ConfigError> {
527 Ok(())
528 }
529}
530
531#[derive(Debug, Deserialize, Serialize, Clone)]
533pub struct LegacyLocale {
534 pub label: String,
535 pub lang: String,
536 pub link: String,
537 pub theme_config: Option<ThemeConfig>,
538}
539
540#[derive(Debug, Deserialize, Serialize, Clone)]
542pub struct LegacyFooter {
543 pub message: String,
544 pub copyright: String,
545}
546
547#[derive(Debug, Deserialize, Serialize, Clone)]
549pub struct LegacyMarkdownTheme {
550 pub light: String,
551 pub dark: String,
552}
553
554#[derive(Debug, Deserialize, Serialize, Clone)]
556pub struct LegacyMarkdownConfig {
557 pub theme: Option<LegacyMarkdownTheme>,
558 pub shiki_setup: Option<serde_json::Value>,
559}
560
561#[derive(Debug, Deserialize, Serialize, Clone)]
563pub struct LegacyBuildConfig {
564 pub out_dir: Option<String>,
565 pub base: Option<String>,
566}
567
568#[derive(Debug, Deserialize, Serialize, Clone)]
570pub struct LegacyConfig {
571 pub title: String,
572 pub description: String,
573 pub locales: Option<Vec<LegacyLocale>>,
574 pub theme: Option<String>,
575 pub theme_config: Option<ThemeConfig>,
576 pub markdown: Option<LegacyMarkdownConfig>,
577 pub build: Option<LegacyBuildConfig>,
578}