use anyhow::{Context, Result};
use serde::de::DeserializeOwned;
use std::path::PathBuf;
#[derive(Debug)]
pub enum ValidateError {
HttpError { status: u16 },
Transport(anyhow::Error),
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct ApiConnectionInfo {
pub port: u16,
pub token: String,
}
fn api_info_path() -> PathBuf {
tmai_core::ipc::protocol::state_dir().join("api.json")
}
pub fn write_api_info(port: u16, token: &str) -> Result<()> {
let dir = tmai_core::ipc::protocol::state_dir();
std::fs::create_dir_all(&dir)
.with_context(|| format!("Failed to create state dir: {}", dir.display()))?;
let info = ApiConnectionInfo {
port,
token: token.to_string(),
};
let path = api_info_path();
let json = serde_json::to_string(&info)?;
std::fs::write(&path, &json)
.with_context(|| format!("Failed to write API info: {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
pub fn remove_api_info() {
let _ = std::fs::remove_file(api_info_path());
}
#[derive(Debug, Clone)]
pub struct TmaiHttpClient {
_private: (),
}
impl TmaiHttpClient {
pub fn from_runtime() -> Result<Self> {
Self::read_connection_info()?;
Ok(Self { _private: () })
}
fn read_connection_info() -> Result<ApiConnectionInfo> {
let path = api_info_path();
let data = std::fs::read_to_string(&path).with_context(|| {
format!(
"tmai is not running (no API info at {}). Start tmai first.",
path.display()
)
})?;
serde_json::from_str(&data).context("Invalid API info file")
}
pub fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
let info = Self::read_connection_info()?;
let url = format!("http://localhost:{}/api{}", info.port, path);
let resp: T = ureq::get(&url)
.header("Authorization", &format!("Bearer {}", info.token))
.call()
.with_context(|| format!("GET {path} failed"))?
.body_mut()
.read_json()
.with_context(|| format!("Failed to parse response from {path}"))?;
Ok(resp)
}
pub fn post<T: DeserializeOwned>(&self, path: &str, body: &serde_json::Value) -> Result<T> {
let info = Self::read_connection_info()?;
let url = format!("http://localhost:{}/api{}", info.port, path);
let resp: T = ureq::post(&url)
.header("Authorization", &format!("Bearer {}", info.token))
.send_json(body)
.with_context(|| format!("POST {path} failed"))?
.body_mut()
.read_json()
.with_context(|| format!("Failed to parse response from {path}"))?;
Ok(resp)
}
pub fn post_ok(&self, path: &str, body: &serde_json::Value) -> Result<()> {
let info = Self::read_connection_info()?;
let url = format!("http://localhost:{}/api{}", info.port, path);
ureq::post(&url)
.header("Authorization", &format!("Bearer {}", info.token))
.send_json(body)
.with_context(|| format!("POST {path} failed"))?;
Ok(())
}
pub fn resolve_repo(&self, repo: &Option<String>) -> Result<String> {
if let Some(r) = repo {
return Ok(r.clone());
}
if let Ok(cwd) = std::env::current_dir() {
let cwd_str = cwd.to_string_lossy().to_string();
if cwd.join(".git").exists() {
return Ok(cwd_str);
}
}
let projects: Vec<String> = self.get("/projects")?;
projects
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("No registered projects. Specify repo explicitly."))
}
pub fn resolve_git_common_dir(&self) -> Result<String> {
let repo = self.resolve_repo(&None)?;
let output = std::process::Command::new("git")
.args(["rev-parse", "--git-common-dir"])
.current_dir(&repo)
.output()
.context("Failed to run git rev-parse --git-common-dir")?;
if !output.status.success() {
anyhow::bail!(
"git rev-parse --git-common-dir failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
let git_dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
let path = std::path::Path::new(&git_dir);
let resolved = if path.is_relative() {
let abs = std::path::Path::new(&repo).join(path);
abs.canonicalize()
.unwrap_or(abs)
.to_string_lossy()
.to_string()
} else {
git_dir
};
Ok(resolved
.strip_suffix("/.git")
.unwrap_or(&resolved)
.to_string())
}
pub fn post_with_error_body(
&self,
path: &str,
body: &serde_json::Value,
) -> Result<serde_json::Value, ValidateError> {
let info = Self::read_connection_info().map_err(ValidateError::Transport)?;
let url = format!("http://localhost:{}/api{}", info.port, path);
match ureq::post(&url)
.header("Authorization", &format!("Bearer {}", info.token))
.send_json(body)
{
Ok(mut resp) => {
let val: serde_json::Value = resp
.body_mut()
.read_json()
.map_err(|e| ValidateError::Transport(e.into()))?;
Ok(val)
}
Err(ureq::Error::StatusCode(status)) => Err(ValidateError::HttpError { status }),
Err(e) => Err(ValidateError::Transport(e.into())),
}
}
pub fn delete_ok(&self, path: &str) -> Result<()> {
let info = Self::read_connection_info()?;
let url = format!("http://localhost:{}/api{}", info.port, path);
ureq::delete(&url)
.header("Authorization", &format!("Bearer {}", info.token))
.call()
.with_context(|| format!("DELETE {path} failed"))?;
Ok(())
}
pub fn get_json_or_error(&self, path: &str) -> String {
match self.get::<serde_json::Value>(path) {
Ok(data) => format_json(&data),
Err(e) => format!("Error: {e}"),
}
}
pub fn get_text_or_error(&self, path: &str) -> String {
match self.get_text(path) {
Ok(text) => text,
Err(e) => format!("Error: {e}"),
}
}
pub fn post_json_or_error(&self, path: &str, body: &serde_json::Value) -> String {
match self.post::<serde_json::Value>(path, body) {
Ok(data) => format_json(&data),
Err(e) => format!("Error: {e}"),
}
}
pub fn post_ok_or_error(
&self,
path: &str,
body: &serde_json::Value,
success: String,
) -> String {
match self.post_ok(path, body) {
Ok(()) => success,
Err(e) => format!("Error: {e}"),
}
}
pub fn delete_ok_or_error(&self, path: &str, success: String) -> String {
match self.delete_ok(path) {
Ok(()) => success,
Err(e) => format!("Error: {e}"),
}
}
pub fn get_text(&self, path: &str) -> Result<String> {
let info = Self::read_connection_info()?;
let url = format!("http://localhost:{}/api{}", info.port, path);
let text = ureq::get(&url)
.header("Authorization", &format!("Bearer {}", info.token))
.call()
.with_context(|| format!("GET {path} failed"))?
.body_mut()
.read_to_string()
.with_context(|| format!("Failed to read response from {path}"))?;
Ok(text)
}
}
pub fn format_json(value: &serde_json::Value) -> String {
serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static API_FILE_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn read_connection_info_picks_up_updated_token() {
let _lock = API_FILE_LOCK.lock().unwrap();
write_api_info(3000, "token-old").unwrap();
let info = TmaiHttpClient::read_connection_info().unwrap();
assert_eq!(info.port, 3000);
assert_eq!(info.token, "token-old");
write_api_info(3001, "token-new").unwrap();
let info = TmaiHttpClient::read_connection_info().unwrap();
assert_eq!(info.port, 3001);
assert_eq!(info.token, "token-new");
remove_api_info();
}
#[test]
fn read_connection_info_error_when_file_missing() {
let _lock = API_FILE_LOCK.lock().unwrap();
remove_api_info();
let err = TmaiHttpClient::read_connection_info().unwrap_err();
assert!(
err.to_string().contains("tmai is not running"),
"Expected 'tmai is not running' error, got: {err}"
);
}
#[test]
fn from_runtime_succeeds_when_api_json_exists() {
let _lock = API_FILE_LOCK.lock().unwrap();
write_api_info(4000, "test-token").unwrap();
let client = TmaiHttpClient::from_runtime();
assert!(client.is_ok());
remove_api_info();
}
#[test]
fn from_runtime_fails_when_api_json_missing() {
let _lock = API_FILE_LOCK.lock().unwrap();
remove_api_info();
let result = TmaiHttpClient::from_runtime();
assert!(result.is_err());
}
#[test]
fn format_json_pretty_prints_object() {
let val = serde_json::json!({"key": "value"});
let result = format_json(&val);
assert!(result.contains("\"key\": \"value\""));
assert!(result.contains('\n')); }
#[test]
fn format_json_handles_array() {
let val = serde_json::json!([1, 2, 3]);
let result = format_json(&val);
assert!(result.contains('1'));
assert!(result.contains('3'));
}
#[test]
fn format_json_handles_null() {
let val = serde_json::Value::Null;
assert_eq!(format_json(&val), "null");
}
}