1#![feature(exit_status_error)]
6
7use std::io::Write;
8
9use owo_colors::OwoColorize as _;
10use rootcause::prelude::ResultExt as _;
11use rootcause::report;
12use url::Url;
13use ytil_git::CmdError;
14use ytil_sys::cli::Args;
15
16fn autocomplete_git_branches_and_switch() -> rootcause::Result<()> {
28 let Some(branch) = ytil_tui::git_branch::select()? else {
29 return Ok(());
30 };
31
32 let branch_name_no_origin = branch.name_no_origin();
33 ytil_git::branch::switch(branch_name_no_origin).inspect(|()| report_branch_switch(branch_name_no_origin))?;
34
35 Ok(())
36}
37
38fn handle_single_input_argument(arg: &str) -> rootcause::Result<()> {
49 let branch_name = if let Ok(url) = Url::parse(arg) {
50 ytil_gh::log_into_github()?;
51 ytil_gh::get_branch_name_from_url(&url)?
52 } else {
53 arg.to_string()
54 };
55
56 match ytil_git::branch::switch(&branch_name).map_err(|e| *e) {
57 Err(CmdError::CmdFailure { stderr, .. }) if stderr.contains("invalid reference: ") => {
58 create_branch_and_switch(&branch_name)
59 }
60 other => Ok(other?),
61 }
62}
63
64fn create_branch_and_switch(branch_name: &str) -> rootcause::Result<()> {
76 if !should_create_new_branch(branch_name)? {
77 return Ok(());
78 }
79 if let Err(err) = ytil_git::branch::create_from_default_branch(branch_name, None) {
80 if err.to_string().contains("already exists") {
81 ytil_git::branch::switch(branch_name).inspect(|()| report_branch_exists(branch_name))?;
82 return Ok(());
83 }
84 return Err(err);
85 }
86 ytil_git::branch::switch(branch_name).inspect(|()| report_branch_new(branch_name))?;
87 Ok(())
88}
89
90fn should_create_new_branch(branch_name: &str) -> rootcause::Result<bool> {
101 let default_branch = ytil_git::branch::get_default()?;
102 if default_branch == branch_name {
103 return Ok(true);
104 }
105 let curr_branch = ytil_git::branch::get_current()?;
106 if default_branch == curr_branch {
107 return Ok(true);
108 }
109 ask_branching_from_not_default(branch_name, &curr_branch);
110 std::io::stdout().flush()?;
111 let mut input = String::new();
112 std::io::stdin().read_line(&mut input)?;
113 if !input.trim().is_empty() {
114 report_branch_not_created(branch_name);
115 return Ok(false);
116 }
117 Ok(true)
118}
119
120fn build_branch_name(args: &[&str]) -> rootcause::Result<String> {
132 fn is_permitted(c: char) -> bool {
133 const PERMITTED_CHARS: [char; 3] = ['.', '/', '_'];
134 c.is_alphanumeric() || PERMITTED_CHARS.contains(&c)
135 }
136
137 let mut branch_name = String::new();
141 let mut need_separator = false;
142
143 for arg in args {
144 for token in arg.split_whitespace() {
145 for c in token.chars() {
146 if is_permitted(c) {
147 if need_separator && !branch_name.is_empty() {
148 branch_name.push('-');
149 }
150 need_separator = false;
151 for lc in c.to_lowercase() {
152 branch_name.push(lc);
153 }
154 } else {
155 need_separator = true;
157 }
158 }
159 need_separator = true;
161 }
162 }
163
164 if branch_name.is_empty() {
165 Err(report!("branch name construction produced empty string")).attach_with(|| format!("args={args:#?}"))?;
166 }
167
168 Ok(branch_name)
169}
170
171fn report_branch_switch(branch_name: &str) {
173 println!("{} {}", ">".magenta().bold(), branch_name.bold());
174}
175
176fn report_branch_new(branch_name: &str) {
178 println!("{} {}", "+".green().bold(), branch_name.bold());
179}
180
181fn report_branch_exists(branch_name: &str) {
183 println!("{}{} {}", "!".blue().bold(), ">".magenta().bold(), branch_name.bold());
184}
185
186fn report_branch_not_created(branch_name: &str) {
188 print!("{} {} not created", "x".red().bold(), branch_name.bold());
189}
190
191fn ask_branching_from_not_default(branch_name: &str, default_branch_name: &str) {
193 print!(
194 "{} {} from {}",
195 "*".cyan().bold(),
196 branch_name.bold(),
197 default_branch_name.bold()
198 );
199}
200
201#[ytil_sys::main]
203fn main() -> rootcause::Result<()> {
204 let args = ytil_sys::cli::get();
205 if args.has_help() {
206 println!("{}", include_str!("../help.txt"));
207 return Ok(());
208 }
209 let args: Vec<_> = args.iter().map(String::as_str).collect();
210
211 match args.split_first() {
212 None => autocomplete_git_branches_and_switch(),
213 Some((hd, _)) if *hd == "-" => ytil_git::branch::switch(hd)
215 .inspect(|()| report_branch_switch(hd))
216 .map_err(From::from),
217 Some((hd, tail)) if *hd == "-b" => create_branch_and_switch(&build_branch_name(tail)?),
218 Some((hd, &[])) => handle_single_input_argument(hd),
219 _ => create_branch_and_switch(&build_branch_name(&args)?),
220 }?;
221
222 Ok(())
223}
224
225#[cfg(test)]
226mod tests {
227 use rstest::rstest;
228
229 use super::*;
230
231 #[rstest]
232 #[case::empty_input("", "branch name construction produced empty string")]
233 #[case::invalid_characters_only("❌", "branch name construction produced empty string")]
234 fn build_branch_name_fails_as_expected(#[case] input: &str, #[case] expected_ctx: &str) {
235 assert2::assert!(let Err(err) = build_branch_name(&[input]));
236 assert_eq!(err.format_current_context().to_string(), expected_ctx);
237 }
238
239 #[rstest]
240 #[case::single_word(&["HelloWorld"], "helloworld")]
241 #[case::space_separated(&["Hello World"], "hello-world")]
242 #[case::special_characters(&["Feature: Implement User Login!"], "feature-implement-user-login")]
243 #[case::version_number(&["Version 2.0"], "version-2.0")]
244 #[case::multiple_separators(&["This---is...a_test"], "this-is...a_test")]
245 #[case::leading_trailing_spaces(&[" Leading and trailing "], "leading-and-trailing")]
246 #[case::emoji(&["Hello 🌎 World"], "hello-world")]
247 #[case::emoji_at_start_end(&["🚀Launch🚀Day"], "launch-day")]
248 #[case::multiple_emojis(&["Smile 😊 and 🤖 code"], "smile-and-code")]
249 #[case::multiple_args(&["Hello", "World"], "hello-world")]
250 #[case::args_with_spaces(&["Hello World", "World"], "hello-world-world")]
251 #[case::mixed_args(&["Hello World", "🌎", "42"], "hello-world-42")]
252 #[case::special_chars_in_args(&["This", "---is.", "..a_test"], "this-is.-..a_test")]
253 #[case::dependabot_path(&["dependabot/cargo/opentelemetry-0.27.1"], "dependabot/cargo/opentelemetry-0.27.1")]
254 fn build_branch_name_succeeds_as_expected(#[case] input: &[&str], #[case] expected_output: &str) {
255 assert2::assert!(let Ok(actual_output) = build_branch_name(input));
256 assert_eq!(actual_output, expected_output);
257 }
258}