1#![deny(missing_docs)]
171#![allow(clippy::tabs_in_doc_comments)]
172
173use std::env::var;
174use std::fmt::Display;
175use std::fs::{create_dir_all, remove_dir, remove_file, File};
176use std::path::PathBuf;
177use std::str;
178use std::{io, result};
179
180use serde::{de::DeserializeOwned, Serialize};
181
182const MSG_NO_SYSTEM_CONFIG_DIR: &str = "no system config directory detected";
183
184pub type Error = anyhow::Error;
186
187pub type Result<T> = result::Result<T, Error>;
189
190#[derive(Debug, PartialEq, Clone, Default)]
192pub enum PrettyJsonIndent {
193 #[default]
195 Tab,
196
197 Spaces(usize),
199}
200
201impl Display for PrettyJsonIndent {
202 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203 match self {
204 PrettyJsonIndent::Tab => write!(f, "\t"),
205 PrettyJsonIndent::Spaces(n) => write!(f, "{}", " ".repeat(*n)),
206 }
207 }
208}
209
210#[derive(Debug, PartialEq, Clone, Default)]
215pub enum Format {
216 #[default]
218
219 #[cfg(feature = "json")]
221 Json,
222
223 #[cfg(feature = "json")]
225 PrettyJson(PrettyJsonIndent),
226
227 #[cfg(feature = "yaml")]
229 Yaml,
230
231 #[cfg(feature = "pickle")]
233 Pickle,
234
235 #[cfg(feature = "ini")]
237 Ini,
238
239 #[cfg(feature = "toml")]
241 Toml,
242}
243
244impl Format {
245 pub fn default_name(&self) -> String {
247 match self {
248 Format::PrettyJson(_) => format!("config.{:?}", Format::Json).to_lowercase(),
249 _ => format!("config.{:?}", self).to_lowercase(),
250 }
251 }
252}
253
254#[derive(Debug, PartialEq, Clone, Default)]
256pub enum Location {
257 #[default]
259 Auto,
260
261 Path(PathBuf),
263
264 File(PathBuf),
266
267 Dir(PathBuf),
269}
270
271#[derive(Debug, PartialEq, Clone)]
273pub struct Abserde {
274 pub app: String,
276
277 pub location: Location,
279
280 pub format: Format,
282}
283
284impl Abserde {
285 fn config_path(&self) -> Result<PathBuf> {
286 let system_config_dir = dirs::config_dir()
287 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, MSG_NO_SYSTEM_CONFIG_DIR))?;
288
289 Ok(match &self.location {
290 Location::Auto => system_config_dir
291 .join(&self.app)
292 .join(&self.format.default_name()),
293 Location::Path(path) => path.clone(),
294 Location::Dir(dir) => dir.join(&self.format.default_name()),
295 Location::File(file) => system_config_dir.join(&self.app).join(file),
296 })
297 }
298
299 pub fn delete(&self) -> Result<()> {
301 let config_path = self.config_path()?;
302
303 remove_file(&config_path)?;
304
305 match &self.location {
306 Location::Dir(_) => {}
308 _ => {
310 let config_dir = config_path.parent().ok_or_else(|| {
311 io::Error::new(io::ErrorKind::NotFound, MSG_NO_SYSTEM_CONFIG_DIR)
312 })?;
313
314 _ = remove_dir(config_dir);
316 }
317 }
318
319 Ok(())
320 }
321}
322
323impl Default for Abserde {
324 fn default() -> Self {
325 Self {
326 app: var("CARGO_PKG_NAME").unwrap_or_else(|_| env!("CARGO_PKG_NAME").to_string()),
327 location: Default::default(),
328 format: Default::default(),
329 }
330 }
331}
332
333pub trait Config {
337 type T;
339
340 fn load_config(abserde: &Abserde) -> Result<Self::T>;
342
343 fn save_config(&self, abserde: &Abserde) -> Result<()>;
345}
346
347impl<T> Config for T
348where
349 T: Serialize,
350 T: DeserializeOwned,
351{
352 type T = T;
353
354 fn load_config(abserde: &Abserde) -> Result<Self::T> {
355 let config_path = abserde.config_path()?;
356
357 Ok(match abserde.format {
358 #[cfg(feature = "json")]
359 Format::Json | Format::PrettyJson(_) => {
360 let file = File::open(config_path)?;
361
362 serde_json::from_reader(io::BufReader::new(file))?
363 }
364 #[cfg(feature = "yaml")]
365 Format::Yaml => {
366 let file = File::open(config_path)?;
367
368 serde_yaml::from_reader(io::BufReader::new(file))?
369 }
370 #[cfg(feature = "pickle")]
371 Format::Pickle => {
372 let file = File::open(config_path)?;
373
374 serde_pickle::from_reader(io::BufReader::new(file), serde_pickle::DeOptions::new())?
375 }
376 #[cfg(feature = "ini")]
377 Format::Ini => {
378 let file = File::open(config_path)?;
379
380 serde_ini::from_read(io::BufReader::new(file))?
381 }
382 #[cfg(feature = "toml")]
383 Format::Toml => {
384 use io::Read;
385
386 let mut file = File::open(config_path)?;
387 let mut buf = String::new();
388
389 file.read_to_string(&mut buf)?;
390
391 toml::from_str(&buf)?
392 }
393 })
394 }
395
396 fn save_config(&self, abserde: &Abserde) -> Result<()> {
397 let config_path = abserde.config_path()?;
398 let config_dir = config_path
399 .parent()
400 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, MSG_NO_SYSTEM_CONFIG_DIR))?;
401
402 create_dir_all(config_dir)?;
403
404 match &abserde.format {
405 #[cfg(feature = "json")]
406 Format::Json => {
407 serde_json::to_writer(File::create(&config_path)?, self)?;
408 }
409 #[cfg(feature = "json")]
410 Format::PrettyJson(indent) => {
411 use io::Write;
412
413 let mut buf = Vec::new();
414 let indent_string = indent.to_string();
415 let formatter =
416 serde_json::ser::PrettyFormatter::with_indent(indent_string.as_bytes());
417 let mut ser = serde_json::Serializer::with_formatter(&mut buf, formatter);
418 self.serialize(&mut ser).unwrap();
419
420 write!(File::create(&config_path)?, "{}\n", String::from_utf8(buf)?)?;
421 }
422 #[cfg(feature = "yaml")]
423 Format::Yaml => {
424 serde_yaml::to_writer(File::create(&config_path)?, self)?;
425 }
426 #[cfg(feature = "pickle")]
427 Format::Pickle => {
428 serde_pickle::to_writer(
429 &mut File::create(&config_path)?,
430 self,
431 serde_pickle::SerOptions::new(),
432 )?;
433 }
434 #[cfg(feature = "ini")]
435 Format::Ini => {
436 serde_ini::to_writer(File::create(&config_path)?, self)?;
437 }
438 #[cfg(feature = "toml")]
439 Format::Toml => {
440 use io::Write;
441
442 write!(File::create(&config_path)?, "{}", toml::to_string(self)?)?;
443 }
444 }
445
446 Ok(())
447 }
448}
449
450#[cfg(test)]
451mod tests {
452 use std::collections::HashMap;
453 use std::fmt::Debug;
454
455 use fake::{Dummy, Fake, Faker};
456 use serde::{de::DeserializeOwned, Deserialize, Serialize};
457 use serial_test::serial;
458 use tempfile::{NamedTempFile, TempDir};
459
460 use crate::{Abserde, Config, Format, Location, PrettyJsonIndent};
461
462 const APP_NAME: &str = env!("CARGO_PKG_NAME");
463
464 #[derive(Serialize, Deserialize, Debug, Default, Dummy, PartialEq)]
466 struct TestConfigSimple {
467 string_val: String,
468 i8_val: i8,
469 i16_val: i16,
470 i32_val: i32,
471 u8_val: u8,
472 u16_val: u16,
473 u32_val: u32,
474 f32_val: f32,
475 }
476
477 #[derive(Serialize, Deserialize, Debug, Default, Dummy, PartialEq)]
479 struct TestConfigComplex {
480 string_val: String,
481 i8_val: i8,
482 i16_val: i16,
483 i32_val: i32,
484 i64_val: i64,
485 i128_val: i128,
486 u8_val: u8,
487 u16_val: u16,
488 u32_val: u32,
489 u64_val: u64,
490 u128_val: u128,
491 f32_val: f32,
492 f64_val: f64,
493 vec_1_val: Vec<i64>,
494 vec_2_val: Vec<(String, i8, i8, i32, i64, String, String, String)>,
495 vec_3_val: Vec<(f32, f32, f32, f64, f64)>,
496 hash_map_1_val: HashMap<String, String>,
497 hash_map_2_val: HashMap<i16, i64>,
498 hash_map_3_val: HashMap<i16, Vec<(String, i32, u8, HashMap<String, i32>)>>,
499 hash_map_4_val: HashMap<String, (f64, f32, i8)>,
500 }
501
502 fn test_save_load_delete<T>(abserde: &Abserde)
504 where
505 T: Serialize,
506 T: DeserializeOwned,
507 T: Dummy<Faker>,
508 T: PartialEq,
509 T: Debug,
510 {
511 let test_config_saved: T = Faker.fake();
512
513 test_config_saved.save_config(&abserde).unwrap();
514
515 let test_config_loaded = T::load_config(&abserde).unwrap();
516
517 assert_eq!(test_config_saved, test_config_loaded);
518
519 abserde.delete().unwrap();
520 }
521
522 #[test]
523 #[serial]
524 fn test_auto() {
525 test_save_load_delete::<TestConfigSimple>(&Abserde::default());
526 }
527
528 #[cfg(feature = "json")]
529 #[test]
530 #[serial]
531 fn test_json_auto() {
532 test_save_load_delete::<TestConfigComplex>(&Abserde {
533 app: APP_NAME.to_string(),
534 location: Location::Auto,
535 format: Format::Json,
536 });
537 }
538
539 #[cfg(feature = "json")]
540 #[test]
541 #[serial]
542 fn test_pretty_json_auto() {
543 test_save_load_delete::<TestConfigComplex>(&Abserde {
544 app: APP_NAME.to_string(),
545 location: Location::Auto,
546 format: Format::PrettyJson(PrettyJsonIndent::default()),
547 });
548 }
549
550 #[cfg(feature = "json")]
551 #[test]
552 fn test_json_path() {
553 let tmp_file = NamedTempFile::new().unwrap();
554
555 test_save_load_delete::<TestConfigComplex>(&Abserde {
556 app: APP_NAME.to_string(),
557 location: Location::Path(tmp_file.path().into()),
558 format: Format::Json,
559 });
560 }
561
562 #[cfg(feature = "json")]
563 #[test]
564 fn test_pretty_json_path() {
565 let tmp_file = NamedTempFile::new().unwrap();
566
567 test_save_load_delete::<TestConfigComplex>(&Abserde {
568 app: APP_NAME.to_string(),
569 location: Location::Path(tmp_file.path().into()),
570 format: Format::PrettyJson(PrettyJsonIndent::Spaces(4)),
571 });
572 }
573
574 #[cfg(feature = "json")]
575 #[test]
576 #[serial]
577 fn test_json_file() {
578 test_save_load_delete::<TestConfigComplex>(&Abserde {
579 app: APP_NAME.to_string(),
580 location: Location::File("custom_file.json".into()),
581 format: Format::Json,
582 });
583 }
584
585 #[cfg(feature = "json")]
586 #[test]
587 #[serial]
588 fn test_pretty_json_file() {
589 test_save_load_delete::<TestConfigComplex>(&Abserde {
590 app: APP_NAME.to_string(),
591 location: Location::File("custom_file.json".into()),
592 format: Format::PrettyJson(PrettyJsonIndent::Spaces(4)),
593 });
594 }
595
596 #[cfg(feature = "json")]
597 #[test]
598 fn test_json_dir() {
599 let tmp_dir = TempDir::new().unwrap();
600
601 test_save_load_delete::<TestConfigComplex>(&Abserde {
602 app: APP_NAME.to_string(),
603 location: Location::Dir(tmp_dir.path().into()),
604 format: Format::Json,
605 });
606 }
607
608 #[cfg(feature = "json")]
609 #[test]
610 fn test_pretty_json_dir() {
611 let tmp_dir = TempDir::new().unwrap();
612
613 test_save_load_delete::<TestConfigComplex>(&Abserde {
614 app: APP_NAME.to_string(),
615 location: Location::Dir(tmp_dir.path().into()),
616 format: Format::PrettyJson(PrettyJsonIndent::Spaces(4)),
617 });
618 }
619
620 #[cfg(feature = "yaml")]
621 #[test]
622 #[serial]
623 fn test_yaml_auto() {
624 test_save_load_delete::<TestConfigComplex>(&Abserde {
625 app: APP_NAME.to_string(),
626 location: Location::Auto,
627 format: Format::Yaml,
628 });
629 }
630
631 #[cfg(feature = "yaml")]
632 #[test]
633 fn test_yaml_path() {
634 let tmp_file = NamedTempFile::new().unwrap();
635
636 test_save_load_delete::<TestConfigComplex>(&Abserde {
637 app: APP_NAME.to_string(),
638 location: Location::Path(tmp_file.path().into()),
639 format: Format::Yaml,
640 });
641 }
642
643 #[cfg(feature = "yaml")]
644 #[test]
645 #[serial]
646 fn test_yaml_file() {
647 test_save_load_delete::<TestConfigComplex>(&Abserde {
648 app: APP_NAME.to_string(),
649 location: Location::File("custom_file.yaml".into()),
650 format: Format::Yaml,
651 });
652 }
653
654 #[cfg(feature = "yaml")]
655 #[test]
656 fn test_yaml_dir() {
657 let tmp_dir = TempDir::new().unwrap();
658
659 test_save_load_delete::<TestConfigComplex>(&Abserde {
660 app: APP_NAME.to_string(),
661 location: Location::Dir(tmp_dir.path().into()),
662 format: Format::Yaml,
663 });
664 }
665
666 #[cfg(feature = "pickle")]
667 #[test]
668 #[serial]
669 fn test_pickle_auto() {
670 test_save_load_delete::<TestConfigSimple>(&Abserde {
671 app: APP_NAME.to_string(),
672 location: Location::Auto,
673 format: Format::Pickle,
674 });
675 }
676
677 #[cfg(feature = "pickle")]
678 #[test]
679 fn test_pickle_path() {
680 let tmp_file = NamedTempFile::new().unwrap();
681
682 test_save_load_delete::<TestConfigSimple>(&Abserde {
683 app: APP_NAME.to_string(),
684 location: Location::Path(tmp_file.path().into()),
685 format: Format::Pickle,
686 });
687 }
688
689 #[cfg(feature = "pickle")]
690 #[test]
691 #[serial]
692 fn test_pickle_file() {
693 test_save_load_delete::<TestConfigSimple>(&Abserde {
694 app: APP_NAME.to_string(),
695 location: Location::File("custom_file.pickle".into()),
696 format: Format::Pickle,
697 });
698 }
699
700 #[cfg(feature = "pickle")]
701 #[test]
702 fn test_pickle_dir() {
703 let tmp_dir = TempDir::new().unwrap();
704
705 test_save_load_delete::<TestConfigSimple>(&Abserde {
706 app: APP_NAME.to_string(),
707 location: Location::Dir(tmp_dir.path().into()),
708 format: Format::Pickle,
709 });
710 }
711
712 #[cfg(feature = "ini")]
713 #[test]
714 #[serial]
715 fn test_ini_auto() {
716 test_save_load_delete::<TestConfigSimple>(&Abserde {
717 app: APP_NAME.to_string(),
718 location: Location::Auto,
719 format: Format::Ini,
720 });
721 }
722
723 #[cfg(feature = "ini")]
724 #[test]
725 fn test_ini_path() {
726 let tmp_file = NamedTempFile::new().unwrap();
727
728 test_save_load_delete::<TestConfigSimple>(&Abserde {
729 app: APP_NAME.to_string(),
730 location: Location::Path(tmp_file.path().into()),
731 format: Format::Ini,
732 });
733 }
734
735 #[cfg(feature = "ini")]
736 #[test]
737 #[serial]
738 fn test_ini_file() {
739 test_save_load_delete::<TestConfigSimple>(&Abserde {
740 app: APP_NAME.to_string(),
741 location: Location::File("custom_file.ini".into()),
742 format: Format::Ini,
743 });
744 }
745
746 #[cfg(feature = "ini")]
747 #[test]
748 fn test_ini_dir() {
749 let tmp_dir = TempDir::new().unwrap();
750
751 test_save_load_delete::<TestConfigSimple>(&Abserde {
752 app: APP_NAME.to_string(),
753 location: Location::Dir(tmp_dir.path().into()),
754 format: Format::Ini,
755 });
756 }
757
758 #[cfg(feature = "toml")]
759 #[test]
760 #[serial]
761 fn test_toml_auto() {
762 test_save_load_delete::<TestConfigSimple>(&Abserde {
763 app: APP_NAME.to_string(),
764 location: Location::Auto,
765 format: Format::Toml,
766 });
767 }
768
769 #[cfg(feature = "toml")]
770 #[test]
771 fn test_toml_path() {
772 let tmp_file = NamedTempFile::new().unwrap();
773
774 test_save_load_delete::<TestConfigSimple>(&Abserde {
775 app: APP_NAME.to_string(),
776 location: Location::Path(tmp_file.path().into()),
777 format: Format::Toml,
778 });
779 }
780
781 #[cfg(feature = "toml")]
782 #[test]
783 #[serial]
784 fn test_toml_file() {
785 test_save_load_delete::<TestConfigSimple>(&Abserde {
786 app: APP_NAME.to_string(),
787 location: Location::File("custom_file.toml".into()),
788 format: Format::Toml,
789 });
790 }
791
792 #[cfg(feature = "toml")]
793 #[test]
794 fn test_toml_dir() {
795 let tmp_dir = TempDir::new().unwrap();
796
797 test_save_load_delete::<TestConfigSimple>(&Abserde {
798 app: APP_NAME.to_string(),
799 location: Location::Dir(tmp_dir.path().into()),
800 format: Format::Toml,
801 });
802 }
803}