use wsb::prelude::*;
use axum::{
extract::State,
http::StatusCode,
routing::get,
Json, Router,
};
use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::Arc,
};
use super::sessions::ErrorResponse;
use crate::workshop::WorkshopState;
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 LogsLimit {
pub max_bytes: u64,
pub min_bytes: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LogsSettings {
pub root: String,
pub max_bytes: u64,
pub usage: wsb::logs::Usage,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct WorkshopSettings {
pub agent_dirs: HashMap<String, Vec<String>>,
pub terminal: TerminalSettings,
pub server: ServerSettings,
pub logs: LogsLimit,
pub appearance: Option<AppearanceSettings>,
}
pub const DENSITIES: [&str; 4] = ["compact", "default", "comfortable", "spacious"];
pub const MESSAGE_LAYOUTS: [&str; 3] = ["default", "compact", "stream"];
pub const BLOCKS: [&str; 5] = ["user", "assistant", "thinking", "tool", "code"];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AppearanceSettings {
pub theme: String,
pub density: String,
pub message_layout: String,
#[serde(default)]
pub hidden_blocks: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct WorktreeSettings {
pub root: String,
pub repo: CommittedSettings<RepoSettings>,
pub project: CommittedSettings<ProjectSettings>,
pub logs: LogsSettings,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UpdateWorkshopSettingsRequest {
#[serde(default)]
pub terminal: Option<TerminalSettingsUpdate>,
#[serde(default)]
pub logs: Option<LogsSettingsUpdate>,
#[serde(default)]
pub appearance: Option<AppearanceSettings>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UpdateWorktreeSettingsRequest {
#[serde(default)]
pub repo: Option<RepoSettings>,
#[serde(default)]
pub project: Option<ProjectSettings>,
}
#[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,
appearance: Option<AppearanceSettings>,
}
#[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 fn workshop_routes() -> Router<Arc<WorkshopState>> {
Router::new().route("/api/v1/settings", get(get_workshop_settings).put(update_workshop_settings))
}
pub async fn get_workshop_settings(
State(workshop): State<Arc<WorkshopState>>,
) -> Result<Json<WorkshopSettings>, (StatusCode, Json<ErrorResponse>)> {
load_workshop_settings(workshop.listen, workshop.port)
.await
.map(Json)
.map_err(internal_error)
}
pub async fn update_workshop_settings(
State(workshop): State<Arc<WorkshopState>>,
Json(body): Json<UpdateWorkshopSettingsRequest>,
) -> Result<Json<WorkshopSettings>, (StatusCode, Json<ErrorResponse>)> {
validate_workshop_update(&body).map_err(refused)?;
let swept = body.logs.is_some();
write_workshop_settings(body).await.map_err(internal_error)?;
if swept {
for worktree in workshop.all().await {
crate::web::log_janitor::sweep_now(&worktree.web).await;
}
}
get_workshop_settings(State(workshop)).await
}
pub async fn get_settings(
State(state): State<WebState>,
) -> Result<Json<WorktreeSettings>, (StatusCode, Json<ErrorResponse>)> {
let repo_root = worktree_root_of(&state);
require_set_up(&repo_root)?;
let settings = load_worktree_settings(&repo_root)
.await
.map_err(internal_error)?;
Ok(Json(settings))
}
pub async fn update_settings(
State(state): State<WebState>,
Json(body): Json<UpdateWorktreeSettingsRequest>,
) -> Result<Json<WorktreeSettings>, (StatusCode, Json<ErrorResponse>)> {
let repo_root = worktree_root_of(&state);
require_set_up(&repo_root)?;
write_committed(&repo_root, body.repo, body.project)
.await
.map_err(internal_error)?;
get_settings(State(state)).await
}
async fn load_workshop_settings(listen: std::net::IpAddr, port: u16) -> anyhow::Result<WorkshopSettings> {
let user_config = tokio::task::spawn_blocking(load_user_config).await??;
Ok(WorkshopSettings {
agent_dirs: load_agent_dirs()?,
terminal: user_config.terminal,
server: ServerSettings {
host: listen.to_string(),
port,
reachable_remotely: !listen.is_loopback(),
},
logs: LogsLimit {
max_bytes: user_config.logs.max_bytes,
min_bytes: wsb::logs::MIN_MAX_BYTES,
},
appearance: user_config.appearance,
})
}
async fn load_worktree_settings(repo_root: &Path) -> anyhow::Result<WorktreeSettings> {
let logs = logs_settings(repo_root).await?;
let root = repo_root.to_path_buf();
let (repo, project) = tokio::task::spawn_blocking(move || {
anyhow::Ok((RepoSettings::require(&root)?, ProjectSettings::load(&root)?))
})
.await??;
Ok(WorktreeSettings {
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,
},
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,
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?
}
fn validate_workshop_update(body: &UpdateWorkshopSettingsRequest) -> anyhow::Result<()> {
if let Some(logs) = &body.logs {
validate_logs_update(logs.max_bytes)?;
}
if let Some(terminal) = &body.terminal {
validate_terminal_settings(terminal)?;
}
if let Some(appearance) = &body.appearance {
validate_appearance(appearance)?;
}
Ok(())
}
async fn write_workshop_settings(body: UpdateWorkshopSettingsRequest) -> anyhow::Result<()> {
tokio::task::spawn_blocking(move || {
update_user_config(|table| {
if let Some(logs) = &body.logs {
set_logs_table(table, logs.max_bytes);
}
if let Some(appearance) = &body.appearance {
set_appearance_table(table, appearance);
}
if let Some(terminal) = &body.terminal {
set_terminal_table(
table,
&TerminalSettings {
mode: terminal.mode.clone(),
custom_bin: terminal.custom_bin.clone(),
custom_args: terminal.custom_args.clone(),
},
);
}
})
})
.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();
let appearance = match table.get("appearance") {
None => None,
Some(toml::Value::Table(appearance)) => {
Some(parse_appearance_table(appearance).with_context(|| format!("reading {}", path.display()))?)
}
Some(_) => anyhow::bail!("reading {}: [appearance] is a table", path.display()),
};
Ok(UserConfig { terminal, logs, appearance })
}
fn parse_appearance_table(table: &toml::Table) -> anyhow::Result<AppearanceSettings> {
let appearance: AppearanceSettings = toml::Value::Table(table.clone())
.try_into()
.context("[appearance] is not an appearance")?;
validate_appearance(&appearance).context("[appearance]")?;
Ok(appearance)
}
fn validate_appearance(appearance: &AppearanceSettings) -> anyhow::Result<()> {
if appearance.theme.trim().is_empty() {
anyhow::bail!("appearance.theme is `auto` or a theme's label, not empty");
}
if !DENSITIES.contains(&appearance.density.as_str()) {
anyhow::bail!("appearance.density must be one of {}; got '{}'", DENSITIES.join(", "), appearance.density);
}
if !MESSAGE_LAYOUTS.contains(&appearance.message_layout.as_str()) {
anyhow::bail!(
"appearance.message_layout must be one of {}; got '{}'",
MESSAGE_LAYOUTS.join(", "),
appearance.message_layout
);
}
if let Some(block) = appearance.hidden_blocks.iter().find(|block| !BLOCKS.contains(&block.as_str())) {
anyhow::bail!("appearance.hidden_blocks names {}; blocks are {}", block, BLOCKS.join(", "));
}
Ok(())
}
fn set_appearance_table(table: &mut toml::Table, appearance: &AppearanceSettings) {
let mut written = toml::Table::new();
written.insert("theme".to_string(), toml::Value::String(appearance.theme.clone()));
written.insert("density".to_string(), toml::Value::String(appearance.density.clone()));
written.insert("message_layout".to_string(), toml::Value::String(appearance.message_layout.clone()));
written.insert(
"hidden_blocks".to_string(),
toml::Value::Array(appearance.hidden_blocks.iter().cloned().map(toml::Value::String).collect()),
);
table.insert("appearance".to_string(), toml::Value::Table(written));
}
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::host_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 std::fmt::Display) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("{error:#}"),
}),
)
}
fn refused(error: impl std::fmt::Display) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::UNPROCESSABLE_ENTITY,
Json(ErrorResponse {
error: format!("{error:#}"),
}),
)
}
#[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 workshop = tempfile::tempdir().unwrap();
let _env = Environment::set(&[("PRJ", ""), (wsb::daemon::HOST_DIR_ENV, &workshop.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_eq!(settings.max_bytes, configured_max_bytes(), "the directory is held to the workshop's limit");
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_a_worktrees_settings_are_its_committed_files() {
let repo = TempDir::new().unwrap();
let workshop = TempDir::new().unwrap();
managed(repo.path());
let _env = Environment::set(&[("PRJ", ""), (wsb::daemon::HOST_DIR_ENV, &workshop.path().to_string_lossy())]);
let rt = tokio::runtime::Runtime::new().unwrap();
let settings = rt.block_on(load_worktree_settings(repo.path())).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.logs.root, wsb::logs::root(repo.path()).unwrap().display().to_string());
let answer = serde_json::to_value(&settings).unwrap();
for part in ["terminal", "server", "agent_dirs"] {
assert!(answer.get(part).is_none(), "a worktree's settings carry the workshop's {part}");
}
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_worktree_settings(repo.path())).unwrap();
assert_eq!(read.repo.settings.version.major, 8);
assert_eq!(read.project.settings.boards, vec!["owner/7".to_string()]);
let terminal = serde_json::json!({ "terminal": { "mode": "clipboard" } });
assert!(serde_json::from_value::<UpdateWorktreeSettingsRequest>(terminal).is_err());
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 workshop = TempDir::new().unwrap();
let _env = Environment::set(&[("PRJ", ""), (wsb::daemon::HOST_DIR_ENV, &workshop.path().to_string_lossy())]);
let rt = tokio::runtime::Runtime::new().unwrap();
let settings = rt
.block_on(load_workshop_settings(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), 4123))
.unwrap();
assert_eq!(settings.server.host, "0.0.0.0");
assert_eq!(settings.server.port, 4123);
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 workshop = TempDir::new().unwrap();
let _env = Environment::set(&[(wsb::daemon::HOST_DIR_ENV, &workshop.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");
}
async fn call(router: &Router, method: &str, body: Option<serde_json::Value>) -> (StatusCode, serde_json::Value) {
use tower::ServiceExt;
let request = axum::http::Request::builder()
.method(method)
.uri("/api/v1/settings")
.header("content-type", "application/json")
.body(body.map_or_else(axum::body::Body::empty, |body| axum::body::Body::from(body.to_string())))
.expect("a request");
let response = router.clone().oneshot(request).await.expect("the router answers");
let status = response.status();
let bytes = hyper::body::to_bytes(response.into_body()).await.expect("a body");
(status, serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null))
}
#[tokio::test]
async fn test20230_the_workshops_settings_need_no_worktree() {
let host_dir = TempDir::new().unwrap();
let _env = Environment::set(&[("PRJ", ""), (wsb::daemon::HOST_DIR_ENV, &host_dir.path().to_string_lossy())]);
let workshop = WorkshopState::new(
host_dir.path().to_path_buf(),
&host_dir.path().join("wsb.db"),
std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
4123,
std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
0,
)
.expect("a workshop");
assert!(workshop.all().await.is_empty(), "no worktree is open");
let router = workshop_routes().with_state(workshop);
let (status, read) = call(&router, "GET", None).await;
assert_eq!(status, StatusCode::OK, "{read}");
assert_eq!(read["terminal"]["mode"], "auto");
assert_eq!(read["server"]["port"], 4123);
assert_eq!(read["logs"]["max_bytes"], wsb::logs::DEFAULT_MAX_BYTES);
assert_eq!(read["logs"]["min_bytes"], wsb::logs::MIN_MAX_BYTES);
assert!(read.get("repo").is_none(), "workshop settings carry a worktree's: {read}");
let limit = wsb::logs::MIN_MAX_BYTES * 2;
let change = serde_json::json!({
"terminal": { "mode": "custom", "custom_bin": "/usr/bin/kitty", "custom_args": "-- bash -lc {cmd}" },
"logs": { "max_bytes": limit },
});
let (status, written) = call(&router, "PUT", Some(change)).await;
assert_eq!(status, StatusCode::OK, "{written}");
assert_eq!((written["terminal"]["mode"].as_str(), written["logs"]["max_bytes"].as_u64()), (Some("custom"), Some(limit)));
let config = std::fs::read_to_string(host_dir.path().join("config.toml")).unwrap();
assert!(config.contains("/usr/bin/kitty") && config.contains(&limit.to_string()), "{config}");
assert_eq!(configured_max_bytes(), limit, "the sweeper does not see the new limit");
let (status, said) = call(
&router,
"PUT",
Some(serde_json::json!({ "terminal": { "mode": "custom" }, "logs": { "max_bytes": limit * 2 } })),
)
.await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{said}");
assert!(said["error"].as_str().unwrap_or_default().contains("custom_bin"), "{said}");
assert_eq!(std::fs::read_to_string(host_dir.path().join("config.toml")).unwrap(), config, "a refused change was written");
let (status, said) = call(&router, "PUT", Some(serde_json::json!({ "repo": { "name": "x" } }))).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "a worktree's settings were taken as the workshop's: {said}");
}
#[tokio::test]
async fn test20232_the_appearance_is_kept_with_the_workshop() {
let host_dir = TempDir::new().unwrap();
let _env = Environment::set(&[("PRJ", ""), (wsb::daemon::HOST_DIR_ENV, &host_dir.path().to_string_lossy())]);
let workshop = WorkshopState::new(
host_dir.path().to_path_buf(),
&host_dir.path().join("wsb.db"),
std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
4123,
std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
0,
)
.expect("a workshop");
let router = workshop_routes().with_state(workshop);
let config = host_dir.path().join("config.toml");
let (status, read) = call(&router, "GET", None).await;
assert_eq!(status, StatusCode::OK, "{read}");
assert!(read["appearance"].is_null(), "an appearance nobody chose: {read}");
let chosen = serde_json::json!({
"theme": "WSB Dark",
"density": "comfortable",
"message_layout": "stream",
"hidden_blocks": ["thinking", "tool"],
});
let (status, written) = call(&router, "PUT", Some(serde_json::json!({ "appearance": chosen }))).await;
assert_eq!(status, StatusCode::OK, "{written}");
assert_eq!(written["appearance"], chosen);
assert_eq!(call(&router, "GET", None).await.1["appearance"], chosen, "the appearance was not kept");
let kept = std::fs::read_to_string(&config).unwrap();
assert!(kept.contains("[appearance]") && kept.contains("WSB Dark"), "{kept}");
for (key, value) in [
("density", serde_json::json!("dense")),
("message_layout", serde_json::json!("grid")),
("hidden_blocks", serde_json::json!(["images"])),
("theme", serde_json::json!(" ")),
] {
let mut refused = chosen.clone();
refused[key] = value;
let (status, said) = call(&router, "PUT", Some(serde_json::json!({ "appearance": refused }))).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{key}: {said}");
assert!(said["error"].as_str().unwrap_or_default().contains(key), "{key}: {said}");
assert_eq!(std::fs::read_to_string(&config).unwrap(), kept, "a refused {key} was written");
}
std::fs::write(&config, kept.replace("comfortable", "roomy")).unwrap();
let (status, said) = call(&router, "GET", None).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR, "a density the workbench does not have was read: {said}");
let message = said["error"].as_str().unwrap_or_default();
assert!(message.contains("config.toml") && message.contains("density"), "{message}");
}
}