use wsb::prelude::*;
use axum::{extract::State, http::StatusCode, Json};
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use super::sessions::ErrorResponse;
use crate::web::home_dir;
use crate::web::state::WebState;
use wsb::state::StateConfig;
use wsb::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 ServerSettings {
pub host: String,
pub port: u16,
pub reachable_remotely: 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 LogsSettings {
pub root: String,
pub max_bytes: u64,
pub min_bytes: u64,
pub usage: wsb::logs::Usage,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AppSettings {
pub workspace: WorkspaceSettings,
pub project: ProjectSettings,
pub agent_dirs: HashMap<String, Vec<String>>,
pub terminal: TerminalSettings,
pub server: ServerSettings,
pub logs: LogsSettings,
}
#[derive(Debug, Deserialize)]
pub struct UpdateSettingsRequest {
#[serde(default)]
pub workspace: Option<WorkspaceSettingsUpdate>,
#[serde(default)]
pub project: Option<ProjectSettingsUpdate>,
#[serde(default)]
pub terminal: Option<TerminalSettingsUpdate>,
#[serde(default)]
pub logs: Option<LogsSettingsUpdate>,
}
#[derive(Debug, Deserialize)]
pub struct LogsSettingsUpdate {
pub max_bytes: u64,
}
#[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 TerminalSettingsUpdate {
pub mode: String,
#[serde(default)]
pub custom_bin: Option<String>,
#[serde(default)]
pub custom_args: Option<String>,
}
#[derive(Default)]
struct UserConfig {
terminal: TerminalSettings,
logs: LogsConfig,
}
#[derive(Debug, Clone, Copy)]
struct LogsConfig {
max_bytes: u64,
}
impl Default for LogsConfig {
fn default() -> Self {
Self {
max_bytes: wsb::logs::DEFAULT_MAX_BYTES,
}
}
}
pub fn configured_max_bytes() -> u64 {
load_user_config()
.map(|config| config.logs.max_bytes)
.unwrap_or(wsb::logs::DEFAULT_MAX_BYTES)
.max(wsb::logs::MIN_MAX_BYTES)
}
fn require_set_up(root: &Path) -> Result<(), (StatusCode, Json<ErrorResponse>)> {
if root.join(".wsb").join("state.json").is_file() {
Ok(())
} else {
Err((
StatusCode::CONFLICT,
Json(ErrorResponse {
error: format!(
"wsb is not set up in {}: run `wsb init` there to give it settings",
root.display()
),
}),
))
}
}
pub async fn get_settings(
State(state): State<WebState>,
) -> Result<Json<AppSettings>, (StatusCode, Json<ErrorResponse>)> {
let repo_root = workspace_root_of(&state);
require_set_up(&repo_root)?;
let settings = load_settings(&repo_root, state.listen, 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_of(&state);
require_set_up(&repo_root)?;
apply_settings_update(&repo_root, &state, body)
.await
.map(Json)
.map_err(internal_error)
}
async fn load_settings(
repo_root: &Path,
listen: std::net::IpAddr,
port: u16,
) -> anyhow::Result<AppSettings> {
let logs = logs_settings(repo_root).await?;
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()?,
terminal: user_config.terminal,
server: ServerSettings {
host: listen.to_string(),
port,
reachable_remotely: !listen.is_loopback(),
},
logs,
})
}
async fn logs_settings(repo_root: &Path) -> anyhow::Result<LogsSettings> {
let root = repo_root.to_path_buf();
let (logs_root, usage, max_bytes) = tokio::task::spawn_blocking(move || {
let logs_root = wsb::logs::root(&root);
let usage = wsb::logs::usage(&logs_root);
(logs_root, usage, configured_max_bytes())
})
.await?;
Ok(LogsSettings {
root: logs_root.display().to_string(),
max_bytes,
min_bytes: wsb::logs::MIN_MAX_BYTES,
usage,
})
}
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(logs_update) = body.logs {
validate_logs_update(logs_update.max_bytes)?;
let max_bytes = logs_update.max_bytes;
tokio::task::spawn_blocking(move || {
update_user_config(|table| set_logs_table(table, max_bytes))
})
.await??;
crate::web::log_janitor::sweep_now(state).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??;
}
load_settings(repo_root, state.listen, 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 logs = table
.get("logs")
.and_then(toml::Value::as_table)
.and_then(|logs| logs.get("max_bytes"))
.and_then(toml::Value::as_integer)
.filter(|bytes| *bytes > 0)
.map(|bytes| LogsConfig {
max_bytes: (bytes as u64).max(wsb::logs::MIN_MAX_BYTES),
})
.unwrap_or_default();
Ok(UserConfig { terminal, logs })
}
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 validate_logs_update(max_bytes: u64) -> anyhow::Result<()> {
if max_bytes < wsb::logs::MIN_MAX_BYTES {
anyhow::bail!(
"the logs limit cannot be below {} — a limit smaller than one run's output \
removes the log a page is showing as fast as it is written",
wsb::logs::human_bytes(wsb::logs::MIN_MAX_BYTES)
);
}
Ok(())
}
fn set_logs_table(table: &mut toml::Table, max_bytes: u64) {
let logs_table = table
.entry("logs".to_string())
.or_insert_with(|| toml::Value::Table(toml::Table::new()));
let logs_table = logs_table
.as_table_mut()
.expect("logs entry must remain a table");
logs_table.insert(
"max_bytes".to_string(),
toml::Value::Integer(max_bytes as i64),
);
}
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 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(wsb::daemon::machine_dir()
.map_err(|e| anyhow::anyhow!(e))?
.join("config.toml"))
}
fn workspace_root_of(state: &WebState) -> PathBuf {
state.workspace_root.clone()
}
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;
#[tokio::test]
async fn test8997_the_reported_root_is_the_one_that_is_written() {
let dir = tempfile::tempdir().unwrap();
let settings = logs_settings(dir.path()).await.expect("settings load");
assert_eq!(
settings.root,
wsb::logs::root(dir.path()).display().to_string()
);
assert!(settings.max_bytes >= settings.min_bytes);
assert_eq!(settings.min_bytes, wsb::logs::MIN_MAX_BYTES);
for stream in wsb::logs::Stream::ALL {
assert!(settings.usage.by_group.contains_key(stream.dir_name()));
}
}
#[test]
fn test8998_a_limit_below_the_floor_is_refused() {
let error = validate_logs_update(1024).expect_err("a 1 KB limit is not a limit");
let message = error.to_string();
assert!(message.contains("cannot be below"), "{message}");
assert!(
message.contains("removes the log a page is showing"),
"the refusal has to say what would go wrong: {message}"
);
validate_logs_update(wsb::logs::MIN_MAX_BYTES).expect("the floor itself is allowed");
validate_logs_update(wsb::logs::DEFAULT_MAX_BYTES).expect("the default is allowed");
}
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 machine = TempDir::new().unwrap();
wsb::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("state", &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();
std::fs::write(
machine.path().join("config.toml"),
r#"
[terminal]
mode = "custom"
custom_bin = "/usr/bin/kitty"
custom_args = "-- bash -lc {cmd}"
"#,
)
.unwrap();
let old_machine = std::env::var_os(wsb::daemon::machine::MACHINE_DIR_ENV);
std::env::set_var(wsb::daemon::machine::MACHINE_DIR_ENV, machine.path());
let rt = tokio::runtime::Runtime::new().unwrap();
let settings = rt
.block_on(load_settings(
repo.path(),
std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
4123,
))
.unwrap();
match old_machine {
Some(value) => std::env::set_var(wsb::daemon::machine::MACHINE_DIR_ENV, value),
None => std::env::remove_var(wsb::daemon::machine::MACHINE_DIR_ENV),
}
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("state"),
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_eq!(settings.server.host, "127.0.0.1");
assert!(!settings.server.reachable_remotely);
}
#[test]
fn server_settings_report_the_address_the_daemon_is_bound_to() {
let _guard = SETTINGS_TEST_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let repo = TempDir::new().unwrap();
let machine = TempDir::new().unwrap();
wsb::git_backend::init_repository(repo.path()).unwrap();
std::fs::create_dir_all(repo.path().join(".wsb")).unwrap();
let workspace_state =
WorkspaceState::initialize(repo.path(), Some("machinefabric")).unwrap();
workspace_state.save(repo.path()).unwrap();
let _ = StateConfig::load(repo.path()).unwrap();
let old_machine = std::env::var_os(wsb::daemon::machine::MACHINE_DIR_ENV);
std::env::set_var(wsb::daemon::machine::MACHINE_DIR_ENV, machine.path());
let rt = tokio::runtime::Runtime::new().unwrap();
let settings = rt
.block_on(load_settings(
repo.path(),
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
4123,
))
.unwrap();
match old_machine {
Some(value) => std::env::set_var(wsb::daemon::machine::MACHINE_DIR_ENV, value),
None => std::env::remove_var(wsb::daemon::machine::MACHINE_DIR_ENV),
}
assert_eq!(settings.server.host, "0.0.0.0");
assert!(
settings.server.reachable_remotely,
"a daemon on every interface must say so: the page warns about it, \
and nothing else in the workbench asks for a password"
);
}
#[test]
fn update_user_config_persists_terminal_settings() {
let _guard = SETTINGS_TEST_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let machine = TempDir::new().unwrap();
let old_machine = std::env::var_os(wsb::daemon::machine::MACHINE_DIR_ENV);
std::env::set_var(wsb::daemon::machine::MACHINE_DIR_ENV, machine.path());
update_user_config(|table| {
set_terminal_table(
table,
&TerminalSettings {
mode: "clipboard".to_string(),
custom_bin: None,
custom_args: None,
},
);
})
.unwrap();
let config = load_user_config().unwrap();
match old_machine {
Some(value) => std::env::set_var(wsb::daemon::machine::MACHINE_DIR_ENV, value),
None => std::env::remove_var(wsb::daemon::machine::MACHINE_DIR_ENV),
}
assert_eq!(config.terminal.mode, "clipboard");
}
#[tokio::test]
async fn state_config_async_round_trip_works_inside_runtime() {
let repo = TempDir::new().unwrap();
wsb::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()]);
}
}