Skip to main content

objectiveai_cli/filesystem/
client.rs

1use std::path::PathBuf;
2
3/// On-disk layout root + state selection.
4///
5/// ```text
6/// <dir>                              default: ~/.objectiveai
7/// ├── bin/                           ── shared across states ──
8/// │   ├── objectiveai[.exe]          (+ -api, -viewer, -mcp, -db)
9/// │   ├── pg-bin/                    postgres install (objectiveai-db)
10/// │   ├── plugins/<owner>/<name>/<version>/
11/// │   └── tools/<owner>/<name>/<version>/
12/// └── state/<state>/                 default state: "default"
13///     ├── config.json
14///     ├── db/  .pgpass  db.lock      (cluster, per state)
15///     ├── logs/
16///     └── instances/agents/
17/// ```
18///
19/// Binaries, the postgres install, plugins and tools are
20/// machine-wide ([`Self::bin_dir`]); everything else is per-state
21/// ([`Self::state_dir`]), so independent states coexist under one
22/// install by switching `OBJECTIVEAI_STATE`.
23#[derive(Debug, Clone)]
24pub struct Client {
25    dir: PathBuf,
26    state: String,
27    pub commit_author_name: String,
28    pub commit_author_email: String,
29}
30
31impl Client {
32    /// Resolution per field: explicit arg → env (`OBJECTIVEAI_DIR` /
33    /// `OBJECTIVEAI_STATE`, feature `env`) → default
34    /// (`~/.objectiveai` / `"default"`).
35    ///
36    /// Panics when the state name doesn't match `[A-Za-z0-9_-]+` —
37    /// state names become directory names under `<dir>/state/`, so
38    /// separators, dot-segments, and empty names are rejected
39    /// outright.
40    pub fn new(
41        dir: Option<impl Into<PathBuf>>,
42        state: Option<impl Into<String>>,
43        commit_author_name: Option<impl Into<String>>,
44        commit_author_email: Option<impl Into<String>>,
45    ) -> Self {
46        let dir = match dir {
47            Some(dir) => dir.into(),
48            None => {
49                #[cfg(feature = "env")]
50                let env_dir = std::env::var("OBJECTIVEAI_DIR").ok();
51                #[cfg(not(feature = "env"))]
52                let env_dir: Option<String> = None;
53                match env_dir {
54                    Some(dir) => PathBuf::from(dir),
55                    None => dirs::home_dir()
56                        .unwrap_or_else(|| PathBuf::from("."))
57                        .join(".objectiveai"),
58                }
59            }
60        };
61        let state = match state {
62            Some(state) => state.into(),
63            None => {
64                #[cfg(feature = "env")]
65                let env_state = std::env::var("OBJECTIVEAI_STATE").ok();
66                #[cfg(not(feature = "env"))]
67                let env_state: Option<String> = None;
68                env_state.unwrap_or_else(|| "default".to_string())
69            }
70        };
71        assert!(
72            is_valid_state_name(&state),
73            "OBJECTIVEAI_STATE {state:?} is invalid: state names must match [A-Za-z0-9_-]+",
74        );
75        Self {
76            dir,
77            state,
78            commit_author_name: resolve_author_name(commit_author_name),
79            commit_author_email: resolve_author_email(commit_author_email),
80        }
81    }
82
83    /// The layout root (`OBJECTIVEAI_DIR`). Forward this (with
84    /// [`Self::state`]) to child processes so they resolve the same
85    /// tree.
86    pub fn dir(&self) -> &PathBuf {
87        &self.dir
88    }
89
90    /// The state name (`OBJECTIVEAI_STATE`).
91    pub fn state(&self) -> &str {
92        &self.state
93    }
94
95    /// `<dir>/bin` — binaries, the postgres install, plugins, tools.
96    /// Shared across every state.
97    pub fn bin_dir(&self) -> PathBuf {
98        self.dir.join("bin")
99    }
100
101    /// `<dir>/state/<state>` — config, database cluster, logs, agent
102    /// registry. Per state.
103    pub fn state_dir(&self) -> PathBuf {
104        self.dir.join("state").join(&self.state)
105    }
106
107    /// Per-state config: `<dir>/state/<state>/config.json`.
108    pub fn config_path(&self) -> PathBuf {
109        self.state_dir().join("config.json")
110    }
111
112    /// Machine-wide (global) config: `<dir>/bin/config.json` — lives
113    /// with the other machine-wide artifacts.
114    pub fn global_config_path(&self) -> PathBuf {
115        self.bin_dir().join("config.json")
116    }
117
118    pub fn logs_dir(&self) -> PathBuf {
119        self.state_dir().join("logs")
120    }
121}
122
123/// `[A-Za-z0-9_-]+` — the only characters a state name may carry.
124fn is_valid_state_name(state: &str) -> bool {
125    !state.is_empty()
126        && state
127            .chars()
128            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
129}
130
131fn resolve_author_name(explicit: Option<impl Into<String>>) -> String {
132    if let Some(name) = explicit {
133        return name.into();
134    }
135    #[cfg(feature = "env")]
136    if let Ok(name) = std::env::var("COMMIT_AUTHOR_NAME") {
137        return name;
138    }
139    "ObjectiveAI".to_string()
140}
141
142fn resolve_author_email(explicit: Option<impl Into<String>>) -> String {
143    if let Some(email) = explicit {
144        return email.into();
145    }
146    #[cfg(feature = "env")]
147    if let Ok(email) = std::env::var("COMMIT_AUTHOR_EMAIL") {
148        return email;
149    }
150    "admin@objectiveai.dev".to_string()
151}