use serde::{Deserialize, Serialize}; use std::path::PathBuf; /// Persistent application configuration (which workspace to open, plus UI prefs). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { /// Directory that holds the markdown files and the git repository. pub workspace: PathBuf, /// Last used ODT export path. pub export_path: PathBuf, /// Whether to show the live preview pane. #[serde(default)] pub show_preview: bool, /// Marker line separating a file's editorial header from its prose. Content /// above it (except `# Title:` / `# Slug:` metadata) is dropped on export. /// An empty value disables header splitting. #[serde(default = "default_marker")] pub draft_marker: String, /// When a chapter title defaults to its position number, pad it with leading /// zeros to the width of the largest chapter number (e.g. "03." of 12). #[serde(default)] pub zero_pad_index: bool, /// Editor text zoom as a percentage offset from the base size (0 = default, /// +100 = double, -50 = half). #[serde(default)] pub editor_zoom: f32, /// Editor text contrast as a percentage: 0 = the theme's default text color, /// 100 = maximum contrast against the background (white in dark mode, black /// in light mode). Higher values make dim editor text easier to read. #[serde(default)] pub editor_text_contrast: f32, /// URL scheme for the LanguageTool server: `http` (local) or `https`. #[serde(default = "default_languagetool_scheme")] pub languagetool_scheme: String, /// Host of the LanguageTool server — an IP address or a domain name. #[serde(default = "default_languagetool_host")] pub languagetool_host: String, /// TCP port of the LanguageTool server. #[serde(default = "default_languagetool_port")] pub languagetool_port: u16, /// Optional bearer token sent in the `Authorization` header (for a server /// behind an auth proxy, or the premium API key). Empty = no auth. #[serde(default)] pub languagetool_token: String, /// Language passed to LanguageTool (`auto` to detect, or a code like `en-US`). #[serde(default = "default_languagetool_language")] pub languagetool_language: String, /// Whether the offline (Hunspell) live spell checker underlines misspellings /// as you type. Used whenever LanguageTool results aren't current. #[serde(default = "default_spell_check")] pub spell_check: bool, /// Id of the spell-check dictionary to use (e.g. `en-CA`, `en_GB`), matching /// a [`crate::spell::DictEntry::id`]. #[serde(default = "default_spell_language")] pub spell_language: String, /// Mistral API key for plot-beat generation. Stored in plain text; empty /// disables the feature until set. #[serde(default)] pub mistral_api_key: String, /// Mistral model id used for plot-beat generation (blank = the built-in default). #[serde(default = "default_mistral_model")] pub mistral_model: String, /// Mistral API base URL (blank = the built-in default). Override for a proxy. #[serde(default = "default_mistral_base_url")] pub mistral_base_url: String, /// Markdown seeded into files made with the file list's template button. /// `{{name}}` expands to the file stem, `{{marker}}` to [`Config::draft_marker`] /// and `{{date}}` to today's UTC date. Blank = the built-in default. #[serde(default = "default_new_file_template")] pub new_file_template: String, } /// Default new-file template: the editorial header fields this app already /// understands, followed by the draft marker so the body starts below it. pub fn default_new_file_template() -> String { [ "# Title: {{name}}", "# Slug:", "# POV:", "# Word Count Target:", "", "{{marker}}", "", "", ] .join("\n") } /// Default Mistral model. pub fn default_mistral_model() -> String { crate::mistral::DEFAULT_MODEL.to_string() } /// Default Mistral API base URL. pub fn default_mistral_base_url() -> String { crate::mistral::DEFAULT_BASE_URL.to_string() } /// Live spell checking is on by default. pub fn default_spell_check() -> bool { true } /// Default dictionary: the built-in Canadian English one. pub fn default_spell_language() -> String { crate::spell::DEFAULT_LANGUAGE.to_string() } /// Default scheme: plain HTTP, matching a local server. pub fn default_languagetool_scheme() -> String { "http".to_string() } /// Default host: the local machine. pub fn default_languagetool_host() -> String { "localhost".to_string() } /// Default port: the one the standalone LanguageTool server listens on. pub fn default_languagetool_port() -> u16 { 8010 } /// Default checking language: let the server auto-detect. pub fn default_languagetool_language() -> String { "auto".to_string() } /// Default header/body separator, matching the manuscript drafting convention. pub fn default_marker() -> String { "### Rough Draft:".to_string() } impl Default for Config { fn default() -> Self { let workspace = default_workspace(); let export_path = workspace.join("manuscript.odt"); Config { workspace, export_path, show_preview: false, draft_marker: default_marker(), zero_pad_index: false, editor_zoom: 0.0, editor_text_contrast: 0.0, languagetool_scheme: default_languagetool_scheme(), languagetool_host: default_languagetool_host(), languagetool_port: default_languagetool_port(), languagetool_token: String::new(), languagetool_language: default_languagetool_language(), spell_check: default_spell_check(), spell_language: default_spell_language(), mistral_api_key: String::new(), mistral_model: default_mistral_model(), mistral_base_url: default_mistral_base_url(), new_file_template: default_new_file_template(), } } } /// The dedicated directory where manuscripts live by default: ~/Manuscript. pub fn default_workspace() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("Manuscript") } /// Location of the config file: ~/.config/md-manuscript/config.json fn config_path() -> PathBuf { dirs::config_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("md-manuscript") .join("config.json") } impl Config { /// Assemble the LanguageTool base URL (`scheme://host:port`) from its parts. pub fn languagetool_base_url(&self) -> String { let scheme = match self.languagetool_scheme.trim() { "" => "http", s => s, }; format!( "{scheme}://{}:{}", self.languagetool_host.trim(), self.languagetool_port ) } /// The Mistral model to use, falling back to the built-in default when blank. pub fn mistral_effective_model(&self) -> String { let m = self.mistral_model.trim(); if m.is_empty() { crate::mistral::DEFAULT_MODEL.to_string() } else { m.to_string() } } /// The new-file template to use, falling back to the built-in default when /// blank, so an accidentally emptied box still produces a usable file. pub fn effective_new_file_template(&self) -> String { if self.new_file_template.trim().is_empty() { default_new_file_template() } else { self.new_file_template.clone() } } /// The Mistral base URL to use, falling back to the built-in default when blank. pub fn mistral_effective_base_url(&self) -> String { let b = self.mistral_base_url.trim().trim_end_matches('/'); if b.is_empty() { crate::mistral::DEFAULT_BASE_URL.to_string() } else { b.to_string() } } pub fn load() -> Config { let path = config_path(); match std::fs::read_to_string(&path) { Ok(text) => serde_json::from_str(&text).unwrap_or_default(), Err(_) => Config::default(), } } pub fn save(&self) { let path = config_path(); if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } if let Ok(text) = serde_json::to_string_pretty(self) { let _ = std::fs::write(&path, text); } } } #[cfg(test)] mod tests { use super::*; /// A config.json written before `new_file_template` existed. `Config::load` /// falls back to `Config::default()` when parsing fails, which would quietly /// discard the user's workspace and API keys — so this must keep parsing. #[test] fn config_without_the_template_field_still_loads() { let old = r####"{ "workspace": "/home/writer/Manuscript", "export_path": "/home/writer/Manuscript/book.odt", "show_preview": true, "draft_marker": "### Rough Draft:", "editor_zoom": 10.0, "languagetool_host": "lt.example.com", "mistral_api_key": "secret" }"####; let cfg: Config = serde_json::from_str(old).expect("old config must still parse"); // Pre-existing values survive. assert_eq!(cfg.workspace, PathBuf::from("/home/writer/Manuscript")); assert_eq!(cfg.languagetool_host, "lt.example.com"); assert_eq!(cfg.mistral_api_key, "secret"); assert!(cfg.show_preview); // The new field is filled in from its default rather than left blank. assert_eq!(cfg.new_file_template, default_new_file_template()); assert!(cfg.new_file_template.contains("{{marker}}")); } #[test] fn blank_template_falls_back_to_the_default() { let blank = Config { new_file_template: " \n ".to_string(), ..Config::default() }; assert_eq!( blank.effective_new_file_template(), default_new_file_template() ); let custom = Config { new_file_template: "# Mine\n".to_string(), ..Config::default() }; assert_eq!(custom.effective_new_file_template(), "# Mine\n"); } #[test] fn config_survives_a_save_load_round_trip() { let cfg = Config { new_file_template: "# Title: {{name}}\n{{date}}\n".to_string(), ..Config::default() }; let text = serde_json::to_string_pretty(&cfg).expect("serialize"); let back: Config = serde_json::from_str(&text).expect("deserialize"); assert_eq!(back.new_file_template, cfg.new_file_template); } }