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