diff --git a/Cargo.lock b/Cargo.lock index 7fb22c4..be8cc27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -174,6 +174,27 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "errno" version = "0.3.14" @@ -184,6 +205,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -232,6 +259,28 @@ dependencies = [ "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]] name = "heck" version = "0.5.0" @@ -342,6 +391,21 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "lock_api" version = "0.4.14" @@ -408,14 +472,22 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ottyl" version = "0.1.0" dependencies = [ "axum", "clap", + "directories", "serde", "serde_json", + "tempfile", "tokio", "tower-http", ] @@ -473,6 +545,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -482,6 +560,30 @@ dependencies = [ "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]] name = "ryu" version = "1.0.23" @@ -615,6 +717,39 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "tokio" version = "1.53.1" diff --git a/Cargo.toml b/Cargo.toml index 24df485..06c67b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,3 +10,7 @@ tower-http = { version = "0.6", features = ["fs", "cors"] } serde = { version = "1", features = ["derive"] } serde_json = "1" clap = { version = "4", features = ["derive"] } +directories = "6.0.0" + +[dev-dependencies] +tempfile = "3.27.0" diff --git a/src/api.rs b/src/api.rs index c4baca2..dde0600 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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>; + #[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)); diff --git a/src/main.rs b/src/main.rs index 4b50470..4b3bf87 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, } #[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")) + ); + } +} diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..b8be1fa --- /dev/null +++ b/src/settings.rs @@ -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, + /// Name of the currently active profile, if any. + pub active_profile: Option, + /// 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, + 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: `/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 { + 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()); + } +}