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:
co-authored by
Claude Sonnet 5
parent
1fb564d06b
commit
22647620fa
Generated
+135
@@ -174,6 +174,27 @@ version = "1.0.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "directories"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
|
||||||
|
dependencies = [
|
||||||
|
"dirs-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs-sys"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"option-ext",
|
||||||
|
"redox_users",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "errno"
|
name = "errno"
|
||||||
version = "0.3.14"
|
version = "0.3.14"
|
||||||
@@ -184,6 +205,12 @@ dependencies = [
|
|||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -232,6 +259,28 @@ dependencies = [
|
|||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -342,6 +391,21 @@ version = "0.2.189"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libredox"
|
||||||
|
version = "0.1.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lock_api"
|
name = "lock_api"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -408,14 +472,22 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "option-ext"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ottyl"
|
name = "ottyl"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"clap",
|
"clap",
|
||||||
|
"directories",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
]
|
]
|
||||||
@@ -473,6 +545,12 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -482,6 +560,30 @@ dependencies = [
|
|||||||
"bitflags",
|
"bitflags",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_users"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libredox",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
@@ -615,6 +717,39 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "2.0.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "2.0.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.53.1"
|
version = "1.53.1"
|
||||||
|
|||||||
@@ -10,3 +10,7 @@ tower-http = { version = "0.6", features = ["fs", "cors"] }
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
directories = "6.0.0"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.27.0"
|
||||||
|
|||||||
+12
-2
@@ -1,5 +1,8 @@
|
|||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use crate::engine::{EngineHandle, EngineMessage, EngineQuery, EngineResponse};
|
use crate::engine::{EngineHandle, EngineMessage, EngineQuery, EngineResponse};
|
||||||
use crate::dto::{LogsParams, TimelineParams};
|
use crate::dto::{LogsParams, TimelineParams};
|
||||||
|
use crate::settings::Settings;
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Json};
|
use axum::response::{IntoResponse, Json};
|
||||||
@@ -8,13 +11,20 @@ use axum::Router;
|
|||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
use tower_http::services::{ServeDir, ServeFile};
|
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)]
|
#[derive(Clone)]
|
||||||
struct ApiState {
|
struct ApiState {
|
||||||
engine: EngineHandle,
|
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 {
|
pub fn router(engine: EngineHandle, static_dir: &str, settings: SettingsHandle) -> Router {
|
||||||
let state = ApiState { engine };
|
let state = ApiState { engine, settings };
|
||||||
let index_html = format!("{static_dir}/index.html");
|
let index_html = format!("{static_dir}/index.html");
|
||||||
let static_service = ServeDir::new(static_dir).fallback(ServeFile::new(index_html));
|
let static_service = ServeDir::new(static_dir).fallback(ServeFile::new(index_html));
|
||||||
|
|
||||||
|
|||||||
+38
-1
@@ -5,12 +5,18 @@ mod engine;
|
|||||||
mod index_key;
|
mod index_key;
|
||||||
mod ingest;
|
mod ingest;
|
||||||
mod metric_primitive;
|
mod metric_primitive;
|
||||||
|
mod settings;
|
||||||
mod timestamp;
|
mod timestamp;
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
use settings::Settings;
|
||||||
|
|
||||||
/// Observability dashboard from terminal output.
|
/// Observability dashboard from terminal output.
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
struct Args {
|
struct Args {
|
||||||
@@ -21,17 +27,28 @@ struct Args {
|
|||||||
/// Directory containing the built webapp (index.html + assets).
|
/// Directory containing the built webapp (index.html + assets).
|
||||||
#[arg(long, default_value = "webapp/dist")]
|
#[arg(long, default_value = "webapp/dist")]
|
||||||
static_dir: String,
|
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]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
let args = Args::parse();
|
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);
|
let (engine_tx, engine_rx) = mpsc::channel(1024);
|
||||||
tokio::spawn(engine::run(engine_rx));
|
tokio::spawn(engine::run(engine_rx));
|
||||||
tokio::spawn(ingest::run(engine_tx.clone()));
|
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))
|
let listener = TcpListener::bind(("0.0.0.0", args.port))
|
||||||
.await
|
.await
|
||||||
.expect("failed to bind listener");
|
.expect("failed to bind listener");
|
||||||
@@ -45,3 +62,23 @@ async fn main() {
|
|||||||
.await
|
.await
|
||||||
.expect("server error");
|
.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
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user