use std::{
env,
ffi::OsString,
io,
path::{Path, PathBuf},
};
use serde::Serialize;
use crate::config::{ConfigurationError, StoredPairingStatus, read_pairing_status};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ApplicationInfo {
name: String,
version: String,
}
#[derive(Clone, Debug)]
pub struct Client {
application_info: ApplicationInfo,
home: PathBuf,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum RequestProgress {
Preparing,
WaitingForDelivery,
WaitingForResponse,
Completing,
Completed,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum PairingStatus {
NotPaired,
Pending,
Active,
}
impl ApplicationInfo {
pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
Self {
name: name.into(),
version: version.into(),
}
}
pub(crate) fn name(&self) -> &str {
&self.name
}
pub(crate) fn version(&self) -> &str {
&self.version
}
}
impl Client {
pub fn new(application_info: ApplicationInfo) -> Result<Self, ConfigurationError> {
let home = default_home(env::var_os("AGENTKNOCK_HOME"), env::var_os("HOME"))?;
Self::new_in(application_info, home)
}
pub fn new_in(
application_info: ApplicationInfo,
home: impl Into<PathBuf>,
) -> Result<Self, ConfigurationError> {
let home = home.into();
let home = std::path::absolute(&home)
.map_err(|source| ConfigurationError::InvalidHome { path: home, source })?;
if home.to_str().is_none() {
return Err(ConfigurationError::InvalidHome {
path: home,
source: io::Error::new(
io::ErrorKind::InvalidInput,
"Agentknock home isn't valid UTF-8",
),
});
}
Ok(Self {
application_info,
home,
})
}
pub fn home(&self) -> &Path {
&self.home
}
pub fn pairing_status(&self) -> Result<PairingStatus, ConfigurationError> {
Ok(match read_pairing_status(&self.pairing_path())? {
None => PairingStatus::NotPaired,
Some(StoredPairingStatus::Pending) => PairingStatus::Pending,
Some(StoredPairingStatus::Active) => PairingStatus::Active,
})
}
pub(crate) fn application_info(&self) -> &ApplicationInfo {
&self.application_info
}
pub(crate) fn pairing_path(&self) -> PathBuf {
self.home.join("pairing.json")
}
pub(crate) fn encode<T>(&self, contents: &T) -> Result<Vec<u8>, serde_json::Error>
where
T: Serialize,
{
crate::protocol::encode(&self.application_info, contents)
}
}
fn default_home(
agentknock_home: Option<OsString>,
home: Option<OsString>,
) -> Result<PathBuf, ConfigurationError> {
if let Some(home) = agentknock_home {
let path = PathBuf::from(home);
if !path.is_absolute() {
return Err(ConfigurationError::InvalidHome {
path,
source: io::Error::new(
io::ErrorKind::InvalidInput,
"AGENTKNOCK_HOME must be a nonempty absolute path",
),
});
}
Ok(path)
} else {
let home = home.ok_or(ConfigurationError::HomeNotSet)?;
if home.is_empty() {
return Err(ConfigurationError::InvalidHome {
path: home.into(),
source: io::Error::new(io::ErrorKind::InvalidInput, "HOME must not be empty"),
});
}
Ok(PathBuf::from(home).join(".agentknock"))
}
}
#[cfg(test)]
mod tests {
use super::{ApplicationInfo, Client, ConfigurationError, PairingStatus, default_home};
#[test]
fn default_home_uses_the_override_without_requiring_home() {
for home in [None, Some("/user-home".into()), Some("".into())] {
assert_eq!(
default_home(Some("/agentknock-home".into()), home).unwrap(),
std::path::Path::new("/agentknock-home")
);
}
assert_eq!(
default_home(None, Some("/user-home".into())).unwrap(),
std::path::Path::new("/user-home/.agentknock")
);
assert!(matches!(
default_home(None, None),
Err(ConfigurationError::HomeNotSet)
));
}
#[test]
fn invalid_home_environment_does_not_fall_back() {
for path in ["", "relative"] {
assert!(matches!(
default_home(Some(path.into()), Some("/user-home".into())),
Err(ConfigurationError::InvalidHome { .. })
));
}
assert!(matches!(
default_home(None, Some("".into())),
Err(ConfigurationError::InvalidHome { .. })
));
}
#[test]
fn explicit_home_resolves_relative_paths_and_rejects_empty_paths() {
let application = ApplicationInfo::new("test-application", "1.0.0");
let client = Client::new_in(application.clone(), "relative-agentknock-homé").unwrap();
assert_eq!(
client.home(),
std::env::current_dir()
.unwrap()
.join("relative-agentknock-homé")
);
assert!(matches!(
Client::new_in(application, ""),
Err(ConfigurationError::InvalidHome { .. })
));
}
#[test]
fn custom_state_directory_contains_pairing_file() {
let client = Client::new_in(
ApplicationInfo::new("test-application", "1.0.0"),
"/tmp/agentknock-test-state",
)
.unwrap();
assert_eq!(
client.pairing_path(),
std::path::Path::new("/tmp/agentknock-test-state/pairing.json")
);
}
#[test]
fn missing_pairing_has_not_paired_status() {
let client = Client::new_in(
ApplicationInfo::new("test-application", "1.0.0"),
std::env::temp_dir().join(format!(
"agentknock-missing-test-state-{}",
ulid::Ulid::generate()
)),
)
.unwrap();
assert_eq!(client.pairing_status().unwrap(), PairingStatus::NotPaired);
}
}