Skip to main content

objectiveai_cli/filesystem/config/
client.rs

1use super::super::{Client, Error};
2
3impl Client {
4    pub async fn read_config(&self) -> Result<super::Config, Error> {
5        let path = self.config_path();
6        match tokio::fs::read(&path).await {
7            Ok(bytes) => serde_json::from_slice(&bytes)
8                .map_err(|e| Error::Parse(path, e)),
9            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
10                Ok(super::Config::default())
11            }
12            Err(e) => Err(Error::Read(path, e)),
13        }
14    }
15
16    pub async fn write_config(
17        &self,
18        config: &super::Config,
19    ) -> Result<(), Error> {
20        let path = self.config_path();
21        if let Some(parent) = path.parent() {
22            tokio::fs::create_dir_all(parent)
23                .await
24                .map_err(|e| Error::Write(parent.to_path_buf(), e))?;
25        }
26        let bytes =
27            serde_json::to_vec_pretty(config).map_err(Error::Serialize)?;
28        // Atomic replace: a cli killed mid-write must never leave a
29        // truncated config.json — that would brick every later
30        // command in this scope.
31        crate::filesystem::util::write_atomic(&path, &bytes)
32            .await
33            .map_err(|e| Error::Write(path, e))?;
34        Ok(())
35    }
36
37    /// Read the config file a MUTATION targets — no merging, ever:
38    /// `--global` edits `<dir>/bin/config.json`, `--state` edits
39    /// `<dir>/state/<state>/config.json`. Missing file = defaults.
40    pub async fn read_config_at(
41        &self,
42        scope: objectiveai_sdk::cli::command::SetScope,
43    ) -> Result<super::Config, Error> {
44        let path = match scope {
45            objectiveai_sdk::cli::command::SetScope::Global => self.global_config_path(),
46            objectiveai_sdk::cli::command::SetScope::State => self.config_path(),
47        };
48        read_config_file(path).await
49    }
50
51    /// Write back the file [`Self::read_config_at`] selected.
52    pub async fn write_config_at(
53        &self,
54        scope: objectiveai_sdk::cli::command::SetScope,
55        config: &super::Config,
56    ) -> Result<(), Error> {
57        let path = match scope {
58            objectiveai_sdk::cli::command::SetScope::Global => self.global_config_path(),
59            objectiveai_sdk::cli::command::SetScope::State => self.config_path(),
60        };
61        write_config_file(path, config).await
62    }
63
64    /// Read the config VIEW a `get` targets: `--global` and
65    /// `--state` return their file verbatim; `--final` joins them —
66    /// the global (root) config is the base and the per-state config
67    /// is layered on top, winning every conflict (recursively: maps
68    /// like `api.mcp_authorization` union per key with the per-state
69    /// entry overwriting the global one).
70    pub async fn read_config_view(
71        &self,
72        scope: objectiveai_sdk::cli::command::GetScope,
73    ) -> Result<super::Config, Error> {
74        match scope {
75            objectiveai_sdk::cli::command::GetScope::Global => {
76                read_config_file(self.global_config_path()).await
77            }
78            objectiveai_sdk::cli::command::GetScope::State => {
79                read_config_file(self.config_path()).await
80            }
81            objectiveai_sdk::cli::command::GetScope::Final => {
82                let global = read_config_file(self.global_config_path()).await?;
83                let state = read_config_file(self.config_path()).await?;
84                merge_final(global, state)
85            }
86        }
87    }
88}
89
90async fn read_config_file(
91    path: std::path::PathBuf,
92) -> Result<super::Config, Error> {
93    match tokio::fs::read(&path).await {
94        Ok(bytes) => {
95            serde_json::from_slice(&bytes).map_err(|e| Error::Parse(path, e))
96        }
97        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
98            Ok(super::Config::default())
99        }
100        Err(e) => Err(Error::Read(path, e)),
101    }
102}
103
104async fn write_config_file(
105    path: std::path::PathBuf,
106    config: &super::Config,
107) -> Result<(), Error> {
108    if let Some(parent) = path.parent() {
109        tokio::fs::create_dir_all(parent)
110            .await
111            .map_err(|e| Error::Write(parent.to_path_buf(), e))?;
112    }
113    let bytes = serde_json::to_vec_pretty(config).map_err(Error::Serialize)?;
114    // Atomic replace — see `write_config` for the rationale.
115    crate::filesystem::util::write_atomic(&path, &bytes)
116        .await
117        .map_err(|e| Error::Write(path, e))?;
118    Ok(())
119}
120
121/// The `--final` join: the global config is the base, the per-state
122/// config is layered on top and WINS every conflict. Objects merge
123/// recursively (so keyed maps like `api.mcp_authorization` union per
124/// key, state entry winning); any non-object state value replaces
125/// the global one outright. serde's omitempty means unset state
126/// fields simply don't appear, so they never clobber global values.
127/// Performed at the JSON level so it tracks the config shape
128/// automatically.
129fn merge_final(
130    global: super::Config,
131    state: super::Config,
132) -> Result<super::Config, Error> {
133    let mut base = serde_json::to_value(&global).map_err(Error::Serialize)?;
134    let overlay = serde_json::to_value(&state).map_err(Error::Serialize)?;
135    merge_override(&mut base, &overlay);
136    serde_json::from_value(base)
137        .map_err(|e| Error::Parse(std::path::PathBuf::from("<final merge>"), e))
138}
139
140/// Recursive overlay merge: objects merge key-by-key with the
141/// OVERLAY winning; everything else is replaced by the overlay.
142fn merge_override(base: &mut serde_json::Value, overlay: &serde_json::Value) {
143    match (&mut *base, overlay) {
144        (serde_json::Value::Object(b), serde_json::Value::Object(o)) => {
145            for (k, v) in o {
146                match b.get_mut(k) {
147                    Some(bv) => merge_override(bv, v),
148                    None => {
149                        b.insert(k.clone(), v.clone());
150                    }
151                }
152            }
153        }
154        (b, o) => *b = o.clone(),
155    }
156}