···11+Copyright 2026 paeth.xyz
22+33+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:
44+55+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
66+77+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.
+49
README.md
···11+# dollysay
22+33+A cloned sheep speaks. Inspired by cowsay, made for [Tangled](https://tangled.org).
44+55+```
66+__________________________
77+< I push, therefore I baa. >
88+--------------------------
99+ \ \
1010+ \ \
1111+ .~``~,,```-. .~``~,,```-.
1212+ ( -~~- ) ( -~~- )
1313+ ( // OO \\ ) ( // OO \\ )
1414+ ( \__/ ) ( \__/ )
1515+ `~_.,~.__.~' `~_.,~.__.~'
1616+```
1717+1818+## Install
1919+2020+You'll need a Rust toolchain. Install via [rustup](https://rustup.rs) if you don't have one.
2121+2222+```sh
2323+git clone <repo-url> dollysay
2424+cd dollysay
2525+cargo install --path .
2626+```
2727+2828+That builds in release mode and installs the `dollysay` binary into `~/.cargo/bin/`, which rustup already puts on your `$PATH`.
2929+3030+## Use
3131+3232+```sh
3333+dollysay hello # speak a message
3434+echo "baa" | dollysay # read from stdin
3535+dollysay --clone "we are many" # two Dollys, one bubble
3636+dollysay --tip # hear what Dolly has to say
3737+```
3838+3939+Run `dollysay --help` for the full flag list.
4040+4141+## Uninstall
4242+4343+```sh
4444+cargo uninstall dollysay
4545+```
4646+4747+## TODO
4848+4949+- Add Dolly moods!
···11+use unicode_width::UnicodeWidthStr;
22+33+// Builds the speech bubble (without trailing newline) and returns
44+// it as one String.
55+pub fn render(text: &str, max_width: usize, min_width: usize) -> String {
66+ let wrapped_text: Vec<String> = textwrap::wrap(text, max_width)
77+ .into_iter()
88+ .map(|cow| cow.into_owned())
99+ .collect();
1010+1111+ // TODO: Better handle long min widths when cloning Dolly. Fragile here.
1212+ // inner width of bubble should be widest line, but never wider than
1313+ // max_width and never narrower than min_width.
1414+ let inner = wrapped_text
1515+ .iter()
1616+ // Crucial library win
1717+ .map(|l| UnicodeWidthStr::width(l.as_str()))
1818+ .max()
1919+ .unwrap_or(0)
2020+ .max(min_width.min(max_width));
2121+2222+ let mut out = String::new();
2323+2424+ // Top border of bubble.
2525+ out.push(' ');
2626+ out.push_str(&"_".repeat(inner + 2));
2727+ out.push('\n');
2828+2929+ if wrapped_text.len() == 1 {
3030+ // Single-line:
3131+ // _______________________
3232+ // < this is a single line >
3333+ // -----------------------
3434+ push_bubble_line(&mut out, &wrapped_text[0], inner, '<', '>');
3535+ } else {
3636+ // Multi-line:
3737+ // ___________________________________________________________
3838+ // / this is a multi line text that will require rounding the \
3939+ // | speech bubble corners. since it is more than two lines it |
4040+ // \ will need to be extended with walls. /
4141+ // -----------------------------------------------------------
4242+ let last = wrapped_text.len() - 1;
4343+ for (i, line) in wrapped_text.iter().enumerate() {
4444+ let (l, r) = if i == 0 {
4545+ ('/', '\\')
4646+ } else if i == last {
4747+ ('\\', '/')
4848+ } else {
4949+ ('|', '|')
5050+ };
5151+ push_bubble_line(&mut out, line, inner, l, r);
5252+ }
5353+ }
5454+5555+ // Bottom border of bubble.
5656+ out.push(' ');
5757+ out.push_str(&"-".repeat(inner + 2));
5858+5959+ out
6060+}
6161+6262+// Writes one bubble row:
6363+// left corner, " text... <spce padding>", right corner, newline.
6464+fn push_bubble_line(out: &mut String, line: &str, inner: usize, left: char, right: char) {
6565+ out.push(left);
6666+ out.push(' ');
6767+6868+ out.push_str(line);
6969+7070+ let pad = inner - UnicodeWidthStr::width(line);
7171+ for _ in 0..pad {
7272+ out.push(' ');
7373+ }
7474+7575+ out.push(' ');
7676+ out.push(right);
7777+7878+ out.push('\n');
7979+}
+125
src/main.rs
···11+mod bubble;
22+33+use std::io::{self, BufWriter, IsTerminal, Read, Write};
44+use std::process::ExitCode;
55+use std::time::{SystemTime, UNIX_EPOCH};
66+77+use clap::Parser;
88+use terminal_size::{Width, terminal_size};
99+1010+const DOLLY: &str = include_str!("../dolly.txt");
1111+const DOLLYS: &str = include_str!("../dollys.txt");
1212+const MIN_BUBBLE_SIZE: usize = 20;
1313+const MAX_BUBBLE_SIZE: usize = 60;
1414+const MIN_CLONE_INNER: usize = 22;
1515+1616+const TIPS: &[&str] = &[
1717+ "I push, therefore I baa.",
1818+ "Identity portable. Repos plural. Wool eternal.",
1919+];
2020+2121+#[derive(Parser, Debug)]
2222+#[command(
2323+ name = "dollysay",
2424+ version, // use the version from Cargo.toml
2525+ about = "A cloned sheep speaks. Inspired by cowsay, made for Tangled.org."
2626+)]
2727+struct Cli {
2828+ /// The message. If absent, dollysay reads from stdin.
2929+ #[arg(trailing_var_arg = true)]
3030+ message: Vec<String>,
3131+3232+ /// Clone Dolly.
3333+ #[arg(long)]
3434+ clone: bool,
3535+3636+ /// Have Dolly share a random Tangled tip.
3737+ #[arg(long)]
3838+ tip: bool,
3939+}
4040+4141+fn main() -> ExitCode {
4242+ let cli = Cli::parse();
4343+4444+ let message = match resolve_message(&cli) {
4545+ Ok(m) => m,
4646+ Err(e) => {
4747+ eprintln!("dollysay: {e}");
4848+ return ExitCode::FAILURE;
4949+ }
5050+ };
5151+5252+ let min_inner = if cli.clone { MIN_CLONE_INNER } else { 0 };
5353+ let bubble: String = bubble::render(&message, message_width(), min_inner);
5454+5555+ // Lock stdout and use BufWriter to group writes to one syscall only.
5656+ let stdout = io::stdout();
5757+ let mut out = BufWriter::new(stdout.lock());
5858+5959+ let art = if cli.clone { DOLLYS } else { DOLLY };
6060+6161+ let _ = writeln!(out, "{bubble}");
6262+ let _ = out.write_all(art.as_bytes());
6363+6464+ match out.flush() {
6565+ Ok(()) => ExitCode::SUCCESS,
6666+ Err(e) if e.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
6767+ Err(e) => {
6868+ eprintln!("dollysay: {e}");
6969+ ExitCode::FAILURE
7070+ }
7171+ }
7272+}
7373+7474+// Finds where the message text comes from: positional args or piped stdin,
7575+// or falls back to default string.
7676+fn resolve_message(cli: &Cli) -> io::Result<String> {
7777+ // --tip wins over positional/stdin: the user is explicitly asking for one.
7878+ if cli.tip {
7979+ return Ok(random_tip().to_string());
8080+ }
8181+8282+ // positional args
8383+ if !cli.message.is_empty() {
8484+ return Ok(cli.message.join(" "));
8585+ }
8686+8787+ // stdin
8888+ let stdin = io::stdin();
8989+9090+ // Two cases for is_terminal():
9191+ // true -- user is just running `dollysay` with no args, so stdin is connected to the TTY.
9292+ if stdin.is_terminal() {
9393+ return Ok("Baa!".to_string());
9494+ }
9595+9696+ // false -- something is piped in to stdin (`echo baa | dollysay`).
9797+ let mut buf = String::new();
9898+ stdin.lock().read_to_string(&mut buf)?;
9999+ // The piping adds a trailing \n; trim it.
100100+ Ok(buf.trim_end().to_string())
101101+}
102102+103103+// Picks a tip pseudo-randomly. Uses the system clock as the seed source so we
104104+// don't pull in `rand`.
105105+fn random_tip() -> &'static str {
106106+ if TIPS.is_empty() {
107107+ return "Baa!";
108108+ }
109109+ let nanos = SystemTime::now()
110110+ .duration_since(UNIX_EPOCH)
111111+ .map(|d| d.as_millis())
112112+ .unwrap_or(0);
113113+ TIPS[(nanos as usize) % TIPS.len()]
114114+}
115115+116116+// Computes the wrap width for the message inside the bubble.
117117+fn message_width() -> usize {
118118+ let term: usize = terminal_size()
119119+ .map(|(Width(w), _)| w as usize)
120120+ .unwrap_or(80); // stdout is not TTY (e.g. pipe), default here
121121+122122+ // Subtract 4 columns for the bubble's left/right borders + a margin.
123123+ term.saturating_sub(4)
124124+ .clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE)
125125+}