Add process-wide Settings model (ticket 008)

Introduces src/settings.rs with a single, process-wide Settings struct
(colour scheme, CSS entries shaped for imported-vs-linked, and a
profiles placeholder for ticket 009), JSON-serialized via serde_json to
keep deps minimal. Default settings path is computed with the
directories crate (ProjectDirs::from("", "", "ottyl")) and overridable
via a new --settings-path CLI arg. Settings::load creates and persists
a default file on first run; Settings::save persists changes.

Wires a single Arc<Mutex<Settings>> into main() and threads it through
api::router as an additive ApiState field for tickets 010/011 to build
on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-05 12:42:53 +01:00
co-authored by Claude Sonnet 5
parent 1fb564d06b
commit 22647620fa
5 changed files with 388 additions and 3 deletions
+12 -2
View File
@@ -1,5 +1,8 @@
use std::sync::{Arc, Mutex};
use crate::engine::{EngineHandle, EngineMessage, EngineQuery, EngineResponse};
use crate::dto::{LogsParams, TimelineParams};
use crate::settings::Settings;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
@@ -8,13 +11,20 @@ use axum::Router;
use tokio::sync::oneshot;
use tower_http::services::{ServeDir, ServeFile};
/// Shared, process-wide settings handle. Cheap to clone; lock is only held
/// briefly since settings changes are rare (unlike the engine's hot path).
pub type SettingsHandle = Arc<Mutex<Settings>>;
#[derive(Clone)]
struct ApiState {
engine: EngineHandle,
// TODO(ticket 010/011): wire up settings/CSS/NavMenu endpoints using this.
#[allow(dead_code)]
settings: SettingsHandle,
}
pub fn router(engine: EngineHandle, static_dir: &str) -> Router {
let state = ApiState { engine };
pub fn router(engine: EngineHandle, static_dir: &str, settings: SettingsHandle) -> Router {
let state = ApiState { engine, settings };
let index_html = format!("{static_dir}/index.html");
let static_service = ServeDir::new(static_dir).fallback(ServeFile::new(index_html));
+38 -1
View File
@@ -5,12 +5,18 @@ mod engine;
mod index_key;
mod ingest;
mod metric_primitive;
mod settings;
mod timestamp;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use clap::Parser;
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use settings::Settings;
/// Observability dashboard from terminal output.
#[derive(Parser, Debug)]
struct Args {
@@ -21,17 +27,28 @@ struct Args {
/// Directory containing the built webapp (index.html + assets).
#[arg(long, default_value = "webapp/dist")]
static_dir: String,
/// Path to the settings file. Defaults to a per-OS config location
/// (e.g. `~/.config/ottyl/settings.json` on Linux).
#[arg(long)]
settings_path: Option<PathBuf>,
}
#[tokio::main]
async fn main() {
let args = Args::parse();
let settings_path = args.settings_path.clone().unwrap_or_else(|| {
settings::default_settings_path()
.expect("could not determine a default settings path; pass --settings-path")
});
let settings = Arc::new(Mutex::new(Settings::load(&settings_path)));
let (engine_tx, engine_rx) = mpsc::channel(1024);
tokio::spawn(engine::run(engine_rx));
tokio::spawn(ingest::run(engine_tx.clone()));
let app = api::router(engine_tx, &args.static_dir);
let app = api::router(engine_tx, &args.static_dir, settings);
let listener = TcpListener::bind(("0.0.0.0", args.port))
.await
.expect("failed to bind listener");
@@ -45,3 +62,23 @@ async fn main() {
.await
.expect("server error");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_path_defaults_to_none() {
let args = Args::parse_from(["ottyl"]);
assert_eq!(args.settings_path, None);
}
#[test]
fn settings_path_flag_overrides_default() {
let args = Args::parse_from(["ottyl", "--settings-path", "/tmp/custom-settings.json"]);
assert_eq!(
args.settings_path,
Some(PathBuf::from("/tmp/custom-settings.json"))
);
}
}
+199
View File
@@ -0,0 +1,199 @@
//! Process-wide settings model: colour scheme, CSS entries, and profile
//! pointers. There is exactly one `Settings` instance at runtime, loaded once
//! on startup and persisted to disk whenever it changes (see `main.rs`).
//!
//! Persistence format is JSON (via `serde_json`, already a dependency) rather
//! than TOML, to keep the dependency list minimal.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
/// User's preferred colour scheme for the webapp.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ColorScheme {
Light,
Dark,
#[default]
System,
}
/// A single CSS entry configured for the webapp: either content that ottyl
/// stores itself ("imported") or a reference to an external path/URL that
/// the webapp links to directly ("linked", and therefore not deletable via
/// the settings API since ottyl doesn't own it).
///
/// Ticket 010 builds the CSS import/link HTTP API around this shape.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum CssEntry {
Imported { id: String, filename: String },
Linked { path_or_url: String },
}
/// Placeholder shape for profile management (ticket 009 builds CRUD on top
/// of this). Profiles are expected to live as individual files under a
/// dedicated directory; this struct only tracks known profile names and
/// which one is currently active.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct Profiles {
/// Names of known profiles. Ticket 009 owns creating/loading the actual
/// profile files.
pub names: Vec<String>,
/// Name of the currently active profile, if any.
pub active_profile: Option<String>,
/// Directory (relative to the settings file's directory, or absolute)
/// where individual profile files are expected to live.
pub profiles_dir: String,
}
/// The single, process-wide settings instance.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Settings {
pub color_scheme: ColorScheme,
pub css_entries: Vec<CssEntry>,
pub profiles: Profiles,
}
impl Default for Settings {
fn default() -> Self {
Settings {
color_scheme: ColorScheme::default(),
css_entries: Vec::new(),
profiles: Profiles {
names: Vec::new(),
active_profile: None,
profiles_dir: "profiles".to_string(),
},
}
}
}
/// Computes the default settings file path using OS-conventional config
/// directories: `<config_dir>/settings.json`, where `config_dir` comes from
/// `directories::ProjectDirs::from("", "", "ottyl")`.
///
/// Returns `None` if no valid config directory could be determined for the
/// current OS/user (this mirrors `ProjectDirs::from`'s own fallibility).
pub fn default_settings_path() -> Option<PathBuf> {
ProjectDirs::from("", "", "ottyl").map(|dirs| dirs.config_dir().join("settings.json"))
}
impl Settings {
/// Loads settings from `path`. If the file doesn't exist yet, a default
/// `Settings` is created, persisted to `path`, and returned.
///
/// Any I/O or parse error while reading an existing file is treated as
/// non-fatal: it is logged to stderr and a default `Settings` is
/// returned (without overwriting the unreadable file).
pub fn load(path: &Path) -> Settings {
match fs::read_to_string(path) {
Ok(contents) => match serde_json::from_str(&contents) {
Ok(settings) => settings,
Err(err) => {
eprintln!(
"warning: failed to parse settings file at {}: {err}; using defaults",
path.display()
);
Settings::default()
}
},
Err(err) if err.kind() == io::ErrorKind::NotFound => {
let settings = Settings::default();
if let Err(save_err) = settings.save(path) {
eprintln!(
"warning: failed to create default settings file at {}: {save_err}",
path.display()
);
}
settings
}
Err(err) => {
eprintln!(
"warning: failed to read settings file at {}: {err}; using defaults",
path.display()
);
Settings::default()
}
}
}
/// Persists these settings to `path` as pretty-printed JSON, creating
/// any missing parent directories first.
pub fn save(&self, path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let contents = serde_json::to_string_pretty(self)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
fs::write(path, contents)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_settings_path_ends_with_settings_json() {
// We can't assert the exact OS path here without touching real
// config dirs, but we can verify the path-computation logic shapes
// it as expected.
if let Some(path) = default_settings_path() {
assert_eq!(path.file_name().unwrap(), "settings.json");
assert!(path.parent().is_some());
}
}
#[test]
fn load_creates_default_when_missing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("settings.json");
assert!(!path.exists());
let settings = Settings::load(&path);
assert_eq!(settings, Settings::default());
assert!(path.exists(), "load() should persist a default file");
let reloaded = Settings::load(&path);
assert_eq!(reloaded, settings);
}
#[test]
fn round_trips_through_save_and_load() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
let mut settings = Settings::default();
settings.color_scheme = ColorScheme::Dark;
settings.css_entries.push(CssEntry::Imported {
id: "abc123".to_string(),
filename: "theme.css".to_string(),
});
settings.css_entries.push(CssEntry::Linked {
path_or_url: "https://example.com/style.css".to_string(),
});
settings.profiles.names.push("default".to_string());
settings.profiles.active_profile = Some("default".to_string());
settings.save(&path).unwrap();
let loaded = Settings::load(&path);
assert_eq!(loaded, settings);
}
#[test]
fn save_creates_parent_directories() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("a").join("b").join("settings.json");
let settings = Settings::default();
settings.save(&path).unwrap();
assert!(path.exists());
}
}