use color_eyre::eyre::{Result, eyre};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct HarnessConfig {
#[serde(default)]
pub chain: ChainConfig,
#[serde(default)]
pub router: RouterConfig,
#[serde(rename = "blueprint", default)]
pub blueprints: Vec<BlueprintSpec>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ChainConfig {
#[serde(default = "default_true")]
pub anvil: bool,
#[serde(default = "default_chain_id")]
pub chain_id: u64,
#[serde(default)]
pub include_anvil_logs: bool,
#[serde(default)]
pub rpc_url: Option<String>,
#[serde(default)]
pub ws_url: Option<String>,
#[serde(default)]
pub tangle_contract: Option<String>,
#[serde(default)]
pub staking_contract: Option<String>,
#[serde(default)]
pub keystore_path: Option<String>,
}
impl Default for ChainConfig {
fn default() -> Self {
Self {
anvil: true,
chain_id: default_chain_id(),
include_anvil_logs: false,
rpc_url: None,
ws_url: None,
tangle_contract: None,
staking_contract: None,
keystore_path: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RouterConfig {
#[serde(default)]
pub spawn: bool,
#[serde(default = "default_router_port")]
pub port: u16,
#[serde(default)]
pub url: Option<String>,
}
impl Default for RouterConfig {
fn default() -> Self {
Self {
spawn: false,
port: default_router_port(),
url: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ModelSpec {
pub id: String,
#[serde(default)]
pub input_price: f64,
#[serde(default)]
pub output_price: f64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum BlueprintSource {
Binary(PathBuf),
Build,
GithubRelease {
repo: String,
tag: String,
#[serde(default)]
binary_name: Option<String>,
},
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BlueprintSpec {
pub name: String,
pub path: PathBuf,
pub port: Option<u16>,
pub binary: Option<PathBuf>,
#[serde(default)]
pub source: Option<BlueprintSource>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default = "default_health_path")]
pub health_path: String,
#[serde(default = "default_startup_timeout")]
pub startup_timeout_secs: u64,
#[serde(default)]
pub blueprint_type: Option<String>,
#[serde(default)]
pub models: Vec<ModelSpec>,
#[serde(default)]
pub public_url: Option<String>,
#[serde(default)]
pub test_command: Option<String>,
}
fn default_true() -> bool {
true
}
fn default_chain_id() -> u64 {
31337
}
fn default_router_port() -> u16 {
3000
}
fn default_health_path() -> String {
"/health".to_string()
}
fn default_startup_timeout() -> u64 {
120
}
impl HarnessConfig {
pub fn load(path: Option<&Path>) -> Result<Self> {
let path = match path {
Some(p) => p.to_path_buf(),
None => Self::default_path()?,
};
if !path.exists() {
return Err(eyre!(
"harness config not found at {}. Create one or pass --config",
path.display()
));
}
let content = std::fs::read_to_string(&path)
.map_err(|e| eyre!("failed to read {}: {e}", path.display()))?;
let mut config: Self = toml::from_str(&content)
.map_err(|e| eyre!("failed to parse {}: {e}", path.display()))?;
config.expand_paths();
config.expand_env()?;
Ok(config)
}
fn default_path() -> Result<PathBuf> {
let cwd = std::env::current_dir()?.join("harness.toml");
if cwd.exists() {
return Ok(cwd);
}
let home = std::env::var("HOME").map_err(|_| eyre!("HOME not set"))?;
Ok(PathBuf::from(home).join(".tangle").join("harness.toml"))
}
fn expand_paths(&mut self) {
let Ok(home) = std::env::var("HOME") else {
return;
};
let home = PathBuf::from(&home);
for bp in &mut self.blueprints {
if let Ok(s) = bp.path.strip_prefix("~") {
bp.path = home.join(s);
}
if let Some(bin) = bp.binary.as_ref()
&& let Ok(s) = bin.strip_prefix("~")
{
bp.binary = Some(home.join(s));
}
}
}
fn expand_env(&mut self) -> Result<()> {
for bp in &mut self.blueprints {
for value in bp.env.values_mut() {
if let Some(stripped) = value.strip_prefix("${").and_then(|s| s.strip_suffix('}')) {
*value = std::env::var(stripped).map_err(|_| {
eyre!(
"env var {stripped} not set (referenced in blueprint '{}')",
bp.name
)
})?;
}
}
}
Ok(())
}
pub fn apply_chain_overrides(&mut self, args: &super::ChainArgs) {
if args.no_anvil {
self.chain.anvil = false;
}
if let Some(ref url) = args.rpc_url {
self.chain.rpc_url = Some(url.clone());
self.chain.anvil = false; }
if let Some(ref url) = args.ws_url {
self.chain.ws_url = Some(url.clone());
}
if let Some(id) = args.chain_id {
self.chain.chain_id = id;
}
if let Some(ref addr) = args.tangle_contract {
self.chain.tangle_contract = Some(addr.clone());
}
if let Some(ref path) = args.keystore_path {
self.chain.keystore_path = Some(path.clone());
}
if let Some(ref url) = args.router_url {
self.router.url = Some(url.clone());
}
}
pub fn filter(&mut self, only: Option<&str>) {
if let Some(only) = only {
let names: Vec<&str> = only.split(',').map(str::trim).collect();
self.blueprints
.retain(|bp| names.contains(&bp.name.as_str()));
}
}
pub fn compose(paths: &[PathBuf]) -> Result<Self> {
if paths.is_empty() {
return Err(eyre!("--compose requires at least one blueprint path"));
}
let mut merged = Self::default();
let mut first = true;
for dir in paths {
let config_path = if dir.is_file() && dir.ends_with("harness.toml") {
dir.clone()
} else {
dir.join("harness.toml")
};
if !config_path.exists() {
return Err(eyre!("no harness.toml found in {}", dir.display()));
}
let mut config = Self::load(Some(&config_path))?;
if first {
merged.chain = config.chain;
merged.router = config.router;
first = false;
}
merged.blueprints.append(&mut config.blueprints);
}
Ok(merged)
}
pub fn discover() -> Vec<(String, PathBuf)> {
let mut found = Vec::new();
if let Ok(home) = std::env::var("HOME") {
let webb_dir = PathBuf::from(&home).join("webb");
if let Ok(entries) = std::fs::read_dir(&webb_dir) {
for entry in entries.flatten() {
let harness = entry.path().join("harness.toml");
if harness.exists() {
let name = entry.file_name().to_string_lossy().to_string();
found.push((name, harness));
}
}
}
let code_dir = PathBuf::from(&home).join("code");
if let Ok(entries) = std::fs::read_dir(&code_dir) {
for entry in entries.flatten() {
let harness = entry.path().join("harness.toml");
if harness.exists() {
let name = entry.file_name().to_string_lossy().to_string();
if !found.iter().any(|(n, _)| n == &name) {
found.push((name, harness));
}
}
}
}
}
let cwd_harness = PathBuf::from("harness.toml");
if cwd_harness.exists() {
let name = std::env::current_dir()
.ok()
.and_then(|d| d.file_name().map(|n| n.to_string_lossy().to_string()))
.unwrap_or_else(|| ".".to_string());
if !found.iter().any(|(_, p)| p == &cwd_harness) {
found.insert(0, (name, cwd_harness));
}
}
found.sort_by(|a, b| a.0.cmp(&b.0));
found
}
}