1use std::fs::File;
4use std::io::BufReader;
5use std::ops::Deref;
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result};
9
10use crate::config::{cfg_dir, DEFAULT_CTX_DIR_NAME};
11
12use super::{ContextManager, WashContext, HOST_CONFIG_NAME};
13
14const DEFAULT: &str = "default";
15
16pub struct ContextDir(PathBuf);
18
19impl AsRef<Path> for ContextDir {
20 fn as_ref(&self) -> &Path {
21 &self.0
22 }
23}
24
25impl Deref for ContextDir {
26 type Target = Path;
27
28 fn deref(&self) -> &Self::Target {
29 &self.0
30 }
31}
32
33impl ContextDir {
34 pub fn new() -> Result<ContextDir> {
36 Self::from_dir(None::<&Path>)
37 }
38
39 pub fn from_dir(path: Option<impl AsRef<Path>>) -> Result<ContextDir> {
41 let path = if let Some(path) = path {
42 path.as_ref().to_path_buf()
43 } else {
44 default_context_dir()?
45 };
46
47 let exists = path.exists();
48 if exists && !path.is_dir() {
49 anyhow::bail!(
50 "{} is not a directory (or cannot be accessed)",
51 path.display()
52 )
53 } else if !exists {
54 std::fs::create_dir_all(&path).context("failed to create context directory")?;
55 }
56
57 let context_dir = path
59 .canonicalize()
60 .context("failed to canonicalize context directory path")?;
61
62 let default_path = context_dir.join(DEFAULT);
64 if !default_path.exists() {
65 initialize_context_dir(&context_dir, &default_path)?;
66 }
67
68 Ok(ContextDir(context_dir))
69 }
70
71 pub fn list_context_paths(&self) -> Result<Vec<PathBuf>> {
73 let entries = std::fs::read_dir(&self.0)?;
74
75 let paths = entries
76 .filter_map(|entry| entry.ok().map(|e| e.path()))
77 .filter(|path| {
78 path.extension()
79 .and_then(|os| os.to_str())
80 .unwrap_or_default()
81 == "json"
82 })
83 .filter(|path| {
85 path.file_stem()
86 .and_then(|os| os.to_str())
87 .unwrap_or_default()
88 != "index"
89 })
90 .collect();
91 Ok(paths)
92 }
93
94 pub fn get_context_path(&self, name: &str) -> Result<Option<PathBuf>> {
96 Ok(self
97 .list_context_paths()?
98 .into_iter()
99 .find(|p| p.file_stem().unwrap_or_default() == name))
100 }
101}
102
103fn default_context_dir() -> Result<PathBuf> {
104 Ok(cfg_dir()?.join(DEFAULT_CTX_DIR_NAME))
105}
106
107fn initialize_context_dir(context_dir: &Path, default_path: &PathBuf) -> Result<()> {
108 let mut default_context_name = HOST_CONFIG_NAME.to_string();
109
110 if let Ok(index_file) = File::open(context_dir.join("index.json")) {
112 #[derive(serde::Deserialize)]
113 struct DefaultContext {
114 name: String,
115 }
116
117 if let Ok(old_default_context) =
118 serde_json::from_reader::<_, DefaultContext>(BufReader::new(index_file))
119 {
120 default_context_name = old_default_context.name;
121 }
122 }
123 std::fs::write(default_path, default_context_name.as_bytes()).with_context(|| {
126 format!(
127 "failed to write default context to `{}`",
128 default_path.display(),
129 )
130 })?;
131
132 let host_config_path = context_dir.join(format!("{default_context_name}.json"));
133 if !host_config_path.exists() {
134 let host_config_context = WashContext::named(default_context_name);
135 std::fs::write(
136 &host_config_path,
137 serde_json::to_vec(&host_config_context)
138 .context("failed to serialize host_config context")?,
139 )
140 .with_context(|| {
141 format!(
142 "failed to write host_config context to `{}`",
143 host_config_path.display()
144 )
145 })?;
146 }
147
148 Ok(())
149}
150
151impl ContextManager for ContextDir {
152 fn default_context_name(&self) -> Result<String> {
154 let raw = std::fs::read(self.0.join(DEFAULT)).context("failed to read default context")?;
155 let name = std::str::from_utf8(&raw).context("failed to read default context")?;
156 Ok(name.to_string())
157 }
158
159 fn set_default_context(&self, name: &str) -> Result<()> {
161 self.load_context(name).context("context does not exist")?;
162
163 let default_path = self.0.join(DEFAULT);
164 std::fs::write(&default_path, name.as_bytes()).with_context(|| {
165 format!(
166 "failed to write default context to `{}`",
167 default_path.display()
168 )
169 })
170 }
171
172 fn save_context(&self, ctx: &WashContext) -> Result<()> {
174 let filepath = context_path_from_name(&self.0, &ctx.name);
175 std::fs::write(
176 &filepath,
177 serde_json::to_vec(&ctx).context("failed to serialize context")?,
178 )
179 .with_context(|| {
180 format!(
181 "failed to save context `{}` to `{}`",
182 ctx.name,
183 filepath.display()
184 )
185 })
186 }
187
188 fn delete_context(&self, name: &str) -> Result<()> {
189 let path = context_path_from_name(&self.0, name);
190 std::fs::remove_file(path).context("failed to remove context")?;
191 if self.default_context_name()? == name {
192 self.set_default_context(HOST_CONFIG_NAME)?; }
194 Ok(())
195 }
196
197 fn load_default_context(&self) -> Result<WashContext> {
199 self.load_context(&self.default_context_name()?)
200 }
201
202 fn load_context(&self, name: &str) -> Result<WashContext> {
204 let path = context_path_from_name(&self.0, name);
205 let file = std::fs::File::open(&path)
206 .with_context(|| format!("failed to open context file [{}]", path.display()))?;
207 let reader = BufReader::new(file);
208 serde_json::from_reader(reader).context("failed to parse context")
209 }
210
211 fn list_contexts(&self) -> Result<Vec<String>> {
212 Ok(self
213 .list_context_paths()?
214 .into_iter()
215 .filter_map(|p| {
216 p.file_stem()
217 .unwrap_or_default()
218 .to_os_string()
219 .into_string()
220 .ok()
221 })
222 .collect())
223 }
224}
225
226fn context_path_from_name(dir: impl AsRef<Path>, name: &str) -> PathBuf {
228 dir.as_ref().join(format!("{name}.json"))
229}
230
231#[cfg(test)]
232mod test {
233 use super::*;
234
235 #[test]
236 fn round_trip_happy_path() {
237 let tempdir = tempfile::tempdir().expect("Unable to create tempdir");
238 let contexts_path = tempdir.path().join("contexts");
239 let ctx_dir = ContextDir::from_dir(Some(&contexts_path))
240 .expect("Should be able to create context dir");
241
242 assert!(
243 contexts_path.exists() && contexts_path.is_dir(),
244 "Non-existent directory should have been created"
245 );
246
247 let mut orig_ctx = WashContext {
248 name: "happy_path".to_string(),
249 lattice: "foobar".to_string(),
250 ..Default::default()
251 };
252
253 ctx_dir
254 .save_context(&orig_ctx)
255 .expect("Should be able to save a context to disk");
256
257 let filenames: std::collections::HashSet<String> = contexts_path
258 .read_dir()
259 .unwrap()
260 .filter_map(|entry| entry.unwrap().file_name().clone().into_string().ok())
261 .collect();
262 let expected_filenames = std::collections::HashSet::from([
263 "default".to_string(),
264 "host_config.json".to_string(),
265 "happy_path.json".to_string(),
266 ]);
267
268 assert_eq!(
269 filenames, expected_filenames,
270 "Newly created context should exist"
271 );
272
273 let loaded = ctx_dir
275 .load_context("happy_path")
276 .expect("Should be able to load context from disk");
277 assert!(
278 orig_ctx.name == loaded.name && orig_ctx.lattice == loaded.lattice,
279 "Should have loaded the correct context from disk"
280 );
281
282 orig_ctx.name = "happy_gilmore".to_string();
284 orig_ctx.lattice = "baz".to_string();
285 ctx_dir
286 .save_context(&orig_ctx)
287 .expect("Should be able to save second context");
288
289 assert_eq!(
290 contexts_path.read_dir().unwrap().count(),
291 4,
292 "Directory should have 4 entries"
293 );
294
295 ctx_dir
296 .set_default_context("happy_gilmore")
297 .expect("Should be able to set default context");
298 assert_eq!(
299 ctx_dir
300 .default_context_name()
301 .expect("Should be able to load default context"),
302 "happy_gilmore",
303 "Default context should be correct"
304 );
305
306 let loaded = ctx_dir
308 .load_default_context()
309 .expect("Should be able to load default context from disk");
310 assert!(
311 orig_ctx.name == loaded.name && orig_ctx.lattice == loaded.lattice,
312 "Should have loaded the correct context from disk"
313 );
314
315 assert_eq!(
316 contexts_path.read_dir().unwrap().count(),
317 4,
318 "Directory should have a new entry from the default context"
319 );
320
321 assert!(
322 contexts_path.join("default").exists(),
323 "default file should exist in directory after setting default context"
324 );
325
326 let list = ctx_dir
328 .list_contexts()
329 .expect("Should be able to list contexts");
330 assert_eq!(list.len(), 3, "Should only list 3 contexts");
331 for ctx in list {
332 assert!(
333 ctx == "happy_path" || ctx == "happy_gilmore" || ctx == "host_config",
334 "Should have found only the contexts we created"
335 );
336 }
337
338 ctx_dir
339 .set_default_context("happy_path")
340 .expect("Should be able to set default context");
341
342 assert_eq!(
343 ctx_dir
344 .default_context_name()
345 .expect("Should be able to load default context"),
346 "happy_path",
347 "Default context should be correct"
348 );
349
350 ctx_dir
352 .delete_context("happy_path")
353 .expect("Should be able to delete context");
354
355 assert!(
356 !contexts_path.read_dir().unwrap().any(|p| p
357 .unwrap()
358 .path()
359 .as_os_str()
360 .to_str()
361 .unwrap()
362 .contains("happy_path")),
363 "Context should have been removed from directory"
364 );
365 }
366
367 #[test]
368 fn load_non_existent_contexts() {
369 let tempdir = tempfile::tempdir().expect("Unable to create tempdir");
370 let ctx_dir =
371 ContextDir::from_dir(Some(&tempdir)).expect("Should be able to create context dir");
372
373 ctx_dir
374 .load_default_context()
375 .expect("The default context should be automatically created");
376
377 ctx_dir
378 .load_context("idontexist")
379 .expect_err("Loading a non-existent context should error");
380 }
381
382 #[test]
383 fn default_context_with_no_settings() {
384 let tempdir = tempfile::tempdir().expect("Unable to create tempdir");
385 let ctx_dir =
386 ContextDir::from_dir(Some(&tempdir)).expect("Should be able to create context dir");
387
388 assert_eq!(
389 ctx_dir
390 .default_context_name()
391 .expect("Should be able to get a default context with nothing set"),
392 "host_config",
393 "Unset context should return none",
394 );
395
396 ctx_dir
397 .set_default_context("idontexist")
398 .expect_err("Should not be able to set a default context that doesn't exist");
399 }
400
401 const PRE_REFACTOR_CONTEXT: &str = r#"{"name":"host_config","cluster_seed":"SCAJ3HQZCDA562YW3VUHHIAUJ2SUCYUNGDCP5DBKQOTEZ6ZZGBKT5NI3DQ","ctl_host":"127.0.0.1","ctl_port":5893,"ctl_jwt":"","ctl_seed":"","ctl_credsfile":null,"ctl_timeout":2000,"ctl_lattice_prefix":"default","rpc_host":"127.0.0.1","rpc_port":5893,"rpc_jwt":"","rpc_seed":"","rpc_credsfile":null,"rpc_timeout":2000,"rpc_lattice_prefix":"default"}"#;
402
403 #[test]
404 fn works_with_existing() {
405 let tempdir = tempfile::tempdir().expect("Unable to create tempdir");
406 std::fs::write(
407 tempdir.path().join("host_config.json"),
408 PRE_REFACTOR_CONTEXT,
409 )
410 .expect("Unable to write test data to disk");
411 let ctx_dir =
412 ContextDir::from_dir(Some(&tempdir)).expect("Should be able to create context dir");
413
414 let ctx = ctx_dir
415 .load_context("host_config")
416 .expect("Should be able to load a pre-existing context");
417
418 assert!(
419 ctx.name == "host_config" && ctx.ctl_port == 5893,
420 "Should read the correct data from disk"
421 );
422 }
423
424 #[test]
425 fn delete_default_context() {
426 let tempdir = tempfile::tempdir().expect("Unable to create tempdir");
427 let ctx_dir =
428 ContextDir::from_dir(Some(&tempdir)).expect("Should be able to create context dir");
429
430 let mut ctx = WashContext {
431 name: "deleteme".to_string(),
432 ..Default::default()
433 };
434
435 ctx_dir
436 .save_context(&ctx)
437 .expect("Should be able to save a context to disk");
438 ctx.name = "keepme".to_string();
439 ctx_dir
440 .save_context(&ctx)
441 .expect("Should be able to save a context to disk");
442
443 ctx_dir
444 .set_default_context("deleteme")
445 .expect("Should be able to set default context");
446
447 assert_eq!(
448 tempdir.path().read_dir().unwrap().count(),
449 4,
450 "Directory should have 4 entries"
451 );
452
453 ctx_dir
454 .delete_context("deleteme")
455 .expect("Should be able to delete context");
456
457 assert_eq!(
458 tempdir.path().read_dir().unwrap().count(),
459 3,
460 "Directory should have 3 entries"
461 );
462
463 assert_eq!(
464 ctx_dir
465 .default_context_name()
466 .expect("Should be able to get default context"),
467 "host_config",
468 "default context should be reset"
469 );
470 }
471}