1 module easysettings;
2 private import siryul;
3 private import standardpaths;
4 
5 private enum settingsFilename = "settings";
6 private alias SettingsFormat = YAML;
7 
8 /**
9  * Get a list of paths where settings files were found.
10  * Params:
11  * settingsForma = The serialization format used to save and load settings (YAML, JSON, etc)
12  * name = Subdirectory of settings dir to save config to. Created if nonexistent.
13  * filename = The filename the settings will be loaded from.
14  * subdir = The subdirectory that the settings will be loaded from.
15  */
16 auto getSettingsPaths(alias settingsFormat = SettingsFormat)(string name, string subdir, string filename, bool writable) {
17 	import std.algorithm : cartesianProduct, filter, map;
18 	import std.experimental.logger : tracef;
19 	import std.file : exists;
20 	import std.path : buildPath, chainPath, withExtension;
21 	import std.range : chain, choose, only;
22 	const subPath = buildPath(name, subdir);
23 	const candidates = writable.choose(only(writablePath(StandardPath.config, subPath, FolderFlag.create)), standardPaths(StandardPath.config, subPath));
24 	auto searchPaths = candidates.chain(["."]).cartesianProduct(only(SettingsExtensions!settingsFormat)).map!(x => chainPath(x[0], filename ~ x[1]));
25 	tracef("Search paths: %s", searchPaths);
26 	return searchPaths.filter!(x => writable || x.exists);
27 }
28 
29 /**
30  * Load settings. Will create settings file by default. Searches all system-wide
31  * settings dirs as well as the user's settings dir, and loads the first file
32  * found.
33  * Params:
34  * T = Type of settings struct to load
35  * name = Subdirectory of settings dir to save config to. Created if nonexistent.
36  * filename = The filename the settings will be loaded from.
37  * subdir = The subdirectory that the settings will be loaded from.
38  */
39 auto loadSettings(T, alias settingsFormat = SettingsFormat)(string name, string filename = settingsFilename, string subdir = "") {
40 	import std.conv : text;
41 	import std.experimental.logger : tracef;
42 	auto paths = getSettingsPaths!settingsFormat(name, subdir, filename, false);
43 	if (!paths.empty) {
44 		tracef("Loading settings from '%s'", paths.front);
45 		return fromFile!(T, settingsFormat, DeSiryulize.optionalByDefault)(paths.front.text);
46 	} else {
47 		saveSettings(T.init, name, filename, subdir);
48 	}
49 	return T.init;
50 }
51 ///
52 @system unittest {
53 	struct Settings {
54 		bool blah;
55 		string text;
56 		string[] texts;
57 	}
58 	auto settings = loadSettings!Settings("testapp", "settings", "subdir");
59 	settings.texts = ["a", "b", "c"];
60 	saveSettings(settings, "testapp", "settings", "subdir");
61 
62 	auto reloadedSettings = loadSettings!Settings("testapp", "settings", "subdir");
63 	assert(reloadedSettings == settings);
64 	assert(reloadedSettings.texts == ["a", "b", "c"]);
65 }
66 /**
67  * Saves settings. Uses user's settings dir.
68  * Params:
69  * data = The data that will be saved to the settings file.
70  * name = The subdirectory of the settings dir to save the config to. Created if nonexistent.
71  * filename = The filename the settings will be saved to.
72  * subdir = The subdirectory that the settings will be saved in.
73  */
74 void saveSettings(T, alias settingsFormat = SettingsFormat)(T data, string name, string filename = settingsFilename, string subdir = "") {
75 	import std.conv : text;
76 	import std.exception : enforce;
77 	auto paths = getSettingsPaths(name, subdir, filename, true);
78 	enforce (!paths.empty, "No writable paths found");
79 	safeSave(paths.front.text, data.toString!settingsFormat());
80 }
81 ///
82 unittest {
83 	struct Settings {
84 		bool blah;
85 		string text;
86 		string[] texts;
87 	}
88 	saveSettings(Settings(true, "some words", ["c", "b", "a"]), "testapp", "settings", "subdir");
89 
90 	assert(loadSettings!Settings("testapp", "settings", "subdir") == Settings(true, "some words", ["c", "b", "a"]));
91 }
92 /**
93  * Deletes settings files for the specified app that are handled by this
94  * library. Also removes directory if empty.
95  * Params:
96  * name = App name.
97  * filename = The settings file that will be deleted.
98  * subdir = Settings subdirectory to delete.
99  */
100 void deleteSettings(alias settingsFormat = SettingsFormat)(string name, string filename = settingsFilename, string subdir = "") {
101 	import std.conv : text;
102 	import std.file : exists, remove, dirEntries, SpanMode, rmdir;
103 	import std.path : dirName;
104 	foreach (path; getSettingsPaths(name, subdir, filename, true)) {
105 		if (path.exists) {
106 			remove(path);
107 			if (path.dirName.text.dirEntries(SpanMode.shallow).empty) {
108 				rmdir(path.dirName);
109 			}
110 		}
111 	}
112 }
113 ///
114 unittest {
115 	deleteSettings("testapp", "settings", "subdir");
116 }
117 
118 private template SettingsExtensions(T) {
119 	import std.meta : AliasSeq;
120 	static if (is(T == YAML)) {
121 		alias SettingsExtensions = AliasSeq!(".yaml", ".yml");
122 	} else static if (is(T == JSON)) {
123 		alias SettingsExtensions = AliasSeq!(".json");
124 	}
125 }
126 
127 /** Safely save a text file. If something goes wrong while writing, the new
128  * file is simply discarded while the old one is untouched
129  */
130 void safeSave(string path, string data) @safe {
131 	import std.file : exists, remove, rename, write;
132 	import std.path : setExtension;
133 	const tmpFile = path.setExtension(".tmp");
134 	scope(exit) {
135 		if (tmpFile.exists) {
136 			remove(tmpFile);
137 		}
138 	}
139 	write(tmpFile, data);
140 	if (path.exists) {
141 		remove(path);
142 	}
143 	rename(tmpFile, path);
144 }
145 
146 @safe unittest {
147 	import std.exception : assertThrown;
148 	import std.file : exists, getAttributes, mkdir, remove, rmdir, setAttributes;
149 	import std.path : buildPath;
150 	enum testdir = "test";
151 	enum testFile = buildPath(testdir, "test.txt");
152 	mkdir(testdir);
153 	scope(exit) {
154 		rmdir(testdir);
155 	}
156 	safeSave(testFile, "");
157 	version(Windows) {
158 		import core.sys.windows.winnt : FILE_ATTRIBUTE_READONLY;
159 		enum readOnly = FILE_ATTRIBUTE_READONLY;
160 		enum attributesTarget = testFile;
161 	} else version(Posix) {
162 		enum readOnly = 555;
163 		enum attributesTarget = "test";
164 	}
165 	const oldAttributes = getAttributes(attributesTarget);
166 	setAttributes(attributesTarget, readOnly);
167 	scope(exit) {
168 		setAttributes(attributesTarget, oldAttributes);
169 		remove(testFile);
170 	}
171 	assertThrown(safeSave(testFile, ""));
172 	assert(!(testFile~".tmp").exists);
173 }