Skip to main content

oboron_cli_core/
config.rs

1//! `config.json` — global config: active profile + per-binary defaults.
2//!
3//! Different binaries care about different fields:
4//!
5//! - `obc` (obcrypt-cli): `profile`, `scheme`.
6//! - `ob`  (oboron-cli):  `profile`, `scheme`, `encoding`.
7//!
8//! [`Config`] surfaces all known fields as `Option`. When writing, we
9//! read the existing file as a `serde_json::Value`, overwrite the
10//! fields the caller is updating, and write the rest back unchanged —
11//! so one binary's update doesn't clobber the other's settings.
12
13use anyhow::{anyhow, bail, Context, Result};
14use serde_json::Value;
15use std::fs;
16use std::path::PathBuf;
17
18use crate::paths::config_path;
19
20#[derive(Debug, Default, Clone, PartialEq, Eq)]
21pub struct Config {
22    /// Active profile name (used by both `ob` and `obc`).
23    pub profile: Option<String>,
24    /// Default scheme (used by both).
25    pub scheme: Option<String>,
26    /// Default encoding (oboron-only; obcrypt has no encoding layer).
27    pub encoding: Option<String>,
28}
29
30/// Load `config.json`. Returns `Ok(None)` if the file doesn't exist.
31pub fn load_config() -> Result<Option<Config>> {
32    let path = config_path()?;
33    if !path.exists() {
34        return Ok(None);
35    }
36    let v = read_json(&path)?;
37    let obj = v
38        .as_object()
39        .ok_or_else(|| anyhow!("{} is not a JSON object", path.display()))?;
40    Ok(Some(Config {
41        profile: obj.get("profile").and_then(Value::as_str).map(str::to_string),
42        scheme: obj.get("scheme").and_then(Value::as_str).map(str::to_string),
43        encoding: obj.get("encoding").and_then(Value::as_str).map(str::to_string),
44    }))
45}
46
47/// Update `config.json`, preserving any unknown JSON fields.
48pub fn save_config(cfg: &Config) -> Result<()> {
49    let path = config_path()?;
50    let mut v = read_json_or_empty(&path)?;
51    let obj = v
52        .as_object_mut()
53        .ok_or_else(|| anyhow!("{} is not a JSON object", path.display()))?;
54    if let Some(p) = &cfg.profile {
55        obj.insert("profile".into(), Value::String(p.clone()));
56    }
57    if let Some(s) = &cfg.scheme {
58        obj.insert("scheme".into(), Value::String(s.clone()));
59    }
60    if let Some(e) = &cfg.encoding {
61        obj.insert("encoding".into(), Value::String(e.clone()));
62    }
63    write_json(&path, &v)
64}
65
66// ---------------------------------------------------------------------------
67// JSON I/O helpers (also re-used by `profile.rs`)
68// ---------------------------------------------------------------------------
69
70pub(crate) fn read_json(path: &PathBuf) -> Result<Value> {
71    let body = fs::read_to_string(path)
72        .with_context(|| format!("read {}", path.display()))?;
73    let v: Value = serde_json::from_str(&body)
74        .with_context(|| format!("parse {}", path.display()))?;
75    if !v.is_object() {
76        bail!("{} is not a JSON object", path.display());
77    }
78    Ok(v)
79}
80
81pub(crate) fn read_json_or_empty(path: &PathBuf) -> Result<Value> {
82    if !path.exists() {
83        return Ok(Value::Object(serde_json::Map::new()));
84    }
85    read_json(path)
86}
87
88pub(crate) fn write_json(path: &PathBuf, value: &Value) -> Result<()> {
89    if let Some(parent) = path.parent() {
90        fs::create_dir_all(parent).context("create parent directory")?;
91    }
92    let pretty = serde_json::to_string_pretty(value).context("serialize JSON")?;
93    fs::write(path, pretty).with_context(|| format!("write {}", path.display()))?;
94    #[cfg(unix)]
95    {
96        use std::os::unix::fs::PermissionsExt;
97        let mut perms = fs::metadata(path)?.permissions();
98        perms.set_mode(0o600);
99        fs::set_permissions(path, perms)?;
100    }
101    Ok(())
102}