use crate::prelude::*;
use axum::{extract::State, http::StatusCode, Json};
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use super::sessions::ErrorResponse;
use crate::web::state::WebState;
use crate::{
fleet::git::GitOps,
state::StateConfig,
web::{github::GitHubClient, home_dir, state::load_github_token},
workspace_state::{WorkspaceState, WstemplateEntry},
};
#[derive(Debug, Serialize, Deserialize)]
pub struct TerminalSettings {
pub mode: String,
#[serde(default)]
pub custom_bin: Option<String>,
#[serde(default)]
pub custom_args: Option<String>,
}
impl Default for TerminalSettings {
fn default() -> Self {
Self {
mode: "auto".to_string(),
custom_bin: None,
custom_args: None,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GitHubSettings {
pub configured: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ServerSettings {
pub host: String,
pub port: u16,
pub remote_access: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct WorkspaceSettings {
pub root: String,
pub project_name: Option<String>,
pub tools: HashMap<String, serde_json::Value>,
#[serde(default)]
pub wstemplate_entry: Option<WstemplateEntryPayload>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct WstemplateEntryPayload {
pub root: String,
pub alias: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ProjectSettings {
pub state: StateConfig,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AppSettings {
pub workspace: WorkspaceSettings,
pub project: ProjectSettings,
pub agent_dirs: HashMap<String, Vec<String>>,
pub github: GitHubSettings,
pub terminal: TerminalSettings,
pub server: ServerSettings,
}
#[derive(Debug, Deserialize)]
pub struct UpdateSettingsRequest {
#[serde(default)]
pub workspace: Option<WorkspaceSettingsUpdate>,
#[serde(default)]
pub project: Option<ProjectSettingsUpdate>,
#[serde(default)]
pub github: Option<GitHubSettingsUpdate>,
#[serde(default)]
pub terminal: Option<TerminalSettingsUpdate>,
#[serde(default)]
pub server: Option<ServerSettingsUpdate>,
}
#[derive(Debug, Deserialize)]
pub struct WorkspaceSettingsUpdate {
#[serde(default)]
pub project_name: Option<String>,
#[serde(default)]
pub tools: Option<HashMap<String, serde_json::Value>>,
#[serde(default)]
pub wstemplate_entry: Option<Option<WstemplateEntryPayload>>,
}
#[derive(Debug, Deserialize)]
pub struct ProjectSettingsUpdate {
#[serde(default)]
pub state: Option<StateConfig>,
}
#[derive(Debug, Deserialize)]
pub struct GitHubSettingsUpdate {
#[serde(default)]
pub token: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct TerminalSettingsUpdate {
pub mode: String,
#[serde(default)]
pub custom_bin: Option<String>,
#[serde(default)]
pub custom_args: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ServerSettingsUpdate {
#[serde(default)]
pub remote_access: Option<bool>,
}
#[derive(Default)]
struct UserConfig {
terminal: TerminalSettings,
remote_access: bool,
}
pub async fn get_settings(
State(state): State<WebState>,
) -> Result<Json<AppSettings>, (StatusCode, Json<ErrorResponse>)> {
let repo_root = workspace_root()?;
let settings = load_settings(&repo_root, state.port)
.await
.map_err(internal_error)?;
Ok(Json(settings))
}
pub async fn update_settings(
State(state): State<WebState>,
Json(body): Json<UpdateSettingsRequest>,
) -> Result<Json<AppSettings>, (StatusCode, Json<ErrorResponse>)> {
let repo_root = workspace_root()?;
apply_settings_update(&repo_root, &state, body)
.await
.map(Json)
.map_err(internal_error)
}
async fn load_settings(repo_root: &Path, port: u16) -> anyhow::Result<AppSettings> {
let repo_root_owned = repo_root.to_path_buf();
let (workspace, user_config) = tokio::task::spawn_blocking(move || {
let workspace = WorkspaceState::load(&repo_root_owned)?;
let user_config = load_user_config()?;
anyhow::Ok((workspace, user_config))
})
.await??;
let state = StateConfig::load_async(repo_root).await?;
let entry = workspace.effective_wstemplate_entry();
let wstemplate_entry = Some(WstemplateEntryPayload {
root: entry.root.display().to_string(),
alias: entry.alias,
});
Ok(AppSettings {
workspace: WorkspaceSettings {
root: repo_root.display().to_string(),
project_name: workspace.project_name,
tools: workspace.tools,
wstemplate_entry,
},
project: ProjectSettings { state },
agent_dirs: load_agent_dirs()?,
github: GitHubSettings {
configured: load_github_token().is_some(),
},
terminal: user_config.terminal,
server: ServerSettings {
host: "127.0.0.1".to_string(),
port,
remote_access: user_config.remote_access,
},
})
}
async fn apply_settings_update(
repo_root: &Path,
state: &WebState,
body: UpdateSettingsRequest,
) -> anyhow::Result<AppSettings> {
if let Some(workspace_update) = body.workspace {
let repo_root_owned = repo_root.to_path_buf();
let mut workspace =
tokio::task::spawn_blocking(move || WorkspaceState::load(&repo_root_owned)).await??;
if let Some(project_name) = workspace_update.project_name {
let trimmed = project_name.trim();
if trimmed.is_empty() {
anyhow::bail!("workspace.project_name cannot be empty");
}
workspace.project_name = Some(trimmed.to_string());
}
if let Some(tools) = workspace_update.tools {
workspace.tools = tools;
}
if let Some(wstemplate_entry) = workspace_update.wstemplate_entry {
match wstemplate_entry {
Some(entry) => {
let alias = entry.alias.trim();
let root = entry.root.trim();
if alias.is_empty() {
anyhow::bail!("workspace.wstemplate_entry.alias cannot be empty");
}
if root.is_empty() {
anyhow::bail!("workspace.wstemplate_entry.root cannot be empty");
}
workspace.set_wstemplate_entry(WstemplateEntry {
alias: alias.to_string(),
root: PathBuf::from(root),
});
}
None => workspace.clear_wstemplate_entry(),
}
}
let repo_root_save = repo_root.to_path_buf();
tokio::task::spawn_blocking(move || workspace.save(&repo_root_save)).await??;
}
if let Some(project_update) = body.project {
if let Some(project_state) = project_update.state {
project_state.save_async(repo_root).await?;
}
}
if let Some(terminal_update) = body.terminal {
validate_terminal_settings(&terminal_update)?;
let terminal = TerminalSettings {
mode: terminal_update.mode.clone(),
custom_bin: terminal_update.custom_bin.clone(),
custom_args: terminal_update.custom_args.clone(),
};
tokio::task::spawn_blocking(move || {
update_user_config(|table| set_terminal_table(table, &terminal))
})
.await??;
}
if let Some(server_update) = body.server {
if let Some(remote_access) = server_update.remote_access {
tokio::task::spawn_blocking(move || {
update_user_config(|table| set_remote_access(table, remote_access))
})
.await??;
}
}
if let Some(github_update) = body.github {
if let Some(token) = github_update.token {
let trimmed = token.trim();
if trimmed.is_empty() {
anyhow::bail!("github.token cannot be empty");
}
let client = GitHubClient::new(trimmed);
client.validate_token().await?;
let token_owned = trimmed.to_string();
tokio::task::spawn_blocking(move || save_github_token(&token_owned)).await??;
let mut github = state.github.write().await;
*github = Some(client);
}
}
load_settings(repo_root, state.port).await
}
fn load_agent_dirs() -> anyhow::Result<HashMap<String, Vec<String>>> {
let home = home_dir()?;
let mut dirs = HashMap::new();
dirs.insert(
"claude".to_string(),
vec![home.join(".claude").join("projects").display().to_string()],
);
Ok(dirs)
}
fn load_user_config() -> anyhow::Result<UserConfig> {
let path = user_config_path()?;
if !path.exists() {
return Ok(UserConfig::default());
}
let content = std::fs::read_to_string(&path)?;
let table: toml::Table = content.parse()?;
let terminal = table
.get("terminal")
.and_then(toml::Value::as_table)
.map(parse_terminal_table)
.transpose()?
.unwrap_or_default();
let remote_access = table
.get("server")
.and_then(toml::Value::as_table)
.and_then(|server| server.get("remote_access"))
.and_then(toml::Value::as_bool)
.unwrap_or(false);
Ok(UserConfig {
terminal,
remote_access,
})
}
fn parse_terminal_table(table: &toml::Table) -> anyhow::Result<TerminalSettings> {
let mode = table
.get("mode")
.and_then(toml::Value::as_str)
.unwrap_or("auto")
.to_string();
let custom_bin = table
.get("custom_bin")
.and_then(toml::Value::as_str)
.map(ToString::to_string);
let custom_args = table
.get("custom_args")
.and_then(toml::Value::as_str)
.map(ToString::to_string);
let terminal = TerminalSettings {
mode,
custom_bin,
custom_args,
};
validate_terminal_settings(&TerminalSettingsUpdate {
mode: terminal.mode.clone(),
custom_bin: terminal.custom_bin.clone(),
custom_args: terminal.custom_args.clone(),
})?;
Ok(terminal)
}
fn validate_terminal_settings(terminal: &TerminalSettingsUpdate) -> anyhow::Result<()> {
match terminal.mode.as_str() {
"auto" | "custom" | "clipboard" => {}
other => anyhow::bail!(
"terminal.mode must be one of auto, custom, clipboard; got '{}'",
other
),
}
if terminal.mode == "custom" {
if terminal
.custom_bin
.as_ref()
.map(|value| value.trim().is_empty())
.unwrap_or(true)
{
anyhow::bail!("terminal.custom_bin is required when terminal.mode is 'custom'");
}
}
Ok(())
}
fn save_github_token(token: &str) -> anyhow::Result<()> {
update_user_config(|table| {
let github = table
.entry("github".to_string())
.or_insert_with(|| toml::Value::Table(toml::Table::new()));
let github = github
.as_table_mut()
.expect("github entry must remain a table");
github.insert("token".to_string(), toml::Value::String(token.to_string()));
})
}
fn set_terminal_table(table: &mut toml::Table, terminal: &TerminalSettings) {
let terminal_table = table
.entry("terminal".to_string())
.or_insert_with(|| toml::Value::Table(toml::Table::new()));
let terminal_table = terminal_table
.as_table_mut()
.expect("terminal entry must remain a table");
terminal_table.insert(
"mode".to_string(),
toml::Value::String(terminal.mode.clone()),
);
match &terminal.custom_bin {
Some(custom_bin) => {
terminal_table.insert(
"custom_bin".to_string(),
toml::Value::String(custom_bin.clone()),
);
}
None => {
terminal_table.remove("custom_bin");
}
}
match &terminal.custom_args {
Some(custom_args) => {
terminal_table.insert(
"custom_args".to_string(),
toml::Value::String(custom_args.clone()),
);
}
None => {
terminal_table.remove("custom_args");
}
}
}
fn set_remote_access(table: &mut toml::Table, remote_access: bool) {
let server = table
.entry("server".to_string())
.or_insert_with(|| toml::Value::Table(toml::Table::new()));
let server = server
.as_table_mut()
.expect("server entry must remain a table");
server.insert(
"remote_access".to_string(),
toml::Value::Boolean(remote_access),
);
}
fn update_user_config(mut apply: impl FnMut(&mut toml::Table)) -> anyhow::Result<()> {
let path = user_config_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut table: toml::Table = if path.exists() {
std::fs::read_to_string(&path)?.parse()?
} else {
toml::Table::new()
};
apply(&mut table);
std::fs::write(&path, toml::to_string_pretty(&table)?)?;
Ok(())
}
fn user_config_path() -> anyhow::Result<PathBuf> {
Ok(home_dir()?.join(".wsb").join("config.toml"))
}
fn workspace_root() -> Result<PathBuf, (StatusCode, Json<ErrorResponse>)> {
let cwd = std::env::current_dir().map_err(internal_error)?;
GitOps::discover_repo_root(&cwd).map_err(internal_error)
}
fn internal_error(error: impl ToString) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: error.to_string(),
}),
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};
use tempfile::TempDir;
static SETTINGS_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
#[test]
fn load_settings_reads_workspace_project_and_user_config() {
let _guard = SETTINGS_TEST_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let repo = TempDir::new().unwrap();
let home = TempDir::new().unwrap();
crate::git_backend::init_repository(repo.path()).unwrap();
std::fs::create_dir_all(repo.path().join(".wsb")).unwrap();
let mut workspace_state =
WorkspaceState::initialize(repo.path(), Some("machinefabric")).unwrap();
workspace_state
.set_tool_config("st8", &serde_json::json!({ "profile": "prod" }))
.unwrap();
workspace_state.set_wstemplate_entry(WstemplateEntry {
alias: "mf".to_string(),
root: repo.path().to_path_buf(),
});
workspace_state.save(repo.path()).unwrap();
let _ = StateConfig::load(repo.path()).unwrap();
let project_state = StateConfig {
version: 7,
enabled: true,
version_file: "VERSION".to_string(),
auto_detect_project_files: false,
project_files: vec!["Cargo.toml".to_string()],
};
project_state.save(repo.path()).unwrap();
let config_dir = home.path().join(".wsb");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.toml"),
r#"
[terminal]
mode = "custom"
custom_bin = "/usr/bin/kitty"
custom_args = "-- bash -lc {cmd}"
[server]
remote_access = true
[github]
token = "ghp_test"
"#,
)
.unwrap();
let old_home = std::env::var_os("HOME");
std::env::set_var("HOME", home.path());
let rt = tokio::runtime::Runtime::new().unwrap();
let settings = rt.block_on(load_settings(repo.path(), 4123)).unwrap();
match old_home {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
assert_eq!(settings.workspace.root, repo.path().display().to_string());
assert_eq!(
settings.workspace.project_name.as_deref(),
Some("machinefabric")
);
assert_eq!(
settings.workspace.tools.get("st8"),
Some(&serde_json::json!({ "profile": "prod" }))
);
assert_eq!(
settings
.workspace
.wstemplate_entry
.as_ref()
.map(|entry| entry.alias.as_str()),
Some("mf")
);
assert_eq!(settings.project.state.version, 1);
assert_eq!(settings.project.state.version_file, "VERSION");
assert!(!settings.project.state.auto_detect_project_files);
assert_eq!(
settings.project.state.project_files,
vec!["Cargo.toml".to_string()]
);
assert_eq!(settings.terminal.mode, "custom");
assert_eq!(
settings.terminal.custom_bin.as_deref(),
Some("/usr/bin/kitty")
);
assert_eq!(settings.server.port, 4123);
assert!(settings.server.remote_access);
assert!(settings.github.configured);
}
#[test]
fn update_user_config_persists_terminal_and_remote_access() {
let _guard = SETTINGS_TEST_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let home = TempDir::new().unwrap();
let old_home = std::env::var_os("HOME");
std::env::set_var("HOME", home.path());
update_user_config(|table| {
set_terminal_table(
table,
&TerminalSettings {
mode: "clipboard".to_string(),
custom_bin: None,
custom_args: None,
},
);
set_remote_access(table, true);
})
.unwrap();
let config = load_user_config().unwrap();
match old_home {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
assert_eq!(config.terminal.mode, "clipboard");
assert!(config.remote_access);
}
#[tokio::test]
async fn state_config_async_round_trip_works_inside_runtime() {
let repo = TempDir::new().unwrap();
crate::git_backend::init_repository(repo.path()).unwrap();
std::fs::create_dir_all(repo.path().join(".wsb")).unwrap();
let initial = StateConfig::load_async(repo.path()).await.unwrap();
assert_eq!(initial.version_file, "version.txt");
let updated = StateConfig {
version: 1,
enabled: true,
version_file: "VERSION".to_string(),
auto_detect_project_files: false,
project_files: vec!["Cargo.toml".to_string()],
};
updated.save_async(repo.path()).await.unwrap();
let loaded = StateConfig::load_async(repo.path()).await.unwrap();
assert_eq!(loaded.version_file, "VERSION");
assert!(!loaded.auto_detect_project_files);
assert_eq!(loaded.project_files, vec!["Cargo.toml".to_string()]);
}
}