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, /// Whether the toolbar under the menu bar — the Workspace, Export, Grammar /// and Spelling rows — is shown. Hiding it hands four rows of height back to /// the editor; the menu bar keeps a toggle so it can be brought back. #[serde(default = "default_show_toolbar")] pub show_toolbar: bool, /// Whether the left-hand file list is shown. Hiding it, with the toolbar, /// leaves nothing on screen but the page. #[serde(default = "default_show_toolbar")] pub show_file_panel: bool, /// Whether the editor hides the editorial header, showing the prose alone. #[serde(default)] pub collapse_header: bool, /// Whether the file panel lists a project's reference files (characters, /// outline, scratch pad) alongside the manuscript. Off by default: the panel /// is for the book, and the reference material has windows of its own. #[serde(default)] pub show_reference_files: 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, /// Directory holding the cookiecutter template used by **File ▸ New /// project…** (the folder containing `cookiecutter.json`). #[serde(default = "default_project_template")] pub project_template: PathBuf, /// Path to the `cookiecutter` executable. Blank means look on `PATH` and in /// the usual per-user Python prefixes. #[serde(default)] pub cookiecutter_bin: String, /// Subfolder of a freshly generated project to open as the workspace. Blank /// (or a folder the template did not create) opens the project root. #[serde(default = "default_project_open_subdir")] pub project_open_subdir: String, /// Whether to run the template's pre/post-generation hooks. The snowflake /// template's post-gen hook publishes the project to Gitea, so this decides /// whether creating a project also creates a remote repository. #[serde(default = "default_project_run_hooks")] pub project_run_hooks: bool, /// Directory new projects are created in; remembered between runs. #[serde(default = "default_projects_dir")] pub projects_dir: PathBuf, /// Author prefilled into the new-project dialog. #[serde(default)] pub project_author: String, /// Base URL of the Gitea server, passed to a template's hooks as /// `GITEA_URL`. Empty leaves the variable unset, which the snowflake hook /// treats as "do not publish". #[serde(default)] pub gitea_url: String, /// Gitea account name, passed to hooks as `GITEA_USER`. #[serde(default)] pub gitea_user: String, /// Gitea API token, passed to hooks as `GITEA_TOKEN`. Stored in plain text, /// like the Mistral key. #[serde(default)] pub gitea_token: String, /// Folders kept out of the file panel entirely, matched leniently so /// `Archive` covers `10-Archive`. An archive of dead drafts can hold /// hundreds of files that would otherwise swamp the tree. #[serde(default = "default_hidden_folders")] pub hidden_folders: Vec, /// Folder the Archive action moves files into. Matched leniently against /// what the project already has, so `10-Archive` is found and reused. #[serde(default = "default_archive_folder")] pub archive_folder: String, /// Manuscript title written into exported documents. Blank falls back to the /// project folder's name. #[serde(default)] pub manuscript_title: String, /// Author written into exported documents. #[serde(default)] pub manuscript_author: String, /// How to reach the author: whatever belongs under their name on a title /// page — an address, an email, a phone number, an agent. Written a line /// per line, so the layout typed here is the layout that is exported. #[serde(default)] pub manuscript_contact: String, /// Whether an export opens with a title page carrying the title, the author /// and their contact details. #[serde(default)] pub manuscript_title_page: bool, /// Whether exports are laid out in standard manuscript format — 12pt /// Courier, double-spaced, with a running header — which is the shape an /// agent or editor expects a submission in. #[serde(default)] pub manuscript_standard_format: bool, } /// The toolbar and the file list are what the window opens with; hiding either /// is a deliberate act, and a config written before the options existed should /// not start hidden. pub fn default_show_toolbar() -> bool { true } /// Default cookiecutter template for **File ▸ New project…**. pub fn default_project_template() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("Documents") .join("Cookiecutters") .join("snowflake") } /// Default subfolder opened after generating a project: the snowflake /// template's drafting folder, where the chapter files live. pub fn default_project_open_subdir() -> String { "06-First Draft".to_string() } /// Templates ship hooks because they are meant to run; honouring them is the /// default, and an unconfigured hook is expected to skip itself. pub fn default_project_run_hooks() -> bool { true } /// Folders excluded from the panel by default: the snowflake layout's archive, /// which holds superseded drafts rather than working material. pub fn default_hidden_folders() -> Vec { vec!["Archive".to_string()] } /// Default archive folder, matching the snowflake layout. pub fn default_archive_folder() -> String { "10-Archive".to_string() } /// Default parent directory for new projects. pub fn default_projects_dir() -> PathBuf { dirs::document_dir().unwrap_or_else(|| { dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")) }) } /// 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, show_toolbar: default_show_toolbar(), show_file_panel: default_show_toolbar(), collapse_header: false, show_reference_files: 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(), project_template: default_project_template(), cookiecutter_bin: String::new(), project_open_subdir: default_project_open_subdir(), project_run_hooks: default_project_run_hooks(), projects_dir: default_projects_dir(), project_author: String::new(), gitea_url: String::new(), gitea_user: String::new(), gitea_token: String::new(), hidden_folders: default_hidden_folders(), archive_folder: default_archive_folder(), manuscript_title: String::new(), manuscript_author: String::new(), manuscript_contact: String::new(), manuscript_title_page: false, manuscript_standard_format: false, } } } /// 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); } /// A config.json written before **File ▸ New project…** existed. Same risk /// as above: a `#[serde(default)]` missing from any new field would make the /// whole file fail to parse and reset the user's settings. #[test] fn config_without_the_project_fields_still_loads() { let old = r####"{ "workspace": "/home/writer/Manuscript", "export_path": "/home/writer/Manuscript/book.odt", "draft_marker": "### Rough Draft:", "mistral_api_key": "secret" }"####; let cfg: Config = serde_json::from_str(old).expect("old config must still parse"); assert_eq!(cfg.workspace, PathBuf::from("/home/writer/Manuscript")); assert_eq!(cfg.mistral_api_key, "secret"); // Every new field arrives at its default rather than blank. assert_eq!(cfg.project_open_subdir, default_project_open_subdir()); assert_eq!(cfg.project_template, default_project_template()); assert_eq!(cfg.projects_dir, default_projects_dir()); assert!(cfg.project_run_hooks, "hooks default to running"); // Credentials are absent until set; blank is what tells a hook to skip. assert!(cfg.gitea_url.is_empty()); assert!(cfg.gitea_token.is_empty()); assert!(cfg.cookiecutter_bin.is_empty()); } /// The folder opened after generating a project. It is the snowflake /// template's drafting folder, and the name contains a space — which has to /// survive being stored and joined onto the project path. #[test] fn the_default_project_subdir_is_the_drafting_folder() { assert_eq!(default_project_open_subdir(), "06-First Draft"); let joined = PathBuf::from("/tmp/The Winter Gate").join(default_project_open_subdir()); assert_eq!(joined, PathBuf::from("/tmp/The Winter Gate/06-First Draft")); } /// A saved subfolder choice must win over the built-in default, so changing /// the default never silently moves an existing user's projects. #[test] fn a_saved_project_subdir_is_honoured() { let saved = r####"{ "workspace": "/w", "export_path": "/w/b.odt", "project_open_subdir": "05-Plot" }"####; let cfg: Config = serde_json::from_str(saved).expect("must parse"); assert_eq!(cfg.project_open_subdir, "05-Plot"); } }