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 @safe 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  * Loads all settings files from a subdirectory, with the assumption that each
68  * file has the same format.
69  * Params:
70  * name = The main settings directory for the application
71  * subdir = The subdirectory to load these settings files from
72  */
73 auto loadSubdirSettings(T, alias settingsFormat = SettingsFormat)(string name, string subdir) {
74 	import std.algorithm : cartesianProduct, filter, joiner, map;
75 	import std.experimental.logger : tracef;
76 	import std.file : dirEntries, exists, SpanMode;
77 	import std.path : buildPath, chainPath, withExtension;
78 	import std.range : chain, choose, only;
79 	const subPath = buildPath(name, subdir);
80 	return standardPaths(StandardPath.config, subPath)
81 		.cartesianProduct(only(SettingsExtensions!settingsFormat))
82 		.filter!(x => x[0].exists)
83 		.map!(x => dirEntries(x[0], "*"~x[1], SpanMode.depth))
84 		.joiner()
85 		.map!(x => fromFile!(T, settingsFormat, DeSiryulize.optionalByDefault)(x));
86 }
87 ///
88 @system unittest {
89 	import std.array : array;
90 	import std.algorithm.searching : canFind;
91 	static struct Settings {
92 		uint a;
93 	}
94 	saveSettings(Settings(1), "testapp", "1", "mysubdir");
95 	saveSettings(Settings(2), "testapp", "2", "mysubdir");
96 	auto loaded = loadSubdirSettings!Settings("testapp", "mysubdir").array;
97 	assert(loaded.canFind(Settings(1)));
98 	assert(loaded.canFind(Settings(2)));
99 }
100 
101 /**
102  * Saves settings. Uses user's settings dir.
103  * Params:
104  * data = The data that will be saved to the settings file.
105  * name = The subdirectory of the settings dir to save the config to. Created if nonexistent.
106  * filename = The filename the settings will be saved to.
107  * subdir = The subdirectory that the settings will be saved in.
108  */
109 void saveSettings(T, alias settingsFormat = SettingsFormat)(T data, string name, string filename = settingsFilename, string subdir = "") {
110 	import std.conv : text;
111 	import std.exception : enforce;
112 	auto paths = getSettingsPaths(name, subdir, filename, true);
113 	enforce (!paths.empty, "No writable paths found");
114 	safeSave(paths.front.text, toString!settingsFormat(data));
115 }
116 ///
117 @safe unittest {
118 	struct Settings {
119 		bool blah;
120 		string text;
121 		string[] texts;
122 	}
123 	saveSettings(Settings(true, "some words", ["c", "b", "a"]), "testapp", "settings", "subdir");
124 
125 	assert(loadSettings!Settings("testapp", "settings", "subdir") == Settings(true, "some words", ["c", "b", "a"]));
126 }
127 /**
128  * Deletes settings files for the specified app that are handled by this
129  * library. Also removes directory if empty.
130  * Params:
131  * name = App name.
132  * filename = The settings file that will be deleted.
133  * subdir = Settings subdirectory to delete.
134  */
135 void deleteSettings(alias settingsFormat = SettingsFormat)(string name, string filename = settingsFilename, string subdir = "") {
136 	import std.conv : text;
137 	import std.file : exists, remove, dirEntries, SpanMode, rmdir;
138 	import std.path : dirName;
139 	foreach (path; getSettingsPaths(name, subdir, filename, true)) {
140 		if (path.exists) {
141 			remove(path);
142 			if (path.dirName.text.dirEntries(SpanMode.shallow).empty) {
143 				rmdir(path.dirName);
144 			}
145 		}
146 	}
147 }
148 ///
149 @system unittest {
150 	deleteSettings("testapp", "settings", "subdir");
151 	deleteSettings("testapp", "settings", "mysubdir");
152 	deleteSettings("testapp", "settings", "");
153 }
154 
155 private template SettingsExtensions(T) {
156 	import std.meta : AliasSeq;
157 	static if (is(T == YAML)) {
158 		alias SettingsExtensions = AliasSeq!(".yaml", ".yml");
159 	} else static if (is(T == JSON)) {
160 		alias SettingsExtensions = AliasSeq!(".json");
161 	}
162 }
163 
164 /** Safely save a text file. If something goes wrong while writing, the new
165  * file is simply discarded while the old one is untouched
166  */
167 void safeSave(string path, string data) @safe {
168 	import std.file : exists, remove, rename, write;
169 	import std.path : setExtension;
170 	const tmpFile = path.setExtension(".tmp");
171 	scope(exit) {
172 		if (tmpFile.exists) {
173 			remove(tmpFile);
174 		}
175 	}
176 	write(tmpFile, data);
177 	if (path.exists) {
178 		remove(path);
179 	}
180 	rename(tmpFile, path);
181 }
182 
183 @safe unittest {
184 	import std.exception : assertThrown;
185 	import std.file : exists, getAttributes, mkdir, remove, rmdir, setAttributes;
186 	import std.path : buildPath;
187 	enum testdir = "test";
188 	enum testFile = buildPath(testdir, "test.txt");
189 	mkdir(testdir);
190 	scope(exit) {
191 		rmdir(testdir);
192 	}
193 	safeSave(testFile, "");
194 	version(Windows) {
195 		import core.sys.windows.winnt : FILE_ATTRIBUTE_READONLY;
196 		enum readOnly = FILE_ATTRIBUTE_READONLY;
197 		enum attributesTarget = testFile;
198 	} else version(Posix) {
199 		enum readOnly = 555;
200 		enum attributesTarget = "test";
201 	}
202 	const oldAttributes = getAttributes(attributesTarget);
203 	setAttributes(attributesTarget, readOnly);
204 	scope(exit) {
205 		setAttributes(attributesTarget, oldAttributes);
206 		remove(testFile);
207 	}
208 	assertThrown(safeSave(testFile, ""));
209 	assert(!(testFile~".tmp").exists);
210 }