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> {
if !DEPRECATED_VARS
.iter()
.any(|(old, _)| template.contains(old))
{
return Cow::Borrowed(template);
}
let env = Environment::new();
let Ok(parsed) = env.template_from_str(template) else {
return Cow::Borrowed(template);
};
let used_vars = parsed.undeclared_variables(false);
let replacements: Vec<_> = DEPRECATED_VARS
.iter()
.copied()
.filter(|(old, _)| used_vars.contains(*old))
.collect();
if replacements.is_empty() {
return Cow::Borrowed(template);
}
rewrite_template_var_identifiers(template, &replacements)
.map(Cow::Owned)
.unwrap_or(Cow::Borrowed(template))
}
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 find_deprecated_vars_from_strings(
template_strings: &[String],
) -> Vec<(&'static str, &'static str)> {
let mut used_vars = HashSet::new();
let env = Environment::new();
for template_str in template_strings {
if let Ok(template) = env.template_from_str(template_str) {
used_vars.extend(template.undeclared_variables(false));
}
}
DEPRECATED_VARS
.iter()
.filter(|(old, _)| used_vars.contains(*old))
.copied()
.collect()
}
fn extract_template_strings_from_doc(doc: &toml_edit::DocumentMut) -> Vec<String> {
let mut strings = Vec::new();
collect_strings_from_edit_table(doc.as_table(), &mut strings);
strings
}
fn collect_strings_from_edit_table(table: &toml_edit::Table, strings: &mut Vec<String>) {
for (_, item) in table.iter() {
collect_strings_from_edit_item(item, strings);
}
}
fn collect_strings_from_edit_item(item: &toml_edit::Item, strings: &mut Vec<String>) {
match item {
toml_edit::Item::Value(v) => collect_strings_from_edit_value(v, strings),
toml_edit::Item::Table(t) => collect_strings_from_edit_table(t, strings),
toml_edit::Item::ArrayOfTables(arr) => {
for t in arr.iter() {
collect_strings_from_edit_table(t, strings);
}
}
_ => {}
}
}
fn collect_strings_from_edit_value(value: &toml_edit::Value, strings: &mut Vec<String>) {
match value {
toml_edit::Value::String(s) => strings.push(s.value().clone()),
toml_edit::Value::Array(arr) => {
for v in arr.iter() {
collect_strings_from_edit_value(v, strings);
}
}
toml_edit::Value::InlineTable(t) => {
for (_, v) in t.iter() {
collect_strings_from_edit_value(v, strings);
}
}
_ => {}
}
}
fn rewrite_deprecated_vars(original: &str) -> Option<String> {
match normalize_template_vars(original) {
Cow::Borrowed(_) => None,
Cow::Owned(modified) => Some(modified),
}
}
fn replace_deprecated_vars_in_doc(doc: &mut toml_edit::DocumentMut) -> bool {
fn walk_table(table: &mut toml_edit::Table, changed: &mut bool) {
for (_, item) in table.iter_mut() {
walk_item(item, changed);
}
}
fn walk_item(item: &mut toml_edit::Item, changed: &mut bool) {
match item {
toml_edit::Item::Value(v) => walk_value(v, changed),
toml_edit::Item::Table(t) => walk_table(t, changed),
toml_edit::Item::ArrayOfTables(arr) => {
for t in arr.iter_mut() {
walk_table(t, changed);
}
}
_ => {}
}
}
fn walk_value(value: &mut toml_edit::Value, changed: &mut bool) {
match value {
toml_edit::Value::String(s) => {
if let Some(new) = rewrite_deprecated_vars(s.value()) {
let decor = s.decor().clone();
let mut formatted = toml_edit::Formatted::new(new);
*formatted.decor_mut() = decor;
*value = toml_edit::Value::String(formatted);
*changed = true;
}
}
toml_edit::Value::Array(arr) => {
for v in arr.iter_mut() {
walk_value(v, changed);
}
}
toml_edit::Value::InlineTable(t) => {
for (_, v) in t.iter_mut() {
walk_value(v, changed);
}
}
_ => {}
}
}
let mut changed = false;
walk_table(doc.as_table_mut(), &mut changed);
changed
}
#[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, Default, Clone)]
pub struct Deprecations {
pub vars: Vec<(&'static str, &'static str)>,
pub commit_gen: CommitGenerationDeprecations,
pub approved_commands: bool,
pub select: bool,
pub pre_start: bool,
pub post_start: bool,
pub ci_section: bool,
pub no_ff: bool,
pub no_cd: bool,
pub pre_hook_table_form: Vec<String>,
pub switch_picker_timeout_ms: bool,
}
impl Deprecations {
pub fn is_empty(&self) -> bool {
self.vars.is_empty()
&& self.commit_gen.is_empty()
&& !self.approved_commands
&& !self.select
&& !self.pre_start
&& !self.post_start
&& !self.ci_section
&& !self.no_ff
&& !self.no_cd
&& self.pre_hook_table_form.is_empty()
&& !self.switch_picker_timeout_ms
}
}
pub fn detect_deprecations(content: &str) -> Deprecations {
let Ok(doc) = content.parse::<toml_edit::DocumentMut>() else {
return Deprecations::default();
};
let template_strings = extract_template_strings_from_doc(&doc);
detect_deprecations_from_doc(&doc, &template_strings)
}
fn detect_deprecations_from_doc(
doc: &toml_edit::DocumentMut,
template_strings: &[String],
) -> Deprecations {
Deprecations {
vars: find_deprecated_vars_from_strings(template_strings),
commit_gen: find_commit_generation_from_doc(doc),
approved_commands: find_approved_commands_from_doc(doc),
select: find_select_from_doc(doc),
pre_start: false,
post_start: false,
ci_section: find_ci_section_from_doc(doc),
no_ff: find_negated_bool_from_doc(doc, "merge", "no-ff", "ff"),
no_cd: find_negated_bool_from_doc(doc, "switch", "no-cd", "cd"),
pre_hook_table_form: find_pre_hook_table_form_from_doc(doc),
switch_picker_timeout_ms: find_switch_picker_timeout_from_doc(doc),
}
}
fn find_approved_commands_from_doc(doc: &toml_edit::DocumentMut) -> bool {
let Some(projects) = doc.get("projects").and_then(|p| p.as_table()) else {
return false;
};
for (_project_key, project_value) in projects.iter() {
if let Some(project_table) = project_value.as_table()
&& let Some(approved) = project_table.get("approved-commands")
&& approved.as_array().is_some_and(|a| !a.is_empty())
{
return true;
}
}
false
}
fn find_commit_generation_from_doc(doc: &toml_edit::DocumentMut) -> CommitGenerationDeprecations {
let mut result = CommitGenerationDeprecations::default();
let has_new_section = has_table_like_child(doc.get("commit"), "generation");
if !has_new_section && let Some(section) = doc.get("commit-generation") {
if let Some(table) = section.as_table() {
if !table.is_empty() {
result.has_top_level = true;
}
} else if let Some(inline) = section.as_inline_table()
&& !inline.is_empty()
{
result.has_top_level = true;
}
}
if let Some(projects) = doc.get("projects").and_then(|p| p.as_table()) {
for (project_key, project_value) in projects.iter() {
if let Some(project_table) = project_value.as_table() {
let has_new_project_section =
has_table_like_child(project_table.get("commit"), "generation");
if !has_new_project_section
&& let Some(old_section) = project_table.get("commit-generation")
{
let is_non_empty = old_section.as_table().is_some_and(|t| !t.is_empty())
|| old_section.as_inline_table().is_some_and(|t| !t.is_empty());
if is_non_empty {
result.project_keys.push(project_key.to_string());
}
}
}
}
}
result
}
fn is_table_like(item: &toml_edit::Item) -> bool {
matches!(
item,
toml_edit::Item::Table(_) | toml_edit::Item::Value(toml_edit::Value::InlineTable(_))
)
}
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_doc(doc: &mut toml_edit::DocumentMut) -> bool {
let mut modified = false;
let has_new_section = has_table_like_child(doc.get("commit"), "generation");
if !has_new_section
&& doc.get("commit-generation").is_some_and(is_table_like)
&& can_host_subtable(doc.get("commit"))
&& let Some(old_section) = doc.remove("commit-generation")
{
let mut table = into_table(old_section).expect("checked is_table_like above");
merge_args_into_command(&mut table);
if let Some(commit_table) = ensure_standard_table_parent(doc.as_table_mut(), "commit") {
commit_table.insert("generation", toml_edit::Item::Table(table));
}
modified = true;
}
if let Some(projects) = doc.get_mut("projects").and_then(|p| p.as_table_mut()) {
for (_project_key, project_value) in projects.iter_mut() {
if let Some(project_table) = project_value.as_table_mut() {
let has_new_project_section =
has_table_like_child(project_table.get("commit"), "generation");
if !has_new_project_section
&& project_table
.get("commit-generation")
.is_some_and(is_table_like)
&& can_host_subtable(project_table.get("commit"))
&& let Some(old_section) = project_table.remove("commit-generation")
{
let mut table = into_table(old_section).expect("checked is_table_like above");
merge_args_into_command(&mut table);
if let Some(commit_table) =
ensure_standard_table_parent(project_table, "commit")
{
commit_table.insert("generation", toml_edit::Item::Table(table));
}
modified = true;
}
}
}
}
modified
}
fn remove_approved_commands_doc(doc: &mut toml_edit::DocumentMut) -> bool {
let mut modified = false;
if let Some(projects) = doc.get_mut("projects").and_then(|p| p.as_table_mut()) {
let mut remove_from: Vec<String> = Vec::new();
let mut emptied: Vec<String> = Vec::new();
for (project_key, project_value) in projects.iter() {
if let Some(project_table) = project_value.as_table()
&& project_table.contains_key("approved-commands")
{
remove_from.push(project_key.to_string());
if project_table.len() == 1 {
emptied.push(project_key.to_string());
}
}
}
for key in &remove_from {
if let Some(project_value) = projects.get_mut(key)
&& let Some(project_table) = project_value.as_table_mut()
{
project_table.remove("approved-commands");
modified = true;
}
}
for key in &emptied {
projects.remove(key);
}
}
if doc
.get("projects")
.and_then(|p| p.as_table())
.is_some_and(|t| t.is_empty())
{
doc.remove("projects");
modified = true;
}
modified
}
fn find_select_from_doc(doc: &toml_edit::DocumentMut) -> bool {
if has_select_without_picker(doc) {
return true;
}
if let Some(projects) = doc.get("projects").and_then(|p| p.as_table()) {
for (_key, project_value) in projects.iter() {
if let Some(project_table) = project_value.as_table()
&& has_select_without_picker(project_table)
{
return true;
}
}
}
false
}
fn has_select_without_picker(table: &toml_edit::Table) -> bool {
let has_new_section = has_table_like_child(table.get("switch"), "picker");
if has_new_section {
return false;
}
if let Some(section) = table.get("select") {
if let Some(t) = section.as_table() {
return !t.is_empty();
}
if let Some(t) = section.as_inline_table() {
return !t.is_empty();
}
}
false
}
fn migrate_select_doc(doc: &mut toml_edit::DocumentMut) -> bool {
let mut modified = false;
migrate_select_table(doc.as_table_mut(), &mut modified);
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() {
migrate_select_table(project_table, &mut modified);
}
}
}
modified
}
fn migrate_select_table(table: &mut toml_edit::Table, modified: &mut bool) {
let has_new_section = has_table_like_child(table.get("switch"), "picker");
if has_new_section {
return;
}
if !table.get("select").is_some_and(is_table_like) {
return;
}
if !can_host_subtable(table.get("switch")) {
return;
}
let select_table =
into_table(table.remove("select").unwrap()).expect("checked is_table_like above");
if let Some(switch_table) = ensure_standard_table_parent(table, "switch") {
switch_table.insert("picker", toml_edit::Item::Table(select_table));
}
*modified = true;
}
const PRE_HOOK_KEYS: &[&str] = &[
"pre-switch",
"pre-start",
"pre-commit",
"pre-merge",
"pre-remove",
];
fn collect_pre_hook_table_form_keys(
table: &toml_edit::Table,
prefix: &str,
found: &mut Vec<String>,
) {
for &key in PRE_HOOK_KEYS {
if let Some(item) = table.get(key)
&& table_like_len(item).is_some_and(|len| len >= 2)
{
if prefix.is_empty() {
found.push(key.to_string());
} else {
found.push(format!("{prefix}.{key}"));
}
}
}
}
fn find_pre_hook_table_form_from_doc(doc: &toml_edit::DocumentMut) -> Vec<String> {
let mut found = Vec::new();
collect_pre_hook_table_form_keys(doc.as_table(), "", &mut found);
if let Some(projects) = doc.get("projects").and_then(|p| p.as_table()) {
for (project_key, project_value) in projects.iter() {
if let Some(project_table) = project_value.as_table() {
let prefix = format!("projects.\"{project_key}\"");
collect_pre_hook_table_form_keys(project_table, &prefix, &mut found);
}
}
}
found
}
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 find_ci_section_from_doc(doc: &toml_edit::DocumentMut) -> bool {
if doc
.get("forge")
.is_some_and(|f| f.is_table() || f.is_inline_table())
{
return false;
}
doc.get("ci")
.and_then(|ci| ci.as_table())
.and_then(|t| t.get("platform"))
.is_some_and(|p| p.as_str().is_some_and(|s| !s.is_empty()))
}
fn migrate_ci_doc(doc: &mut toml_edit::DocumentMut) -> bool {
if doc
.get("forge")
.is_some_and(|f| f.is_table() || f.is_inline_table())
{
return false;
}
let platform = doc
.get("ci")
.and_then(|ci| ci.as_table())
.and_then(|t| t.get("platform"))
.and_then(|p| p.as_str())
.map(String::from);
let Some(platform) = platform else {
return false;
};
if let Some(ci_table) = doc.get_mut("ci").and_then(|ci| ci.as_table_mut()) {
ci_table.remove("platform");
if ci_table.is_empty() {
doc.remove("ci");
}
}
let mut forge_table = toml_edit::Table::new();
forge_table.insert("platform", toml_edit::value(platform));
doc.insert("forge", toml_edit::Item::Table(forge_table));
true
}
fn find_negated_bool_from_doc(
doc: &toml_edit::DocumentMut,
section: &str,
old_key: &str,
new_key: &str,
) -> bool {
if let Some(table) = doc.get(section).and_then(|s| s.as_table())
&& !table.contains_key(new_key)
&& table.contains_key(old_key)
{
return true;
}
if let Some(projects) = doc.get("projects").and_then(|p| p.as_table()) {
for (_key, project_value) in projects.iter() {
if let Some(table) = project_value
.as_table()
.and_then(|t| t.get(section))
.and_then(|s| s.as_table())
&& !table.contains_key(new_key)
&& table.contains_key(old_key)
{
return true;
}
}
}
false
}
fn migrate_negated_bool(table: &mut toml_edit::Table, old_key: &str, new_key: &str) -> bool {
if table.contains_key(new_key) {
return table.remove(old_key).is_some();
}
let Some(old_item) = table.remove(old_key) else {
return false;
};
if let Some(bool_val) = old_item.as_value().and_then(|v| v.as_bool()) {
table.insert(new_key, toml_edit::value(!bool_val));
true
} else {
table.insert(old_key, old_item);
false
}
}
fn migrate_negated_bool_doc(
doc: &mut toml_edit::DocumentMut,
section: &str,
old_key: &str,
new_key: &str,
) -> bool {
let mut modified = false;
if let Some(table) = doc.get_mut(section).and_then(|s| s.as_table_mut())
&& migrate_negated_bool(table, old_key, new_key)
{
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(table) = project_value
.as_table_mut()
.and_then(|t| t.get_mut(section))
.and_then(|s| s.as_table_mut())
&& migrate_negated_bool(table, old_key, new_key)
{
modified = true;
}
}
}
modified
}
fn migrate_pre_hook_table_in(table: &mut toml_edit::Table, modified: &mut bool) {
let keys_to_migrate: Vec<String> = table
.iter()
.filter(|(k, v)| {
PRE_HOOK_KEYS.contains(k)
&& pre_hook_pipeline_entries(v).is_some_and(|entries| entries.len() >= 2)
})
.map(|(k, _)| k.to_string())
.collect();
for key in keys_to_migrate {
let item = table.get_mut(&key).unwrap();
let entries = pre_hook_pipeline_entries(item).unwrap();
let mut arr = toml_edit::ArrayOfTables::new();
for (name, value) in entries.iter() {
let mut block = toml_edit::Table::new();
block.insert(name, toml_edit::value(value.as_str()));
arr.push(block);
}
*item = toml_edit::Item::ArrayOfTables(arr);
*modified = true;
}
}
fn pre_hook_pipeline_entries(item: &toml_edit::Item) -> Option<Vec<(String, String)>> {
match item {
toml_edit::Item::Table(t) => {
let entries = t
.iter()
.map(|(name, value)| Some((name.to_string(), value.as_str()?.to_string())))
.collect::<Option<Vec<_>>>()?;
Some(entries)
}
toml_edit::Item::Value(toml_edit::Value::InlineTable(t)) => {
let entries = t
.iter()
.map(|(name, value)| Some((name.to_string(), value.as_str()?.to_string())))
.collect::<Option<Vec<_>>>()?;
Some(entries)
}
_ => None,
}
}
fn migrate_pre_hook_table_form_doc(doc: &mut toml_edit::DocumentMut) -> bool {
let mut modified = false;
migrate_pre_hook_table_in(doc.as_table_mut(), &mut modified);
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() {
migrate_pre_hook_table_in(project_table, &mut modified);
}
}
}
modified
}
fn migrate_content_doc(doc: &mut toml_edit::DocumentMut) -> bool {
let mut modified = false;
modified |= migrate_commit_generation_doc(doc);
modified |= migrate_select_doc(doc);
modified |= migrate_create_hooks_doc(doc);
modified |= migrate_pre_hook_table_form_doc(doc);
modified |= migrate_ci_doc(doc);
modified |= migrate_negated_bool_doc(doc, "merge", "no-ff", "ff");
modified |= migrate_negated_bool_doc(doc, "switch", "no-cd", "cd");
modified |= migrate_switch_picker_timeout_doc(doc);
modified
}
fn migrate_create_hooks_doc(doc: &mut toml_edit::DocumentMut) -> bool {
let mut modified = rename_hook_key(doc, "pre-create", "pre-start");
modified |= rename_hook_key(doc, "post-create", "post-start");
modified
}
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 has_switch_picker_timeout(table: &toml_edit::Table) -> bool {
table
.get("switch")
.and_then(|s| s.as_table())
.and_then(|t| t.get("picker"))
.and_then(|p| match p {
toml_edit::Item::Table(t) => Some(t.contains_key("timeout-ms")),
toml_edit::Item::Value(toml_edit::Value::InlineTable(it)) => {
Some(it.contains_key("timeout-ms"))
}
_ => None,
})
.unwrap_or(false)
}
fn find_switch_picker_timeout_from_doc(doc: &toml_edit::DocumentMut) -> bool {
if has_switch_picker_timeout(doc.as_table()) {
return true;
}
if let Some(projects) = doc.get("projects").and_then(|p| p.as_table()) {
for (_key, project_value) in projects.iter() {
if let Some(project_table) = project_value.as_table()
&& has_switch_picker_timeout(project_table)
{
return true;
}
}
}
false
}
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_switch_picker_timeout_doc(doc: &mut toml_edit::DocumentMut) -> bool {
let mut modified = remove_switch_picker_timeout_in(doc.as_table_mut());
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() {
modified |= remove_switch_picker_timeout_in(project_table);
}
}
}
modified
}
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 template_strings = extract_template_strings_from_doc(&doc);
let deprecations = detect_deprecations_from_doc(&doc, &template_strings);
let migrated_content = migrate_content_from_doc(content, doc);
(deprecations, migrated_content)
}
Err(_) => (Deprecations::default(), 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");
let template_strings = extract_template_strings_from_doc(&doc);
let deprecations = detect_deprecations_from_doc(&doc, &template_strings);
let mut modified = if !deprecations.vars.is_empty() {
replace_deprecated_vars_in_doc(&mut doc)
} else {
false
};
modified |= migrate_content_doc(&mut doc);
if deprecations.approved_commands {
modified |= remove_approved_commands_doc(&mut doc);
}
if modified {
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 mut out = String::new();
for (old, new) in &info.deprecations.vars {
let _ = writeln!(
out,
"{}",
warning_message(cformat!(
"{label}: template variable <bold>{old}</> is deprecated in favor of <bold>{new}</>",
label = info.label,
))
);
}
if info.deprecations.commit_gen.has_top_level {
let _ = writeln!(
out,
"{}",
warning_message(cformat!(
"{}: <bold>[commit-generation]</> is deprecated in favor of <bold>[commit.generation]</>",
info.label
))
);
}
for project_key in &info.deprecations.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]</>",
label = info.label,
k = project_key
))
);
}
if info.deprecations.approved_commands {
let _ = writeln!(
out,
"{}",
warning_message(cformat!(
"{}: <bold>approved-commands</> under <bold>[projects]</> is deprecated in favor of <bold>approvals.toml</>",
info.label
))
);
}
if info.deprecations.select {
let _ = writeln!(
out,
"{}",
warning_message(cformat!(
"{}: <bold>[select]</> is deprecated in favor of <bold>[switch.picker]</>",
info.label
))
);
}
if info.deprecations.ci_section {
let _ = writeln!(
out,
"{}",
warning_message(cformat!(
"{}: <bold>[ci]</> is deprecated in favor of <bold>[forge]</>",
info.label
))
);
}
if info.deprecations.no_ff {
let _ = writeln!(
out,
"{}",
warning_message(cformat!(
"{}: <bold>merge.no-ff</> is deprecated in favor of <bold>merge.ff</> (inverted)",
info.label
))
);
}
if info.deprecations.no_cd {
let _ = writeln!(
out,
"{}",
warning_message(cformat!(
"{}: <bold>switch.no-cd</> is deprecated in favor of <bold>switch.cd</> (inverted)",
info.label
))
);
}
if info.deprecations.switch_picker_timeout_ms {
let _ = writeln!(
out,
"{}",
warning_message(cformat!(
"{}: <bold>switch.picker.timeout-ms</> is no longer used — the picker now renders progressively",
info.label
))
);
}
if !info.deprecations.pre_hook_table_form.is_empty() {
let hook_list = info
.deprecations
.pre_hook_table_form
.iter()
.map(|h| cformat!("<bold>{h}</>"))
.collect::<Vec<_>>()
.join(", ");
let _ = writeln!(
out,
"{}",
warning_message(cformat!(
"{}: table form for {} is deprecated in favor of the pipeline form. \
We're unifying pre-hooks, post-hooks, and aliases so that list form always runs serially \
and table form always runs in parallel — migrate now to keep the current serial behavior \
once the table form is repurposed.",
info.label,
hook_list
))
);
}
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 extract_template_strings(content: &str) -> Vec<String> {
let Ok(doc) = content.parse::<toml_edit::DocumentMut>() else {
return vec![];
};
extract_template_strings_from_doc(&doc)
}
fn replace_deprecated_vars(content: &str) -> String {
let Ok(mut doc) = content.parse::<toml_edit::DocumentMut>() else {
return content.to_string();
};
if !replace_deprecated_vars_in_doc(&mut doc) {
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)> {
let strings = extract_template_strings(content);
find_deprecated_vars_from_strings(&strings)
}
fn find_commit_generation_deprecations(content: &str) -> CommitGenerationDeprecations {
content
.parse::<toml_edit::DocumentMut>()
.map(|doc| find_commit_generation_from_doc(&doc))
.unwrap_or_default()
}
fn find_approved_commands_deprecation(content: &str) -> bool {
content
.parse::<toml_edit::DocumentMut>()
.ok()
.is_some_and(|doc| find_approved_commands_from_doc(&doc))
}
fn find_select_deprecation(content: &str) -> bool {
content
.parse::<toml_edit::DocumentMut>()
.ok()
.is_some_and(|doc| find_select_from_doc(&doc))
}
fn migrate_commit_generation_sections(content: &str) -> String {
let Ok(mut doc) = content.parse::<toml_edit::DocumentMut>() else {
return content.to_string();
};
if migrate_commit_generation_doc(&mut doc) {
doc.to_string()
} else {
content.to_string()
}
}
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) {
doc.to_string()
} else {
content.to_string()
}
}
fn migrate_select_to_switch_picker(content: &str) -> String {
let Ok(mut doc) = content.parse::<toml_edit::DocumentMut>() else {
return content.to_string();
};
if migrate_select_doc(&mut doc) {
doc.to_string()
} else {
content.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_commit_generation_sections(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_commit_generation_sections(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_commit_generation_sections(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_commit_generation_sections(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_commit_generation_sections(&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_commit_generation_sections(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_commit_generation_sections(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_commit_generation_sections(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_commit_generation_sections(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_commit_generation_sections(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_commit_generation_sections(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_commit_generation_sections(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"
"#;
let result = migrate_content(content);
assert!(
result.contains(r#"platform = "github""#) && result.contains("[forge]"),
"platform should have moved into [forge]; got:\n{result}"
);
assert!(
result.contains(r#"hostname = "ghe.example""#),
"Unrelated [ci].hostname must be preserved; got:\n{result}"
);
}
#[test]
fn test_migrate_invalid_toml_returns_unchanged() {
let content = "this is [not valid {toml";
let result = migrate_commit_generation_sections(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_commit_generation_sections(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_commit_generation_sections(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_commit_generation_sections(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_commit_generation_sections(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_commit_generation_sections(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_commit_generation_sections(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 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!(deprecations.approved_commands);
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: Deprecations {
vars: vec![],
commit_gen: CommitGenerationDeprecations::default(),
approved_commands: true,
select: false,
pre_start: false,
post_start: false,
ci_section: false,
no_ff: false,
no_cd: false,
pre_hook_table_form: vec![],
switch_picker_timeout_ms: false,
},
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_select_to_switch_picker(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_select_to_switch_picker(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_select_to_switch_picker(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_select_to_switch_picker(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_select_to_switch_picker(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!(deprecations.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_select_to_switch_picker(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: Deprecations {
vars: vec![],
commit_gen: CommitGenerationDeprecations::default(),
approved_commands: false,
select: true,
pre_start: false,
post_start: false,
ci_section: false,
no_ff: false,
no_cd: false,
pre_hook_table_form: vec![],
switch_picker_timeout_ms: false,
},
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}"
);
}
fn migrate_switch_picker_timeout(content: &str) -> String {
let Ok(mut doc) = content.parse::<toml_edit::DocumentMut>() else {
return content.to_string();
};
if migrate_switch_picker_timeout_doc(&mut doc) {
doc.to_string()
} else {
content.to_string()
}
}
#[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!(deprecations.switch_picker_timeout_ms);
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!(deprecations.switch_picker_timeout_ms);
}
#[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!(deprecations.switch_picker_timeout_ms);
}
#[test]
fn test_migrate_switch_picker_timeout_inline_table() {
let content = r#"
[switch]
picker = { pager = "delta", timeout-ms = 500 }
"#;
let result = migrate_switch_picker_timeout(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!(!deprecations.switch_picker_timeout_ms);
}
#[test]
fn test_migrate_switch_picker_timeout_removes_key() {
let content = r#"
[switch.picker]
pager = "delta"
timeout-ms = 500
"#;
let result = migrate_switch_picker_timeout(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_switch_picker_timeout(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_switch_picker_timeout(content);
assert_eq!(result, content);
}
#[test]
fn test_migrate_switch_picker_timeout_invalid_toml() {
let content = "this is { not valid toml";
let result = migrate_switch_picker_timeout(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: Deprecations {
switch_picker_timeout_ms: true,
..Deprecations::default()
},
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: Deprecations {
vars: vec![],
commit_gen: CommitGenerationDeprecations::default(),
approved_commands: false,
select: false,
pre_start: false,
post_start: false,
ci_section: false,
no_ff: true,
no_cd: true,
pre_hook_table_form: vec![],
switch_picker_timeout_ms: false,
},
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!(deprecations.no_ff);
}
#[test]
fn test_detect_no_ff_not_flagged_when_ff_exists() {
let deprecations = detect_deprecations("[merge]\nff = true\nno-ff = true\n");
assert!(!deprecations.no_ff);
}
#[test]
fn test_detect_no_cd_deprecation() {
let deprecations = detect_deprecations("[switch]\nno-cd = true\n");
assert!(deprecations.no_cd);
}
#[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!(deprecations.no_ff);
}
#[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!(deprecations.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",
);
}
fn find_pre_hook_table_form(content: &str) -> Vec<String> {
content
.parse::<toml_edit::DocumentMut>()
.map(|doc| find_pre_hook_table_form_from_doc(&doc))
.unwrap_or_default()
}
fn migrate_pre_hook_table_form(content: &str) -> String {
let Ok(mut doc) = content.parse::<toml_edit::DocumentMut>() else {
return content.to_string();
};
if migrate_pre_hook_table_form_doc(&mut doc) {
doc.to_string()
} else {
content.to_string()
}
}
#[test]
fn test_detect_pre_hook_table_form() {
let found = find_pre_hook_table_form("[pre-merge]\ntest = \"t\"\nlint = \"l\"\n");
assert_eq!(found, vec!["pre-merge"]);
let found = find_pre_hook_table_form("[pre-merge]\ntest = \"t\"\n");
assert!(found.is_empty());
let found = find_pre_hook_table_form("pre-merge = \"cargo test\"\n");
assert!(found.is_empty());
let found = find_pre_hook_table_form("pre-merge = { test = \"t\", lint = \"l\" }\n");
assert_eq!(found, vec!["pre-merge"]);
let found = find_pre_hook_table_form("pre-merge = [{test = \"t\"}, {lint = \"l\"}]\n");
assert!(found.is_empty());
let found = find_pre_hook_table_form("[post-merge]\ntest = \"t\"\nlint = \"l\"\n");
assert!(found.is_empty());
let content = r#"
[pre-switch]
a = "1"
b = "2"
[pre-start]
a = "1"
b = "2"
[pre-commit]
a = "1"
b = "2"
[pre-merge]
a = "1"
b = "2"
[pre-remove]
a = "1"
b = "2"
"#;
let found = find_pre_hook_table_form(content);
assert_eq!(
found,
vec![
"pre-switch",
"pre-start",
"pre-commit",
"pre-merge",
"pre-remove"
]
);
}
#[test]
fn test_detect_pre_hook_table_form_per_project() {
let content = r#"
[projects."github.com/user/repo".pre-start]
install = "npm ci"
build = "npm run build"
"#;
let found = find_pre_hook_table_form(content);
assert_eq!(found, vec!["projects.\"github.com/user/repo\".pre-start"]);
}
#[test]
fn test_migrate_pre_hook_table_form_converts_to_pipeline() {
let content = r#"
[pre-merge]
test = "cargo test"
lint = "cargo clippy"
"#;
let result = migrate_pre_hook_table_form(content);
assert!(
result.contains("[[pre-merge]]"),
"Should emit [[pre-merge]] blocks: {result}"
);
let doc: toml_edit::DocumentMut = result.parse().unwrap();
let arr = doc["pre-merge"]
.as_array_of_tables()
.expect("should be array of tables");
assert_eq!(arr.len(), 2);
let first = arr.get(0).unwrap();
assert_eq!(first.get("test").unwrap().as_str().unwrap(), "cargo test");
let second = arr.get(1).unwrap();
assert_eq!(
second.get("lint").unwrap().as_str().unwrap(),
"cargo clippy"
);
}
#[test]
fn test_migrate_pre_hook_inline_table_form_converts_to_pipeline() {
let content = r#"pre-merge = { test = "cargo test", lint = "cargo clippy" }
"#;
let result = migrate_pre_hook_table_form(content);
let doc: toml_edit::DocumentMut = result.parse().unwrap();
let arr = doc["pre-merge"]
.as_array_of_tables()
.expect("should be array of tables");
assert_eq!(arr.len(), 2);
assert_eq!(arr.get(0).unwrap()["test"].as_str(), Some("cargo test"));
assert_eq!(arr.get(1).unwrap()["lint"].as_str(), Some("cargo clippy"));
}
#[test]
fn test_migrate_pre_hook_table_form_preserves_order() {
let content = r#"
[pre-merge]
first = "1"
second = "2"
third = "3"
"#;
let result = migrate_pre_hook_table_form(content);
let doc: toml_edit::DocumentMut = result.parse().unwrap();
let arr = doc["pre-merge"].as_array_of_tables().unwrap();
let names: Vec<&str> = arr.iter().map(|t| t.iter().next().unwrap().0).collect();
assert_eq!(names, vec!["first", "second", "third"]);
}
#[test]
fn test_migrate_pre_hook_table_form_single_entry_untouched() {
let content = "[pre-merge]\ntest = \"t\"\n";
let result = migrate_pre_hook_table_form(content);
assert_eq!(result, content, "Single-entry table should not be migrated");
}
#[test]
fn test_migrate_pre_hook_table_form_per_project() {
let content = r#"
[projects."web".pre-start]
install = "npm ci"
build = "npm run build"
"#;
let result = migrate_pre_hook_table_form(content);
let doc: toml_edit::DocumentMut = result.parse().unwrap();
let project = doc["projects"]["web"].as_table().unwrap();
let arr = project["pre-start"]
.as_array_of_tables()
.expect("should be array of tables");
assert_eq!(arr.len(), 2);
}
#[test]
fn test_migrate_content_includes_pre_hook_table_form() {
let content = r#"
[pre-merge]
test = "cargo test"
lint = "cargo clippy"
[merge]
no-ff = true
"#;
let result = migrate_content(content);
assert!(
result.contains("[[pre-merge]]"),
"Table section should become [[pre-merge]] blocks: {result}"
);
assert!(
result.contains("ff = false"),
"no-ff should also migrate: {result}"
);
}
#[test]
fn snapshot_migrate_pre_hook_table_form() {
let content = r#"[pre-merge]
test = "cargo test"
lint = "cargo clippy"
[post-start]
server = "npm run dev"
"#;
let result = migrate_pre_hook_table_form(content);
insta::assert_snapshot!(migration_diff(content, &result));
}
}