0f144737a5
Completes the move off the external cookiecutter tool: drops src/cookiecutter.rs and the cookiecutter_bin/Template-path setting now that scaffolding always runs through the built-in MiniJinja engine, and rewrites default_project_template() to walk up from the executable without panicking when the bundled template can't be found. Rounds out the snowflake template with the remaining 06-Revisions, 07-Final, 08-Covers and 09-Archive folders. Simplifies the scene/chapter YAML frontmatter (drops type/word_count_min/ word_count_max in favor of status + a flat characters list) and updates the new-file template, header parsing, outline, file list and Story Bible editor to match. Separately, de-duplicates the toolbar and menu bar: settings that only existed as always-visible toolbar controls (draft marker, zero-pad, spell-check toggle, dictionary picker) move into their Settings windows, toolbar buttons that exactly duplicated a menu item are dropped, and toolbar-only actions with no menu path (open project folder, git sync/init) gain one under File. Also fixes two ambiguous menu labels: the Story Bible editor now reads "Story Bible editor…" next to "Story Bible…" in Tools instead of sharing a label with Edit, and Settings' "New project…" is now "New-project settings…" so it doesn't read like File's actual create action. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
643 lines
26 KiB
Rust
643 lines
26 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use std::path::{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 template used by **File ▸ New project…** — the
|
|
/// folder containing `cookiecutter.json`. Rendered by the built-in
|
|
/// MiniJinja engine; the manifest keeps the cookiecutter name so existing
|
|
/// templates still work.
|
|
#[serde(default = "default_project_template")]
|
|
pub project_template: PathBuf,
|
|
/// Which folder of a project holds the book — the manuscript folder. A
|
|
/// generated project always opens at its root; this names the folder inside
|
|
/// it whose files are ordered, numbered and exported.
|
|
#[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` and used for the app's
|
|
/// own API calls. Stored in plain text, like the Mistral key.
|
|
#[serde(default)]
|
|
pub gitea_token: String,
|
|
/// Whether repositories the app creates on Gitea are private.
|
|
#[serde(default)]
|
|
pub gitea_private: bool,
|
|
/// Whether to point `origin` at the SSH clone URL rather than the HTTPS one.
|
|
/// SSH is the default: it pushes on a key, where HTTPS would want a
|
|
/// credential typed in (or stored) on every push.
|
|
#[serde(default = "default_gitea_use_ssh")]
|
|
pub gitea_use_ssh: bool,
|
|
/// 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<String>,
|
|
/// 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,
|
|
/// Whether this project is a screenplay. The files keep the same editorial
|
|
/// header as a prose project, but the body below the draft marker is read
|
|
/// as Fountain rather than markdown, and the manuscript exports as a
|
|
/// paginated screenplay PDF instead of an ODT.
|
|
#[serde(default)]
|
|
pub manuscript_screenplay: bool,
|
|
/// Whether auto-snapshot (git commit every N words) is enabled.
|
|
#[serde(default)]
|
|
pub auto_snapshot_enabled: bool,
|
|
/// Word count threshold for auto-snapshots.
|
|
#[serde(default = "default_auto_snapshot_threshold")]
|
|
pub auto_snapshot_threshold: usize,
|
|
}
|
|
|
|
/// Default auto-snapshot threshold: 250 words.
|
|
pub fn default_auto_snapshot_threshold() -> usize {
|
|
250
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
/// Where the bundled project template lives, for **File ▸ New project…**.
|
|
///
|
|
/// Walks up from the executable and then tries the working directory, which
|
|
/// covers `cargo run`, a release build run in place, and an installed binary
|
|
/// with the templates beside it.
|
|
///
|
|
/// Returns a path even when nothing is found, rather than failing: this is the
|
|
/// default for a config field, so it is reached on any machine that has no
|
|
/// `config.json` yet — and an installed binary without the templates folder
|
|
/// beside it is an ordinary way to arrive here, not a reason to refuse to
|
|
/// start. The path that comes back simply has no `cookiecutter.json` in it,
|
|
/// which the settings dialog reports ("✖ no cookiecutter.json in that folder")
|
|
/// and project generation refuses with a message naming the folder.
|
|
pub fn default_project_template() -> PathBuf {
|
|
const BUNDLED: &str = "templates/snowflake";
|
|
let exe_dir = std::env::current_exe()
|
|
.ok()
|
|
.and_then(|exe| exe.parent().map(Path::to_path_buf));
|
|
|
|
if let Some(dir) = exe_dir.as_deref() {
|
|
for ancestor in dir.ancestors() {
|
|
let candidate = ancestor.join(BUNDLED);
|
|
if candidate.join("cookiecutter.json").is_file() {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
if let Ok(cwd) = std::env::current_dir() {
|
|
let candidate = cwd.join(BUNDLED);
|
|
if candidate.join("cookiecutter.json").is_file() {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
// Nothing found: name where the templates are expected to be, so the
|
|
// message the user eventually sees points at a useful folder.
|
|
match exe_dir {
|
|
Some(dir) => dir.join(BUNDLED),
|
|
None => PathBuf::from(BUNDLED),
|
|
}
|
|
}
|
|
|
|
/// Default subfolder opened after generating a project: the snowflake
|
|
/// template's drafting folder (05-First Draft), where the chapter files live.
|
|
/// Contains merged planning and draft content in a single folder.
|
|
pub fn default_project_open_subdir() -> String {
|
|
"05-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
|
|
}
|
|
|
|
/// SSH by default — see [`Config::gitea_use_ssh`].
|
|
pub fn default_gitea_use_ssh() -> 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<String> {
|
|
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: Uses YAML frontmatter for metadata,
|
|
/// with everything after the closing --- being the draft content.
|
|
pub fn default_new_file_template() -> String {
|
|
[
|
|
"---",
|
|
"title: {{name}}",
|
|
"slug: ",
|
|
"status: draft",
|
|
"pov: ",
|
|
"characters: []",
|
|
"word_count_target: ",
|
|
"---",
|
|
"",
|
|
]
|
|
.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.
|
|
/// Now empty by default since we use YAML frontmatter.
|
|
pub fn default_marker() -> String {
|
|
"".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(),
|
|
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(),
|
|
gitea_private: false,
|
|
gitea_use_ssh: default_gitea_use_ssh(),
|
|
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,
|
|
manuscript_screenplay: false,
|
|
auto_snapshot_enabled: false,
|
|
auto_snapshot_threshold: default_auto_snapshot_threshold(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
}
|
|
|
|
/// The project template path to use, falling back to the bundled template
|
|
/// if the saved path doesn't exist.
|
|
pub fn effective_project_template(&self) -> PathBuf {
|
|
// If the saved template path exists and has cookiecutter.json, use it
|
|
if self.project_template.join("cookiecutter.json").is_file() {
|
|
return self.project_template.clone();
|
|
}
|
|
// Otherwise fall back to the bundled template
|
|
default_project_template()
|
|
}
|
|
|
|
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("---"));
|
|
// Likewise the Gitea pair. `use_ssh` defaulting to *true* is the point:
|
|
// a `#[serde(default)]` bool would come back false and quietly attach
|
|
// every repository over HTTPS.
|
|
assert!(cfg.gitea_use_ssh);
|
|
assert!(!cfg.gitea_private);
|
|
}
|
|
|
|
/// A defaulted-to-`true` flag is only half done: the default has to lose to
|
|
/// what is actually in the file, or turning SSH off would never stick.
|
|
#[test]
|
|
fn choosing_https_survives_a_reload() {
|
|
let saved = r####"{
|
|
"workspace": "/home/writer/Manuscript",
|
|
"export_path": "/home/writer/Manuscript/book.odt",
|
|
"show_preview": true,
|
|
"editor_zoom": 10.0,
|
|
"languagetool_host": "lt.example.com",
|
|
"mistral_api_key": "secret",
|
|
"gitea_use_ssh": false,
|
|
"gitea_private": true
|
|
}"####;
|
|
let cfg: Config = serde_json::from_str(saved).expect("config must parse");
|
|
assert!(!cfg.gitea_use_ssh, "an explicit false must beat the true default");
|
|
assert!(cfg.gitea_private);
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
|
|
/// The folder opened after generating a project. It is the snowflake
|
|
/// template's drafting folder (merged planning + draft), 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(), "05-First Draft");
|
|
let joined = PathBuf::from("/tmp/The Winter Gate").join(default_project_open_subdir());
|
|
assert_eq!(joined, PathBuf::from("/tmp/The Winter Gate/05-First Draft"));
|
|
}
|
|
|
|
/// A config.json written before screenplay projects existed. Same risk as
|
|
/// the tests above: a missing `#[serde(default)]` would fail the whole
|
|
/// parse and reset every setting the user had.
|
|
#[test]
|
|
fn config_without_the_screenplay_field_still_loads() {
|
|
let old = r####"{
|
|
"workspace": "/home/writer/Manuscript",
|
|
"export_path": "/home/writer/Manuscript/book.odt",
|
|
"manuscript_author": "A. Writer",
|
|
"manuscript_standard_format": true
|
|
}"####;
|
|
let cfg: Config = serde_json::from_str(old).expect("old config must still parse");
|
|
assert_eq!(cfg.manuscript_author, "A. Writer");
|
|
assert!(cfg.manuscript_standard_format);
|
|
// A project is prose unless it says otherwise.
|
|
assert!(!cfg.manuscript_screenplay);
|
|
}
|
|
|
|
/// 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");
|
|
}
|
|
|
|
#[test]
|
|
fn bundled_template_is_found_via_exe_path() {
|
|
let path = default_project_template();
|
|
// The function should find the bundled template
|
|
// Verify it returns a valid path that contains cookiecutter.json
|
|
assert!(path.join("cookiecutter.json").is_file(),
|
|
"Template path {} should contain cookiecutter.json", path.display());
|
|
|
|
// Also verify it's using the bundled template (contains "templates/snowflake")
|
|
let path_str = path.to_string_lossy();
|
|
assert!(path_str.contains("templates/snowflake"),
|
|
"Template path {} should be the bundled template", path.display());
|
|
}
|
|
|
|
#[test]
|
|
fn bundled_template_contains_05_first_draft() {
|
|
let path = default_project_template();
|
|
let template_dir = path.join("{{ cookiecutter.project_name }}");
|
|
|
|
// Verify the template has the merged 05-First Draft folder
|
|
assert!(template_dir.join("05-First Draft").is_dir(),
|
|
"Bundled template should contain 05-First Draft directory");
|
|
|
|
// Verify it does NOT have the old separate directories
|
|
assert!(!template_dir.join("05-Plot").is_dir(),
|
|
"Bundled template should NOT contain old 05-Plot directory");
|
|
assert!(!template_dir.join("06-First Draft").is_dir(),
|
|
"Bundled template should NOT contain old 06-First Draft directory");
|
|
}
|
|
}
|