rvlib/
cfg.rs

1use crate::{
2    cache::FileCacheCfgArgs,
3    file_util::{self, path_to_str, DEFAULT_PRJ_PATH, DEFAULT_TMPDIR},
4    result::trace_ok_err,
5    sort_params::SortParams,
6    ssh,
7};
8use rvimage_domain::{rverr, to_rv, RvResult};
9use serde::{de::DeserializeOwned, Deserialize, Serialize};
10use std::{
11    fmt::Debug,
12    fs,
13    path::{Path, PathBuf},
14};
15use tracing::{info, warn};
16
17#[cfg(feature = "azure_blob")]
18#[derive(Deserialize, Serialize, Debug, Default, Clone, PartialEq, Eq)]
19pub struct AzureBlobCfgLegacy {
20    pub connection_string_path: String,
21    pub container_name: String,
22    pub prefix: String,
23}
24#[derive(Deserialize, Serialize, Debug, Default, Clone, PartialEq, Eq)]
25pub struct SshCfgLegacy {
26    pub user: String,
27    pub ssh_identity_file_path: String,
28    n_reconnection_attempts: Option<usize>,
29    pub remote_folder_paths: Vec<String>,
30    pub address: String,
31}
32#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Default)]
33pub struct CfgLegacy {
34    pub connection: Connection,
35    pub cache: Cache,
36    http_address: Option<String>,
37    tmpdir: Option<String>,
38    current_prj_path: Option<PathBuf>,
39    pub file_cache_args: Option<FileCacheCfgArgs>,
40    pub ssh_cfg: SshCfgLegacy,
41    pub home_folder: Option<String>,
42    pub py_http_reader_cfg: Option<PyHttpReaderCfg>,
43    pub darkmode: Option<bool>,
44    pub n_autosaves: Option<u8>,
45    pub import_old_path: Option<String>,
46    pub import_new_path: Option<String>,
47    #[cfg(feature = "azure_blob")]
48    pub azure_blob_cfg: Option<AzureBlobCfgLegacy>,
49}
50impl CfgLegacy {
51    pub fn to_cfg(self) -> Cfg {
52        let usr = CfgUsr {
53            darkmode: self.darkmode,
54            n_autosaves: self.n_autosaves,
55            home_folder: self.home_folder,
56            cache: self.cache,
57            tmpdir: self.tmpdir,
58            current_prj_path: self.current_prj_path,
59            file_cache_args: self.file_cache_args.unwrap_or_default(),
60            image_change_delay_on_held_key_ms: get_image_change_delay_on_held_key_ms(),
61
62            ssh: SshCfgUsr {
63                user: self.ssh_cfg.user,
64                ssh_identity_file_path: self.ssh_cfg.ssh_identity_file_path,
65                n_reconnection_attempts: self.ssh_cfg.n_reconnection_attempts,
66            },
67        };
68        let prj = CfgPrj {
69            connection: self.connection,
70            http_address: self.http_address,
71            py_http_reader_cfg: self.py_http_reader_cfg,
72            ssh: SshCfgPrj {
73                remote_folder_paths: self.ssh_cfg.remote_folder_paths,
74                address: self.ssh_cfg.address,
75            },
76            azure_blob: self.azure_blob_cfg.map(|ab| AzureBlobCfgPrj {
77                connection_string_path: ab.connection_string_path,
78                container_name: ab.container_name,
79                prefix: ab.prefix,
80            }),
81            sort_params: SortParams::default(),
82        };
83        Cfg { usr, prj }
84    }
85}
86
87pub fn get_cfg_path_legacy(homefolder: &Path) -> PathBuf {
88    homefolder.join("rv_cfg.toml")
89}
90
91pub fn get_cfg_path_usr(homefolder: &Path) -> PathBuf {
92    homefolder.join("rv_cfg_usr.toml")
93}
94
95pub fn get_cfg_path_prj(homefolder: &Path) -> PathBuf {
96    homefolder.join("rv_cfg_prjtmp.toml")
97}
98
99pub fn get_cfg_tmppath(cfg: &Cfg) -> PathBuf {
100    Path::new(cfg.tmpdir())
101        .join(".rvimage")
102        .join("rv_cfg_tmp.toml")
103}
104
105pub fn get_log_folder(homefolder: &Path) -> PathBuf {
106    homefolder.join("logs")
107}
108
109fn parse_toml_str<CFG: Debug + DeserializeOwned + Default>(toml_str: &str) -> RvResult<CFG> {
110    match toml::from_str(toml_str) {
111        Ok(cfg) => Ok(cfg),
112        Err(_) => {
113            // lets try replacing \ by / and see if we can parse it
114            let toml_str = toml_str.replace('\\', "/");
115            match toml::from_str(&toml_str) {
116                Ok(cfg) => Ok(cfg),
117                Err(_) => {
118                    // lets try replacing " by ' and see if we can parse it
119                    let toml_str = toml_str.replace('"', "'");
120                    toml::from_str(&toml_str)
121                        .map_err(|e| rverr!("failed to parse cfg due to {e:?}"))
122                }
123            }
124        }
125    }
126}
127
128pub fn read_cfg_gen<CFG: Debug + DeserializeOwned + Default>(
129    cfg_toml_path: &Path,
130) -> RvResult<CFG> {
131    if cfg_toml_path.exists() {
132        let toml_str = file_util::read_to_string(cfg_toml_path)?;
133        parse_toml_str(&toml_str)
134    } else {
135        warn!("cfg {cfg_toml_path:?} file does not exist. using default cfg");
136        Ok(CFG::default())
137    }
138}
139
140fn read_cfg_from_paths(
141    cfg_toml_path_usr: &Path,
142    cfg_toml_path_prj: &Path,
143    cfg_toml_path_legacy: &Path,
144) -> RvResult<Cfg> {
145    if cfg_toml_path_usr.exists() || cfg_toml_path_prj.exists() {
146        let usr = read_cfg_gen::<CfgUsr>(cfg_toml_path_usr)?;
147        let prj = read_cfg_gen::<CfgPrj>(cfg_toml_path_prj)?;
148        Ok(Cfg { usr, prj })
149    } else if cfg_toml_path_legacy.exists() {
150        tracing::warn!("using legacy cfg file {cfg_toml_path_legacy:?}");
151        let legacy = read_cfg_gen::<CfgLegacy>(cfg_toml_path_legacy)?;
152        Ok(legacy.to_cfg())
153    } else {
154        tracing::info!("no cfg file found. using default cfg");
155        Ok(Cfg::default())
156    }
157}
158
159pub fn write_cfg_str(cfg_str: &str, p: &Path, log: bool) -> RvResult<()> {
160    file_util::write(p, cfg_str)?;
161    if log {
162        info!("wrote cfg to {p:?}");
163    }
164    Ok(())
165}
166
167#[derive(Deserialize, Serialize, Debug, Eq, PartialEq, Clone, Copy, Default)]
168pub enum Connection {
169    Ssh,
170    PyHttp,
171    #[cfg(feature = "azure_blob")]
172    AzureBlob,
173    #[default]
174    Local,
175}
176#[derive(Deserialize, Serialize, Debug, Eq, PartialEq, Clone, Default)]
177pub enum Cache {
178    #[default]
179    FileCache,
180    NoCache,
181}
182#[derive(Deserialize, Serialize, Debug, Default, Clone, PartialEq, Eq)]
183pub struct SshCfgUsr {
184    pub user: String,
185    pub ssh_identity_file_path: String,
186    n_reconnection_attempts: Option<usize>,
187}
188#[derive(Deserialize, Serialize, Debug, Default, Clone, PartialEq, Eq)]
189pub struct SshCfgPrj {
190    pub remote_folder_paths: Vec<String>,
191    pub address: String,
192}
193#[derive(Deserialize, Serialize, Debug, Default, Clone, PartialEq, Eq)]
194pub struct SshCfg {
195    pub usr: SshCfgUsr,
196    pub prj: SshCfgPrj,
197}
198impl SshCfg {
199    pub fn n_reconnection_attempts(&self) -> usize {
200        let default = 5;
201        self.usr.n_reconnection_attempts.unwrap_or(default)
202    }
203}
204
205#[cfg(feature = "azure_blob")]
206#[derive(Deserialize, Serialize, Debug, Default, Clone, PartialEq, Eq)]
207pub struct AzureBlobCfgPrj {
208    #[serde(default)]
209    pub connection_string_path: String,
210    pub container_name: String,
211    pub prefix: String,
212}
213
214#[cfg(feature = "azure_blob")]
215#[derive(Deserialize, Serialize, Debug, Default, Clone, PartialEq, Eq)]
216pub struct AzureBlobCfg {
217    pub prj: AzureBlobCfgPrj,
218}
219
220#[derive(Deserialize, Serialize, Debug, Default, Clone, PartialEq, Eq)]
221pub struct PyHttpReaderCfg {
222    pub server_addresses: Vec<String>,
223}
224
225#[derive(Deserialize, Serialize, Default, Clone, Debug, PartialEq, Eq)]
226pub enum ExportPathConnection {
227    Ssh,
228    #[default]
229    Local,
230}
231impl ExportPathConnection {
232    pub fn write_bytes(
233        &self,
234        data: &[u8],
235        dst_path: &Path,
236        ssh_cfg: Option<&SshCfg>,
237    ) -> RvResult<()> {
238        match (self, ssh_cfg) {
239            (ExportPathConnection::Ssh, Some(ssh_cfg)) => {
240                let sess = ssh::auth(ssh_cfg)?;
241                ssh::write_bytes(data, dst_path, &sess).map_err(to_rv)?;
242                Ok(())
243            }
244            (ExportPathConnection::Local, _) => {
245                file_util::write(dst_path, data)?;
246                Ok(())
247            }
248            (ExportPathConnection::Ssh, None) => Err(rverr!("cannot save to ssh. config missing")),
249        }
250    }
251    pub fn write(&self, data_str: &str, dst_path: &Path, ssh_cfg: Option<&SshCfg>) -> RvResult<()> {
252        self.write_bytes(data_str.as_bytes(), dst_path, ssh_cfg)
253    }
254    pub fn read(&self, src_path: &Path, ssh_cfg: Option<&SshCfg>) -> RvResult<String> {
255        match (self, ssh_cfg) {
256            (ExportPathConnection::Ssh, Some(ssh_cfg)) => {
257                let sess = ssh::auth(ssh_cfg)?;
258                let read_bytes = ssh::download(path_to_str(src_path)?, &sess)?;
259                String::from_utf8(read_bytes).map_err(to_rv)
260            }
261            (ExportPathConnection::Local, _) => file_util::read_to_string(src_path),
262            (ExportPathConnection::Ssh, None) => {
263                Err(rverr!("cannot read from ssh. config missing"))
264            }
265        }
266    }
267}
268#[derive(Deserialize, Serialize, Default, Clone, Debug, PartialEq, Eq)]
269pub struct ExportPath {
270    pub path: PathBuf,
271    pub conn: ExportPathConnection,
272}
273
274pub enum Style {
275    Dark,
276    Light,
277}
278
279fn get_default_n_autosaves() -> Option<u8> {
280    Some(2)
281}
282
283fn get_image_change_delay_on_held_key_ms() -> u64 {
284    300
285}
286
287#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Default)]
288pub struct CfgUsr {
289    pub darkmode: Option<bool>,
290    #[serde(default = "get_default_n_autosaves")]
291    pub n_autosaves: Option<u8>,
292
293    #[serde(default = "get_image_change_delay_on_held_key_ms")]
294    pub image_change_delay_on_held_key_ms: u64,
295
296    // This is only variable to make the CLI and tests not override your config.
297    // You shall not change this when actually running RV Image.
298    pub home_folder: Option<String>,
299
300    pub cache: Cache,
301    tmpdir: Option<String>,
302    current_prj_path: Option<PathBuf>,
303    #[serde(default)]
304    pub file_cache_args: FileCacheCfgArgs,
305    pub ssh: SshCfgUsr,
306}
307#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Default)]
308pub struct CfgPrj {
309    pub py_http_reader_cfg: Option<PyHttpReaderCfg>,
310    pub connection: Connection,
311    http_address: Option<String>,
312    pub ssh: SshCfgPrj,
313    #[cfg(feature = "azure_blob")]
314    pub azure_blob: Option<AzureBlobCfgPrj>,
315    #[serde(default)]
316    pub sort_params: SortParams,
317}
318#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
319pub struct Cfg {
320    pub usr: CfgUsr,
321    pub prj: CfgPrj,
322}
323
324impl Cfg {
325    /// for multiple cli instances to run in parallel
326    pub fn with_unique_folders() -> Self {
327        let mut cfg = Self::default();
328        let uuid_str = format!("{}", uuid::Uuid::new_v4());
329        let tmpdir_str = DEFAULT_TMPDIR
330            .to_str()
331            .expect("default tmpdir does not exist. cannot work without")
332            .to_string();
333        cfg.usr.tmpdir = Some(format!("{tmpdir_str}/rvimage_tmp_{uuid_str}"));
334        let tmp_homedir = format!("{tmpdir_str}/rvimage_home_{uuid_str}");
335
336        // copy user cfg to tmp homedir
337        trace_ok_err(fs::create_dir_all(&tmp_homedir));
338        if let Some(home_folder) = &cfg.usr.home_folder {
339            let usrcfg_path = get_cfg_path_usr(Path::new(home_folder));
340            if usrcfg_path.exists() {
341                if let Some(filename) = usrcfg_path.file_name() {
342                    trace_ok_err(fs::copy(
343                        &usrcfg_path,
344                        Path::new(&tmp_homedir).join(filename),
345                    ));
346                }
347            }
348        }
349        cfg.usr.home_folder = Some(tmp_homedir);
350        cfg
351    }
352    pub fn ssh_cfg(&self) -> SshCfg {
353        SshCfg {
354            usr: self.usr.ssh.clone(),
355            prj: self.prj.ssh.clone(),
356        }
357    }
358    #[cfg(feature = "azure_blob")]
359    pub fn azure_blob_cfg(&self) -> Option<AzureBlobCfg> {
360        self.prj
361            .azure_blob
362            .as_ref()
363            .map(|prj| AzureBlobCfg { prj: prj.clone() })
364    }
365    pub fn home_folder(&self) -> &str {
366        let ef = self.usr.home_folder.as_deref();
367        match ef {
368            None => file_util::get_default_homedir(),
369            Some(ef) => ef,
370        }
371    }
372
373    pub fn tmpdir(&self) -> &str {
374        match &self.usr.tmpdir {
375            Some(td) => td.as_str(),
376            None => DEFAULT_TMPDIR.to_str().unwrap(),
377        }
378    }
379
380    pub fn http_address(&self) -> &str {
381        match &self.prj.http_address {
382            Some(http_addr) => http_addr,
383            None => "127.0.0.1:5432",
384        }
385    }
386
387    pub fn current_prj_path(&self) -> &Path {
388        if let Some(pp) = &self.usr.current_prj_path {
389            pp
390        } else {
391            &DEFAULT_PRJ_PATH
392        }
393    }
394    pub fn set_current_prj_path(&mut self, pp: PathBuf) {
395        self.usr.current_prj_path = Some(pp);
396    }
397    pub fn unset_current_prj_path(&mut self) {
398        self.usr.current_prj_path = None;
399    }
400
401    pub fn write(&self) -> RvResult<()> {
402        let homefolder = Path::new(self.home_folder());
403        let cfg_usr_path = get_cfg_path_usr(homefolder);
404        if let Some(cfg_parent) = cfg_usr_path.parent() {
405            fs::create_dir_all(cfg_parent).map_err(to_rv)?;
406        }
407        let cfg_usr_str = toml::to_string_pretty(&self.usr).map_err(to_rv)?;
408        let log = true;
409        write_cfg_str(&cfg_usr_str, &cfg_usr_path, log).and_then(|_| {
410            let cfg_prj_path = get_cfg_path_prj(homefolder);
411            let cfg_prj_str = toml::to_string_pretty(&self.prj).map_err(to_rv)?;
412            write_cfg_str(&cfg_prj_str, &cfg_prj_path, log)
413        })
414    }
415    pub fn read(homefolder: &Path) -> RvResult<Self> {
416        let cfg_toml_path_usr = get_cfg_path_usr(homefolder);
417        let cfg_toml_path_prj = get_cfg_path_prj(homefolder);
418        let cfg_toml_path_legacy = get_cfg_path_legacy(homefolder);
419        read_cfg_from_paths(
420            &cfg_toml_path_usr,
421            &cfg_toml_path_prj,
422            &cfg_toml_path_legacy,
423        )
424    }
425}
426impl Default for Cfg {
427    fn default() -> Self {
428        let usr = CfgUsr::default();
429        let prj = CfgPrj::default();
430        let mut cfg = Cfg { usr, prj };
431        cfg.usr.current_prj_path = Some(DEFAULT_PRJ_PATH.to_path_buf());
432        cfg
433    }
434}
435#[cfg(test)]
436use file_util::get_default_homedir;
437
438#[test]
439fn test_default_cfg_paths() {
440    get_default_homedir();
441    DEFAULT_PRJ_PATH.to_str().unwrap();
442    DEFAULT_TMPDIR.to_str().unwrap();
443}
444
445#[test]
446fn test_read_cfg_legacy() {
447    let test_folder = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test_data");
448    let cfg_toml_path_usr = test_folder.join("rv_cfg_usr_doesntexist.toml");
449    let cfg_toml_path_prj = test_folder.join("rv_cfg_prj_doesntexist.toml");
450    let cfg_toml_path_legacy = test_folder.join("rv_cfg_legacy.toml");
451    let cfg = read_cfg_from_paths(
452        &cfg_toml_path_usr,
453        &cfg_toml_path_prj,
454        &cfg_toml_path_legacy,
455    )
456    .unwrap();
457    assert_eq!(
458        cfg.usr.current_prj_path,
459        Some(PathBuf::from("/Users/ultrauser/Desktop/ultra.json"))
460    );
461    assert_eq!(cfg.usr.darkmode, Some(true));
462    assert_eq!(cfg.usr.ssh.user, "someuser");
463    assert_eq!(cfg.prj.ssh.address, "73.42.73.42")
464}
465
466#[cfg(test)]
467fn make_cfg_str(ssh_identity_filepath: &str) -> String {
468    let part1 = r#"
469[usr]
470n_autosaves = 10
471image_change_delay_on_held_key_ms = 10
472cache = "FileCache"
473current_prj_path = "someprjpath.json"
474
475[usr.file_cache_args]
476n_prev_images = 4
477n_next_images = 8
478n_threads = 2
479clear_on_close = true
480cachedir = "C:/Users/ShafeiB/.rvimage/cache"
481
482[usr.ssh]
483user = "auser"
484ssh_identity_file_path ="#;
485
486    let part2 = r#"
487[prj]
488connection = "Local"
489
490[prj.py_http_reader_cfg]
491server_addresses = [
492    "http://localhost:8000/somewhere",
493    "http://localhost:8000/elsewhere",
494]
495
496[prj.ssh]
497remote_folder_paths = ["/"]
498address = "12.11.10.13:22"
499
500[prj.sort_params]
501kind = "Natural"
502sort_by_filename = false
503"#;
504
505    format!("{part1} {ssh_identity_filepath} {part2}")
506}
507
508#[test]
509fn test_parse_toml() {
510    fn test(ssh_path: &str, ssh_path_expected: &str) {
511        let toml_str = make_cfg_str(ssh_path);
512        let cfg: Cfg = parse_toml_str(&toml_str).unwrap();
513        assert_eq!(cfg.usr.ssh.ssh_identity_file_path, ssh_path_expected);
514    }
515    test("\"c:\\somehome\\.ssh\\id_rsa\"", "c:/somehome/.ssh/id_rsa");
516    test(
517        "'c:\\some home\\.ssh\\id_rsa'",
518        "c:\\some home\\.ssh\\id_rsa",
519    );
520    test("\"/s omehome\\.ssh\\id_rsa\"", "/s omehome/.ssh/id_rsa");
521    test("'/some home/.ssh/id_rsa'", "/some home/.ssh/id_rsa");
522}