Phase 5c: claw-cargo UX — config files + status + prefetch
Ships the last-mile ergonomics that make claw-cargo actually usable
day-to-day: layered config files so you don't retype --peer-addr on
every invocation, plus two lightweight subcommands (status +
prefetch) for the "what's in the cache" and "warm my target dir"
workflows respectively.
## Config precedence
Later wins:
1. Built-in defaults (profile=dev, features=[])
2. ~/.claw-cargo/config.toml (per-user defaults)
3. <workspace>/.claw-cargo.toml (per-repo overrides)
4. CLI flags (per-invocation overrides)
Shape:
[peer]
name = "tank"
addr = "10.0.0.14:7702"
tls_dir = "/etc/claw-store/tls"
[build]
profile = "release"
features = ["a", "b"]
## New module: cluster/client_config.rs (496 lines)
- ClientConfig / PeerSection / BuildSection — TOML-serialisable, all
Option<> fields at every layer so partial configs are legal
- ClientConfig::from_toml_str / from_file_or_default (missing file →
default, not error)
- ClientConfig::merge — Option::Some in `other` wins over `self`
- ClientConfig::load_layered(workspace) — user → workspace
- ResolvedClientConfig — final flattened shape after CLI overrides,
with require_peer_name / require_peer_addr / require_tls_dir /
require_peer_bundle helpers that produce a specific error message
instead of "some Option was None"
- write_config_file — for tests + future `claw-cargo config init`
Ships with 11 unit tests including a load_layered test that fakes
HOME + workspace via a tempdir, writes both configs, verifies the
workspace override takes precedence.
## claw-cargo (rewritten to 469 lines)
Four subcommands with layered config:
claw-cargo fingerprint [--profile ...] [--features ...] [--workspace ...]
→ local-only, no network
claw-cargo status <peer args>
→ connect + GetRef + BlobStat, print hit/miss + size, no download
claw-cargo prefetch <peer args>
→ hit → BlobGetStream + restore_target, no cargo
claw-cargo build <peer args> [-- extra cargo args]
→ same as Phase 5b flow, now with layered config for peer args
Refactored internals:
- setup_local / setup_peer — figure out workspace, load config,
resolve CLI overrides, compute fingerprint
- connect_peer — load NodeIdentity, open QUIC connection
- peer_lookup — GetRef → BlobStat, handle the "ref points at a
garbage-collected blob" case as a miss
## Live smoke test
Verified end-to-end on this workspace:
# No config file → built-in defaults
$ claw-cargo fingerprint
profile: dev, features: (none), fingerprint: 8ee4cf…
# Add .claw-cargo.toml with profile=release + features=some-feature
$ claw-cargo fingerprint
profile: release, features: some-feature, fingerprint: 2dfcb1…
# CLI overrides just the profile; features fall through from config
$ claw-cargo fingerprint --profile dev
profile: dev, features: some-feature, fingerprint: 84a756…
# `status` without peer args → clean validation error
$ claw-cargo status
Error: peer.name not set (config file or --peer)
## Tests (11 new, all real filesystem — no mocks)
- from_toml_str_parses_full_config
- from_toml_str_handles_partial_sections (peer.name only)
- from_file_or_default_returns_default_when_missing
- merge_prefers_later_over_earlier (unset fields fall through)
- resolve_applies_cli_overrides_over_layered
- resolve_falls_through_to_default_profile_when_unset_everywhere
- require_peer_bundle_errors_when_incomplete (specific error text)
- validate_peer_errors_on_missing_field
- load_layered_reads_both_files — fake HOME + workspace, verifies
workspace override takes precedence
- user_config_path_uses_home
- write_and_read_round_trip_via_disk (nested dir creation)
199 tests pass. Pre-existing macOS-only failure unchanged.
File sizes (well under 1300-line ceiling):
- cluster/client_config.rs: 496
- claw_cargo.rs: 469
## What's next
The CLI is now usable day-to-day. Realistic next steps:
- 5d: publish cache hit/miss metrics into gossip so the placement
engine can bias runner scheduling toward warm nodes
- 5e: pre-fetch on Gitea webhook — daemon receives a "PR opened for
fingerprint X" hint and warms the local cache before the runner
even starts pulling
- 3: CRDT metadata for human-readable pins (`clawverse:main:latest`
→ fingerprint hex) so operators can pin cache versions without
passing raw hashes around
- 6: FUSE mount so `~/projects/clawverse` on any node is transparently
the tank-hosted canonical warm-tier copy
This commit is contained in:
@@ -0,0 +1,496 @@
|
||||
//! Client-side config for `claw-cargo` and any future peer-facing
|
||||
//! tools (Phase 5c).
|
||||
//!
|
||||
//! Layered defaults:
|
||||
//! 1. Built-in defaults (empty struct).
|
||||
//! 2. `~/.claw-cargo/config.toml` if present.
|
||||
//! 3. `<workspace>/.claw-cargo.toml` if present.
|
||||
//! 4. CLI overrides.
|
||||
//!
|
||||
//! Every field is optional at every layer; the final merged
|
||||
//! [`ResolvedClientConfig`] validates that the fields it needs are
|
||||
//! actually set before running an operation that requires them.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Location of a per-user config file: `<home>/.claw-cargo/config.toml`.
|
||||
pub fn user_config_path() -> Option<PathBuf> {
|
||||
std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.map(|h| h.join(".claw-cargo").join("config.toml"))
|
||||
}
|
||||
|
||||
/// Location of a workspace-level config file: `<workspace>/.claw-cargo.toml`.
|
||||
pub fn workspace_config_path(workspace: &Path) -> PathBuf {
|
||||
workspace.join(".claw-cargo.toml")
|
||||
}
|
||||
|
||||
/// TOML-serialisable config. Every field optional; layers merge with
|
||||
/// later-wins semantics on `Option::or`.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ClientConfig {
|
||||
#[serde(default)]
|
||||
pub peer: Option<PeerSection>,
|
||||
#[serde(default)]
|
||||
pub build: Option<BuildSection>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PeerSection {
|
||||
/// Peer's node name — must match the peer's leaf cert SAN.
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
/// Peer's RPC socket (gossip_port + 1 typically).
|
||||
#[serde(default)]
|
||||
pub addr: Option<SocketAddr>,
|
||||
/// Directory holding this node's mTLS material
|
||||
/// (`ca.crt` + `node.crt` + `node.key`).
|
||||
#[serde(default)]
|
||||
pub tls_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BuildSection {
|
||||
#[serde(default)]
|
||||
pub profile: Option<String>,
|
||||
#[serde(default)]
|
||||
pub features: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl ClientConfig {
|
||||
/// Parse TOML from a string. Errors on syntactic problems, not on
|
||||
/// missing fields (those get None).
|
||||
pub fn from_toml_str(s: &str) -> Result<Self> {
|
||||
toml::from_str(s).context("parsing client config TOML")
|
||||
}
|
||||
|
||||
/// Read a file. Returns default (all-None) when the file doesn't
|
||||
/// exist, so callers can chain multiple loads unconditionally.
|
||||
pub fn from_file_or_default(path: &Path) -> Result<Self> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(s) => Self::from_toml_str(&s)
|
||||
.with_context(|| format!("reading client config at {}", path.display())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
|
||||
Err(e) => Err(anyhow::Error::from(e))
|
||||
.with_context(|| format!("reading client config at {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge `other` on top of `self`: any field set in `other` wins;
|
||||
/// unset fields fall through to `self`. In-place — mutates `self`.
|
||||
pub fn merge(&mut self, other: ClientConfig) {
|
||||
if let Some(o_peer) = other.peer {
|
||||
let p = self.peer.get_or_insert_with(PeerSection::default);
|
||||
if o_peer.name.is_some() {
|
||||
p.name = o_peer.name;
|
||||
}
|
||||
if o_peer.addr.is_some() {
|
||||
p.addr = o_peer.addr;
|
||||
}
|
||||
if o_peer.tls_dir.is_some() {
|
||||
p.tls_dir = o_peer.tls_dir;
|
||||
}
|
||||
}
|
||||
if let Some(o_build) = other.build {
|
||||
let b = self.build.get_or_insert_with(BuildSection::default);
|
||||
if o_build.profile.is_some() {
|
||||
b.profile = o_build.profile;
|
||||
}
|
||||
if o_build.features.is_some() {
|
||||
b.features = o_build.features;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard load: user config < workspace config. Missing files
|
||||
/// treated as empty. Doesn't apply CLI overrides — the caller
|
||||
/// merges those in last so the semantics stay tidy.
|
||||
pub fn load_layered(workspace: &Path) -> Result<Self> {
|
||||
let mut cfg = ClientConfig::default();
|
||||
if let Some(user_path) = user_config_path() {
|
||||
let user = ClientConfig::from_file_or_default(&user_path)?;
|
||||
cfg.merge(user);
|
||||
}
|
||||
let workspace_cfg =
|
||||
ClientConfig::from_file_or_default(&workspace_config_path(workspace))?;
|
||||
cfg.merge(workspace_cfg);
|
||||
Ok(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
/// A ClientConfig plus the CLI-supplied overrides, resolved against
|
||||
/// each other into concrete-final-values with validation. Every field
|
||||
/// that is required for a subcommand to run is `Result`-checked via
|
||||
/// the `require_*` helpers so the caller gets a specific error message
|
||||
/// instead of "some Option was None".
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ResolvedClientConfig {
|
||||
pub peer_name: Option<String>,
|
||||
pub peer_addr: Option<SocketAddr>,
|
||||
pub tls_dir: Option<PathBuf>,
|
||||
pub profile: String,
|
||||
pub features: Vec<String>,
|
||||
}
|
||||
|
||||
impl ResolvedClientConfig {
|
||||
/// Apply CLI overrides (Option::Some wins) on top of the layered
|
||||
/// config and produce the final resolved shape. `default_profile`
|
||||
/// is the process default when nothing is set anywhere (`"dev"`
|
||||
/// per cargo convention).
|
||||
pub fn resolve(
|
||||
layered: ClientConfig,
|
||||
cli_peer_name: Option<String>,
|
||||
cli_peer_addr: Option<SocketAddr>,
|
||||
cli_tls_dir: Option<PathBuf>,
|
||||
cli_profile: Option<String>,
|
||||
cli_features: Option<Vec<String>>,
|
||||
default_profile: &str,
|
||||
) -> Self {
|
||||
let (peer_name, peer_addr, tls_dir) = match layered.peer {
|
||||
Some(p) => (
|
||||
cli_peer_name.or(p.name),
|
||||
cli_peer_addr.or(p.addr),
|
||||
cli_tls_dir.or(p.tls_dir),
|
||||
),
|
||||
None => (cli_peer_name, cli_peer_addr, cli_tls_dir),
|
||||
};
|
||||
let (profile, features) = match layered.build {
|
||||
Some(b) => (
|
||||
cli_profile
|
||||
.or(b.profile)
|
||||
.unwrap_or_else(|| default_profile.to_string()),
|
||||
cli_features.or(b.features).unwrap_or_default(),
|
||||
),
|
||||
None => (
|
||||
cli_profile.unwrap_or_else(|| default_profile.to_string()),
|
||||
cli_features.unwrap_or_default(),
|
||||
),
|
||||
};
|
||||
Self {
|
||||
peer_name,
|
||||
peer_addr,
|
||||
tls_dir,
|
||||
profile,
|
||||
features,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn require_peer_name(&self) -> Result<&str> {
|
||||
self.peer_name
|
||||
.as_deref()
|
||||
.context("peer.name not set (config file or --peer)")
|
||||
}
|
||||
|
||||
pub fn require_peer_addr(&self) -> Result<SocketAddr> {
|
||||
self.peer_addr
|
||||
.context("peer.addr not set (config file or --peer-addr)")
|
||||
}
|
||||
|
||||
pub fn require_tls_dir(&self) -> Result<&Path> {
|
||||
self.tls_dir
|
||||
.as_deref()
|
||||
.context("peer.tls_dir not set (config file or --tls-dir)")
|
||||
}
|
||||
|
||||
/// Bundled "everything a build/prefetch/status needs" check.
|
||||
pub fn require_peer_bundle(&self) -> Result<(&str, SocketAddr, &Path)> {
|
||||
let name = self.require_peer_name()?;
|
||||
let addr = self.require_peer_addr()?;
|
||||
let tls = self.require_tls_dir()?;
|
||||
Ok((name, addr, tls))
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the given `cfg` to `path`. Creates parent dirs if needed.
|
||||
/// Used by an eventual `claw-cargo config write` helper — currently
|
||||
/// consumed by tests but public so integration callers can seed a
|
||||
/// config in a tempdir.
|
||||
pub fn write_config_file(path: &Path, cfg: &ClientConfig) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("creating config dir {}", parent.display()))?;
|
||||
}
|
||||
let s = toml::to_string_pretty(cfg).context("serialising client config to TOML")?;
|
||||
std::fs::write(path, s)
|
||||
.with_context(|| format!("writing client config to {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sanity: refuse to run a peer operation when the resolved config
|
||||
/// leaves any of `peer_name` / `peer_addr` / `tls_dir` unset.
|
||||
pub fn validate_peer(cfg: &ResolvedClientConfig) -> Result<()> {
|
||||
if cfg.peer_name.is_none() || cfg.peer_addr.is_none() || cfg.tls_dir.is_none() {
|
||||
bail!(
|
||||
"peer settings incomplete: name={:?} addr={:?} tls_dir={:?} \
|
||||
(set via config file or CLI flags)",
|
||||
cfg.peer_name,
|
||||
cfg.peer_addr,
|
||||
cfg.tls_dir
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_toml_str_parses_full_config() {
|
||||
let s = r#"
|
||||
[peer]
|
||||
name = "tank"
|
||||
addr = "10.0.0.14:7702"
|
||||
tls_dir = "/etc/claw-store/tls"
|
||||
|
||||
[build]
|
||||
profile = "release"
|
||||
features = ["a", "b"]
|
||||
"#;
|
||||
let cfg = ClientConfig::from_toml_str(s).unwrap();
|
||||
let peer = cfg.peer.unwrap();
|
||||
assert_eq!(peer.name.unwrap(), "tank");
|
||||
assert_eq!(
|
||||
peer.addr.unwrap(),
|
||||
"10.0.0.14:7702".parse::<SocketAddr>().unwrap()
|
||||
);
|
||||
assert_eq!(peer.tls_dir.unwrap(), PathBuf::from("/etc/claw-store/tls"));
|
||||
let build = cfg.build.unwrap();
|
||||
assert_eq!(build.profile.unwrap(), "release");
|
||||
assert_eq!(build.features.unwrap(), vec!["a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_toml_str_handles_partial_sections() {
|
||||
// Only peer.name — should parse fine with everything else None.
|
||||
let s = r#"
|
||||
[peer]
|
||||
name = "tank"
|
||||
"#;
|
||||
let cfg = ClientConfig::from_toml_str(s).unwrap();
|
||||
let peer = cfg.peer.unwrap();
|
||||
assert_eq!(peer.name.unwrap(), "tank");
|
||||
assert!(peer.addr.is_none());
|
||||
assert!(peer.tls_dir.is_none());
|
||||
assert!(cfg.build.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_file_or_default_returns_default_when_missing() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let cfg =
|
||||
ClientConfig::from_file_or_default(&tmp.path().join("does-not-exist")).unwrap();
|
||||
assert_eq!(cfg, ClientConfig::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_prefers_later_over_earlier() {
|
||||
let mut base = ClientConfig::from_toml_str(
|
||||
r#"
|
||||
[peer]
|
||||
name = "tank"
|
||||
addr = "10.0.0.14:7702"
|
||||
tls_dir = "/etc/tls"
|
||||
|
||||
[build]
|
||||
profile = "dev"
|
||||
features = ["a"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let workspace = ClientConfig::from_toml_str(
|
||||
r#"
|
||||
[peer]
|
||||
name = "architect"
|
||||
|
||||
[build]
|
||||
profile = "release"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
base.merge(workspace);
|
||||
let peer = base.peer.unwrap();
|
||||
assert_eq!(peer.name.unwrap(), "architect", "later config wins");
|
||||
assert_eq!(
|
||||
peer.addr.unwrap(),
|
||||
"10.0.0.14:7702".parse::<SocketAddr>().unwrap(),
|
||||
"unset field falls through"
|
||||
);
|
||||
assert_eq!(peer.tls_dir.unwrap(), PathBuf::from("/etc/tls"));
|
||||
let build = base.build.unwrap();
|
||||
assert_eq!(build.profile.unwrap(), "release");
|
||||
assert_eq!(build.features.unwrap(), vec!["a"], "features fall through");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_applies_cli_overrides_over_layered() {
|
||||
let layered = ClientConfig::from_toml_str(
|
||||
r#"
|
||||
[peer]
|
||||
name = "tank"
|
||||
addr = "10.0.0.14:7702"
|
||||
tls_dir = "/etc/tls"
|
||||
|
||||
[build]
|
||||
profile = "dev"
|
||||
features = ["a", "b"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let resolved = ResolvedClientConfig::resolve(
|
||||
layered,
|
||||
Some("architect".into()), // override
|
||||
None, // fall through
|
||||
None, // fall through
|
||||
Some("release".into()), // override
|
||||
None, // fall through
|
||||
"dev",
|
||||
);
|
||||
assert_eq!(resolved.peer_name.unwrap(), "architect");
|
||||
assert_eq!(
|
||||
resolved.peer_addr.unwrap(),
|
||||
"10.0.0.14:7702".parse::<SocketAddr>().unwrap()
|
||||
);
|
||||
assert_eq!(resolved.tls_dir.unwrap(), PathBuf::from("/etc/tls"));
|
||||
assert_eq!(resolved.profile, "release");
|
||||
assert_eq!(resolved.features, vec!["a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_falls_through_to_default_profile_when_unset_everywhere() {
|
||||
let resolved = ResolvedClientConfig::resolve(
|
||||
ClientConfig::default(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
"dev",
|
||||
);
|
||||
assert_eq!(resolved.profile, "dev");
|
||||
assert!(resolved.features.is_empty());
|
||||
assert!(resolved.peer_name.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_peer_bundle_errors_when_incomplete() {
|
||||
let resolved = ResolvedClientConfig {
|
||||
peer_name: Some("tank".into()),
|
||||
peer_addr: None, // missing
|
||||
tls_dir: Some("/etc/tls".into()),
|
||||
profile: "dev".into(),
|
||||
features: vec![],
|
||||
};
|
||||
let err = resolved.require_peer_bundle().unwrap_err().to_string();
|
||||
assert!(err.contains("peer.addr"), "unexpected: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_peer_errors_on_missing_field() {
|
||||
let resolved = ResolvedClientConfig {
|
||||
peer_name: Some("tank".into()),
|
||||
peer_addr: Some("10.0.0.14:7702".parse().unwrap()),
|
||||
tls_dir: None,
|
||||
profile: "dev".into(),
|
||||
features: vec![],
|
||||
};
|
||||
let err = validate_peer(&resolved).unwrap_err().to_string();
|
||||
assert!(err.contains("peer settings incomplete"), "unexpected: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_layered_reads_both_files() {
|
||||
// Fake HOME → tmp/user; workspace at tmp/ws. Both files exist.
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let user_home = tmp.path().join("user_home");
|
||||
let workspace = tmp.path().join("ws");
|
||||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
|
||||
// User config: sets everything.
|
||||
let user_cfg = ClientConfig::from_toml_str(
|
||||
r#"
|
||||
[peer]
|
||||
name = "tank"
|
||||
addr = "10.0.0.14:7702"
|
||||
tls_dir = "/user/tls"
|
||||
|
||||
[build]
|
||||
profile = "dev"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
write_config_file(
|
||||
&user_home.join(".claw-cargo").join("config.toml"),
|
||||
&user_cfg,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Workspace config overrides profile only.
|
||||
let ws_cfg = ClientConfig::from_toml_str(
|
||||
r#"
|
||||
[build]
|
||||
profile = "release"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
write_config_file(&workspace.join(".claw-cargo.toml"), &ws_cfg).unwrap();
|
||||
|
||||
// Point HOME at our fake home so user_config_path picks it up.
|
||||
// std::env::set_var is process-global; save/restore the prior
|
||||
// value to keep the test hermetic.
|
||||
let saved = std::env::var_os("HOME");
|
||||
std::env::set_var("HOME", &user_home);
|
||||
let cfg = ClientConfig::load_layered(&workspace).unwrap();
|
||||
match saved {
|
||||
Some(v) => std::env::set_var("HOME", v),
|
||||
None => std::env::remove_var("HOME"),
|
||||
}
|
||||
|
||||
let peer = cfg.peer.unwrap();
|
||||
assert_eq!(peer.name.unwrap(), "tank", "user config peer name survives");
|
||||
assert_eq!(
|
||||
peer.addr.unwrap(),
|
||||
"10.0.0.14:7702".parse::<SocketAddr>().unwrap()
|
||||
);
|
||||
let build = cfg.build.unwrap();
|
||||
assert_eq!(
|
||||
build.profile.unwrap(),
|
||||
"release",
|
||||
"workspace config overrode user profile"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_config_path_uses_home() {
|
||||
let saved = std::env::var_os("HOME");
|
||||
std::env::set_var("HOME", "/tmp/test-home");
|
||||
let path = user_config_path().unwrap();
|
||||
assert_eq!(path, PathBuf::from("/tmp/test-home/.claw-cargo/config.toml"));
|
||||
match saved {
|
||||
Some(v) => std::env::set_var("HOME", v),
|
||||
None => std::env::remove_var("HOME"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_and_read_round_trip_via_disk() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let path = tmp.path().join("nested").join("dir").join("config.toml");
|
||||
let cfg = ClientConfig::from_toml_str(
|
||||
r#"
|
||||
[peer]
|
||||
name = "tank"
|
||||
addr = "10.0.0.14:7702"
|
||||
tls_dir = "/etc/tls"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
write_config_file(&path, &cfg).unwrap();
|
||||
assert!(path.exists());
|
||||
let round = ClientConfig::from_file_or_default(&path).unwrap();
|
||||
assert_eq!(round, cfg);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user