use std::fs;
use std::io::IsTerminal;
use std::process::ExitCode;
use callisto_format::{Changeset, Entry};
use callisto_graph::DependencyResolver;
use callisto_model::{PackageId, Severity, SCHEMA_VERSION};
use dialoguer::{Confirm, Input, MultiSelect};
use serde_json::json;
use crate::cli::{AddArgs, GlobalArgs, OutputFormat};
use crate::error::CliError;
use crate::output::{log_line, write_json};
use crate::runner::CliCommandRunner;
use crate::workspace::load_workspace;
pub fn handle(args: AddArgs, global: &GlobalArgs) -> Result<ExitCode, CliError> {
let runner = CliCommandRunner;
let ws = load_workspace(global, &runner)?;
let mut entries = Vec::new();
let mut summary = args.summary.clone();
if !args.packages.is_empty() {
for pkg_str in args.packages {
let (name, sev_str) = pkg_str.rsplit_once(':').ok_or_else(|| {
CliError::Other(format!(
"Invalid package spec `{pkg_str}`. Expected format: `package-name:severity`"
))
})?;
let severity: Severity = sev_str.parse().map_err(|_err| {
CliError::Other(format!(
"Invalid severity `{sev_str}`. Must be patch, minor, or major."
))
})?;
let id = PackageId::parse(name)
.map_err(|e| CliError::Other(format!("Invalid package name `{name}`: {e}")))?;
entries.push(Entry {
name: id.name().to_string(),
severity,
});
}
} else if std::io::stdin().is_terminal() {
let all_packages: Vec<String> = ws
.graph
.packages()
.map(|p| p.id.name().to_string())
.collect();
if all_packages.is_empty() {
return Err(CliError::Other(
"No packages found in workspace.".to_string(),
));
}
println!("Which packages would you like to include in this changeset?");
let selected_indices = MultiSelect::new()
.items(&all_packages)
.interact()
.map_err(|e| CliError::Other(format!("Interactive selection failed: {e}")))?;
if selected_indices.is_empty() {
return Err(CliError::Other(
"No packages selected for changeset.".to_string(),
));
}
let selected_packages: Vec<String> = selected_indices
.into_iter()
.map(|i| all_packages[i].clone())
.collect();
println!("\nWhich of these packages should be a MAJOR bump?");
println!("(Select none if there are no breaking changes)");
let major_indices = MultiSelect::new()
.items(&selected_packages)
.interact()
.map_err(|e| CliError::Other(format!("Interactive selection failed: {e}")))?;
let major_set: std::collections::HashSet<usize> = major_indices.into_iter().collect();
let minor_candidates: Vec<String> = selected_packages
.iter()
.enumerate()
.filter(|(idx, _)| !major_set.contains(idx))
.map(|(_, name)| name.clone())
.collect();
let minor_indices = if !minor_candidates.is_empty() {
println!("\nWhich of these packages should be a MINOR bump?");
println!("(Any remaining packages will default to a PATCH bump)");
MultiSelect::new()
.items(&minor_candidates)
.interact()
.map_err(|e| CliError::Other(format!("Interactive selection failed: {e}")))?
} else {
Vec::new()
};
let minor_set: std::collections::HashSet<String> = minor_indices
.into_iter()
.map(|idx| minor_candidates[idx].clone())
.collect();
for (idx, pkg_name) in selected_packages.iter().enumerate() {
let severity = if major_set.contains(&idx) {
Severity::Major
} else if minor_set.contains(pkg_name) {
Severity::Minor
} else {
Severity::Patch
};
entries.push(Entry {
name: pkg_name.clone(),
severity,
});
}
if summary.is_none() {
println!("\nPlease enter a summary for this change:");
let input_summary: String = Input::new()
.interact_text()
.map_err(|e| CliError::Other(format!("Interactive prompt failed: {e}")))?;
summary = Some(input_summary);
}
let summary_text = summary.as_deref().unwrap_or("Updated package.");
let temp_changeset = Changeset {
entries: entries.clone(),
summary: summary_text.to_string(),
};
let preview_text = callisto_format::write_changeset(&temp_changeset)?;
println!("\n=== Changeset Preview ===\n{preview_text}");
let confirm = Confirm::new()
.with_prompt("Is this your desired changeset?")
.default(true)
.interact()
.map_err(|e| CliError::Other(format!("Interactive confirmation failed: {e}")))?;
if !confirm {
println!("Changeset creation cancelled.");
return Ok(ExitCode::SUCCESS);
}
} else {
return Err(CliError::NotATty);
}
let summary_text = summary.ok_or_else(|| {
CliError::Other(
"--summary is required when specifying packages via CLI flags in non-interactive mode"
.to_string(),
)
})?;
let changeset = Changeset {
entries,
summary: summary_text,
};
let text = callisto_format::write_changeset(&changeset)?;
let changeset_dir = ws.root.join(".changeset");
fs::create_dir_all(&changeset_dir)?;
let slug = generate_human_slug();
let filename = format!("{slug}.md");
let rel_path = format!(".changeset/{filename}");
let target_file = changeset_dir.join(&filename);
callisto_manifests::atomic::atomic_write(&target_file, &text)?;
match global.format {
OutputFormat::Json => {
let env = json!({
"schemaVersion": SCHEMA_VERSION,
"command": "add",
"path": rel_path
});
write_json(&mut std::io::stdout(), &env)?;
}
OutputFormat::Text => {
log_line(global.format, &format!("Added changeset: {rel_path}"));
}
}
Ok(ExitCode::SUCCESS)
}
fn generate_human_slug() -> String {
let adjectives = [
"swift", "clever", "bright", "silent", "brave", "calm", "eager", "gentle", "happy",
"jolly", "keen", "lively", "mighty", "noble", "proud", "quick", "radiant", "sharp",
"tough", "vivid",
];
let nouns = [
"foxes", "hawks", "wolves", "eagles", "bears", "lions", "otters", "pandas", "falcons",
"tigers", "dolphins", "panthers", "cheetahs", "leopards", "ravens", "badgers", "lynxes",
"cobras", "owls", "stags",
];
let verbs = [
"run", "fly", "leap", "soar", "dash", "hunt", "glide", "climb", "swim", "roar", "pounce",
"sprint", "chase", "race", "bound", "drift", "jump", "strut", "spin", "surge",
];
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let a = adjectives[(timestamp as usize) % adjectives.len()];
let n = nouns[((timestamp >> 4) as usize) % nouns.len()];
let v = verbs[((timestamp >> 8) as usize) % verbs.len()];
format!("{a}-{n}-{v}")
}