use clap::{Parser, Subcommand};
use entrenar_common::cli::{styles, CommonArgs};
use entrenar_common::output::{format_bytes, format_number, TableBuilder};
use entrenar_lora::{plan, Method};
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "entrenar-lora")]
#[command(about = "LoRA/QLoRA configuration optimizer and memory planner")]
#[command(version)]
struct Cli {
#[command(subcommand)]
command: Commands,
#[command(flatten)]
common: CommonArgs,
}
#[derive(Subcommand)]
enum Commands {
Plan {
#[arg(short, long)]
model: String,
#[arg(short, long)]
vram: f64,
#[arg(short = 'm', long, default_value = "auto")]
method: String,
},
Compare {
#[arg(short, long)]
model: String,
#[arg(short, long, default_value = "24")]
vram: f64,
},
Merge {
#[arg(short, long)]
base: PathBuf,
#[arg(short, long)]
adapter: PathBuf,
#[arg(short, long)]
output: PathBuf,
#[arg(short, long, default_value = "1.0")]
scale: f32,
},
Inspect {
path: PathBuf,
},
}
fn main() {
let cli = Cli::parse();
let config = cli.common.to_cli();
let result = match cli.command {
Commands::Plan { model, vram, method } => plan_command(&model, vram, &method, &config),
Commands::Compare { model, vram } => compare_command(&model, vram, &config),
Commands::Merge { base, adapter, output, scale } => {
merge_command(&base, &adapter, &output, scale, &config)
}
Commands::Inspect { path } => inspect_command(&path, &config),
};
if let Err(e) = result {
if !config.is_quiet() {
eprintln!("{}", styles::error(&e.to_string()));
}
std::process::exit(1);
}
}
fn parse_model_size(model: &str) -> u64 {
let lower = model.to_lowercase();
if lower.ends_with('b') {
let num: f64 = lower.trim_end_matches('b').parse().unwrap_or(7.0);
(num * 1e9) as u64
} else if lower.ends_with('m') {
let num: f64 = lower.trim_end_matches('m').parse().unwrap_or(350.0);
(num * 1e6) as u64
} else {
lower.parse().unwrap_or(7_000_000_000)
}
}
fn plan_command(
model: &str,
vram: f64,
method: &str,
cli: &entrenar_common::Cli,
) -> entrenar_common::Result<()> {
let model_params = parse_model_size(model);
let method: Method =
method.parse().map_err(|e| entrenar_common::EntrenarError::ConfigValue {
field: "method".into(),
message: e,
suggestion: "Use: full, lora, qlora, auto".into(),
})?;
let config = plan(model_params, vram, method)?;
if cli.format == entrenar_common::OutputFormat::Json {
println!(
"{}",
serde_json::json!({
"method": format!("{:?}", config.method),
"rank": config.rank,
"alpha": config.alpha,
"target_modules": config.target_modules,
"trainable_params": config.trainable_params,
"trainable_percent": config.trainable_percent,
"memory_gb": config.memory_gb,
"utilization_percent": config.utilization_percent,
"speedup": config.speedup,
})
);
} else {
if !cli.is_quiet() {
println!(
"{}",
styles::header(&format!("Optimal Configuration for {} VRAM", format_vram(vram)))
);
}
let table = TableBuilder::new()
.headers(vec!["Property", "Value"])
.row(vec!["Method", &format!("{:?}", config.method)])
.row(vec!["Rank", &config.rank.to_string()])
.row(vec!["Alpha", &format!("{:.1}", config.alpha)])
.row(vec!["Target Modules", &config.target_modules.join(", ")])
.row(vec![
"Trainable Parameters",
&format!(
"{} ({:.2}%)",
format_number(config.trainable_params),
config.trainable_percent
),
])
.row(vec![
"Memory Required",
&format!(
"{:.1} GB ({:.0}% utilization)",
config.memory_gb, config.utilization_percent
),
])
.row(vec!["Training Speedup", &format!("{:.1}x vs full fine-tuning", config.speedup)])
.build();
println!("{}", table.render());
}
Ok(())
}
fn compare_command(
model: &str,
vram: f64,
cli: &entrenar_common::Cli,
) -> entrenar_common::Result<()> {
let model_params = parse_model_size(model);
let comparisons = entrenar_lora::optimizer::compare_methods(model_params, vram);
if cli.format == entrenar_common::OutputFormat::Json {
let json: Vec<_> = comparisons
.iter()
.map(|c| {
serde_json::json!({
"method": format!("{:?}", c.method),
"fits": c.fits,
"memory_gb": c.memory_gb,
"trainable_params": c.trainable_params,
"speedup": c.speedup,
"rank": c.rank,
})
})
.collect();
if let Ok(json_str) = serde_json::to_string_pretty(&json) {
println!("{json_str}");
}
} else {
if !cli.is_quiet() {
println!("{}", styles::header("Method Comparison"));
}
let mut builder = TableBuilder::new()
.headers(vec!["Method", "Fits", "Memory", "Params", "Speedup", "Rank"]);
for c in &comparisons {
let fits = if c.fits { "✓" } else { "✗" };
builder = builder.row(vec![
&format!("{:?}", c.method),
fits,
&format!("{:.1} GB", c.memory_gb),
&format_number(c.trainable_params),
&format!("{:.1}x", c.speedup),
&c.rank.to_string(),
]);
}
println!("{}", builder.build().render());
if let Some(best) = comparisons
.iter()
.filter(|c| c.fits)
.max_by(|a, b| a.speedup.partial_cmp(&b.speedup).unwrap_or(std::cmp::Ordering::Equal))
{
println!(
"\n{}",
styles::success(&format!(
"Recommendation: {:?} (rank {}) for optimal speed/memory balance",
best.method, best.rank
))
);
}
}
Ok(())
}
fn merge_command(
base: &std::path::Path,
adapter: &std::path::Path,
output: &std::path::Path,
scale: f32,
cli: &entrenar_common::Cli,
) -> entrenar_common::Result<()> {
let engine = entrenar_lora::MergeEngine::new().with_scale(scale);
let result = engine.merge_from_file(base, adapter, output)?;
if !cli.is_quiet() {
println!(
"{}",
styles::success(&format!(
"Merged adapter into base model\n Output: {}\n Size: {}",
result.output_path.display(),
format_bytes(result.output_size_bytes)
))
);
}
Ok(())
}
fn inspect_command(
path: &std::path::Path,
cli: &entrenar_common::Cli,
) -> entrenar_common::Result<()> {
if !path.exists() {
return Err(entrenar_common::EntrenarError::ModelNotFound { path: path.to_path_buf() });
}
if !cli.is_quiet() {
println!("{}", styles::header(&format!("Adapter Analysis: {}", path.display())));
println!(" (Detailed analysis requires loading adapter file)");
}
Ok(())
}
fn format_vram(gb: f64) -> String {
format!("{gb:.0} GB")
}