seaplane_cli/
fs.rs

1use std::{
2    fs, io,
3    path::{Path, PathBuf},
4};
5
6use directories::ProjectDirs;
7use serde::{de::DeserializeOwned, Serialize};
8use tempfile::NamedTempFile;
9
10use crate::{
11    cli::{CliCommand, SeaplaneInit},
12    context::Ctx,
13    error::{CliError, CliErrorKind, Context, Result},
14    printer::Color,
15};
16
17/// A utility function to get the correct "project" directories in a platform specific manner
18#[inline]
19fn project_dirs() -> Option<ProjectDirs> {
20    directories::ProjectDirs::from("io", "Seaplane", "seaplane")
21}
22
23/// Finds all appropriate configuration directories in a platform specific manner
24pub fn conf_dirs() -> Vec<PathBuf> {
25    let mut dirs = Vec::new();
26    if let Some(proj_dirs) = project_dirs() {
27        dirs.push(proj_dirs.config_dir().to_owned());
28    }
29    if let Some(base_dirs) = directories::BaseDirs::new() {
30        // On Linux ProjectDirs already adds ~/.config/seaplane, but not on macOS or Windows
31        if !cfg!(target_os = "linux") {
32            dirs.push(base_dirs.home_dir().join(".config/seaplane"));
33        }
34        dirs.push(base_dirs.home_dir().join(".seaplane"));
35    }
36    dirs
37}
38
39/// A utility function to get the correct data directory
40#[cfg(not(feature = "ui_tests"))]
41#[inline]
42pub fn data_dir() -> PathBuf {
43    project_dirs()
44        .expect("Failed to determine usable directories")
45        .data_dir()
46        .to_owned()
47}
48
49#[cfg(feature = "ui_tests")]
50#[cfg_attr(feature = "ui_tests", inline)]
51pub fn data_dir() -> PathBuf { std::env::current_dir().unwrap() }
52
53/// A struct that writes to a tempfile and persists to a given location atomically on Drop
54#[derive(Debug)]
55pub struct AtomicFile<'p> {
56    path: &'p Path,
57    temp_file: Option<NamedTempFile>,
58}
59
60impl<'p> AtomicFile<'p> {
61    /// Creates a new temporary file that will eventually be persisted to path `p`
62    pub fn new(p: &'p Path) -> Result<Self> {
63        Ok(Self { path: p, temp_file: Some(NamedTempFile::new()?) })
64    }
65
66    /// Gives a chance to persist the file and retrieve the error if any
67    #[allow(dead_code)]
68    pub fn persist(mut self) -> Result<()> {
69        let tf = self.temp_file.take().unwrap();
70        tf.persist(self.path).map(|_| ()).map_err(CliError::from)
71    }
72
73    /// Returns the `Path` of the underlying temporary file
74    pub fn temp_path(&self) -> &Path { self.temp_file.as_ref().unwrap().path() }
75}
76
77impl<'p> io::Write for AtomicFile<'p> {
78    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
79        if let Some(ref mut tf) = &mut self.temp_file {
80            return tf.write(buf);
81        }
82
83        Ok(0)
84    }
85
86    fn flush(&mut self) -> io::Result<()> {
87        if let Some(ref mut tf) = &mut self.temp_file {
88            return tf.flush();
89        }
90
91        Ok(())
92    }
93}
94
95impl<'p> Drop for AtomicFile<'p> {
96    fn drop(&mut self) {
97        // Swallow the error
98        let tf = self.temp_file.take().unwrap();
99        let _ = tf.persist(self.path);
100    }
101}
102
103// TODO: make the deserializer generic
104pub trait FromDisk {
105    /// Allows one to save or deserialize what path the item was loaded from
106    fn set_loaded_from<P: AsRef<Path>>(&mut self, _p: P) {}
107
108    /// If saved, get the path the item was loaded from
109    fn loaded_from(&self) -> Option<&Path> { None }
110
111    /// Only load from disk if `yes` is `true`, otherwise return `None`
112    fn load_if<P: AsRef<Path>>(p: P, yes: bool) -> Option<Result<Self>>
113    where
114        Self: Sized + DeserializeOwned,
115    {
116        if yes {
117            return Some(Self::load(p));
118        }
119        None
120    }
121
122    /// Deserialize from some given path
123    fn load<P: AsRef<Path>>(p: P) -> Result<Self>
124    where
125        Self: Sized + DeserializeOwned,
126    {
127        let path = p.as_ref();
128
129        let json_str = match fs::read_to_string(path) {
130            Ok(s) => s,
131            Err(e) => {
132                // If it's a file missing error we try to auto-initialize, then return the error if
133                // it happens again
134                if e.kind() == io::ErrorKind::NotFound {
135                    let mut ctx = Ctx::default();
136                    ctx.internal_run = true;
137                    SeaplaneInit.run(&mut ctx)?;
138
139                    fs::read_to_string(path)
140                        .map_err(CliError::from)
141                        .context("\n\tpath: ")
142                        .with_color_context(|| (Color::Yellow, format!("{path:?}")))?
143                } else {
144                    return Err(CliError::from(e));
145                }
146            }
147        };
148        let mut item: Self = serde_json::from_str(&json_str)
149            .map_err(CliError::from)
150            .context("\n\tpath: ")
151            .with_color_context(|| (Color::Yellow, format!("{path:?}")))?;
152
153        item.set_loaded_from(p);
154
155        Ok(item)
156    }
157}
158
159// TODO: make the serializer generic
160pub trait ToDisk: FromDisk {
161    /// Persist to path only if `yes` is `true`
162    fn persist_if(&self, yes: bool) -> Result<()>
163    where
164        Self: Sized + Serialize,
165    {
166        if yes {
167            return self.persist();
168        }
169        Ok(())
170    }
171
172    /// Serializes itself to the given path
173    fn persist(&self) -> Result<()>
174    where
175        Self: Sized + Serialize,
176    {
177        if let Some(path) = self.loaded_from() {
178            let file = AtomicFile::new(path)?;
179            // TODO: long term consider something like SQLite
180            Ok(serde_json::to_writer(file, self)
181                .map_err(CliError::from)
182                .context("\n\tpath: ")
183                .with_color_context(|| (Color::Yellow, format!("{path:?}")))?)
184        } else {
185            Err(CliErrorKind::MissingPath.into_err())
186        }
187    }
188}