use std::{
net::ToSocketAddrs,
sync::{Arc, Mutex, OnceLock, mpsc},
thread,
time::Duration,
};
use objects::{
error::HeddleError,
object::{ChangeId, ContentHash, ThreadName},
};
use wire::ProtocolError;
use repo::{BlobHydrator, Repository};
use super::{HostedAuthMode, HostedGrpcClient, HostedSession};
const DEFAULT_HOSTED_HYDRATION_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PullMaterialization {
Full,
Lazy,
}
impl PullMaterialization {
pub(crate) fn allows_partial_fetch(self) -> bool {
matches!(self, Self::Lazy)
}
}
impl HostedGrpcClient {
pub async fn hydrate_pulled_state(
&mut self,
repo: &Repository,
repo_path: &str,
remote_thread: &str,
target_state: ChangeId,
) -> Result<usize, ProtocolError> {
self.hydrate_missing_blobs_for_state(repo, repo_path, remote_thread, target_state)
.await
}
}
pub struct LazyHostedHydrator {
endpoint: String,
repo_path: String,
remote_thread: String,
local_thread: String,
bridge: OnceLock<HydrationBridge>,
init_lock: Mutex<()>,
}
impl LazyHostedHydrator {
pub fn new(
endpoint: impl Into<String>,
repo_path: impl Into<String>,
remote_thread: impl Into<String>,
local_thread: impl Into<String>,
) -> Self {
Self {
endpoint: endpoint.into(),
repo_path: repo_path.into(),
remote_thread: remote_thread.into(),
local_thread: local_thread.into(),
bridge: OnceLock::new(),
init_lock: Mutex::new(()),
}
}
fn ensure_bridge(&self) -> objects::error::Result<&HydrationBridge> {
if let Some(bridge) = self.bridge.get() {
return Ok(bridge);
}
let _guard = self.init_lock.lock().unwrap_or_else(|poison| {
poison.into_inner()
});
if let Some(bridge) = self.bridge.get() {
return Ok(bridge);
}
let bridge = HydrationBridge::connect(&self.endpoint)?;
self.bridge.set(bridge).map_err(|_| {
HeddleError::Config(
"lazy hosted hydrator: bridge slot already filled under init_lock — \
this indicates a logic bug in LazyHostedHydrator"
.to_string(),
)
})?;
Ok(self.bridge.get().expect("just set under init_lock"))
}
}
impl BlobHydrator for LazyHostedHydrator {
fn hydrate(&self, repo: &Repository, _hash: &ContentHash) -> objects::error::Result<()> {
let target_state = match repo
.refs()
.get_thread(&ThreadName::from(self.local_thread.as_str()))
{
Ok(Some(id)) => id,
Ok(None) => {
return Err(HeddleError::Config(format!(
"lazy hosted hydrator: local thread '{}' has no recorded tip — \
was the lazy clone interrupted? Try `heddle pull --lazy` to refresh.",
self.local_thread,
)));
}
Err(err) => {
return Err(HeddleError::Config(format!(
"lazy hosted hydrator: failed to read local thread '{}': {err}",
self.local_thread,
)));
}
};
let bridge = self.ensure_bridge()?;
bridge
.hydrate(repo, &self.repo_path, &self.remote_thread, target_state)
.map(|_count| ())
.map_err(|err| HeddleError::Io(std::io::Error::other(err.to_string())))
}
}
struct HydrationBridge {
tx: mpsc::Sender<HydrateMessage>,
_worker: thread::JoinHandle<()>,
}
enum HydrateMessage {
Run {
repo: Arc<Repository>,
repo_path: String,
remote_thread: String,
target_state: ChangeId,
reply: mpsc::SyncSender<Result<usize, ProtocolError>>,
},
}
impl HydrationBridge {
fn connect(endpoint: &str) -> objects::error::Result<Self> {
let addr = endpoint
.to_socket_addrs()
.map_err(|err| {
HeddleError::Config(format!(
"lazy hosted hydrator: resolve endpoint '{endpoint}': {err}",
))
})?
.next()
.ok_or_else(|| {
HeddleError::Config(format!(
"lazy hosted hydrator: DNS returned no addresses for '{endpoint}'",
))
})?;
let user_config = cli_shared::UserConfig::load_default().map_err(|err| {
HeddleError::Config(format!("lazy hosted hydrator: load user config: {err}"))
})?;
let session = HostedSession::build(&user_config, None, HostedAuthMode::ConfigToken)
.map_err(|err| {
HeddleError::Config(format!(
"lazy hosted hydrator: load TLS/auth client config: {err}"
))
})?;
let (tx, rx) = mpsc::channel::<HydrateMessage>();
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<(), HeddleError>>(0);
let endpoint_for_thread = endpoint.to_string();
let worker = thread::Builder::new()
.name("heddle-lazy-hydrator".into())
.spawn(move || {
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(err) => {
let _ = ready_tx.send(Err(HeddleError::Config(format!(
"lazy hosted hydrator: build worker runtime: {err}",
))));
return;
}
};
let connect_result = runtime.block_on(async {
let client = match tokio::time::timeout(
DEFAULT_HOSTED_HYDRATION_TIMEOUT,
session.connect(addr),
)
.await
{
Ok(result) => result.map_err(|err: ProtocolError| {
HeddleError::Config(format!(
"lazy hosted hydrator: connect to '{endpoint_for_thread}' \
(resolved to {addr}): {err}",
))
})?,
Err(_) => {
return Err(HeddleError::Config(format!(
"lazy hosted hydrator: connect to '{endpoint_for_thread}' \
(resolved to {addr}) timed out after {}",
format_duration(DEFAULT_HOSTED_HYDRATION_TIMEOUT)
)));
}
};
Ok::<_, HeddleError>(client)
});
let mut client = match connect_result {
Ok(c) => c,
Err(err) => {
let _ = ready_tx.send(Err(err));
return;
}
};
if ready_tx.send(Ok(())).is_err() {
return;
}
runtime.block_on(async {
while let Ok(message) = rx.recv() {
match message {
HydrateMessage::Run {
repo,
repo_path,
remote_thread,
target_state,
reply,
} => {
let result = hydrate_with_rpc_timeout(
&mut client,
repo.as_ref(),
&repo_path,
&remote_thread,
target_state,
DEFAULT_HOSTED_HYDRATION_TIMEOUT,
)
.await;
let _ = reply.send(result);
}
}
}
});
})
.map_err(|err| {
HeddleError::Config(format!("lazy hosted hydrator: spawn worker thread: {err}",))
})?;
match ready_rx.recv_timeout(DEFAULT_HOSTED_HYDRATION_TIMEOUT) {
Ok(Ok(())) => Ok(Self {
tx,
_worker: worker,
}),
Ok(Err(err)) => Err(err),
Err(mpsc::RecvTimeoutError::Timeout) => Err(HeddleError::Config(format!(
"lazy hosted hydrator: worker did not signal readiness within {}",
format_duration(DEFAULT_HOSTED_HYDRATION_TIMEOUT)
))),
Err(mpsc::RecvTimeoutError::Disconnected) => Err(HeddleError::Config(
"lazy hosted hydrator: worker thread exited before signalling readiness"
.to_string(),
)),
}
}
fn hydrate(
&self,
repo: &Repository,
repo_path: &str,
remote_thread: &str,
target_state: ChangeId,
) -> Result<usize, ProtocolError> {
self.hydrate_with_timeout(
repo,
repo_path,
remote_thread,
target_state,
DEFAULT_HOSTED_HYDRATION_TIMEOUT,
)
}
fn hydrate_with_timeout(
&self,
repo: &Repository,
repo_path: &str,
remote_thread: &str,
target_state: ChangeId,
timeout: Duration,
) -> Result<usize, ProtocolError> {
let repo = Arc::new(Repository::open(repo.root()).map_err(ProtocolError::from)?);
let (reply_tx, reply_rx) = mpsc::sync_channel::<Result<usize, ProtocolError>>(1);
self.tx
.send(HydrateMessage::Run {
repo,
repo_path: repo_path.to_string(),
remote_thread: remote_thread.to_string(),
target_state,
reply: reply_tx,
})
.map_err(|err| {
ProtocolError::Io(std::io::Error::other(format!(
"lazy hosted hydrator: worker channel closed: {err}",
)))
})?;
match reply_rx.recv_timeout(timeout) {
Ok(result) => result,
Err(mpsc::RecvTimeoutError::Timeout) => Err(hydration_timeout_error(
timeout,
repo_path,
remote_thread,
target_state,
)),
Err(mpsc::RecvTimeoutError::Disconnected) => {
Err(ProtocolError::Io(std::io::Error::other(
"lazy hosted hydrator: worker reply channel closed before hydration completed",
)))
}
}
}
}
async fn hydrate_with_rpc_timeout(
client: &mut HostedGrpcClient,
repo: &Repository,
repo_path: &str,
remote_thread: &str,
target_state: ChangeId,
timeout: Duration,
) -> Result<usize, ProtocolError> {
match tokio::time::timeout(
timeout,
client.hydrate_pulled_state(repo, repo_path, remote_thread, target_state),
)
.await
{
Ok(result) => result,
Err(_) => Err(hydration_timeout_error(
timeout,
repo_path,
remote_thread,
target_state,
)),
}
}
fn hydration_timeout_error(
timeout: Duration,
repo_path: &str,
remote_thread: &str,
target_state: ChangeId,
) -> ProtocolError {
ProtocolError::Io(std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!(
"lazy hosted hydrator: blob hydration timed out after {} \
(repo={repo_path}, remote_thread={remote_thread}, target_state={target_state})",
format_duration(timeout)
),
))
}
fn format_duration(duration: Duration) -> String {
if duration.subsec_nanos() == 0 {
format!("{}s", duration.as_secs())
} else {
format!("{duration:?}")
}
}
pub fn register_hosted_factory() {
use std::{path::Path as StdPath, sync::Arc as StdArc};
use repo::lazy_hydrator::{
BlobHydratorFactory, HydratorSection, KIND_HOSTED, register_factory,
};
let factory: BlobHydratorFactory = StdArc::new(
|_root: &StdPath,
section: &HydratorSection|
-> objects::error::Result<StdArc<dyn BlobHydrator>> {
let hosted = section.hosted.as_ref().ok_or_else(|| {
HeddleError::Config(
"lazy hosted hydrator: lazy-hydrator.toml has kind=\"hosted\" \
but no [hydrator.hosted] table was found"
.to_string(),
)
})?;
Ok(StdArc::new(LazyHostedHydrator::new(
hosted.endpoint.clone(),
hosted.repo_path.clone(),
hosted.remote_thread.clone(),
hosted.local_thread.clone(),
)))
},
);
register_factory(KIND_HOSTED, factory);
}
#[cfg(test)]
mod tests {
use std::{
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
mpsc,
},
thread,
time::{Duration, Instant},
};
use cli_shared::ClientConfig;
use grpc::heddle::v1::{
auth_service_client::AuthServiceClient, content_service_client::ContentServiceClient,
hosted_user_service_client::HostedUserServiceClient,
repo_sync_service_client::RepoSyncServiceClient,
};
use objects::object::{Blob, ChangeId, ThreadName};
use repo::Repository;
use tempfile::TempDir;
use tonic::transport::Endpoint;
use super::{
super::{HostedGrpcClient, helpers::HostedTransportPolicy},
BlobHydrator, HydrationBridge, LazyHostedHydrator,
};
fn fabricate_offline_client() -> HostedGrpcClient {
let endpoint = Endpoint::from_static("http://127.0.0.1:1");
let channel = endpoint.connect_lazy();
let config = ClientConfig::default();
let transport = HostedTransportPolicy::from_client_config(&config);
HostedGrpcClient {
inner: RepoSyncServiceClient::new(channel.clone()),
user: HostedUserServiceClient::new(channel.clone()),
auth: AuthServiceClient::new(channel.clone()),
content: ContentServiceClient::new(channel),
token_header: None,
transport,
auth_proof_key_pem: None,
server_key: None,
}
}
fn temp_repo() -> (TempDir, Repository) {
let temp = TempDir::new().expect("temp");
let repo = Repository::init_default(temp.path()).expect("init heddle repo");
(temp, repo)
}
fn offline_bridge() -> HydrationBridge {
let (tx, rx) = mpsc::channel::<super::HydrateMessage>();
let worker = thread::Builder::new()
.name("test-lazy-hydrator".into())
.spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("worker runtime");
let mut client = runtime.block_on(async { fabricate_offline_client() });
runtime.block_on(async {
while let Ok(message) = rx.recv() {
match message {
super::HydrateMessage::Run {
repo,
repo_path,
remote_thread,
target_state,
reply,
} => {
let result = client
.hydrate_pulled_state(
repo.as_ref(),
&repo_path,
&remote_thread,
target_state,
)
.await;
let _ = reply.send(result);
}
}
}
});
})
.expect("spawn test worker");
HydrationBridge {
tx,
_worker: worker,
}
}
fn offline_lazy_hydrator(local_thread: &str) -> LazyHostedHydrator {
let hydrator = LazyHostedHydrator::new(
"ignored.example.test:443",
"org/acme/repo",
"main",
local_thread,
);
hydrator
.bridge
.set(offline_bridge())
.map_err(|_| ())
.expect("set bridge");
hydrator
}
#[test]
fn hydrate_safe_from_tokio_main_context() {
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.expect("multi-thread runtime");
runtime.block_on(async {
let (_temp, repo) = temp_repo();
let target = repo
.refs()
.get_thread(&ThreadName::from("main"))
.unwrap()
.unwrap();
let _ = target;
let hydrator = offline_lazy_hydrator("main");
let blake3 = Blob::new(b"placeholder".to_vec()).hash();
let err = hydrator
.hydrate(&repo, &blake3)
.expect_err("offline endpoint must produce an error");
assert!(!err.to_string().is_empty(), "must surface a real error");
});
}
#[test]
fn hydrate_safe_from_blocking_context() {
let (_temp, repo) = temp_repo();
let hydrator = offline_lazy_hydrator("main");
let blake3 = Blob::new(b"placeholder".to_vec()).hash();
let err = hydrator
.hydrate(&repo, &blake3)
.expect_err("offline endpoint must produce an error");
assert!(!err.to_string().is_empty(), "must surface a real error");
}
#[test]
fn hydrate_after_thread_advance_uses_new_state() {
let recorded: Arc<std::sync::Mutex<Vec<ChangeId>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let recorded_for_worker = Arc::clone(&recorded);
let (tx, rx) = mpsc::channel::<super::HydrateMessage>();
let worker = thread::Builder::new()
.name("inspect-hydrator".into())
.spawn(move || {
while let Ok(message) = rx.recv() {
match message {
super::HydrateMessage::Run {
target_state,
reply,
..
} => {
recorded_for_worker.lock().unwrap().push(target_state);
let _ = reply.send(Err(wire::ProtocolError::Io(
std::io::Error::other("simulated"),
)));
}
}
}
})
.expect("spawn inspect worker");
let bridge = HydrationBridge {
tx,
_worker: worker,
};
let hydrator =
LazyHostedHydrator::new("ignored.example.test:443", "org/acme/repo", "main", "main");
hydrator.bridge.set(bridge).map_err(|_| ()).expect("set");
let (_temp, repo) = temp_repo();
let first_tip = repo
.refs()
.get_thread(&ThreadName::from("main"))
.unwrap()
.unwrap();
let blake3 = Blob::new(b"a".to_vec()).hash();
let _ = hydrator.hydrate(&repo, &blake3);
let advanced = ChangeId::generate();
assert_ne!(advanced, first_tip, "fresh ChangeId must differ");
repo.refs()
.set_thread(&ThreadName::from("main"), &advanced)
.expect("advance");
let _ = hydrator.hydrate(&repo, &blake3);
let seen = recorded.lock().unwrap().clone();
assert_eq!(seen.len(), 2, "two hydrate calls = two recorded states");
assert_eq!(seen[0], first_tip, "first call uses original tip");
assert_eq!(
seen[1], advanced,
"second call MUST re-resolve to the advanced tip"
);
}
#[test]
fn concurrent_first_use_no_race() {
const N: usize = 8;
let (_temp, repo) = temp_repo();
let repo = Arc::new(repo);
let hydrator = Arc::new(offline_lazy_hydrator("main"));
let observed_ok: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
let observed_err: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
let mut handles = Vec::with_capacity(N);
for _ in 0..N {
let repo = Arc::clone(&repo);
let hydrator = Arc::clone(&hydrator);
let observed_ok = Arc::clone(&observed_ok);
let observed_err = Arc::clone(&observed_err);
handles.push(thread::spawn(move || {
let blake3 = Blob::new(b"placeholder".to_vec()).hash();
match hydrator.hydrate(repo.as_ref(), &blake3) {
Ok(()) => observed_ok.fetch_add(1, Ordering::SeqCst),
Err(_) => observed_err.fetch_add(1, Ordering::SeqCst),
};
}));
}
for h in handles {
h.join().expect("worker joined");
}
let total = observed_ok.load(Ordering::SeqCst) + observed_err.load(Ordering::SeqCst);
assert_eq!(total, N, "every concurrent caller must receive a reply");
}
#[test]
fn hydrate_times_out_when_worker_never_replies() {
let (_temp, repo) = temp_repo();
let target = repo
.refs()
.get_thread(&ThreadName::from("main"))
.unwrap()
.unwrap();
let (tx, rx) = mpsc::channel::<super::HydrateMessage>();
let (release_tx, release_rx) = mpsc::sync_channel::<()>(0);
let (done_tx, done_rx) = mpsc::sync_channel::<()>(0);
let worker = thread::Builder::new()
.name("stalling-hydrator".into())
.spawn(move || {
match rx.recv() {
Ok(super::HydrateMessage::Run { reply, .. }) => {
let _ = release_rx.recv();
drop(reply);
}
Err(_) => {}
}
let _ = done_tx.send(());
})
.expect("spawn stalling worker");
let bridge = HydrationBridge {
tx,
_worker: worker,
};
let started = Instant::now();
let err = bridge
.hydrate_with_timeout(
&repo,
"org/acme/repo",
"main",
target,
Duration::from_millis(50),
)
.expect_err("stalled worker must time out");
let elapsed = started.elapsed();
assert!(
elapsed < Duration::from_secs(1),
"hydrate timeout must return promptly; elapsed {elapsed:?}"
);
let msg = err.to_string();
assert!(
msg.contains("blob hydration timed out after") && msg.contains("org/acme/repo"),
"timeout error must name the operation and repo context; got: {msg}"
);
release_tx.send(()).expect("release stalled worker");
done_rx
.recv_timeout(Duration::from_secs(1))
.expect("worker exits after release");
}
#[test]
fn dropping_bridge_shuts_worker_down() {
let bridge = offline_bridge();
drop(bridge);
thread::sleep(Duration::from_millis(50));
}
#[test]
fn hydration_message_carries_send_owned_repo_handle() {
fn assert_send_static<T: Send + 'static>(_: &T) {}
let (_temp, repo) = temp_repo();
let (reply, _recv) = mpsc::sync_channel::<Result<usize, wire::ProtocolError>>(1);
let message = super::HydrateMessage::Run {
repo: Arc::new(repo),
repo_path: "org/acme/repo".to_string(),
remote_thread: "main".to_string(),
target_state: ChangeId::generate(),
reply,
};
assert_send_static(&message);
}
#[test]
fn hydration_bridge_does_not_reintroduce_raw_repo_pointer() {
let source = include_str!("hydration.rs");
let raw_wrapper = ["Repo", "Ptr"].concat();
let raw_repo_pointer = ["*const ", "Repository"].concat();
assert!(
!source.contains(&raw_wrapper),
"hydration bridge must not reintroduce the raw-pointer send wrapper"
);
assert!(
!source.contains(&raw_repo_pointer),
"hydration bridge must not send raw Repository pointers across threads"
);
}
#[test]
fn hydrate_returns_config_error_when_local_thread_missing() {
let (_temp, repo) = temp_repo();
let hydrator = offline_lazy_hydrator("thread-that-was-never-written");
let blake3 = Blob::new(b"placeholder".to_vec()).hash();
let err = hydrator
.hydrate(&repo, &blake3)
.expect_err("missing thread must surface as Config error");
let msg = err.to_string();
assert!(
msg.contains("no recorded tip") && msg.contains("thread-that-was-never-written"),
"error must name the missing thread and explain why hydration was skipped; got: {msg}"
);
}
#[test]
fn ensure_bridge_propagates_dns_failure() {
let (_temp, repo) = temp_repo();
let hydrator = LazyHostedHydrator::new(
"definitely-nonexistent-host-for-tests.invalid:443",
"org/acme/repo",
"main",
"main",
);
let blake3 = Blob::new(b"placeholder".to_vec()).hash();
let err = hydrator
.hydrate(&repo, &blake3)
.expect_err("unresolvable endpoint must surface as a Config error");
let msg = err.to_string();
assert!(
msg.contains("resolve endpoint")
|| msg.contains("DNS returned no addresses")
|| msg.contains(".invalid"),
"error must identify the DNS-resolution failure; got: {msg}"
);
let err2 = hydrator
.hydrate(&repo, &blake3)
.expect_err("second call must also fail rather than reuse a partial bridge");
assert!(
!err2.to_string().is_empty(),
"second call must surface a real error"
);
}
}
#[cfg(test)]
mod register_factory_tests {
use std::sync::Mutex;
use repo::lazy_hydrator::{HostedHydratorConfig, HydratorSection, KIND_HOSTED, lookup_factory};
use tempfile::TempDir;
use super::register_hosted_factory;
static REGISTRY_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn register_hosted_factory_installs_factory_for_kind_hosted() {
let _guard = REGISTRY_LOCK.lock().unwrap_or_else(|p| p.into_inner());
register_hosted_factory();
assert!(
lookup_factory(KIND_HOSTED).is_some(),
"register_hosted_factory must populate the registry under KIND_HOSTED"
);
}
#[test]
fn registered_factory_builds_adapter_for_hosted_section() {
let _guard = REGISTRY_LOCK.lock().unwrap_or_else(|p| p.into_inner());
register_hosted_factory();
let factory =
lookup_factory(KIND_HOSTED).expect("factory present after register_hosted_factory");
let temp = TempDir::new().expect("temp");
let section = HydratorSection {
kind: KIND_HOSTED.to_string(),
hosted: Some(HostedHydratorConfig {
endpoint: "example.heddle.cloud:443".to_string(),
repo_path: "org/acme/repo".to_string(),
remote_thread: "main".to_string(),
local_thread: "main".to_string(),
}),
git_overlay: None,
};
let _hydrator = factory(temp.path(), §ion)
.expect("factory must produce an adapter when [hydrator.hosted] is present");
}
#[test]
fn registered_factory_errors_when_hosted_section_absent() {
let _guard = REGISTRY_LOCK.lock().unwrap_or_else(|p| p.into_inner());
register_hosted_factory();
let factory = lookup_factory(KIND_HOSTED).expect("factory present");
let temp = TempDir::new().expect("temp");
let section = HydratorSection {
kind: KIND_HOSTED.to_string(),
hosted: None,
git_overlay: None,
};
let err = match factory(temp.path(), §ion) {
Ok(_) => panic!(
"factory must reject a kind=hosted section that omits the [hydrator.hosted] table"
),
Err(e) => e,
};
let msg = err.to_string();
assert!(
msg.contains("[hydrator.hosted]") || msg.contains("hydrator.hosted"),
"error must name the missing TOML table; got: {msg}"
);
}
}
#[cfg(test)]
mod connect_path_tests {
#[test]
fn lazy_hosted_connect_opens_session_through_rotating_seam() {
let source = include_str!("hydration.rs");
assert!(
source
.contains("HostedSession::build(&user_config, None, HostedAuthMode::ConfigToken)"),
"hydration.rs must build its session through the shared HostedSession seam",
);
assert!(
source.contains("session.connect(addr)"),
"hydration.rs must connect via HostedSession::connect, which owns rotation",
);
}
}
#[cfg(test)]
mod config_persistence_tests {
use repo::lazy_hydrator::LazyHydratorConfig;
use tempfile::TempDir;
#[test]
fn lazy_hydrator_config_round_trip_preserves_hostname() {
let temp = TempDir::new().expect("temp");
let heddle = temp.path().join(".heddle");
let endpoint = "example.heddle.cloud:443";
let cfg = LazyHydratorConfig::hosted(endpoint, "org/acme/repo", "main", "main");
cfg.save(&heddle).expect("save");
let loaded = LazyHydratorConfig::load(&heddle)
.expect("load")
.expect("present");
let hosted = loaded
.hydrator
.hosted
.expect("hosted section present after round-trip");
assert_eq!(
hosted.endpoint, endpoint,
"endpoint MUST round-trip as the original hostname:port spec; \
pinning the IP at clone time would break hosts with rotating IPs"
);
assert!(
hosted.endpoint.parse::<std::net::SocketAddr>().is_err(),
"persisted endpoint must be a hostname spec, not a SocketAddr literal"
);
}
}