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