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