Phase 5c: claw-cargo UX (config files + status + prefetch) #12
+284
-134
@@ -1,38 +1,42 @@
|
|||||||
//! `claw-cargo` — fingerprint-keyed cargo build cache (Phase 5b).
|
//! `claw-cargo` — fingerprint-keyed cargo build cache CLI (Phase 5b/5c).
|
||||||
//!
|
//!
|
||||||
//! Wraps `cargo build` with a peer-cache lookup. Flow:
|
//! Wraps `cargo build` with a peer-cache lookup:
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! 1. compute_workspace_fingerprint(workspace, profile, features)
|
//! HIT : GetRef → BlobStat → BlobGetStream → restore_target → cargo build
|
||||||
//! 2. QUIC + mTLS connect to peer
|
//! MISS: cargo build → capture_target → BlobPutStream → PutRef
|
||||||
//! 3. GetRef(fingerprint) → BlobId?
|
|
||||||
//! HIT: BlobGetStream → restore_target → cargo build (fast, just workspace)
|
|
||||||
//! MISS: cargo build (full) → capture_target → BlobPutStream → PutRef
|
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! First argument after subcommand flags is passed through to cargo verbatim,
|
//! Config precedence (later wins):
|
||||||
//! so `claw-cargo build --profile release -p my-crate` works the same as
|
//! 1. Built-in defaults (profile=dev, features=[])
|
||||||
//! `cargo build --profile release -p my-crate` — the only difference is
|
//! 2. `~/.claw-cargo/config.toml`
|
||||||
//! the pre/post cache lookup.
|
//! 3. `<workspace>/.claw-cargo.toml`
|
||||||
|
//! 4. CLI flags
|
||||||
|
//!
|
||||||
|
//! Subcommands:
|
||||||
|
//! * `build` — run cargo with cache lookup + populate on miss
|
||||||
|
//! * `prefetch` — download+restore cache into target/, no cargo
|
||||||
|
//! * `status` — print fingerprint + peer cache state (no cargo)
|
||||||
|
//! * `fingerprint` — print fingerprint only (no network)
|
||||||
|
|
||||||
mod cluster;
|
mod actions;
|
||||||
mod cargo_init;
|
mod cargo_init;
|
||||||
|
mod cluster;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod daemon;
|
||||||
mod head_watch;
|
mod head_watch;
|
||||||
mod hot;
|
mod hot;
|
||||||
mod manifest;
|
mod manifest;
|
||||||
mod restore;
|
mod restore;
|
||||||
|
mod serve;
|
||||||
mod snapshot;
|
mod snapshot;
|
||||||
mod sync;
|
mod sync;
|
||||||
mod zfs;
|
mod zfs;
|
||||||
mod actions;
|
|
||||||
mod daemon;
|
|
||||||
mod serve;
|
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
@@ -40,6 +44,7 @@ use crate::cluster::blob::BlobId;
|
|||||||
use crate::cluster::build_cache::{
|
use crate::cluster::build_cache::{
|
||||||
capture_target, compute_workspace_fingerprint, restore_target, Fingerprint,
|
capture_target, compute_workspace_fingerprint, restore_target, Fingerprint,
|
||||||
};
|
};
|
||||||
|
use crate::cluster::client_config::{ClientConfig, ResolvedClientConfig};
|
||||||
use crate::cluster::rpc::{
|
use crate::cluster::rpc::{
|
||||||
call_blob_get_stream, call_blob_put_stream, call_blob_stat, call_get_ref, call_put_ref,
|
call_blob_get_stream, call_blob_put_stream, call_blob_stat, call_get_ref, call_put_ref,
|
||||||
};
|
};
|
||||||
@@ -58,46 +63,62 @@ struct Cli {
|
|||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Cmd {
|
enum Cmd {
|
||||||
/// Build with cache lookup: hit → restore + cargo build (fast);
|
/// Build with cache lookup: hit → restore + cargo build; miss →
|
||||||
/// miss → cargo build + capture + upload.
|
/// cargo build + capture + upload.
|
||||||
Build(BuildArgs),
|
Build(BuildArgs),
|
||||||
/// Print the fingerprint for the current workspace without touching
|
/// Download + restore the cached target dir into `target/<profile>`
|
||||||
/// the cache. Useful for diagnosis + integration tests.
|
/// without running cargo. Prints hit/miss + bytes.
|
||||||
Fingerprint {
|
Prefetch(PeerArgs),
|
||||||
#[arg(long, default_value = "dev")]
|
/// Print the fingerprint + peer cache state, no build, no download.
|
||||||
profile: String,
|
Status(PeerArgs),
|
||||||
#[arg(long, num_args = 0.., value_delimiter = ',')]
|
/// Print the fingerprint only. Local, no network.
|
||||||
features: Vec<String>,
|
Fingerprint(LocalArgs),
|
||||||
#[arg(long)]
|
|
||||||
workspace: Option<PathBuf>,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(clap::Args)]
|
/// Args every network-touching subcommand accepts (with layered
|
||||||
struct BuildArgs {
|
/// defaults from `~/.claw-cargo/config.toml` + workspace config).
|
||||||
/// Peer's RPC socket (gossip_port + 1 by default).
|
#[derive(clap::Args, Debug, Clone)]
|
||||||
|
struct PeerArgs {
|
||||||
|
/// Peer's node name (overrides config).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
peer_addr: SocketAddr,
|
peer: Option<String>,
|
||||||
/// Peer's node name — must match the peer's leaf cert SAN.
|
/// Peer's RPC socket (overrides config).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
peer: String,
|
peer_addr: Option<SocketAddr>,
|
||||||
/// Directory holding this node's mTLS material
|
/// Directory holding this node's mTLS material (overrides config).
|
||||||
/// (`ca.crt` + `node.crt` + `node.key`).
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
tls_dir: PathBuf,
|
tls_dir: Option<PathBuf>,
|
||||||
/// Cargo profile — passed to cargo AND used in the fingerprint.
|
/// Cargo profile (overrides config).
|
||||||
#[arg(long, default_value = "dev")]
|
#[arg(long)]
|
||||||
profile: String,
|
profile: Option<String>,
|
||||||
/// Enabled features — passed to cargo AND used in the fingerprint.
|
/// Enabled features (overrides config).
|
||||||
#[arg(long, num_args = 0.., value_delimiter = ',')]
|
#[arg(long, num_args = 0.., value_delimiter = ',')]
|
||||||
features: Vec<String>,
|
features: Option<Vec<String>>,
|
||||||
/// Workspace root. Defaults to the current directory.
|
/// Workspace root. Defaults to the current directory.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
workspace: Option<PathBuf>,
|
workspace: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Args for the local-only `fingerprint` subcommand.
|
||||||
|
#[derive(clap::Args, Debug, Clone)]
|
||||||
|
struct LocalArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
profile: Option<String>,
|
||||||
|
#[arg(long, num_args = 0.., value_delimiter = ',')]
|
||||||
|
features: Option<Vec<String>>,
|
||||||
|
#[arg(long)]
|
||||||
|
workspace: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `build` gets peer args + a couple extras.
|
||||||
|
#[derive(clap::Args, Debug, Clone)]
|
||||||
|
struct BuildArgs {
|
||||||
|
#[command(flatten)]
|
||||||
|
peer: PeerArgs,
|
||||||
/// Skip capture + upload on a miss. Useful for read-only cache use.
|
/// Skip capture + upload on a miss. Useful for read-only cache use.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
no_upload: bool,
|
no_upload: bool,
|
||||||
/// Extra args passed verbatim to `cargo build`.
|
/// Extra args passed verbatim to `cargo build` (after `--`).
|
||||||
#[arg(last = true)]
|
#[arg(last = true)]
|
||||||
cargo_args: Vec<String>,
|
cargo_args: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -110,107 +131,257 @@ async fn main() -> Result<()> {
|
|||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
match cli.cmd {
|
match cli.cmd {
|
||||||
Cmd::Build(args) => cmd_build(args).await,
|
Cmd::Build(args) => cmd_build(args).await,
|
||||||
Cmd::Fingerprint {
|
Cmd::Prefetch(args) => cmd_prefetch(args).await,
|
||||||
profile,
|
Cmd::Status(args) => cmd_status(args).await,
|
||||||
features,
|
Cmd::Fingerprint(args) => cmd_fingerprint(args),
|
||||||
workspace,
|
|
||||||
} => cmd_fingerprint(profile, features, workspace),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_fingerprint(
|
/// Common setup shared by every subcommand: figure out the workspace,
|
||||||
profile: String,
|
/// load layered config, resolve CLI overrides, compute the fingerprint.
|
||||||
features: Vec<String>,
|
fn setup_local(
|
||||||
workspace: Option<PathBuf>,
|
workspace_arg: Option<PathBuf>,
|
||||||
) -> Result<()> {
|
profile_arg: Option<String>,
|
||||||
let workspace =
|
features_arg: Option<Vec<String>>,
|
||||||
workspace.unwrap_or_else(|| std::env::current_dir().expect("cwd"));
|
) -> Result<(PathBuf, ResolvedClientConfig, Fingerprint)> {
|
||||||
|
let workspace = workspace_arg
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| std::env::current_dir().expect("cwd"));
|
||||||
|
let layered = ClientConfig::load_layered(&workspace)?;
|
||||||
|
let resolved = ResolvedClientConfig::resolve(
|
||||||
|
layered,
|
||||||
|
None, // peer_name
|
||||||
|
None, // peer_addr
|
||||||
|
None, // tls_dir
|
||||||
|
profile_arg,
|
||||||
|
features_arg,
|
||||||
|
"dev",
|
||||||
|
);
|
||||||
let (_inputs, fp) =
|
let (_inputs, fp) =
|
||||||
compute_workspace_fingerprint(&workspace, &profile, &features)?;
|
compute_workspace_fingerprint(&workspace, &resolved.profile, &resolved.features)?;
|
||||||
|
Ok((workspace, resolved, fp))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full setup for a peer-touching subcommand. Resolves peer settings
|
||||||
|
/// too and validates they're all present.
|
||||||
|
fn setup_peer(peer_args: &PeerArgs) -> Result<(PathBuf, ResolvedClientConfig, Fingerprint)> {
|
||||||
|
let workspace = peer_args
|
||||||
|
.workspace
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| std::env::current_dir().expect("cwd"));
|
||||||
|
let layered = ClientConfig::load_layered(&workspace)?;
|
||||||
|
let resolved = ResolvedClientConfig::resolve(
|
||||||
|
layered,
|
||||||
|
peer_args.peer.clone(),
|
||||||
|
peer_args.peer_addr,
|
||||||
|
peer_args.tls_dir.clone(),
|
||||||
|
peer_args.profile.clone(),
|
||||||
|
peer_args.features.clone(),
|
||||||
|
"dev",
|
||||||
|
);
|
||||||
|
let (_inputs, fp) =
|
||||||
|
compute_workspace_fingerprint(&workspace, &resolved.profile, &resolved.features)?;
|
||||||
|
Ok((workspace, resolved, fp))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a QUIC connection to the resolved peer. Returns
|
||||||
|
/// `(client, connection)` — client stays alive so callers can shut down.
|
||||||
|
async fn connect_peer(cfg: &ResolvedClientConfig) -> Result<(QuicClient, quinn::Connection)> {
|
||||||
|
let (peer_name, peer_addr, tls_dir) = cfg.require_peer_bundle()?;
|
||||||
|
let identity = NodeIdentity::from_pem_dir(tls_dir)
|
||||||
|
.with_context(|| format!("loading node identity from {}", tls_dir.display()))?;
|
||||||
|
let client = QuicClient::new("0.0.0.0:0".parse()?, identity)?;
|
||||||
|
let conn = client
|
||||||
|
.connect(peer_addr, peer_name)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("connecting to {peer_name} @ {peer_addr}"))?;
|
||||||
|
Ok((client, conn))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the peer for the BlobId keyed by our fingerprint. Returns
|
||||||
|
/// (BlobId, Option<BlobStat>) — the second is present when the
|
||||||
|
/// referenced blob actually exists (a ref could point at a
|
||||||
|
/// garbage-collected blob).
|
||||||
|
async fn peer_lookup(
|
||||||
|
conn: &quinn::Connection,
|
||||||
|
fp: &Fingerprint,
|
||||||
|
) -> Result<Option<(BlobId, crate::cluster::blob::BlobStat)>> {
|
||||||
|
let key = *fp.as_bytes();
|
||||||
|
let value_bytes = match call_get_ref(conn, &key).await? {
|
||||||
|
Some(v) => v,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
let blob_id = BlobId::from_bytes(value_bytes);
|
||||||
|
match call_blob_stat(conn, &blob_id).await? {
|
||||||
|
Some(stat) => Ok(Some((blob_id, stat))),
|
||||||
|
None => {
|
||||||
|
// Ref points at a missing blob — treat as miss so
|
||||||
|
// build+upload will re-populate.
|
||||||
|
tracing::warn!(
|
||||||
|
"ref pointed at blob {} but peer has no such blob; treating as miss",
|
||||||
|
blob_id
|
||||||
|
);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fingerprint ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn cmd_fingerprint(args: LocalArgs) -> Result<()> {
|
||||||
|
let (workspace, resolved, fp) =
|
||||||
|
setup_local(args.workspace, args.profile, args.features)?;
|
||||||
println!("workspace: {}", workspace.display());
|
println!("workspace: {}", workspace.display());
|
||||||
println!("profile: {}", profile);
|
println!("profile: {}", resolved.profile);
|
||||||
println!("features: {}", features.join(","));
|
println!("features: {}", resolved.features.join(","));
|
||||||
println!("fingerprint: {}", fp);
|
println!("fingerprint: {}", fp);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_build(args: BuildArgs) -> Result<()> {
|
// ── status ───────────────────────────────────────────────────────────
|
||||||
let workspace = args
|
|
||||||
.workspace
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| std::env::current_dir().expect("cwd"));
|
|
||||||
let target_dir = workspace.join("target").join(&args.profile);
|
|
||||||
|
|
||||||
// ── 1. Fingerprint ────────────────────────────────────────────
|
async fn cmd_status(args: PeerArgs) -> Result<()> {
|
||||||
let (_inputs, fingerprint) =
|
let (workspace, resolved, fp) = setup_peer(&args)?;
|
||||||
compute_workspace_fingerprint(&workspace, &args.profile, &args.features)
|
let (peer_name, peer_addr, tls_dir) = resolved.require_peer_bundle()?;
|
||||||
.context("computing workspace fingerprint")?;
|
|
||||||
tracing::info!("fingerprint {}", fingerprint);
|
|
||||||
|
|
||||||
// ── 2. Connect to peer ────────────────────────────────────────
|
println!("workspace: {}", workspace.display());
|
||||||
let identity = NodeIdentity::from_pem_dir(&args.tls_dir).with_context(|| {
|
println!("profile: {}", resolved.profile);
|
||||||
format!("loading node identity from {}", args.tls_dir.display())
|
println!("features: {}", resolved.features.join(","));
|
||||||
})?;
|
println!("fingerprint: {}", fp);
|
||||||
let client = QuicClient::new("0.0.0.0:0".parse()?, identity)?;
|
println!("peer: {peer_name} @ {peer_addr}");
|
||||||
let conn = client.connect(args.peer_addr, &args.peer).await?;
|
println!("tls: {}", tls_dir.display());
|
||||||
|
|
||||||
// ── 3. Cache lookup ───────────────────────────────────────────
|
let (client, conn) = connect_peer(&resolved).await?;
|
||||||
let key = *fingerprint.as_bytes();
|
match peer_lookup(&conn, &fp).await? {
|
||||||
let mut outcome = CacheOutcome::Miss;
|
Some((blob_id, stat)) => {
|
||||||
match call_get_ref(&conn, &key).await? {
|
println!("cache: HIT");
|
||||||
Some(value_bytes) => {
|
println!(" blob: {}", blob_id);
|
||||||
let blob_id = BlobId::from_bytes(value_bytes);
|
println!(" size: {} bytes ({} chunks)", stat.total_size, stat.chunk_count);
|
||||||
tracing::info!("cache HIT — blob {}", blob_id);
|
}
|
||||||
match call_blob_stat(&conn, &blob_id).await? {
|
None => {
|
||||||
Some(stat) => {
|
println!("cache: MISS");
|
||||||
tracing::info!(
|
}
|
||||||
"downloading cached target ({} chunks, {} bytes) → {}",
|
}
|
||||||
stat.chunk_count,
|
conn.close(quinn::VarInt::from_u32(0), b"done");
|
||||||
|
client.shutdown().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── prefetch ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn cmd_prefetch(args: PeerArgs) -> Result<()> {
|
||||||
|
let (workspace, resolved, fp) = setup_peer(&args)?;
|
||||||
|
let target_dir = workspace.join("target").join(&resolved.profile);
|
||||||
|
|
||||||
|
let (client, conn) = connect_peer(&resolved).await?;
|
||||||
|
let outcome = match peer_lookup(&conn, &fp).await? {
|
||||||
|
Some((blob_id, stat)) => {
|
||||||
|
println!(
|
||||||
|
"cache HIT — downloading {} bytes ({} chunks) to {}",
|
||||||
stat.total_size,
|
stat.total_size,
|
||||||
|
stat.chunk_count,
|
||||||
target_dir.display()
|
target_dir.display()
|
||||||
);
|
);
|
||||||
|
let start = Instant::now();
|
||||||
let mut buf = Vec::with_capacity(stat.total_size as usize);
|
let mut buf = Vec::with_capacity(stat.total_size as usize);
|
||||||
let ok = call_blob_get_stream(&conn, &blob_id, &mut buf).await?;
|
let ok = call_blob_get_stream(&conn, &blob_id, &mut buf).await?;
|
||||||
if !ok {
|
if !ok {
|
||||||
tracing::warn!(
|
println!("cache: MISS (ref pointed at a missing blob)");
|
||||||
"ref pointed at blob {} but peer returned NotFound; falling through",
|
PrefetchOutcome::Miss
|
||||||
blob_id
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
|
std::fs::create_dir_all(&target_dir)
|
||||||
|
.with_context(|| format!("creating {}", target_dir.display()))?;
|
||||||
|
restore_target(&buf, &target_dir).context("restoring target dir")?;
|
||||||
|
PrefetchOutcome::Hit {
|
||||||
|
blob_id,
|
||||||
|
bytes: buf.len() as u64,
|
||||||
|
elapsed: start.elapsed(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
println!("cache: MISS (no ref for fingerprint)");
|
||||||
|
PrefetchOutcome::Miss
|
||||||
|
}
|
||||||
|
};
|
||||||
|
conn.close(quinn::VarInt::from_u32(0), b"done");
|
||||||
|
client.shutdown().await;
|
||||||
|
|
||||||
|
match outcome {
|
||||||
|
PrefetchOutcome::Hit {
|
||||||
|
blob_id,
|
||||||
|
bytes,
|
||||||
|
elapsed,
|
||||||
|
} => {
|
||||||
|
println!();
|
||||||
|
println!("── claw-cargo prefetch ─────────────────────────────");
|
||||||
|
println!("fingerprint: {}", fp);
|
||||||
|
println!("blob: {}", blob_id);
|
||||||
|
println!("downloaded: {} bytes in {:?}", bytes, elapsed);
|
||||||
|
println!("restored to: {}", target_dir.display());
|
||||||
|
println!("────────────────────────────────────────────────────");
|
||||||
|
}
|
||||||
|
PrefetchOutcome::Miss => {
|
||||||
|
println!();
|
||||||
|
println!("fingerprint: {} — no cache to prefetch", fp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PrefetchOutcome {
|
||||||
|
Hit {
|
||||||
|
blob_id: BlobId,
|
||||||
|
bytes: u64,
|
||||||
|
elapsed: std::time::Duration,
|
||||||
|
},
|
||||||
|
Miss,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── build ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn cmd_build(args: BuildArgs) -> Result<()> {
|
||||||
|
let (workspace, resolved, fp) = setup_peer(&args.peer)?;
|
||||||
|
let target_dir = workspace.join("target").join(&resolved.profile);
|
||||||
|
tracing::info!("fingerprint {}", fp);
|
||||||
|
|
||||||
|
let (client, conn) = connect_peer(&resolved).await?;
|
||||||
|
|
||||||
|
let mut outcome = CacheOutcome::Miss;
|
||||||
|
match peer_lookup(&conn, &fp).await? {
|
||||||
|
Some((blob_id, stat)) => {
|
||||||
|
tracing::info!("cache HIT — blob {} ({} bytes)", blob_id, stat.total_size);
|
||||||
|
let mut buf = Vec::with_capacity(stat.total_size as usize);
|
||||||
|
let ok = call_blob_get_stream(&conn, &blob_id, &mut buf).await?;
|
||||||
|
if ok {
|
||||||
std::fs::create_dir_all(&target_dir).with_context(|| {
|
std::fs::create_dir_all(&target_dir).with_context(|| {
|
||||||
format!("creating {}", target_dir.display())
|
format!("creating {}", target_dir.display())
|
||||||
})?;
|
})?;
|
||||||
restore_target(&buf, &target_dir)
|
restore_target(&buf, &target_dir).context("restoring cached target dir")?;
|
||||||
.context("restoring cached target dir")?;
|
|
||||||
outcome = CacheOutcome::Hit {
|
outcome = CacheOutcome::Hit {
|
||||||
blob_id,
|
blob_id,
|
||||||
downloaded_bytes: buf.len() as u64,
|
downloaded_bytes: buf.len() as u64,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => tracing::info!("cache MISS"),
|
||||||
tracing::warn!(
|
|
||||||
"ref pointed at blob {} but peer has no such blob; falling through",
|
|
||||||
blob_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
tracing::info!("cache MISS — no ref for fingerprint");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 4. Run cargo ──────────────────────────────────────────────
|
// Run cargo. Even on hit we run cargo — the workspace's own crates
|
||||||
|
// still need to be compiled; the cache only shortcuts the deps.
|
||||||
let cargo_started = Instant::now();
|
let cargo_started = Instant::now();
|
||||||
let cargo_status = run_cargo(&workspace, &args.profile, &args.features, &args.cargo_args)?;
|
let cargo_status = run_cargo(
|
||||||
|
&workspace,
|
||||||
|
&resolved.profile,
|
||||||
|
&resolved.features,
|
||||||
|
&args.cargo_args,
|
||||||
|
)?;
|
||||||
let cargo_elapsed = cargo_started.elapsed();
|
let cargo_elapsed = cargo_started.elapsed();
|
||||||
if !cargo_status.success() {
|
if !cargo_status.success() {
|
||||||
bail!("cargo build failed with exit {}", cargo_status);
|
anyhow::bail!("cargo build failed with exit {}", cargo_status);
|
||||||
}
|
}
|
||||||
tracing::info!("cargo build finished in {:?}", cargo_elapsed);
|
tracing::info!("cargo build finished in {:?}", cargo_elapsed);
|
||||||
|
|
||||||
// ── 5. On miss, capture + upload ──────────────────────────────
|
|
||||||
if matches!(outcome, CacheOutcome::Miss) && !args.no_upload {
|
if matches!(outcome, CacheOutcome::Miss) && !args.no_upload {
|
||||||
if !target_dir.is_dir() {
|
if !target_dir.is_dir() {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -218,28 +389,11 @@ async fn cmd_build(args: BuildArgs) -> Result<()> {
|
|||||||
target_dir.display()
|
target_dir.display()
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
let capture_started = Instant::now();
|
|
||||||
let bytes = capture_target(&target_dir).context("capturing target dir")?;
|
let bytes = capture_target(&target_dir).context("capturing target dir")?;
|
||||||
let capture_elapsed = capture_started.elapsed();
|
|
||||||
tracing::info!(
|
|
||||||
"captured target: {} bytes in {:?}",
|
|
||||||
bytes.len(),
|
|
||||||
capture_elapsed
|
|
||||||
);
|
|
||||||
|
|
||||||
let upload_started = Instant::now();
|
|
||||||
let cursor = std::io::Cursor::new(bytes.clone());
|
let cursor = std::io::Cursor::new(bytes.clone());
|
||||||
let blob_id = call_blob_put_stream(&conn, cursor).await?;
|
let blob_id = call_blob_put_stream(&conn, cursor).await?;
|
||||||
let upload_elapsed = upload_started.elapsed();
|
call_put_ref(&conn, fp.as_bytes(), blob_id.as_bytes()).await?;
|
||||||
tracing::info!(
|
tracing::info!("uploaded blob {} + set ref", blob_id);
|
||||||
"uploaded blob {} in {:?}",
|
|
||||||
blob_id,
|
|
||||||
upload_elapsed
|
|
||||||
);
|
|
||||||
|
|
||||||
call_put_ref(&conn, &key, blob_id.as_bytes()).await?;
|
|
||||||
tracing::info!("set ref {} → {}", fingerprint, blob_id);
|
|
||||||
|
|
||||||
outcome = CacheOutcome::Populated {
|
outcome = CacheOutcome::Populated {
|
||||||
blob_id,
|
blob_id,
|
||||||
uploaded_bytes: bytes.len() as u64,
|
uploaded_bytes: bytes.len() as u64,
|
||||||
@@ -247,15 +401,13 @@ async fn cmd_build(args: BuildArgs) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 6. Shutdown + summary ─────────────────────────────────────
|
|
||||||
conn.close(quinn::VarInt::from_u32(0), b"done");
|
conn.close(quinn::VarInt::from_u32(0), b"done");
|
||||||
client.shutdown().await;
|
client.shutdown().await;
|
||||||
|
|
||||||
print_summary(&fingerprint, &outcome, cargo_elapsed);
|
print_summary(&fp, &outcome, cargo_elapsed);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Outcome of the cache lookup + build cycle. Reported at end-of-run.
|
|
||||||
enum CacheOutcome {
|
enum CacheOutcome {
|
||||||
Hit {
|
Hit {
|
||||||
blob_id: BlobId,
|
blob_id: BlobId,
|
||||||
@@ -298,10 +450,8 @@ fn print_summary(fp: &Fingerprint, outcome: &CacheOutcome, cargo_elapsed: std::t
|
|||||||
println!("────────────────────────────────────────────────────");
|
println!("────────────────────────────────────────────────────");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Invoke `cargo build` with the same profile/features + any passthrough
|
|
||||||
/// args the caller supplied. Runs to completion, returns the exit status.
|
|
||||||
fn run_cargo(
|
fn run_cargo(
|
||||||
workspace: &std::path::Path,
|
workspace: &Path,
|
||||||
profile: &str,
|
profile: &str,
|
||||||
features: &[String],
|
features: &[String],
|
||||||
passthrough: &[String],
|
passthrough: &[String],
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
pub mod blob;
|
pub mod blob;
|
||||||
pub mod build_cache;
|
pub mod build_cache;
|
||||||
|
pub mod client_config;
|
||||||
pub mod gossip;
|
pub mod gossip;
|
||||||
pub mod refs;
|
pub mod refs;
|
||||||
pub mod rpc;
|
pub mod rpc;
|
||||||
|
|||||||
@@ -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