use crate::common::GlobalOpts;
use crate::plugins::install::get_package_info;
use clap::Subcommand;
use colored::Colorize;
use r2x_config::Config;
use r2x_logger as logger;
use r2x_python::python_bridge::configure_python_venv;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Subcommand, Debug, Clone)]
pub enum ConfigAction {
Show,
Set { key: String, value: String },
Path {
new_path: Option<String>,
},
Reset {
#[arg(short = 'y', long = "yes")]
yes: bool,
},
#[command(subcommand)]
Python(PythonAction),
#[command(subcommand)]
Venv(VenvAction),
#[command(subcommand)]
Cache(CacheAction),
}
#[derive(Subcommand, Debug, Clone)]
pub enum PythonAction {
Install {
version: Option<String>,
},
Path,
Show,
}
#[derive(Subcommand, Debug, Clone)]
pub enum VenvAction {
Create {
#[arg(short = 'y', long = "yes")]
yes: bool,
},
Path {
new_path: Option<String>,
},
}
#[derive(Subcommand, Debug, Clone)]
pub enum CacheAction {
Clean,
Path {
new_path: Option<String>,
},
}
pub fn handle_config(action: Option<ConfigAction>, opts: GlobalOpts) {
let action = if let Some(action) = action {
action
} else {
println!(
"{}",
"Tip: run `r2x config show` to inspect settings or `r2x config set <key> <value>` to update them."
.dimmed()
);
return;
};
match action {
ConfigAction::Show => match Config::load() {
Ok(config) => {
println!("{}", "Configuration:".bold().green());
let python_version = config.python_version.as_deref().unwrap_or("3.12");
let python_suffix = if config.python_version.is_none() {
" (default)"
} else {
""
};
println!(
" {}: {}{}",
"python-version".cyan(),
python_version,
python_suffix.dimmed()
);
let venv_path = config.get_venv_path();
let venv_suffix = if config.venv_path.is_some() {
""
} else {
" (default)"
};
println!(
" {}: {}{}",
"venv-path".cyan(),
venv_path,
venv_suffix.dimmed()
);
let cache_path = config.get_cache_path();
let cache_suffix = if config.cache_path.is_some() {
""
} else {
" (default)"
};
println!(
" {}: {}{}",
"cache-path".cyan(),
cache_path,
cache_suffix.dimmed()
);
if let Some(log_path) = r2x_logger::get_log_path() {
println!(" {}: {}", "log-file".cyan(), log_path.display());
}
if let Some(ref uv) = config.uv_path {
println!(" {}: {}", "uv-path".cyan(), uv);
}
if let Some(ref core_ver) = config.r2x_core_version {
println!(" {}: {}", "r2x-core-version".cyan(), core_ver);
}
if let Some(log_python) = config.log_python {
println!(" {}: {}", "log-python".cyan(), log_python);
}
if let Some(no_stdout) = config.no_stdout {
println!(" {}: {}", "no-stdout".cyan(), no_stdout);
}
if let Some(ref log_path) = config.log_path {
println!(" {}: {}", "log-path".cyan(), log_path);
}
if let Some(log_max_size) = config.log_max_size {
println!(" {}: {}", "log-max-size".cyan(), log_max_size);
}
let python_path = config.get_venv_python_path();
if PathBuf::from(&python_path).exists() {
let uv_path = config.uv_path.as_deref().unwrap_or("uv");
match get_package_info(uv_path, &python_path, "r2x-core") {
Ok((Some(version), _)) => {
println!(" {}: {}", "r2x-core-version".cyan(), version);
}
Ok((None, _)) => {
println!(" {}: {}", "r2x-core-version".cyan(), "not found".dimmed());
}
Err(_) => {
logger::debug("Could not query r2x-core package info");
}
}
} else {
logger::debug("Venv does not exist, skipping r2x-core version check");
}
}
Err(e) => {
logger::error(&format!("Failed to load config: {}", e));
}
},
ConfigAction::Set { key, value } => match Config::load() {
Ok(mut config) => {
if config.get(&key).is_some()
|| matches!(
key.as_str(),
"cache-path"
| "verbosity"
| "python-version"
| "venv-path"
| "r2x-core-version"
| "log-python"
| "no-stdout"
| "log-path"
| "log-max-size"
)
{
config.set(&key, value.clone());
match config.save() {
Ok(()) => {
logger::success(&format!("Set {} = {}", key, value));
}
Err(e) => {
logger::error(&format!("Failed to save config: {}", e));
}
}
println!(
"{}",
"Tip: run `r2x config show` to confirm the updated value.".dimmed()
);
} else {
logger::error(&format!(
"Unknown config key: {}. Currently supported keys: cache-path, verbosity, python-version, venv-path, r2x-core-version, log-python, no-stdout, log-path, log-max-size",
key
));
}
}
Err(e) => {
logger::error(&format!("Failed to load config: {}", e));
}
},
ConfigAction::Path { new_path } => {
let config_path = Config::path();
logger::debug(&format!("Reading config from: {}", config_path.display()));
if let Some(p) = new_path {
let pointer_path = config_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join(".r2x_config_path");
if let Some(parent) = pointer_path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
logger::error(&format!("Failed to set config path: {}", e));
return;
}
}
if let Err(e) = std::fs::write(&pointer_path, p.as_bytes()) {
logger::error(&format!("Failed to set config path: {}", e));
return;
}
logger::success(&format!("Config path set to {}", p));
} else {
println!("{}", config_path.display());
let pointer_path = config_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join(".r2x_config_path");
if pointer_path.exists() {
if let Ok(contents) = std::fs::read_to_string(&pointer_path) {
let trimmed = contents.trim();
if !trimmed.is_empty() {
println!("{} {}", "overridden-by".cyan(), trimmed);
}
}
}
}
}
ConfigAction::Reset { yes } => {
let config_path = Config::path();
if !yes {
print!(
"{} Reset R2X configuration at `{}` to default settings? {} ",
"?".bold().cyan(),
config_path.display(),
"[y/n] ›".dimmed()
);
if let Err(e) = io::stdout().flush() {
logger::error(&format!("Failed to flush stdout: {}", e));
return;
}
let mut input = String::new();
match io::stdin().read_line(&mut input) {
Ok(_) => {
let response = input.trim().to_lowercase();
if response != "y" && response != "yes" {
println!("{}", "Reset cancelled.".yellow());
return;
}
}
Err(e) => {
logger::error(&format!("Failed to read confirmation: {}", e));
return;
}
}
}
if opts.verbosity_level() > 0 {
logger::step("Resetting configuration to defaults");
}
match Config::reset() {
Ok(()) => {
println!(
"{} configuration {} has been reset to default settings.",
"\u{2714}".green().bold(),
config_path.display()
);
}
Err(e) => {
logger::error(&format!("Failed to reset config: {}", e));
}
}
}
ConfigAction::Python(python_action) => {
handle_python(python_action, opts);
}
ConfigAction::Venv(venv_action) => {
handle_venv(venv_action, opts);
}
ConfigAction::Cache(cache_action) => {
handle_cache(cache_action, opts);
}
}
}
pub fn handle_python(action: PythonAction, opts: GlobalOpts) {
match action {
PythonAction::Show => {
handle_python_show(opts);
}
PythonAction::Path => {
handle_python_path(opts);
}
PythonAction::Install { version } => {
handle_python_install(version, opts);
}
}
}
fn handle_venv(action: VenvAction, opts: GlobalOpts) {
match action {
VenvAction::Create { yes } => {
handle_venv_create(yes);
}
VenvAction::Path { new_path } => {
handle_venv_path(new_path, opts);
}
}
}
fn handle_cache(action: CacheAction, opts: GlobalOpts) {
match action {
CacheAction::Clean => {
clean_cache(opts);
}
CacheAction::Path { new_path } => {
handle_cache_path(new_path, opts);
}
}
}
fn handle_python_install(version: Option<String>, _opts: GlobalOpts) {
logger::debug("Handling Python install command");
match Config::load() {
Ok(mut config) => {
let version_str = version
.or_else(|| config.python_version.clone())
.unwrap_or_else(|| "3.12".to_string());
config.python_version = Some(version_str.clone());
if let Err(e) = config.save() {
logger::error(&format!("Failed to save config: {}", e));
return;
}
let venv_path = config.get_venv_path();
logger::step(&format!(
"Installing Python {} and creating venv...",
version_str
));
if let Err(e) = remove_existing_venv(&venv_path) {
logger::error(&e);
return;
}
match configure_python_venv() {
Ok(python_env) => {
logger::info(&format!(
"Configuration saved with Python version {}",
version_str
));
if let Some(actual_version) = verify_python_version(&python_env.interpreter) {
logger::success(&format!(
"Python {} installed (reported {}). Venv ready at {}",
version_str,
actual_version,
PathBuf::from(&venv_path).display()
));
} else {
logger::success(&format!(
"Python {} installed and venv created at {}",
version_str, venv_path
));
}
}
Err(e) => {
logger::error(&format!("Failed to configure Python environment: {}", e));
}
}
}
Err(e) => {
logger::error(&format!("Failed to load config: {}", e));
}
}
}
fn handle_python_path(_opts: GlobalOpts) {
logger::debug("Handling python path command");
match Config::load() {
Ok(config) => {
println!("{}", config.get_venv_python_path());
}
Err(e) => {
logger::error(&format!("Failed to load config: {}", e));
}
}
}
fn handle_venv_create(skip_confirmation: bool) {
logger::debug(&format!(
"Handling venv create command (skip_confirmation: {})",
skip_confirmation
));
match Config::load() {
Ok(config) => {
let venv_path = config.get_venv_path();
let venv_dir = PathBuf::from(&venv_path);
if venv_dir.exists() {
let should_skip = skip_confirmation || std::env::var("R2X_VENV_YES").is_ok();
if should_skip {
logger::debug("Skipping confirmation (--yes flag or R2X_VENV_YES set)");
} else {
print!(
"{} A virtual environment already exists at `{}`. Do you want to replace it? {} ",
"?".bold().cyan(),
venv_path,
"[y/n] ›".dimmed()
);
let _ = io::stdout().flush();
logger::debug("Prompting user for venv replacement confirmation");
let mut response = String::new();
if io::stdin().read_line(&mut response).is_ok() {
let response = response.trim().to_lowercase();
if response != "y" && response != "yes" {
logger::info("Operation cancelled by user");
println!("Operation cancelled.");
return;
}
logger::debug("User confirmed venv replacement");
} else {
logger::error("Failed to read input");
return;
}
}
if let Err(e) = remove_existing_venv(&venv_path) {
logger::error(&e);
return;
}
}
match configure_python_venv() {
Ok(python_env) => {
logger::success(&format!(
"Virtual environment ready at {} (python {})",
venv_path,
python_env.interpreter.display()
));
}
Err(e) => logger::error(&format!("Failed to configure venv: {}", e)),
}
if !skip_confirmation && std::env::var("R2X_VENV_YES").is_err() {
println!(
"\n{} Use the `{}` flag or set `{}` to skip this prompt",
"hint:".dimmed(),
"-y/--yes".bold(),
"R2X_VENV_YES=1".bold()
);
}
}
Err(e) => {
logger::error(&format!("Failed to load config: {}", e));
}
}
}
fn handle_venv_path(new_path: Option<String>, _opts: GlobalOpts) {
logger::debug("Handling venv path command");
match Config::load() {
Ok(mut config) => {
if let Err(e) = config.ensure_uv_path() {
logger::error(&format!("Failed to setup uv: {}", e));
return;
}
if let Some(path) = new_path {
logger::debug(&format!("Setting venv path to: {}", path));
let venv_path = PathBuf::from(&path);
if !venv_path.exists() {
logger::error(&format!("Path does not exist: {}", path));
return;
}
if !is_valid_venv(&venv_path) {
logger::error(&format!("Path is not a valid venv: {}", path));
return;
}
config.venv_path = Some(path.clone());
if let Err(e) = config.save() {
logger::error(&format!("Failed to save config: {}", e));
return;
}
logger::success(&format!("Venv path set to {}", path));
} else {
let venv_path = config.get_venv_path();
logger::debug(&format!("Current venv path: {}", venv_path));
if !PathBuf::from(&venv_path).exists() {
logger::error(&format!("Venv path does not exist: {}", venv_path));
return;
}
if !is_valid_venv(&PathBuf::from(&venv_path)) {
logger::error(&format!("Venv path is not a valid venv: {}", venv_path));
return;
}
println!("{}", venv_path);
}
}
Err(e) => {
logger::error(&format!("Failed to load config: {}", e));
}
}
}
fn is_valid_venv(path: &Path) -> bool {
logger::debug(&format!("Validating venv at: {}", path.display()));
if !path.exists() || !path.is_dir() {
logger::debug("Path does not exist or is not a directory");
return false;
}
let bin_dir = if cfg!(windows) {
path.join("Scripts")
} else {
path.join("bin")
};
bin_dir.exists() && bin_dir.is_dir()
}
fn remove_existing_venv(venv_path: &str) -> Result<(), String> {
let venv_dir = PathBuf::from(venv_path);
if venv_dir.exists() {
logger::debug(&format!("Removing existing venv at {}", venv_path));
fs::remove_dir_all(&venv_dir)
.map_err(|e| format!("Failed to remove existing venv: {}", e))?;
}
Ok(())
}
fn verify_python_version(python_path: &Path) -> Option<String> {
if !python_path.exists() {
return None;
}
match Command::new(python_path).args(["--version"]).output() {
Ok(output) if output.status.success() => {
let raw = if output.stdout.is_empty() {
output.stderr
} else {
output.stdout
};
Some(String::from_utf8_lossy(&raw).trim().to_string())
}
_ => None,
}
}
fn handle_python_show(_opts: GlobalOpts) {
logger::debug("Handling python show command");
match Config::load() {
Ok(config) => {
let version = config.python_version.as_deref().unwrap_or("not configured");
let venv_path = config.get_venv_path();
let python_path = PathBuf::from(config.get_venv_python_path());
let venv_exists = python_path.exists();
let mut actual_version_str = String::new();
let mut version_mismatch = false;
if venv_exists {
if let Some(actual_version) = verify_python_version(&python_path) {
actual_version_str.clone_from(&actual_version);
if let Some(version_num) = actual_version.split_whitespace().nth(1) {
let configured_short =
version.split('.').take(2).collect::<Vec<_>>().join(".");
let actual_short =
version_num.split('.').take(2).collect::<Vec<_>>().join(".");
if configured_short != actual_short && version != "not configured" {
version_mismatch = true;
}
}
} else {
logger::debug("Could not determine actual Python version");
}
}
if version_mismatch {
logger::warn(&format!(
"Version mismatch: config has {}, venv has {}. Run 'r2x config venv create --yes' to recreate.",
version, actual_version_str.trim()
));
}
println!("{}", "Python Configuration:".bold().green());
println!(" version: {}", version);
println!(" venv path: {}", venv_path);
println!(" venv exists: {}", if venv_exists { "yes" } else { "no" });
if !actual_version_str.is_empty() {
println!(" Actual venv version: {}", actual_version_str.trim());
}
}
Err(e) => {
logger::error(&format!("Failed to load config: {}", e));
}
}
}
fn clean_cache(_opts: GlobalOpts) {
match Config::load() {
Ok(config) => {
let cache_path = config.get_cache_path();
let cache_dir = PathBuf::from(&cache_path);
if !cache_dir.exists() {
logger::debug("Cache folder already clean");
return;
}
match fs::remove_dir_all(&cache_dir) {
Ok(()) => {
logger::success("Cache folder cleaned");
}
Err(e) => {
logger::error(&format!("Failed to clean cache folder: {}", e));
}
}
}
Err(e) => {
logger::error(&format!("Failed to load config: {}", e));
}
}
}
fn handle_cache_path(new_path: Option<String>, _opts: GlobalOpts) {
match Config::load() {
Ok(mut config) => {
if let Some(path) = new_path {
let cache_path = PathBuf::from(&path);
if let Err(e) = fs::create_dir_all(&cache_path) {
logger::error(&format!("Failed to create cache directory: {}", e));
return;
}
config.cache_path = Some(path.clone());
if let Err(e) = config.save() {
logger::error(&format!("Failed to save config: {}", e));
return;
}
logger::success(&format!("Cache path set to {}", path));
} else {
let cache_path = config.get_cache_path();
println!("{}", cache_path);
}
}
Err(e) => {
logger::error(&format!("Failed to load config: {}", e));
}
}
}
#[cfg(test)]
mod tests {
use crate::commands::config::*;
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_temp_config(f: impl FnOnce()) {
let _guard = ENV_LOCK.lock();
let Ok(dir) = tempfile::tempdir() else {
return;
};
let config_path = dir.path().join("config.toml");
std::env::set_var("R2X_CONFIG", &config_path);
f();
std::env::remove_var("R2X_CONFIG");
}
fn quiet_opts() -> GlobalOpts {
GlobalOpts {
quiet: 1,
verbose: 0,
log_python: false,
no_stdout: false,
}
}
fn verbose_opts() -> GlobalOpts {
GlobalOpts {
quiet: 0,
verbose: 1,
log_python: false,
no_stdout: false,
}
}
fn normal_opts() -> GlobalOpts {
GlobalOpts {
quiet: 0,
verbose: 0,
log_python: false,
no_stdout: false,
}
}
#[test]
fn test_config_show() {
handle_config(Some(ConfigAction::Show), normal_opts());
}
#[test]
fn test_config_set() {
with_temp_config(|| {
handle_config(
Some(ConfigAction::Set {
key: "cache-path".to_string(),
value: "test-value".to_string(),
}),
normal_opts(),
);
});
}
#[test]
fn test_config_set_quiet() {
with_temp_config(|| {
handle_config(
Some(ConfigAction::Set {
key: "cache-path".to_string(),
value: "test-value".to_string(),
}),
quiet_opts(),
);
});
}
#[test]
fn test_config_set_verbose() {
with_temp_config(|| {
handle_config(
Some(ConfigAction::Set {
key: "cache-path".to_string(),
value: "test-value".to_string(),
}),
verbose_opts(),
);
});
}
#[test]
fn test_config_reset() {
with_temp_config(|| {
handle_config(Some(ConfigAction::Reset { yes: true }), normal_opts());
});
}
#[test]
fn test_config_no_action_tip() {
handle_config(None, normal_opts());
}
}