use calcit::cli_args::{
ConfigAddModuleCommand, ConfigCommand, ConfigModulesCommand, ConfigRmModuleCommand, ConfigRmTypeSlotCommand, ConfigSetCommand,
ConfigSetTypeSlotCommand, ConfigShowCommand, ConfigSubcommand, ConfigTypeSlotsCommand, ConfigVersionCommand,
};
use calcit::snapshot;
use calcit::util::string::strip_shebang;
use cirru_edn::{Edn, EdnMapView};
use colored::Colorize;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use super::edit::{bump_semver_value, load_snapshot, parse_semver_value, save_snapshot};
fn load_snapshot_for_display(input_path: &str) -> Result<snapshot::Snapshot, String> {
if !Path::new(input_path).exists() {
return Err(format!("{input_path} does not exist"));
}
let mut content = fs::read_to_string(input_path).map_err(|e| format!("Failed to read file: {e}"))?;
strip_shebang(&mut content);
let data = cirru_edn::parse(&content).map_err(|e| {
eprintln!("\nFailed to parse file '{input_path}':");
eprintln!("{e}");
format!("Failed to parse file '{input_path}'")
})?;
snapshot::load_snapshot_data(&data, input_path)
}
pub fn handle_config_command(cmd: &ConfigCommand, snapshot_file: &str) -> Result<(), String> {
match &cmd.subcommand {
ConfigSubcommand::Show(opts) => handle_show(opts, snapshot_file),
ConfigSubcommand::Modules(opts) => handle_modules(opts, snapshot_file),
ConfigSubcommand::TypeSlots(opts) => handle_type_slots(opts, snapshot_file),
ConfigSubcommand::Version(opts) => handle_version(opts, snapshot_file),
ConfigSubcommand::Set(opts) => handle_set(opts, snapshot_file),
ConfigSubcommand::AddModule(opts) => handle_add_module(opts, snapshot_file),
ConfigSubcommand::RmModule(opts) => handle_rm_module(opts, snapshot_file),
ConfigSubcommand::SetTypeSlot(opts) => handle_set_type_slot(opts, snapshot_file),
ConfigSubcommand::RmTypeSlot(opts) => handle_rm_type_slot(opts, snapshot_file),
}
}
fn format_type_slots(type_slots: &std::collections::HashMap<String, String>) -> String {
let mut pairs: Vec<String> = type_slots
.iter()
.map(|(slot, type_path)| format!(":{slot} -> {type_path}"))
.collect();
pairs.sort();
format!("{{{}}}", pairs.join(", "))
}
fn handle_show(opts: &ConfigShowCommand, input_path: &str) -> Result<(), String> {
let snapshot = load_snapshot_for_display(input_path)?;
if let Some(name) = &opts.entry {
let entry = snapshot.entries.get(name).ok_or_else(|| {
format!(
"Entry '{name}' not found. Available: {}",
snapshot.entries.keys().cloned().collect::<Vec<_>>().join(", ")
)
})?;
println!("{}", format!("Entry '{name}' Configs:").bold());
println!(" {}: {}", "init_fn".cyan(), entry.init_fn);
println!(" {}: {}", "reload_fn".cyan(), entry.reload_fn);
println!(" {}: {}", "version".cyan(), entry.version);
println!(" {}: {:?}", "modules".cyan(), entry.modules);
println!(" {}: {}", "type_slots".cyan(), format_type_slots(&entry.type_slots));
return Ok(());
}
println!("{}", "Project Configs:".bold());
println!(" {}: {}", "init_fn".cyan(), snapshot.configs.init_fn);
println!(" {}: {}", "reload_fn".cyan(), snapshot.configs.reload_fn);
println!(" {}: {}", "version".cyan(), snapshot.configs.version);
println!(" {}: {:?}", "modules".cyan(), snapshot.configs.modules);
println!(" {}: {}", "type_slots".cyan(), format_type_slots(&snapshot.configs.type_slots));
if !snapshot.entries.is_empty() {
println!("\n{}", "Snapshot Entries:".bold());
let mut names: Vec<&String> = snapshot.entries.keys().collect();
names.sort();
for name in names {
let entry = snapshot
.entries
.get(name)
.ok_or_else(|| format!("Missing entry config for '{name}'"))?;
println!(" {}", name.cyan());
println!(" {}: {}", "init_fn".cyan(), entry.init_fn);
println!(" {}: {}", "reload_fn".cyan(), entry.reload_fn);
println!(" {}: {}", "version".cyan(), entry.version);
println!(" {}: {:?}", "modules".cyan(), entry.modules);
println!(" {}: {}", "type_slots".cyan(), format_type_slots(&entry.type_slots));
}
}
Ok(())
}
fn handle_type_slots(opts: &ConfigTypeSlotsCommand, input_path: &str) -> Result<(), String> {
let snapshot = load_snapshot_for_display(input_path)?;
let (label, type_slots) = if let Some(name) = &opts.entry {
let entry = snapshot.entries.get(name).ok_or_else(|| {
format!(
"Entry '{name}' not found. Available: {}",
snapshot.entries.keys().cloned().collect::<Vec<_>>().join(", ")
)
})?;
(format!("Type slots in entry '{name}':"), &entry.type_slots)
} else {
("Type slots in project configs:".to_owned(), &snapshot.configs.type_slots)
};
println!("{}", label.bold());
if type_slots.is_empty() {
println!(" {}", "(none)".dimmed());
} else {
let mut slots: Vec<(&String, &String)> = type_slots.iter().collect();
slots.sort_by_key(|(slot, _)| *slot);
for (slot, type_path) in slots {
println!(" {} -> {}", format!(":{slot}").cyan(), type_path);
}
}
Ok(())
}
fn handle_modules(opts: &ConfigModulesCommand, input_path: &str) -> Result<(), String> {
let snapshot = load_snapshot_for_display(input_path)?;
let base_dir = Path::new(input_path).parent().unwrap_or(Path::new("."));
let module_folder = dirs::home_dir()
.map(|buf| buf.as_path().join(".config/calcit/modules/"))
.unwrap_or_else(|| Path::new(".").to_owned());
let (label, modules) = if let Some(name) = &opts.entry {
let entry = snapshot.entries.get(name).ok_or_else(|| {
format!(
"Entry '{name}' not found. Available: {}",
snapshot.entries.keys().cloned().collect::<Vec<_>>().join(", ")
)
})?;
(format!("Modules in entry '{name}':"), entry.modules.clone())
} else {
("Modules in project:".to_string(), snapshot.configs.modules.clone())
};
println!("{}", label.bold());
if opts.entry.is_none() {
println!(" {} {}", snapshot.package.cyan(), "(main)".dimmed());
}
for module_path in &modules {
match load_module_silent(module_path, base_dir, &module_folder) {
Ok(module_snapshot) => {
println!(" {} {}", module_snapshot.package.cyan(), format!("({module_path})").dimmed());
}
Err(_) => {
println!(" {} {}", module_path.yellow(), "(failed)".red());
}
}
}
if opts.entry.is_none() && !snapshot.entries.is_empty() {
println!("\n{}", "Entries:".bold());
for name in snapshot.entries.keys() {
println!(" {}", name.cyan());
}
}
Ok(())
}
fn load_module_silent(module_path: &str, base_dir: &Path, module_folder: &Path) -> Result<snapshot::Snapshot, String> {
let candidates = [
base_dir.join(module_path).join("calcit.cirru"),
base_dir.join(module_path).join("compact.cirru"),
module_folder.join(module_path).join("calcit.cirru"),
module_folder.join(module_path).join("compact.cirru"),
];
for candidate in &candidates {
if candidate.exists() {
let mut content = fs::read_to_string(candidate).map_err(|e| format!("Failed to read: {e}"))?;
strip_shebang(&mut content);
let data = cirru_edn::parse(&content).map_err(|e| format!("Failed to parse: {e}"))?;
return snapshot::load_snapshot_data(&data, &candidate.to_string_lossy());
}
}
Err(format!("Module not found: {module_path}"))
}
fn handle_version(opts: &ConfigVersionCommand, snapshot_file: &str) -> Result<(), String> {
match &opts.value {
None => {
let snapshot = load_snapshot_for_display(snapshot_file)?;
println!("{}", snapshot.configs.version);
Ok(())
}
Some(v) if matches!(v.as_str(), "patch" | "minor" | "major") => {
let mut snapshot = load_snapshot(snapshot_file)?;
let previous = snapshot.configs.version.clone();
let next = bump_semver_value(&previous, v)?;
snapshot.configs.version = next.clone();
save_snapshot(&snapshot, snapshot_file)?;
println!("{} Bumped version: {} → {}", "✓".green(), previous.yellow(), next.green());
Ok(())
}
Some(v) => {
parse_semver_value(v)?;
let mut snapshot = load_snapshot(snapshot_file)?;
snapshot.configs.version = v.clone();
save_snapshot(&snapshot, snapshot_file)?;
println!("{} Set version to {}", "✓".green(), v.green());
Ok(())
}
}
}
fn handle_set(opts: &ConfigSetCommand, snapshot_file: &str) -> Result<(), String> {
let mut snapshot = load_snapshot(snapshot_file)?;
if let Some(name) = &opts.entry
&& !snapshot.entries.contains_key(name)
{
let available: Vec<_> = snapshot.entries.keys().cloned().collect();
return Err(format!("Entry '{name}' not found. Available: {}", available.join(", ")));
}
let entry_label = opts.entry.as_deref().unwrap_or("configs");
let configs = match &opts.entry {
Some(name) => snapshot.entries.get_mut(name).unwrap(),
None => &mut snapshot.configs,
};
let message = match opts.key.as_str() {
"init-fn" | "init_fn" => {
configs.init_fn = opts.value.clone();
format!("{} Set [{entry_label}] '{}' = '{}'", "✓".green(), opts.key.cyan(), opts.value)
}
"reload-fn" | "reload_fn" => {
configs.reload_fn = opts.value.clone();
format!("{} Set [{entry_label}] '{}' = '{}'", "✓".green(), opts.key.cyan(), opts.value)
}
"version" => {
if matches!(opts.value.as_str(), "patch" | "minor" | "major") {
let previous = configs.version.clone();
let next = bump_semver_value(&previous, &opts.value)?;
configs.version = next.clone();
format!(
"{} Bumped [{entry_label}] version: {} → {}",
"✓".green(),
previous.yellow(),
next.green()
)
} else {
parse_semver_value(&opts.value)?;
configs.version = opts.value.clone();
format!("{} Set [{entry_label}] '{}' = '{}'", "✓".green(), opts.key.cyan(), opts.value)
}
}
_ => {
return Err(format!(
"Unknown config key '{}'. Valid keys: init-fn, reload-fn, version (accepts semver string or patch|minor|major)",
opts.key
));
}
};
save_snapshot(&snapshot, snapshot_file)?;
println!("{message}");
Ok(())
}
fn handle_add_module(opts: &ConfigAddModuleCommand, snapshot_file: &str) -> Result<(), String> {
let mut snapshot = load_snapshot(snapshot_file)?;
if let Some(name) = &opts.entry
&& !snapshot.entries.contains_key(name)
{
let available: Vec<_> = snapshot.entries.keys().cloned().collect();
return Err(format!("Entry '{name}' not found. Available: {}", available.join(", ")));
}
let configs = match &opts.entry {
Some(name) => snapshot.entries.get_mut(name).unwrap(),
None => &mut snapshot.configs,
};
if configs.modules.contains(&opts.module_path) {
return Err(format!("Module '{}' already exists", opts.module_path));
}
configs.modules.push(opts.module_path.clone());
save_snapshot(&snapshot, snapshot_file)?;
let scope = opts.entry.as_deref().unwrap_or("configs");
println!("{} Added module '{}' to [{scope}]", "✓".green(), opts.module_path.cyan());
Ok(())
}
fn handle_rm_module(opts: &ConfigRmModuleCommand, snapshot_file: &str) -> Result<(), String> {
let mut snapshot = load_snapshot(snapshot_file)?;
if let Some(name) = &opts.entry
&& !snapshot.entries.contains_key(name)
{
let available: Vec<_> = snapshot.entries.keys().cloned().collect();
return Err(format!("Entry '{name}' not found. Available: {}", available.join(", ")));
}
let configs = match &opts.entry {
Some(name) => snapshot.entries.get_mut(name).unwrap(),
None => &mut snapshot.configs,
};
let original_len = configs.modules.len();
configs.modules.retain(|m| m != &opts.module_path);
if configs.modules.len() == original_len {
return Err(format!("Module '{}' not found", opts.module_path));
}
save_snapshot(&snapshot, snapshot_file)?;
let scope = opts.entry.as_deref().unwrap_or("configs");
println!("{} Removed module '{}' from [{scope}]", "✓".green(), opts.module_path.cyan());
Ok(())
}
fn normalize_type_slot_name(raw: &str) -> Result<String, String> {
let slot = raw.trim().trim_start_matches(':');
if slot.is_empty() {
Err("Type slot name cannot be empty".to_owned())
} else {
Ok(slot.to_owned())
}
}
fn select_configs_mut<'a>(
snapshot: &'a mut snapshot::Snapshot,
entry: Option<&str>,
) -> Result<&'a mut snapshot::SnapshotConfigs, String> {
match entry {
Some(name) => {
let available = snapshot.entries.keys().cloned().collect::<Vec<_>>().join(", ");
snapshot
.entries
.get_mut(name)
.ok_or_else(|| format!("Entry '{name}' not found. Available: {available}"))
}
None => Ok(&mut snapshot.configs),
}
}
fn find_map_value_mut<'a>(map: &'a mut EdnMapView, key: &str) -> Option<&'a mut Edn> {
let tag_key = Edn::tag(key);
if map.0.contains_key(&tag_key) {
return map.0.get_mut(&tag_key);
}
map.0.get_mut(&Edn::str(key))
}
fn type_slots_as_edn(type_slots: &HashMap<String, String>) -> Edn {
let mut slots = EdnMapView::default();
for (slot, type_path) in type_slots {
let value = if type_path == ":dynamic" {
Edn::tag("dynamic")
} else {
Edn::str(type_path.as_str())
};
slots.insert_key(slot.as_str(), value);
}
slots.into()
}
fn save_type_slots_preserving_snapshot(
snapshot_file: &str,
entry: Option<&str>,
type_slots: &HashMap<String, String>,
) -> Result<(), String> {
let original = fs::read_to_string(snapshot_file).map_err(|e| format!("Failed to read {snapshot_file}: {e}"))?;
let shebang = original.lines().next().filter(|line| line.starts_with("#!")).map(str::to_owned);
let mut content = original;
strip_shebang(&mut content);
let mut data = cirru_edn::parse(&content).map_err(|e| format!("Failed to parse EDN: {e}"))?;
let Edn::Map(root) = &mut data else {
return Err("Snapshot root must be an EDN map".to_owned());
};
let configs = if let Some(entry_name) = entry {
let entries_value = find_map_value_mut(root, "entries").ok_or_else(|| "Snapshot is missing :entries".to_owned())?;
let Edn::Map(entries) = entries_value else {
return Err("Snapshot :entries must be an EDN map".to_owned());
};
let entry_value = find_map_value_mut(entries, entry_name).ok_or_else(|| format!("Entry '{entry_name}' not found"))?;
let Edn::Map(entry_configs) = entry_value else {
return Err(format!("Entry '{entry_name}' config must be an EDN map"));
};
entry_configs
} else {
let configs_value = find_map_value_mut(root, "configs").ok_or_else(|| "Snapshot is missing :configs".to_owned())?;
let Edn::Map(configs) = configs_value else {
return Err("Snapshot :configs must be an EDN map".to_owned());
};
configs
};
configs.insert_key("type-slots", type_slots_as_edn(type_slots));
let formatted = cirru_edn::format(&data, true).map_err(|e| format!("Failed to format snapshot EDN: {e}"))?;
let output = match shebang {
Some(line) => format!("{line}\n{formatted}"),
None => formatted,
};
fs::write(snapshot_file, output).map_err(|e| format!("Failed to write {snapshot_file}: {e}"))
}
fn handle_set_type_slot(opts: &ConfigSetTypeSlotCommand, snapshot_file: &str) -> Result<(), String> {
let slot = normalize_type_slot_name(&opts.slot)?;
let type_path = opts.type_path.trim();
if !matches!(type_path, ":dynamic" | "dynamic") {
let Some((ns, def)) = type_path.rsplit_once('/') else {
return Err(format!(
"Type slot binding must use a full `namespace/definition` path or `:dynamic`, got `{type_path}`"
));
};
if ns.is_empty() || def.is_empty() {
return Err(format!(
"Type slot binding must use a full `namespace/definition` path or `:dynamic`, got `{type_path}`"
));
}
}
let mut snapshot = load_snapshot(snapshot_file)?;
let scope = opts.entry.as_deref().unwrap_or("configs");
let configs = select_configs_mut(&mut snapshot, opts.entry.as_deref())?;
let normalized_type = if type_path == "dynamic" { ":dynamic" } else { type_path };
let previous = configs.type_slots.insert(slot.clone(), normalized_type.to_owned());
save_type_slots_preserving_snapshot(snapshot_file, opts.entry.as_deref(), &configs.type_slots)?;
if let Some(previous) = previous {
println!(
"{} Updated type slot ':{}' in [{scope}]: {} -> {}",
"✓".green(),
slot.cyan(),
previous.yellow(),
normalized_type.green()
);
} else {
println!(
"{} Bound type slot ':{}' to '{}' in [{scope}]",
"✓".green(),
slot.cyan(),
normalized_type.green()
);
}
Ok(())
}
fn handle_rm_type_slot(opts: &ConfigRmTypeSlotCommand, snapshot_file: &str) -> Result<(), String> {
let slot = normalize_type_slot_name(&opts.slot)?;
let mut snapshot = load_snapshot(snapshot_file)?;
let scope = opts.entry.as_deref().unwrap_or("configs");
let configs = select_configs_mut(&mut snapshot, opts.entry.as_deref())?;
let Some(previous) = configs.type_slots.remove(&slot) else {
return Err(format!("Type slot ':{slot}' is not bound in [{scope}]"));
};
save_type_slots_preserving_snapshot(snapshot_file, opts.entry.as_deref(), &configs.type_slots)?;
println!(
"{} Removed type slot ':{}' ({}) from [{scope}]",
"✓".green(),
slot.cyan(),
previous.yellow()
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn type_slot_edn_mutation_preserves_unrelated_snapshot_data() {
let source = r#"{} (:package |demo)
:configs $ {} (:init-fn |app.main/main!) (:reload-fn |app.main/reload!) (:version |0.0.1)
:modules $ []
:entries $ {}
:files $ {}
|app.main $ %{} :FileEntry
:defs $ {}
|f $ %{} :CodeEntry (:schema $ :: :fn $ {} (:rest :any) (:return $ :: :list :any))
:code $ quote (defn f (x) x)
:ns $ %{} :CodeEntry (:code $ quote $ ns app.main)
"#;
let before = cirru_edn::parse(source).expect("source snapshot");
let temp_path = std::env::temp_dir().join(format!("calcit-type-slots-preserve-{}.cirru", std::process::id()));
fs::write(&temp_path, source).expect("write fixture");
let slots = HashMap::from([("dispatch-op".to_owned(), "app.schema/Op".to_owned())]);
save_type_slots_preserving_snapshot(&temp_path.to_string_lossy(), None, &slots).expect("save type slots");
let after_text = fs::read_to_string(&temp_path).expect("read output");
let after = cirru_edn::parse(&after_text).expect("output snapshot");
fs::remove_file(&temp_path).expect("remove fixture");
let Edn::Map(before_root) = before else { panic!("before root") };
let Edn::Map(after_root) = after else { panic!("after root") };
let read_definition_field = |root: &EdnMapView, field: &str| {
let Edn::Map(files) = root.get_or_nil("files") else {
panic!("files")
};
let Edn::Record(file) = files.get_or_nil("app.main") else {
panic!("app.main")
};
let Edn::Map(defs) = file["defs"].clone() else { panic!("defs") };
let Edn::Record(definition) = defs.get_or_nil("f") else {
panic!("f")
};
definition[field].clone()
};
assert_eq!(
read_definition_field(&before_root, "code"),
read_definition_field(&after_root, "code")
);
assert_eq!(
read_definition_field(&before_root, "schema"),
read_definition_field(&after_root, "schema")
);
let Edn::Map(configs) = after_root.get_or_nil("configs") else {
panic!("configs")
};
let Edn::Map(saved_slots) = configs.get_or_nil("type-slots") else {
panic!("type slots")
};
assert_eq!(saved_slots.get_or_nil("dispatch-op"), Edn::str("app.schema/Op"));
}
}