Files
DS4Server/src/config.rs
2026-08-02 12:38:21 +02:00

542 lines
19 KiB
Rust

use serde::{Deserialize, Serialize};
use serde_norway::{Mapping, Value};
use std::collections::BTreeMap;
use std::fmt;
use std::fs;
use std::path::Path;
use crate::model::{MODEL_CHOICES, ModelChoice};
use crate::settings::{
DEFAULT_SYSTEM_PROMPT, GenerationPreferences, REASONING_MODES, ReasoningMode,
RuntimePreferences, SpeculativePreferences, SsdPreferences,
};
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct ModelPreferences {
pub(crate) reasoning_mode: ReasoningMode,
pub(crate) speculative: SpeculativePreferences,
pub(crate) ssd: SsdPreferences,
}
/// Settings the application persists beside its database, as a YAML file users
/// and agents can edit by hand. Only values that differ from the defaults are
/// written; anything missing falls back to the default.
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub model: ModelChoice,
pub default_permission_mode: PermissionMode,
pub idle_timeout_minutes: i32,
pub a2ui_enabled: bool,
pub dev_brain: DevBrainConfig,
pub endpoint: EndpointConfig,
pub system_prompt: String,
pub(crate) generation_profiles:
BTreeMap<ModelChoice, BTreeMap<ReasoningMode, GenerationPreferences>>,
pub(crate) model_profiles: BTreeMap<ModelChoice, ModelPreferences>,
pub runtime: RuntimePreferences,
pub git: GitConfig,
pub interface: InterfaceConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
model: ModelChoice::default(),
default_permission_mode: PermissionMode::default(),
idle_timeout_minutes: 10,
a2ui_enabled: true,
dev_brain: DevBrainConfig::default(),
endpoint: EndpointConfig::default(),
system_prompt: DEFAULT_SYSTEM_PROMPT.into(),
generation_profiles: MODEL_CHOICES
.into_iter()
.map(|model| {
(
model,
REASONING_MODES
.into_iter()
.map(|mode| (mode, GenerationPreferences::default()))
.collect(),
)
})
.collect(),
model_profiles: MODEL_CHOICES
.into_iter()
.map(|model| (model, ModelPreferences::default()))
.collect(),
runtime: RuntimePreferences::default(),
git: GitConfig::default(),
interface: InterfaceConfig::default(),
}
}
}
pub const PERMISSION_MODES: [PermissionMode; 2] = [PermissionMode::Heuristic, PermissionMode::Ai];
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum PermissionMode {
#[default]
Heuristic,
Ai,
}
impl PermissionMode {
pub(crate) fn as_id(self) -> &'static str {
match self {
Self::Heuristic => "heuristic",
Self::Ai => "ai",
}
}
pub(crate) fn from_id(id: &str) -> Option<Self> {
match id {
"heuristic" => Some(Self::Heuristic),
"ai" => Some(Self::Ai),
_ => None,
}
}
}
impl fmt::Display for PermissionMode {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Heuristic => "Heuristic",
Self::Ai => "AI based",
})
}
}
pub const GIT_DIFF_ALGORITHMS: [GitDiffAlgorithm; 3] = [
GitDiffAlgorithm::Default,
GitDiffAlgorithm::Patience,
GitDiffAlgorithm::Minimal,
];
pub const GIT_DIFF_LAYOUTS: [GitDiffLayout; 2] = [GitDiffLayout::Unified, GitDiffLayout::Split];
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GitDiffLayout {
#[default]
Unified,
Split,
}
impl fmt::Display for GitDiffLayout {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Unified => "1 column",
Self::Split => "2 columns",
})
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GitDiffAlgorithm {
#[default]
Default,
Patience,
Minimal,
}
impl fmt::Display for GitDiffAlgorithm {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Default => "Default (Myers)",
Self::Patience => "Patience",
Self::Minimal => "Minimal",
})
}
}
pub const GIT_DIFF_WHITESPACE_MODES: [GitDiffWhitespace; 4] = [
GitDiffWhitespace::ShowAll,
GitDiffWhitespace::IgnoreAll,
GitDiffWhitespace::IgnoreChanges,
GitDiffWhitespace::IgnoreEndOfLine,
];
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GitDiffWhitespace {
#[default]
ShowAll,
IgnoreAll,
IgnoreChanges,
IgnoreEndOfLine,
}
impl fmt::Display for GitDiffWhitespace {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::ShowAll => "Show all changes",
Self::IgnoreAll => "Ignore all whitespace",
Self::IgnoreChanges => "Ignore whitespace amount",
Self::IgnoreEndOfLine => "Ignore end-of-line whitespace",
})
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct GitConfig {
pub diff_layout: GitDiffLayout,
pub diff_algorithm: GitDiffAlgorithm,
pub context_lines: u32,
pub interhunk_lines: u32,
pub indent_heuristic: bool,
pub whitespace: GitDiffWhitespace,
pub ignore_blank_lines: bool,
}
impl Default for GitConfig {
fn default() -> Self {
Self {
diff_layout: GitDiffLayout::Unified,
diff_algorithm: GitDiffAlgorithm::Default,
context_lines: 3,
interhunk_lines: 0,
indent_heuristic: false,
whitespace: GitDiffWhitespace::ShowAll,
ignore_blank_lines: false,
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct DevBrainConfig {
pub enabled: bool,
pub vault_path: Option<String>,
}
impl DevBrainConfig {
pub fn vault(&self) -> Result<std::path::PathBuf, String> {
if !self.enabled {
return Err("Dev Brain is disabled.".into());
}
let path = self
.vault_path
.as_deref()
.filter(|path| !path.trim().is_empty())
.ok_or_else(|| "Choose an Obsidian vault before enabling Dev Brain.".to_owned())?;
crate::dev_brain::validate_vault(Path::new(path))
}
fn validate(&self) -> Result<(), String> {
if self.enabled {
self.vault().map(|_| ())
} else {
Ok(())
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct EndpointConfig {
pub port: i32,
pub enabled: bool,
pub cors: bool,
}
impl Default for EndpointConfig {
fn default() -> Self {
Self {
port: 4000,
enabled: true,
cors: false,
}
}
}
/// Window state the application restores on the next launch.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct InterfaceConfig {
pub sidebar_collapsed: bool,
pub sidebar_width: i32,
pub window_size: [u32; 2],
pub window_position: Option<[i32; 2]>,
/// Project the app reopens on. Cleared when that project goes away.
pub last_project_id: Option<i32>,
}
impl Default for InterfaceConfig {
fn default() -> Self {
Self {
sidebar_collapsed: false,
sidebar_width: 276,
window_size: [1120, 720],
window_position: None,
last_project_id: None,
}
}
}
impl Config {
/// A missing file is the default configuration; a malformed one is an error,
/// so a typo never silently resets everything.
pub fn load(path: &Path) -> Result<Self, String> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(Self::default());
}
Err(error) => return Err(format!("Could not read {}: {error}", path.display())),
};
let mut value: Value = serde_norway::from_str(&text)
.map_err(|error| format!("Could not read {}: {error}", path.display()))?;
drop_legacy_model_settings(&mut value);
let mut config: Self = serde_norway::from_value(value)
.map_err(|error| format!("Could not read {}: {error}", path.display()))?;
config.fill_profile_defaults();
config.validate()?;
Ok(config)
}
pub fn save(&self, path: &Path) -> Result<(), String> {
self.validate()?;
let value = serde_norway::to_value(self).map_err(|error| error.to_string())?;
let defaults =
serde_norway::to_value(Self::default()).map_err(|error| error.to_string())?;
let value = without_defaults(value, &defaults)
.unwrap_or_else(|| Value::Mapping(Mapping::default()));
let text = serde_norway::to_string(&value).map_err(|error| error.to_string())?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
}
fs::write(path, text)
.map_err(|error| format!("Could not write {}: {error}", path.display()))
}
pub fn validate(&self) -> Result<(), String> {
if !(1..=1440).contains(&self.idle_timeout_minutes) {
return Err("Idle timeout must be between 1 and 1440 minutes.".into());
}
if !(1..=65_535).contains(&self.endpoint.port) {
return Err("Endpoint port must be between 1 and 65535.".into());
}
for (model, profiles) in &self.generation_profiles {
for (mode, generation) in profiles {
let mut generation = generation.clone();
generation.system_prompt = self.system_prompt.clone();
generation.reasoning_mode = *mode;
generation.validate().map_err(|error| {
format!("Generation settings for {model} / {mode}: {error}")
})?;
}
}
for (model, preferences) in &self.model_profiles {
preferences.speculative.validate(*model)?;
preferences.ssd.validate(*model)?;
if preferences.reasoning_mode == ReasoningMode::Max
&& !self
.supported_reasoning_modes(*model)
.contains(&ReasoningMode::Max)
{
return Err(format!(
"Default thinking mode for {model} requires its Think Max profile to use at least 393216 context tokens."
));
}
}
self.runtime_for(self.model).validate(self.model)?;
self.dev_brain.validate()
}
pub(crate) fn reasoning_mode(&self, model: ModelChoice) -> ReasoningMode {
self.model_profiles
.get(&model)
.map_or(ReasoningMode::default(), |profile| profile.reasoning_mode)
}
pub(crate) fn generation_for(
&self,
model: ModelChoice,
reasoning_mode: ReasoningMode,
) -> GenerationPreferences {
let mut generation = self
.generation_profiles
.get(&model)
.and_then(|profiles| profiles.get(&reasoning_mode))
.cloned()
.unwrap_or_default();
generation.system_prompt = self.system_prompt.clone();
generation.reasoning_mode = reasoning_mode;
generation
}
pub(crate) fn active_generation(&self) -> GenerationPreferences {
self.generation_for(self.model, self.reasoning_mode(self.model))
}
pub(crate) fn runtime_for(&self, model: ModelChoice) -> RuntimePreferences {
let mut runtime = self.runtime.clone();
if let Some(profile) = self.model_profiles.get(&model) {
runtime.speculative = profile.speculative.clone();
runtime.ssd = profile.ssd.clone();
}
runtime
}
pub(crate) fn supported_reasoning_modes(&self, model: ModelChoice) -> &'static [ReasoningMode] {
let max = self.generation_for(model, ReasoningMode::Max);
max.supported_reasoning_modes()
}
fn fill_profile_defaults(&mut self) {
for model in MODEL_CHOICES {
self.model_profiles.entry(model).or_default();
let profiles = self.generation_profiles.entry(model).or_default();
for mode in REASONING_MODES {
profiles.entry(mode).or_default();
}
}
}
}
fn drop_legacy_model_settings(value: &mut Value) {
let Value::Mapping(root) = value else {
return;
};
let generation_key = Value::String("generation".into());
let prompt_key = Value::String("system_prompt".into());
if let Some(Value::Mapping(mut generation)) = root.remove(&generation_key)
&& !root.contains_key(&prompt_key)
&& let Some(prompt) = generation.remove(&prompt_key)
{
root.insert(prompt_key, prompt);
}
if let Some(Value::Mapping(runtime)) = root.get_mut(Value::String("runtime".into())) {
runtime.remove(Value::String("speculative".into()));
runtime.remove(Value::String("ssd".into()));
}
}
/// Drops every value that still matches the default, so the file lists only what
/// the user actually set. Mappings that end up empty disappear with their key.
fn without_defaults(value: Value, defaults: &Value) -> Option<Value> {
if let (Value::Mapping(entries), Value::Mapping(defaults)) = (&value, defaults) {
let kept = entries
.iter()
.filter_map(|(key, entry)| {
let kept = match defaults.get(key) {
Some(default) => without_defaults(entry.clone(), default)?,
None => entry.clone(),
};
Some((key.clone(), kept))
})
.collect::<Mapping>();
return (!kept.is_empty()).then_some(Value::Mapping(kept));
}
(&value != defaults).then_some(value)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::settings::{ReasoningMode, SsdPreferences, StreamingCacheBudget};
#[test]
fn defaults_write_an_empty_file_and_reload_unchanged() {
let directory = std::env::temp_dir().join(format!("ds4-config-{}", std::process::id()));
let path = directory.join("config.yaml");
Config::default().save(&path).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap().trim(), "{}");
assert_eq!(Config::load(&path).unwrap(), Config::default());
fs::remove_dir_all(&directory).unwrap();
}
#[test]
fn only_changed_values_are_written_and_read_back() {
let directory = std::env::temp_dir().join(format!("ds4-config-set-{}", std::process::id()));
let path = directory.join("config.yaml");
let mut config = Config {
model: ModelChoice::Glm52,
default_permission_mode: PermissionMode::Ai,
a2ui_enabled: false,
git: GitConfig {
diff_layout: GitDiffLayout::Split,
diff_algorithm: GitDiffAlgorithm::Patience,
context_lines: 5,
whitespace: GitDiffWhitespace::IgnoreEndOfLine,
..GitConfig::default()
},
interface: InterfaceConfig {
window_size: [1280, 800],
window_position: Some([120, -40]),
..InterfaceConfig::default()
},
..Config::default()
};
config
.generation_profiles
.get_mut(&ModelChoice::Glm52)
.unwrap()
.get_mut(&ReasoningMode::Direct)
.unwrap()
.context_tokens = 65_536;
let model = config.model_profiles.get_mut(&ModelChoice::Glm52).unwrap();
model.reasoning_mode = ReasoningMode::Direct;
model.ssd = SsdPreferences {
enabled: true,
cache: Some(StreamingCacheBudget::Gib(64)),
..SsdPreferences::default()
};
config.save(&path).unwrap();
let text = fs::read_to_string(&path).unwrap();
assert_eq!(
text,
"model: glm-5.2\ndefault_permission_mode: ai\na2ui_enabled: false\n\
generation_profiles:\n glm-5.2:\n none:\n context_tokens: 65536\n\
model_profiles:\n glm-5.2:\n reasoning_mode: none\n ssd:\n enabled: true\n cache: 64GB\n\
git:\n diff_layout: split\n diff_algorithm: patience\n context_lines: 5\n whitespace: ignore-end-of-line\n\
interface:\n window_size:\n - 1280\n - 800\n window_position:\n - 120\n - -40\n"
);
assert_eq!(Config::load(&path).unwrap(), config);
fs::remove_dir_all(&directory).unwrap();
}
#[test]
fn legacy_flat_model_settings_are_dropped_but_the_prompt_is_kept() {
let directory =
std::env::temp_dir().join(format!("ds4-config-legacy-{}", std::process::id()));
let path = directory.join("config.yaml");
fs::create_dir_all(&directory).unwrap();
fs::write(
&path,
"generation:\n system_prompt: keep me\n temperature: 0.2\nruntime:\n execution:\n quality: true\n ssd:\n enabled: true\n",
)
.unwrap();
let config = Config::load(&path).unwrap();
assert_eq!(config.system_prompt, "keep me");
assert_eq!(config.active_generation().temperature, None);
assert!(!config.runtime_for(config.model).ssd.enabled);
assert!(config.runtime.execution.quality);
fs::remove_dir_all(&directory).unwrap();
}
#[test]
fn invalid_files_are_reported_instead_of_reset() {
let directory = std::env::temp_dir().join(format!("ds4-config-bad-{}", std::process::id()));
let path = directory.join("config.yaml");
fs::create_dir_all(&directory).unwrap();
fs::write(&path, "idle_timeout_minutes: 0\n").unwrap();
assert!(Config::load(&path).is_err());
fs::write(&path, "nonsense: 1\n").unwrap();
assert!(Config::load(&path).is_err());
fs::remove_dir_all(&directory).unwrap();
// A missing file is simply the defaults.
assert_eq!(Config::load(&path).unwrap(), Config::default());
}
}