initial commit

This commit is contained in:
candle 2025-07-10 20:36:32 -04:00
commit 99e3eba54a
17 changed files with 1393 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/target
Cargo.lock
.c*

12
Cargo.toml Normal file
View File

@ -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"

25
LICENSE-MIT Normal file
View File

@ -0,0 +1,25 @@
Copyright (c) Filip Bicki <candle@lampnet.io>
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.

156
README.md Normal file
View File

@ -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.

150
src/cli.rs Normal file
View File

@ -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<String>,
/// Write output to file
#[arg(short = 'o', long)]
pub output: Option<String>,
/// Add an adjective
#[arg(short = 'a', long)]
pub adjective: Option<Vec<String>>,
/// Add a noun
#[arg(short = 'n', long)]
pub noun: Option<Vec<String>>,
/// Add a verb
#[arg(short = 'v', long)]
pub verb: Option<Vec<String>>,
/// Add an adverb
#[arg(short = 'd', long)]
pub adverb: Option<Vec<String>>,
/// Add a location
#[arg(short = 'l', long)]
pub location: Option<Vec<String>>,
/// Custom override template: "{(n)oun} {(a)djective} {(v)erb} {a(d)verb} {(l)ocation}"
#[arg(short = 't', long)]
pub template: Option<String>,
/// 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<Vec<String>>,
/// Delimiter
#[arg(short = 'D', long)]
pub delimiter: Option<String>,
/// Cut-off
#[arg(short = 'C', long)]
pub cut_off: Option<i32>,
/// Dump default lists
#[arg(short = 'p', long, value_name = "WORD_TYPE")]
pub print: Option<Option<String>>,
/// Outputs shell completion for the given shell
#[arg(long, value_name = "SHELL")]
pub completion: Option<clap_complete::Shell>,
}
#[derive(Deserialize, Default, Debug)]
pub struct RcArgs {
pub output: Option<String>,
pub adjective: Option<Vec<String>>,
pub noun: Option<Vec<String>>,
pub verb: Option<Vec<String>>,
pub adverb: Option<Vec<String>>,
pub location: Option<Vec<String>>,
pub template: Option<String>,
pub replace: Option<bool>,
pub lowercase: Option<bool>,
pub uppercase: Option<bool>,
pub strip: Option<Vec<String>>,
pub delimiter: Option<String>,
pub cut_off: Option<i32>,
}
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
}
}

234
src/config.rs Normal file
View File

@ -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<String>,
pub template_exclusions: Vec<String>,
}
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<String>, Vec<String>) {
match fs::read_to_string(path) {
Ok(content) => {
let lines: Vec<String> = 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: &regex::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: &regex::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::<RcArgs>(&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<PathBuf> {
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
}

255
src/generator.rs Normal file
View File

@ -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<String, GeneratorError> {
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<WordType, Vec<String>> = 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<WordType, Vec<String>> = 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<String>>,
exclusions: Option<&[String]>,
replace: bool,
) -> Vec<String> {
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<String> {
let mut templates: Vec<String> = 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<WordType, usize> {
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<R: Rng>(word_list: &[String], count: usize, rng: &mut R) -> Vec<String> {
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<WordType, Vec<String>>) -> 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
}

118
src/main.rs Normal file
View File

@ -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<dyn std::error::Error>> {
// 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 = <Args as CommandFactory>::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(())
}

47
src/message_builder.rs Normal file
View File

@ -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<String>) -> Self {
for word in words {
self.content = self.content.replace(word, "");
}
self
}
pub fn build(self) -> String {
self.content
}
}

13
src/templates.rs Normal file
View File

@ -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}.",
];

215
src/text_utils.rs Normal file
View File

@ -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::<i32>() {
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
}

80
src/words.rs Normal file
View File

@ -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<Vec<String>>,
pub nouns: Option<Vec<String>>,
pub verbs: Option<Vec<String>>,
pub adverbs: Option<Vec<String>>,
pub locations: Option<Vec<String>>,
}
impl WordList {
pub fn new() -> Self {
Self {
adjectives: None,
nouns: None,
verbs: None,
adverbs: None,
locations: None,
}
}
}

23
src/words/adjectives.rs Normal file
View File

@ -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
];

20
src/words/adverbs.rs Normal file
View File

@ -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",
];

14
src/words/locations.rs Normal file
View File

@ -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",
];

5
src/words/nouns.rs Normal file
View File

@ -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
];

23
src/words/verbs.rs Normal file
View File

@ -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
];