use anyhow::{Context, Result};
use rch_common::{ApiResponse, TransferConfig};
use crate::config::{WorkerEntry, WorkersConfig};
use crate::ui;
use crate::ui::context::OutputContext;
use crate::ui::theme::StatusIndicator;
use super::helpers::config_dir;
use super::types::ConfigInitResponse;
fn format_default_exclude_patterns() -> String {
TransferConfig::default()
.exclude_patterns
.iter()
.map(|p| format!(" {},\n", toml_string_literal(p)))
.collect::<String>()
}
fn toml_string_literal(value: &str) -> String {
toml::Value::String(value.to_string()).to_string()
}
fn write_configuration(path: &std::path::Path, contents: &str, overwrite: bool) -> Result<bool> {
if overwrite {
std::fs::write(path, contents)?;
return Ok(true);
}
match std::fs::File::create_new(path) {
Ok(mut file) => {
use std::io::Write;
file.write_all(contents.as_bytes())?;
Ok(true)
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
Err(error) => Err(error.into()),
}
}
pub fn config_init(ctx: &OutputContext, wizard: bool, use_defaults: bool) -> Result<()> {
use dialoguer::Confirm;
let style = ctx.theme();
let config_dir = config_dir().context("Could not determine config directory")?;
std::fs::create_dir_all(&config_dir)
.with_context(|| format!("Failed to create config directory: {:?}", config_dir))?;
let config_path = config_dir.join("config.toml");
let workers_path = config_dir.join("workers.toml");
let mut created = Vec::new();
let mut already_existed = Vec::new();
let config_exists = config_path.exists();
let workers_exists = workers_path.exists();
if !wizard {
return config_init_simple(ctx, &config_path, &workers_path);
}
if !ctx.is_json() {
println!("{}", style.format_header("RCH Configuration Wizard"));
println!();
if use_defaults {
println!(
" {} Using default values (non-interactive mode)",
style.muted("→")
);
} else {
println!(
" {} Interactive configuration - press Enter to accept defaults",
style.muted("→")
);
}
println!();
}
let should_write_config = if config_exists {
if use_defaults {
false } else {
Confirm::new()
.with_prompt(format!(
"{} already exists. Overwrite?",
config_path.display()
))
.default(false)
.interact()
.unwrap_or(false)
}
} else {
true
};
let config_values = if should_write_config {
Some(collect_config_values(use_defaults, style)?)
} else {
None
};
let should_write_workers = if workers_exists {
if use_defaults {
false } else {
Confirm::new()
.with_prompt(format!(
"{} already exists. Overwrite?",
workers_path.display()
))
.default(false)
.interact()
.unwrap_or(false)
}
} else {
true
};
let workers = if should_write_workers {
Some(collect_worker_values(use_defaults, style)?)
} else {
None
};
let wrote_config = if let Some(values) = config_values {
let config_content = generate_config_toml(&values);
write_configuration(&config_path, &config_content, config_exists)?
} else {
false
};
if wrote_config {
created.push(config_path.display().to_string());
if !ctx.is_json() {
println!(
"{} {} {}",
StatusIndicator::Success.display(style),
style.muted("Created:"),
style.value(&config_path.display().to_string())
);
}
} else {
already_existed.push(config_path.display().to_string());
if !ctx.is_json() {
println!(
"{} {} {}",
style.muted("-"),
style.muted("Exists:"),
style.muted(&config_path.display().to_string())
);
}
}
let wrote_workers = if let Some(workers) = workers.as_ref() {
let workers_content = generate_workers_toml(workers)?;
write_configuration(&workers_path, &workers_content, workers_exists)?
} else {
false
};
if wrote_workers {
created.push(workers_path.display().to_string());
if !ctx.is_json() {
println!(
"{} {} {}",
StatusIndicator::Success.display(style),
style.muted("Created:"),
style.value(&workers_path.display().to_string())
);
}
} else {
already_existed.push(workers_path.display().to_string());
if !ctx.is_json() {
println!(
"{} {} {}",
style.muted("-"),
style.muted("Exists:"),
style.muted(&workers_path.display().to_string())
);
}
}
if ctx.is_json() {
ctx.json(&ApiResponse::ok(
"config init",
ConfigInitResponse {
created,
already_existed,
},
))?;
return Ok(());
}
println!("\n{}", style.format_success("Configuration initialized!"));
println!("\n{}", style.highlight("Next steps:"));
if workers.is_some() || !workers_exists {
println!(
" {}. Edit {} with your actual worker details",
style.muted("1"),
style.info(&workers_path.display().to_string())
);
println!(
" {}. Or auto-discover workers: {}",
style.muted("2"),
style.highlight("rch workers discover --add")
);
}
println!(
" {}. Test connectivity: {}",
style.muted("3"),
style.highlight("rch workers probe --all")
);
println!(
" {}. Start the daemon: {}",
style.muted("4"),
style.highlight("rch daemon start")
);
Ok(())
}
fn config_init_simple(
ctx: &OutputContext,
config_path: &std::path::Path,
workers_path: &std::path::Path,
) -> Result<()> {
let style = ctx.theme();
let mut created = Vec::new();
let mut already_existed = Vec::new();
let wrote_config = if !config_path.exists() {
let exclude_lines = format_default_exclude_patterns();
let socket_literal = toml_string_literal(&rch_common::default_socket_path());
let config_content = format!(
r###"# RCH Configuration
# See documentation for all options
[general]
enabled = true
log_level = "info"
socket_path = {socket_literal}
[compilation]
confidence_threshold = 0.85
min_local_time_ms = 2000
[transfer]
compression_level = 3
exclude_patterns = [
{exclude_lines}]
"###
);
write_configuration(config_path, &config_content, false)?
} else {
false
};
if wrote_config {
created.push(config_path.display().to_string());
if !ctx.is_json() {
println!(
"{} {} {}",
StatusIndicator::Success.display(style),
style.muted("Created:"),
style.value(&config_path.display().to_string())
);
}
} else {
already_existed.push(config_path.display().to_string());
if !ctx.is_json() {
println!(
"{} {} {}",
style.muted("-"),
style.muted("Exists:"),
style.muted(&config_path.display().to_string())
);
}
}
let wrote_workers = if !workers_path.exists() {
let workers_content = r###"# RCH Workers Configuration
# Define your remote compilation workers here
# Example worker definition
[[workers]]
id = "worker1"
host = "192.168.1.100"
user = "ubuntu"
identity_file = "~/.ssh/id_rsa"
total_slots = 16
priority = 100
tags = ["rust", "fast"]
enabled = true
# Add more workers as needed:
# [[workers]]
# id = "worker2"
# host = "192.168.1.101"
# ...
"###;
write_configuration(workers_path, workers_content, false)?
} else {
false
};
if wrote_workers {
created.push(workers_path.display().to_string());
if !ctx.is_json() {
println!(
"{} {} {}",
StatusIndicator::Success.display(style),
style.muted("Created:"),
style.value(&workers_path.display().to_string())
);
}
} else {
already_existed.push(workers_path.display().to_string());
if !ctx.is_json() {
println!(
"{} {} {}",
style.muted("-"),
style.muted("Exists:"),
style.muted(&workers_path.display().to_string())
);
}
}
if ctx.is_json() {
ctx.json(&ApiResponse::ok(
"config init",
ConfigInitResponse {
created,
already_existed,
},
))?;
return Ok(());
}
println!("\n{}", style.format_success("Configuration initialized!"));
println!("\n{}", style.highlight("Next steps:"));
println!(
" {}. Edit {} with your worker details",
style.muted("1"),
style.info(&workers_path.display().to_string())
);
println!(
" {}. Test connectivity: {}",
style.muted("2"),
style.highlight("rch workers probe --all")
);
println!(
" {}. Start the daemon: {}",
style.muted("3"),
style.highlight("rch daemon start")
);
Ok(())
}
struct ConfigValues {
log_level: String,
socket_path: String,
confidence_threshold: f64,
min_local_time_ms: u32,
compression_level: u8,
}
struct WizardWorker {
id: String,
host: String,
user: String,
identity_file: String,
total_slots: u16,
priority: u16,
}
fn collect_config_values(use_defaults: bool, style: &ui::theme::Theme) -> Result<ConfigValues> {
use dialoguer::{Input, Select};
let default_socket = rch_common::default_socket_path();
if use_defaults {
return Ok(ConfigValues {
log_level: "info".to_string(),
socket_path: default_socket,
confidence_threshold: 0.85,
min_local_time_ms: 2000,
compression_level: 3,
});
}
println!("\n{}", style.highlight("General Settings"));
println!();
let log_levels = vec!["error", "warn", "info", "debug", "trace"];
let log_level_idx = Select::new()
.with_prompt("Log level")
.items(&log_levels)
.default(2) .interact()
.unwrap_or(2);
let log_level = log_levels[log_level_idx].to_string();
let socket_path: String = Input::new()
.with_prompt("Unix socket path")
.default(default_socket.clone())
.interact_text()
.unwrap_or(default_socket);
println!("\n{}", style.highlight("Compilation Settings"));
println!();
let confidence_threshold: f64 = Input::new()
.with_prompt("Classification confidence threshold (0.0-1.0)")
.default(0.85)
.validate_with(|input: &f64| {
if *input >= 0.0 && *input <= 1.0 {
Ok(())
} else {
Err("Must be between 0.0 and 1.0")
}
})
.interact_text()
.unwrap_or(0.85);
let min_local_time_ms: u32 = Input::new()
.with_prompt("Minimum estimated local time (ms) to offload")
.default(2000u32)
.interact_text()
.unwrap_or(2000);
println!("\n{}", style.highlight("Transfer Settings"));
println!();
let compression_level: u8 = Input::new()
.with_prompt("Zstd compression level (1-19, lower=faster)")
.default(3u8)
.validate_with(|input: &u8| {
if *input >= 1 && *input <= 19 {
Ok(())
} else {
Err("Must be between 1 and 19")
}
})
.interact_text()
.unwrap_or(3);
Ok(ConfigValues {
log_level,
socket_path,
confidence_threshold,
min_local_time_ms,
compression_level,
})
}
fn collect_worker_values(
use_defaults: bool,
style: &ui::theme::Theme,
) -> Result<Vec<WizardWorker>> {
use dialoguer::{Confirm, Input};
if use_defaults {
return Ok(vec![WizardWorker {
id: "worker1".to_string(),
host: "192.168.1.100".to_string(),
user: "ubuntu".to_string(),
identity_file: "~/.ssh/id_rsa".to_string(),
total_slots: 16,
priority: 100,
}]);
}
println!("\n{}", style.highlight("Worker Configuration"));
println!();
println!(
" {} Define your remote compilation workers.",
style.muted("→")
);
println!(
" {} You can also auto-discover workers later with: {}",
style.muted("→"),
style.info("rch workers discover --add")
);
println!();
let mut workers = Vec::new();
let mut worker_num = 1;
loop {
println!("\n{} Worker #{}", style.muted("─────────────"), worker_num);
let id: String = Input::new()
.with_prompt("Worker ID (short name)")
.default(format!("worker{}", worker_num))
.interact_text()
.unwrap_or_else(|_| format!("worker{}", worker_num));
let host: String = Input::new()
.with_prompt("Hostname or IP address")
.interact_text()
.context("Host is required")?;
let user: String = Input::new()
.with_prompt("SSH username")
.default("ubuntu".to_string())
.interact_text()
.unwrap_or_else(|_| "ubuntu".to_string());
let identity_file: String = Input::new()
.with_prompt("SSH identity file path")
.default("~/.ssh/id_rsa".to_string())
.interact_text()
.unwrap_or_else(|_| "~/.ssh/id_rsa".to_string());
let total_slots: u16 = Input::new()
.with_prompt("Total CPU slots (cores) available")
.default(16u16)
.interact_text()
.unwrap_or(16);
let priority: u16 = Input::new()
.with_prompt("Priority (higher = preferred)")
.default(100u16)
.interact_text()
.unwrap_or(100);
workers.push(WizardWorker {
id,
host,
user,
identity_file,
total_slots,
priority,
});
worker_num += 1;
let add_another = Confirm::new()
.with_prompt("Add another worker?")
.default(false)
.interact()
.unwrap_or(false);
if !add_another {
break;
}
}
if workers.is_empty() {
workers.push(WizardWorker {
id: "worker1".to_string(),
host: "192.168.1.100".to_string(),
user: "ubuntu".to_string(),
identity_file: "~/.ssh/id_rsa".to_string(),
total_slots: 16,
priority: 100,
});
}
Ok(workers)
}
fn generate_config_toml(values: &ConfigValues) -> String {
let exclude_lines = format_default_exclude_patterns();
let log_level = toml_string_literal(&values.log_level);
let socket_path = toml_string_literal(&values.socket_path);
format!(
r###"# RCH Configuration
# Generated by rch config init --wizard
# See documentation for all options
[general]
enabled = true
log_level = {}
socket_path = {}
[compilation]
# Minimum classification confidence to intercept (0.0-1.0)
confidence_threshold = {}
# Skip offloading if estimated local time is below this (milliseconds)
min_local_time_ms = {}
[transfer]
# Zstd compression level (1-19, lower=faster)
compression_level = {}
exclude_patterns = [
{exclude_lines}]
# [remediation]
# Session-history remediation knobs: temporary bypass, auto-rejoin, proof mode,
# reconciliation, incident ledger, build-root safety, pooled targets, telemetry
# freshness, log retention, disk pressure, and smoke defaults. Every section is
# OPTIONAL and falls back to safe defaults; uncomment only what you override.
# Validate with `rch config doctor`; see effective overrides with `rch config diff`.
#
# [remediation.policy]
# hook_exec_fail_open = true # hook/exec errors fall back to local builds
# proof_mode_fail_closed = true # RCH_REQUIRE_REMOTE refuses local fallback
"###,
log_level,
socket_path,
values.confidence_threshold,
values.min_local_time_ms,
values.compression_level
)
}
fn generate_workers_toml(workers: &[WizardWorker]) -> Result<String> {
let config = WorkersConfig {
workers: workers
.iter()
.map(|worker| WorkerEntry {
id: worker.id.clone(),
host: worker.host.clone(),
user: worker.user.clone(),
identity_file: worker.identity_file.clone(),
total_slots: u32::from(worker.total_slots),
priority: u32::from(worker.priority),
tags: Vec::new(),
os: None,
enabled: true,
})
.collect(),
};
let mut content = String::from(
"# RCH Workers Configuration\n\
# Generated by rch config init --wizard\n\
# Define your remote compilation workers here\n\n",
);
let rendered =
toml::to_string_pretty(&config).context("Failed to serialize wizard workers.toml")?;
content.push_str(&rendered);
Ok(content)
}
#[cfg(test)]
mod tests {
use super::*;
use rch_common::test_guard;
#[test]
fn exclusive_configuration_creation_preserves_one_real_concurrent_writer() {
use std::sync::{Arc, Barrier};
let retained = tempfile::tempdir().unwrap().keep();
let path = retained.join("config.toml");
let sentinel = retained.join("peer-data");
std::fs::write(&sentinel, b"retained peer bytes\n").unwrap();
let barrier = Arc::new(Barrier::new(9));
let writers = (0..8)
.map(|index| {
let path = path.clone();
let barrier = Arc::clone(&barrier);
std::thread::spawn(move || {
let contents = format!("writer-{index}\n");
barrier.wait();
let created = write_configuration(&path, &contents, false).unwrap();
(contents, created)
})
})
.collect::<Vec<_>>();
barrier.wait();
let outcomes = writers
.into_iter()
.map(|writer| writer.join().unwrap())
.collect::<Vec<_>>();
let winners = outcomes
.iter()
.filter(|outcome| outcome.1)
.collect::<Vec<_>>();
assert_eq!(winners.len(), 1);
assert_eq!(std::fs::read(&path).unwrap(), winners[0].0.as_bytes());
assert!(!write_configuration(&path, "later initializer\n", false).unwrap());
assert_eq!(std::fs::read(&path).unwrap(), winners[0].0.as_bytes());
assert_eq!(std::fs::read(sentinel).unwrap(), b"retained peer bytes\n");
}
#[cfg(unix)]
#[test]
fn exclusive_configuration_creation_preserves_dangling_symlinks() {
use std::os::unix::fs::symlink;
let retained = tempfile::tempdir().unwrap().keep();
let path = retained.join("config.toml");
let target = retained.join("peer-owned-target");
symlink(&target, &path).unwrap();
assert!(!path.exists());
assert!(!write_configuration(&path, "must not follow link\n", false).unwrap());
assert_eq!(std::fs::read_link(&path).unwrap(), target);
assert!(!target.exists());
}
#[cfg(unix)]
#[test]
fn simple_initialization_preserves_files_and_reports_real_delivery_failures() {
use crate::ui::context::{ColorChoice, OutputConfig, OutputError, OutputMode};
use crate::ui::writer::OutputWriter;
use std::os::unix::net::UnixStream;
let retained = tempfile::tempdir().unwrap().keep();
for existing in [false, true] {
for closed in [false, true] {
let directory = retained.join(format!("state-{existing}-{closed}"));
std::fs::create_dir(&directory).unwrap();
let config = directory.join("config.toml");
let workers = directory.join("workers.toml");
if existing {
std::fs::write(&config, b"owned configuration bytes\n").unwrap();
std::fs::write(&workers, b"owned worker bytes\n").unwrap();
}
let output = retained.join(format!("output-{existing}-{closed}"));
let stdout = if closed {
let (socket, peer) = UnixStream::pair().unwrap();
socket
.set_write_timeout(Some(std::time::Duration::from_secs(2)))
.unwrap();
drop(peer);
OutputWriter::new(socket, false)
} else {
OutputWriter::new(std::fs::File::create_new(&output).unwrap(), false)
};
let ctx = OutputContext::with_writers(
OutputConfig {
force_mode: Some(OutputMode::Json),
color: ColorChoice::Never,
..Default::default()
},
stdout,
OutputWriter::stderr(),
);
let result = config_init_simple(&ctx, &config, &workers);
if closed {
let error = result.unwrap_err();
assert!(matches!(
error.downcast_ref::<OutputError>(),
Some(OutputError::Delivery(cause))
if cause.kind() == std::io::ErrorKind::BrokenPipe
));
assert!(!output.exists());
} else {
result.unwrap();
let report = serde_json::from_slice::<serde_json::Value>(
&std::fs::read(&output).unwrap(),
)
.unwrap();
assert_eq!(report["command"], "config init");
assert_eq!(report["success"], true);
let paths = serde_json::json!([
config.display().to_string(),
workers.display().to_string()
]);
assert_eq!(
report["data"]["created"],
if existing {
serde_json::json!([])
} else {
paths.clone()
}
);
assert_eq!(
report["data"]["already_existed"],
if existing {
paths
} else {
serde_json::json!([])
}
);
}
let config_bytes = std::fs::read(&config).unwrap();
let worker_bytes = std::fs::read(&workers).unwrap();
if existing {
assert_eq!(config_bytes, b"owned configuration bytes\n");
assert_eq!(worker_bytes, b"owned worker bytes\n");
} else {
let parsed =
toml::from_str::<toml::Value>(std::str::from_utf8(&config_bytes).unwrap())
.unwrap();
let expected_socket = rch_common::default_socket_path();
assert_eq!(
parsed["general"]["socket_path"].as_str(),
Some(expected_socket.as_str())
);
toml::from_str::<toml::Value>(std::str::from_utf8(&worker_bytes).unwrap())
.unwrap();
}
assert_eq!(std::fs::read_dir(&directory).unwrap().count(), 2);
assert!(!write_configuration(&config, "later replacement\n", false).unwrap());
assert!(!write_configuration(&workers, "later replacement\n", false).unwrap());
assert_eq!(std::fs::read(config).unwrap(), config_bytes);
assert_eq!(std::fs::read(workers).unwrap(), worker_bytes);
}
}
}
#[test]
fn generate_config_toml_contains_all_sections() {
let _guard = test_guard!();
let values = ConfigValues {
log_level: "info".to_string(),
socket_path: "/tmp/rch.sock".to_string(),
confidence_threshold: 0.85,
min_local_time_ms: 2000,
compression_level: 3,
};
let toml = generate_config_toml(&values);
assert!(toml.contains("[general]"));
assert!(toml.contains("[compilation]"));
assert!(toml.contains("[transfer]"));
assert!(toml.contains("enabled = true"));
assert!(toml.contains("log_level = \"info\""));
assert!(toml.contains("confidence_threshold = 0.85"));
assert!(toml.contains("min_local_time_ms = 2000"));
assert!(toml.contains("compression_level = 3"));
}
#[test]
fn generate_workers_toml_single_worker() {
let _guard = test_guard!();
let workers = vec![WizardWorker {
id: "worker-1".to_string(),
host: "192.168.1.100".to_string(),
user: "ubuntu".to_string(),
identity_file: "~/.ssh/id_rsa".to_string(),
total_slots: 8,
priority: 100,
}];
let toml = generate_workers_toml(&workers).expect("workers TOML should serialize");
assert!(toml.contains("[[workers]]"));
assert!(toml.contains("id = \"worker-1\""));
assert!(toml.contains("host = \"192.168.1.100\""));
assert!(toml.contains("user = \"ubuntu\""));
assert!(toml.contains("total_slots = 8"));
assert!(toml.contains("priority = 100"));
}
#[test]
fn generate_config_toml_escapes_string_values() {
let _guard = test_guard!();
let values = ConfigValues {
log_level: "info\"debug".to_string(),
socket_path: "/tmp/rch.sock\"\n[evil]\nkey = \"value".to_string(),
confidence_threshold: 0.85,
min_local_time_ms: 2000,
compression_level: 3,
};
let toml = generate_config_toml(&values);
let parsed: toml::Value = toml::from_str(&toml).expect("config TOML should parse");
assert_eq!(
parsed
.get("general")
.and_then(|general| general.get("log_level"))
.and_then(toml::Value::as_str),
Some("info\"debug")
);
assert_eq!(
parsed
.get("general")
.and_then(|general| general.get("socket_path"))
.and_then(toml::Value::as_str),
Some("/tmp/rch.sock\"\n[evil]\nkey = \"value")
);
assert!(parsed.get("evil").is_none());
}
#[test]
fn generate_workers_toml_escapes_string_values() {
let _guard = test_guard!();
let workers = vec![WizardWorker {
id: "worker-1".to_string(),
host: "host\"\n[[workers]]\nid = \"injected".to_string(),
user: "ubuntu\"admin".to_string(),
identity_file: "/tmp/key\\with\"quote".to_string(),
total_slots: 8,
priority: 100,
}];
let toml = generate_workers_toml(&workers).expect("workers TOML should serialize");
let parsed: WorkersConfig = toml::from_str(&toml).expect("workers TOML should parse");
assert_eq!(parsed.workers.len(), 1);
assert_eq!(parsed.workers[0].id, "worker-1");
assert_eq!(
parsed.workers[0].host,
"host\"\n[[workers]]\nid = \"injected"
);
assert_eq!(parsed.workers[0].user, "ubuntu\"admin");
assert_eq!(parsed.workers[0].identity_file, "/tmp/key\\with\"quote");
}
#[test]
fn generate_workers_toml_multiple_workers() {
let _guard = test_guard!();
let workers = vec![
WizardWorker {
id: "w1".to_string(),
host: "host1".to_string(),
user: "user1".to_string(),
identity_file: "key1".to_string(),
total_slots: 4,
priority: 100,
},
WizardWorker {
id: "w2".to_string(),
host: "host2".to_string(),
user: "user2".to_string(),
identity_file: "key2".to_string(),
total_slots: 8,
priority: 50,
},
];
let toml = generate_workers_toml(&workers).expect("workers TOML should serialize");
let worker_count = toml.matches("[[workers]]").count();
assert_eq!(worker_count, 2);
assert!(toml.contains("id = \"w1\""));
assert!(toml.contains("id = \"w2\""));
}
#[test]
fn generate_workers_toml_empty() {
let _guard = test_guard!();
let workers: Vec<WizardWorker> = vec![];
let toml = generate_workers_toml(&workers).expect("workers TOML should serialize");
assert!(toml.contains("RCH Workers Configuration"));
assert!(!toml.contains("[[workers]]"));
}
}