use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub const DEFAULT_PROJECT_CONFIG: &str = include_str!("../assets/default_project.hjson");
pub const DEFAULT_PROMPTS: &str = include_str!("../assets/default_prompts.hjson");
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub embeddings: EmbeddingsConfig,
#[serde(default)]
pub llm: LlmConfig,
#[serde(default)]
pub editor: EditorConfig,
#[serde(default)]
pub keys: KeyBindings,
#[serde(default)]
pub hierarchy: HierarchyConfig,
#[serde(default)]
pub theme: ThemeConfig,
#[serde(default)]
pub backup: BackupConfig,
#[serde(default)]
pub sound: SoundConfig,
#[serde(default)]
pub typst_templates: TypstTemplatesConfig,
#[serde(default)]
pub typst_compile: TypstCompileConfig,
#[serde(default)]
pub typst_page: TypstPageConfig,
#[serde(default)]
pub typst_fonts: TypstFontsConfig,
#[serde(default)]
pub typst_layout: TypstLayoutConfig,
#[serde(default)]
pub images: ImagesConfig,
#[serde(default)]
pub scripting: crate::scripting::policy::Policy,
#[serde(default = "default_language")]
pub language: String,
#[serde(default = "default_prompts_path")]
pub prompts_file: PathBuf,
#[serde(default = "default_artefacts_directory")]
pub artefacts_directory: String,
#[serde(default = "default_sync_interval")]
pub sync_interval_seconds: u64,
}
fn default_sync_interval() -> u64 {
600
}
fn default_prompts_path() -> PathBuf {
PathBuf::from("prompts.hjson")
}
fn default_language() -> String {
"english".into()
}
fn default_artefacts_directory() -> String {
String::new()
}
impl Default for Config {
fn default() -> Self {
Self {
embeddings: EmbeddingsConfig::default(),
llm: LlmConfig::default(),
editor: EditorConfig::default(),
keys: KeyBindings::default(),
hierarchy: HierarchyConfig::default(),
theme: ThemeConfig::default(),
backup: BackupConfig::default(),
sound: SoundConfig::default(),
typst_templates: TypstTemplatesConfig::default(),
typst_compile: TypstCompileConfig::default(),
typst_page: TypstPageConfig::default(),
typst_fonts: TypstFontsConfig::default(),
typst_layout: TypstLayoutConfig::default(),
images: ImagesConfig::default(),
scripting: crate::scripting::policy::Policy::default(),
language: default_language(),
prompts_file: default_prompts_path(),
artefacts_directory: default_artefacts_directory(),
sync_interval_seconds: default_sync_interval(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct BackupConfig {
pub out_dir: String,
#[serde(with = "humantime_serde")]
pub max_age: std::time::Duration,
}
impl Default for BackupConfig {
fn default() -> Self {
Self {
out_dir: String::new(),
max_age: std::time::Duration::from_secs(7 * 24 * 3600),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SoundConfig {
pub enabled: bool,
pub volume: f32,
}
impl Default for SoundConfig {
fn default() -> Self {
Self {
enabled: false,
volume: 0.6,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TypstTemplatesConfig {
pub wrap_book: String,
pub wrap_chapter: String,
pub wrap_subchapter: String,
pub wrap_paragraph: String,
pub wrap_image_book: String,
pub wrap_image_chapter: String,
pub wrap_image_subchapter: String,
pub wrap_image_inline: String,
}
impl Default for TypstTemplatesConfig {
fn default() -> Self {
Self {
wrap_book: default_wrap_book().into(),
wrap_chapter: default_wrap_chapter().into(),
wrap_subchapter: default_wrap_subchapter().into(),
wrap_paragraph: default_wrap_paragraph().into(),
wrap_image_book: default_wrap_image_book().into(),
wrap_image_chapter: default_wrap_image_chapter().into(),
wrap_image_subchapter: default_wrap_image_subchapter().into(),
wrap_image_inline: default_wrap_image_inline().into(),
}
}
}
pub fn default_wrap_book() -> &'static str {
"#let wrap_book(body) = {\n body\n}\n"
}
pub fn default_wrap_chapter() -> &'static str {
"#let wrap_chapter(title, body) = {\n heading(level: 1, title)\n body\n}\n"
}
pub fn default_wrap_subchapter() -> &'static str {
"#let wrap_subchapter(title, body) = {\n heading(level: 2, title)\n body\n}\n"
}
pub fn default_wrap_paragraph() -> &'static str {
"#let wrap_paragraph(body) = {\n body\n parbreak()\n}\n"
}
pub fn default_wrap_image_book() -> &'static str {
"// Frontispiece — Image directly under a Book.\n\
#let wrap_image_book(path, title, caption, alt: none) = {\n\
\u{20}\u{20}pagebreak(weak: true)\n\
\u{20}\u{20}align(center + horizon, image(path, alt: alt, width: 90%))\n\
\u{20}\u{20}if caption != none [#align(center)[#emph(caption)]]\n\
\u{20}\u{20}pagebreak(weak: true)\n\
}\n"
}
pub fn default_wrap_image_chapter() -> &'static str {
"// Chapter-art — Image directly under a Chapter.\n\
#let wrap_image_chapter(path, title, caption, alt: none) = {\n\
\u{20}\u{20}pagebreak(weak: true)\n\
\u{20}\u{20}align(center, image(path, alt: alt, width: 80%))\n\
\u{20}\u{20}if caption != none [#align(center)[#emph(caption)]]\n\
}\n"
}
pub fn default_wrap_image_subchapter() -> &'static str {
"// Section image — Image directly under a Subchapter.\n\
#let wrap_image_subchapter(path, title, caption, alt: none) = {\n\
\u{20}\u{20}align(center, image(path, alt: alt, width: 60%))\n\
\u{20}\u{20}if caption != none [#align(center)[#emph(caption)]]\n\
}\n"
}
pub fn default_wrap_image_inline() -> &'static str {
"// Inline figure — call from paragraph text with #wrap_image_inline(...).\n\
#let wrap_image_inline(path, title, caption, alt: none) = figure(\n\
\u{20}\u{20}image(path, alt: alt, width: 80%),\n\
\u{20}\u{20}caption: caption,\n\
)\n"
}
impl TypstTemplatesConfig {
pub fn resolved_wrap_book(&self) -> String {
if self.wrap_book.trim().is_empty() {
default_wrap_book().into()
} else {
self.wrap_book.clone()
}
}
pub fn resolved_wrap_chapter(&self) -> String {
if self.wrap_chapter.trim().is_empty() {
default_wrap_chapter().into()
} else {
self.wrap_chapter.clone()
}
}
pub fn resolved_wrap_subchapter(&self) -> String {
if self.wrap_subchapter.trim().is_empty() {
default_wrap_subchapter().into()
} else {
self.wrap_subchapter.clone()
}
}
pub fn resolved_wrap_paragraph(&self) -> String {
if self.wrap_paragraph.trim().is_empty() {
default_wrap_paragraph().into()
} else {
self.wrap_paragraph.clone()
}
}
pub fn resolved_wrap_image_book(&self) -> String {
if self.wrap_image_book.trim().is_empty() {
default_wrap_image_book().into()
} else {
self.wrap_image_book.clone()
}
}
pub fn resolved_wrap_image_chapter(&self) -> String {
if self.wrap_image_chapter.trim().is_empty() {
default_wrap_image_chapter().into()
} else {
self.wrap_image_chapter.clone()
}
}
pub fn resolved_wrap_image_subchapter(&self) -> String {
if self.wrap_image_subchapter.trim().is_empty() {
default_wrap_image_subchapter().into()
} else {
self.wrap_image_subchapter.clone()
}
}
pub fn resolved_wrap_image_inline(&self) -> String {
if self.wrap_image_inline.trim().is_empty() {
default_wrap_image_inline().into()
} else {
self.wrap_image_inline.clone()
}
}
pub fn globals_typ_body(&self) -> String {
let mut out = String::new();
out.push_str("= globals.typ\n\n");
out.push_str(
"// Wrap functions used by inkhaven's `Book assembly` (Ctrl+B A).\n\
// Each node in the manuscript tree is fed through the matching\n\
// wrap_* call when the assembler synthesises index.typ files.\n\
// Customise to taste — page breaks, headings, fonts, layout.\n\n",
);
out.push_str("// ---- Prose wrappers ----\n");
out.push_str(&self.resolved_wrap_book());
out.push('\n');
out.push_str(&self.resolved_wrap_chapter());
out.push('\n');
out.push_str(&self.resolved_wrap_subchapter());
out.push('\n');
out.push_str(&self.resolved_wrap_paragraph());
out.push_str("\n// ---- Image wrappers ----\n");
out.push_str(&self.resolved_wrap_image_book());
out.push('\n');
out.push_str(&self.resolved_wrap_image_chapter());
out.push('\n');
out.push_str(&self.resolved_wrap_image_subchapter());
out.push('\n');
out.push_str(&self.resolved_wrap_image_inline());
out
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TypstCompileConfig {
pub error_system_prompt: String,
}
impl Default for TypstCompileConfig {
fn default() -> Self {
Self {
error_system_prompt: String::new(),
}
}
}
impl TypstCompileConfig {
pub fn resolved_error_system_prompt(&self) -> String {
if self.error_system_prompt.trim().is_empty() {
default_typst_error_system_prompt().into()
} else {
self.error_system_prompt.clone()
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ImagesConfig {
pub preview_enabled: bool,
pub allowed_extensions: Vec<String>,
pub max_size_bytes: u64,
}
impl Default for ImagesConfig {
fn default() -> Self {
Self {
preview_enabled: true,
allowed_extensions: vec![
"png".into(),
"jpg".into(),
"jpeg".into(),
"gif".into(),
"webp".into(),
"svg".into(),
],
max_size_bytes: 32 * 1024 * 1024,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TypstPageConfig {
pub paper: String,
pub margin_top: String,
pub margin_bottom: String,
pub margin_inside: String,
pub margin_outside: String,
pub page_numbering: String,
pub columns: u32,
}
impl Default for TypstPageConfig {
fn default() -> Self {
Self {
paper: "us-letter".into(),
margin_top: "2.5cm".into(),
margin_bottom: "2.5cm".into(),
margin_inside: "3cm".into(),
margin_outside: "2cm".into(),
page_numbering: "1".into(),
columns: 1,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TypstFontsConfig {
pub body: String,
pub body_size: String,
pub monospace: String,
pub language: String,
}
impl Default for TypstFontsConfig {
fn default() -> Self {
Self {
body: "EB Garamond".into(),
body_size: "11pt".into(),
monospace: "JetBrains Mono".into(),
language: "en".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TypstLayoutConfig {
pub justify: bool,
pub leading: String,
pub paragraph_indent: String,
pub heading_numbering: String,
}
impl Default for TypstLayoutConfig {
fn default() -> Self {
Self {
justify: true,
leading: "0.7em".into(),
paragraph_indent: String::new(),
heading_numbering: String::new(),
}
}
}
impl Config {
pub fn synthesised_settings_typ_header(&self) -> String {
let mut out = String::new();
out.push_str(
"// ── inkhaven auto-generated · do not edit ────────────────\n\
// Source: typst_page / typst_fonts / typst_layout in\n\
// inkhaven.hjson. Change values there and re-run Ctrl+B A.\n\
// Anything below the `User overrides` line below is your\n\
// free-form paragraph content; preserved across rebuilds.\n\n",
);
let p = &self.typst_page;
if !p.paper.trim().is_empty() {
let mut args: Vec<String> = Vec::new();
args.push(format!("paper: \"{}\"", typst_escape(&p.paper)));
let any_margin = !(p.margin_top.is_empty()
&& p.margin_bottom.is_empty()
&& p.margin_inside.is_empty()
&& p.margin_outside.is_empty());
if any_margin {
args.push(format!(
"margin: (top: {}, bottom: {}, inside: {}, outside: {})",
pad_or(&p.margin_top, "2.5cm"),
pad_or(&p.margin_bottom, "2.5cm"),
pad_or(&p.margin_inside, "3cm"),
pad_or(&p.margin_outside, "2cm"),
));
}
if !p.page_numbering.trim().is_empty() {
args.push(format!(
"numbering: \"{}\"",
typst_escape(&p.page_numbering)
));
}
if p.columns > 1 {
args.push(format!("columns: {}", p.columns));
}
out.push_str(&format!("#set page({})\n\n", args.join(", ")));
}
let f = &self.typst_fonts;
let mut text_args: Vec<String> = Vec::new();
if !f.body.trim().is_empty() {
text_args.push(format!("font: \"{}\"", typst_escape(&f.body)));
}
if !f.body_size.trim().is_empty() {
text_args.push(format!("size: {}", f.body_size));
}
if !f.language.trim().is_empty() {
text_args.push(format!("lang: \"{}\"", typst_escape(&f.language)));
}
if !text_args.is_empty() {
out.push_str(&format!("#set text({})\n\n", text_args.join(", ")));
}
if !f.monospace.trim().is_empty() {
out.push_str(&format!(
"#set raw(font: \"{}\")\n\n",
typst_escape(&f.monospace)
));
}
let l = &self.typst_layout;
let mut par_args: Vec<String> = Vec::new();
par_args.push(format!("justify: {}", l.justify));
if !l.leading.trim().is_empty() {
par_args.push(format!("leading: {}", l.leading));
}
if !l.paragraph_indent.trim().is_empty() {
par_args.push(format!("first-line-indent: {}", l.paragraph_indent));
}
out.push_str(&format!("#set par({})\n\n", par_args.join(", ")));
if !l.heading_numbering.trim().is_empty() {
out.push_str(&format!(
"#set heading(numbering: \"{}\")\n\n",
typst_escape(&l.heading_numbering)
));
}
out.push_str(
"// ── User overrides (your settings.typ paragraph below) ─────\n",
);
out
}
}
fn typst_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' | '\r' => out.push(' '),
other => out.push(other),
}
}
out
}
fn pad_or<'a>(v: &'a str, fallback: &'a str) -> &'a str {
if v.trim().is_empty() { fallback } else { v }
}
pub fn default_typst_error_system_prompt() -> &'static str {
"You are an expert Typst typesetter helping debug `typst compile` failures \
for books assembled by inkhaven. Inkhaven generates a tree of `.typ` files:\n\
- `<slug>.typ` — root, imports globals.typ + settings.typ, calls wrap_book(include \"book/index.typ\").\n\
- `globals.typ` — defines wrap_book / wrap_chapter / wrap_subchapter / wrap_paragraph functions.\n\
- `settings.typ` — document-wide #set / #show rules.\n\
- `book/index.typ` — sequence of `#include` for chapters at markup scope.\n\
- `book/<NN-chapter>/index.typ` — calls `#wrap_chapter(\"title\", { include … })` in code mode.\n\
- `book/<NN-chapter>/<NN-paragraph>.typ` — the user's prose (leading `= title` stripped).\n\n\
When you receive an error, walk through:\n\
1. What the error means in plain language.\n\
2. Which of the file categories above most likely caused it.\n\
3. The smallest concrete fix the user can apply — either in their inkhaven \
paragraph (via the editor) or in HJSON config (`typst_templates.wrap_*`).\n\n\
Be concise. The user wants to ship a PDF, not a tutorial."
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ThemeConfig {
pub pane_bg: String,
pub pane_fg: String,
pub line_number_fg: String,
pub current_line_bg: String,
pub border_focused: String,
pub border_unfocused: String,
pub border_dirty: String,
pub border_saved: String,
pub border_readonly: String,
pub modal_bg: String,
pub modal_border: String,
pub modal_fg: String,
pub places_fg: String,
pub characters_fg: String,
pub artefacts_fg: String,
pub notes_underline_fg: String,
pub search_match_bg: String,
pub search_current_bg: String,
pub tree_open_marker: String,
pub tree_book_fg: String,
pub tree_chapter_fg: String,
pub tree_subchapter_fg: String,
pub tree_paragraph_fg: String,
pub tree_image_fg: String,
pub tree_script_fg: String,
pub editor_position_fg: String,
pub ai_scope_fg: String,
pub ai_infer_fg: String,
pub grammar_change_fg: String,
pub syntax_heading: String,
pub syntax_bold: String,
pub syntax_italic: String,
pub syntax_string: String,
pub syntax_number: String,
pub syntax_comment: String,
pub syntax_keyword: String,
pub syntax_function: String,
pub syntax_operator: String,
pub syntax_list_marker: String,
pub syntax_raw: String,
pub syntax_tag: String,
pub syntax_quote: String,
}
impl Default for ThemeConfig {
fn default() -> Self {
Self {
pane_bg: "#1e1e2e".into(),
pane_fg: "#cdd6f4".into(),
line_number_fg: "#6c7086".into(),
current_line_bg: "#313244".into(),
border_focused: "#cba6f7".into(),
border_unfocused: "#45475a".into(),
border_dirty: "#f9e2af".into(),
border_saved: "#a6e3a1".into(),
border_readonly: "#94e2d5".into(),
modal_bg: "#181825".into(),
modal_border: "#cba6f7".into(),
modal_fg: "#cdd6f4".into(),
places_fg: "#89dceb".into(),
characters_fg: "#f9e2af".into(),
artefacts_fg: "#fab387".into(),
notes_underline_fg: "#cdd6f4".into(),
search_match_bg: "#f38ba8".into(),
search_current_bg: "#f5c2e7".into(),
tree_open_marker: "#a6e3a1".into(),
tree_book_fg: "#f5c2e7".into(), tree_chapter_fg: "#89b4fa".into(), tree_subchapter_fg: "#94e2d5".into(), tree_paragraph_fg: "#cdd6f4".into(), tree_image_fg: "#fab387".into(), tree_script_fg: "#cba6f7".into(),
editor_position_fg: "#89dceb".into(), ai_scope_fg: "#fab387".into(), ai_infer_fg: "#94e2d5".into(),
grammar_change_fg: "#f38ba8".into(),
syntax_heading: "#cba6f7".into(),
syntax_bold: "#f9e2af".into(),
syntax_italic: "#94e2d5".into(),
syntax_string: "#a6e3a1".into(),
syntax_number: "#fab387".into(),
syntax_comment: "#6c7086".into(),
syntax_keyword: "#cba6f7".into(),
syntax_function: "#89dceb".into(),
syntax_operator: "#94e2d5".into(),
syntax_list_marker: "#cba6f7".into(),
syntax_raw: "#fab387".into(),
syntax_tag: "#89b4fa".into(),
syntax_quote: "#9399b2".into(),
}
}
}
pub fn parse_color(s: &str) -> Option<ratatui::style::Color> {
use ratatui::style::Color;
let t = s.trim();
if t.is_empty() {
return None;
}
let hex = t.strip_prefix('#').unwrap_or(t);
let parse_byte = |h: &str| u8::from_str_radix(h, 16).ok();
match hex.len() {
3 => {
let r = parse_byte(&hex[0..1])? * 17;
let g = parse_byte(&hex[1..2])? * 17;
let b = parse_byte(&hex[2..3])? * 17;
Some(Color::Rgb(r, g, b))
}
6 => {
let r = parse_byte(&hex[0..2])?;
let g = parse_byte(&hex[2..4])?;
let b = parse_byte(&hex[4..6])?;
Some(Color::Rgb(r, g, b))
}
_ => None,
}
}
pub fn color_or(s: &str, default: ratatui::style::Color) -> ratatui::style::Color {
parse_color(s).unwrap_or(default)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct EmbeddingsConfig {
pub model: String,
pub chunk_size: usize,
pub chunk_overlap: f32,
}
impl Default for EmbeddingsConfig {
fn default() -> Self {
Self {
model: "MultilingualE5Small".into(),
chunk_size: 800,
chunk_overlap: 0.15,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LlmConfig {
pub default: String,
pub providers: std::collections::BTreeMap<String, LlmProvider>,
}
impl Default for LlmConfig {
fn default() -> Self {
let mut providers = std::collections::BTreeMap::new();
providers.insert(
"gemini".into(),
LlmProvider {
model: "gemini-2.5-pro".into(),
api_key_env: Some("GEMINI_API_KEY".into()),
},
);
providers.insert(
"claude".into(),
LlmProvider {
model: "claude-sonnet-4-5".into(),
api_key_env: Some("ANTHROPIC_API_KEY".into()),
},
);
providers.insert(
"openai".into(),
LlmProvider {
model: "gpt-4o".into(),
api_key_env: Some("OPENAI_API_KEY".into()),
},
);
providers.insert(
"deepseek".into(),
LlmProvider {
model: "deepseek-chat".into(),
api_key_env: Some("DEEPSEEK_API_KEY".into()),
},
);
providers.insert(
"grok".into(),
LlmProvider {
model: "grok-2-latest".into(),
api_key_env: Some("XAI_API_KEY".into()),
},
);
Self {
default: "gemini".into(),
providers,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmProvider {
pub model: String,
#[serde(default)]
pub api_key_env: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct EditorConfig {
pub theme: String,
pub tab_width: usize,
pub wrap: bool,
pub autosave_seconds: u64,
pub auto_close_pairs: bool,
pub stemming: StemmingConfig,
}
impl Default for EditorConfig {
fn default() -> Self {
Self {
theme: "default".into(),
tab_width: 2,
wrap: true,
autosave_seconds: 5,
auto_close_pairs: true,
stemming: StemmingConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct StemmingConfig {
pub languages: Vec<String>,
}
impl Default for StemmingConfig {
fn default() -> Self {
Self {
languages: vec!["english".into(), "russian".into()],
}
}
}
pub fn parse_stemmer_language(name: &str) -> Option<rust_stemmers::Algorithm> {
use rust_stemmers::Algorithm;
let lower = name.trim().to_ascii_lowercase();
Some(match lower.as_str() {
"arabic" => Algorithm::Arabic,
"danish" => Algorithm::Danish,
"dutch" => Algorithm::Dutch,
"english" | "en" => Algorithm::English,
"finnish" => Algorithm::Finnish,
"french" => Algorithm::French,
"german" => Algorithm::German,
"greek" => Algorithm::Greek,
"hungarian" => Algorithm::Hungarian,
"italian" => Algorithm::Italian,
"norwegian" => Algorithm::Norwegian,
"portuguese" => Algorithm::Portuguese,
"romanian" => Algorithm::Romanian,
"russian" | "ru" => Algorithm::Russian,
"spanish" => Algorithm::Spanish,
"swedish" => Algorithm::Swedish,
"tamil" => Algorithm::Tamil,
"turkish" => Algorithm::Turkish,
_ => return None,
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct KeyBindings {
pub save: String,
pub search: String,
pub ai_prompt: String,
pub next_pane: String,
pub prev_pane: String,
pub page_up: String,
pub page_down: String,
pub meta_prefix: String,
pub bund_prefix: String,
#[serde(default)]
pub bindings: Vec<BindingOverride>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BindingOverride {
pub chord: String,
pub action: String,
#[serde(default)]
pub scope: Option<String>,
}
impl Default for KeyBindings {
fn default() -> Self {
Self {
save: "Ctrl+s".into(),
search: "Ctrl+/".into(),
ai_prompt: "Ctrl+i".into(),
next_pane: "Tab".into(),
prev_pane: "Shift+Tab".into(),
page_up: "PageUp".into(),
page_down: "PageDown".into(),
meta_prefix: "Ctrl+b".into(),
bund_prefix: "Ctrl+z".into(),
bindings: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct HierarchyConfig {
pub unbounded_subchapters: bool,
}
impl Default for HierarchyConfig {
fn default() -> Self {
Self {
unbounded_subchapters: false,
}
}
}
impl Config {
pub fn load(path: &Path) -> crate::error::Result<Self> {
let raw = std::fs::read_to_string(path).map_err(crate::error::Error::Io)?;
serde_hjson::from_str(&raw).map_err(|e| crate::error::Error::Config(e.to_string()))
}
#[allow(dead_code)]
pub fn save(&self, path: &Path) -> crate::error::Result<()> {
let s = serde_hjson::to_string(self)
.map_err(|e| crate::error::Error::Config(e.to_string()))?;
std::fs::write(path, s).map_err(crate::error::Error::Io)
}
}
#[cfg(test)]
mod settings_synth_tests {
use super::*;
#[test]
fn synthesised_header_with_defaults_compiles_typst_shape() {
let cfg = Config::default();
let s = cfg.synthesised_settings_typ_header();
assert!(s.contains("auto-generated"));
assert!(s.contains("User overrides"));
assert!(s.contains("#set page("));
assert!(s.contains("paper: \"us-letter\""));
assert!(s.contains("margin: (top: 2.5cm"));
assert!(s.contains("#set text("));
assert!(s.contains("lang: \"en\""));
assert!(s.contains("#set par(justify: true"));
assert!(!s.contains("#set heading(numbering"));
}
#[test]
fn synthesised_header_emits_numbering_when_set() {
let mut cfg = Config::default();
cfg.typst_layout.heading_numbering = "1.1".into();
let s = cfg.synthesised_settings_typ_header();
assert!(s.contains("#set heading(numbering: \"1.1\")"));
}
#[test]
fn synthesised_header_omits_text_set_when_all_empty() {
let mut cfg = Config::default();
cfg.typst_fonts.body = String::new();
cfg.typst_fonts.body_size = String::new();
cfg.typst_fonts.language = String::new();
let s = cfg.synthesised_settings_typ_header();
assert!(!s.contains("#set text("));
assert!(s.contains("#set raw(font:")); }
#[test]
fn synthesised_header_escapes_double_quotes_in_values() {
let mut cfg = Config::default();
cfg.typst_fonts.body = "Bad\"Font".into();
let s = cfg.synthesised_settings_typ_header();
assert!(s.contains("font: \"Bad\\\"Font\""), "got:\n{s}");
}
#[test]
fn synthesised_header_multi_column_emits_columns_arg() {
let mut cfg = Config::default();
cfg.typst_page.columns = 2;
let s = cfg.synthesised_settings_typ_header();
assert!(s.contains("columns: 2"));
}
}