use crate::common::wt_command;
use std::collections::HashSet;
use worktrunk::styling::SUCCESS_SYMBOL;
#[derive(Debug)]
struct Issue {
shell: String,
severity: Severity,
category: Category,
message: String,
}
#[derive(Debug, PartialEq)]
enum Severity {
Error,
Warning,
}
#[derive(Debug)]
enum Category {
HiddenFlag,
Consistency,
}
impl Issue {
fn error(shell: impl Into<String>, category: Category, message: impl Into<String>) -> Self {
Self {
shell: shell.into(),
severity: Severity::Error,
category,
message: message.into(),
}
}
fn warning(shell: impl Into<String>, category: Category, message: impl Into<String>) -> Self {
Self {
shell: shell.into(),
severity: Severity::Warning,
category,
message: message.into(),
}
}
}
fn validate_fish(_content: &str) -> Vec<Issue> {
Vec::new()
}
fn validate_bash(_content: &str) -> Vec<Issue> {
Vec::new()
}
fn validate_zsh(_content: &str) -> Vec<Issue> {
Vec::new()
}
fn extract_flags(content: &str, shell: &str) -> HashSet<String> {
let mut flags = HashSet::new();
match shell {
"fish" => {
for line in content.lines() {
if line.contains("complete -c wt")
&& let Some(captures) = line.split("-l ").nth(1)
&& let Some(flag) = captures.split_whitespace().next()
{
flags.insert(flag.to_string());
}
}
}
"bash" => {
for line in content.lines() {
if let Some(opts_start) = line.find("opts=\"") {
let search_from = opts_start + 6;
if let Some(rel_end) = line[search_from..].find('"') {
let opts_end = search_from + rel_end;
let opts_str = &line[search_from..opts_end];
for word in opts_str.split_whitespace() {
if let Some(flag) = word.strip_prefix("--") {
flags.insert(flag.to_string());
}
}
}
}
}
for line in content.lines() {
if let Some(stripped) = line
.trim()
.strip_prefix("--")
.and_then(|s| s.strip_suffix(')'))
{
flags.insert(stripped.to_string());
}
}
}
"zsh" => {
for line in content.lines() {
if let Some(start) = line.find("'--")
&& let Some(rest) = line[start + 3..].split(&['[', '='][..]).next()
{
flags.insert(rest.to_string());
}
}
}
_ => {}
}
flags
}
fn validate_cross_shell(fish_content: &str, bash_content: &str, zsh_content: &str) -> Vec<Issue> {
let mut issues = Vec::new();
let fish_flags = extract_flags(fish_content, "fish");
let bash_flags = extract_flags(bash_content, "bash");
let zsh_flags = extract_flags(zsh_content, "zsh");
let hidden_flags: HashSet<String> = ["internal"].iter().map(|s| s.to_string()).collect();
for flag in &hidden_flags {
let mut appears_in = Vec::new();
if fish_flags.contains(flag) {
appears_in.push("fish");
}
if bash_flags.contains(flag) {
appears_in.push("bash");
}
if zsh_flags.contains(flag) {
appears_in.push("zsh");
}
if !appears_in.is_empty() {
issues.push(Issue::error(
"cross-shell",
Category::HiddenFlag,
format!(
"Hidden flag --{} appears in: {}",
flag,
appears_in.join(", ")
),
));
}
}
let all_flags: HashSet<_> = fish_flags
.union(&bash_flags)
.chain(zsh_flags.iter())
.filter(|f| !hidden_flags.contains(*f))
.collect();
for flag in all_flags {
let in_fish = fish_flags.contains(flag);
let in_bash = bash_flags.contains(flag);
let in_zsh = zsh_flags.contains(flag);
let present_count = [in_fish, in_bash, in_zsh].iter().filter(|&&x| x).count();
if present_count == 1 {
let mut missing = Vec::new();
if !in_fish {
missing.push("fish");
}
if !in_bash {
missing.push("bash");
}
if !in_zsh {
missing.push("zsh");
}
issues.push(Issue::warning(
"cross-shell",
Category::Consistency,
format!("Flag --{} missing from: {}", flag, missing.join(", ")),
));
}
}
issues
}
#[test]
fn test_completion_validation() {
let fish_output = wt_command()
.arg("config")
.arg("shell")
.arg("init")
.arg("fish")
.output()
.unwrap();
let bash_output = wt_command()
.arg("config")
.arg("shell")
.arg("init")
.arg("bash")
.output()
.unwrap();
let zsh_output = wt_command()
.arg("config")
.arg("shell")
.arg("init")
.arg("zsh")
.output()
.unwrap();
assert!(fish_output.status.success());
assert!(bash_output.status.success());
assert!(zsh_output.status.success());
let fish_content = String::from_utf8_lossy(&fish_output.stdout);
let bash_content = String::from_utf8_lossy(&bash_output.stdout);
let zsh_content = String::from_utf8_lossy(&zsh_output.stdout);
let mut all_issues = Vec::new();
all_issues.extend(validate_fish(&fish_content));
all_issues.extend(validate_bash(&bash_content));
all_issues.extend(validate_zsh(&zsh_content));
all_issues.extend(validate_cross_shell(
&fish_content,
&bash_content,
&zsh_content,
));
let errors: Vec<_> = all_issues
.iter()
.filter(|i| i.severity == Severity::Error)
.collect();
let warnings: Vec<_> = all_issues
.iter()
.filter(|i| i.severity == Severity::Warning)
.collect();
if !errors.is_empty() {
eprintln!("\n{}", "=".repeat(80));
eprintln!("COMPLETION VALIDATION ERRORS ({})", errors.len());
eprintln!("{}", "=".repeat(80));
for issue in &errors {
eprintln!(
"❌ [{}] {:?}: {}",
issue.shell, issue.category, issue.message
);
}
}
if !warnings.is_empty() {
eprintln!("\n{}", "=".repeat(80));
eprintln!("COMPLETION VALIDATION WARNINGS ({})", warnings.len());
eprintln!("{}", "=".repeat(80));
for issue in &warnings {
eprintln!(
"⚠️ [{}] {:?}: {}",
issue.shell, issue.category, issue.message
);
}
}
if !errors.is_empty() {
panic!(
"\n{} completion validation error(s) found - see output above",
errors.len()
);
}
if errors.is_empty() && warnings.is_empty() {
println!("{SUCCESS_SYMBOL} All shell completions validated successfully!");
}
}