use std::borrow::Cow;
use std::collections::HashSet;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex, OnceLock};
use anyhow::Context;
use color_print::cformat;
use minijinja::Environment;
use shell_escape::unix::escape;
use crate::config::WorktrunkConfig;
use crate::shell_exec::Cmd;
use crate::styling::{
eprintln, format_with_gutter, hint_message, info_message, suggest_command_in_dir,
warning_message,
};
static WARNED_DEPRECATED_PATHS: LazyLock<Mutex<HashSet<PathBuf>>> =
LazyLock::new(|| Mutex::new(HashSet::new()));
static DEPRECATION_HINT_EMITTED: OnceLock<()> = OnceLock::new();
static SUPPRESS_WARNINGS: OnceLock<()> = OnceLock::new();
pub fn suppress_warnings() {
let _ = SUPPRESS_WARNINGS.set(());
}
fn warnings_suppressed() -> bool {
SUPPRESS_WARNINGS.get().is_some()
}
static WARNED_UNKNOWN_PATHS: LazyLock<Mutex<HashSet<PathBuf>>> =
LazyLock::new(|| Mutex::new(HashSet::new()));
const DEPRECATED_VARS: &[(&str, &str)] = &[
("repo_root", "repo_path"),
("worktree", "worktree_path"),
("main_worktree", "repo"),
("main_worktree_path", "primary_worktree_path"),
];
#[derive(Debug)]
pub struct DeprecatedSection {
pub key: &'static str,
pub canonical_top_key: &'static str,
pub canonical_display: &'static str,
}
pub const DEPRECATED_SECTION_KEYS: &[DeprecatedSection] = &[
DeprecatedSection {
key: "commit-generation",
canonical_top_key: "commit",
canonical_display: "[commit.generation]",
},
DeprecatedSection {
key: "select",
canonical_top_key: "switch",
canonical_display: "[switch.picker]",
},
DeprecatedSection {
key: "ci",
canonical_top_key: "forge",
canonical_display: "[forge]",
},
];
pub fn normalize_template_vars(template: &str) -> Cow<'_, str> {
let replacements = deprecated_vars_in_template(template);
if replacements.is_empty() {
return Cow::Borrowed(template);
}
rewrite_template_var_identifiers(template, &replacements)
.map(Cow::Owned)
.unwrap_or(Cow::Borrowed(template))
}
fn deprecated_vars_in_template(template: &str) -> Vec<(&'static str, &'static str)> {
if !DEPRECATED_VARS
.iter()
.any(|(old, _)| template.contains(old))
{
return Vec::new();
}
let env = Environment::new();
let Ok(parsed) = env.template_from_str(template) else {
return Vec::new();
};
let used_vars = parsed.undeclared_variables(false);
DEPRECATED_VARS
.iter()
.copied()
.filter(|(old, _)| used_vars.contains(*old))
.collect()
}
fn rewrite_template_var_identifiers(
template: &str,
replacements: &[(&str, &'static str)],
) -> Option<String> {
let mut out = String::with_capacity(template.len());
let mut cursor = 0;
let mut changed = false;
let mut in_raw = false;
while let Some((tag_start, tag_kind)) = find_next_template_tag(template, cursor) {
out.push_str(&template[cursor..tag_start]);
let (body_start, close_delim) = match tag_kind {
TemplateTagKind::Variable => (tag_start + 2, "}}"),
TemplateTagKind::Block => (tag_start + 2, "%}"),
TemplateTagKind::Comment => {
let end = template[tag_start + 2..].find("#}")? + tag_start + 4;
out.push_str(&template[tag_start..end]);
cursor = end;
continue;
}
};
let tag_end = template[body_start..].find(close_delim)? + body_start;
let full_tag_end = tag_end + close_delim.len();
if tag_kind == TemplateTagKind::Block
&& matches!(
template_block_name(&template[body_start..tag_end]),
Some("raw")
)
{
in_raw = true;
}
if in_raw {
out.push_str(&template[tag_start..full_tag_end]);
if tag_kind == TemplateTagKind::Block
&& matches!(
template_block_name(&template[body_start..tag_end]),
Some("endraw")
)
{
in_raw = false;
}
} else {
let body_start =
body_start + usize::from(template[body_start..tag_end].starts_with('-'));
let body_end = tag_end - usize::from(template[body_start..tag_end].ends_with('-'));
let (rewritten_body, body_changed) =
rewrite_template_tag_body(&template[body_start..body_end], replacements);
out.push_str(&template[tag_start..body_start]);
out.push_str(&rewritten_body);
out.push_str(&template[body_end..full_tag_end]);
changed |= body_changed;
}
cursor = full_tag_end;
}
out.push_str(&template[cursor..]);
changed.then_some(out)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum TemplateTagKind {
Variable,
Block,
Comment,
}
fn find_next_template_tag(template: &str, from: usize) -> Option<(usize, TemplateTagKind)> {
let mut search_from = from;
loop {
let rel = template[search_from..].find('{')?;
let idx = search_from + rel;
let rest = &template[idx..];
let kind = if rest.starts_with("{{") {
TemplateTagKind::Variable
} else if rest.starts_with("{%") {
TemplateTagKind::Block
} else if rest.starts_with("{#") {
TemplateTagKind::Comment
} else {
search_from = idx + 1;
continue;
};
return Some((idx, kind));
}
}
fn template_block_name(body: &str) -> Option<&str> {
let body = body.strip_prefix('-').unwrap_or(body).trim_start();
let end = body
.find(|c: char| !is_template_identifier_char(c))
.unwrap_or(body.len());
(end > 0).then_some(&body[..end])
}
fn rewrite_template_tag_body(body: &str, replacements: &[(&str, &'static str)]) -> (String, bool) {
let mut out = String::with_capacity(body.len());
let mut cursor = 0;
let mut changed = false;
while let Some(ch) = body.get(cursor..).and_then(|s| s.chars().next()) {
if ch == '"' || ch == '\'' {
let end = quoted_template_string_end(body, cursor, ch);
out.push_str(&body[cursor..end]);
cursor = end;
} else if is_template_identifier_start(ch) {
let end = identifier_end(body, cursor);
let ident = &body[cursor..end];
if !is_template_attribute_or_assignment(body, cursor, end)
&& let Some((_, new)) = replacements.iter().find(|(old, _)| *old == ident)
{
out.push_str(new);
changed = true;
} else {
out.push_str(ident);
}
cursor = end;
} else {
out.push(ch);
cursor += ch.len_utf8();
}
}
(out, changed)
}
fn quoted_template_string_end(body: &str, start: usize, quote: char) -> usize {
let mut escaped = false;
let mut cursor = start + quote.len_utf8();
while let Some(ch) = body.get(cursor..).and_then(|s| s.chars().next()) {
cursor += ch.len_utf8();
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == quote {
break;
}
}
cursor
}
fn identifier_end(body: &str, start: usize) -> usize {
let mut cursor = start;
while let Some(ch) = body.get(cursor..).and_then(|s| s.chars().next()) {
if !is_template_identifier_char(ch) {
break;
}
cursor += ch.len_utf8();
}
cursor
}
fn is_template_attribute_or_assignment(body: &str, start: usize, end: usize) -> bool {
let previous = body[..start].chars().rev().find(|c| !c.is_whitespace());
if previous == Some('.') {
return true;
}
let next = body[end..].trim_start();
next.starts_with('=') && !next.starts_with("==")
}
fn is_template_identifier_start(ch: char) -> bool {
ch == '_' || ch.is_ascii_alphabetic()
}
fn is_template_identifier_char(ch: char) -> bool {
ch == '_' || ch.is_ascii_alphanumeric()
}
fn migrate_template_vars_doc(doc: &mut toml_edit::DocumentMut) -> Deprecations {
type Replaced = HashSet<(&'static str, &'static str)>;
fn walk_table(table: &mut toml_edit::Table, replaced: &mut Replaced) {
for (_, item) in table.iter_mut() {
walk_item(item, replaced);
}
}
fn walk_item(item: &mut toml_edit::Item, replaced: &mut Replaced) {
match item {
toml_edit::Item::Value(v) => walk_value(v, replaced),
toml_edit::Item::Table(t) => walk_table(t, replaced),
toml_edit::Item::ArrayOfTables(arr) => {
for t in arr.iter_mut() {
walk_table(t, replaced);
}
}
_ => {}
}
}
fn walk_value(value: &mut toml_edit::Value, replaced: &mut Replaced) {
match value {
toml_edit::Value::String(s) => {
let pairs = deprecated_vars_in_template(s.value());
if pairs.is_empty() {
return;
}
if let Some(new) = rewrite_template_var_identifiers(s.value(), &pairs) {
let decor = s.decor().clone();
let mut formatted = toml_edit::Formatted::new(new);
*formatted.decor_mut() = decor;
*value = toml_edit::Value::String(formatted);
replaced.extend(pairs);
}
}
toml_edit::Value::Array(arr) => {
for v in arr.iter_mut() {
walk_value(v, replaced);
}
}
toml_edit::Value::InlineTable(t) => {
for (_, v) in t.iter_mut() {
walk_value(v, replaced);
}
}
_ => {}
}
}
let mut replaced = Replaced::new();
walk_table(doc.as_table_mut(), &mut replaced);
DEPRECATED_VARS
.iter()
.filter(|pair| replaced.contains(*pair))
.map(|&(old, new)| DeprecationKind::TemplateVar { old, new })
.collect()
}
#[derive(Debug, Default, Clone)]
pub struct CommitGenerationDeprecations {
pub has_top_level: bool,
pub project_keys: Vec<String>,
}
impl CommitGenerationDeprecations {
pub fn is_empty(&self) -> bool {
!self.has_top_level && self.project_keys.is_empty()
}
}
#[derive(Debug, Clone)]
pub enum DeprecationKind {
TemplateVar {
old: &'static str,
new: &'static str,
},
CommitGeneration(CommitGenerationDeprecations),
ApprovedCommands,
Select,
CiSection,
NoFf,
NoCd,
SwitchPickerTimeout,
}
pub type Deprecations = Vec<DeprecationKind>;
type MigrateFn = fn(&mut toml_edit::DocumentMut) -> Deprecations;
type SilentMigrateFn = fn(&mut toml_edit::DocumentMut) -> bool;
enum DeprecationRule {
Structural(MigrateFn),
UpdateOnly(MigrateFn),
Silent(SilentMigrateFn),
}
const DEPRECATION_RULES: &[DeprecationRule] = &[
DeprecationRule::UpdateOnly(migrate_template_vars_doc),
DeprecationRule::Structural(migrate_commit_generation_doc),
DeprecationRule::UpdateOnly(remove_approved_commands_doc),
DeprecationRule::Structural(|doc| {
if for_each_config_table_mut(doc, |_, table| migrate_select_table(table)) {
vec![DeprecationKind::Select]
} else {
Vec::new()
}
}),
DeprecationRule::Silent(|doc| {
let pre = rename_hook_key(doc, "pre-create", "pre-start");
let post = rename_hook_key(doc, "post-create", "post-start");
pre || post
}),
DeprecationRule::Structural(migrate_ci_doc),
DeprecationRule::Structural(|doc| {
migrate_negated_bool_doc(doc, "merge", "no-ff", "ff", DeprecationKind::NoFf)
}),
DeprecationRule::Structural(|doc| {
migrate_negated_bool_doc(doc, "switch", "no-cd", "cd", DeprecationKind::NoCd)
}),
DeprecationRule::Structural(|doc| {
if for_each_config_table_mut(doc, |_, table| remove_switch_picker_timeout_in(table)) {
vec![DeprecationKind::SwitchPickerTimeout]
} else {
Vec::new()
}
}),
];
pub fn detect_deprecations(content: &str) -> Deprecations {
let Ok(doc) = content.parse::<toml_edit::DocumentMut>() else {
return Vec::new();
};
detect_deprecations_from_doc(&doc)
}
fn detect_deprecations_from_doc(doc: &toml_edit::DocumentMut) -> Deprecations {
let mut scratch = doc.clone();
let mut kinds = Vec::new();
apply_rules(&mut scratch, true, &mut kinds);
kinds
}
fn apply_rules(
doc: &mut toml_edit::DocumentMut,
include_update_only: bool,
kinds: &mut Deprecations,
) -> bool {
let mut modified = false;
for rule in DEPRECATION_RULES {
match rule {
DeprecationRule::Structural(migrate) => {
let new_kinds = migrate(doc);
modified |= !new_kinds.is_empty();
kinds.extend(new_kinds);
}
DeprecationRule::UpdateOnly(migrate) => {
if include_update_only {
let new_kinds = migrate(doc);
modified |= !new_kinds.is_empty();
kinds.extend(new_kinds);
}
}
DeprecationRule::Silent(migrate) => modified |= migrate(doc),
}
}
modified
}
fn for_each_config_table_mut(
doc: &mut toml_edit::DocumentMut,
mut f: impl FnMut(Option<&str>, &mut toml_edit::Table) -> bool,
) -> bool {
let mut modified = f(None, doc.as_table_mut());
if let Some(projects) = doc.get_mut("projects").and_then(|p| p.as_table_mut()) {
for (key, value) in projects.iter_mut() {
if let Some(table) = value.as_table_mut() {
modified |= f(Some(key.get()), table);
}
}
}
modified
}
fn migrate_commit_generation_doc(doc: &mut toml_edit::DocumentMut) -> Deprecations {
let mut found = CommitGenerationDeprecations::default();
for_each_config_table_mut(doc, |scope, table| {
let migrated = migrate_commit_generation_in(table);
if migrated {
match scope {
None => found.has_top_level = true,
Some(key) => found.project_keys.push(key.to_string()),
}
}
migrated
});
if found.is_empty() {
Vec::new()
} else {
vec![DeprecationKind::CommitGeneration(found)]
}
}
fn is_table_like(item: &toml_edit::Item) -> bool {
matches!(
item,
toml_edit::Item::Table(_) | toml_edit::Item::Value(toml_edit::Value::InlineTable(_))
)
}
fn is_nonempty_table_like(item: &toml_edit::Item) -> bool {
table_like_len(item).is_some_and(|len| len > 0)
}
fn can_host_subtable(item: Option<&toml_edit::Item>) -> bool {
item.is_none_or(is_table_like)
}
fn has_table_like_child(item: Option<&toml_edit::Item>, key: &str) -> bool {
match item {
Some(toml_edit::Item::Table(t)) => t.get(key).is_some_and(is_table_like),
Some(toml_edit::Item::Value(toml_edit::Value::InlineTable(t))) => t
.get(key)
.is_some_and(|v| matches!(v, toml_edit::Value::InlineTable(_))),
_ => false,
}
}
fn ensure_standard_table_parent<'a>(
table: &'a mut toml_edit::Table,
key: &str,
) -> Option<&'a mut toml_edit::Table> {
if !table.contains_key(key) {
let mut parent = toml_edit::Table::new();
parent.set_implicit(true);
table.insert(key, toml_edit::Item::Table(parent));
}
let item = table.get_mut(key)?;
if let Some(inline) = item.as_inline_table().cloned() {
*item = toml_edit::Item::Table(inline.into_table());
}
item.as_table_mut()
}
fn into_table(item: toml_edit::Item) -> Option<toml_edit::Table> {
match item {
toml_edit::Item::Table(t) => Some(t),
toml_edit::Item::Value(toml_edit::Value::InlineTable(it)) => Some(it.into_table()),
_ => None,
}
}
fn migrate_commit_generation_in(table: &mut toml_edit::Table) -> bool {
if has_table_like_child(table.get("commit"), "generation")
|| !table
.get("commit-generation")
.is_some_and(is_nonempty_table_like)
|| !can_host_subtable(table.get("commit"))
{
return false;
}
let Some(old_section) = table.remove("commit-generation") else {
return false;
};
let mut generation = into_table(old_section).expect("checked is_nonempty_table_like above");
merge_args_into_command(&mut generation);
if let Some(commit_table) = ensure_standard_table_parent(table, "commit") {
commit_table.insert("generation", toml_edit::Item::Table(generation));
}
true
}
fn remove_approved_commands_doc(doc: &mut toml_edit::DocumentMut) -> Deprecations {
let mut modified = false;
if let Some(projects) = doc.get_mut("projects").and_then(|p| p.as_table_mut()) {
let remove_from: Vec<String> = projects
.iter()
.filter(|(_, project_value)| {
project_value.as_table().is_some_and(|t| {
t.get("approved-commands")
.and_then(|a| a.as_array())
.is_some_and(|a| !a.is_empty())
})
})
.map(|(key, _)| key.to_string())
.collect();
for key in &remove_from {
let project_table = projects
.get_mut(key)
.and_then(|v| v.as_table_mut())
.expect("selected as a table above");
project_table.remove("approved-commands");
modified = true;
if project_table.is_empty() {
projects.remove(key);
}
}
}
if modified
&& doc
.get("projects")
.and_then(|p| p.as_table())
.is_some_and(|t| t.is_empty())
{
doc.remove("projects");
}
if modified {
vec![DeprecationKind::ApprovedCommands]
} else {
Vec::new()
}
}
fn migrate_select_table(table: &mut toml_edit::Table) -> bool {
let has_new_section = has_table_like_child(table.get("switch"), "picker");
if has_new_section {
return false;
}
if !table.get("select").is_some_and(is_nonempty_table_like) {
return false;
}
if !can_host_subtable(table.get("switch")) {
return false;
}
let select_table =
into_table(table.remove("select").unwrap()).expect("checked is_nonempty_table_like above");
if let Some(switch_table) = ensure_standard_table_parent(table, "switch") {
switch_table.insert("picker", toml_edit::Item::Table(select_table));
}
true
}
fn table_like_len(item: &toml_edit::Item) -> Option<usize> {
match item {
toml_edit::Item::Table(t) => Some(t.len()),
toml_edit::Item::Value(toml_edit::Value::InlineTable(t)) => Some(t.len()),
_ => None,
}
}
fn migrate_ci_doc(doc: &mut toml_edit::DocumentMut) -> Deprecations {
if doc.get("forge").is_some() {
return Vec::new();
}
let Some(ci_table) = doc.get_mut("ci").and_then(|ci| ci.as_table_mut()) else {
return Vec::new();
};
if ci_table
.get("platform")
.is_none_or(|p| p.as_str().is_none_or(str::is_empty))
{
return Vec::new();
}
let (key, item) = ci_table
.remove_entry("platform")
.expect("checked platform exists above");
let mut forge_table = toml_edit::Table::new();
forge_table.insert_formatted(&key, item);
forge_table.set_position(ci_table.position());
if ci_table.is_empty() {
*forge_table.decor_mut() = ci_table.decor().clone();
doc.remove("ci");
}
doc.insert("forge", toml_edit::Item::Table(forge_table));
vec![DeprecationKind::CiSection]
}
fn migrate_negated_bool(table: &mut toml_edit::Table, old_key: &str, new_key: &str) -> bool {
let Some(old_val) = table
.get(old_key)
.and_then(|v| v.as_value())
.and_then(|v| v.as_bool())
else {
return false;
};
table.remove(old_key);
if !table.contains_key(new_key) {
table.insert(new_key, toml_edit::value(!old_val));
}
true
}
fn migrate_negated_bool_doc(
doc: &mut toml_edit::DocumentMut,
section: &str,
old_key: &str,
new_key: &str,
kind: DeprecationKind,
) -> Deprecations {
if for_each_config_table_mut(doc, |_, scope| {
scope
.get_mut(section)
.and_then(|s| s.as_table_mut())
.is_some_and(|table| migrate_negated_bool(table, old_key, new_key))
}) {
vec![kind]
} else {
Vec::new()
}
}
fn migrate_content_doc(doc: &mut toml_edit::DocumentMut) -> bool {
apply_rules(doc, false, &mut Vec::new())
}
fn rename_hook_key(doc: &mut toml_edit::DocumentMut, old_key: &str, new_key: &str) -> bool {
let mut modified = false;
if doc.get(new_key).is_none()
&& let Some(value) = doc.remove(old_key)
{
doc.insert(new_key, value);
modified = true;
}
if let Some(projects) = doc.get_mut("projects").and_then(|p| p.as_table_mut()) {
for (_key, project_value) in projects.iter_mut() {
if let Some(project_table) = project_value.as_table_mut()
&& project_table.get(new_key).is_none()
&& let Some(value) = project_table.remove(old_key)
{
project_table.insert(new_key, value);
modified = true;
}
}
}
modified
}
fn remove_switch_picker_timeout_in(table: &mut toml_edit::Table) -> bool {
let Some(picker) = table
.get_mut("switch")
.and_then(|s| s.as_table_mut())
.and_then(|t| t.get_mut("picker"))
else {
return false;
};
match picker {
toml_edit::Item::Table(t) => t.remove("timeout-ms").is_some(),
toml_edit::Item::Value(toml_edit::Value::InlineTable(it)) => {
it.remove("timeout-ms").is_some()
}
_ => false,
}
}
fn migrate_content_from_doc(content: &str, mut doc: toml_edit::DocumentMut) -> String {
if migrate_content_doc(&mut doc) {
doc.to_string()
} else {
content.to_string()
}
}
pub fn migrate_content(content: &str) -> String {
let Ok(doc) = content.parse::<toml_edit::DocumentMut>() else {
return content.to_string();
};
migrate_content_from_doc(content, doc)
}
pub fn copy_approved_commands_to_approvals_file(
config_path: &Path,
) -> anyhow::Result<Option<PathBuf>> {
let approvals_path = config_path.with_file_name("approvals.toml");
let _lock = super::user::mutation::acquire_config_lock(&approvals_path)?;
if approvals_path.exists() {
validate_existing_approvals_file(&approvals_path)?;
return Ok(None); }
let approvals =
super::approvals::Approvals::load_from_config_file(config_path).with_context(|| {
format!(
"Failed to read approved-commands from {} for migration",
config_path.display()
)
})?;
if approvals.projects().next().is_none() {
return Ok(None); }
approvals.save_to(&approvals_path).with_context(|| {
format!(
"Failed to write migrated approvals to {}",
approvals_path.display()
)
})?;
Ok(Some(approvals_path))
}
fn validate_existing_approvals_file(approvals_path: &Path) -> anyhow::Result<()> {
let content = std::fs::read_to_string(approvals_path).with_context(|| {
format!(
"Failed to read existing approvals file {}",
crate::path::format_path_for_display(approvals_path)
)
})?;
toml::from_str::<super::approvals::Approvals>(&content).with_context(|| {
format!(
"Failed to parse existing approvals file {}",
crate::path::format_path_for_display(approvals_path)
)
})?;
Ok(())
}
fn merge_args_into_command(table: &mut toml_edit::Table) {
let can_merge = table
.get("args")
.and_then(|a| a.as_array())
.is_some_and(|a| a.iter().all(|v| v.as_str().is_some()))
&& table
.get("command")
.and_then(|c| c.as_value())
.is_some_and(|v| v.as_str().is_some());
if !can_merge {
return;
}
let args = table.remove("args").unwrap();
let args_array = args.as_array().unwrap();
let command = table
.get_mut("command")
.and_then(|c| c.as_value_mut())
.unwrap();
let cmd_str = command.as_str().unwrap();
let args_str: Vec<&str> = args_array.iter().filter_map(|a| a.as_str()).collect();
if !args_str.is_empty() {
let new_command = if cmd_str.is_empty() {
shell_join(&args_str)
} else {
format!("{} {}", cmd_str, shell_join(&args_str))
};
*command = toml_edit::Value::from(new_command);
}
}
fn shell_join(args: &[&str]) -> String {
args.iter()
.map(|arg| escape(Cow::Borrowed(*arg)).into_owned())
.collect::<Vec<_>>()
.join(" ")
}
#[derive(Debug)]
pub struct DeprecationInfo {
pub config_path: PathBuf,
pub deprecations: Deprecations,
pub label: String,
pub main_worktree_path: Option<PathBuf>,
}
impl DeprecationInfo {
pub fn has_deprecations(&self) -> bool {
!self.deprecations.is_empty()
}
}
#[derive(Debug)]
pub struct CheckAndMigrateResult {
pub info: Option<DeprecationInfo>,
pub migrated_content: String,
}
pub fn check_and_migrate(
path: &Path,
content: &str,
warn_and_migrate: bool,
label: &str,
repo: Option<&crate::git::Repository>,
emit_inline_warnings: bool,
) -> anyhow::Result<CheckAndMigrateResult> {
let (deprecations, migrated_content) = match content.parse::<toml_edit::DocumentMut>() {
Ok(doc) => {
let deprecations = detect_deprecations_from_doc(&doc);
let migrated_content = migrate_content_from_doc(content, doc);
(deprecations, migrated_content)
}
Err(_) => (Vec::new(), content.to_string()),
};
if deprecations.is_empty() {
return Ok(CheckAndMigrateResult {
info: None,
migrated_content,
});
}
let info = DeprecationInfo {
config_path: path.to_path_buf(),
deprecations,
label: label.to_string(),
main_worktree_path: if !warn_and_migrate {
repo.and_then(|r| r.repo_path().ok())
.map(|p| p.to_path_buf())
} else {
None
},
};
if !warn_and_migrate {
return Ok(CheckAndMigrateResult {
info: Some(info),
migrated_content,
});
}
let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
{
let mut guard = WARNED_DEPRECATED_PATHS
.lock()
.map_err(|e| anyhow::anyhow!("failed to lock deprecation warning tracker: {e}"))?;
if guard.contains(&canonical_path) {
return Ok(CheckAndMigrateResult {
info: Some(info),
migrated_content,
});
}
guard.insert(canonical_path);
}
if emit_inline_warnings && !warnings_suppressed() {
let warnings = format_deprecation_warnings(&info);
if !warnings.is_empty() {
eprint!("{warnings}");
if DEPRECATION_HINT_EMITTED.set(()).is_ok() {
eprintln!(
"{}",
hint_message(cformat!(
"To see details, run <underline>wt config show</>; to apply updates, run <underline>wt config update</>"
))
);
}
std::io::stderr().flush().ok();
}
}
Ok(CheckAndMigrateResult {
info: Some(info),
migrated_content,
})
}
pub fn compute_migrated_content(content: &str) -> String {
let mut doc = content
.parse::<toml_edit::DocumentMut>()
.expect("compute_migrated_content called with content that failed TOML parse; callers must funnel through check_and_migrate first");
if apply_rules(&mut doc, true, &mut Vec::new()) {
doc.to_string()
} else {
content.to_string()
}
}
pub fn format_migration_diff(original: &str, migrated: &str, label: &str) -> Option<String> {
let dir = tempfile::tempdir().expect("failed to create tempdir for migration diff");
let subdir = dir.path().join(label);
std::fs::create_dir(&subdir).expect("failed to create subdir in fresh tempdir");
let current = subdir.join("current");
let migrated_path = subdir.join("migrated");
std::fs::write(¤t, original).expect("failed to write current config to tempfile");
std::fs::write(&migrated_path, migrated).expect("failed to write migrated config to tempfile");
let output = Cmd::new("git")
.args(["diff", "--no-index", "--color=always", "-U3", "--"])
.arg(format!("{label}/current"))
.arg(format!("{label}/migrated"))
.current_dir(dir.path())
.run()
.expect("git diff --no-index failed");
let diff_output = String::from_utf8_lossy(&output.stdout);
if diff_output.is_empty() {
return None;
}
Some(format_with_gutter(diff_output.trim_end(), None))
}
pub fn format_deprecation_warnings(info: &DeprecationInfo) -> String {
use std::fmt::Write;
let label = &info.label;
let mut out = String::new();
for kind in &info.deprecations {
match kind {
DeprecationKind::TemplateVar { old, new } => {
let _ = writeln!(
out,
"{}",
warning_message(cformat!(
"{label}: template variable <bold>{old}</> is deprecated in favor of <bold>{new}</>"
))
);
}
DeprecationKind::CommitGeneration(commit_gen) => {
if commit_gen.has_top_level {
let _ = writeln!(
out,
"{}",
warning_message(cformat!(
"{label}: <bold>[commit-generation]</> is deprecated in favor of <bold>[commit.generation]</>"
))
);
}
for k in &commit_gen.project_keys {
let _ = writeln!(
out,
"{}",
warning_message(cformat!(
"{label}: <bold>[projects.\"{k}\".commit-generation]</> is deprecated in favor of <bold>[projects.\"{k}\".commit.generation]</>"
))
);
}
}
DeprecationKind::ApprovedCommands => {
let _ = writeln!(
out,
"{}",
warning_message(cformat!(
"{label}: <bold>approved-commands</> under <bold>[projects]</> is deprecated in favor of <bold>approvals.toml</>"
))
);
}
DeprecationKind::Select => {
let _ = writeln!(
out,
"{}",
warning_message(cformat!(
"{label}: <bold>[select]</> is deprecated in favor of <bold>[switch.picker]</>"
))
);
}
DeprecationKind::CiSection => {
let _ = writeln!(
out,
"{}",
warning_message(cformat!(
"{label}: <bold>[ci]</> is deprecated in favor of <bold>[forge]</>"
))
);
}
DeprecationKind::NoFf => {
let _ = writeln!(
out,
"{}",
warning_message(cformat!(
"{label}: <bold>merge.no-ff</> is deprecated in favor of <bold>merge.ff</> (inverted)"
))
);
}
DeprecationKind::NoCd => {
let _ = writeln!(
out,
"{}",
warning_message(cformat!(
"{label}: <bold>switch.no-cd</> is deprecated in favor of <bold>switch.cd</> (inverted)"
))
);
}
DeprecationKind::SwitchPickerTimeout => {
let _ = writeln!(
out,
"{}",
warning_message(cformat!(
"{label}: <bold>switch.picker.timeout-ms</> is no longer used — the picker now renders progressively"
))
);
}
}
}
out
}
pub fn format_deprecation_details(info: &DeprecationInfo, original_content: &str) -> String {
use std::fmt::Write;
let mut out = format_deprecation_warnings(info);
if let Some(main_path) = &info.main_worktree_path {
let cmd = suggest_command_in_dir(main_path, "config", &["update"], &[]);
let _ = writeln!(
out,
"{}",
hint_message(cformat!("To apply: <underline>{cmd}</>"))
);
return out;
}
let _ = writeln!(
out,
"{}",
hint_message(cformat!("To apply: <underline>wt config update</>"))
);
let migrated = compute_migrated_content(original_content);
let label = info
.config_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "config".to_string());
if let Some(diff) = format_migration_diff(original_content, &migrated, &label) {
let _ = writeln!(out, "{}", info_message("Proposed diff:"));
let _ = writeln!(out, "{diff}");
}
out
}
pub fn key_belongs_in<C: WorktrunkConfig>(key: &str) -> Option<&'static str> {
C::Other::is_valid_key(key).then(C::Other::description)
}
const USER_ONLY_COMMIT_GENERATION_PATHS: &[&str] = &[
"commit.generation.command",
"commit.generation.template",
"commit.generation.template-file",
"commit.generation.squash-template",
"commit.generation.squash-template-file",
];
pub fn nested_key_belongs_in<C: WorktrunkConfig>(path: &str) -> Option<&'static str> {
USER_ONLY_COMMIT_GENERATION_PATHS
.contains(&path)
.then(C::Other::description)
}
pub enum UnknownKeyKind {
DeprecatedHandled,
DeprecatedWrongConfig {
other_description: &'static str,
canonical_display: &'static str,
},
WrongConfig { other_description: &'static str },
Unknown,
}
pub fn classify_unknown_key<C: WorktrunkConfig>(key: &str) -> UnknownKeyKind {
if let Some(dep) = DEPRECATED_SECTION_KEYS.iter().find(|d| d.key == key) {
return if C::is_valid_key(dep.canonical_top_key) {
UnknownKeyKind::DeprecatedHandled
} else {
UnknownKeyKind::DeprecatedWrongConfig {
other_description: C::Other::description(),
canonical_display: dep.canonical_display,
}
};
}
match key_belongs_in::<C>(key) {
Some(other) => UnknownKeyKind::WrongConfig {
other_description: other,
},
None => UnknownKeyKind::Unknown,
}
}
pub fn warn_unknown_fields<C: WorktrunkConfig>(raw_contents: &str, path: &Path, label: &str) {
if warnings_suppressed() {
return;
}
let warnings = crate::config::collect_unknown_warnings::<C>(raw_contents);
if warnings.is_empty() {
return;
}
let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
{
let mut guard = WARNED_UNKNOWN_PATHS.lock().unwrap();
if guard.contains(&canonical_path) {
return; }
guard.insert(canonical_path);
}
for warning in warnings {
eprintln!("{}", warning_message(format_load_warning(label, &warning)));
}
std::io::stderr().flush().ok();
}
fn format_load_warning(label: &str, warning: &crate::config::UnknownWarning) -> String {
use crate::config::UnknownWarning;
match warning {
UnknownWarning::TopLevelUnknown { key } => {
cformat!("{label} has unknown field <bold>{key}</> (will be ignored)")
}
UnknownWarning::TopLevelWrongConfig {
key,
other_description,
} => cformat!(
"{label} has key <bold>{key}</> which belongs in {other_description} (will be ignored)"
),
UnknownWarning::TopLevelDeprecatedWrongConfig {
key,
other_description,
canonical_display,
} => cformat!(
"{label} has key <bold>{key}</> which belongs in {other_description} as {canonical_display}"
),
UnknownWarning::NestedWrongConfig {
path,
other_description,
} => cformat!(
"{label} has key <bold>{path}</> which belongs in {other_description} (will be ignored)"
),
UnknownWarning::NestedUnknown { path } => {
cformat!("{label} has unknown field <bold>{path}</> (will be ignored)")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_only_commit_generation_paths_track_schema() {
let schema = schemars::SchemaGenerator::default()
.into_root_schema_for::<crate::config::CommitGenerationConfig>();
let mut expected: Vec<String> = schema
.as_object()
.and_then(|o| o.get("properties"))
.and_then(|p| p.as_object())
.map(|props| props.keys().cloned().collect())
.unwrap_or_default();
expected.retain(|k| k != "template-append");
let mut expected: Vec<String> = expected
.iter()
.map(|k| format!("commit.generation.{k}"))
.collect();
expected.sort();
let mut actual: Vec<String> = USER_ONLY_COMMIT_GENERATION_PATHS
.iter()
.map(|s| s.to_string())
.collect();
actual.sort();
assert_eq!(actual, expected);
}
fn replace_deprecated_vars(content: &str) -> String {
let Ok(mut doc) = content.parse::<toml_edit::DocumentMut>() else {
return content.to_string();
};
if migrate_template_vars_doc(&mut doc).is_empty() {
return content.to_string();
}
let out = doc.to_string();
if !content.ends_with('\n') {
out.strip_suffix('\n').map(str::to_owned).unwrap_or(out)
} else {
out
}
}
fn find_deprecated_vars(content: &str) -> Vec<(&'static str, &'static str)> {
detect_deprecations(content)
.into_iter()
.filter_map(|k| match k {
DeprecationKind::TemplateVar { old, new } => Some((old, new)),
_ => None,
})
.collect()
}
fn has_kind(deprecations: &Deprecations, pred: impl Fn(&DeprecationKind) -> bool) -> bool {
deprecations.iter().any(pred)
}
fn find_commit_generation_deprecations(content: &str) -> CommitGenerationDeprecations {
detect_deprecations(content)
.into_iter()
.find_map(|k| match k {
DeprecationKind::CommitGeneration(found) => Some(found),
_ => None,
})
.unwrap_or_default()
}
fn find_approved_commands_deprecation(content: &str) -> bool {
has_kind(&detect_deprecations(content), |k| {
matches!(k, DeprecationKind::ApprovedCommands)
})
}
fn find_select_deprecation(content: &str) -> bool {
has_kind(&detect_deprecations(content), |k| {
matches!(k, DeprecationKind::Select)
})
}
fn remove_approved_commands_from_config(content: &str) -> String {
let Ok(mut doc) = content.parse::<toml_edit::DocumentMut>() else {
return content.to_string();
};
if remove_approved_commands_doc(&mut doc).is_empty() {
content.to_string()
} else {
doc.to_string()
}
}
#[test]
fn test_find_deprecated_vars_empty() {
let content = r#"
worktree-path = "../{{ repo }}.{{ branch | sanitize }}"
"#;
let found = find_deprecated_vars(content);
assert!(found.is_empty());
}
#[test]
fn test_find_deprecated_vars_repo_root() {
let content = r#"
post-start = "ln -sf {{ repo_root }}/node_modules node_modules"
"#;
let found = find_deprecated_vars(content);
assert_eq!(found, vec![("repo_root", "repo_path")]);
}
#[test]
fn test_find_deprecated_vars_worktree() {
let content = r#"
post-start = "cd {{ worktree }} && npm install"
"#;
let found = find_deprecated_vars(content);
assert_eq!(found, vec![("worktree", "worktree_path")]);
}
#[test]
fn test_find_deprecated_vars_main_worktree() {
let content = r#"
worktree-path = "../{{ main_worktree }}.{{ branch | sanitize }}"
"#;
let found = find_deprecated_vars(content);
assert_eq!(found, vec![("main_worktree", "repo")]);
}
#[test]
fn test_find_deprecated_vars_main_worktree_path() {
let content = r#"
post-start = "ln -sf {{ main_worktree_path }}/node_modules ."
"#;
let found = find_deprecated_vars(content);
assert_eq!(found, vec![("main_worktree_path", "primary_worktree_path")]);
}
#[test]
fn test_find_deprecated_vars_multiple() {
let content = r#"
worktree-path = "../{{ main_worktree }}.{{ branch | sanitize }}"
post-start = "ln -sf {{ repo_root }}/node_modules {{ worktree }}/node_modules"
"#;
let found = find_deprecated_vars(content);
assert_eq!(
found,
vec![
("repo_root", "repo_path"),
("worktree", "worktree_path"),
("main_worktree", "repo"),
]
);
}
#[test]
fn test_find_deprecated_vars_with_filter() {
let content = r#"
post-start = "ln -sf {{ repo_root | something }}/node_modules"
"#;
let found = find_deprecated_vars(content);
assert_eq!(found, vec![("repo_root", "repo_path")]);
}
#[test]
fn test_find_deprecated_vars_deduplicates() {
let content = r#"
post-start = "{{ repo_root }}/a {{ repo_root }}/b"
"#;
let found = find_deprecated_vars(content);
assert_eq!(found, vec![("repo_root", "repo_path")]);
}
#[test]
fn test_find_deprecated_vars_does_not_match_suffix() {
let content = r#"
post-start = "cd {{ worktree_path }} && npm install"
"#;
let found = find_deprecated_vars(content);
assert!(
found.is_empty(),
"Should not match worktree_path as worktree"
);
}
#[test]
fn test_replace_deprecated_vars_simple() {
let content = r#"cmd = "{{ repo_root }}""#;
let result = replace_deprecated_vars(content);
assert_eq!(result, r#"cmd = "{{ repo_path }}""#);
}
#[test]
fn test_replace_deprecated_vars_with_filter() {
let content = r#"cmd = "{{ repo_root | sanitize }}""#;
let result = replace_deprecated_vars(content);
assert_eq!(result, r#"cmd = "{{ repo_path | sanitize }}""#);
}
#[test]
fn test_replace_deprecated_vars_with_escaped_quotes() {
let content = r#"pre-start = "echo \"{{ repo_root }}\"""#;
let result = replace_deprecated_vars(content);
assert!(
!result.contains("repo_root"),
"deprecated var must be migrated even with escaped quotes; got: {result}"
);
assert!(
result.contains("repo_path"),
"migrated var must be present; got: {result}"
);
}
#[test]
fn test_compute_migrated_content_escaped_quotes() {
let content = "pre-start = \"echo \\\"{{ repo_root }}\\\"\"\n";
let migrated = compute_migrated_content(content);
assert!(
!migrated.contains("repo_root"),
"compute_migrated_content must migrate vars inside escaped strings; got: {migrated}"
);
assert!(migrated.contains("repo_path"));
}
#[test]
fn test_replace_deprecated_vars_no_spaces() {
let content = r#"cmd = "{{repo_root}}""#;
let result = replace_deprecated_vars(content);
assert_eq!(result, r#"cmd = "{{repo_path}}""#); }
#[test]
fn test_replace_deprecated_vars_filter_no_spaces() {
let content = r#"cmd = "{{repo_root|sanitize}}""#;
let result = replace_deprecated_vars(content);
assert_eq!(result, r#"cmd = "{{repo_path|sanitize}}""#); }
#[test]
fn test_replace_deprecated_vars_multiple() {
let content = r#"
worktree-path = "../{{ main_worktree }}.{{ branch | sanitize }}"
post-start = "ln -sf {{ repo_root }}/node_modules {{ worktree }}/node_modules"
"#;
let result = replace_deprecated_vars(content);
assert_eq!(
result,
r#"
worktree-path = "../{{ repo }}.{{ branch | sanitize }}"
post-start = "ln -sf {{ repo_path }}/node_modules {{ worktree_path }}/node_modules"
"#
);
}
#[test]
fn test_replace_deprecated_vars_preserves_other_content() {
let content = r#"
# This is a comment
worktree-path = "../{{ repo }}.{{ branch }}"
[hooks]
post-start = "echo hello"
"#;
let result = replace_deprecated_vars(content);
assert_eq!(result, content); }
#[test]
fn test_replace_deprecated_vars_preserves_whitespace() {
let content = r#"cmd = "{{ repo_root }}""#;
let result = replace_deprecated_vars(content);
assert_eq!(result, r#"cmd = "{{ repo_path }}""#); }
#[test]
fn test_replace_deprecated_vars_walks_array_of_tables_and_inline_table() {
let content = r#"
[[steps]]
run = "build {{ repo_root }}"
[env]
script = { cmd = "{{ repo_root }}/x" }
timeout = 30
"#;
let result = replace_deprecated_vars(content);
assert!(
result.contains("build {{ repo_path }}"),
"array-of-tables var migrated: {result}"
);
assert!(
result.contains("{{ repo_path }}/x"),
"inline-table var migrated: {result}"
);
assert!(
result.contains("timeout = 30"),
"non-string scalar left untouched: {result}"
);
}
#[test]
fn test_into_table_returns_none_for_non_table() {
let scalar = toml_edit::Item::Value(toml_edit::Value::from(5));
assert!(into_table(scalar).is_none());
}
#[test]
fn test_compute_migrated_content_noop_returns_input_unchanged() {
let content = "pre-start = \"echo {{ repo_path }}\"\n";
assert_eq!(compute_migrated_content(content), content);
}
#[test]
fn test_compute_migrated_content_does_not_rewrite_literal_text_when_other_template_uses_deprecated_var()
{
let content = "pre-merge = \"echo repo_root\"\npost-merge = \"echo {{ repo_root }}\"\n";
let migrated = compute_migrated_content(content);
assert_eq!(
migrated,
"pre-merge = \"echo repo_root\"\npost-merge = \"echo {{ repo_path }}\"\n"
);
}
#[test]
fn test_replace_deprecated_vars_returns_input_on_parse_error() {
let content = "this is = = not valid toml";
assert_eq!(replace_deprecated_vars(content), content);
}
#[test]
fn test_replace_does_not_match_suffix() {
let content = r#"cmd = "{{ worktree_path }}""#;
let result = replace_deprecated_vars(content);
assert_eq!(
result, r#"cmd = "{{ worktree_path }}""#,
"Should not modify worktree_path"
);
}
#[test]
fn test_replace_in_statement_blocks() {
let content = r#"cmd = "{% if repo_root %}echo {{ repo_root }}{% endif %}""#;
let result = replace_deprecated_vars(content);
assert_eq!(
result,
r#"cmd = "{% if repo_path %}echo {{ repo_path }}{% endif %}""#
);
}
#[test]
fn test_normalize_no_deprecated_vars() {
let template = "ln -sf {{ repo_path }}/node_modules";
let result = normalize_template_vars(template);
assert!(matches!(result, Cow::Borrowed(_)), "Should not allocate");
assert_eq!(result, template);
}
#[test]
fn test_normalize_does_not_rewrite_literal_text() {
let template = "echo repo_root";
let result = normalize_template_vars(template);
assert!(matches!(result, Cow::Borrowed(_)), "Should not allocate");
assert_eq!(result, template);
}
#[test]
fn test_normalize_only_rewrites_template_identifiers() {
let template = "echo repo_root && echo {{ repo_root }}";
let result = normalize_template_vars(template);
assert_eq!(result, "echo repo_root && echo {{ repo_path }}");
}
#[test]
fn test_normalize_skips_set_assignment_target() {
let template = "{% set repo_root = \"x\" %}{{ repo_root }}";
let result = normalize_template_vars(template);
assert!(matches!(result, Cow::Borrowed(_)), "Should not allocate");
assert_eq!(result, template);
}
#[test]
fn test_normalize_skips_comment_tags() {
let template = "{# repo_root #}{{ repo_root }}";
let result = normalize_template_vars(template);
assert_eq!(result, "{# repo_root #}{{ repo_path }}");
}
#[test]
fn test_normalize_skips_raw_blocks() {
let template = "{% raw %}{{ repo_root }}{% endraw %}{{ repo_root }}";
let result = normalize_template_vars(template);
assert_eq!(
result,
"{% raw %}{{ repo_root }}{% endraw %}{{ repo_path }}"
);
}
#[test]
fn test_normalize_skips_string_literals_in_tags() {
let template = "{{ \"repo_root\" }} {{ repo_root }}";
let result = normalize_template_vars(template);
assert_eq!(result, "{{ \"repo_root\" }} {{ repo_path }}");
}
#[test]
fn test_normalize_skips_attribute_access() {
let template = "{{ obj.repo_root }} {{ repo_root }}";
let result = normalize_template_vars(template);
assert_eq!(result, "{{ obj.repo_root }} {{ repo_path }}");
}
#[test]
fn test_normalize_skips_bare_brace() {
let template = "{ literal {{ repo_root }}";
let result = normalize_template_vars(template);
assert_eq!(result, "{ literal {{ repo_path }}");
}
#[test]
fn test_normalize_handles_escaped_quote_in_tag_string() {
let template = "{{ \"a\\\"repo_root\" }} {{ repo_root }}";
let result = normalize_template_vars(template);
assert_eq!(result, "{{ \"a\\\"repo_root\" }} {{ repo_path }}");
}
#[test]
fn test_normalize_repo_root() {
let template = "ln -sf {{ repo_root }}/node_modules";
let result = normalize_template_vars(template);
assert_eq!(result, "ln -sf {{ repo_path }}/node_modules");
}
#[test]
fn test_normalize_worktree() {
let template = "cd {{ worktree }} && npm install";
let result = normalize_template_vars(template);
assert_eq!(result, "cd {{ worktree_path }} && npm install");
}
#[test]
fn test_normalize_main_worktree() {
let template = "../{{ main_worktree }}.{{ branch }}";
let result = normalize_template_vars(template);
assert_eq!(result, "../{{ repo }}.{{ branch }}");
}
#[test]
fn test_normalize_multiple_vars() {
let template = "ln -sf {{ repo_root }}/node_modules {{ worktree }}/node_modules";
let result = normalize_template_vars(template);
assert_eq!(
result,
"ln -sf {{ repo_path }}/node_modules {{ worktree_path }}/node_modules"
);
}
#[test]
fn test_normalize_does_not_match_suffix() {
let template = "cd {{ worktree_path }}";
let result = normalize_template_vars(template);
assert_eq!(result, template);
}
#[test]
fn test_normalize_with_filter() {
let template = "{{ repo_root | sanitize }}";
let result = normalize_template_vars(template);
assert_eq!(result, "{{ repo_path | sanitize }}");
}
#[test]
fn test_find_deprecated_vars_in_array_of_tables() {
let content = r#"
[[hooks]]
command = "ln -sf {{ repo_root }}/node_modules"
"#;
let found = find_deprecated_vars(content);
assert_eq!(found, vec![("repo_root", "repo_path")]);
}
#[test]
fn test_find_deprecated_vars_in_approved_commands() {
let content = r#"
[projects."github.com/user/repo"]
approved-commands = [
"ln -sf {{ repo_root }}/node_modules",
"cd {{ worktree }} && npm install",
]
"#;
let found = find_deprecated_vars(content);
assert_eq!(
found,
vec![("repo_root", "repo_path"), ("worktree", "worktree_path"),]
);
}
#[test]
fn test_replace_deprecated_vars_in_approved_commands() {
let content = r#"
[projects."github.com/user/repo"]
approved-commands = [
"ln -sf {{ repo_root }}/node_modules",
"cd {{ worktree }} && npm install",
]
"#;
let result = replace_deprecated_vars(content);
assert_eq!(
result,
r#"
[projects."github.com/user/repo"]
approved-commands = [
"ln -sf {{ repo_path }}/node_modules",
"cd {{ worktree_path }} && npm install",
]
"#
);
}
#[test]
fn test_check_and_migrate_write_failure() {
let content = "[merge]\nno-ff = true\n";
let non_existent_path = std::path::Path::new("/nonexistent/dir/config.toml");
let result =
check_and_migrate(non_existent_path, content, true, "Test config", None, false);
assert!(result.is_ok());
assert!(result.unwrap().info.is_some());
}
#[test]
fn test_check_and_migrate_deduplicates_warnings() {
let content = "[merge]\nno-ff = true\n";
let unique_path = std::path::Path::new("/nonexistent/dedup_test_12345/config.toml");
let result1 = check_and_migrate(unique_path, content, true, "Test config", None, false);
assert!(result1.is_ok());
assert!(result1.unwrap().info.is_some());
let result2 = check_and_migrate(unique_path, content, true, "Test config", None, false);
assert!(result2.is_ok());
assert!(result2.unwrap().info.is_some());
}
#[test]
fn test_check_and_migrate_returns_migrated_content() {
let content = r#"
[select]
pager = "delta"
"#;
let result = check_and_migrate(
std::path::Path::new("/tmp/config.toml"),
content,
true,
"Test config",
None,
false,
)
.unwrap();
assert_eq!(result.migrated_content, migrate_content(content));
assert!(result.info.is_some());
}
#[test]
fn test_find_commit_generation_deprecations_none() {
let content = r#"
[commit.generation]
command = "llm -m haiku"
"#;
let result = find_commit_generation_deprecations(content);
assert!(result.is_empty());
}
#[test]
fn test_find_commit_generation_deprecations_top_level() {
let content = r#"
[commit-generation]
command = "llm -m haiku"
"#;
let result = find_commit_generation_deprecations(content);
assert!(result.has_top_level);
assert!(result.project_keys.is_empty());
}
#[test]
fn test_find_commit_generation_deprecations_project_level() {
let content = r#"
[projects."github.com/user/repo".commit-generation]
command = "llm -m gpt-4"
"#;
let result = find_commit_generation_deprecations(content);
assert!(!result.has_top_level);
assert_eq!(result.project_keys, vec!["github.com/user/repo"]);
}
#[test]
fn test_find_commit_generation_deprecations_multiple_projects() {
let content = r#"
[commit-generation]
command = "llm -m haiku"
[projects."github.com/user/repo1".commit-generation]
command = "llm -m gpt-4"
[projects."github.com/user/repo2".commit-generation]
command = "llm -m opus"
"#;
let result = find_commit_generation_deprecations(content);
assert!(result.has_top_level);
assert_eq!(result.project_keys.len(), 2);
assert!(
result
.project_keys
.contains(&"github.com/user/repo1".to_string())
);
assert!(
result
.project_keys
.contains(&"github.com/user/repo2".to_string())
);
}
#[test]
fn test_migrate_commit_generation_args_with_spaces() {
let content = r#"
[commit-generation]
command = "llm"
args = ["-m", "claude haiku 4.5"]
"#;
let result = migrate_content(content);
insta::assert_snapshot!(result, @r#"
[commit.generation]
command = "llm -m 'claude haiku 4.5'"
"#);
}
#[test]
fn test_migrate_commit_generation_preserves_other_fields() {
let content = r#"
[commit-generation]
command = "llm -m haiku"
template = "Write commit: {{ diff }}"
"#;
let result = migrate_content(content);
insta::assert_snapshot!(result, @r#"
[commit.generation]
command = "llm -m haiku"
template = "Write commit: {{ diff }}"
"#);
}
#[test]
fn test_migrate_no_changes_needed() {
let content = r#"
[commit.generation]
command = "llm -m haiku"
"#;
let result = migrate_content(content);
assert_eq!(result, content);
}
#[test]
fn test_migrate_skips_when_new_section_exists() {
let content = r#"
[commit.generation]
command = "new-command"
[commit-generation]
command = "old-command"
"#;
let result = migrate_content(content);
insta::assert_snapshot!(result, @r#"
[commit.generation]
command = "new-command"
[commit-generation]
command = "old-command"
"#);
}
#[test]
fn test_find_deprecations_skips_when_new_section_exists() {
let content = r#"
[commit.generation]
command = "new-command"
[commit-generation]
command = "old-command"
"#;
let result = find_commit_generation_deprecations(content);
assert!(
!result.has_top_level,
"Should not flag deprecation when new section exists"
);
}
#[test]
fn test_find_deprecations_skips_empty_section() {
let content = r#"
[commit-generation]
"#;
let result = find_commit_generation_deprecations(content);
assert!(
!result.has_top_level,
"Should not flag empty deprecated section"
);
}
#[test]
fn test_shell_join_simple() {
assert_eq!(shell_join(&["-m", "haiku"]), "-m haiku");
}
#[test]
fn test_shell_join_with_spaces() {
assert_eq!(shell_join(&["-m", "claude haiku"]), "-m 'claude haiku'");
}
#[test]
fn test_shell_join_with_quotes() {
assert_eq!(shell_join(&["echo", "it's"]), r"echo 'it'\''s'");
}
#[test]
fn test_combined_migrations_template_vars_and_section_rename() {
let content = r#"
worktree-path = "../{{ main_worktree }}.{{ branch }}"
[commit-generation]
command = "llm"
args = ["-m", "haiku"]
"#;
let step1 = replace_deprecated_vars(content);
let step2 = migrate_content(&step1);
insta::assert_snapshot!(step2, @r#"
worktree-path = "../{{ repo }}.{{ branch }}"
[commit.generation]
command = "llm -m haiku"
"#);
}
#[test]
fn test_find_deprecations_inline_table_top_level() {
let content = r#"
commit-generation = { command = "llm -m haiku" }
"#;
let result = find_commit_generation_deprecations(content);
assert!(result.has_top_level, "Should detect inline table format");
}
#[test]
fn test_find_deprecations_inline_table_project_level() {
let content = r#"
[projects."github.com/user/repo"]
commit-generation = { command = "llm -m gpt-4" }
"#;
let result = find_commit_generation_deprecations(content);
assert_eq!(
result.project_keys,
vec!["github.com/user/repo"],
"Should detect project-level inline table"
);
}
#[test]
fn test_migrate_inline_table_top_level() {
let content = r#"
commit-generation = { command = "llm", args = ["-m", "haiku"] }
"#;
let result = migrate_content(content);
assert!(
result.contains("[commit.generation]") || result.contains("[commit]"),
"Should migrate inline table"
);
assert!(
result.contains("command = \"llm -m haiku\""),
"Should merge args into command"
);
assert!(
!result.contains("commit-generation"),
"Should remove old inline table"
);
}
#[test]
fn test_find_deprecations_malformed_generation_not_table() {
let content = r#"
[commit]
generation = "not a table"
[commit-generation]
command = "llm -m haiku"
"#;
let result = find_commit_generation_deprecations(content);
assert!(
result.has_top_level,
"Should flag deprecated section when new section is malformed"
);
}
#[test]
fn test_migrate_inline_table_project_level() {
let content = r#"
[projects."github.com/user/repo"]
commit-generation = { command = "llm", args = ["-m", "gpt-4"] }
"#;
let result = migrate_content(content);
assert!(
result.contains("[projects.\"github.com/user/repo\".commit.generation]")
|| result.contains("[projects.\"github.com/user/repo\".commit]"),
"Should migrate project-level inline table"
);
assert!(
result.contains("command = \"llm -m gpt-4\""),
"Should merge args into command"
);
assert!(
!result.contains("commit-generation"),
"Should remove old inline table"
);
}
#[test]
fn test_find_deprecations_empty_inline_table() {
let content = r#"
commit-generation = {}
"#;
let result = find_commit_generation_deprecations(content);
assert!(
!result.has_top_level,
"Should not flag empty inline table as deprecated"
);
}
#[test]
fn test_migrate_args_without_command_preserved() {
let content = r#"
[commit-generation]
args = ["-m", "haiku"]
template = "some template"
"#;
let result = migrate_content(content);
insta::assert_snapshot!(result, @r#"
[commit.generation]
args = ["-m", "haiku"]
template = "some template"
"#);
}
#[test]
fn test_migrate_args_with_non_string_command() {
let content = r#"
[commit-generation]
command = 123
args = ["-m", "haiku"]
"#;
let result = migrate_content(content);
insta::assert_snapshot!(result, @r#"
[commit.generation]
command = 123
args = ["-m", "haiku"]
"#);
}
#[test]
fn test_migrate_empty_command_with_args() {
let content = r#"
[commit-generation]
command = ""
args = ["-m", "haiku"]
"#;
let result = migrate_content(content);
insta::assert_snapshot!(result, @r#"
[commit.generation]
command = "-m haiku"
"#);
}
#[test]
fn test_migrate_malformed_string_value_unchanged() {
let content = r#"
commit-generation = "not a table"
other = "value"
"#;
let result = migrate_content(content);
assert!(
!result.contains("[commit.generation]"),
"Should not create new section for malformed input"
);
assert!(
result.contains("commit-generation = \"not a table\""),
"Malformed value must be preserved; got: {result}"
);
}
#[test]
fn test_migrate_malformed_project_level_string_unchanged() {
let content = r#"
[projects."github.com/user/repo"]
commit-generation = "not a table"
other = "value"
"#;
let result = migrate_content(content);
assert!(
!result.contains("[projects.\"github.com/user/repo\".commit.generation]"),
"Should not create new section for malformed project-level input"
);
assert!(
result.contains("commit-generation = \"not a table\""),
"Malformed project-level value must be preserved; got: {result}"
);
}
#[test]
fn test_malformed_section_preserved_with_sibling_migration() {
let content = r#"commit-generation = "keep me"
[merge]
no-ff = true
"#;
let result = migrate_content(content);
assert!(
result.contains(r#"commit-generation = "keep me""#),
"Malformed commit-generation must survive sibling migrations; got:\n{result}"
);
assert!(
result.contains("ff = false"),
"merge.no-ff should have migrated to merge.ff = false; got:\n{result}"
);
}
#[test]
fn test_malformed_select_preserved_with_sibling_migration() {
let content = r#"select = "not a table"
[merge]
no-ff = true
"#;
let result = migrate_content(content);
assert!(
result.contains(r#"select = "not a table""#),
"Malformed select must survive sibling migrations; got:\n{result}"
);
assert!(
result.contains("ff = false"),
"merge.no-ff should have migrated; got:\n{result}"
);
}
#[test]
fn test_commit_generation_preserved_when_commit_is_scalar() {
let content = r#"commit = "x"
[commit-generation]
template = "tpl"
[merge]
no-ff = true
"#;
let result = migrate_content(content);
assert!(
result.contains("[commit-generation]") && result.contains(r#"template = "tpl""#),
"[commit-generation] must survive when scalar `commit` blocks the new key; got:\n{result}"
);
assert!(
result.contains(r#"commit = "x""#),
"scalar `commit` must be preserved unchanged; got:\n{result}"
);
assert!(
result.contains("ff = false"),
"merge.no-ff should have migrated; got:\n{result}"
);
}
#[test]
fn test_commit_generation_migrates_when_commit_parent_is_inline_table() {
let content = r#"commit = { stage = "tracked" }
[commit-generation]
command = "llm"
"#;
let result = migrate_content(content);
let doc: toml_edit::DocumentMut = result.parse().unwrap();
let commit = doc["commit"].as_table().expect("commit table");
assert_eq!(
commit["stage"].as_str(),
Some("tracked"),
"inline parent fields must survive: {result}"
);
assert_eq!(
commit["generation"]["command"].as_str(),
Some("llm"),
"deprecated section should move under commit.generation: {result}"
);
assert!(
doc.get("commit-generation").is_none(),
"old section should be removed after migration: {result}"
);
}
#[test]
fn test_project_commit_generation_migrates_when_commit_parent_is_inline_table() {
let content = r#"
[projects."github.com/user/repo"]
commit = { stage = "tracked" }
commit-generation = { command = "llm" }
"#;
let result = migrate_content(content);
let doc: toml_edit::DocumentMut = result.parse().unwrap();
let project = doc["projects"]["github.com/user/repo"]
.as_table()
.expect("project table");
let commit = project["commit"].as_table().expect("project commit table");
assert_eq!(
commit["stage"].as_str(),
Some("tracked"),
"inline project parent fields must survive: {result}"
);
assert_eq!(
commit["generation"]["command"].as_str(),
Some("llm"),
"project deprecated section should move under commit.generation: {result}"
);
assert!(
project.get("commit-generation").is_none(),
"old project section should be removed after migration: {result}"
);
}
#[test]
fn test_select_preserved_when_switch_is_scalar() {
let content = r#"switch = "x"
[select]
preview = "p"
[merge]
no-ff = true
"#;
let result = migrate_content(content);
assert!(
result.contains("[select]") && result.contains(r#"preview = "p""#),
"[select] must survive when scalar `switch` blocks the new key; got:\n{result}"
);
assert!(
result.contains(r#"switch = "x""#),
"scalar `switch` must be preserved unchanged; got:\n{result}"
);
assert!(
result.contains("ff = false"),
"merge.no-ff should have migrated; got:\n{result}"
);
}
#[test]
fn test_commit_generation_args_preserved_when_non_string_element() {
let content = r#"[commit-generation]
command = "echo"
args = [1, "--ok"]
"#;
let result = migrate_content(content);
assert!(
result.contains("[commit.generation]"),
"section should still migrate; got:\n{result}"
);
assert!(
result.contains("args = [1, \"--ok\"]") || result.contains("args = [ 1, \"--ok\" ]"),
"args must be preserved unchanged when any element is non-string; got:\n{result}"
);
assert!(
result.contains(r#"command = "echo""#),
"command must not be mutated when args is preserved; got:\n{result}"
);
}
#[test]
fn test_ci_migration_preserves_other_keys() {
let content = r#"[ci]
platform = "github"
hostname = "ghe.example"
[merge]
ff = false
"#;
let result = migrate_content(content);
insta::assert_snapshot!(result, @r#"
[ci]
hostname = "ghe.example"
[forge]
platform = "github"
[merge]
ff = false
"#);
}
#[test]
fn test_ci_migration_keeps_section_position() {
let content = r#"# which forge to talk to
[ci]
platform = "github" # not gitlab
[merge]
ff = false
"#;
let result = migrate_content(content);
insta::assert_snapshot!(result, @r#"
# which forge to talk to
[forge]
platform = "github" # not gitlab
[merge]
ff = false
"#);
}
#[test]
fn test_ci_migration_suppressed_by_scalar_forge() {
let content = r#"forge = "x"
[ci]
platform = "github"
"#;
assert_eq!(migrate_content(content), content);
assert!(detect_deprecations(content).is_empty());
}
#[test]
fn test_warning_fires_iff_update_changes() {
let untouched = [
"[ci]\nplatform = \"\"\n",
"[ci]\nplatform = 42\n",
"[ci]\nhostname = \"ghe.example\"\n",
"[forge]\nplatform = \"gitlab\"\n\n[ci]\nplatform = \"github\"\n",
"forge = \"x\"\n\n[ci]\nplatform = \"github\"\n",
"[commit-generation]\n",
"[select]\n",
"switch = \"x\"\n\n[select]\npreview = \"p\"\n",
"commit = \"x\"\n\n[commit-generation]\ncommand = \"llm\"\n",
"[merge]\nno-ff = \"yes\"\n",
"[merge]\nff = true\nno-ff = \"yes\"\n",
"[projects.\"github.com/u/r\"]\napproved-commands = []\n",
];
for content in untouched {
assert!(
detect_deprecations(content).is_empty(),
"no warning expected for:\n{content}"
);
assert_eq!(
compute_migrated_content(content),
content,
"no rewrite expected for:\n{content}"
);
}
let rewritten = [
"[ci]\nplatform = \"github\"\n",
"[merge]\nff = true\nno-ff = true\n",
"[select]\ntimeout-ms = 500\n",
"worktree-path = \"../{{ repo_root }}.{{ branch }}\"\n",
"[projects.\"github.com/u/r\"]\napproved-commands = [\"npm test\"]\n",
];
for content in rewritten {
assert!(
!detect_deprecations(content).is_empty(),
"warning expected for:\n{content}"
);
let migrated = compute_migrated_content(content);
assert_ne!(migrated, content, "rewrite expected for:\n{content}");
assert!(
detect_deprecations(&migrated).is_empty(),
"no warning expected after update for:\n{migrated}"
);
}
}
#[test]
fn test_select_timeout_ms_warns_both_kinds() {
let deprecations = detect_deprecations("[select]\npager = \"delta\"\ntimeout-ms = 500\n");
assert!(has_kind(&deprecations, |k| matches!(
k,
DeprecationKind::Select
)));
assert!(has_kind(&deprecations, |k| matches!(
k,
DeprecationKind::SwitchPickerTimeout
)));
}
#[test]
fn test_migrate_invalid_toml_returns_unchanged() {
let content = "this is [not valid {toml";
let result = migrate_content(content);
assert_eq!(result, content, "Invalid TOML should be returned unchanged");
}
fn migration_diff(original: &str, migrated: &str) -> String {
use similar::{ChangeTag, TextDiff};
let diff = TextDiff::from_lines(original, migrated);
let mut output = String::new();
for change in diff.iter_all_changes() {
let sign = match change.tag() {
ChangeTag::Delete => "-",
ChangeTag::Insert => "+",
ChangeTag::Equal => " ",
};
output.push_str(&format!("{}{}", sign, change));
}
output
}
#[test]
fn snapshot_migrate_commit_generation_simple() {
let content = r#"
[commit-generation]
command = "llm -m haiku"
"#;
let result = migrate_content(content);
insta::assert_snapshot!(migration_diff(content, &result));
}
#[test]
fn snapshot_migrate_commit_generation_with_args() {
let content = r#"
[commit-generation]
command = "llm"
args = ["-m", "claude-haiku-4.5"]
"#;
let result = migrate_content(content);
insta::assert_snapshot!(migration_diff(content, &result));
}
#[test]
fn snapshot_migrate_with_trailing_sections() {
let content = r#"# Config file
worktree-path = "../{{ repo }}.{{ branch | sanitize }}"
[commit-generation]
command = "llm"
args = ["-m", "claude-haiku-4.5"]
[list]
branches = true
remotes = false
"#;
let result = migrate_content(content);
insta::assert_snapshot!(migration_diff(content, &result));
}
#[test]
fn snapshot_migrate_preserves_existing_commit_section() {
let content = r#"
[commit]
stage = "all"
[commit-generation]
command = "llm -m haiku"
"#;
let result = migrate_content(content);
insta::assert_snapshot!(migration_diff(content, &result));
}
#[test]
fn snapshot_migrate_project_level() {
let content = r#"
[projects."github.com/user/repo"]
approved-commands = ["npm test"]
[projects."github.com/user/repo".commit-generation]
command = "llm"
args = ["-m", "gpt-4"]
"#;
let result = migrate_content(content);
insta::assert_snapshot!(migration_diff(content, &result));
}
#[test]
fn snapshot_migrate_combined_top_and_project() {
let content = r#"
[commit-generation]
command = "llm -m haiku"
[projects."github.com/user/repo".commit-generation]
command = "llm -m gpt-4"
[list]
branches = true
"#;
let result = migrate_content(content);
insta::assert_snapshot!(migration_diff(content, &result));
}
#[test]
fn test_find_approved_commands_deprecation_none() {
let content = r#"
[commit.generation]
command = "llm -m haiku"
"#;
assert!(!find_approved_commands_deprecation(content));
}
#[test]
fn test_find_approved_commands_deprecation_present() {
let content = r#"
[projects."github.com/user/repo"]
approved-commands = ["npm install", "npm test"]
"#;
assert!(find_approved_commands_deprecation(content));
}
#[test]
fn test_find_approved_commands_deprecation_empty_array() {
let content = r#"
[projects."github.com/user/repo"]
approved-commands = []
"#;
assert!(!find_approved_commands_deprecation(content));
}
#[test]
fn test_find_approved_commands_deprecation_no_projects() {
let content = r#"
worktree-path = "../{{ repo }}.{{ branch }}"
"#;
assert!(!find_approved_commands_deprecation(content));
}
#[test]
fn test_find_approved_commands_deprecation_project_without_approvals() {
let content = r#"
[projects."github.com/user/repo"]
worktree-path = ".worktrees/{{ branch | sanitize }}"
"#;
assert!(!find_approved_commands_deprecation(content));
}
#[test]
fn test_remove_approved_commands_multiple_projects() {
let content = r#"
[projects."github.com/user/repo1"]
approved-commands = ["npm install"]
[projects."github.com/user/repo2"]
approved-commands = ["cargo test"]
worktree-path = ".worktrees/{{ branch | sanitize }}"
"#;
let result = remove_approved_commands_from_config(content);
insta::assert_snapshot!(result, @r#"
[projects."github.com/user/repo2"]
worktree-path = ".worktrees/{{ branch | sanitize }}"
"#);
}
#[test]
fn test_remove_approved_commands_no_change() {
let content = r#"
[projects."github.com/user/repo"]
worktree-path = ".worktrees/{{ branch | sanitize }}"
"#;
let result = remove_approved_commands_from_config(content);
assert_eq!(result, content);
}
#[test]
fn test_remove_approved_commands_keeps_empty_sibling() {
let content = r#"
[projects."github.com/user/repo1"]
approved-commands = ["npm install"]
[projects."github.com/user/repo2"]
approved-commands = []
"#;
let result = remove_approved_commands_from_config(content);
insta::assert_snapshot!(result, @r#"
[projects."github.com/user/repo2"]
approved-commands = []
"#);
}
#[test]
fn snapshot_remove_approved_commands() {
let content = r#"worktree-path = "../{{ repo }}.{{ branch | sanitize }}"
[projects."github.com/user/repo"]
approved-commands = ["npm install", "npm test"]
worktree-path = ".worktrees/{{ branch | sanitize }}"
"#;
let result = remove_approved_commands_from_config(content);
insta::assert_snapshot!(migration_diff(content, &result));
}
#[test]
fn snapshot_remove_approved_commands_entire_section() {
let content = r#"worktree-path = "../{{ repo }}.{{ branch | sanitize }}"
[projects."github.com/user/repo"]
approved-commands = ["npm install"]
"#;
let result = remove_approved_commands_from_config(content);
insta::assert_snapshot!(migration_diff(content, &result));
}
#[test]
fn test_detect_deprecations_includes_approved_commands() {
let content = r#"
[projects."github.com/user/repo"]
approved-commands = ["npm install"]
"#;
let deprecations = detect_deprecations(content);
assert!(has_kind(&deprecations, |k| matches!(
k,
DeprecationKind::ApprovedCommands
)));
assert!(!deprecations.is_empty());
}
#[test]
fn test_remove_approved_commands_invalid_toml() {
let content = "this is { not valid toml";
let result = remove_approved_commands_from_config(content);
assert_eq!(result, content, "Invalid TOML should be returned unchanged");
}
#[test]
fn test_format_deprecation_details_approved_commands() {
let content = r#"
[projects."github.com/user/repo"]
approved-commands = ["npm install"]
"#;
let info = DeprecationInfo {
config_path: std::path::PathBuf::from("/tmp/test-config.toml"),
deprecations: vec![DeprecationKind::ApprovedCommands],
label: "User config".to_string(),
main_worktree_path: None,
};
let output = format_deprecation_details(&info, content);
assert!(
output.contains("approved-commands"),
"Should mention approved-commands in output: {}",
output
);
assert!(
output.contains("approvals.toml"),
"Should mention approvals.toml: {}",
output
);
}
#[test]
fn test_compute_migrated_content_removes_approved_commands() {
let content = r#"worktree-path = "../{{ repo }}.{{ branch | sanitize }}"
[projects."github.com/user/repo"]
approved-commands = ["npm install"]
"#;
let migrated = compute_migrated_content(content);
assert!(!migrated.contains("approved-commands"));
}
#[test]
fn test_copy_approved_commands_creates_approvals_file() {
let temp_dir = tempfile::TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let content = r#"
[projects."github.com/user/repo"]
approved-commands = ["npm install", "npm test"]
[projects."github.com/other/repo"]
approved-commands = ["cargo build"]
"#;
std::fs::write(&config_path, content).unwrap();
let result =
copy_approved_commands_to_approvals_file(&config_path).expect("copy should succeed");
assert!(result.is_some(), "Should create approvals.toml");
let approvals_path = result.unwrap();
assert_eq!(approvals_path, temp_dir.path().join("approvals.toml"));
let approvals_content = std::fs::read_to_string(&approvals_path).unwrap();
assert!(
approvals_content.contains("npm install"),
"Should contain npm install: {}",
approvals_content
);
assert!(
approvals_content.contains("npm test"),
"Should contain npm test: {}",
approvals_content
);
assert!(
approvals_content.contains("cargo build"),
"Should contain cargo build: {}",
approvals_content
);
}
#[test]
fn test_copy_approved_commands_skips_when_approvals_exists() {
let temp_dir = tempfile::TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let approvals_path = temp_dir.path().join("approvals.toml");
let content = r#"
[projects."github.com/user/repo"]
approved-commands = ["npm install"]
"#;
std::fs::write(&config_path, content).unwrap();
std::fs::write(&approvals_path, "# existing approvals\n").unwrap();
let result = copy_approved_commands_to_approvals_file(&config_path)
.expect("skip should not surface error");
assert!(result.is_none(), "Should skip when approvals.toml exists");
let existing = std::fs::read_to_string(&approvals_path).unwrap();
assert_eq!(existing, "# existing approvals\n");
}
#[test]
fn test_copy_approved_commands_errors_when_existing_approvals_invalid() {
let temp_dir = tempfile::TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let approvals_path = temp_dir.path().join("approvals.toml");
let content = r#"
[projects."github.com/user/repo"]
approved-commands = ["npm install"]
"#;
std::fs::write(&config_path, content).unwrap();
std::fs::write(&approvals_path, "this is = = not valid toml\n").unwrap();
let result = copy_approved_commands_to_approvals_file(&config_path);
assert!(
result.is_err(),
"Invalid existing approvals.toml must surface as Err; got {result:?}"
);
assert!(
result
.unwrap_err()
.to_string()
.contains("Failed to parse existing approvals file"),
"Error should identify the invalid approvals file"
);
}
#[test]
fn test_copy_approved_commands_skips_when_empty() {
let temp_dir = tempfile::TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let content = r#"
[projects."github.com/user/repo"]
worktree-path = ".worktrees/{{ branch | sanitize }}"
"#;
std::fs::write(&config_path, content).unwrap();
let result = copy_approved_commands_to_approvals_file(&config_path)
.expect("empty case should not surface error");
assert!(
result.is_none(),
"Should skip when no approved-commands exist"
);
}
#[cfg(unix)]
#[test]
fn test_copy_approved_commands_surfaces_write_failure() {
use std::os::unix::fs::PermissionsExt;
let temp_dir = tempfile::TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let content = r#"
[projects."github.com/user/repo"]
approved-commands = ["npm install"]
"#;
std::fs::write(&config_path, content).unwrap();
let mut perms = std::fs::metadata(temp_dir.path()).unwrap().permissions();
perms.set_mode(0o555);
std::fs::set_permissions(temp_dir.path(), perms).unwrap();
if std::fs::write(temp_dir.path().join("__probe"), "").is_ok() {
let mut perms = std::fs::metadata(temp_dir.path()).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(temp_dir.path(), perms).unwrap();
std::eprintln!("Skipping permission test - running with elevated privileges");
return;
}
let result = copy_approved_commands_to_approvals_file(&config_path);
let mut perms = std::fs::metadata(temp_dir.path()).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(temp_dir.path(), perms).unwrap();
assert!(
result.is_err(),
"Write failure must surface as Err, not Ok(None); got {result:?}"
);
}
#[test]
fn test_copy_approved_commands_surfaces_read_failure() {
let temp_dir = tempfile::TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
std::fs::write(&config_path, "this is = = not valid toml\n").unwrap();
let result = copy_approved_commands_to_approvals_file(&config_path);
assert!(
result.is_err(),
"Unparsable source config must surface as Err; got {result:?}"
);
}
#[test]
fn test_set_implicit_suppresses_parent_header() {
use toml_edit::{DocumentMut, Item, Table};
let mut doc: DocumentMut = "[foo]\nbar = 1\n".parse().unwrap();
let mut commit_table = Table::new();
commit_table.set_implicit(true);
let mut gen_table = Table::new();
gen_table.insert("command", toml_edit::value("llm"));
commit_table.insert("generation", Item::Table(gen_table));
doc.insert("commit", Item::Table(commit_table));
let result = doc.to_string();
assert!(
!result.contains("\n[commit]\n"),
"set_implicit should suppress separate [commit] header"
);
assert!(
result.contains("[commit.generation]"),
"Should have [commit.generation] header"
);
}
#[test]
fn test_find_select_deprecation_none() {
let content = r#"
[switch.picker]
pager = "delta --paging=never"
"#;
assert!(!find_select_deprecation(content));
}
#[test]
fn test_find_select_deprecation_present() {
let content = r#"
[select]
pager = "delta --paging=never"
"#;
assert!(find_select_deprecation(content));
}
#[test]
fn test_find_select_deprecation_empty_not_flagged() {
let content = r#"
[select]
"#;
assert!(!find_select_deprecation(content));
}
#[test]
fn test_find_select_deprecation_skips_when_new_exists() {
let content = r#"
[select]
pager = "old"
[switch.picker]
pager = "new"
"#;
assert!(!find_select_deprecation(content));
}
#[test]
fn test_find_select_deprecation_inline_table() {
let content = r#"
select = { pager = "delta" }
"#;
assert!(find_select_deprecation(content));
}
#[test]
fn test_find_select_deprecation_empty_inline_table() {
let content = r#"
select = {}
"#;
assert!(!find_select_deprecation(content));
}
#[test]
fn test_migrate_select_simple() {
let content = r#"
[select]
pager = "delta --paging=never"
"#;
let result = migrate_content(content);
assert!(
result.contains("[switch.picker]"),
"Should have [switch.picker]: {result}"
);
assert!(
result.contains("pager = \"delta --paging=never\""),
"Should preserve pager: {result}"
);
assert!(
!result.contains("[select]"),
"Should remove [select]: {result}"
);
}
#[test]
fn test_migrate_select_when_switch_parent_is_inline_table() {
let content = r#"switch = { cd = false }
[select]
pager = "delta"
"#;
let result = migrate_content(content);
let doc: toml_edit::DocumentMut = result.parse().unwrap();
let switch = doc["switch"].as_table().expect("switch table");
assert_eq!(
switch["cd"].as_bool(),
Some(false),
"inline switch fields must survive: {result}"
);
assert_eq!(
switch["picker"]["pager"].as_str(),
Some("delta"),
"select should move under switch.picker: {result}"
);
assert!(
doc.get("select").is_none(),
"old select section should be removed after migration: {result}"
);
}
#[test]
fn test_migrate_select_skips_when_new_exists() {
let content = r#"
[select]
pager = "old"
[switch.picker]
pager = "new"
"#;
let result = migrate_content(content);
assert_eq!(
result, content,
"Should not migrate when new section exists"
);
}
#[test]
fn test_migrate_select_invalid_toml() {
let content = "this is { not valid toml";
let result = migrate_content(content);
assert_eq!(result, content, "Invalid TOML should be returned unchanged");
}
#[test]
fn test_migrate_select_no_select_section() {
let content = r#"
[list]
full = true
"#;
let result = migrate_content(content);
assert_eq!(result, content, "No [select] section means no migration");
}
#[test]
fn test_detect_deprecations_includes_select() {
let content = r#"
[select]
pager = "delta"
"#;
let deprecations = detect_deprecations(content);
assert!(has_kind(&deprecations, |k| matches!(
k,
DeprecationKind::Select
)));
assert!(!deprecations.is_empty());
}
#[test]
fn snapshot_migrate_select_to_switch_picker() {
let content = r#"worktree-path = "../{{ repo }}.{{ branch | sanitize }}"
[select]
pager = "delta --paging=never"
[list]
branches = true
"#;
let result = migrate_content(content);
insta::assert_snapshot!(migration_diff(content, &result));
}
#[test]
fn test_format_deprecation_details_select() {
let content = r#"[select]
pager = "delta --paging=never"
"#;
let info = DeprecationInfo {
config_path: std::path::PathBuf::from("/tmp/test-config.toml"),
deprecations: vec![DeprecationKind::Select],
label: "User config".to_string(),
main_worktree_path: None,
};
let output = format_deprecation_details(&info, content);
assert!(
output.contains("[select]"),
"Should mention [select] in output: {output}"
);
assert!(
output.contains("[switch.picker]"),
"Should mention [switch.picker]: {output}"
);
}
#[test]
fn test_compute_migrated_content_renames_select() {
let content = r#"worktree-path = "../{{ repo }}.{{ branch | sanitize }}"
[select]
pager = "delta --paging=never"
"#;
let migrated = compute_migrated_content(content);
assert!(
migrated.contains("[switch.picker]"),
"Migrated content should have [switch.picker]: {migrated}"
);
assert!(
!migrated.contains("[select]"),
"Migrated content should not have [select]: {migrated}"
);
}
#[test]
fn test_migrate_create_hooks_renames_every_shape() {
let content = r#"pre-create = "npm install"
[[post-create]]
lint = "cargo clippy"
[projects."my-project"]
pre-create = "cargo build"
[projects."my-project".post-create]
server = "npm run dev"
"#;
let result = migrate_content(content);
assert!(
!result.contains("pre-create") && !result.contains("post-create"),
"no deprecated key may remain; got:\n{result}"
);
assert!(
result.contains(r#"pre-start = "npm install""#),
"top-level string renamed; got:\n{result}"
);
assert!(
result.contains("[[post-start]]"),
"top-level array-of-tables renamed; got:\n{result}"
);
assert!(
result.contains(r#"pre-start = "cargo build""#),
"per-project string renamed; got:\n{result}"
);
assert!(
result.contains(r#"[projects."my-project".post-start]"#),
"per-project table renamed; got:\n{result}"
);
}
#[test]
fn test_migrate_create_hooks_skips_when_start_exists() {
let content = r#"pre-create = "old"
pre-start = "new"
[projects."my-project"]
post-create = "old"
post-start = "new"
"#;
assert_eq!(
migrate_content(content),
content,
"must not clobber an existing canonical key"
);
}
#[test]
fn test_migrate_create_hooks_invalid_toml() {
let content = "this is { not valid toml";
assert_eq!(migrate_content(content), content);
}
#[test]
fn snapshot_migrate_create_to_start() {
let content = r#"pre-create = "npm install"
[post-create]
server = "npm run dev"
"#;
let migrated = compute_migrated_content(content);
insta::assert_snapshot!(migration_diff(content, &migrated));
}
#[test]
fn test_detect_switch_picker_timeout_top_level() {
let content = r#"
[switch.picker]
pager = "delta"
timeout-ms = 500
"#;
let deprecations = detect_deprecations(content);
assert!(has_kind(&deprecations, |k| matches!(
k,
DeprecationKind::SwitchPickerTimeout
)));
assert!(!deprecations.is_empty());
}
#[test]
fn test_detect_switch_picker_timeout_project_level() {
let content = r#"
[projects."github.com/user/repo".switch.picker]
timeout-ms = 300
"#;
let deprecations = detect_deprecations(content);
assert!(has_kind(&deprecations, |k| matches!(
k,
DeprecationKind::SwitchPickerTimeout
)));
}
#[test]
fn test_detect_switch_picker_timeout_inline_table() {
let content = r#"
[switch]
picker = { pager = "delta", timeout-ms = 500 }
"#;
let deprecations = detect_deprecations(content);
assert!(has_kind(&deprecations, |k| matches!(
k,
DeprecationKind::SwitchPickerTimeout
)));
}
#[test]
fn test_migrate_switch_picker_timeout_inline_table() {
let content = r#"
[switch]
picker = { pager = "delta", timeout-ms = 500 }
"#;
let result = migrate_content(content);
assert!(!result.contains("timeout-ms"));
assert!(result.contains("pager"));
}
#[test]
fn test_detect_switch_picker_timeout_absent() {
let content = r#"
[switch.picker]
pager = "delta"
"#;
let deprecations = detect_deprecations(content);
assert!(!has_kind(&deprecations, |k| matches!(
k,
DeprecationKind::SwitchPickerTimeout
)));
}
#[test]
fn test_migrate_switch_picker_timeout_removes_key() {
let content = r#"
[switch.picker]
pager = "delta"
timeout-ms = 500
"#;
let result = migrate_content(content);
assert!(
!result.contains("timeout-ms"),
"Should strip timeout-ms: {result}"
);
assert!(
result.contains("pager"),
"Should preserve sibling keys: {result}"
);
}
#[test]
fn test_migrate_switch_picker_timeout_project_level() {
let content = r#"
[projects."github.com/user/repo".switch.picker]
pager = "bat"
timeout-ms = 100
"#;
let result = migrate_content(content);
assert!(!result.contains("timeout-ms"));
assert!(result.contains("pager"));
}
#[test]
fn test_migrate_switch_picker_timeout_noop_when_absent() {
let content = r#"
[switch.picker]
pager = "delta"
"#;
let result = migrate_content(content);
assert_eq!(result, content);
}
#[test]
fn test_migrate_switch_picker_timeout_invalid_toml() {
let content = "this is { not valid toml";
let result = migrate_content(content);
assert_eq!(result, content);
}
#[test]
fn test_format_deprecation_warnings_switch_picker_timeout() {
let info = DeprecationInfo {
config_path: std::path::PathBuf::from("/tmp/test-config.toml"),
deprecations: vec![DeprecationKind::SwitchPickerTimeout],
label: "User config".to_string(),
main_worktree_path: None,
};
let output = format_deprecation_warnings(&info);
assert!(
output.contains("switch.picker.timeout-ms"),
"Should mention the field: {output}"
);
assert!(
output.contains("no longer used"),
"Should explain deprecation reason: {output}"
);
}
#[test]
fn test_format_deprecation_warnings_no_ff_and_no_cd() {
let info = DeprecationInfo {
config_path: std::path::PathBuf::from("/tmp/test-config.toml"),
deprecations: vec![DeprecationKind::NoFf, DeprecationKind::NoCd],
label: "User config".to_string(),
main_worktree_path: None,
};
let output = format_deprecation_warnings(&info);
assert!(output.contains("no-ff"), "Should mention no-ff: {output}");
assert!(output.contains("no-cd"), "Should mention no-cd: {output}");
}
#[test]
fn test_detect_no_ff_deprecation() {
let deprecations = detect_deprecations("[merge]\nno-ff = true\n");
assert!(has_kind(&deprecations, |k| matches!(
k,
DeprecationKind::NoFf
)));
}
#[test]
fn test_no_ff_warned_and_removed_when_ff_exists() {
let content = "[merge]\nff = true\nno-ff = true\n";
let deprecations = detect_deprecations(content);
assert!(has_kind(&deprecations, |k| matches!(
k,
DeprecationKind::NoFf
)));
insta::assert_snapshot!(migrate_content(content), @r#"
[merge]
ff = true
"#);
}
#[test]
fn test_detect_no_cd_deprecation() {
let deprecations = detect_deprecations("[switch]\nno-cd = true\n");
assert!(has_kind(&deprecations, |k| matches!(
k,
DeprecationKind::NoCd
)));
}
#[test]
fn test_detect_no_ff_project_level() {
let content = r#"
[projects."github.com/user/repo".merge]
no-ff = true
"#;
let deprecations = detect_deprecations(content);
assert!(has_kind(&deprecations, |k| matches!(
k,
DeprecationKind::NoFf
)));
}
#[test]
fn test_migrate_no_ff_to_ff() {
let content = "[merge]\nno-ff = true\n";
let result = migrate_content(content);
assert!(result.contains("ff = false"), "Should invert: {result}");
assert!(!result.contains("no-ff"), "Should remove no-ff: {result}");
}
#[test]
fn test_migrate_no_cd_to_cd() {
let content = "[switch]\nno-cd = false\n";
let result = migrate_content(content);
assert!(result.contains("cd = true"), "Should invert: {result}");
assert!(!result.contains("no-cd"), "Should remove no-cd: {result}");
}
#[test]
fn test_migrate_no_ff_project_level() {
let content = r#"
[projects."github.com/user/repo".merge]
no-ff = true
"#;
let result = migrate_content(content);
assert!(result.contains("ff = false"), "Should migrate: {result}");
assert!(!result.contains("no-ff"), "Should remove no-ff: {result}");
}
#[test]
fn test_migrate_negated_bool_non_boolean_value_preserved() {
let content = "[merge]\nno-ff = \"not-a-bool\"\n";
let result = migrate_content(content);
assert!(
result.contains("no-ff"),
"Non-boolean value should be preserved: {result}"
);
}
#[test]
fn test_migrate_no_ff_skips_when_ff_exists() {
let content = "[merge]\nff = true\nno-ff = true\n";
let result = migrate_content(content);
assert!(result.contains("ff = true"), "ff should be kept: {result}");
assert!(
!result.contains("no-ff"),
"no-ff should be removed: {result}"
);
}
#[test]
fn test_detect_select_project_level() {
let content = r#"
[projects."github.com/user/repo".select]
pager = "bat"
"#;
let deprecations = detect_deprecations(content);
assert!(has_kind(&deprecations, |k| matches!(
k,
DeprecationKind::Select
)));
}
#[test]
fn test_migrate_select_project_level() {
let content = r#"
[projects."github.com/user/repo".select]
pager = "bat"
"#;
let result = migrate_content(content);
assert!(
result.contains("[projects.\"github.com/user/repo\".switch.picker]"),
"Should migrate project select: {result}"
);
assert!(
!result.contains("[projects.\"github.com/user/repo\".select]"),
"Should remove project select: {result}"
);
}
#[test]
fn test_migrate_content_applies_all_structural_migrations() {
let content = r#"
[commit-generation]
command = "llm"
[select]
pager = "delta"
[merge]
no-ff = true
[switch]
no-cd = true
"#;
let result = migrate_content(content);
assert!(
result.contains("[commit.generation]"),
"commit-generation: {result}"
);
assert!(
result.contains("[switch.picker]"),
"select to switch.picker: {result}"
);
assert!(result.contains("ff = false"), "no-ff to ff: {result}");
assert!(result.contains("cd = false"), "no-cd to cd: {result}");
}
#[test]
fn test_migrate_content_is_no_op_for_canonical_config() {
let content = r#"
[commit.generation]
command = "llm"
[merge]
ff = true
"#;
let result = migrate_content(content);
assert_eq!(result, content);
}
#[test]
fn test_warn_unknown_fields_deprecated_key_in_wrong_config() {
use crate::config::{ProjectConfig, UnknownWarning, UserConfig, collect_unknown_warnings};
let warnings =
collect_unknown_warnings::<ProjectConfig>("[commit-generation]\ncommand = \"llm\"\n");
assert!(
matches!(
warnings.as_slice(),
[UnknownWarning::NestedWrongConfig { path, other_description }]
if path == "commit.generation.command" && *other_description == "user config"
),
"expected one NestedWrongConfig → user config, got {warnings:?}"
);
let warnings = collect_unknown_warnings::<UserConfig>("[ci]\nplatform = \"github\"\n");
assert!(
matches!(
warnings.as_slice(),
[UnknownWarning::TopLevelDeprecatedWrongConfig { other_description, .. }]
if *other_description == "project config"
),
"expected one TopLevelDeprecatedWrongConfig → project config, got {warnings:?}"
);
let path = std::env::temp_dir().join("test-deprecated-wrong-config-project.toml");
warn_unknown_fields::<ProjectConfig>(
"[commit-generation]\ncommand = \"llm\"\n",
&path,
"Project config",
);
}
#[test]
fn snapshot_migrate_all_rules_combined() {
let content = r#"worktree-path = "../{{ repo_root }}.{{ branch }}"
pre-create = "npm install"
[commit-generation]
command = "llm"
args = ["-m", "haiku"]
[ci]
platform = "github"
[select]
pager = "delta"
timeout-ms = 500
[merge]
no-ff = true
[switch]
no-cd = true
[post-create]
server = "npm run dev"
[projects."github.com/user/repo"]
approved-commands = ["npm test"]
"#;
let migrated = compute_migrated_content(content);
assert_eq!(
compute_migrated_content(&migrated),
migrated,
"migration must be idempotent"
);
assert!(
detect_deprecations(&migrated).is_empty(),
"applying the update must silence every warning; got {:?}",
detect_deprecations(&migrated)
);
insta::assert_snapshot!(migration_diff(content, &migrated));
}
}