Add CSS import/link/list/delete/content API (ticket 010)

Wires up Settings.css_entries behind a small settings API: import raw CSS
content to a file stored alongside settings.json, link external CSS
paths/URLs by reference (never deletable/copied), list all entries, delete
imported entries, and serve back imported content. CSS storage logic lives
on Settings (import_css/link_css/remove_css/css_content) with api.rs
handlers staying thin, matching the existing engine-query pattern.

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:51:22 +01:00
co-authored by Claude Sonnet 5
parent faca2af328
commit 71aaa4ebb4
4 changed files with 457 additions and 10 deletions
+265
View File
@@ -5,9 +5,13 @@
//! Persistence format is JSON (via `serde_json`, already a dependency) rather
//! than TOML, to keep the dependency list minimal.
use std::collections::hash_map::DefaultHasher;
use std::fmt;
use std::fs;
use std::hash::{Hash, Hasher};
use std::io;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use directories::ProjectDirs;
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)]
mod tests {
use super::*;
@@ -196,4 +360,105 @@ mod tests {
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)));
}
}