Initial commit

This commit is contained in:
Quantum 2024-10-13 23:50:36 -04:00
commit 95f516cb87
6 changed files with 1731 additions and 0 deletions

17
.gitignore vendored Normal file
View file

@ -0,0 +1,17 @@
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# RustRover
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/

1380
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

15
Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[package]
name = "ntfy-run"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4.5.20", features = ["derive", "env"] }
itertools = "0.13.0"
reqwest = "0.12.8"
tokio = { version = "1.40.0", features = ["io-std", "io-util", "macros", "process", "rt-multi-thread"] }
[profile.release]
lto = true
strip = true
panic = "abort"

215
src/main.rs Normal file
View file

@ -0,0 +1,215 @@
use crate::runner::CaptureError;
use clap::Parser;
use runner::CapturedOutput;
mod quote;
mod runner;
#[derive(Parser)]
/// Tool to run a command, capture its output, and send it to ntfy.
struct Cli {
/// URL of the ntfy server and topic, e.g. https://ntfy.sh/topic
#[arg(short = 'n', long = "ntfy-url", env = "NTFY_URL", alias = "url")]
url: String,
/// Access token to use with ntfy
#[arg(short, long, env = "NTFY_TOKEN")]
token: Option<String>,
/// User to use with ntfy
#[arg(
short,
long,
env = "NTFY_USER",
conflicts_with = "token",
requires = "password"
)]
user: Option<String>,
/// Password to use with nfty
#[arg(
short,
long,
env = "NTFY_PASSWORD",
conflicts_with = "token",
requires = "user"
)]
password: Option<String>,
/// Notify even when the output is empty
#[arg(short = 'N', long, env = "NTFY_ALWAYS_NOTIFY")]
always_notify: bool,
/// Notify only when command fails
#[arg(
short = 'o',
long,
env = "NTFY_FAILURE_ONLY",
conflicts_with = "always_notify"
)]
only_failures: bool,
/// Message title, will be prefixed with "Success" or "Failure".
/// Defaults to command line.
#[arg(short = 'T', long, env = "NTFY_TITLE")]
title: Option<String>,
/// Message title upon successful executions
#[arg(short = 's', long, env = "NTFY_SUCCESS_TITLE")]
success_title: Option<String>,
/// Message title upon failed executions
#[arg(short = 'f', long, env = "NTFY_FAILURE_TITLE")]
failure_title: Option<String>,
/// Message priority upon successful executions
#[arg(short = 'S', long, env = "NTFY_SUCCESS_PRIORITY")]
success_priority: Option<String>,
/// Message priority upon failed executions
#[arg(short = 'F', long, env = "NTFY_FAILURE_PRIORITY")]
failure_priority: Option<String>,
/// Message tags/emojis upon successful executions
#[arg(short = 'a', long, env = "NTFY_SUCCESS_TAGS")]
success_tags: Option<String>,
/// Message tags/emojis upon failed executions
#[arg(short = 'A', long, env = "NTFY_FAILURE_TAGS")]
failure_tags: Option<String>,
/// An optional email for ntfy to notify
#[arg(short, long, env = "NTFY_EMAIL")]
email: Option<String>,
/// URL to icon to display in notification
#[arg(short, long, env = "NTFY_ICON")]
icon: Option<String>,
/// The command line to execute (no shell used).
/// If shell is desired, pass `bash -c 'command line'`.
#[arg(trailing_var_arg = true, required = true)]
cmdline: Vec<String>,
}
fn format_post_body(output: CapturedOutput) -> String {
let mut fragments: Vec<String> = vec![match output.status {
Some(status) => status.to_string(),
None => "Did not run.".to_string(),
}];
if !output.errors.is_empty() {
fragments.push("==================== Errors ====================".to_string());
for error in &output.errors {
fragments.push(match error {
CaptureError::Spawn(error) => format!("Spawn error: {}", error),
CaptureError::Stdout(error) => format!("Error while reading stdout: {}", error),
CaptureError::Stderr(error) => format!("Error while reading stderr: {}", error),
CaptureError::Wait(error) => format!("Error while waiting for process: {}", error),
});
}
fragments.push("\n".to_string());
}
if !output.stdout.is_empty() {
fragments.push("==================== STDOUT ====================".to_string());
fragments.push(output.stdout);
fragments.push("\n".to_string());
}
if !output.stderr.is_empty() {
fragments.push("==================== STDERR ====================".to_string());
fragments.push(output.stderr);
fragments.push("\n".to_string());
}
fragments.join("\n")
}
#[tokio::main]
async fn main() {
let args = Cli::parse();
let result = runner::run_forward_and_capture(&args.cmdline).await;
let status = result.status;
let success = match status {
Some(status) => status.success(),
None => false,
};
if !args.always_notify && success && result.is_empty() {
return;
} else if args.only_failures && success {
return;
}
let fallback_title = match args.title {
Some(title) => title,
None => quote::quote_cmdline(&args.cmdline),
};
let title = if success {
match args.success_title {
Some(title) => title,
None => format!("Success: {}", fallback_title),
}
} else {
match args.failure_title {
Some(title) => title,
None => format!("Failure: {}", fallback_title),
}
};
let priority = if success {
args.success_priority
} else {
args.failure_priority
};
let tags = if success {
args.success_tags
} else {
args.failure_tags
};
let body = format_post_body(result);
let request = reqwest::Client::new()
.post(&args.url)
.header("title", title)
.body(body);
let request = match priority {
Some(priority) => request.header("priority", priority),
None => request,
};
let request = match tags {
Some(tags) => request.header("tags", tags),
None => request,
};
let request = match args.email {
Some(email) => request.header("email", email),
None => request,
};
let request = match args.icon {
Some(icon) => request.header("icon", icon),
None => request,
};
let request = if let Some(token) = args.token {
request.bearer_auth(token)
} else if let Some(user) = args.user {
request.basic_auth(user, args.password)
} else {
request
};
match request.send().await.and_then(|r| r.error_for_status()) {
Ok(_) => (),
Err(error) => eprintln!("Failed to send request to ntfy: {}", error),
}
}

