Skip to main content

robonix_cli/
config.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Configuration Module
3//
4// Configuration management for robonix-cli
5
6use anyhow::{Context, Result};
7use dirs;
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10
11pub const ROBONIX_HOME_ENV: &str = "ROBONIX_HOME";
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Config {
15    pub package_storage_path: PathBuf,
16    /// Absolute path to the cloned robonix repo root (the directory containing `rust/`).
17    /// Set by `rbnx setup` from inside a working copy. Required so out-of-tree packages
18    /// (e.g. mapping_rbnx on a robot) can find capabilities/ and rust/crates/robonix-interfaces/lib.
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub robonix_source_path: Option<PathBuf>,
21}
22
23impl Config {
24    pub fn robonix_home_dir() -> Result<PathBuf> {
25        if let Some(raw) = std::env::var_os(ROBONIX_HOME_ENV)
26            && !raw.is_empty()
27        {
28            return Ok(PathBuf::from(raw));
29        }
30
31        let home_dir = dirs::home_dir().context("Failed to get home directory")?;
32        Ok(home_dir.join(".robonix"))
33    }
34
35    pub fn config_file_path() -> Result<PathBuf> {
36        Ok(Self::robonix_home_dir()?.join("config.yaml"))
37    }
38
39    pub fn load() -> Result<Self> {
40        let config_path = Self::config_file_path()?;
41
42        if !config_path.exists() {
43            // Create default config
44            let default = Self::default();
45            default.save()?;
46            return Ok(default);
47        }
48
49        let content = std::fs::read_to_string(&config_path)
50            .with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
51
52        let config: Config = serde_yaml::from_str(&content)
53            .with_context(|| format!("Failed to parse config file: {}", config_path.display()))?;
54
55        Ok(config)
56    }
57
58    /// Validates that the config has been upgraded for the new `rbnx setup` flow
59    /// (robonix_source_path present + pointing to an existing tree). If not,
60    /// prints a migration hint and exits; call this only from subcommands that
61    /// actually need source paths. Setup / Config / Path itself are exempt.
62    pub fn require_source_path(&self) -> Result<&std::path::Path> {
63        match self.robonix_source_path.as_deref() {
64            Some(p) if p.exists() => Ok(p),
65            Some(p) => {
66                eprintln!(
67                    "[rbnx] configured robonix_source_path no longer exists: {}",
68                    p.display()
69                );
70                eprintln!(
71                    "Re-run `rbnx setup` from the robonix source repo root (containing `rust/`)."
72                );
73                std::process::exit(2);
74            }
75            None => {
76                eprintln!(
77                    "[rbnx] config is missing robonix_source_path (legacy config from before the `rbnx setup` migration)."
78                );
79                eprintln!(
80                    "This is required so packages anywhere on disk can resolve capabilities/IDL paths."
81                );
82                eprintln!();
83                eprintln!("Fix:  cd /path/to/robonix   # the repo root (containing `rust/`)");
84                eprintln!("      rbnx setup");
85                eprintln!();
86                eprintln!(
87                    "Config file: {}",
88                    Self::config_file_path()
89                        .map(|p| p.display().to_string())
90                        .unwrap_or_else(|_| "~/.robonix/config.yaml".to_string())
91                );
92                std::process::exit(2);
93            }
94        }
95    }
96
97    pub fn save(&self) -> Result<()> {
98        let config_path = Self::config_file_path()?;
99
100        // Create parent directory if it doesn't exist
101        if let Some(parent) = config_path.parent() {
102            std::fs::create_dir_all(parent).with_context(|| {
103                format!("Failed to create config directory: {}", parent.display())
104            })?;
105        }
106
107        let content = serde_yaml::to_string(self).context("Failed to serialize config")?;
108
109        std::fs::write(&config_path, content)
110            .with_context(|| format!("Failed to write config file: {}", config_path.display()))?;
111
112        Ok(())
113    }
114
115    #[allow(clippy::should_implement_trait)]
116    pub fn default() -> Self {
117        let default_path = Self::robonix_home_dir()
118            .unwrap_or_else(|_| PathBuf::from("/tmp/robonix"))
119            .join("packages");
120
121        Self {
122            package_storage_path: default_path,
123            robonix_source_path: None,
124        }
125    }
126
127    /// Resolve a well-known path rooted in the robonix source tree.
128    /// Returns an error if `robonix_source_path` is unset (tell user to run `rbnx setup`)
129    /// or if the computed path doesn't exist.
130    pub fn resolve_source_path(&self, key: SourcePathKey) -> Result<PathBuf> {
131        let root = self.robonix_source_path.as_ref().ok_or_else(|| {
132            anyhow::anyhow!(
133                "robonix_source_path is not set. Run `rbnx setup` from the robonix source root (the directory containing `rust/`)."
134            )
135        })?;
136        let abs = match key {
137            SourcePathKey::Root => root.clone(),
138            // The Cargo workspace lives at the repo root now (was previously
139            // under rust/). RustRoot is kept as an alias of Root so downstream
140            // consumers asking for "rust" still get something sensible.
141            SourcePathKey::RustRoot => root.clone(),
142            SourcePathKey::Capabilities => root.join("capabilities"),
143            // capabilities/lib is the unified IDL root. Codegen and any
144            // downstream IDL search starts from here so msg/srv references in
145            // contract TOMLs (e.g. `[io.srv].srv = "demo/srv/Hello"`) have a
146            // single, unambiguous base.
147            SourcePathKey::InterfacesLib => root.join("capabilities").join("lib"),
148            // Atlas proto lives inside the atlas system component.
149            SourcePathKey::RuntimeProto => root.join("system").join("atlas").join("proto"),
150            SourcePathKey::RobonixApi => root.join("pylib").join("robonix-api"),
151        };
152        if !abs.exists() {
153            anyhow::bail!(
154                "resolved path does not exist: {} (robonix_source_path={}). The source tree may be incomplete — re-run `rbnx setup` from the correct root.",
155                abs.display(),
156                root.display()
157            );
158        }
159        Ok(abs)
160    }
161}
162
163/// Well-known paths a package build.sh might need from the robonix source tree.
164#[derive(Debug, Clone, Copy)]
165pub enum SourcePathKey {
166    /// Repository root (the dir containing `rust/`, `docs/`, etc.).
167    Root,
168    /// `<root>/rust` (cargo workspace).
169    RustRoot,
170    /// `<root>/capabilities` (contract TOMLs).
171    Capabilities,
172    /// `<root>/rust/crates/robonix-interfaces/lib` (ROS IDL source).
173    InterfacesLib,
174    /// `<root>/rust/crates/robonix-atlas/proto` (atlas proto).
175    RuntimeProto,
176    /// `<root>/pylib/robonix-api` — shared Python helper lib.
177    /// Carries `mcp_contract` (codegen IO class → FastMCP tool wrapper).
178    /// Add this dir to PYTHONPATH; `from robonix_api import mcp_contract`.
179    RobonixApi,
180}
181
182impl std::str::FromStr for SourcePathKey {
183    type Err = String;
184    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
185        match s {
186            "root" | "source" => Ok(Self::Root),
187            "rust" | "rust-root" => Ok(Self::RustRoot),
188            "capabilities" => Ok(Self::Capabilities),
189            "interfaces-lib" | "idl" => Ok(Self::InterfacesLib),
190            "runtime-proto" => Ok(Self::RuntimeProto),
191            "robonix-api" => Ok(Self::RobonixApi),
192            other => Err(format!(
193                "unknown path key: {other}. Valid: root, rust, capabilities, interfaces-lib, runtime-proto, robonix-api"
194            )),
195        }
196    }
197}
198
199impl Config {
200    pub fn ensure_storage_dir(&self) -> Result<()> {
201        // Check if path exists and is a directory (following symlinks)
202        if let Ok(metadata) = std::fs::metadata(&self.package_storage_path) {
203            if metadata.is_dir() {
204                // Directory already exists (or symlink points to directory), nothing to do
205                return Ok(());
206            } else {
207                anyhow::bail!(
208                    "Package storage path exists but is not a directory: {}",
209                    self.package_storage_path.display()
210                );
211            }
212        }
213
214        // Path doesn't exist, create it
215        std::fs::create_dir_all(&self.package_storage_path).with_context(|| {
216            format!(
217                "Failed to create package storage directory: {}",
218                self.package_storage_path.display()
219            )
220        })?;
221        Ok(())
222    }
223}