344 lines
11 KiB
Rust
344 lines
11 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use serde_norway::{Mapping, Value};
|
|
use std::fmt;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
use crate::model::ModelChoice;
|
|
use crate::settings::{GenerationPreferences, RuntimePreferences};
|
|
|
|
/// 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 idle_timeout_minutes: i32,
|
|
pub a2ui_enabled: bool,
|
|
pub dev_brain: DevBrainConfig,
|
|
pub endpoint: EndpointConfig,
|
|
pub generation: GenerationPreferences,
|
|
pub runtime: RuntimePreferences,
|
|
pub git: GitConfig,
|
|
pub interface: InterfaceConfig,
|
|
}
|
|
|
|
impl Default for Config {
|
|
fn default() -> Self {
|
|
Self {
|
|
model: ModelChoice::default(),
|
|
idle_timeout_minutes: 10,
|
|
a2ui_enabled: true,
|
|
dev_brain: DevBrainConfig::default(),
|
|
endpoint: EndpointConfig::default(),
|
|
generation: GenerationPreferences::default(),
|
|
runtime: RuntimePreferences::default(),
|
|
git: GitConfig::default(),
|
|
interface: InterfaceConfig::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
/// 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,
|
|
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 config: Self = serde_norway::from_str(&text)
|
|
.map_err(|error| format!("Could not read {}: {error}", path.display()))?;
|
|
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());
|
|
}
|
|
self.generation.validate()?;
|
|
self.runtime.validate(self.model)?;
|
|
self.dev_brain.validate()
|
|
}
|
|
}
|
|
|
|
/// 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 default = defaults.get(key)?;
|
|
Some((key.clone(), without_defaults(entry.clone(), default)?))
|
|
})
|
|
.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 config = Config {
|
|
model: ModelChoice::Glm52,
|
|
a2ui_enabled: false,
|
|
generation: GenerationPreferences {
|
|
context_tokens: 65_536,
|
|
reasoning_mode: ReasoningMode::Direct,
|
|
..GenerationPreferences::default()
|
|
},
|
|
runtime: RuntimePreferences {
|
|
ssd: SsdPreferences {
|
|
enabled: true,
|
|
cache: Some(StreamingCacheBudget::Gib(64)),
|
|
..SsdPreferences::default()
|
|
},
|
|
..RuntimePreferences::default()
|
|
},
|
|
git: GitConfig {
|
|
diff_layout: GitDiffLayout::Split,
|
|
diff_algorithm: GitDiffAlgorithm::Patience,
|
|
context_lines: 5,
|
|
whitespace: GitDiffWhitespace::IgnoreEndOfLine,
|
|
..GitConfig::default()
|
|
},
|
|
..Config::default()
|
|
};
|
|
config.save(&path).unwrap();
|
|
|
|
let text = fs::read_to_string(&path).unwrap();
|
|
assert_eq!(
|
|
text,
|
|
"model: glm-5.2\na2ui_enabled: false\n\
|
|
generation:\n context_tokens: 65536\n reasoning_mode: none\n\
|
|
runtime:\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"
|
|
);
|
|
assert_eq!(Config::load(&path).unwrap(), config);
|
|
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());
|
|
}
|
|
}
|