use clap::Parser;
use std::fs;
use std::path::PathBuf;
const DEFAULT_CONFIG: &str = r#"## Lottie configuration file
## Place this file at ~/.config/lottie/lottie.conf
##
## Use "set <option>" to enable a boolean option or assign a value.
## Use "unset <option>" to disable a boolean option.
## Editor View
# Show scene numbers in the left margin.
set show_scene_numbers
# Show page numbers on the right side of the screen.
set show_page_numbers
# Mirror scene numbers to the right margin instead of page numbers.
# Available values: "always" (editor and export), "export" (export only), "off"
set mirror_scene_numbers "export"
# Automatically hide Fountain markup when the cursor is not
# on the current line.
set hide_markup
# Highlight active action line (or nearest action line above)
# in bright white color.
unset highlight_active_action
# Typewriter mode
unset typewriter_mode
# Strict typewriter mode (forces the active line to stay in the exact
# vertical center of the terminal at all times, even at the beginning
# of the document).
unset strict_typewriter_mode
# Focus mode
unset focus_mode
## Editor Behavior
# Auto-complete scene headings (INT./EXT.) and character names.
set autocomplete
# Automatically append (CONT'D) to a character name when they speak
# consecutively.
set auto_contd
# Automatically insert paragraph breaks (double newlines) after Action,
# Dialogue, and similar elements.
set auto_paragraph_breaks
# Automatically insert a closing parenthesis when typing an opening one.
set match_parentheses
# Automatically close paired elements such as [[]], /**/, and ****.
set close_elements
# Insert a blank Title Page template when creating a new file.
unset auto_title_page
## Formatting
# The string appended to a character name when they speak consecutively.
set contd_extension "(CONT'D)"
# Allow action blocks to be split across pages.
# Use "unset break_actions" to keep action blocks on a single page.
set break_actions
# Open the file with the cursor at the end
unset goto_end
# Styling applied to scene headings. Available values: "bold",
# "underline", "bold underline"
set heading_style "bold"
# Number of blank lines before a scene heading. Set to 2 for double
# spacing before each new scene.
set heading_spacing 1
# Styling applied to shots (e.g. !! CLOSE UP). Available values: "bold",
# "underline", "bold underline"
set shot_style "bold"
## Display & Terminal
# Disable all terminal colors. Lottie will still render bold, italic,
# and underline modifiers if supported by your terminal. Lottie tries
# to detect color support automatically.
unset no_color
# Disable all text formatting (bold, italic, underline).
unset no_formatting
# Force output of ANSI color escape codes, even if Lottie detects
# that your terminal does not support them.
unset force_ansi
# Force the use of ASCII characters instead of Unicode (e.g., for page
# break lines). Useful for older terminals. Lottie will try to detect
# Unicode support automatically.
unset force_ascii
"#;
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub enum MirrorOption {
Off,
Always,
#[default]
ExportOnly,
}
#[derive(Parser, Debug, Default, Clone)]
#[command(name = "lottie", author, version, about, long_about = None)]
pub struct Cli {
#[arg(num_args = 0..)]
pub files: Vec<PathBuf>,
#[arg(long, value_name = "FILE")]
pub config: Option<PathBuf>,
#[arg(long)]
pub hide_scene_numbers: bool,
#[arg(long)]
pub hide_page_numbers: bool,
#[arg(long)]
pub show_markup: bool,
#[arg(long)]
pub no_autocomplete: bool,
#[arg(long)]
pub no_auto_contd: bool,
#[arg(long)]
pub no_auto_paragraph_breaks: bool,
#[arg(long)]
pub no_match_parentheses: bool,
#[arg(long)]
pub no_close_elements: bool,
#[arg(long)]
pub auto_title_page: bool,
#[arg(long)]
pub typewriter_mode: bool,
#[arg(long)]
pub strict_typewriter_mode: bool,
#[arg(long)]
pub focus_mode: bool,
#[arg(long)]
pub highlight_active_action: bool,
#[arg(long)]
pub no_break_actions: bool,
#[arg(long)]
pub goto_end: bool,
#[arg(long, value_name = "MODE", num_args = 0..=1, default_missing_value = "always")]
pub mirror_scene_numbers: Option<String>,
#[arg(long)]
pub contd_extension: Option<String>,
#[arg(long)]
pub heading_style: Option<String>,
#[arg(long)]
pub heading_spacing: Option<usize>,
#[arg(long)]
pub shot_style: Option<String>,
#[arg(long)]
pub no_color: bool,
#[arg(long)]
pub no_formatting: bool,
#[arg(long)]
pub force_ascii: bool,
#[arg(long)]
pub force_ansi: bool,
#[arg(long, value_name = "FILE", num_args = 0..=1, default_missing_value = "-")]
pub export: Option<PathBuf>,
#[arg(long, default_value = "plain", value_name = "FORMAT")]
pub format: String,
}
#[derive(Clone, Debug)]
pub struct Config {
pub show_scene_numbers: bool,
pub show_page_numbers: bool,
pub hide_markup: bool,
pub autocomplete: bool,
pub auto_contd: bool,
pub auto_paragraph_breaks: bool,
pub match_parentheses: bool,
pub close_elements: bool,
pub auto_title_page: bool,
pub typewriter_mode: bool,
pub strict_typewriter_mode: bool,
pub focus_mode: bool,
pub highlight_active_action: bool,
pub break_actions: bool,
pub goto_end: bool,
pub no_color: bool,
pub no_formatting: bool,
pub force_ascii: bool,
pub force_ansi: bool,
pub mirror_scene_numbers: MirrorOption,
pub contd_extension: String,
pub heading_style: String,
pub heading_spacing: usize,
pub shot_style: String,
}
impl Default for Config {
fn default() -> Self {
Self {
show_scene_numbers: true,
show_page_numbers: true,
hide_markup: true,
autocomplete: true,
auto_contd: true,
auto_paragraph_breaks: true,
match_parentheses: true,
close_elements: true,
auto_title_page: false,
typewriter_mode: false,
strict_typewriter_mode: false,
focus_mode: false,
highlight_active_action: false,
break_actions: true,
goto_end: false,
contd_extension: "(CONT'D)".to_string(),
heading_style: "bold".to_string(),
heading_spacing: 1,
shot_style: "bold".to_string(),
no_color: false,
no_formatting: false,
force_ascii: false,
force_ansi: false,
mirror_scene_numbers: MirrorOption::ExportOnly,
}
}
}
impl Config {
pub fn parse_config_str(&mut self, content: &str) {
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
let cmd = parts[0];
let key = parts[1];
let val = if parts.len() > 2 {
parts[2..].join(" ").trim_matches('"').to_string()
} else {
String::new()
};
if cmd == "set" {
match key {
"show_scene_numbers" => self.show_scene_numbers = true,
"show_page_numbers" => self.show_page_numbers = true,
"hide_markup" => self.hide_markup = true,
"autocomplete" => self.autocomplete = true,
"auto_contd" => self.auto_contd = true,
"auto_paragraph_breaks" => self.auto_paragraph_breaks = true,
"match_parentheses" => self.match_parentheses = true,
"close_elements" => self.close_elements = true,
"auto_title_page" => self.auto_title_page = true,
"typewriter_mode" => self.typewriter_mode = true,
"strict_typewriter_mode" => self.strict_typewriter_mode = true,
"focus_mode" => self.focus_mode = true,
"highlight_active_action" => self.highlight_active_action = true,
"break_actions" => self.break_actions = true,
"goto_end" => self.goto_end = true,
"mirror_scene_numbers" => {
self.mirror_scene_numbers = match val.as_str() {
"export" => MirrorOption::ExportOnly,
"off" | "false" => MirrorOption::Off,
_ => MirrorOption::Always,
};
}
"contd_extension" => self.contd_extension = val,
"heading_style" => self.heading_style = val,
"heading_spacing" => {
if let Ok(v) = val.parse() {
self.heading_spacing = v
}
}
"shot_style" => self.shot_style = val,
"no_color" => self.no_color = true,
"no_formatting" => self.no_formatting = true,
"force_ascii" => self.force_ascii = true,
"force_ansi" => self.force_ansi = true,
_ => {}
}
} else if cmd == "unset" {
match key {
"show_scene_numbers" => self.show_scene_numbers = false,
"show_page_numbers" => self.show_page_numbers = false,
"hide_markup" => self.hide_markup = false,
"autocomplete" => self.autocomplete = false,
"auto_contd" => self.auto_contd = false,
"auto_paragraph_breaks" => self.auto_paragraph_breaks = false,
"match_parentheses" => self.match_parentheses = false,
"close_elements" => self.close_elements = false,
"auto_title_page" => self.auto_title_page = false,
"typewriter_mode" => self.typewriter_mode = false,
"strict_typewriter_mode" => self.strict_typewriter_mode = false,
"focus_mode" => self.focus_mode = false,
"highlight_active_action" => self.highlight_active_action = false,
"break_actions" => self.break_actions = false,
"goto_end" => self.goto_end = false,
"mirror_scene_numbers" => self.mirror_scene_numbers = MirrorOption::Off,
"no_color" => self.no_color = false,
"no_formatting" => self.no_formatting = false,
"force_ascii" => self.force_ascii = false,
"force_ansi" => self.force_ansi = false,
_ => {}
}
}
}
}
}
pub fn load(cli: &Cli) -> Self {
let mut config = Self::default();
let is_custom_path = cli.config.is_some();
let config_path = cli.config.clone().or_else(|| {
#[cfg(windows)]
{
directories::ProjectDirs::from("", "", "Lottie")
.map(|proj_dirs| proj_dirs.config_dir().join("lottie.conf"))
}
#[cfg(not(windows))]
{
let config_dir = std::env::var_os("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
directories::BaseDirs::new()
.map(|base| base.home_dir().join(".config"))
.unwrap_or_default()
});
Some(config_dir.join("lottie").join("lottie.conf"))
}
});
if let Some(path) = config_path {
if !is_custom_path && !path.exists() {
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let _ = fs::write(&path, DEFAULT_CONFIG);
}
match fs::read_to_string(&path) {
Ok(content) => config.parse_config_str(&content),
Err(e) if is_custom_path => {
eprintln!(
"Warning: Failed to load custom config file at '{}': {}",
path.display(),
e
);
}
_ => {}
}
}
config.show_scene_numbers &= !cli.hide_scene_numbers;
config.show_page_numbers &= !cli.hide_page_numbers;
config.hide_markup &= !cli.show_markup;
config.autocomplete &= !cli.no_autocomplete;
config.auto_contd &= !cli.no_auto_contd;
config.auto_paragraph_breaks &= !cli.no_auto_paragraph_breaks;
config.match_parentheses &= !cli.no_match_parentheses;
config.close_elements &= !cli.no_close_elements;
config.break_actions &= !cli.no_break_actions;
config.auto_title_page |= cli.auto_title_page;
config.typewriter_mode |= cli.typewriter_mode;
config.strict_typewriter_mode |= cli.strict_typewriter_mode;
config.focus_mode |= cli.focus_mode;
config.highlight_active_action |= cli.highlight_active_action;
config.no_color |= cli.no_color;
config.no_formatting |= cli.no_formatting;
config.force_ascii |= cli.force_ascii;
config.force_ansi |= cli.force_ansi;
config.goto_end |= cli.goto_end;
if let Some(ref mode) = cli.mirror_scene_numbers {
config.mirror_scene_numbers = match mode.as_str() {
"export" => MirrorOption::ExportOnly,
"off" | "false" => MirrorOption::Off,
_ => MirrorOption::Always,
};
}
if let Some(ref ext) = cli.contd_extension {
config.contd_extension = ext.clone();
}
if let Some(ref style) = cli.heading_style {
config.heading_style = style.clone();
}
if let Some(spacing) = cli.heading_spacing {
config.heading_spacing = spacing;
}
if let Some(ref style) = cli.shot_style {
config.shot_style = style.clone();
}
let supports_unicode = supports_unicode::on(supports_unicode::Stream::Stdout);
let supports_color = supports_color::on(supports_color::Stream::Stdout).is_some();
config.force_ascii |= !supports_unicode;
if config.force_ansi {
config.no_color = false;
} else if !supports_color {
config.no_color = true;
}
config
}
}
#[cfg(test)]
mod config_tests {
use super::*;
#[test]
fn test_config_default_values() {
let config = Config::default();
assert!(config.show_scene_numbers);
assert!(config.show_page_numbers);
assert!(config.hide_markup);
assert!(!config.highlight_active_action);
assert!(!config.typewriter_mode);
assert!(!config.strict_typewriter_mode);
assert!(!config.focus_mode);
assert!(config.autocomplete);
assert!(config.auto_contd);
assert!(config.auto_paragraph_breaks);
assert!(config.match_parentheses);
assert!(config.close_elements);
assert!(!config.auto_title_page);
assert!(config.break_actions);
assert!(!config.goto_end);
assert_eq!(config.contd_extension, "(CONT'D)");
assert_eq!(config.heading_style, "bold");
assert_eq!(config.heading_spacing, 1);
assert_eq!(config.shot_style, "bold");
assert!(!config.no_color);
assert!(!config.no_formatting);
assert!(!config.force_ascii);
assert!(!config.force_ansi);
}
#[test]
fn test_config_parsing_appearance_flags() {
let mut config = Config::default();
let mock_file_content = "
set no_color
set no_formatting
set force_ascii
set force_ansi
";
config.parse_config_str(mock_file_content);
assert!(config.no_color, "no_color should be set by parsing");
assert!(
config.no_formatting,
"no_formatting should be set by parsing"
);
assert!(config.force_ascii, "force_ascii should be set by parsing");
assert!(config.force_ansi, "force_ansi should be set by parsing");
}
#[test]
fn test_config_parsing_behavior_flags() {
let mut config = Config::default();
let mock_file_content = "
set strict_typewriter_mode
set highlight_active_action
set goto_end
unset break_actions
";
config.parse_config_str(mock_file_content);
assert!(
config.strict_typewriter_mode,
"strict_typewriter_mode should be set by parsing"
);
assert!(
config.highlight_active_action,
"highlight_active_action should be set by parsing"
);
assert!(config.goto_end, "goto_end should be set by parsing");
assert!(
!config.break_actions,
"break_actions should be unset by parsing"
);
}
#[test]
fn test_cli_overrides_for_appearance() {
let mut cli = Cli::default();
cli.force_ascii = true;
cli.no_color = true;
cli.no_formatting = true;
let config = Config::load(&cli);
assert!(config.no_color);
assert!(config.no_formatting);
assert!(config.force_ascii);
assert!(!config.force_ansi);
}
#[test]
fn test_cli_overrides_for_behavior_flags() {
let mut cli = Cli::default();
cli.strict_typewriter_mode = true;
cli.highlight_active_action = true;
cli.goto_end = true;
cli.no_break_actions = true;
let config = Config::load(&cli);
assert!(config.strict_typewriter_mode);
assert!(config.highlight_active_action);
assert!(config.goto_end);
assert!(
!config.break_actions,
"no_break_actions CLI flag should unset break_actions"
);
}
#[test]
fn test_force_ansi_overrides_no_color() {
let mut cli = Cli::default();
cli.no_color = true;
cli.force_ansi = true;
let config = Config::load(&cli);
assert!(
!config.no_color,
"force_ansi should override no_color to false"
);
assert!(config.force_ansi);
}
#[test]
fn test_config_load_cli_overrides_values() {
let mut cli = Cli::default();
cli.contd_extension = Some(" (ПРОД.)".to_string());
cli.heading_style = Some("underline".to_string());
cli.heading_spacing = Some(3);
cli.shot_style = Some("italic".to_string());
let config = Config::load(&cli);
assert_eq!(config.contd_extension, " (ПРОД.)");
assert_eq!(config.heading_style, "underline");
assert_eq!(config.heading_spacing, 3);
assert_eq!(config.shot_style, "italic");
}
#[test]
fn test_custom_config_file_error() {
let mut cli = Cli::default();
cli.config = Some(std::path::PathBuf::from(
"/this/path/doesnt/exist/neither/does/the/meaning/of/life.conf",
));
let config = Config::load(&cli);
assert_eq!(config.heading_spacing, 1);
assert!(!config.strict_typewriter_mode);
}
}