From 99e3eba54a85c6e1f12f4fb1c7f2694d80349cf0 Mon Sep 17 00:00:00 2001 From: candle Date: Thu, 10 Jul 2025 20:36:32 -0400 Subject: [PATCH] initial commit --- .gitignore | 3 + Cargo.toml | 12 ++ LICENSE-MIT | 25 ++++ README.md | 156 ++++++++++++++++++++++++ src/cli.rs | 150 +++++++++++++++++++++++ src/config.rs | 234 ++++++++++++++++++++++++++++++++++++ src/generator.rs | 255 ++++++++++++++++++++++++++++++++++++++++ src/main.rs | 118 +++++++++++++++++++ src/message_builder.rs | 47 ++++++++ src/templates.rs | 13 ++ src/text_utils.rs | 215 +++++++++++++++++++++++++++++++++ src/words.rs | 80 +++++++++++++ src/words/adjectives.rs | 23 ++++ src/words/adverbs.rs | 20 ++++ src/words/locations.rs | 14 +++ src/words/nouns.rs | 5 + src/words/verbs.rs | 23 ++++ 17 files changed, 1393 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 LICENSE-MIT create mode 100644 README.md create mode 100644 src/cli.rs create mode 100644 src/config.rs create mode 100644 src/generator.rs create mode 100644 src/main.rs create mode 100644 src/message_builder.rs create mode 100644 src/templates.rs create mode 100644 src/text_utils.rs create mode 100644 src/words.rs create mode 100644 src/words/adjectives.rs create mode 100644 src/words/adverbs.rs create mode 100644 src/words/locations.rs create mode 100644 src/words/nouns.rs create mode 100644 src/words/verbs.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18f63ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +Cargo.lock +.c* diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f9b7553 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "motd" +version = "0.1.0" +edition = "2024" + +[dependencies] +rand = "0.8" +clap = { version = "4.0", features = ["derive"] } +clap_complete = "4.0" +regex = "1.10" +serde = { version = "1.0", features = ["derive"] } +toml = "0.8" diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..f29028a --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,25 @@ +Copyright (c) Filip Bicki + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..adca80f --- /dev/null +++ b/README.md @@ -0,0 +1,156 @@ +# MOTD Maker - motd + +`motd` is a command line utility which outputs a (semi) intelligible, randomly generated message to your standard output. + +## Build and Install +##### Requirements +`git`, `rust`/`rustup`/`cargo` +##### Arch Linux +`sudo pacman -S git rust` +##### Ubuntu/Debian +`sudo apt install git rust` + +#### Install +```bash +git clone https://code.lampnet.io/candle/motdmaker +cd motdmaker && cargo build --release +sudo mv target/release/motd /usr/local/bin/ +``` +You may remove the cloned directory. + +## Usage + +`motd` is best when customized with your own word pools and templates. + +`motd` will look for a `$HOME/.motdrc`, `$XDG_CONFIG_HOME/.motdrc`, or `$XDG_CONFIG_HOME/motd/motdrc` file and use the settings there as defaults when running `motd` with no flags. If somehow `motd` is configured in a way that would result in an empty word list, it will fallback to the built in defaults. + +``` +# Example motdrc +# This file can be placed at: +# ~/.motdrc +# $XDG_CONFIG_HOME/.motdrc +# $XDG_CONFIG_HOME/motd/motdrc +# (or ~/.config/motd/motdrc if XDG_CONFIG_HOME is not set) + +# Word lists - add custom words to the defaults +adjective = ["mystical", "ancient", "shimmering", "ethereal"] +noun = ["artifact", "prophecy", "wanderer", "sanctuary"] +verb = ["whispers", "echoes", "illuminates", "beckons"] +adverb = ["mysteriously", "gracefully", "eternally", "silently"] +location = ["in the forgotten realm", "beneath the starlit sky", "within the crystal caves"] + +# Template customization +# template = "The {adjective} {noun} {verb} {adverb} {location}." + +# Behavior settings +# Set to true to replace default word lists instead of extending them +replace = false +# Convert output to lowercase +lowercase = false +# Convert output to uppercase +uppercase = false + +# Text processing +# Words to remove from output +strip = [".", ":"] +# Delimiter between words (default is space) +delimiter = "." +# Maximum length of generated message +cut_off = 100 + +# Write output to file instead of stdout, WARNING: will overwrite +# output = "$HOME/.MOTD" + +# Note: CLI arguments will override these RC file settings +``` + +### Word Files + +You can also create custom word files inside of `$XDG_CONFIG_HOME/motd/`: +``` +$XDG_CONFIG_HOME/motd/ +|-nouns +|-adjectives +|-adverbs +|-locations +|-templates +``` +You can dump the default word banks into their respectice word file and disable/add entries individually: + +``` +mkdir -p $XDG_CONFIG_HOME/motd +motd -p templates > $XDG_CONFIG_HOME/motd/templates +motd -p nouns > $XDG_CONFIG_HOME/motd/nouns +motd -p adjectives > $XDG_CONFIG_HOME/motd/adjectives +motd -p adverbs > $XDG_CONFIG_HOME/motd/adverbs +motd -p locations > $XDG_CONFIG_HOME/motd/locations +``` +Words are separated by line, meaning you can have multiple words treated as a single unit.\ +Re-defining a word will not add an additional entry to the word pool. +Lines beginning with a `!` are actively removed from their respective word pool.\ +Lines beginning with a `#` are ignored as comments. +Lines with the format `$()` can be `bash` expressions which are evaluated at runtime.\ +Example: `nouns` file +``` +# I'm really scared of these +!ocean +ocean +# The previous ocean will still not be added to the word pool +$(date +%H:%M) +``` + +Now running `motd` will remove 'ocean' from the noun word pool, while also adding the possibilty of running `date +%H:%M` when replacing a template's noun, which will give the current time of day. + +``` +motd -r # -r is to force the added nouns to be used, for this example +When the moon rises, the mighty 19:58 shines in the misty valley. +``` + +Word files don't have to live in your configuration directory, you can pass `motd` a custom `--configdir/-c`. + +### Templates + +Templates are separated by line. They can have as many or as few word types as you'd like. +* `{noun}` or `{n}` +* `{adjective}` or `{a}` +* `{verb}` or `{v}` +* `{adverb}` or `{d}` +* `{location}` or `{l}` + +Bash expressions (`$()` form) in the `templates` file are evaluate at runtime. + +## Examples + +``` +❯ motd -U -s . -s : -s , -D . +THE.ETERNAL.PHOENIX.BLOOMS.SILENTLY.WHERE.SHADOWS.DANCE +``` +`-U`: Capitalizes output\ +`-s`: Strips given character from generation\ +`-D`: Change delimiter from `space`\ +Note that the stripping of characters occurs first, then the delimiter is replaced with the argument. + +``` +❯ motd -v opens --noun book -n chair -d weirdly -t "A {n} {v} the {noun} {d}" -r +A chair opens the book weirdly +``` +`--noun\-n\-v\-d`: Single word by type, to add to the word pool. Can be specified multiple times.\ +`-t`: The template to base the message off. Using `-t` will force the template used to be argument given.\ +`-r`: Only new words, passed as arguments, or from configuration files, will be used. + +``` +❯ motd -t "The {n} and the {noun} and the {n}." +The wizard and the knight and the ocean. +``` +`-t`: Force template to be used.\ +`--noun/-n`: Adding multiple noun positions and nothing else. + +``` +❯ motd -C 20 2>output.txt +❯ echo $? +1 +❯ cat output.txt +Error: CutOffExceeded { attempts: 100, cut_off: 20 } +``` +`-C`: Specify a max amount of characters the message can be. +`motd` exits with `1` when it is unable to generate a valid message. Either the template pool is too diluted, or you create a valid template within the confines you want. diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..671f5ae --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,150 @@ +use clap::Parser; +use serde::Deserialize; + +#[derive(Parser)] +#[command(name = env!("CARGO_PKG_NAME"))] +#[command(version)] +#[command(about = "Generate random messages with customizable word lists.")] +pub struct Args { + /// Specify configuration directory + #[arg(short = 'c', long)] + pub configdir: Option, + + /// Write output to file + #[arg(short = 'o', long)] + pub output: Option, + + /// Add an adjective + #[arg(short = 'a', long)] + pub adjective: Option>, + + /// Add a noun + #[arg(short = 'n', long)] + pub noun: Option>, + + /// Add a verb + #[arg(short = 'v', long)] + pub verb: Option>, + + /// Add an adverb + #[arg(short = 'd', long)] + pub adverb: Option>, + + /// Add a location + #[arg(short = 'l', long)] + pub location: Option>, + + /// Custom override template: "{(n)oun} {(a)djective} {(v)erb} {a(d)verb} {(l)ocation}" + #[arg(short = 't', long)] + pub template: Option, + + /// Replace default lists instead of extending + #[arg(short = 'r', long)] + pub replace: bool, + + /// Lowercase output + #[arg(short = 'L', long)] + pub lowercase: bool, + + /// Capitalize output + #[arg(short = 'U', long)] + pub uppercase: bool, + + /// String to strip + #[arg(short = 's', long)] + pub strip: Option>, + + /// Delimiter + #[arg(short = 'D', long)] + pub delimiter: Option, + + /// Cut-off + #[arg(short = 'C', long)] + pub cut_off: Option, + + /// Dump default lists + #[arg(short = 'p', long, value_name = "WORD_TYPE")] + pub print: Option>, + + /// Outputs shell completion for the given shell + #[arg(long, value_name = "SHELL")] + pub completion: Option, +} + +#[derive(Deserialize, Default, Debug)] +pub struct RcArgs { + pub output: Option, + pub adjective: Option>, + pub noun: Option>, + pub verb: Option>, + pub adverb: Option>, + pub location: Option>, + pub template: Option, + pub replace: Option, + pub lowercase: Option, + pub uppercase: Option, + pub strip: Option>, + pub delimiter: Option, + pub cut_off: Option, +} + +impl Args { + pub fn merge_with_rc(mut self, rc_args: RcArgs) -> Self { + // CLI args take precedence over RC file args + // Only use RC file values if CLI values are None/false/default + + if self.output.is_none() { + self.output = rc_args.output; + } + + if self.adjective.is_none() { + self.adjective = rc_args.adjective; + } + + if self.noun.is_none() { + self.noun = rc_args.noun; + } + + if self.verb.is_none() { + self.verb = rc_args.verb; + } + + if self.adverb.is_none() { + self.adverb = rc_args.adverb; + } + + if self.location.is_none() { + self.location = rc_args.location; + } + + if self.template.is_none() { + self.template = rc_args.template; + } + + if !self.replace { + self.replace = rc_args.replace.unwrap_or(false); + } + + if !self.lowercase { + self.lowercase = rc_args.lowercase.unwrap_or(false); + } + + if !self.uppercase { + self.uppercase = rc_args.uppercase.unwrap_or(false); + } + + if self.strip.is_none() { + self.strip = rc_args.strip; + } + + if self.delimiter.is_none() { + self.delimiter = rc_args.delimiter; + } + + if self.cut_off.is_none() { + self.cut_off = rc_args.cut_off; + } + + self + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..6571d88 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,234 @@ +use crate::cli::RcArgs; +use crate::words::WordList; +use crate::words::{WordType, WORD_TYPES}; +use regex::Regex; +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +pub struct Config { + pub words: WordList, + pub exclusions: WordList, + pub templates: Vec, + pub template_exclusions: Vec, +} + +impl Config { + pub fn load(config_dir: Option<&str>) -> Self { + let config_dir = if let Some(dir) = config_dir { + PathBuf::from(dir) + } else { + get_config_dir() + }; + + let mut words = WordList::new(); + let mut exclusions = WordList::new(); + let mut templates = Vec::new(); + let mut template_exclusions = Vec::new(); + for word_type in WORD_TYPES { + let (word_list, exclusion_list) = load_word_list_with_exclusions(&config_dir.join(word_type.name())); + if !word_list.is_empty() { + match word_type { + WordType::Adjective => words.adjectives = Some(word_list), + WordType::Noun => words.nouns = Some(word_list), + WordType::Verb => words.verbs = Some(word_list), + WordType::Adverb => words.adverbs = Some(word_list), + WordType::Location => words.locations = Some(word_list), + WordType::Template => templates = word_list, + } + } + if !exclusion_list.is_empty() { + match word_type { + WordType::Adjective => exclusions.adjectives = Some(exclusion_list), + WordType::Noun => exclusions.nouns = Some(exclusion_list), + WordType::Verb => exclusions.verbs = Some(exclusion_list), + WordType::Adverb => exclusions.adverbs = Some(exclusion_list), + WordType::Location => exclusions.locations = Some(exclusion_list), + WordType::Template => template_exclusions = exclusion_list, + } + } + } + + Config { + words, + exclusions, + templates, + template_exclusions, + } + } +} + +fn get_config_dir() -> PathBuf { + if let Ok(xdg_config) = env::var("XDG_CONFIG_HOME") { + PathBuf::from(xdg_config).join(env!("CARGO_PKG_NAME")) + } else if let Ok(home) = env::var("HOME") { + PathBuf::from(home) + .join(".config") + .join(env!("CARGO_PKG_NAME")) + } else { + PathBuf::from(".config").join(env!("CARGO_PKG_NAME")) + } +} + +fn load_word_list_with_exclusions(path: &PathBuf) -> (Vec, Vec) { + match fs::read_to_string(path) { + Ok(content) => { + let lines: Vec = content + .lines() + .map(|line| line.trim().to_string()) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(|line| expand_line(&line)) + .collect(); + + // Separate exclusions (lines starting with '!') from regular words + let mut exclusions = Vec::new(); + let mut words = Vec::new(); + + for line in lines { + if line.starts_with('!') { + // Remove the '!' prefix and add to exclusions + exclusions.push(line[1..].to_string()); + } else { + words.push(line); + } + } + + // Filter out any words that are in the exclusion list + let filtered_words = words.into_iter() + .filter(|word| !exclusions.contains(word)) + .collect(); + + (filtered_words, exclusions) + }, + Err(_) => (Vec::new(), Vec::new()), + } +} + +fn expand_line(line: &str) -> String { + let mut result = line.to_string(); + + result = expand_env_vars(&result); + result = expand_bash_expressions(&result); + + result +} + +fn expand_env_vars(text: &str) -> String { + let re = Regex::new(r"\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)").unwrap(); + + re.replace_all(text, |caps: ®ex::Captures| { + let var_name = match caps.get(1).or_else(|| caps.get(2)) { + Some(v) => v.as_str(), + None => return "".to_string(), + }; + env::var(var_name).unwrap_or_else(|_| format!("${{{}}}", var_name)) + }) + .to_string() +} + +fn expand_bash_expressions(text: &str) -> String { + let re = Regex::new(r"\$\(([^)]+)\)|`([^`]+)`").unwrap(); + + re.replace_all(text, |caps: ®ex::Captures| { + let command = match caps.get(1).or_else(|| caps.get(2)) { + Some(c) => c.as_str(), + None => return "".to_string(), + }; + execute_bash_command(command) + }) + .to_string() +} + +fn execute_bash_command(command: &str) -> String { + if is_recursive_command(command) { + return "nice try".to_string(); + } + + match Command::new("sh").arg("-c").arg(command).output() { + Ok(output) => { + if output.status.success() { + String::from_utf8_lossy(&output.stdout).trim().to_string() + } else { + format!("$({})", command) + } + } + Err(_) => format!("$({})", command), + } +} + +fn is_recursive_command(command: &str) -> bool { + let current_exe = env::current_exe().unwrap_or_default(); + let program_name = current_exe + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + + if program_name.is_empty() { + return false; + } + + let tokens: Vec<&str> = command.split_whitespace().collect(); + + for token in tokens { + if token == program_name { + return true; + } + + if let Some(command_name) = token.split('/').last() { + if command_name == program_name { + return true; + } + } + } + + false +} + +pub fn load_rc_file() -> RcArgs { + let rc_locations = get_rc_locations(); + + for path in rc_locations { + if path.exists() { + match fs::read_to_string(&path) { + Ok(content) => { + match toml::from_str::(&content) { + Ok(rc_args) => return rc_args, + Err(e) => { + eprintln!("Warning: Failed to parse RC file '{}': {}", path.display(), e); + } + } + } + Err(e) => { + eprintln!("Warning: Failed to read RC file '{}': {}", path.display(), e); + } + } + } + } + + RcArgs::default() +} + +fn get_rc_locations() -> Vec { + let mut locations = Vec::new(); + + // ~/.motdrc + if let Ok(home) = env::var("HOME") { + locations.push(PathBuf::from(home).join(".motdrc")); + } + + // $XDG_CONFIG_HOME/.motdrc + if let Ok(xdg_config) = env::var("XDG_CONFIG_HOME") { + locations.push(PathBuf::from(xdg_config).join(".motdrc")); + } + + // $XDG_CONFIG_HOME/motd/motdrc + if let Ok(xdg_config) = env::var("XDG_CONFIG_HOME") { + locations.push(PathBuf::from(xdg_config).join("motd").join("motdrc")); + } else if let Ok(home) = env::var("HOME") { + // Fallback to ~/.config/motd/motdrc if XDG_CONFIG_HOME is not set + locations.push(PathBuf::from(home).join(".config").join("motd").join("motdrc")); + } + + locations +} diff --git a/src/generator.rs b/src/generator.rs new file mode 100644 index 0000000..2fd2f58 --- /dev/null +++ b/src/generator.rs @@ -0,0 +1,255 @@ +use crate::cli::Args; +use crate::config::Config; +use crate::message_builder::MessageBuilder; +use crate::templates::TEMPLATES; +use crate::words::{ + WORD_TYPES, WordList, WordType, adjectives::ADJECTIVES, adverbs::ADVERBS, locations::LOCATIONS, + nouns::NOUNS, verbs::VERBS, +}; +use rand::Rng; +use rand::seq::SliceRandom; +use std::collections::HashMap; +use std::error::Error; +use std::fmt; + +#[derive(Debug)] +pub enum GeneratorError { + EmptyTemplateList, + CutOffExceeded { attempts: usize, cut_off: i32 }, +} + +impl fmt::Display for GeneratorError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + GeneratorError::EmptyTemplateList => write!( + f, + "No templates available. Provide a template or use the default ones." + ), + GeneratorError::CutOffExceeded { attempts, cut_off } => write!( + f, + "Could not generate a message within {} characters after {} attempts.", + cut_off, attempts + ), + } + } +} + +impl Error for GeneratorError {} + +const MAX_CUTOFF_ATTEMPTS: usize = 100; + +pub fn generate_message(arg_words: WordList, args: Args) -> Result { + let mut rng = rand::thread_rng(); + + // Load config files + let config = Config::load(args.configdir.as_deref()); + + // Get available templates + let templates = if args.template.is_some() { + vec![args.template.as_ref().unwrap().clone()] + } else { + let template_list = build_template_list(&config.templates, &config.template_exclusions); + if template_list.is_empty() { + return Err(GeneratorError::EmptyTemplateList); + } + template_list + }; + + // Build word lists once + let word_lists: HashMap> = WORD_TYPES + .iter() + .filter(|&word_type| *word_type != WordType::Template) + .map(|&word_type| { + let (defaults, config_words, custom_words, exclusions) = match word_type { + WordType::Adjective => ( + ADJECTIVES, + config.words.adjectives.as_deref(), + arg_words.adjectives.as_ref(), + config.exclusions.adjectives.as_deref(), + ), + WordType::Noun => ( + NOUNS, + config.words.nouns.as_deref(), + arg_words.nouns.as_ref(), + config.exclusions.nouns.as_deref(), + ), + WordType::Verb => ( + VERBS, + config.words.verbs.as_deref(), + arg_words.verbs.as_ref(), + config.exclusions.verbs.as_deref(), + ), + WordType::Adverb => ( + ADVERBS, + config.words.adverbs.as_deref(), + arg_words.adverbs.as_ref(), + config.exclusions.adverbs.as_deref(), + ), + WordType::Location => ( + LOCATIONS, + config.words.locations.as_deref(), + arg_words.locations.as_ref(), + config.exclusions.locations.as_deref(), + ), + _ => (NOUNS, None, None, None), + }; + ( + word_type, + build_word_list(defaults, config_words, custom_words, exclusions, args.replace), + ) + }) + .collect(); + + let cut_off = args.cut_off; + let mut attempts = 0; + + loop { + attempts += 1; + + // Select a template + let template = templates[rng.gen_range(0..templates.len())].clone(); + + // Analyze template to count placeholders + let placeholder_counts = analyze_template(&template); + + let all_words: HashMap> = WORD_TYPES + .iter() + .filter_map(|&word_type| { + let count = *placeholder_counts.get(&word_type).unwrap_or(&0); + if count > 0 { + word_lists + .get(&word_type) + .map(|list| (word_type, generate_words(list, count, &mut rng))) + } else { + None + } + }) + .collect(); + + // Replace placeholders with generated words + let builder = replace_placeholders(&template, &all_words); + + let mut message = MessageBuilder::new(builder) + .remove_duplicates() + .fix_articles() + .fix_s() + .capitalize_sentences(args.uppercase, args.lowercase); + + if let Some(ref strip) = args.strip { + message = message.strip_words(strip); + } + + if let Some(ref delimiter) = args.delimiter { + message = message.replace_delimiter(delimiter.as_str()); + } + + let final_message = message.build(); + + // Check cut-off if specified + if let Some(cut_off_len) = cut_off { + if final_message.len() <= cut_off_len as usize { + return Ok(final_message); + } + + // If we've exceeded max attempts, return an error + if attempts >= MAX_CUTOFF_ATTEMPTS { + return Err(GeneratorError::CutOffExceeded { + attempts, + cut_off: cut_off_len + }); + } + + // Continue the loop to try again + continue; + } else { + // No cut-off specified, return the message + return Ok(final_message); + } + } +} + +fn build_word_list( + defaults: &[&str], + config_words: Option<&[String]>, + custom: Option<&Vec>, + exclusions: Option<&[String]>, + replace: bool, +) -> Vec { + let mut words = if replace { + if let Some(custom_words) = custom.filter(|v| !v.is_empty()) { + custom_words.clone() + } else if let Some(config_words) = config_words.filter(|v| !v.is_empty()) { + config_words.to_vec() + } else { + defaults.iter().map(|s| s.to_string()).collect() + } + } else { + defaults + .iter() + .map(|s| s.to_string()) + .chain(config_words.into_iter().flatten().cloned()) + .chain(custom.into_iter().flatten().cloned()) + .collect() + }; + + // Apply exclusions if they exist + if let Some(exclusion_list) = exclusions { + words.retain(|word| !exclusion_list.contains(word)); + } + + words +} + +fn build_template_list(config_templates: &[String], exclusions: &[String]) -> Vec { + let mut templates: Vec = TEMPLATES.iter().map(|s| s.to_string()).collect(); + templates.extend(config_templates.iter().cloned()); + + // Apply exclusions if they exist + if !exclusions.is_empty() { + templates.retain(|template| !exclusions.contains(template)); + } + + templates +} + +fn analyze_template(template: &str) -> HashMap { + WORD_TYPES + .iter() + .map(|&word_type| { + let count = template.matches(word_type.long_placeholder()).count() + + template.matches(word_type.short_placeholder()).count(); + (word_type, count) + }) + .collect() +} + +fn generate_words(word_list: &[String], count: usize, rng: &mut R) -> Vec { + if count <= word_list.len() { + word_list.choose_multiple(rng, count).cloned().collect() + } else { + (0..count) + .filter_map(|_| word_list.choose(rng).cloned()) + .collect() + } +} + +fn replace_placeholders(template: &str, all_words: &HashMap>) -> String { + let mut builder = template.to_string(); + + for &word_type in &WORD_TYPES { + builder = builder.replace(word_type.short_placeholder(), word_type.long_placeholder()); + + if let Some(words) = all_words.get(&word_type) { + let mut word_iter = words.iter(); + while let Some(pos) = builder.find(word_type.long_placeholder()) { + if let Some(word) = word_iter.next() { + builder.replace_range(pos..pos + word_type.long_placeholder().len(), word); + } else { + break; + } + } + } + } + + builder +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..ef11321 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,118 @@ +use clap::{CommandFactory, Parser}; +use cli::Args; +use config::load_rc_file; +use words::WordList; + +mod cli; +mod config; +mod generator; +mod message_builder; +mod templates; +mod text_utils; +mod words; + +use generator::generate_message; +use templates::TEMPLATES; +use words::{adjectives, adverbs, locations, nouns, verbs}; + +fn main() -> Result<(), Box> { + // Load RC file configuration first + let rc_args = load_rc_file(); + + // Parse CLI args and merge with RC file defaults + let mut args = Args::parse().merge_with_rc(rc_args); + + if let Some(shell) = args.completion { + let mut cmd = ::command(); + let name = cmd.get_name().to_string(); + clap_complete::generate(shell, &mut cmd, name, &mut std::io::stdout()); + return Ok(()); + } + + if let Some(print_arg) = args.print { + match print_arg { + Some(word_type) => { + // Print specific word type without headers + match word_type.to_lowercase().as_str() { + "adjectives" | "adjective" => { + for adj in adjectives::ADJECTIVES { + println!("{}", adj); + } + } + "nouns" | "noun" => { + for noun in nouns::NOUNS { + println!("{}", noun); + } + } + "verbs" | "verb" => { + for verb in verbs::VERBS { + println!("{}", verb); + } + } + "adverbs" | "adverb" => { + for adverb in adverbs::ADVERBS { + println!("{}", adverb); + } + } + "locations" | "location" => { + for location in locations::LOCATIONS { + println!("{}", location); + } + } + "templates" | "template" => { + for template in TEMPLATES { + println!("{}", template); + } + } + _ => { + eprintln!( + "Unknown word type: {}. Valid types are: adjectives, nouns, verbs, adverbs, locations, templates", + word_type + ); + std::process::exit(1); + } + } + } + None => { + // Print all lists with headers (original behavior) + println!( + "-------Adjectives------\n{}", + adjectives::ADJECTIVES.join("\n") + ); + println!("---------Nouns---------\n{}", nouns::NOUNS.join("\n")); + println!("---------Verbs---------\n{}", verbs::VERBS.join("\n")); + println!("--------Adverbs--------\n{}", adverbs::ADVERBS.join("\n")); + println!( + "-------Locations-------\n{}", + locations::LOCATIONS.join("\n") + ); + println!("-------Templates-------\n{}", TEMPLATES.join("\n")); + } + } + return Ok(()); + } + + let word_list = WordList { + adjectives: args.adjective.take(), + nouns: args.noun.take(), + verbs: args.verb.take(), + adverbs: args.adverb.take(), + locations: args.location.take(), + }; + let output = args.output.take(); + + let message = generate_message(word_list, args)?; + match output { + Some(output_path) => { + std::fs::write(&output_path, format!("{}\n", message)).unwrap_or_else(|e| { + eprintln!("Failed to write to file '{}': {}", output_path, e); + std::process::exit(1); + }); + } + None => { + println!("{}", message); + } + } + + Ok(()) +} diff --git a/src/message_builder.rs b/src/message_builder.rs new file mode 100644 index 0000000..2b387f0 --- /dev/null +++ b/src/message_builder.rs @@ -0,0 +1,47 @@ +use crate::text_utils::*; + +pub struct MessageBuilder { + content: String, +} + +impl MessageBuilder { + pub fn new(content: String) -> Self { + Self { content } + } + + pub fn fix_articles(mut self) -> Self { + self.content = fix_articles(&self.content); + self + } + + pub fn capitalize_sentences(mut self, uppercase: bool, lowercase: bool) -> Self { + self.content = capitalize_sentences(&self.content, uppercase, lowercase); + self + } + + pub fn fix_s(mut self) -> Self { + self.content = fix_s(&self.content); + self + } + + pub fn remove_duplicates(mut self) -> Self { + self.content = remove_duplicates(&self.content); + self + } + + pub fn replace_delimiter(mut self, delimiter: &str) -> Self { + self.content = self.content.replace(" ", delimiter); + self + } + + pub fn strip_words(mut self, words: &Vec) -> Self { + for word in words { + self.content = self.content.replace(word, ""); + } + self + } + + pub fn build(self) -> String { + self.content + } +} diff --git a/src/templates.rs b/src/templates.rs new file mode 100644 index 0000000..562122e --- /dev/null +++ b/src/templates.rs @@ -0,0 +1,13 @@ +pub const TEMPLATES: &[&str] = &[ + "The {adjective} {noun} {verb} {adverb} {location}.", + "Today, the {adjective} {noun} {verb} {location}.", + "Behold! {adjective} {noun} {verb} {adverb}.", + "In ancient times, the {adjective} {noun} {verb} {adverb} {location}.", + "Legend speaks of {adjective} {noun} that {verb} {location}.", + "The {adjective} {noun} {verb} {adverb}, bringing peace to all.", + "When the moon rises, the {adjective} {noun} {verb} {location}.", + "Remember: even the {adjective} {noun} {verb} {adverb}.", + "The wise say that {adjective} {noun} always {verb} {location}.", + "Every {adjective} {noun} {verb} {adverb} when the time is right.", + "A {adjective} {noun} {verb} {adverb} {location}.", +]; diff --git a/src/text_utils.rs b/src/text_utils.rs new file mode 100644 index 0000000..6e46ade --- /dev/null +++ b/src/text_utils.rs @@ -0,0 +1,215 @@ +use std::collections::HashSet; + +pub fn starts_with_vowel(word: &str) -> bool { + let first_char = word + .chars() + .next() + .unwrap_or(' ') + .to_lowercase() + .next() + .unwrap_or(' '); + matches!(first_char, 'a' | 'e' | 'i' | 'o' | 'u') +} + +pub fn fix_articles(text: &str) -> String { + let mut result = String::new(); + let words: Vec<&str> = text.split_whitespace().collect(); + + for (i, word) in words.iter().enumerate() { + if i > 0 { + result.push(' '); + } + + if (word.eq_ignore_ascii_case("a") || word.eq_ignore_ascii_case("an")) + && i + 1 < words.len() + { + let next_word = words[i + 1]; + if starts_with_vowel(next_word) { + result.push_str("an"); + } else { + result.push('a'); + } + } else { + result.push_str(word); + } + } + + result +} + +pub fn capitalize_sentences(text: &str, uppercase: bool, lowercase: bool) -> String { + let mut result = String::new(); + let mut capitalize_next = true; + + match (uppercase, lowercase) { + (true, false) => result = text.to_uppercase(), + (false, true) => result = text.to_lowercase(), + _ => { + for ch in text.chars() { + if capitalize_next && ch.is_alphabetic() { + result.push(ch.to_uppercase().next().unwrap_or(ch)); + capitalize_next = false; + } else { + result.push(ch); + if matches!(ch, '.' | '!' | '?') { + capitalize_next = true; + } + } + } + } + } + + result +} + +static SINGULAR_SUBJECTS: &[&str] = &["he", "she", "it", "this", "that", "one"]; +static PLURAL_SUBJECTS: &[&str] = &[ + "they", "we", "you", "these", "those", "many", "several", "few", "both", "all", "some", +]; +static PLURAL_NUMBERS: &[&str] = &[ + "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", +]; +static PLURAL_ADVERBS: &[&str] = &[ + "always", + "never", + "often", + "usually", + "sometimes", + "rarely", + "frequently", + "occasionally", +]; + +// Protected nouns that should never lose their 's' +static PROTECTED_NOUNS: &[&str] = &[ + "crisis", + "basis", + "analysis", + "thesis", + "synthesis", + "emphasis", + "oasis", + "synopsis", + "diagnosis", + "prognosis", + "paralysis", + "class", + "glass", + "grass", + "mass", + "pass", + "business", + "process", + "address", + "success", + "access", + "progress", + "fitness", + "illness", + "darkness", + "kindness", + "goodness", + "awareness", + "fairness", +]; + +pub fn fix_s(text: &str) -> String { + let protected_set: HashSet<&str> = PROTECTED_NOUNS.iter().copied().collect(); + let singular_set: HashSet<&str> = SINGULAR_SUBJECTS.iter().copied().collect(); + let plural_set: HashSet<&str> = PLURAL_SUBJECTS + .iter() + .chain(PLURAL_NUMBERS.iter()) + .chain(PLURAL_ADVERBS.iter()) + .copied() + .collect(); + + let words: Vec<&str> = text.split_whitespace().collect(); + let mut result = String::new(); + + for (i, word) in words.iter().enumerate() { + if i > 0 { + result.push(' '); + } + + if i > 0 && (word.ends_with('s') || word.ends_with("es")) { + let word_lower = word.to_lowercase(); + + // Skip protected nouns + if protected_set.contains(word_lower.as_str()) { + result.push_str(word); + continue; + } + + let prev_word = words[i - 1].to_lowercase(); + + let should_remove_s = if let Ok(num) = prev_word.parse::() { + num != 1 + } else if plural_set.contains(prev_word.as_str()) { + true + } else if singular_set.contains(prev_word.as_str()) { + false + } else { + // Default: keep 's' for unknown cases + false + }; + + if should_remove_s { + result.push_str(&conjugate_to_base_form(word)); + } else { + result.push_str(word); + } + } else { + result.push_str(word); + } + } + + result +} + +fn conjugate_to_base_form(word: &str) -> String { + match word { + "goes" => "go".to_string(), + "does" => "do".to_string(), + "is" => "are".to_string(), + "has" => "have".to_string(), + _ => { + if word.ends_with("ies") && word.len() > 3 { + // flies -> fly, tries -> try + format!("{}y", &word[..word.len() - 3]) + } else if word.ends_with("ches") + || word.ends_with("shes") + || word.ends_with("xes") + || word.ends_with("zes") + || word.ends_with("ses") + { + // watches -> watch, fixes -> fix, focuses -> focus + word[..word.len() - 2].to_string() + } else if word.ends_with("oes") && word.len() > 3 { + // echoes -> echo, heroes -> hero, potatoes -> potato + word[..word.len() - 2].to_string() + } else if word.ends_with('s') && word.len() > 1 { + // runs -> run, sparkles -> sparkle + word[..word.len() - 1].to_string() + } else { + word.to_string() + } + } + } +} + +pub fn remove_duplicates(text: &str) -> String { + let mut result = String::new(); + let words: Vec<&str> = text.split_whitespace().collect(); + + for (i, word) in words.iter().enumerate() { + if i > 0 { + result.push(' '); + } + + if i == 0 || *word != words[i - 1] { + result.push_str(word); + } + } + + result +} diff --git a/src/words.rs b/src/words.rs new file mode 100644 index 0000000..21c2fdf --- /dev/null +++ b/src/words.rs @@ -0,0 +1,80 @@ +pub mod adjectives; +pub mod adverbs; +pub mod locations; +pub mod nouns; +pub mod verbs; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum WordType { + Adjective, + Noun, + Verb, + Adverb, + Location, + Template, +} + +impl WordType { + pub fn name(&self) -> &'static str { + match self { + WordType::Adjective => "adjectives", + WordType::Noun => "nouns", + WordType::Verb => "verbs", + WordType::Adverb => "adverbs", + WordType::Location => "locations", + WordType::Template => "templates", + } + } + + pub fn long_placeholder(&self) -> &'static str { + match self { + WordType::Adjective => "{adjective}", + WordType::Noun => "{noun}", + WordType::Verb => "{verb}", + WordType::Adverb => "{adverb}", + WordType::Location => "{location}", + WordType::Template => "", + } + } + + pub fn short_placeholder(&self) -> &'static str { + match self { + WordType::Adjective => "{a}", + WordType::Noun => "{n}", + WordType::Verb => "{v}", + WordType::Adverb => "{d}", + WordType::Location => "{l}", + WordType::Template => "", + } + } +} + +pub const WORD_TYPES: [WordType; 6] = [ + WordType::Adjective, + WordType::Noun, + WordType::Verb, + WordType::Adverb, + WordType::Location, + WordType::Template, +]; + +#[derive(Default)] +pub struct WordList { + pub adjectives: Option>, + pub nouns: Option>, + pub verbs: Option>, + pub adverbs: Option>, + pub locations: Option>, +} + +impl WordList { + pub fn new() -> Self { + Self { + adjectives: None, + nouns: None, + verbs: None, + adverbs: None, + locations: None, + } + } +} \ No newline at end of file diff --git a/src/words/adjectives.rs b/src/words/adjectives.rs new file mode 100644 index 0000000..98dac20 --- /dev/null +++ b/src/words/adjectives.rs @@ -0,0 +1,23 @@ +pub const ADJECTIVES: &[&str] = &[ + "brilliant", + "mysterious", + "ancient", + "shimmering", + "peaceful", + "mighty", + "clever", + "graceful", + "bold", + "serene", + "vibrant", + "wise", + "swift", + "gentle", + "fierce", + "luminous", + "silent", + "eternal", + "magnificent", + "curious", + // More adjectives +]; diff --git a/src/words/adverbs.rs b/src/words/adverbs.rs new file mode 100644 index 0000000..4630b82 --- /dev/null +++ b/src/words/adverbs.rs @@ -0,0 +1,20 @@ +pub const ADVERBS: &[&str] = &[ + "silently", + "gracefully", + "powerfully", + "mysteriously", + "peacefully", + "boldly", + "gently", + "swiftly", + "wisely", + "eternally", + "brilliantly", + "quietly", + "majestically", + "softly", + "fiercely", + "calmly", + "steadily", + "proudly", +]; diff --git a/src/words/locations.rs b/src/words/locations.rs new file mode 100644 index 0000000..2fc3021 --- /dev/null +++ b/src/words/locations.rs @@ -0,0 +1,14 @@ +pub const LOCATIONS: &[&str] = &[ + "in the misty valley", + "beneath the starlit sky", + "beyond the ancient ruins", + "within the enchanted grove", + "atop the highest peak", + "beside the crystal lake", + "through the whispering woods", + "across the golden plains", + "near the forgotten temple", + "in the realm of dreams", + "at the edge of time", + "where shadows dance", +]; diff --git a/src/words/nouns.rs b/src/words/nouns.rs new file mode 100644 index 0000000..719a5bf --- /dev/null +++ b/src/words/nouns.rs @@ -0,0 +1,5 @@ +pub const NOUNS: &[&str] = &[ + "dragon", "castle", "forest", "ocean", "mountain", "wizard", "knight", "phoenix", "crystal", + "portal", "garden", "library", "tower", "bridge", "temple", "compass", "lantern", "scroll", + "mirror", "key", "crown", "sword", "shield", "staff", // More nouns +]; diff --git a/src/words/verbs.rs b/src/words/verbs.rs new file mode 100644 index 0000000..d91f56d --- /dev/null +++ b/src/words/verbs.rs @@ -0,0 +1,23 @@ +pub const VERBS: &[&str] = &[ + "awakens", + "glows", + "whispers", + "dances", + "soars", + "guards", + "seeks", + "remembers", + "shines", + "flows", + "stands", + "calls", + "watches", + "guides", + "protects", + "reveals", + "echoes", + "blooms", + "sparkles", + "wanders", + // More verbs +];