Merge branch 'worktree-agent-a58c59406199f7d11'
# Conflicts: # src/dto.rs
This commit is contained in:
+135
-9
@@ -1,12 +1,13 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
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::{CssEntryDto, ImportCssRequest, LinkCssRequest, LogsParams, TimelineParams};
|
||||||
use crate::settings::Settings;
|
use crate::settings::{CssError, Settings};
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Path as AxumPath, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::{header, StatusCode};
|
||||||
use axum::response::{IntoResponse, Json};
|
use axum::response::{IntoResponse, Json};
|
||||||
use axum::routing::get;
|
use axum::routing::{delete, get, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
use tower_http::services::{ServeDir, ServeFile};
|
use tower_http::services::{ServeDir, ServeFile};
|
||||||
@@ -18,13 +19,32 @@ 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,
|
settings: SettingsHandle,
|
||||||
|
/// Path to the settings JSON file on disk. Imported CSS files are stored
|
||||||
|
/// alongside it, in its parent directory (see `ApiState::css_dir`).
|
||||||
|
settings_path: Arc<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(engine: EngineHandle, static_dir: &str, settings: SettingsHandle) -> Router {
|
impl ApiState {
|
||||||
let state = ApiState { engine, settings };
|
fn css_dir(&self) -> PathBuf {
|
||||||
|
self.settings_path
|
||||||
|
.parent()
|
||||||
|
.map(Path::to_path_buf)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router(
|
||||||
|
engine: EngineHandle,
|
||||||
|
static_dir: &str,
|
||||||
|
settings: SettingsHandle,
|
||||||
|
settings_path: PathBuf,
|
||||||
|
) -> Router {
|
||||||
|
let state = ApiState {
|
||||||
|
engine,
|
||||||
|
settings,
|
||||||
|
settings_path: Arc::new(settings_path),
|
||||||
|
};
|
||||||
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));
|
||||||
|
|
||||||
@@ -32,6 +52,10 @@ pub fn router(engine: EngineHandle, static_dir: &str, settings: SettingsHandle)
|
|||||||
.route("/api/logs", get(get_logs))
|
.route("/api/logs", get(get_logs))
|
||||||
.route("/api/timeline", get(get_timeline))
|
.route("/api/timeline", get(get_timeline))
|
||||||
.route("/api/stats", get(get_stats))
|
.route("/api/stats", get(get_stats))
|
||||||
|
.route("/api/settings/css", get(list_css).post(import_css))
|
||||||
|
.route("/api/settings/css/link", post(link_css))
|
||||||
|
.route("/api/settings/css/{id}", delete(remove_css))
|
||||||
|
.route("/api/settings/css/{id}/content", get(get_css_content))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
.fallback_service(static_service)
|
.fallback_service(static_service)
|
||||||
}
|
}
|
||||||
@@ -74,3 +98,105 @@ async fn get_stats(State(state): State<ApiState>) -> impl IntoResponse {
|
|||||||
Err(status) => status.into_response(),
|
Err(status) => status.into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Maps a `CssError` from a settings mutation/lookup to a response status
|
||||||
|
/// and message: `NotFound` -> 404, `NotImported` -> 400 (the id is valid but
|
||||||
|
/// refers to a linked entry, which this operation doesn't apply to), `Io` ->
|
||||||
|
/// 500.
|
||||||
|
fn css_error_response(err: CssError) -> axum::response::Response {
|
||||||
|
match err {
|
||||||
|
CssError::NotFound => (StatusCode::NOT_FOUND, err.to_string()).into_response(),
|
||||||
|
CssError::NotImported => (StatusCode::BAD_REQUEST, err.to_string()).into_response(),
|
||||||
|
CssError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/settings/css` — lists all configured CSS entries (imported and
|
||||||
|
/// linked) as `Vec<CssEntryDto>`.
|
||||||
|
async fn list_css(State(state): State<ApiState>) -> impl IntoResponse {
|
||||||
|
let settings = state.settings.lock().unwrap();
|
||||||
|
let entries: Vec<CssEntryDto> = settings.css_entries.iter().map(CssEntryDto::from).collect();
|
||||||
|
Json(entries).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/settings/css` — imports raw CSS content. Body:
|
||||||
|
/// `{ "filename": "theme.css", "content": "body { ... }" }`. Stores the
|
||||||
|
/// content as a file alongside the settings file, adds a `CssEntry::Imported`
|
||||||
|
/// to settings, and returns the new entry as `CssEntryDto` (201 Created).
|
||||||
|
async fn import_css(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
Json(body): Json<ImportCssRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let css_dir = state.css_dir();
|
||||||
|
let mut settings = state.settings.lock().unwrap();
|
||||||
|
match settings.import_css(&state.settings_path, &css_dir, body.filename, &body.content) {
|
||||||
|
Ok(id) => {
|
||||||
|
let entry = settings
|
||||||
|
.css_entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| crate::settings::css_entry_id(entry) == id)
|
||||||
|
.expect("entry was just inserted by import_css");
|
||||||
|
(StatusCode::CREATED, Json(CssEntryDto::from(entry))).into_response()
|
||||||
|
}
|
||||||
|
Err(err) => css_error_response(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/settings/css/link` — registers a reference to an external CSS
|
||||||
|
/// path/URL. Body: `{ "path_or_url": "https://example.com/style.css" }`.
|
||||||
|
/// Content is never copied; the entry is never deletable via
|
||||||
|
/// `DELETE /api/settings/css/:id`. Returns the new entry as `CssEntryDto`
|
||||||
|
/// (201 Created).
|
||||||
|
async fn link_css(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
Json(body): Json<LinkCssRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let mut settings = state.settings.lock().unwrap();
|
||||||
|
match settings.link_css(&state.settings_path, body.path_or_url.clone()) {
|
||||||
|
Ok(()) => {
|
||||||
|
let entry = settings
|
||||||
|
.css_entries
|
||||||
|
.last()
|
||||||
|
.expect("entry was just pushed by link_css");
|
||||||
|
(StatusCode::CREATED, Json(CssEntryDto::from(entry))).into_response()
|
||||||
|
}
|
||||||
|
Err(err) => css_error_response(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE /api/settings/css/:id` — removes an imported CSS entry (file +
|
||||||
|
/// settings reference). Rejects with 400 if `id` refers to a `Linked` entry
|
||||||
|
/// (never deletable this way) and 404 if `id` is unknown. On success returns
|
||||||
|
/// 204 No Content.
|
||||||
|
async fn remove_css(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
AxumPath(id): AxumPath<String>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let css_dir = state.css_dir();
|
||||||
|
let mut settings = state.settings.lock().unwrap();
|
||||||
|
match settings.remove_css(&state.settings_path, &css_dir, &id) {
|
||||||
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(err) => css_error_response(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/settings/css/:id/content` — serves back the raw CSS content of
|
||||||
|
/// an imported entry (`content-type: text/css`). Rejects with 400 for a
|
||||||
|
/// linked entry (there's no local content to serve — the frontend should use
|
||||||
|
/// `path_or_url` directly) and 404 for an unknown id.
|
||||||
|
async fn get_css_content(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
AxumPath(id): AxumPath<String>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let css_dir = state.css_dir();
|
||||||
|
let settings = state.settings.lock().unwrap();
|
||||||
|
match settings.css_content(&css_dir, &id) {
|
||||||
|
Ok(content) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
[(header::CONTENT_TYPE, "text/css; charset=utf-8")],
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(err) => css_error_response(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+56
@@ -1,4 +1,5 @@
|
|||||||
use crate::data_value::{MetricPrimitiveKind, RegexToMetricPrimitiveMatcher};
|
use crate::data_value::{MetricPrimitiveKind, RegexToMetricPrimitiveMatcher};
|
||||||
|
use crate::settings::CssEntry;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -52,3 +53,58 @@ pub struct TimelineParams {
|
|||||||
pub end_ms: Option<i64>,
|
pub end_ms: Option<i64>,
|
||||||
pub bucket_ms: Option<i64>,
|
pub bucket_ms: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request body for `POST /api/settings/css`: raw CSS content to import and
|
||||||
|
/// store alongside the settings file.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ImportCssRequest {
|
||||||
|
pub filename: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body for `POST /api/settings/css/link`: a reference to an
|
||||||
|
/// external CSS path/URL that ottyl doesn't copy or own.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LinkCssRequest {
|
||||||
|
pub path_or_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response shape for a single CSS entry, used both by the list endpoint and
|
||||||
|
/// by mutation endpoints that return the affected entry. `id` is the entry's
|
||||||
|
/// real id for `Imported` entries, or a stable id derived from the
|
||||||
|
/// linked path/URL for `Linked` entries (see `settings::css_entry_id`) —
|
||||||
|
/// this lets the frontend key/reference both kinds of entries uniformly,
|
||||||
|
/// even though only `Imported` entries are deletable/content-fetchable.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct CssEntryDto {
|
||||||
|
pub id: String,
|
||||||
|
pub kind: CssEntryKind,
|
||||||
|
pub filename: Option<String>,
|
||||||
|
pub path_or_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum CssEntryKind {
|
||||||
|
Imported,
|
||||||
|
Linked,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&CssEntry> for CssEntryDto {
|
||||||
|
fn from(entry: &CssEntry) -> Self {
|
||||||
|
match entry {
|
||||||
|
CssEntry::Imported { id, filename } => CssEntryDto {
|
||||||
|
id: id.clone(),
|
||||||
|
kind: CssEntryKind::Imported,
|
||||||
|
filename: Some(filename.clone()),
|
||||||
|
path_or_url: None,
|
||||||
|
},
|
||||||
|
CssEntry::Linked { path_or_url } => CssEntryDto {
|
||||||
|
id: crate::settings::css_entry_id(entry),
|
||||||
|
kind: CssEntryKind::Linked,
|
||||||
|
filename: None,
|
||||||
|
path_or_url: Some(path_or_url.clone()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@ async fn main() {
|
|||||||
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, settings);
|
let app = api::router(engine_tx, &args.static_dir, settings, settings_path);
|
||||||
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");
|
||||||
|
|||||||
+265
@@ -5,9 +5,13 @@
|
|||||||
//! Persistence format is JSON (via `serde_json`, already a dependency) rather
|
//! Persistence format is JSON (via `serde_json`, already a dependency) rather
|
||||||
//! than TOML, to keep the dependency list minimal.
|
//! than TOML, to keep the dependency list minimal.
|
||||||
|
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::fmt;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use directories::ProjectDirs;
|
use directories::ProjectDirs;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -134,6 +138,166 @@ impl Settings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Error returned by CSS-entry mutations and lookups (ticket 010).
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum CssError {
|
||||||
|
/// No entry with the given id exists.
|
||||||
|
NotFound,
|
||||||
|
/// The entry exists but is a `Linked` entry, not an `Imported` one, so
|
||||||
|
/// the requested operation (delete / read-content) doesn't apply: ottyl
|
||||||
|
/// never owns a linked entry's content, so there's nothing to delete or
|
||||||
|
/// serve back.
|
||||||
|
NotImported,
|
||||||
|
/// Underlying I/O failure: writing/reading/removing the CSS file on
|
||||||
|
/// disk, or persisting `settings.json`.
|
||||||
|
Io(io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for CssError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
CssError::NotFound => write!(f, "no CSS entry with that id"),
|
||||||
|
CssError::NotImported => write!(f, "CSS entry is linked, not imported"),
|
||||||
|
CssError::Io(err) => write!(f, "I/O error: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for CssError {}
|
||||||
|
|
||||||
|
impl From<io::Error> for CssError {
|
||||||
|
fn from(err: io::Error) -> Self {
|
||||||
|
CssError::Io(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the id used to reference a `CssEntry` over the API.
|
||||||
|
///
|
||||||
|
/// For `Imported` entries this is just the stored id. `Linked` entries have
|
||||||
|
/// no stored id (ottyl doesn't own them), so one is derived deterministically
|
||||||
|
/// by hashing the `path_or_url`; this lets the frontend key/reference linked
|
||||||
|
/// entries the same way as imported ones (e.g. for list rendering) without
|
||||||
|
/// ottyl needing to persist an id it has no other use for. Because it's
|
||||||
|
/// derived from content, the same `path_or_url` always maps to the same id.
|
||||||
|
pub fn css_entry_id(entry: &CssEntry) -> String {
|
||||||
|
match entry {
|
||||||
|
CssEntry::Imported { id, .. } => id.clone(),
|
||||||
|
CssEntry::Linked { path_or_url } => linked_entry_id(path_or_url),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn linked_entry_id(path_or_url: &str) -> String {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
path_or_url.hash(&mut hasher);
|
||||||
|
format!("linked-{:016x}", hasher.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates an id for a newly-imported CSS entry. Not cryptographically
|
||||||
|
/// meaningful — just needs to be unique enough in practice: it hashes the
|
||||||
|
/// filename, content, and current time together, avoiding a new dependency
|
||||||
|
/// (e.g. `uuid`) for what's otherwise a random-ish opaque string.
|
||||||
|
fn generate_imported_css_id(filename: &str, content: &str) -> String {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
filename.hash(&mut hasher);
|
||||||
|
content.hash(&mut hasher);
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos())
|
||||||
|
.unwrap_or(0);
|
||||||
|
nanos.hash(&mut hasher);
|
||||||
|
format!("{:016x}", hasher.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn imported_css_file_path(css_dir: &Path, id: &str) -> PathBuf {
|
||||||
|
css_dir.join(format!("{id}.css"))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Settings {
|
||||||
|
/// Imports raw CSS `content`: writes it to `<css_dir>/<id>.css` (id
|
||||||
|
/// generated from the filename/content/timestamp, see
|
||||||
|
/// `generate_imported_css_id`), records a `CssEntry::Imported { id,
|
||||||
|
/// filename }` in `css_entries`, and persists settings to
|
||||||
|
/// `settings_path`. Returns the generated id.
|
||||||
|
///
|
||||||
|
/// `css_dir` is expected to be the settings file's parent directory
|
||||||
|
/// (i.e. CSS files live alongside `settings.json`).
|
||||||
|
pub fn import_css(
|
||||||
|
&mut self,
|
||||||
|
settings_path: &Path,
|
||||||
|
css_dir: &Path,
|
||||||
|
filename: String,
|
||||||
|
content: &str,
|
||||||
|
) -> Result<String, CssError> {
|
||||||
|
fs::create_dir_all(css_dir)?;
|
||||||
|
let id = generate_imported_css_id(&filename, content);
|
||||||
|
fs::write(imported_css_file_path(css_dir, &id), content)?;
|
||||||
|
self.css_entries.push(CssEntry::Imported {
|
||||||
|
id: id.clone(),
|
||||||
|
filename,
|
||||||
|
});
|
||||||
|
self.save(settings_path)?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers a reference to an external CSS path/URL without copying
|
||||||
|
/// its content. Persists settings to `settings_path`.
|
||||||
|
pub fn link_css(&mut self, settings_path: &Path, path_or_url: String) -> Result<(), CssError> {
|
||||||
|
self.css_entries.push(CssEntry::Linked { path_or_url });
|
||||||
|
self.save(settings_path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes an imported CSS entry identified by `id` (see
|
||||||
|
/// `css_entry_id`): deletes its file under `css_dir` and drops it from
|
||||||
|
/// `css_entries`, then persists settings to `settings_path`.
|
||||||
|
///
|
||||||
|
/// Returns `CssError::NotFound` if no entry matches `id`, and
|
||||||
|
/// `CssError::NotImported` if `id` refers to a `Linked` entry — linked
|
||||||
|
/// entries are never deletable this way since ottyl doesn't own their
|
||||||
|
/// content.
|
||||||
|
pub fn remove_css(
|
||||||
|
&mut self,
|
||||||
|
settings_path: &Path,
|
||||||
|
css_dir: &Path,
|
||||||
|
id: &str,
|
||||||
|
) -> Result<(), CssError> {
|
||||||
|
let index = self
|
||||||
|
.css_entries
|
||||||
|
.iter()
|
||||||
|
.position(|entry| css_entry_id(entry) == id);
|
||||||
|
match index.map(|i| &self.css_entries[i]) {
|
||||||
|
None => Err(CssError::NotFound),
|
||||||
|
Some(CssEntry::Linked { .. }) => Err(CssError::NotImported),
|
||||||
|
Some(CssEntry::Imported { .. }) => {
|
||||||
|
let idx = index.unwrap();
|
||||||
|
self.css_entries.remove(idx);
|
||||||
|
// Best-effort: if the file is already gone, the settings
|
||||||
|
// reference is still what matters and has been removed above.
|
||||||
|
let _ = fs::remove_file(imported_css_file_path(css_dir, id));
|
||||||
|
self.save(settings_path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads back the raw content of an imported CSS entry identified by
|
||||||
|
/// `id` (see `css_entry_id`).
|
||||||
|
///
|
||||||
|
/// Returns `CssError::NotFound` if no entry matches `id`, and
|
||||||
|
/// `CssError::NotImported` if `id` refers to a `Linked` entry (there's no
|
||||||
|
/// local content to serve for those — the frontend should link/fetch the
|
||||||
|
/// original `path_or_url` directly instead).
|
||||||
|
pub fn css_content(&self, css_dir: &Path, id: &str) -> Result<String, CssError> {
|
||||||
|
match self.css_entries.iter().find(|entry| css_entry_id(entry) == id) {
|
||||||
|
None => Err(CssError::NotFound),
|
||||||
|
Some(CssEntry::Linked { .. }) => Err(CssError::NotImported),
|
||||||
|
Some(CssEntry::Imported { .. }) => {
|
||||||
|
Ok(fs::read_to_string(imported_css_file_path(css_dir, id))?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -196,4 +360,105 @@ mod tests {
|
|||||||
|
|
||||||
assert!(path.exists());
|
assert!(path.exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_css_persists_file_and_entry_across_reload() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let settings_path = dir.path().join("settings.json");
|
||||||
|
let css_dir = dir.path().to_path_buf();
|
||||||
|
|
||||||
|
let mut settings = Settings::load(&settings_path);
|
||||||
|
let id = settings
|
||||||
|
.import_css(
|
||||||
|
&settings_path,
|
||||||
|
&css_dir,
|
||||||
|
"theme.css".to_string(),
|
||||||
|
"body { color: red; }",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let css_file = css_dir.join(format!("{id}.css"));
|
||||||
|
assert!(css_file.exists());
|
||||||
|
assert_eq!(fs::read_to_string(&css_file).unwrap(), "body { color: red; }");
|
||||||
|
|
||||||
|
// Drop and reload settings from disk (simulates a restart).
|
||||||
|
drop(settings);
|
||||||
|
let reloaded = Settings::load(&settings_path);
|
||||||
|
assert!(reloaded.css_entries.iter().any(|entry| matches!(
|
||||||
|
entry,
|
||||||
|
CssEntry::Imported { id: entry_id, filename } if entry_id == &id && filename == "theme.css"
|
||||||
|
)));
|
||||||
|
assert!(css_file.exists());
|
||||||
|
assert_eq!(
|
||||||
|
reloaded.css_content(&css_dir, &id).unwrap(),
|
||||||
|
"body { color: red; }"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_css_deletes_file_and_entry() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let settings_path = dir.path().join("settings.json");
|
||||||
|
let css_dir = dir.path().to_path_buf();
|
||||||
|
|
||||||
|
let mut settings = Settings::load(&settings_path);
|
||||||
|
let id = settings
|
||||||
|
.import_css(&settings_path, &css_dir, "a.css".to_string(), "a{}")
|
||||||
|
.unwrap();
|
||||||
|
let css_file = css_dir.join(format!("{id}.css"));
|
||||||
|
assert!(css_file.exists());
|
||||||
|
|
||||||
|
settings.remove_css(&settings_path, &css_dir, &id).unwrap();
|
||||||
|
|
||||||
|
assert!(!css_file.exists());
|
||||||
|
assert!(!settings.css_entries.iter().any(|e| css_entry_id(e) == id));
|
||||||
|
|
||||||
|
let reloaded = Settings::load(&settings_path);
|
||||||
|
assert!(!reloaded.css_entries.iter().any(|e| css_entry_id(e) == id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_css_rejects_linked_entry() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let settings_path = dir.path().join("settings.json");
|
||||||
|
let css_dir = dir.path().to_path_buf();
|
||||||
|
|
||||||
|
let mut settings = Settings::load(&settings_path);
|
||||||
|
settings
|
||||||
|
.link_css(&settings_path, "https://example.com/style.css".to_string())
|
||||||
|
.unwrap();
|
||||||
|
let linked_id = css_entry_id(&settings.css_entries[0]);
|
||||||
|
|
||||||
|
let result = settings.remove_css(&settings_path, &css_dir, &linked_id);
|
||||||
|
assert!(matches!(result, Err(CssError::NotImported)));
|
||||||
|
// Entry must still be there — rejection shouldn't silently drop it.
|
||||||
|
assert_eq!(settings.css_entries.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_css_rejects_unknown_id() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let settings_path = dir.path().join("settings.json");
|
||||||
|
let css_dir = dir.path().to_path_buf();
|
||||||
|
|
||||||
|
let mut settings = Settings::load(&settings_path);
|
||||||
|
let result = settings.remove_css(&settings_path, &css_dir, "does-not-exist");
|
||||||
|
assert!(matches!(result, Err(CssError::NotFound)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn css_content_rejects_linked_entry() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let settings_path = dir.path().join("settings.json");
|
||||||
|
let css_dir = dir.path().to_path_buf();
|
||||||
|
|
||||||
|
let mut settings = Settings::load(&settings_path);
|
||||||
|
settings
|
||||||
|
.link_css(&settings_path, "https://example.com/style.css".to_string())
|
||||||
|
.unwrap();
|
||||||
|
let linked_id = css_entry_id(&settings.css_entries[0]);
|
||||||
|
|
||||||
|
let result = settings.css_content(&css_dir, &linked_id);
|
||||||
|
assert!(matches!(result, Err(CssError::NotImported)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user