fabro/lib/crates/fabro-config/src/server.rs
Bryan Helmkamp bd195175fd
Docs updates: reorder nav, rename DOT Language, simplify dark factory, add fork/upgrade/asset docs
- Move Comparison link below Troubleshooting in nav
- Rename "DOT Language" page to "Fabro Language"
- Remove five-tier table from dark factory page, keep link to Dan Shapiro's post
- Add fork command docs and checkpoints section
- Add upgrade, asset list, asset cp command docs
- Add upgrade_check config reference
- Add retros feature flag to server config

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:13:23 -04:00

557 lines
16 KiB
Rust

use std::path::{Path, PathBuf};
use fabro_workflows::cli::run_config::RunDefaults;
use serde::{Deserialize, Serialize};
use tracing::debug;
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthProvider {
#[default]
Github,
InsecureDisabled,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
pub struct AuthConfig {
#[serde(default)]
pub provider: AuthProvider,
#[serde(default)]
pub allowed_usernames: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ApiAuthStrategy {
Jwt,
Mtls,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
pub struct TlsConfig {
pub cert: PathBuf,
pub key: PathBuf,
pub ca: PathBuf,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
pub struct ApiConfig {
#[serde(default = "default_base_url")]
pub base_url: String,
#[serde(default)]
pub authentication_strategies: Vec<ApiAuthStrategy>,
pub tls: Option<TlsConfig>,
}
fn default_base_url() -> String {
"http://localhost:3000".to_string()
}
impl Default for ApiConfig {
fn default() -> Self {
Self {
base_url: default_base_url(),
authentication_strategies: Vec::new(),
tls: None,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GitProvider {
#[default]
Github,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
pub struct GitAuthorConfig {
pub name: Option<String>,
pub email: Option<String>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum WebhookStrategy {
TailscaleFunnel,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
pub struct WebhookConfig {
pub strategy: WebhookStrategy,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
pub struct GitConfig {
#[serde(default)]
pub provider: GitProvider,
pub app_id: Option<String>,
pub client_id: Option<String>,
pub slug: Option<String>,
#[serde(default)]
pub author: GitAuthorConfig,
pub webhooks: Option<WebhookConfig>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
pub struct WebConfig {
#[serde(default = "default_web_url")]
pub url: String,
#[serde(default)]
pub auth: AuthConfig,
}
fn default_web_url() -> String {
"http://localhost:5173".to_string()
}
impl Default for WebConfig {
fn default() -> Self {
Self {
url: default_web_url(),
auth: AuthConfig::default(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
pub struct Features {
#[serde(default)]
pub session_sandboxes: bool,
/// Experimental: enable automatic retro generation after workflow runs.
#[serde(default)]
pub retros: bool,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct LogConfig {
pub level: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct ServerConfig {
pub data_dir: Option<PathBuf>,
pub max_concurrent_runs: Option<usize>,
#[serde(default)]
pub web: WebConfig,
#[serde(default)]
pub api: ApiConfig,
#[serde(default)]
pub git: GitConfig,
#[serde(default)]
pub features: Features,
#[serde(default)]
pub log: LogConfig,
#[serde(flatten)]
pub run_defaults: RunDefaults,
}
/// Load server config from an explicit path or `~/.fabro/server.toml`, returning defaults if the
/// default file doesn't exist. An explicit path that doesn't exist is an error.
pub fn load_server_config(path: Option<&Path>) -> anyhow::Result<ServerConfig> {
if let Some(explicit) = path {
debug!(path = %explicit.display(), "Loading server config from explicit path");
let contents = std::fs::read_to_string(explicit)?;
return Ok(toml::from_str(&contents)?);
}
let Some(home) = dirs::home_dir() else {
debug!("No home directory found, using default server config");
return Ok(ServerConfig::default());
};
let default_path = home.join(".fabro").join("server.toml");
debug!(path = %default_path.display(), "Loading server config");
match std::fs::read_to_string(&default_path) {
Ok(contents) => Ok(toml::from_str(&contents)?),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ServerConfig::default()),
Err(e) => Err(e.into()),
}
}
/// Resolve the data directory: config value > default `~/.fabro`.
pub fn resolve_data_dir(config: &ServerConfig) -> PathBuf {
if let Some(ref dir) = config.data_dir {
return dir.clone();
}
dirs::home_dir()
.map(|h| h.join(".fabro"))
.unwrap_or_else(|| PathBuf::from(".fabro"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_config_with_data_dir() {
let toml = r#"data_dir = "/custom/path""#;
let config: ServerConfig = toml::from_str(toml).unwrap();
assert_eq!(config.data_dir, Some(PathBuf::from("/custom/path")));
}
#[test]
fn parse_empty_config_defaults() {
let toml = "";
let config: ServerConfig = toml::from_str(toml).unwrap();
assert_eq!(config.data_dir, None);
}
#[test]
fn resolve_data_dir_uses_config_value() {
let config = ServerConfig {
data_dir: Some(PathBuf::from("/my/data")),
..ServerConfig::default()
};
assert_eq!(resolve_data_dir(&config), PathBuf::from("/my/data"));
}
#[test]
fn resolve_data_dir_defaults_to_home_arc() {
let config = ServerConfig::default();
let dir = resolve_data_dir(&config);
// Should end with .fabro
assert!(
dir.ends_with(".fabro"),
"expected path ending with .fabro, got: {}",
dir.display()
);
}
#[test]
fn parse_full_config() {
let toml = r#"
[web]
url = "https://arc.example.com"
[web.auth]
provider = "github"
allowed_usernames = ["brynary", "alice"]
[api]
base_url = "http://example.com:8080"
authentication_strategies = ["jwt"]
[git]
provider = "github"
app_id = "12345"
client_id = "Iv1.abc123"
"#;
let config: ServerConfig = toml::from_str(toml).unwrap();
assert_eq!(config.web.url, "https://arc.example.com");
assert_eq!(config.web.auth.provider, AuthProvider::Github);
assert_eq!(config.web.auth.allowed_usernames, vec!["brynary", "alice"]);
assert_eq!(config.api.base_url, "http://example.com:8080");
assert_eq!(
config.api.authentication_strategies,
vec![ApiAuthStrategy::Jwt]
);
assert_eq!(config.git.provider, GitProvider::Github);
assert_eq!(config.git.app_id.as_deref(), Some("12345"));
assert_eq!(config.git.client_id.as_deref(), Some("Iv1.abc123"));
}
#[test]
fn parse_web_defaults() {
let toml = "";
let config: ServerConfig = toml::from_str(toml).unwrap();
assert_eq!(config.web.url, "http://localhost:5173");
assert_eq!(config.web.auth.provider, AuthProvider::Github);
assert!(config.web.auth.allowed_usernames.is_empty());
}
#[test]
fn parse_api_defaults() {
let toml = "";
let config: ServerConfig = toml::from_str(toml).unwrap();
assert_eq!(config.api.base_url, "http://localhost:3000");
assert!(config.api.authentication_strategies.is_empty());
assert!(config.api.tls.is_none());
}
#[test]
fn parse_git_config() {
let toml = r#"
[git]
provider = "github"
app_id = "12345"
client_id = "Iv1.abc123"
"#;
let config: ServerConfig = toml::from_str(toml).unwrap();
assert_eq!(config.git.provider, GitProvider::Github);
assert_eq!(config.git.app_id.as_deref(), Some("12345"));
assert_eq!(config.git.client_id.as_deref(), Some("Iv1.abc123"));
}
#[test]
fn parse_git_defaults() {
let toml = "";
let config: ServerConfig = toml::from_str(toml).unwrap();
assert_eq!(config.git.provider, GitProvider::Github);
assert_eq!(config.git.app_id, None);
assert_eq!(config.git.client_id, None);
assert_eq!(config.git.author.name, None);
assert_eq!(config.git.author.email, None);
}
#[test]
fn parse_git_author_config() {
let toml = r#"
[git.author]
name = "fabro-bot"
email = "fabro-bot@company.com"
"#;
let config: ServerConfig = toml::from_str(toml).unwrap();
assert_eq!(config.git.author.name.as_deref(), Some("fabro-bot"));
assert_eq!(
config.git.author.email.as_deref(),
Some("fabro-bot@company.com")
);
}
#[test]
fn parse_git_author_partial() {
let toml = r#"
[git.author]
name = "custom-name"
"#;
let config: ServerConfig = toml::from_str(toml).unwrap();
assert_eq!(config.git.author.name.as_deref(), Some("custom-name"));
assert_eq!(config.git.author.email, None);
}
#[test]
fn parse_config_with_run_defaults() {
let toml = r#"
[llm]
model = "claude-haiku"
provider = "anthropic"
[sandbox]
provider = "daytona"
[vars]
repo_url = "https://github.com/org/repo"
"#;
let config: ServerConfig = toml::from_str(toml).unwrap();
let llm = config.run_defaults.llm.unwrap();
assert_eq!(llm.model.as_deref(), Some("claude-haiku"));
assert_eq!(llm.provider.as_deref(), Some("anthropic"));
let sandbox = config.run_defaults.sandbox.unwrap();
assert_eq!(sandbox.provider.as_deref(), Some("daytona"));
let vars = config.run_defaults.vars.unwrap();
assert_eq!(vars["repo_url"], "https://github.com/org/repo");
}
#[test]
fn parse_config_server_and_run_defaults_together() {
let toml = r#"
[web.auth]
provider = "github"
[git]
provider = "github"
app_id = "123"
[llm]
model = "gpt-4"
"#;
let config: ServerConfig = toml::from_str(toml).unwrap();
assert_eq!(config.web.auth.provider, AuthProvider::Github);
assert_eq!(config.git.app_id.as_deref(), Some("123"));
let llm = config.run_defaults.llm.unwrap();
assert_eq!(llm.model.as_deref(), Some("gpt-4"));
}
#[test]
fn parse_insecure_disabled_auth_provider() {
let toml = r#"
[web.auth]
provider = "insecure_disabled"
"#;
let config: ServerConfig = toml::from_str(toml).unwrap();
assert_eq!(config.web.auth.provider, AuthProvider::InsecureDisabled);
}
#[test]
fn parse_jwt_and_mtls_strategies() {
let toml = r#"
[api]
authentication_strategies = ["jwt", "mtls"]
[api.tls]
cert = "~/.fabro/certs/server.crt"
key = "~/.fabro/certs/server.key"
ca = "~/.fabro/certs/ca.crt"
"#;
let config: ServerConfig = toml::from_str(toml).unwrap();
assert_eq!(
config.api.authentication_strategies,
vec![ApiAuthStrategy::Jwt, ApiAuthStrategy::Mtls]
);
let tls = config.api.tls.unwrap();
assert_eq!(tls.cert, PathBuf::from("~/.fabro/certs/server.crt"));
assert_eq!(tls.key, PathBuf::from("~/.fabro/certs/server.key"));
assert_eq!(tls.ca, PathBuf::from("~/.fabro/certs/ca.crt"));
}
#[test]
fn parse_max_concurrent_runs() {
let toml = r#"max_concurrent_runs = 8"#;
let config: ServerConfig = toml::from_str(toml).unwrap();
assert_eq!(config.max_concurrent_runs, Some(8));
}
#[test]
fn parse_max_concurrent_runs_defaults_to_none() {
let toml = "";
let config: ServerConfig = toml::from_str(toml).unwrap();
assert_eq!(config.max_concurrent_runs, None);
}
#[test]
fn parse_empty_strategies() {
let toml = r#"
[api]
authentication_strategies = []
"#;
let config: ServerConfig = toml::from_str(toml).unwrap();
assert!(config.api.authentication_strategies.is_empty());
}
#[test]
fn parse_jwt_only_strategy() {
let toml = r#"
[api]
authentication_strategies = ["jwt"]
"#;
let config: ServerConfig = toml::from_str(toml).unwrap();
assert_eq!(
config.api.authentication_strategies,
vec![ApiAuthStrategy::Jwt]
);
assert!(config.api.tls.is_none());
}
#[test]
fn load_server_config_from_explicit_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("custom.toml");
std::fs::write(&path, r#"max_concurrent_runs = 42"#).unwrap();
let config = load_server_config(Some(&path)).unwrap();
assert_eq!(config.max_concurrent_runs, Some(42));
}
#[test]
fn load_server_config_explicit_path_missing_is_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nonexistent.toml");
let result = load_server_config(Some(&path));
assert!(result.is_err());
}
#[test]
fn parse_config_with_hooks() {
let toml = r#"
[[hooks]]
event = "run_start"
command = "echo 'run starting'"
[[hooks]]
event = "stage_complete"
command = "echo 'stage done'"
matcher = "agent_loop"
"#;
let config: ServerConfig = toml::from_str(toml).unwrap();
assert_eq!(config.run_defaults.hooks.len(), 2);
assert_eq!(
config.run_defaults.hooks[0].event,
fabro_workflows::hook::HookEvent::RunStart
);
assert_eq!(
config.run_defaults.hooks[0].command.as_deref(),
Some("echo 'run starting'")
);
assert_eq!(
config.run_defaults.hooks[1].event,
fabro_workflows::hook::HookEvent::StageComplete
);
assert_eq!(
config.run_defaults.hooks[1].matcher.as_deref(),
Some("agent_loop")
);
}
#[test]
fn parse_features() {
let toml = "[features]\nsession_sandboxes = true";
let config: ServerConfig = toml::from_str(toml).unwrap();
assert!(config.features.session_sandboxes);
}
#[test]
fn parse_features_defaults() {
let toml = "";
let config: ServerConfig = toml::from_str(toml).unwrap();
assert!(!config.features.session_sandboxes);
}
#[test]
fn parse_config_without_hooks_defaults_empty() {
let toml = "";
let config: ServerConfig = toml::from_str(toml).unwrap();
assert!(config.run_defaults.hooks.is_empty());
}
#[test]
fn parse_config_with_checkpoint_exclude_globs() {
let toml = r#"
[checkpoint]
exclude_globs = ["**/node_modules/**", "**/.cache/**"]
"#;
let config: ServerConfig = toml::from_str(toml).unwrap();
assert_eq!(
config.run_defaults.checkpoint.exclude_globs,
vec!["**/node_modules/**", "**/.cache/**"]
);
}
#[test]
fn parse_config_checkpoint_defaults_empty() {
let toml = "";
let config: ServerConfig = toml::from_str(toml).unwrap();
assert!(config.run_defaults.checkpoint.exclude_globs.is_empty());
}
#[test]
fn parse_git_webhooks_config() {
let toml = r#"
[git]
provider = "github"
app_id = "2993730"
[git.webhooks]
strategy = "tailscale_funnel"
"#;
let config: ServerConfig = toml::from_str(toml).unwrap();
let webhooks = config.git.webhooks.unwrap();
assert_eq!(webhooks.strategy, WebhookStrategy::TailscaleFunnel);
}
#[test]
fn parse_git_webhooks_missing_is_none() {
let toml = r#"
[git]
provider = "github"
app_id = "123"
"#;
let config: ServerConfig = toml::from_str(toml).unwrap();
assert!(config.git.webhooks.is_none());
}
#[test]
fn parse_log_config() {
let toml = "[log]\nlevel = \"trace\"";
let config: ServerConfig = toml::from_str(toml).unwrap();
assert_eq!(config.log.level.as_deref(), Some("trace"));
}
}