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:
Omar Sobh
2026-07-11 23:45:04 -07:00
parent 2d09b4687c
commit 57d8358255
3 changed files with 792 additions and 145 deletions
+496
View File
@@ -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);
}
}