@@ -3,13 +3,72 @@ use std::{
3 path::{Path, PathBuf},
3 path::{Path, PathBuf},
6−use serde::Deserialize;
6+use serde::{Deserialize, Serialize};
8+use crate::vm::DirectoryShare;
8pub const CONFIG_FILENAME: &str = "vibebox.toml";
10pub const CONFIG_FILENAME: &str = "vibebox.toml";
10−#[derive(Debug, Default, Deserialize)]
12+const DEFAULT_CPU_COUNT: usize = 2;
13+const DEFAULT_RAM_MB: u64 = 2048;
14+const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 20000;
16+#[derive(Debug, Clone, Serialize, Deserialize)]
18+ #[serde(rename = "box")]
19+ pub box_cfg: BoxConfig,
20+ pub supervisor: SupervisorConfig,
23+impl Default for Config {
24+ fn default() -> Self {
26+ box_cfg: BoxConfig::default(),
27+ supervisor: SupervisorConfig::default(),
32+#[derive(Debug, Clone, Serialize, Deserialize)]
11−pub struct ProjectConfig {
33+pub struct BoxConfig {
12− pub auto_shutdown_ms: Option<u64>,
34+ pub cpu_count: usize,
36+ pub mounts: Vec<String>,
39+impl Default for BoxConfig {
40+ fn default() -> Self {
42+ cpu_count: default_cpu_count(),
43+ ram_mb: default_ram_mb(),
49+#[derive(Debug, Clone, Serialize, Deserialize)]
50+pub struct SupervisorConfig {
51+ pub auto_shutdown_ms: u64,
54+impl Default for SupervisorConfig {
55+ fn default() -> Self {
57+ auto_shutdown_ms: default_auto_shutdown_ms(),
62+fn default_cpu_count() -> usize {
66+fn default_ram_mb() -> u64 {
70+fn default_auto_shutdown_ms() -> u64 {
71+ DEFAULT_AUTO_SHUTDOWN_MS
15pub fn config_path(project_root: &Path) -> PathBuf {
74pub fn config_path(project_root: &Path) -> PathBuf {
@@ -19,20 +78,147 @@ pub fn config_path(project_root: &Path) -> PathBuf {
19pub fn ensure_config_file(project_root: &Path) -> Result<PathBuf, io::Error> {
78pub fn ensure_config_file(project_root: &Path) -> Result<PathBuf, io::Error> {
20 let path = config_path(project_root);
79 let path = config_path(project_root);
21 if !path.exists() {
80 if !path.exists() {
81+ let default_config = Config::default();
82+ let contents = toml::to_string_pretty(&default_config).unwrap_or_default();
22− fs::write(&path, "")?;
83+ fs::write(&path, contents)?;
23 tracing::info!(path = %path.display(), "created vibebox config");
84 tracing::info!(path = %path.display(), "created vibebox config");
28−pub fn load_config(
89+pub fn load_config(project_root: &Path) -> Config {
30−) -> Result<ProjectConfig, Box<dyn std::error::Error + Send + Sync>> {
31− let path = ensure_config_file(project_root)?;
90+ let path = match ensure_config_file(project_root) {
92+ Err(err) => die(&format!("failed to create config: {err}")),
32− let raw = fs::read_to_string(&path)?;
94+ let raw = match fs::read_to_string(&path) {
96+ Err(err) => die(&format!("failed to read config: {err}")),
98+ let trimmed = raw.trim();
33 tracing::debug!(path = %path.display(), bytes = raw.len(), "loaded vibebox config");
99 tracing::debug!(path = %path.display(), bytes = raw.len(), "loaded vibebox config");
34− if raw.trim().is_empty() {
100+ if trimmed.is_empty() {
35− return Ok(ProjectConfig::default());
101+ die(&format!(
102+ "config file ({}) is empty. Required fields: [box].cpu_count (integer), [box].ram_mb (integer), [box].mounts (array of strings), [supervisor].auto_shutdown_ms (integer)",
107+ let value: toml::Value = match toml::from_str(trimmed) {
109+ Err(err) => die(&format!("invalid config: {err}")),
111+ let schema_errors = validate_schema(&value);
112+ if !schema_errors.is_empty() {
113+ let message = format!(
114+ "config file ({}) is missing or invalid fields:\n- {}",
116+ schema_errors.join("\n- ")
37− Ok(toml::from_str::<ProjectConfig>(&raw)?)
120+
121+ let config: Config = match toml::from_str(trimmed) {
122+ Ok(config) => config,
123+ Err(err) => die(&format!("invalid config: {err}")),
125+ validate_or_exit(&config);
129+fn validate_schema(value: &toml::Value) -> Vec<String> {
130+ let mut errors = Vec::new();
131+ let root = match value.as_table() {
132+ Some(table) => table,
134+ errors.push("config must be a table".to_string());
139+ match root.get("box") {
140+ None => errors.push("missing [box] table".to_string()),
141+ Some(value) => match value.as_table() {
143+ validate_int(table, "cpu_count", "[box].cpu_count (integer)", &mut errors);
144+ validate_int(table, "ram_mb", "[box].ram_mb (integer)", &mut errors);
145+ validate_string_array(
148+ "[box].mounts (array of strings)",
152+ None => errors.push("[box] must be a table".to_string()),
156+ match root.get("supervisor") {
157+ None => errors.push("missing [supervisor] table".to_string()),
158+ Some(value) => match value.as_table() {
163+ "[supervisor].auto_shutdown_ms (integer)",
167+ None => errors.push("[supervisor] must be a table".to_string()),
174+fn validate_int(table: &toml::value::Table, key: &str, label: &str, errors: &mut Vec<String>) {
175+ match table.get(key) {
176+ None => errors.push(format!("missing {label}")),
178+ if value.as_integer().is_none() {
179+ errors.push(format!("invalid {label}: expected integer"));
185+fn validate_string_array(
186+ table: &toml::value::Table,
189+ errors: &mut Vec<String>,
191+ match table.get(key) {
192+ None => errors.push(format!("missing {label}")),
193+ Some(value) => match value.as_array() {
195+ if values.iter().any(|value| !value.is_str()) {
196+ errors.push(format!("invalid {label}: expected array of strings"));
199+ None => errors.push(format!("invalid {label}: expected array of strings")),
204+fn validate_or_exit(config: &Config) {
205+ if config.box_cfg.cpu_count == 0 {
206+ die("box.cpu_count must be >= 1");
208+ if config.box_cfg.ram_mb == 0 {
209+ die("box.ram_mb must be >= 1");
211+ if config.supervisor.auto_shutdown_ms == 0 {
212+ die("supervisor.auto_shutdown_ms must be >= 1");
214+ for spec in &config.box_cfg.mounts {
215+ if let Err(err) = DirectoryShare::from_mount_spec(spec) {
216+ die(&format!("invalid mount spec '{spec}': {err}"));
221+fn die(message: &str) -> ! {
222+ eprintln!("[vibebox] {message}");
223+ std::process::exit(1);