18
src/quote.rs Normal file
View file

@ -0,0 +1,18 @@
use itertools::Itertools;
fn quote_argument(arg: &str) -> String {
let has_single = arg.contains('\'');
let has_double = arg.contains('"');
let has_space = arg.contains(' ');
match (has_space, has_single, has_double) {
(false, false, false) => arg.to_string(),
(_, true, false) => format!("\"{}\"", arg),
(_, false, _) => format!("'{}'", arg),
_ => format!("'{}'", arg.replace('\'', "\\'")),
}
}
pub fn quote_cmdline<T: AsRef<str>>(cmdline: &[T]) -> String {
cmdline.iter().map(|s| quote_argument(s.as_ref())).join(" ")
}

86
src/runner.rs Normal file
View file

@ -0,0 +1,86 @@
use std::process::{ExitStatus, Stdio};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::select;
pub enum CaptureError {
Spawn(std::io::Error),
Stdout(std::io::Error),
Stderr(std::io::Error),
Wait(std::io::Error),
}
pub struct CapturedOutput {
pub status: Option<ExitStatus>,
pub stdout: String,
pub stderr: String,
pub errors: Vec<CaptureError>,
}
impl CapturedOutput {
pub fn is_empty(&self) -> bool {
self.errors.is_empty() && self.stdout.is_empty() && self.stderr.is_empty()
}
}
pub async fn run_forward_and_capture(cmdline: &Vec<String>) -> CapturedOutput {
let command = cmdline.first().unwrap();
let ref args = cmdline[1..];
let mut child = match Command::new(command)
.args(args)
.stdout(Stdio::inherit())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(process) => process,
Err(error) => {
return CapturedOutput {
status: None,
stdout: "".to_string(),
stderr: "".to_string(),
errors: vec![CaptureError::Spawn(error)],
}
}
};
let mut stdout_stream = BufReader::new(child.stdout.take().unwrap()).lines();
let mut stderr_stream = BufReader::new(child.stderr.take().unwrap()).lines();
let mut stdout_buffer = Vec::new();
let mut stderr_buffer = Vec::new();
let mut errors = Vec::new();
let status = loop {
select! {
line = stdout_stream.next_line() => match line {
Ok(Some(line)) => {
println!("{}", line);
stdout_buffer.push(line);
},
Ok(None) => (),
Err(error) => errors.push(CaptureError::Stdout(error)),
},
line = stderr_stream.next_line() => match line {
Ok(Some(line)) => {
eprintln!("{}", line);
stderr_buffer.push(line);
},
Ok(None) => (),
Err(error) => errors.push(CaptureError::Stderr(error)),
},
status = child.wait() => match status {
Ok(status) => break status,
Err(error) => errors.push(CaptureError::Wait(error)),
}
}
};
CapturedOutput {
status: Some(status),
stdout: stdout_buffer.join("\n").to_string(),
stderr: stderr_buffer.join("\n").to_string(),
errors,
}
}