1use std::path::PathBuf;
2
3pub fn data_dir() -> PathBuf {
4 let base = dirs::data_dir().unwrap_or_else(|| PathBuf::from("."));
5 base.join("huddle")
6}
7
8pub fn config_path() -> PathBuf {
15 let base = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
16 base.join("huddle").join("config.toml")
17}
18
19pub fn load_relays() -> Option<Vec<String>> {
39 let path = config_path();
40 let body = std::fs::read_to_string(&path).ok()?;
41 Some(parse_relays(&body))
42}
43
44fn parse_relays(body: &str) -> Vec<String> {
47 let mut out: Vec<String> = Vec::new();
48 let mut in_array = false;
49 for raw in body.lines() {
50 let line = strip_inline_comment(raw).trim();
51 if line.is_empty() {
52 continue;
53 }
54 if in_array {
55 let (segment, closed) = match line.find(']') {
58 Some(idx) => (&line[..idx], true),
59 None => (line, false),
60 };
61 collect_relay_items(segment, &mut out);
62 if closed {
63 in_array = false;
64 }
65 continue;
66 }
67 let rest = match line.strip_prefix("relays") {
72 Some(r) => r.trim_start(),
73 None => continue,
74 };
75 let rest = match rest.strip_prefix('=') {
76 Some(r) => r.trim(),
77 None => continue, };
79 match rest.strip_prefix('[') {
80 Some(after_open) => match after_open.find(']') {
82 Some(idx) => collect_relay_items(&after_open[..idx], &mut out),
83 None => {
84 collect_relay_items(after_open, &mut out);
85 in_array = true;
86 }
87 },
88 None => {
90 let item = rest.trim_matches('"').trim_matches('\'');
91 if !item.is_empty() {
92 out.push(item.to_string());
93 }
94 }
95 }
96 }
97 out
98}
99
100fn strip_inline_comment(line: &str) -> &str {
104 match line.find('#') {
105 Some(idx) => &line[..idx],
106 None => line,
107 }
108}
109
110fn collect_relay_items(segment: &str, out: &mut Vec<String>) {
113 for item in segment.split(',') {
114 let item = item.trim().trim_matches('"').trim_matches('\'');
115 if !item.is_empty() {
116 out.push(item.to_string());
117 }
118 }
119}
120
121pub fn server_url() -> Option<String> {
133 parse_scalar(&std::fs::read_to_string(config_path()).ok()?, "server_url")
134}
135
136pub fn tor_socks() -> Option<String> {
141 parse_scalar(&std::fs::read_to_string(config_path()).ok()?, "tor_socks")
142}
143
144pub fn clearnet_url() -> Option<String> {
154 parse_scalar(&std::fs::read_to_string(config_path()).ok()?, "clearnet_url")
155}
156
157pub fn tor_bridge() -> Option<String> {
166 parse_scalar(&std::fs::read_to_string(config_path()).ok()?, "tor_bridge")
167}
168
169fn parse_scalar(body: &str, key: &str) -> Option<String> {
174 for raw in body.lines() {
175 let line = strip_inline_comment(raw).trim();
176 let rest = match line.strip_prefix(key) {
177 Some(r) => r.trim_start(),
178 None => continue,
179 };
180 let rest = match rest.strip_prefix('=') {
183 Some(r) => r.trim(),
184 None => continue,
185 };
186 let val = rest.trim_matches('"').trim_matches('\'').trim();
187 if !val.is_empty() {
188 return Some(val.to_string());
189 }
190 }
191 None
192}
193
194pub fn db_path() -> PathBuf {
195 data_dir().join("huddle.db")
196}
197
198pub fn identity_key_path() -> PathBuf {
199 data_dir().join("identity.key")
200}
201
202pub fn log_path() -> PathBuf {
203 data_dir().join("huddle.log")
204}
205
206pub fn ensure_data_dir() -> std::io::Result<()> {
207 std::fs::create_dir_all(data_dir())
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn data_dir_is_inside_huddle_directory() {
216 let dir = data_dir();
217 assert!(dir.ends_with("huddle") || dir.to_string_lossy().contains("huddle"));
218 }
219
220 #[test]
221 fn db_path_ends_with_huddle_db() {
222 let path = db_path();
223 assert_eq!(path.file_name().unwrap(), "huddle.db");
224 }
225
226 #[test]
227 fn identity_path_ends_with_identity_key() {
228 let path = identity_key_path();
229 assert_eq!(path.file_name().unwrap(), "identity.key");
230 }
231
232 #[test]
236 fn parse_relays_documented_multiline_no_header() {
237 let body = "relays = [\n \"/dns4/relay.example.com/tcp/4001/p2p/12D3Koo\",\n]\n";
238 assert_eq!(
239 parse_relays(body),
240 vec!["/dns4/relay.example.com/tcp/4001/p2p/12D3Koo".to_string()]
241 );
242 }
243
244 #[test]
245 fn parse_relays_multiline_with_network_header() {
246 let body = "[network]\nrelays = [\n \"/ip4/1.2.3.4/tcp/4001/p2p/A\",\n \"/ip4/5.6.7.8/tcp/4001/p2p/B\",\n]\n";
247 assert_eq!(
248 parse_relays(body),
249 vec![
250 "/ip4/1.2.3.4/tcp/4001/p2p/A".to_string(),
251 "/ip4/5.6.7.8/tcp/4001/p2p/B".to_string(),
252 ]
253 );
254 }
255
256 #[test]
257 fn parse_relays_single_line_array() {
258 let body = "relays = [\"/ip4/1.2.3.4/tcp/1/p2p/A\", \"/ip4/5.6.7.8/tcp/2/p2p/B\"]";
259 assert_eq!(parse_relays(body).len(), 2);
260 }
261
262 #[test]
263 fn parse_relays_scalar_form() {
264 let body = "relays = \"/ip4/1.2.3.4/tcp/1/p2p/A\"";
265 assert_eq!(
266 parse_relays(body),
267 vec!["/ip4/1.2.3.4/tcp/1/p2p/A".to_string()]
268 );
269 }
270
271 #[test]
272 fn parse_relays_strips_comments_and_blanks() {
273 let body = "# a comment\n\nrelays = [\n \"/ip4/1.2.3.4/tcp/1/p2p/A\", # inline note\n]\n";
274 assert_eq!(
275 parse_relays(body),
276 vec!["/ip4/1.2.3.4/tcp/1/p2p/A".to_string()]
277 );
278 }
279
280 #[test]
281 fn parse_relays_empty_when_absent() {
282 assert!(parse_relays("[network]\nfoo = 1\n").is_empty());
283 assert!(parse_relays("").is_empty());
284 }
285
286 #[test]
287 fn parse_relays_ignores_similar_key() {
288 assert!(parse_relays("relays_enabled = true\n").is_empty());
290 }
291
292 #[test]
294 fn parse_scalar_reads_quoted_value() {
295 let body = "server_url = \"ws://abc.onion:80/ws\"\n";
296 assert_eq!(
297 parse_scalar(body, "server_url").as_deref(),
298 Some("ws://abc.onion:80/ws")
299 );
300 }
301
302 #[test]
303 fn parse_scalar_strips_comment_and_header() {
304 let body = "[network]\ntor_socks = \"127.0.0.1:9150\" # tor browser\n";
305 assert_eq!(
306 parse_scalar(body, "tor_socks").as_deref(),
307 Some("127.0.0.1:9150")
308 );
309 }
310
311 #[test]
312 fn parse_scalar_none_when_absent_or_similar_key() {
313 assert!(parse_scalar("foo = 1\n", "server_url").is_none());
314 assert!(parse_scalar("server_url_backup = \"x\"\n", "server_url").is_none());
316 }
317}