use std::borrow::Cow;
use std::collections::HashSet;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex, OnceLock};
use color_print::cformat;
use minijinja::Environment;
use regex::Regex;
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 DEPRECATED_VAR_REGEXES: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
DEPRECATED_VARS
.iter()
.map(|&(old, new)| {
let re = Regex::new(&format!(r"\b{}\b", regex::escape(old))).unwrap();
(re, new)
})
.collect()
});
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 mut result = template.to_string();
for (re, new) in DEPRECATED_VAR_REGEXES.iter() {
result = re.replace_all(&result, *new).into_owned();
}
Cow::Owned(result)
}
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 replace_deprecated_vars_from_strings(content: &str, template_strings: &[String]) -> String {
let mut result = content.to_string();
for original in template_strings {
let mut modified = original.clone();
for (re, new) in DEPRECATED_VAR_REGEXES.iter() {
modified = re.replace_all(&modified, *new).into_owned();
}
if modified != *original {
result = result.replace(original, &modified);
}
}
result
}
#[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 post_create: bool,
pub ci_section: bool,
pub no_ff: bool,
pub no_cd: bool,
pub pre_hook_table_form: Vec<String>,
}
impl Deprecations {
pub fn is_empty(&self) -> bool {
self.vars.is_empty()
&& self.commit_gen.is_empty()
&& !self.approved_commands
&& !self.select
&& !self.post_create
&& !self.ci_section
&& !self.no_ff
&& !self.no_cd
&& self.pre_hook_table_form.is_empty()
}
}
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),
post_create: find_post_create_from_doc(doc),
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),
}
}
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 = doc
.get("commit")
.and_then(|c| c.as_table())
.and_then(|t| t.get("generation"))
.is_some_and(|g| g.is_table() || g.is_inline_table());
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 = project_table
.get("commit")
.and_then(|c| c.as_table())
.and_then(|t| t.get("generation"))
.is_some_and(|g| g.is_table() || g.is_inline_table());
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 migrate_commit_generation_doc(doc: &mut toml_edit::DocumentMut) -> bool {
let mut modified = false;
let has_new_section = doc
.get("commit")
.and_then(|c| c.as_table())
.and_then(|t| t.get("generation"))
.is_some_and(|g| g.is_table() || g.is_inline_table());
if !has_new_section && let Some(old_section) = doc.remove("commit-generation") {
let table_opt = match old_section {
toml_edit::Item::Table(t) => Some(t),
toml_edit::Item::Value(toml_edit::Value::InlineTable(it)) => Some(it.into_table()),
_ => None,
};
if let Some(mut table) = table_opt {
merge_args_into_command(&mut table);
if !doc.contains_key("commit") {
let mut commit_table = toml_edit::Table::new();
commit_table.set_implicit(true);
doc.insert("commit", toml_edit::Item::Table(commit_table));
}
if let Some(commit_table) = doc["commit"].as_table_mut() {
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 = project_table
.get("commit")
.and_then(|c| c.as_table())
.and_then(|t| t.get("generation"))
.is_some_and(|g| g.is_table() || g.is_inline_table());
if !has_new_project_section
&& let Some(old_section) = project_table.remove("commit-generation")
{
let table_opt = match old_section {
toml_edit::Item::Table(t) => Some(t),
toml_edit::Item::Value(toml_edit::Value::InlineTable(it)) => {
Some(it.into_table())
}
_ => None,
};
if let Some(mut table) = table_opt {
merge_args_into_command(&mut table);
if !project_table.contains_key("commit") {
let mut commit_table = toml_edit::Table::new();
commit_table.set_implicit(true);
project_table.insert("commit", toml_edit::Item::Table(commit_table));
}
if let Some(commit_table) = project_table["commit"].as_table_mut() {
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 = table
.get("switch")
.and_then(|s| s.as_table())
.and_then(|t| t.get("picker"))
.is_some_and(|p| p.is_table() || p.is_inline_table());
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 find_post_create_from_doc(doc: &toml_edit::DocumentMut) -> bool {
if doc.get("pre-start").is_none() && doc.get("post-create").is_some_and(is_non_empty_item) {
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()
&& project_table.get("pre-start").is_none()
&& project_table
.get("post-create")
.is_some_and(is_non_empty_item)
{
return true;
}
}
}
false
}
fn is_non_empty_item(item: &toml_edit::Item) -> bool {
match item {
toml_edit::Item::Value(toml_edit::Value::InlineTable(t)) => !t.is_empty(),
toml_edit::Item::Table(t) => !t.is_empty(),
_ => true, }
}
fn migrate_post_create_doc(doc: &mut toml_edit::DocumentMut) -> bool {
let mut modified = false;
if doc.get("pre-start").is_none()
&& let Some(value) = doc.remove("post-create")
{
doc.insert("pre-start", 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("pre-start").is_none()
&& let Some(value) = project_table.remove("post-create")
{
project_table.insert("pre-start", value);
modified = true;
}
}
}
modified
}
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 = table
.get("switch")
.and_then(|s| s.as_table())
.and_then(|t| t.get("picker"))
.is_some_and(|p| p.is_table() || p.is_inline_table());
if has_new_section {
return;
}
let Some(old_section) = table.remove("select") else {
return;
};
let table_opt = match old_section {
toml_edit::Item::Table(t) => Some(t),
toml_edit::Item::Value(toml_edit::Value::InlineTable(it)) => Some(it.into_table()),
_ => None,
};
let Some(select_table) = table_opt else {
return;
};
if !table.contains_key("switch") {
let mut switch_table = toml_edit::Table::new();
switch_table.set_implicit(true);
table.insert("switch", toml_edit::Item::Table(switch_table));
}
if let Some(switch_table) = table["switch"].as_table_mut() {
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)
&& item.as_table().is_some_and(|t| t.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 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;
};
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)
&& v.as_table()
.is_some_and(|t| t.len() >= 2 && t.iter().all(|(_, v)| v.as_str().is_some()))
})
.map(|(k, _)| k.to_string())
.collect();
for key in keys_to_migrate {
let item = table.get_mut(&key).unwrap();
let entries = item.as_table().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().unwrap()));
arr.push(block);
}
*item = toml_edit::Item::ArrayOfTables(arr);
*modified = true;
}
}
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_post_create_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
}
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) -> Option<PathBuf> {
let approvals_path = config_path.with_file_name("approvals.toml");
if approvals_path.exists() {
return None; }
let approvals = super::approvals::Approvals::load_from_config_file(config_path).ok()?;
approvals.projects().next()?;
approvals.save_to(&approvals_path).ok()?;
Some(approvals_path)
}
fn merge_args_into_command(table: &mut toml_edit::Table) {
let can_merge = table.get("args").is_some_and(|a| a.as_array().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() {
eprint!("{}", format_deprecation_warnings(&info));
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 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 after_vars = if !deprecations.vars.is_empty() {
replace_deprecated_vars_from_strings(content, &template_strings)
} else {
content.to_string()
};
let mut doc = after_vars
.parse::<toml_edit::DocumentMut>()
.expect("template-var replacement preserves TOML structure");
let mut modified = migrate_content_doc(&mut doc);
if deprecations.approved_commands {
modified |= remove_approved_commands_doc(&mut doc);
}
if modified {
doc.to_string()
} else {
after_vars
}
}
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.post_create {
let _ = writeln!(
out,
"{}",
warning_message(cformat!(
"{}: <bold>post-create</> hook is deprecated in favor of <bold>pre-start</>",
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.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)
}
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::NestedUnknown { path } => {
cformat!("{label} has unknown field <bold>{path}</> (will be ignored)")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
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 strings = extract_template_strings(content);
replace_deprecated_vars_from_strings(content, &strings)
}
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 find_post_create_deprecation(content: &str) -> bool {
content
.parse::<toml_edit::DocumentMut>()
.ok()
.is_some_and(|doc| find_post_create_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()
}
}
fn migrate_post_create_to_pre_start(content: &str) -> String {
let Ok(mut doc) = content.parse::<toml_edit::DocumentMut>() else {
return content.to_string();
};
if migrate_post_create_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-create = "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-create = "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-create = "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-create = "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-create = "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-create = "{{ 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-create = "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_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-create = "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-create = "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-create = "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_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_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 = r#"post-create = "{{ repo_root }}/script.sh""#;
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 = r#"post-create = "{{ repo_root }}/script.sh""#;
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"
);
}
#[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"
);
}
#[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,
post_create: false,
ci_section: false,
no_ff: false,
no_cd: false,
pre_hook_table_form: vec![],
},
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);
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);
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_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);
assert!(
result.is_none(),
"Should skip when no approved-commands exist"
);
}
#[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_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,
post_create: false,
ci_section: false,
no_ff: false,
no_cd: false,
pre_hook_table_form: vec![],
},
label: "User config".to_string(),
main_worktree_path: None,
};
let output = format_deprecation_details(&info, content);
assert!(
output.contains("[select]"),
"Should mention [select] in output: {output}"
);
assert!(
output.contains("[switch.picker]"),
"Should mention [switch.picker]: {output}"
);
}
#[test]
fn test_compute_migrated_content_renames_select() {
let content = r#"worktree-path = "../{{ repo }}.{{ branch | sanitize }}"
[select]
pager = "delta --paging=never"
"#;
let migrated = compute_migrated_content(content);
assert!(
migrated.contains("[switch.picker]"),
"Migrated content should have [switch.picker]: {migrated}"
);
assert!(
!migrated.contains("[select]"),
"Migrated content should not have [select]: {migrated}"
);
}
#[test]
fn test_find_post_create_deprecation_none() {
let content = r#"
pre-start = "npm install"
"#;
assert!(!find_post_create_deprecation(content));
}
#[test]
fn test_find_post_create_deprecation_top_level() {
let content = r#"
post-create = "npm install"
"#;
assert!(find_post_create_deprecation(content));
}
#[test]
fn test_find_post_create_deprecation_project_level() {
let content = r#"
[projects."my-project"]
post-create = "npm install"
"#;
assert!(find_post_create_deprecation(content));
}
#[test]
fn test_find_post_create_deprecation_named_commands() {
let content = r#"
[post-create]
lint = "npm run lint"
build = "npm run build"
"#;
assert!(find_post_create_deprecation(content));
}
#[test]
fn test_find_post_create_deprecation_empty_table_not_flagged() {
let content = r#"
[post-create]
"#;
assert!(!find_post_create_deprecation(content));
}
#[test]
fn test_find_post_create_deprecation_skips_when_pre_start_exists_top_level() {
let content = r#"
post-create = "old"
pre-start = "new"
"#;
assert!(!find_post_create_deprecation(content));
}
#[test]
fn test_find_post_create_deprecation_skips_when_pre_start_exists_project() {
let content = r#"
[projects."my-project"]
post-create = "old"
pre-start = "new"
"#;
assert!(!find_post_create_deprecation(content));
}
#[test]
fn test_migrate_post_create_top_level() {
let content = r#"
post-create = "npm install"
[post-start]
server = "npm run dev"
"#;
let result = migrate_post_create_to_pre_start(content);
assert!(
result.contains("pre-start"),
"Should have pre-start: {result}"
);
assert!(
!result.contains("post-create"),
"Should not have post-create: {result}"
);
assert!(
result.contains("[post-start]"),
"Should preserve other sections: {result}"
);
}
#[test]
fn test_migrate_post_create_project_level() {
let content = r#"
[projects."my-project"]
post-create = "npm install"
"#;
let result = migrate_post_create_to_pre_start(content);
assert!(
result.contains("pre-start"),
"Should have pre-start: {result}"
);
assert!(
!result.contains("post-create"),
"Should not have post-create: {result}"
);
}
#[test]
fn test_migrate_post_create_named_commands() {
let content = r#"
[post-create]
lint = "npm run lint"
build = "npm run build"
"#;
let result = migrate_post_create_to_pre_start(content);
assert!(
result.contains("[pre-start]"),
"Should rename section header: {result}"
);
assert!(
!result.contains("[post-create]"),
"Should not have old section header: {result}"
);
assert!(
result.contains("lint = \"npm run lint\""),
"Should preserve named commands: {result}"
);
}
#[test]
fn test_migrate_post_create_skips_when_pre_start_exists() {
let content = r#"
post-create = "old"
pre-start = "new"
"#;
let result = migrate_post_create_to_pre_start(content);
assert_eq!(
result, content,
"Should not migrate when pre-start already exists"
);
}
#[test]
fn test_migrate_post_create_invalid_toml() {
let content = "this is { not valid toml";
let result = migrate_post_create_to_pre_start(content);
assert_eq!(result, content, "Invalid TOML should be returned unchanged");
}
#[test]
fn test_migrate_post_create_no_post_create() {
let content = r#"
pre-start = "npm install"
"#;
let result = migrate_post_create_to_pre_start(content);
assert_eq!(result, content, "No post-create means no migration");
}
#[test]
fn test_detect_deprecations_includes_post_create() {
let content = r#"
post-create = "npm install"
"#;
let deprecations = detect_deprecations(content);
assert!(deprecations.post_create);
assert!(!deprecations.is_empty());
}
#[test]
fn snapshot_migrate_post_create_to_pre_start() {
let content = r#"post-create = "npm install"
[post-start]
server = "npm run dev"
"#;
let result = migrate_post_create_to_pre_start(content);
insta::assert_snapshot!(migration_diff(content, &result));
}
#[test]
fn test_format_deprecation_details_post_create() {
let content = r#"post-create = "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: false,
select: false,
post_create: true,
ci_section: false,
no_ff: false,
no_cd: false,
pre_hook_table_form: vec![],
},
label: "Project config".to_string(),
main_worktree_path: None,
};
let output = format_deprecation_details(&info, content);
assert!(
output.contains("post-create"),
"Should mention post-create: {output}"
);
assert!(
output.contains("pre-start"),
"Should mention pre-start: {output}"
);
}
#[test]
fn test_compute_migrated_content_renames_post_create() {
let content = r#"post-create = "npm install"
[post-start]
server = "npm run dev"
"#;
let migrated = compute_migrated_content(content);
assert!(
migrated.contains("pre-start"),
"Migrated content should have pre-start: {migrated}"
);
assert!(
!migrated.contains("post-create"),
"Migrated content should not have post-create: {migrated}"
);
}
#[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,
post_create: false,
ci_section: false,
no_ff: true,
no_cd: true,
pre_hook_table_form: vec![],
},
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, UserConfig};
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",
);
let path = std::env::temp_dir().join("test-deprecated-wrong-config-user.toml");
warn_unknown_fields::<UserConfig>("[ci]\nplatform = \"github\"\n", &path, "User 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!(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_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_pre_hook_table_form_after_post_create_rename() {
let content = r#"
[post-create]
install = "npm ci"
build = "npm run build"
"#;
let result = migrate_content(content);
assert!(
!result.contains("post-create"),
"post-create should be renamed: {result}"
);
let doc: toml_edit::DocumentMut = result.parse().unwrap();
let arr = doc["pre-start"]
.as_array_of_tables()
.expect("should be pipeline 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));
}
}