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::settings::{ProjectSettings, RepoSettings};
#[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 CommittedSettings<T> {
pub path: String,
pub settings: T,
}
#[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 root: String,
pub repo: CommittedSettings<RepoSettings>,
pub project: CommittedSettings<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 repo: Option<RepoSettings>,
#[serde(default)]
pub project: Option<ProjectSettings>,
#[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 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 RepoSettings::exists(root) {
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 = worktree_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 = worktree_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 root = repo_root.to_path_buf();
let (repo, project, user_config) = tokio::task::spawn_blocking(move || {
anyhow::Ok((RepoSettings::require(&root)?, ProjectSettings::load(&root)?, load_user_config()?))
})
.await??;
Ok(AppSettings {
root: repo_root.display().to_string(),
repo: CommittedSettings {
path: RepoSettings::path(repo_root).display().to_string(),
settings: repo,
},
project: CommittedSettings {
path: ProjectSettings::path(repo_root).display().to_string(),
settings: project,
},
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);
anyhow::Ok((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 write_committed(
repo_root: &Path,
repo: Option<RepoSettings>,
project: Option<ProjectSettings>,
) -> anyhow::Result<()> {
let root = repo_root.to_path_buf();
tokio::task::spawn_blocking(move || {
if let Some(repo) = repo {
repo.save(&root)?;
}
if let Some(project) = project {
project.save(&root)?;
}
anyhow::Ok(())
})
.await?
}
async fn apply_settings_update(
repo_root: &Path,
state: &WebState,
body: UpdateSettingsRequest,
) -> anyhow::Result<AppSettings> {
write_committed(repo_root, body.repo, body.project).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 worktree_root_of(state: &WebState) -> PathBuf {
state.worktree_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 tempfile::TempDir;
use wsb::test_environment::Environment;
fn managed(root: &Path) {
wsb::git_backend::init_repository(root).unwrap();
RepoSettings {
name: "machinefabric".to_string(),
alias: "mf".to_string(),
templates_root: ".".to_string(),
version: wsb::settings::VersionSettings::at_major(7),
}
.save(root)
.unwrap();
}
#[tokio::test]
async fn test8997_the_reported_root_is_the_one_that_is_written() {
let dir = tempfile::tempdir().unwrap();
let machine = tempfile::tempdir().unwrap();
let _env = Environment::set(&[("PRJ", ""), (wsb::daemon::MACHINE_DIR_ENV, &machine.path().to_string_lossy())]);
let settings = logs_settings(dir.path()).await.expect("settings load");
assert_eq!(settings.root, wsb::logs::root(dir.path()).unwrap().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");
}
#[test]
fn test20117_settings_are_the_committed_files_and_the_machines() {
let repo = TempDir::new().unwrap();
let machine = TempDir::new().unwrap();
managed(repo.path());
std::fs::write(
machine.path().join("config.toml"),
"[terminal]\nmode = \"custom\"\ncustom_bin = \"/usr/bin/kitty\"\ncustom_args = \"-- bash -lc {cmd}\"\n",
)
.unwrap();
let _env = Environment::set(&[("PRJ", ""), (wsb::daemon::MACHINE_DIR_ENV, &machine.path().to_string_lossy())]);
let rt = tokio::runtime::Runtime::new().unwrap();
let loopback = std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST);
let settings = rt.block_on(load_settings(repo.path(), loopback, 4123)).unwrap();
assert_eq!(settings.root, repo.path().display().to_string());
assert_eq!(settings.repo.path, RepoSettings::path(repo.path()).display().to_string());
assert_eq!((settings.repo.settings.alias.as_str(), settings.repo.settings.version.major), ("mf", 7));
assert!(settings.project.settings.boards.is_empty());
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);
let mut repo_settings = settings.repo.settings;
repo_settings.version.major = 8;
let project = ProjectSettings { boards: vec!["owner/7".to_string()], ignored_submodules: Vec::new() };
rt.block_on(write_committed(repo.path(), Some(repo_settings), Some(project))).unwrap();
let read = rt.block_on(load_settings(repo.path(), loopback, 4123)).unwrap();
assert_eq!(read.repo.settings.version.major, 8);
assert_eq!(read.project.settings.boards, vec!["owner/7".to_string()]);
let plain = TempDir::new().unwrap();
assert_eq!(require_set_up(plain.path()).unwrap_err().0, StatusCode::CONFLICT);
}
#[test]
fn server_settings_report_the_address_the_daemon_is_bound_to() {
let repo = TempDir::new().unwrap();
let machine = TempDir::new().unwrap();
managed(repo.path());
let _env = Environment::set(&[("PRJ", ""), (wsb::daemon::MACHINE_DIR_ENV, &machine.path().to_string_lossy())]);
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();
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 machine = TempDir::new().unwrap();
let _env = Environment::set(&[(wsb::daemon::MACHINE_DIR_ENV, &machine.path().to_string_lossy())]);
update_user_config(|table| {
set_terminal_table(
table,
&TerminalSettings {
mode: "clipboard".to_string(),
custom_bin: None,
custom_args: None,
},
);
})
.unwrap();
assert_eq!(load_user_config().unwrap().terminal.mode, "clipboard");
}
}