1#![warn(clippy::pedantic)]
2
3use std::fmt;
4use std::io::{self, Write};
5use std::process::Command;
6use std::time::Duration;
7
8use reqwest::{Client, Url};
9use tracing::{info, warn};
10
11extern crate config as config_rs;
12
13pub type Result<T> = std::result::Result<T, Error>;
14
15mod config;
16pub use config::Config;
17
18#[derive(thiserror::Error)]
19pub enum Error {
20 #[error(transparent)]
21 Cli(#[from] clap::error::Error),
22
23 #[error("config error: {0}")]
25 Config(String),
26
27 #[error(transparent)]
28 EnvVar(#[from] std::env::VarError),
29
30 #[error("settings error: {0}")]
32 Settings(#[from] config_rs::ConfigError),
33
34 #[error("command exited with empty exit status code")]
35 EmptyExitCode,
36
37 #[error(transparent)]
38 Io(#[from] std::io::Error),
39
40 #[error("join error: {0}")]
41 Join(#[from] tokio::task::JoinError),
42
43 #[error(transparent)]
44 ParseInt(#[from] std::num::ParseIntError),
45
46 #[error(transparent)]
47 ParseFilter(#[from] tracing_subscriber::filter::ParseError),
48
49 #[error(transparent)]
50 ParseUrl(#[from] url::ParseError),
51
52 #[error(transparent)]
53 Reqwest(#[from] reqwest::Error),
54
55 #[error(transparent)]
56 TryFromInt(#[from] std::num::TryFromIntError),
57
58 #[error("unknown hc-runner error")]
59 Unknown,
60
61 #[error(transparent)]
62 Utf8(#[from] std::str::Utf8Error),
63}
64
65impl fmt::Debug for Error {
66 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67 write!(f, "{self}")
68 }
69}
70
71fn add_slug(mut url: Url, slug: String) -> Result<Url> {
72 let path = url.path();
76 if !path.ends_with('/') {
77 url.set_path(&(path.to_string() + "/"));
78 };
79
80 let with_slug = url.join(&(slug + "/"))?;
81 Ok(with_slug)
82}
83
84#[tracing::instrument]
87pub async fn run(config: Config) -> Result<u8> {
88 let url = add_slug(config.url, config.slug)?;
89 info!("using base url: {}", url);
90
91 let client = Client::builder()
92 .timeout(Duration::from_secs(config.timeout))
93 .build()?;
94
95 let start_req = if config.success_only {
100 None
101 } else {
102 let client = client.clone();
103 let mut url = url.join("start")?;
104 Some(tokio::spawn(async move {
105 url.set_query(Some("create=1"));
106 info!("calling start url {}", url);
107 client.head(url).send().await
108 }))
109 };
110
111 let output = if cfg!(target_os = "macos") {
112 Command::new("/usr/bin/caffeinate")
113 .args(config.command)
114 .output()?
115 } else {
116 let mut args = config.command.iter();
117 let cmd = args
118 .next()
119 .ok_or_else(|| Error::Config("command was empty".into()))?;
120 Command::new(cmd).args(args).output()?
121 };
122
123 let (stdout, stderr) = (output.stdout, output.stderr);
124 io::stdout().write_all(&stdout)?;
125 io::stderr().write_all(&stderr)?;
126
127 let status = output.status;
128 let exit_code = if status.success() {
129 0
130 } else {
131 status.code().ok_or_else(|| Error::EmptyExitCode)?
132 };
133
134 let stderr = std::str::from_utf8(&stderr)?;
135
136 if let Some(req) = start_req {
137 let _ = req.await?;
138 };
139
140 match (config.success_only, exit_code) {
141 (false, _) | (true, 0) => {
142 let res = {
143 let url = url.join(exit_code.to_string().as_ref())?;
144 info!("calling end url {}", url);
145 client.post(url).body(stderr.to_string()).send().await?
146 };
147
148 if !res.status().is_success() {
149 let text = res.text().await?;
150 writeln!(io::stderr(), "failed to update status: {text}")?;
151 };
152 }
153 _ => (),
154 }
155
156 Ok(exit_code.try_into()?)
157}