wash_lib/context/
fs.rs

1//! Implementations for managing contexts within a directory on a filesystem
2
3use 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
16/// A concrete type representing a path to a context directory
17pub 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    /// Creates and initializes a new `ContextDir` at ~/.wash/contexts
35    pub fn new() -> Result<ContextDir> {
36        Self::from_dir(None::<&Path>)
37    }
38
39    /// Creates and initializes a new [`ContextDir`] at the specified path. If a path is not provided, defaults to ~/.wash/contexts
40    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        // Make sure we have the fully qualified path at this point
58        let context_dir = path
59            .canonicalize()
60            .context("failed to canonicalize context directory path")?;
61
62        // Initialize the default context if it doesn't exist
63        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    /// Returns a list of paths to all contexts in the context directory
72    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 old index.json files. TODO: remove me after a few releases
84            .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    /// Returns the full path on disk for the named context
95    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    // TEMPORARY (TM): look for and parse existing index.json, to preserve backwards compatibility
111    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    // END TEMPORARY (TM)
124
125    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    /// Returns the name of the currently set default context
153    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    /// Sets the current default context to the given name
160    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    /// Saves the given context to the context directory. The file will be named `{ctx.name}.json`
173    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)?; // reset default
193        }
194        Ok(())
195    }
196
197    /// Loads the currently set default context
198    fn load_default_context(&self) -> Result<WashContext> {
199        self.load_context(&self.default_context_name()?)
200    }
201
202    /// Loads the named context from disk
203    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
226/// Helper function to properly format the path to a context JSON file
227fn 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        // Now load the context from disk and compare
274        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        // Save one more context
283        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        // Load the default context
307        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        // List the contexts
327        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        // Delete a context
351        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}