use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
use color_print::cformat;
use minijinja::Environment;
use path_slash::PathExt as _;
use regex::Regex;
use shell_escape::unix::escape;
use crate::config::WorktrunkConfig;
use crate::shell_exec::Cmd;
use crate::styling::{
eprintln, format_bash_with_gutter, format_with_gutter, hint_message, suggest_command_in_dir,
warning_message,
};
static WARNED_DEPRECATED_PATHS: LazyLock<Mutex<HashSet<PathBuf>>> =
LazyLock::new(|| Mutex::new(HashSet::new()));
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 HINT_DEPRECATED_CONFIG: &str = "deprecated-config";
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,
}
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
}
}
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"),
}
}
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(hooks) = doc.get("hooks").and_then(|h| h.as_table())
&& hooks.get("pre-start").is_none()
&& hooks.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()
&& let Some(hooks) = project_table.get("hooks").and_then(|h| h.as_table())
&& hooks.get("pre-start").is_none()
&& hooks.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(hooks) = doc.get_mut("hooks").and_then(|h| h.as_table_mut())
&& hooks.get("pre-start").is_none()
&& let Some(value) = hooks.remove("post-create")
{
hooks.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()
&& let Some(hooks) = project_table
.get_mut("hooks")
.and_then(|h| h.as_table_mut())
&& hooks.get("pre-start").is_none()
&& let Some(value) = hooks.remove("post-create")
{
hooks.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;
}
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_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_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)
}
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 migration_path: Option<PathBuf>,
pub deprecations: Deprecations,
pub label: String,
pub main_worktree_path: Option<PathBuf>,
pub approvals_copied_to: 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,
}
fn migration_path(path: &Path) -> PathBuf {
match path.extension() {
Some(ext) => path.with_extension(format!("{}.new", ext.to_string_lossy())),
None => path.with_extension("new"),
}
}
pub fn check_and_migrate(
path: &Path,
content: &str,
warn_and_migrate: bool,
label: &str,
repo: Option<&crate::git::Repository>,
show_brief_warning: bool,
) -> anyhow::Result<CheckAndMigrateResult> {
let (deprecations, migrated_content, template_strings) =
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, template_strings)
}
Err(_) => (Deprecations::default(), content.to_string(), vec![]),
};
if deprecations.is_empty() {
if let Some(repo) = repo {
let _ = repo.clear_hint(HINT_DEPRECATED_CONFIG);
}
return Ok(CheckAndMigrateResult {
info: None,
migrated_content,
});
}
let new_path = migration_path(path);
let should_skip_write = show_brief_warning && new_path.exists();
let mut info = DeprecationInfo {
config_path: path.to_path_buf(),
migration_path: None,
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
},
approvals_copied_to: 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) {
if new_path.exists() {
info.migration_path = Some(new_path);
}
return Ok(CheckAndMigrateResult {
info: Some(info),
migrated_content,
});
}
guard.insert(canonical_path.clone());
}
if info.deprecations.approved_commands {
info.approvals_copied_to = copy_approved_commands_to_approvals_file(path);
}
if show_brief_warning {
eprintln!("{}", format_brief_warning(label));
if let Some(approvals_path) = &info.approvals_copied_to {
let approvals_filename = approvals_path
.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_default();
eprintln!(
"{}",
hint_message(cformat!(
"Copied approved commands to <underline>{approvals_filename}</>"
))
);
}
if !should_skip_write {
info.migration_path =
write_migration_file(path, content, &info.deprecations, repo, &template_strings);
}
std::io::stderr().flush().ok();
return Ok(CheckAndMigrateResult {
info: Some(info),
migrated_content,
});
}
if !should_skip_write {
info.migration_path =
write_migration_file(path, content, &info.deprecations, repo, &template_strings);
}
Ok(CheckAndMigrateResult {
info: Some(info),
migrated_content,
})
}
pub fn format_brief_warning(label: &str) -> String {
warning_message(cformat!(
"{} has deprecated settings. Run <bold>wt config show</> for details or <bold>wt config update</> to apply updates",
label
))
.to_string()
}
pub fn write_migration_file(
path: &Path,
content: &str,
deprecations: &Deprecations,
repo: Option<&crate::git::Repository>,
template_strings: &[String],
) -> Option<PathBuf> {
let new_path = migration_path(path);
let new_content = if !deprecations.vars.is_empty() {
replace_deprecated_vars_from_strings(content, template_strings)
} else {
content.to_string()
};
let new_content = match new_content.parse::<toml_edit::DocumentMut>() {
Ok(mut doc) => {
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 {
new_content
}
}
Err(_) => new_content,
};
if let Err(e) = std::fs::write(&new_path, &new_content) {
log::warn!("Could not write migration file: {}", e);
return None;
}
if let Some(repo) = repo {
let _ = repo.mark_hint_shown(HINT_DEPRECATED_CONFIG);
}
Some(new_path)
}
pub fn format_migration_diff(original_path: &Path, new_path: &Path) -> Option<String> {
if let Ok(output) = Cmd::new("git")
.args(["diff", "--no-index", "--color=always", "-U3", "--"])
.arg(original_path.to_slash_lossy().into_owned())
.arg(new_path.to_slash_lossy().into_owned())
.run()
{
let diff_output = String::from_utf8_lossy(&output.stdout);
if !diff_output.is_empty() {
return Some(format_with_gutter(diff_output.trim_end(), None));
}
}
None
}
pub fn format_deprecation_warnings(info: &DeprecationInfo) -> String {
use std::fmt::Write;
let mut out = String::new();
if !info.deprecations.vars.is_empty() {
let var_list: Vec<String> = info
.deprecations
.vars
.iter()
.map(|(old, new)| cformat!("<dim>{}</> → <bold>{}</>", old, new))
.collect();
let _ = writeln!(
out,
"{}",
warning_message(format!(
"{} uses deprecated template variables: {}",
info.label,
var_list.join(", ")
))
);
}
if !info.deprecations.commit_gen.is_empty() {
let mut parts = Vec::new();
if info.deprecations.commit_gen.has_top_level {
parts.push("[commit-generation] → [commit.generation]".to_string());
}
for project_key in &info.deprecations.commit_gen.project_keys {
parts.push(format!(
"[projects.\"{}\".commit-generation] → [projects.\"{}\".commit.generation]",
project_key, project_key
));
}
let _ = writeln!(
out,
"{}",
warning_message(format!(
"{} uses deprecated config sections: {}",
info.label,
parts.join(", ")
))
);
}
if info.deprecations.approved_commands {
let _ = writeln!(
out,
"{}",
warning_message(format!(
"{} has approved-commands in [projects] sections (moved to approvals.toml)",
info.label
))
);
if let Some(approvals_path) = &info.approvals_copied_to {
let approvals_filename = approvals_path
.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_default();
let _ = writeln!(
out,
"{}",
hint_message(cformat!(
"Copied approved commands to <underline>{approvals_filename}</>"
))
);
}
}
if info.deprecations.select {
let _ = writeln!(
out,
"{}",
warning_message(format!(
"{} uses deprecated config section: [select] → [switch.picker]",
info.label
))
);
}
if info.deprecations.post_create {
let _ = writeln!(
out,
"{}",
warning_message(format!(
"{} uses deprecated hook name: post-create → pre-start",
info.label
))
);
}
if info.deprecations.ci_section {
let _ = writeln!(
out,
"{}",
warning_message(format!(
"{} uses deprecated config section: [ci] → [forge]",
info.label
))
);
}
if info.deprecations.no_ff {
let _ = writeln!(
out,
"{}",
warning_message(format!(
"{} uses deprecated field: [merge] no-ff → ff (inverted)",
info.label
))
);
}
if info.deprecations.no_cd {
let _ = writeln!(
out,
"{}",
warning_message(format!(
"{} uses deprecated field: [switch] no-cd → cd (inverted)",
info.label
))
);
}
out
}
pub fn format_deprecation_details(info: &DeprecationInfo) -> String {
use std::fmt::Write;
let mut out = format_deprecation_warnings(info);
if let Some(new_path) = &info.migration_path {
let _ = writeln!(out, "{}", hint_message("To apply:"));
let _ = writeln!(out, "{}", format_bash_with_gutter("wt config update"));
if let Some(diff) = format_migration_diff(&info.config_path, new_path) {
let _ = writeln!(out, "{diff}");
}
} else if let Some(main_path) = &info.main_worktree_path {
let cmd = suggest_command_in_dir(main_path, "config", &["update"], &[]);
let _ = writeln!(out, "{}", hint_message("To apply:"));
let _ = writeln!(out, "{}", format_bash_with_gutter(&cmd));
}
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>(
path: &Path,
unknown_keys: &HashMap<String, toml::Value>,
label: &str,
) {
if unknown_keys.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);
}
let mut keys: Vec<_> = unknown_keys.keys().collect();
keys.sort();
for key in keys {
let msg = match classify_unknown_key::<C>(key) {
UnknownKeyKind::DeprecatedHandled => continue,
UnknownKeyKind::DeprecatedWrongConfig {
other_description,
canonical_display,
} => cformat!(
"{label} has key <bold>{key}</> which belongs in {other_description} as {canonical_display}"
),
UnknownKeyKind::WrongConfig { other_description } => cformat!(
"{label} has key <bold>{key}</> which belongs in {other_description} (will be ignored)"
),
UnknownKeyKind::Unknown => {
cformat!("{label} has unknown field <bold>{key}</> (will be ignored)")
}
};
eprintln!("{}", warning_message(msg));
}
std::io::stderr().flush().ok();
}
#[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_migration_path_with_extension() {
let path = std::path::Path::new("/tmp/config.toml");
let new_path = migration_path(path);
assert_eq!(new_path.to_str().unwrap(), "/tmp/config.toml.new");
}
#[test]
fn test_migration_path_without_extension() {
let path = std::path::Path::new("/tmp/config");
let new_path = migration_path(path);
assert_eq!(new_path.to_str().unwrap(), "/tmp/config.new");
}
#[test]
fn test_migration_path_hidden_file() {
let path = std::path::Path::new("/tmp/.hidden");
let new_path = migration_path(path);
assert_eq!(new_path.to_str().unwrap(), "/tmp/.hidden.new");
}
#[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"]), "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 info = DeprecationInfo {
config_path: std::path::PathBuf::from("/tmp/test-config.toml"),
migration_path: None,
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,
},
label: "User config".to_string(),
main_worktree_path: None,
approvals_copied_to: None,
};
let output = format_deprecation_details(&info);
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_format_deprecation_details_approvals_copied() {
let info = DeprecationInfo {
config_path: std::path::PathBuf::from("/tmp/test-config.toml"),
migration_path: None,
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,
},
label: "User config".to_string(),
main_worktree_path: None,
approvals_copied_to: Some(std::path::PathBuf::from("/tmp/approvals.toml")),
};
let output = format_deprecation_details(&info);
assert!(
output.contains("Copied approved commands"),
"Should mention approvals were copied: {}",
output
);
}
#[test]
fn test_write_migration_file_with_approved_commands() {
let temp_dir = tempfile::TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let content = r#"worktree-path = "../{{ repo }}.{{ branch | sanitize }}"
[projects."github.com/user/repo"]
approved-commands = ["npm install"]
"#;
std::fs::write(&config_path, content).unwrap();
let 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,
};
let result = write_migration_file(&config_path, content, &deprecations, None, &[]);
assert!(
result.is_some(),
"Should write migration file for approved-commands"
);
let migration_path = result.unwrap();
let migrated = std::fs::read_to_string(&migration_path).unwrap();
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 info = DeprecationInfo {
config_path: std::path::PathBuf::from("/tmp/test-config.toml"),
migration_path: None,
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,
},
label: "User config".to_string(),
main_worktree_path: None,
approvals_copied_to: None,
};
let output = format_deprecation_details(&info);
assert!(
output.contains("[select]"),
"Should mention [select] in output: {output}"
);
assert!(
output.contains("[switch.picker]"),
"Should mention [switch.picker]: {output}"
);
}
#[test]
fn test_write_migration_file_with_select() {
let temp_dir = tempfile::TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let content = r#"worktree-path = "../{{ repo }}.{{ branch | sanitize }}"
[select]
pager = "delta --paging=never"
"#;
std::fs::write(&config_path, content).unwrap();
let 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,
};
let result = write_migration_file(&config_path, content, &deprecations, None, &[]);
assert!(result.is_some(), "Should write migration file for select");
let migration_path = result.unwrap();
let migrated = std::fs::read_to_string(&migration_path).unwrap();
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_hooks_section() {
let content = r#"
[hooks]
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".hooks]
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_hooks() {
let content = r#"
[hooks]
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".hooks]
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_hooks_section() {
let content = r#"
[hooks]
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_project_level() {
let content = r#"
[projects."my-project".hooks]
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 info = DeprecationInfo {
config_path: std::path::PathBuf::from("/tmp/test-config.toml"),
migration_path: None,
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,
},
label: "Project config".to_string(),
main_worktree_path: None,
approvals_copied_to: None,
};
let output = format_deprecation_details(&info);
assert!(
output.contains("post-create"),
"Should mention post-create: {output}"
);
assert!(
output.contains("pre-start"),
"Should mention pre-start: {output}"
);
}
#[test]
fn test_write_migration_file_with_post_create() {
let temp_dir = tempfile::TempDir::new().unwrap();
let config_path = temp_dir.path().join("wt.toml");
let content = r#"post-create = "npm install"
[post-start]
server = "npm run dev"
"#;
std::fs::write(&config_path, content).unwrap();
let 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,
};
let result = write_migration_file(&config_path, content, &deprecations, None, &[]);
assert!(
result.is_some(),
"Should write migration file for post_create"
);
let migration_path = result.unwrap();
let migrated = std::fs::read_to_string(&migration_path).unwrap();
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"),
migration_path: None,
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,
},
label: "User config".to_string(),
main_worktree_path: None,
approvals_copied_to: 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 mut unknown = HashMap::new();
unknown.insert(
"commit-generation".to_string(),
toml::Value::Table(toml::map::Map::new()),
);
let path = std::env::temp_dir().join("test-deprecated-wrong-config-project.toml");
warn_unknown_fields::<ProjectConfig>(&path, &unknown, "Project config");
let mut unknown = HashMap::new();
unknown.insert("ci".to_string(), toml::Value::Table(toml::map::Map::new()));
let path = std::env::temp_dir().join("test-deprecated-wrong-config-user.toml");
warn_unknown_fields::<UserConfig>(&path, &unknown, "User config");
}
}