1use anyhow::{Context, Result};
4use clap::{Parser, Subcommand};
5use tracing::{debug, error};
6
7#[derive(Parser)]
9pub struct GitCommand {
10 #[command(subcommand)]
12 pub command: GitSubcommands,
13}
14
15#[derive(Subcommand)]
17pub enum GitSubcommands {
18 Commit(CommitCommand),
20 Branch(BranchCommand),
22}
23
24#[derive(Parser)]
26pub struct CommitCommand {
27 #[command(subcommand)]
29 pub command: CommitSubcommands,
30}
31
32#[derive(Subcommand)]
34pub enum CommitSubcommands {
35 Message(MessageCommand),
37}
38
39#[derive(Parser)]
41pub struct MessageCommand {
42 #[command(subcommand)]
44 pub command: MessageSubcommands,
45}
46
47#[derive(Subcommand)]
49pub enum MessageSubcommands {
50 View(ViewCommand),
52 Amend(AmendCommand),
54 Twiddle(TwiddleCommand),
56 Check(CheckCommand),
58}
59
60#[derive(Parser)]
62pub struct ViewCommand {
63 #[arg(value_name = "COMMIT_RANGE")]
65 pub commit_range: Option<String>,
66}
67
68#[derive(Parser)]
70pub struct AmendCommand {
71 #[arg(value_name = "YAML_FILE")]
73 pub yaml_file: String,
74}
75
76#[derive(Parser)]
78pub struct TwiddleCommand {
79 #[arg(value_name = "COMMIT_RANGE")]
81 pub commit_range: Option<String>,
82
83 #[arg(long)]
85 pub model: Option<String>,
86
87 #[arg(long)]
89 pub auto_apply: bool,
90
91 #[arg(long, value_name = "FILE")]
93 pub save_only: Option<String>,
94
95 #[arg(long, default_value = "true")]
97 pub use_context: bool,
98
99 #[arg(long)]
101 pub context_dir: Option<std::path::PathBuf>,
102
103 #[arg(long)]
105 pub work_context: Option<String>,
106
107 #[arg(long)]
109 pub branch_context: Option<String>,
110
111 #[arg(long)]
113 pub no_context: bool,
114
115 #[arg(long, default_value = "4")]
117 pub batch_size: usize,
118
119 #[arg(long)]
121 pub no_ai: bool,
122
123 #[arg(long)]
125 pub fresh: bool,
126
127 #[arg(long)]
129 pub check: bool,
130}
131
132#[derive(Parser)]
134pub struct CheckCommand {
135 #[arg(value_name = "COMMIT_RANGE")]
138 pub commit_range: Option<String>,
139
140 #[arg(long)]
142 pub model: Option<String>,
143
144 #[arg(long)]
146 pub context_dir: Option<std::path::PathBuf>,
147
148 #[arg(long)]
150 pub guidelines: Option<std::path::PathBuf>,
151
152 #[arg(long, default_value = "text")]
154 pub format: String,
155
156 #[arg(long)]
158 pub strict: bool,
159
160 #[arg(long)]
162 pub quiet: bool,
163
164 #[arg(long)]
166 pub verbose: bool,
167
168 #[arg(long)]
170 pub show_passing: bool,
171
172 #[arg(long, default_value = "4")]
174 pub batch_size: usize,
175
176 #[arg(long)]
178 pub no_suggestions: bool,
179}
180
181#[derive(Parser)]
183pub struct BranchCommand {
184 #[command(subcommand)]
186 pub command: BranchSubcommands,
187}
188
189#[derive(Subcommand)]
191pub enum BranchSubcommands {
192 Info(InfoCommand),
194 Create(CreateCommand),
196}
197
198#[derive(Parser)]
200pub struct InfoCommand {
201 #[arg(value_name = "BASE_BRANCH")]
203 pub base_branch: Option<String>,
204}
205
206#[derive(Parser)]
208pub struct CreateCommand {
209 #[command(subcommand)]
211 pub command: CreateSubcommands,
212}
213
214#[derive(Subcommand)]
216pub enum CreateSubcommands {
217 Pr(CreatePrCommand),
219}
220
221#[derive(Parser)]
223pub struct CreatePrCommand {
224 #[arg(long, value_name = "BRANCH")]
226 pub base: Option<String>,
227
228 #[arg(long)]
230 pub auto_apply: bool,
231
232 #[arg(long, value_name = "FILE")]
234 pub save_only: Option<String>,
235
236 #[arg(long, conflicts_with = "draft")]
238 pub ready: bool,
239
240 #[arg(long, conflicts_with = "ready")]
242 pub draft: bool,
243}
244
245impl GitCommand {
246 pub fn execute(self) -> Result<()> {
248 match self.command {
249 GitSubcommands::Commit(commit_cmd) => commit_cmd.execute(),
250 GitSubcommands::Branch(branch_cmd) => branch_cmd.execute(),
251 }
252 }
253}
254
255impl CommitCommand {
256 pub fn execute(self) -> Result<()> {
258 match self.command {
259 CommitSubcommands::Message(message_cmd) => message_cmd.execute(),
260 }
261 }
262}
263
264impl MessageCommand {
265 pub fn execute(self) -> Result<()> {
267 match self.command {
268 MessageSubcommands::View(view_cmd) => view_cmd.execute(),
269 MessageSubcommands::Amend(amend_cmd) => amend_cmd.execute(),
270 MessageSubcommands::Twiddle(twiddle_cmd) => {
271 let rt =
273 tokio::runtime::Runtime::new().context("Failed to create tokio runtime")?;
274 rt.block_on(twiddle_cmd.execute())
275 }
276 MessageSubcommands::Check(check_cmd) => {
277 let rt =
279 tokio::runtime::Runtime::new().context("Failed to create tokio runtime")?;
280 rt.block_on(check_cmd.execute())
281 }
282 }
283 }
284}
285
286impl ViewCommand {
287 pub fn execute(self) -> Result<()> {
289 use crate::data::{
290 AiInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
291 WorkingDirectoryInfo,
292 };
293 use crate::git::{GitRepository, RemoteInfo};
294 use crate::utils::ai_scratch;
295
296 let commit_range = self.commit_range.as_deref().unwrap_or("HEAD");
297
298 let repo = GitRepository::open()
300 .context("Failed to open git repository. Make sure you're in a git repository.")?;
301
302 let wd_status = repo.get_working_directory_status()?;
304 let working_directory = WorkingDirectoryInfo {
305 clean: wd_status.clean,
306 untracked_changes: wd_status
307 .untracked_changes
308 .into_iter()
309 .map(|fs| FileStatusInfo {
310 status: fs.status,
311 file: fs.file,
312 })
313 .collect(),
314 };
315
316 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
318
319 let commits = repo.get_commits_in_range(commit_range)?;
321
322 let versions = Some(VersionInfo {
324 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
325 });
326
327 let ai_scratch_path =
329 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
330 let ai_info = AiInfo {
331 scratch: ai_scratch_path.to_string_lossy().to_string(),
332 };
333
334 let mut repo_view = RepositoryView {
336 versions,
337 explanation: FieldExplanation::default(),
338 working_directory,
339 remotes,
340 ai: ai_info,
341 branch_info: None,
342 pr_template: None,
343 pr_template_location: None,
344 branch_prs: None,
345 commits,
346 };
347
348 repo_view.update_field_presence();
350
351 let yaml_output = crate::data::to_yaml(&repo_view)?;
353 println!("{}", yaml_output);
354
355 Ok(())
356 }
357}
358
359impl AmendCommand {
360 pub fn execute(self) -> Result<()> {
362 use crate::git::AmendmentHandler;
363
364 println!("🔄 Starting commit amendment process...");
365 println!("📄 Loading amendments from: {}", self.yaml_file);
366
367 let handler = AmendmentHandler::new().context("Failed to initialize amendment handler")?;
369
370 handler
371 .apply_amendments(&self.yaml_file)
372 .context("Failed to apply amendments")?;
373
374 Ok(())
375 }
376}
377
378impl TwiddleCommand {
379 pub async fn execute(self) -> Result<()> {
381 if self.no_ai {
383 return self.execute_no_ai().await;
384 }
385
386 let use_contextual = self.use_context && !self.no_context;
388
389 if use_contextual {
390 println!(
391 "🪄 Starting AI-powered commit message improvement with contextual intelligence..."
392 );
393 } else {
394 println!("🪄 Starting AI-powered commit message improvement...");
395 }
396
397 let full_repo_view = self.generate_repository_view().await?;
399
400 if full_repo_view.commits.len() > self.batch_size {
402 println!(
403 "📦 Processing {} commits in batches of {} to ensure reliable analysis...",
404 full_repo_view.commits.len(),
405 self.batch_size
406 );
407 return self
408 .execute_with_batching(use_contextual, full_repo_view)
409 .await;
410 }
411
412 let context = if use_contextual {
414 Some(self.collect_context(&full_repo_view).await?)
415 } else {
416 None
417 };
418
419 if let Some(ref ctx) = context {
421 self.show_context_summary(ctx)?;
422 }
423
424 let claude_client = crate::claude::create_default_claude_client(self.model.clone())?;
426
427 self.show_model_info_from_client(&claude_client)?;
429
430 if self.fresh {
432 println!("🔄 Fresh mode: ignoring existing commit messages...");
433 }
434 if use_contextual && context.is_some() {
435 println!("🤖 Analyzing commits with enhanced contextual intelligence...");
436 } else {
437 println!("🤖 Analyzing commits with Claude AI...");
438 }
439
440 let amendments = if let Some(ctx) = context {
441 claude_client
442 .generate_contextual_amendments_with_options(&full_repo_view, &ctx, self.fresh)
443 .await?
444 } else {
445 claude_client
446 .generate_amendments_with_options(&full_repo_view, self.fresh)
447 .await?
448 };
449
450 if let Some(save_path) = self.save_only {
452 amendments.save_to_file(save_path)?;
453 println!("💾 Amendments saved to file");
454 return Ok(());
455 }
456
457 if !amendments.amendments.is_empty() {
459 let temp_dir = tempfile::tempdir()?;
461 let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
462 amendments.save_to_file(&amendments_file)?;
463
464 if !self.auto_apply && !self.handle_amendments_file(&amendments_file, &amendments)? {
466 println!("❌ Amendment cancelled by user");
467 return Ok(());
468 }
469
470 self.apply_amendments_from_file(&amendments_file).await?;
472 println!("✅ Commit messages improved successfully!");
473
474 if self.check {
476 self.run_post_twiddle_check().await?;
477 }
478 } else {
479 println!("✨ No commits found to process!");
480 }
481
482 Ok(())
483 }
484
485 async fn execute_with_batching(
487 &self,
488 use_contextual: bool,
489 full_repo_view: crate::data::RepositoryView,
490 ) -> Result<()> {
491 use crate::data::amendments::AmendmentFile;
492
493 let claude_client = crate::claude::create_default_claude_client(self.model.clone())?;
495
496 self.show_model_info_from_client(&claude_client)?;
498
499 let commit_batches: Vec<_> = full_repo_view.commits.chunks(self.batch_size).collect();
501
502 let total_batches = commit_batches.len();
503 let mut all_amendments = AmendmentFile {
504 amendments: Vec::new(),
505 };
506
507 if self.fresh {
508 println!("🔄 Fresh mode: ignoring existing commit messages...");
509 }
510 println!("📊 Processing {} batches...", total_batches);
511
512 for (batch_num, commit_batch) in commit_batches.into_iter().enumerate() {
513 println!(
514 "🔄 Processing batch {}/{} ({} commits)...",
515 batch_num + 1,
516 total_batches,
517 commit_batch.len()
518 );
519
520 let batch_repo_view = crate::data::RepositoryView {
522 versions: full_repo_view.versions.clone(),
523 explanation: full_repo_view.explanation.clone(),
524 working_directory: full_repo_view.working_directory.clone(),
525 remotes: full_repo_view.remotes.clone(),
526 ai: full_repo_view.ai.clone(),
527 branch_info: full_repo_view.branch_info.clone(),
528 pr_template: full_repo_view.pr_template.clone(),
529 pr_template_location: full_repo_view.pr_template_location.clone(),
530 branch_prs: full_repo_view.branch_prs.clone(),
531 commits: commit_batch.to_vec(),
532 };
533
534 let batch_context = if use_contextual {
536 Some(self.collect_context(&batch_repo_view).await?)
537 } else {
538 None
539 };
540
541 let batch_amendments = if let Some(ctx) = batch_context {
543 claude_client
544 .generate_contextual_amendments_with_options(&batch_repo_view, &ctx, self.fresh)
545 .await?
546 } else {
547 claude_client
548 .generate_amendments_with_options(&batch_repo_view, self.fresh)
549 .await?
550 };
551
552 all_amendments
554 .amendments
555 .extend(batch_amendments.amendments);
556
557 if batch_num + 1 < total_batches {
558 println!(" ✅ Batch {}/{} completed", batch_num + 1, total_batches);
559 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
561 }
562 }
563
564 println!(
565 "✅ All batches completed! Found {} commits to improve.",
566 all_amendments.amendments.len()
567 );
568
569 if let Some(save_path) = &self.save_only {
571 all_amendments.save_to_file(save_path)?;
572 println!("💾 Amendments saved to file");
573 return Ok(());
574 }
575
576 if !all_amendments.amendments.is_empty() {
578 let temp_dir = tempfile::tempdir()?;
580 let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
581 all_amendments.save_to_file(&amendments_file)?;
582
583 if !self.auto_apply
585 && !self.handle_amendments_file(&amendments_file, &all_amendments)?
586 {
587 println!("❌ Amendment cancelled by user");
588 return Ok(());
589 }
590
591 self.apply_amendments_from_file(&amendments_file).await?;
593 println!("✅ Commit messages improved successfully!");
594
595 if self.check {
597 self.run_post_twiddle_check().await?;
598 }
599 } else {
600 println!("✨ No commits found to process!");
601 }
602
603 Ok(())
604 }
605
606 async fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
608 use crate::data::{
609 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
610 WorkingDirectoryInfo,
611 };
612 use crate::git::{GitRepository, RemoteInfo};
613 use crate::utils::ai_scratch;
614
615 let commit_range = self.commit_range.as_deref().unwrap_or("HEAD~5..HEAD");
616
617 let repo = GitRepository::open()
619 .context("Failed to open git repository. Make sure you're in a git repository.")?;
620
621 let current_branch = repo
623 .get_current_branch()
624 .unwrap_or_else(|_| "HEAD".to_string());
625
626 let wd_status = repo.get_working_directory_status()?;
628 let working_directory = WorkingDirectoryInfo {
629 clean: wd_status.clean,
630 untracked_changes: wd_status
631 .untracked_changes
632 .into_iter()
633 .map(|fs| FileStatusInfo {
634 status: fs.status,
635 file: fs.file,
636 })
637 .collect(),
638 };
639
640 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
642
643 let commits = repo.get_commits_in_range(commit_range)?;
645
646 let versions = Some(VersionInfo {
648 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
649 });
650
651 let ai_scratch_path =
653 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
654 let ai_info = AiInfo {
655 scratch: ai_scratch_path.to_string_lossy().to_string(),
656 };
657
658 let mut repo_view = RepositoryView {
660 versions,
661 explanation: FieldExplanation::default(),
662 working_directory,
663 remotes,
664 ai: ai_info,
665 branch_info: Some(BranchInfo {
666 branch: current_branch,
667 }),
668 pr_template: None,
669 pr_template_location: None,
670 branch_prs: None,
671 commits,
672 };
673
674 repo_view.update_field_presence();
676
677 Ok(repo_view)
678 }
679
680 fn handle_amendments_file(
682 &self,
683 amendments_file: &std::path::Path,
684 amendments: &crate::data::amendments::AmendmentFile,
685 ) -> Result<bool> {
686 use std::io::{self, Write};
687
688 println!(
689 "\n📝 Found {} commits that could be improved.",
690 amendments.amendments.len()
691 );
692 println!("💾 Amendments saved to: {}", amendments_file.display());
693 println!();
694
695 loop {
696 print!("❓ [A]pply amendments, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] ");
697 io::stdout().flush()?;
698
699 let mut input = String::new();
700 io::stdin().read_line(&mut input)?;
701
702 match input.trim().to_lowercase().as_str() {
703 "a" | "apply" | "" => return Ok(true),
704 "s" | "show" => {
705 self.show_amendments_file(amendments_file)?;
706 println!();
707 }
708 "e" | "edit" => {
709 self.edit_amendments_file(amendments_file)?;
710 println!();
711 }
712 "q" | "quit" => return Ok(false),
713 _ => {
714 println!(
715 "Invalid choice. Please enter 'a' to apply, 's' to show, 'e' to edit, or 'q' to quit."
716 );
717 }
718 }
719 }
720 }
721
722 fn show_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
724 use std::fs;
725
726 println!("\n📄 Amendments file contents:");
727 println!("─────────────────────────────");
728
729 let contents =
730 fs::read_to_string(amendments_file).context("Failed to read amendments file")?;
731
732 println!("{}", contents);
733 println!("─────────────────────────────");
734
735 Ok(())
736 }
737
738 fn edit_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
740 use std::env;
741 use std::io::{self, Write};
742 use std::process::Command;
743
744 let editor = env::var("OMNI_DEV_EDITOR")
746 .or_else(|_| env::var("EDITOR"))
747 .unwrap_or_else(|_| {
748 println!(
750 "🔧 Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined."
751 );
752 print!("Please enter the command to use as your editor: ");
753 io::stdout().flush().expect("Failed to flush stdout");
754
755 let mut input = String::new();
756 io::stdin()
757 .read_line(&mut input)
758 .expect("Failed to read user input");
759 input.trim().to_string()
760 });
761
762 if editor.is_empty() {
763 println!("❌ No editor specified. Returning to menu.");
764 return Ok(());
765 }
766
767 println!("📝 Opening amendments file in editor: {}", editor);
768
769 let mut cmd_parts = editor.split_whitespace();
771 let editor_cmd = cmd_parts.next().unwrap_or(&editor);
772 let args: Vec<&str> = cmd_parts.collect();
773
774 let mut command = Command::new(editor_cmd);
775 command.args(args);
776 command.arg(amendments_file.to_string_lossy().as_ref());
777
778 match command.status() {
779 Ok(status) => {
780 if status.success() {
781 println!("✅ Editor session completed.");
782 } else {
783 println!(
784 "⚠️ Editor exited with non-zero status: {:?}",
785 status.code()
786 );
787 }
788 }
789 Err(e) => {
790 println!("❌ Failed to execute editor '{}': {}", editor, e);
791 println!(" Please check that the editor command is correct and available in your PATH.");
792 }
793 }
794
795 Ok(())
796 }
797
798 async fn apply_amendments_from_file(&self, amendments_file: &std::path::Path) -> Result<()> {
800 use crate::git::AmendmentHandler;
801
802 let handler = AmendmentHandler::new().context("Failed to initialize amendment handler")?;
804 handler
805 .apply_amendments(&amendments_file.to_string_lossy())
806 .context("Failed to apply amendments")?;
807
808 Ok(())
809 }
810
811 async fn collect_context(
813 &self,
814 repo_view: &crate::data::RepositoryView,
815 ) -> Result<crate::data::context::CommitContext> {
816 use crate::claude::context::{BranchAnalyzer, ProjectDiscovery, WorkPatternAnalyzer};
817 use crate::data::context::CommitContext;
818
819 let mut context = CommitContext::new();
820
821 let context_dir = self
823 .context_dir
824 .as_ref()
825 .cloned()
826 .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
827
828 let repo_root = std::path::PathBuf::from(".");
830 let discovery = ProjectDiscovery::new(repo_root, context_dir.clone());
831 debug!(context_dir = ?context_dir, "Using context directory");
832 match discovery.discover() {
833 Ok(project_context) => {
834 debug!("Discovery successful");
835
836 self.show_guidance_files_status(&project_context, &context_dir)?;
838
839 context.project = project_context;
840 }
841 Err(e) => {
842 debug!(error = %e, "Discovery failed");
843 context.project = Default::default();
844 }
845 }
846
847 if let Some(branch_info) = &repo_view.branch_info {
849 context.branch = BranchAnalyzer::analyze(&branch_info.branch).unwrap_or_default();
850 } else {
851 use crate::git::GitRepository;
853 let repo = GitRepository::open()?;
854 let current_branch = repo
855 .get_current_branch()
856 .unwrap_or_else(|_| "HEAD".to_string());
857 context.branch = BranchAnalyzer::analyze(¤t_branch).unwrap_or_default();
858 }
859
860 if !repo_view.commits.is_empty() {
862 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
863 }
864
865 if let Some(ref work_ctx) = self.work_context {
867 context.user_provided = Some(work_ctx.clone());
868 }
869
870 if let Some(ref branch_ctx) = self.branch_context {
871 context.branch.description = branch_ctx.clone();
872 }
873
874 Ok(context)
875 }
876
877 fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
879 use crate::data::context::{VerbosityLevel, WorkPattern};
880
881 println!("🔍 Context Analysis:");
882
883 if !context.project.valid_scopes.is_empty() {
885 let scope_names: Vec<&str> = context
886 .project
887 .valid_scopes
888 .iter()
889 .map(|s| s.name.as_str())
890 .collect();
891 println!(" 📁 Valid scopes: {}", scope_names.join(", "));
892 }
893
894 if context.branch.is_feature_branch {
896 println!(
897 " 🌿 Branch: {} ({})",
898 context.branch.description, context.branch.work_type
899 );
900 if let Some(ref ticket) = context.branch.ticket_id {
901 println!(" 🎫 Ticket: {}", ticket);
902 }
903 }
904
905 match context.range.work_pattern {
907 WorkPattern::Sequential => println!(" 🔄 Pattern: Sequential development"),
908 WorkPattern::Refactoring => println!(" 🧹 Pattern: Refactoring work"),
909 WorkPattern::BugHunt => println!(" 🐛 Pattern: Bug investigation"),
910 WorkPattern::Documentation => println!(" 📖 Pattern: Documentation updates"),
911 WorkPattern::Configuration => println!(" ⚙️ Pattern: Configuration changes"),
912 WorkPattern::Unknown => {}
913 }
914
915 match context.suggested_verbosity() {
917 VerbosityLevel::Comprehensive => {
918 println!(" 📝 Detail level: Comprehensive (significant changes detected)")
919 }
920 VerbosityLevel::Detailed => println!(" 📝 Detail level: Detailed"),
921 VerbosityLevel::Concise => println!(" 📝 Detail level: Concise"),
922 }
923
924 if let Some(ref user_ctx) = context.user_provided {
926 println!(" 👤 User context: {}", user_ctx);
927 }
928
929 println!();
930 Ok(())
931 }
932
933 fn show_model_info_from_client(
935 &self,
936 client: &crate::claude::client::ClaudeClient,
937 ) -> Result<()> {
938 use crate::claude::model_config::get_model_registry;
939
940 println!("🤖 AI Model Configuration:");
941
942 let metadata = client.get_ai_client_metadata();
944 let registry = get_model_registry();
945
946 if let Some(spec) = registry.get_model_spec(&metadata.model) {
947 if metadata.model != spec.api_identifier {
949 println!(
950 " 📡 Model: {} → \x1b[33m{}\x1b[0m",
951 metadata.model, spec.api_identifier
952 );
953 } else {
954 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
955 }
956
957 println!(" 🏷️ Provider: {}", spec.provider);
958 println!(" 📊 Generation: {}", spec.generation);
959 println!(" ⭐ Tier: {} ({})", spec.tier, {
960 if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
961 &tier_info.description
962 } else {
963 "No description available"
964 }
965 });
966 println!(" 📤 Max output tokens: {}", spec.max_output_tokens);
967 println!(" 📥 Input context: {}", spec.input_context);
968
969 if spec.legacy {
970 println!(" ⚠️ Legacy model (consider upgrading to newer version)");
971 }
972 } else {
973 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
975 println!(" 🏷️ Provider: {}", metadata.provider);
976 println!(" ⚠️ Model not found in registry, using client metadata:");
977 println!(" 📤 Max output tokens: {}", metadata.max_response_length);
978 println!(" 📥 Input context: {}", metadata.max_context_length);
979 }
980
981 println!();
982 Ok(())
983 }
984
985 fn show_guidance_files_status(
987 &self,
988 project_context: &crate::data::context::ProjectContext,
989 context_dir: &std::path::Path,
990 ) -> Result<()> {
991 println!("📋 Project guidance files status:");
992
993 let guidelines_found = project_context.commit_guidelines.is_some();
995 let guidelines_source = if guidelines_found {
996 let local_path = context_dir.join("local").join("commit-guidelines.md");
997 let project_path = context_dir.join("commit-guidelines.md");
998 let home_path = dirs::home_dir()
999 .map(|h| h.join(".omni-dev").join("commit-guidelines.md"))
1000 .unwrap_or_default();
1001
1002 if local_path.exists() {
1003 format!("✅ Local override: {}", local_path.display())
1004 } else if project_path.exists() {
1005 format!("✅ Project: {}", project_path.display())
1006 } else if home_path.exists() {
1007 format!("✅ Global: {}", home_path.display())
1008 } else {
1009 "✅ (source unknown)".to_string()
1010 }
1011 } else {
1012 "❌ None found".to_string()
1013 };
1014 println!(" 📝 Commit guidelines: {}", guidelines_source);
1015
1016 let scopes_count = project_context.valid_scopes.len();
1018 let scopes_source = if scopes_count > 0 {
1019 let local_path = context_dir.join("local").join("scopes.yaml");
1020 let project_path = context_dir.join("scopes.yaml");
1021 let home_path = dirs::home_dir()
1022 .map(|h| h.join(".omni-dev").join("scopes.yaml"))
1023 .unwrap_or_default();
1024
1025 let source = if local_path.exists() {
1026 format!("Local override: {}", local_path.display())
1027 } else if project_path.exists() {
1028 format!("Project: {}", project_path.display())
1029 } else if home_path.exists() {
1030 format!("Global: {}", home_path.display())
1031 } else {
1032 "(source unknown + ecosystem defaults)".to_string()
1033 };
1034 format!("✅ {} ({} scopes)", source, scopes_count)
1035 } else {
1036 "❌ None found".to_string()
1037 };
1038 println!(" 🎯 Valid scopes: {}", scopes_source);
1039
1040 println!();
1041 Ok(())
1042 }
1043
1044 async fn execute_no_ai(&self) -> Result<()> {
1046 use crate::data::amendments::{Amendment, AmendmentFile};
1047
1048 println!("📋 Generating amendments YAML without AI processing...");
1049
1050 let repo_view = self.generate_repository_view().await?;
1052
1053 let amendments: Vec<Amendment> = repo_view
1055 .commits
1056 .iter()
1057 .map(|commit| Amendment {
1058 commit: commit.hash.clone(),
1059 message: commit.original_message.clone(),
1060 })
1061 .collect();
1062
1063 let amendment_file = AmendmentFile { amendments };
1064
1065 if let Some(save_path) = &self.save_only {
1067 amendment_file.save_to_file(save_path)?;
1068 println!("💾 Amendments saved to file");
1069 return Ok(());
1070 }
1071
1072 if !amendment_file.amendments.is_empty() {
1074 let temp_dir = tempfile::tempdir()?;
1076 let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
1077 amendment_file.save_to_file(&amendments_file)?;
1078
1079 if !self.auto_apply
1081 && !self.handle_amendments_file(&amendments_file, &amendment_file)?
1082 {
1083 println!("❌ Amendment cancelled by user");
1084 return Ok(());
1085 }
1086
1087 self.apply_amendments_from_file(&amendments_file).await?;
1089 println!("✅ Commit messages applied successfully!");
1090
1091 if self.check {
1093 self.run_post_twiddle_check().await?;
1094 }
1095 } else {
1096 println!("✨ No commits found to process!");
1097 }
1098
1099 Ok(())
1100 }
1101
1102 async fn run_post_twiddle_check(&self) -> Result<()> {
1104 println!();
1105 println!("🔍 Running commit message validation...");
1106
1107 let repo_view = self.generate_repository_view().await?;
1109
1110 if repo_view.commits.is_empty() {
1111 println!("⚠️ No commits to check");
1112 return Ok(());
1113 }
1114
1115 println!("📊 Checking {} commits", repo_view.commits.len());
1116
1117 let guidelines = self.load_check_guidelines()?;
1119 let valid_scopes = self.load_check_scopes();
1120
1121 self.show_check_guidance_files_status(&guidelines, &valid_scopes);
1122
1123 let claude_client = crate::claude::create_default_claude_client(self.model.clone())?;
1125
1126 let report = if repo_view.commits.len() > self.batch_size {
1128 println!("📦 Checking commits in batches of {}...", self.batch_size);
1129 self.check_commits_with_batching(
1130 &claude_client,
1131 &repo_view,
1132 guidelines.as_deref(),
1133 &valid_scopes,
1134 )
1135 .await?
1136 } else {
1137 println!("🤖 Analyzing commits with AI...");
1138 claude_client
1139 .check_commits_with_scopes(&repo_view, guidelines.as_deref(), &valid_scopes, true)
1140 .await?
1141 };
1142
1143 self.output_check_text_report(&report)?;
1145
1146 if report.has_errors() {
1148 println!("⚠️ Some commit messages still have issues after twiddling");
1149 } else if report.has_warnings() {
1150 println!("ℹ️ Some commit messages have minor warnings");
1151 } else {
1152 println!("✅ All commit messages pass validation");
1153 }
1154
1155 Ok(())
1156 }
1157
1158 fn load_check_guidelines(&self) -> Result<Option<String>> {
1160 use std::fs;
1161
1162 let context_dir = self
1163 .context_dir
1164 .clone()
1165 .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
1166
1167 let local_path = context_dir.join("local").join("commit-guidelines.md");
1169 if local_path.exists() {
1170 let content = fs::read_to_string(&local_path)
1171 .with_context(|| format!("Failed to read guidelines: {:?}", local_path))?;
1172 return Ok(Some(content));
1173 }
1174
1175 let project_path = context_dir.join("commit-guidelines.md");
1177 if project_path.exists() {
1178 let content = fs::read_to_string(&project_path)
1179 .with_context(|| format!("Failed to read guidelines: {:?}", project_path))?;
1180 return Ok(Some(content));
1181 }
1182
1183 if let Some(home) = dirs::home_dir() {
1185 let home_path = home.join(".omni-dev").join("commit-guidelines.md");
1186 if home_path.exists() {
1187 let content = fs::read_to_string(&home_path)
1188 .with_context(|| format!("Failed to read guidelines: {:?}", home_path))?;
1189 return Ok(Some(content));
1190 }
1191 }
1192
1193 Ok(None)
1194 }
1195
1196 fn load_check_scopes(&self) -> Vec<crate::data::context::ScopeDefinition> {
1198 use crate::data::context::ScopeDefinition;
1199 use std::fs;
1200
1201 #[derive(serde::Deserialize)]
1202 struct ScopesConfig {
1203 scopes: Vec<ScopeDefinition>,
1204 }
1205
1206 let context_dir = self
1207 .context_dir
1208 .clone()
1209 .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
1210
1211 let local_path = context_dir.join("local").join("scopes.yaml");
1213 if local_path.exists() {
1214 if let Ok(content) = fs::read_to_string(&local_path) {
1215 if let Ok(config) = serde_yaml::from_str::<ScopesConfig>(&content) {
1216 return config.scopes;
1217 }
1218 }
1219 }
1220
1221 let project_path = context_dir.join("scopes.yaml");
1223 if project_path.exists() {
1224 if let Ok(content) = fs::read_to_string(&project_path) {
1225 if let Ok(config) = serde_yaml::from_str::<ScopesConfig>(&content) {
1226 return config.scopes;
1227 }
1228 }
1229 }
1230
1231 if let Some(home) = dirs::home_dir() {
1233 let home_path = home.join(".omni-dev").join("scopes.yaml");
1234 if home_path.exists() {
1235 if let Ok(content) = fs::read_to_string(&home_path) {
1236 if let Ok(config) = serde_yaml::from_str::<ScopesConfig>(&content) {
1237 return config.scopes;
1238 }
1239 }
1240 }
1241 }
1242
1243 Vec::new()
1244 }
1245
1246 fn show_check_guidance_files_status(
1248 &self,
1249 guidelines: &Option<String>,
1250 valid_scopes: &[crate::data::context::ScopeDefinition],
1251 ) {
1252 let context_dir = self
1253 .context_dir
1254 .clone()
1255 .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
1256
1257 println!("📋 Project guidance files status:");
1258
1259 let guidelines_found = guidelines.is_some();
1261 let guidelines_source = if guidelines_found {
1262 let local_path = context_dir.join("local").join("commit-guidelines.md");
1263 let project_path = context_dir.join("commit-guidelines.md");
1264 let home_path = dirs::home_dir()
1265 .map(|h| h.join(".omni-dev").join("commit-guidelines.md"))
1266 .unwrap_or_default();
1267
1268 if local_path.exists() {
1269 format!("✅ Local override: {}", local_path.display())
1270 } else if project_path.exists() {
1271 format!("✅ Project: {}", project_path.display())
1272 } else if home_path.exists() {
1273 format!("✅ Global: {}", home_path.display())
1274 } else {
1275 "✅ (source unknown)".to_string()
1276 }
1277 } else {
1278 "⚪ Using defaults".to_string()
1279 };
1280 println!(" 📝 Commit guidelines: {}", guidelines_source);
1281
1282 let scopes_count = valid_scopes.len();
1284 let scopes_source = if scopes_count > 0 {
1285 let local_path = context_dir.join("local").join("scopes.yaml");
1286 let project_path = context_dir.join("scopes.yaml");
1287 let home_path = dirs::home_dir()
1288 .map(|h| h.join(".omni-dev").join("scopes.yaml"))
1289 .unwrap_or_default();
1290
1291 let source = if local_path.exists() {
1292 format!("Local override: {}", local_path.display())
1293 } else if project_path.exists() {
1294 format!("Project: {}", project_path.display())
1295 } else if home_path.exists() {
1296 format!("Global: {}", home_path.display())
1297 } else {
1298 "(source unknown)".to_string()
1299 };
1300 format!("✅ {} ({} scopes)", source, scopes_count)
1301 } else {
1302 "⚪ None found (any scope accepted)".to_string()
1303 };
1304 println!(" 🎯 Valid scopes: {}", scopes_source);
1305
1306 println!();
1307 }
1308
1309 async fn check_commits_with_batching(
1311 &self,
1312 claude_client: &crate::claude::client::ClaudeClient,
1313 full_repo_view: &crate::data::RepositoryView,
1314 guidelines: Option<&str>,
1315 valid_scopes: &[crate::data::context::ScopeDefinition],
1316 ) -> Result<crate::data::check::CheckReport> {
1317 use crate::data::check::{CheckReport, CommitCheckResult};
1318
1319 let commit_batches: Vec<_> = full_repo_view.commits.chunks(self.batch_size).collect();
1320 let total_batches = commit_batches.len();
1321 let mut all_results: Vec<CommitCheckResult> = Vec::new();
1322
1323 for (batch_num, commit_batch) in commit_batches.into_iter().enumerate() {
1324 println!(
1325 "🔄 Checking batch {}/{} ({} commits)...",
1326 batch_num + 1,
1327 total_batches,
1328 commit_batch.len()
1329 );
1330
1331 let batch_repo_view = crate::data::RepositoryView {
1332 versions: full_repo_view.versions.clone(),
1333 explanation: full_repo_view.explanation.clone(),
1334 working_directory: full_repo_view.working_directory.clone(),
1335 remotes: full_repo_view.remotes.clone(),
1336 ai: full_repo_view.ai.clone(),
1337 branch_info: full_repo_view.branch_info.clone(),
1338 pr_template: full_repo_view.pr_template.clone(),
1339 pr_template_location: full_repo_view.pr_template_location.clone(),
1340 branch_prs: full_repo_view.branch_prs.clone(),
1341 commits: commit_batch.to_vec(),
1342 };
1343
1344 let batch_report = claude_client
1345 .check_commits_with_scopes(&batch_repo_view, guidelines, valid_scopes, true)
1346 .await?;
1347
1348 all_results.extend(batch_report.commits);
1349
1350 if batch_num + 1 < total_batches {
1351 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
1352 }
1353 }
1354
1355 Ok(CheckReport::new(all_results))
1356 }
1357
1358 fn output_check_text_report(&self, report: &crate::data::check::CheckReport) -> Result<()> {
1360 use crate::data::check::IssueSeverity;
1361
1362 println!();
1363
1364 for result in &report.commits {
1365 if result.passes {
1367 continue;
1368 }
1369
1370 let icon = if result
1372 .issues
1373 .iter()
1374 .any(|i| i.severity == IssueSeverity::Error)
1375 {
1376 "❌"
1377 } else {
1378 "⚠️ "
1379 };
1380
1381 let short_hash = if result.hash.len() > 7 {
1383 &result.hash[..7]
1384 } else {
1385 &result.hash
1386 };
1387
1388 println!("{} {} - \"{}\"", icon, short_hash, result.message);
1389
1390 for issue in &result.issues {
1392 let severity_str = match issue.severity {
1393 IssueSeverity::Error => "\x1b[31mERROR\x1b[0m ",
1394 IssueSeverity::Warning => "\x1b[33mWARNING\x1b[0m",
1395 IssueSeverity::Info => "\x1b[36mINFO\x1b[0m ",
1396 };
1397
1398 println!(
1399 " {} [{}] {}",
1400 severity_str, issue.section, issue.explanation
1401 );
1402 }
1403
1404 if let Some(suggestion) = &result.suggestion {
1406 println!();
1407 println!(" Suggested message:");
1408 for line in suggestion.message.lines() {
1409 println!(" {}", line);
1410 }
1411 }
1412
1413 println!();
1414 }
1415
1416 println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
1418 println!("Summary: {} commits checked", report.summary.total_commits);
1419 println!(
1420 " {} errors, {} warnings",
1421 report.summary.error_count, report.summary.warning_count
1422 );
1423 println!(
1424 " {} passed, {} with issues",
1425 report.summary.passing_commits, report.summary.failing_commits
1426 );
1427
1428 Ok(())
1429 }
1430}
1431
1432impl BranchCommand {
1433 pub fn execute(self) -> Result<()> {
1435 match self.command {
1436 BranchSubcommands::Info(info_cmd) => info_cmd.execute(),
1437 BranchSubcommands::Create(create_cmd) => {
1438 let rt = tokio::runtime::Runtime::new()
1440 .context("Failed to create tokio runtime for PR creation")?;
1441 rt.block_on(create_cmd.execute())
1442 }
1443 }
1444 }
1445}
1446
1447impl InfoCommand {
1448 pub fn execute(self) -> Result<()> {
1450 use crate::data::{
1451 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
1452 WorkingDirectoryInfo,
1453 };
1454 use crate::git::{GitRepository, RemoteInfo};
1455 use crate::utils::ai_scratch;
1456
1457 let repo = GitRepository::open()
1459 .context("Failed to open git repository. Make sure you're in a git repository.")?;
1460
1461 let current_branch = repo.get_current_branch().context(
1463 "Failed to get current branch. Make sure you're not in detached HEAD state.",
1464 )?;
1465
1466 let base_branch = match self.base_branch {
1468 Some(branch) => {
1469 if !repo.branch_exists(&branch)? {
1471 anyhow::bail!("Base branch '{}' does not exist", branch);
1472 }
1473 branch
1474 }
1475 None => {
1476 if repo.branch_exists("main")? {
1478 "main".to_string()
1479 } else if repo.branch_exists("master")? {
1480 "master".to_string()
1481 } else {
1482 anyhow::bail!("No default base branch found (main or master)");
1483 }
1484 }
1485 };
1486
1487 let commit_range = format!("{}..HEAD", base_branch);
1489
1490 let wd_status = repo.get_working_directory_status()?;
1492 let working_directory = WorkingDirectoryInfo {
1493 clean: wd_status.clean,
1494 untracked_changes: wd_status
1495 .untracked_changes
1496 .into_iter()
1497 .map(|fs| FileStatusInfo {
1498 status: fs.status,
1499 file: fs.file,
1500 })
1501 .collect(),
1502 };
1503
1504 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
1506
1507 let commits = repo.get_commits_in_range(&commit_range)?;
1509
1510 let pr_template_result = Self::read_pr_template().ok();
1512 let (pr_template, pr_template_location) = match pr_template_result {
1513 Some((content, location)) => (Some(content), Some(location)),
1514 None => (None, None),
1515 };
1516
1517 let branch_prs = Self::get_branch_prs(¤t_branch)
1519 .ok()
1520 .filter(|prs| !prs.is_empty());
1521
1522 let versions = Some(VersionInfo {
1524 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
1525 });
1526
1527 let ai_scratch_path =
1529 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
1530 let ai_info = AiInfo {
1531 scratch: ai_scratch_path.to_string_lossy().to_string(),
1532 };
1533
1534 let mut repo_view = RepositoryView {
1536 versions,
1537 explanation: FieldExplanation::default(),
1538 working_directory,
1539 remotes,
1540 ai: ai_info,
1541 branch_info: Some(BranchInfo {
1542 branch: current_branch,
1543 }),
1544 pr_template,
1545 pr_template_location,
1546 branch_prs,
1547 commits,
1548 };
1549
1550 repo_view.update_field_presence();
1552
1553 let yaml_output = crate::data::to_yaml(&repo_view)?;
1555 println!("{}", yaml_output);
1556
1557 Ok(())
1558 }
1559
1560 fn read_pr_template() -> Result<(String, String)> {
1562 use std::fs;
1563 use std::path::Path;
1564
1565 let template_path = Path::new(".github/pull_request_template.md");
1566 if template_path.exists() {
1567 let content = fs::read_to_string(template_path)
1568 .context("Failed to read .github/pull_request_template.md")?;
1569 Ok((content, template_path.to_string_lossy().to_string()))
1570 } else {
1571 anyhow::bail!("PR template file does not exist")
1572 }
1573 }
1574
1575 fn get_branch_prs(branch_name: &str) -> Result<Vec<crate::data::PullRequest>> {
1577 use serde_json::Value;
1578 use std::process::Command;
1579
1580 let output = Command::new("gh")
1582 .args([
1583 "pr",
1584 "list",
1585 "--head",
1586 branch_name,
1587 "--json",
1588 "number,title,state,url,body,baseRefName",
1589 "--limit",
1590 "50",
1591 ])
1592 .output()
1593 .context("Failed to execute gh command")?;
1594
1595 if !output.status.success() {
1596 anyhow::bail!(
1597 "gh command failed: {}",
1598 String::from_utf8_lossy(&output.stderr)
1599 );
1600 }
1601
1602 let json_str = String::from_utf8_lossy(&output.stdout);
1603 let prs_json: Value =
1604 serde_json::from_str(&json_str).context("Failed to parse PR JSON from gh")?;
1605
1606 let mut prs = Vec::new();
1607 if let Some(prs_array) = prs_json.as_array() {
1608 for pr_json in prs_array {
1609 if let (Some(number), Some(title), Some(state), Some(url), Some(body)) = (
1610 pr_json.get("number").and_then(|n| n.as_u64()),
1611 pr_json.get("title").and_then(|t| t.as_str()),
1612 pr_json.get("state").and_then(|s| s.as_str()),
1613 pr_json.get("url").and_then(|u| u.as_str()),
1614 pr_json.get("body").and_then(|b| b.as_str()),
1615 ) {
1616 let base = pr_json
1617 .get("baseRefName")
1618 .and_then(|b| b.as_str())
1619 .unwrap_or("")
1620 .to_string();
1621 prs.push(crate::data::PullRequest {
1622 number,
1623 title: title.to_string(),
1624 state: state.to_string(),
1625 url: url.to_string(),
1626 body: body.to_string(),
1627 base,
1628 });
1629 }
1630 }
1631 }
1632
1633 Ok(prs)
1634 }
1635}
1636
1637#[derive(Debug, PartialEq)]
1639enum PrAction {
1640 CreateNew,
1641 UpdateExisting,
1642 Cancel,
1643}
1644
1645#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
1647pub struct PrContent {
1648 pub title: String,
1650 pub description: String,
1652}
1653
1654impl CreateCommand {
1655 pub async fn execute(self) -> Result<()> {
1657 match self.command {
1658 CreateSubcommands::Pr(pr_cmd) => pr_cmd.execute().await,
1659 }
1660 }
1661}
1662
1663impl CreatePrCommand {
1664 fn should_create_as_draft(&self) -> bool {
1672 use crate::utils::settings::get_env_var;
1673
1674 if self.ready {
1676 return false;
1677 }
1678 if self.draft {
1679 return true;
1680 }
1681
1682 get_env_var("OMNI_DEV_DEFAULT_DRAFT_PR")
1684 .ok()
1685 .and_then(|val| match val.to_lowercase().as_str() {
1686 "true" | "1" | "yes" => Some(true),
1687 "false" | "0" | "no" => Some(false),
1688 _ => None,
1689 })
1690 .unwrap_or(true) }
1692
1693 pub async fn execute(self) -> Result<()> {
1695 println!("🔄 Starting pull request creation process...");
1696
1697 let repo_view = self.generate_repository_view()?;
1699
1700 self.validate_branch_state(&repo_view)?;
1702
1703 use crate::claude::context::ProjectDiscovery;
1705 let repo_root = std::path::PathBuf::from(".");
1706 let context_dir = std::path::PathBuf::from(".omni-dev");
1707 let discovery = ProjectDiscovery::new(repo_root, context_dir);
1708 let project_context = discovery.discover().unwrap_or_default();
1709 self.show_guidance_files_status(&project_context)?;
1710
1711 let claude_client = crate::claude::create_default_claude_client(None)?;
1713 self.show_model_info_from_client(&claude_client)?;
1714
1715 self.show_commit_range_info(&repo_view)?;
1717
1718 let context = {
1720 use crate::claude::context::{BranchAnalyzer, WorkPatternAnalyzer};
1721 use crate::data::context::CommitContext;
1722 let mut context = CommitContext::new();
1723 context.project = project_context;
1724
1725 if let Some(branch_info) = &repo_view.branch_info {
1727 context.branch = BranchAnalyzer::analyze(&branch_info.branch).unwrap_or_default();
1728 }
1729
1730 if !repo_view.commits.is_empty() {
1731 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
1732 }
1733 context
1734 };
1735 self.show_context_summary(&context)?;
1736
1737 debug!("About to generate PR content from AI");
1739 let (pr_content, _claude_client) = self
1740 .generate_pr_content_with_client_internal(&repo_view, claude_client)
1741 .await?;
1742
1743 self.show_context_information(&repo_view).await?;
1745 debug!(
1746 generated_title = %pr_content.title,
1747 generated_description_length = pr_content.description.len(),
1748 generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1749 "Generated PR content from AI"
1750 );
1751
1752 if let Some(save_path) = self.save_only {
1754 let pr_yaml = crate::data::to_yaml(&pr_content)
1755 .context("Failed to serialize PR content to YAML")?;
1756 std::fs::write(&save_path, &pr_yaml).context("Failed to save PR details to file")?;
1757 println!("💾 PR details saved to: {}", save_path);
1758 return Ok(());
1759 }
1760
1761 debug!("About to serialize PR content to YAML");
1763 let temp_dir = tempfile::tempdir()?;
1764 let pr_file = temp_dir.path().join("pr-details.yaml");
1765
1766 debug!(
1767 pre_serialize_title = %pr_content.title,
1768 pre_serialize_description_length = pr_content.description.len(),
1769 pre_serialize_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1770 "About to serialize PR content with to_yaml"
1771 );
1772
1773 let pr_yaml =
1774 crate::data::to_yaml(&pr_content).context("Failed to serialize PR content to YAML")?;
1775
1776 debug!(
1777 file_path = %pr_file.display(),
1778 yaml_content_length = pr_yaml.len(),
1779 yaml_content = %pr_yaml,
1780 original_title = %pr_content.title,
1781 original_description_length = pr_content.description.len(),
1782 "Writing PR details to temporary YAML file"
1783 );
1784
1785 std::fs::write(&pr_file, &pr_yaml)?;
1786
1787 let pr_action = if self.auto_apply {
1789 if repo_view
1791 .branch_prs
1792 .as_ref()
1793 .is_some_and(|prs| !prs.is_empty())
1794 {
1795 PrAction::UpdateExisting
1796 } else {
1797 PrAction::CreateNew
1798 }
1799 } else {
1800 self.handle_pr_file(&pr_file, &repo_view)?
1801 };
1802
1803 if pr_action == PrAction::Cancel {
1804 println!("❌ PR operation cancelled by user");
1805 return Ok(());
1806 }
1807
1808 self.validate_environment()?;
1810
1811 let final_pr_yaml =
1813 std::fs::read_to_string(&pr_file).context("Failed to read PR details file")?;
1814
1815 debug!(
1816 yaml_length = final_pr_yaml.len(),
1817 yaml_content = %final_pr_yaml,
1818 "Read PR details YAML from file"
1819 );
1820
1821 let final_pr_content: PrContent = serde_yaml::from_str(&final_pr_yaml)
1822 .context("Failed to parse PR details YAML. Please check the file format.")?;
1823
1824 debug!(
1825 title = %final_pr_content.title,
1826 description_length = final_pr_content.description.len(),
1827 description_preview = %final_pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1828 "Parsed PR content from YAML"
1829 );
1830
1831 let is_draft = self.should_create_as_draft();
1833
1834 match pr_action {
1835 PrAction::CreateNew => {
1836 self.create_github_pr(
1837 &repo_view,
1838 &final_pr_content.title,
1839 &final_pr_content.description,
1840 is_draft,
1841 self.base.as_deref(),
1842 )?;
1843 println!("✅ Pull request created successfully!");
1844 }
1845 PrAction::UpdateExisting => {
1846 self.update_github_pr(
1847 &repo_view,
1848 &final_pr_content.title,
1849 &final_pr_content.description,
1850 self.base.as_deref(),
1851 )?;
1852 println!("✅ Pull request updated successfully!");
1853 }
1854 PrAction::Cancel => unreachable!(), }
1856
1857 Ok(())
1858 }
1859
1860 fn validate_environment(&self) -> Result<()> {
1862 let gh_check = std::process::Command::new("gh")
1864 .args(["--version"])
1865 .output();
1866
1867 match gh_check {
1868 Ok(output) if output.status.success() => {
1869 let repo_check = std::process::Command::new("gh")
1871 .args(["repo", "view", "--json", "name"])
1872 .output();
1873
1874 match repo_check {
1875 Ok(repo_output) if repo_output.status.success() => Ok(()),
1876 Ok(repo_output) => {
1877 let error_details = String::from_utf8_lossy(&repo_output.stderr);
1879 if error_details.contains("authentication") || error_details.contains("login") {
1880 anyhow::bail!("GitHub CLI (gh) authentication failed. Please run 'gh auth login' or check your GITHUB_TOKEN environment variable.")
1881 } else {
1882 anyhow::bail!("GitHub CLI (gh) cannot access this repository. Error: {}", error_details.trim())
1883 }
1884 }
1885 Err(e) => anyhow::bail!("Failed to test GitHub CLI access: {}", e),
1886 }
1887 }
1888 _ => anyhow::bail!("GitHub CLI (gh) is not installed or not available in PATH. Please install it from https://cli.github.com/"),
1889 }
1890 }
1891
1892 fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
1894 use crate::data::{
1895 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
1896 WorkingDirectoryInfo,
1897 };
1898 use crate::git::{GitRepository, RemoteInfo};
1899 use crate::utils::ai_scratch;
1900
1901 let repo = GitRepository::open()
1903 .context("Failed to open git repository. Make sure you're in a git repository.")?;
1904
1905 let current_branch = repo.get_current_branch().context(
1907 "Failed to get current branch. Make sure you're not in detached HEAD state.",
1908 )?;
1909
1910 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
1912
1913 let primary_remote = remotes
1915 .iter()
1916 .find(|r| r.name == "origin")
1917 .or_else(|| remotes.first())
1918 .ok_or_else(|| anyhow::anyhow!("No remotes found in repository"))?;
1919
1920 let base_branch = match self.base.as_ref() {
1922 Some(branch) => {
1923 let remote_ref = format!("refs/remotes/{}", branch);
1926 if repo.repository().find_reference(&remote_ref).is_ok() {
1927 branch.clone()
1928 } else {
1929 let with_remote = format!("{}/{}", primary_remote.name, branch);
1931 let remote_ref = format!("refs/remotes/{}", with_remote);
1932 if repo.repository().find_reference(&remote_ref).is_ok() {
1933 with_remote
1934 } else {
1935 anyhow::bail!(
1936 "Remote branch '{}' does not exist (also tried '{}')",
1937 branch,
1938 with_remote
1939 );
1940 }
1941 }
1942 }
1943 None => {
1944 let main_branch = &primary_remote.main_branch;
1946 if main_branch == "unknown" {
1947 anyhow::bail!(
1948 "Could not determine main branch for remote '{}'",
1949 primary_remote.name
1950 );
1951 }
1952
1953 let remote_main = format!("{}/{}", primary_remote.name, main_branch);
1954
1955 let remote_ref = format!("refs/remotes/{}", remote_main);
1957 if repo.repository().find_reference(&remote_ref).is_err() {
1958 anyhow::bail!(
1959 "Remote main branch '{}' does not exist. Try running 'git fetch' first.",
1960 remote_main
1961 );
1962 }
1963
1964 remote_main
1965 }
1966 };
1967
1968 let commit_range = format!("{}..HEAD", base_branch);
1970
1971 let wd_status = repo.get_working_directory_status()?;
1973 let working_directory = WorkingDirectoryInfo {
1974 clean: wd_status.clean,
1975 untracked_changes: wd_status
1976 .untracked_changes
1977 .into_iter()
1978 .map(|fs| FileStatusInfo {
1979 status: fs.status,
1980 file: fs.file,
1981 })
1982 .collect(),
1983 };
1984
1985 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
1987
1988 let commits = repo.get_commits_in_range(&commit_range)?;
1990
1991 let pr_template_result = InfoCommand::read_pr_template().ok();
1993 let (pr_template, pr_template_location) = match pr_template_result {
1994 Some((content, location)) => (Some(content), Some(location)),
1995 None => (None, None),
1996 };
1997
1998 let branch_prs = InfoCommand::get_branch_prs(¤t_branch)
2000 .ok()
2001 .filter(|prs| !prs.is_empty());
2002
2003 let versions = Some(VersionInfo {
2005 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
2006 });
2007
2008 let ai_scratch_path =
2010 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
2011 let ai_info = AiInfo {
2012 scratch: ai_scratch_path.to_string_lossy().to_string(),
2013 };
2014
2015 let mut repo_view = RepositoryView {
2017 versions,
2018 explanation: FieldExplanation::default(),
2019 working_directory,
2020 remotes,
2021 ai: ai_info,
2022 branch_info: Some(BranchInfo {
2023 branch: current_branch,
2024 }),
2025 pr_template,
2026 pr_template_location,
2027 branch_prs,
2028 commits,
2029 };
2030
2031 repo_view.update_field_presence();
2033
2034 Ok(repo_view)
2035 }
2036
2037 fn validate_branch_state(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
2039 if !repo_view.working_directory.clean {
2041 anyhow::bail!(
2042 "Working directory has uncommitted changes. Please commit or stash your changes before creating a PR."
2043 );
2044 }
2045
2046 if !repo_view.working_directory.untracked_changes.is_empty() {
2048 let file_list: Vec<&str> = repo_view
2049 .working_directory
2050 .untracked_changes
2051 .iter()
2052 .map(|f| f.file.as_str())
2053 .collect();
2054 anyhow::bail!(
2055 "Working directory has untracked changes: {}. Please commit or stash your changes before creating a PR.",
2056 file_list.join(", ")
2057 );
2058 }
2059
2060 if repo_view.commits.is_empty() {
2062 anyhow::bail!("No commits found to create PR from. Make sure you have commits that are not in the base branch.");
2063 }
2064
2065 if let Some(existing_prs) = &repo_view.branch_prs {
2067 if !existing_prs.is_empty() {
2068 let pr_info: Vec<String> = existing_prs
2069 .iter()
2070 .map(|pr| format!("#{} ({})", pr.number, pr.state))
2071 .collect();
2072
2073 println!(
2074 "📋 Existing PR(s) found for this branch: {}",
2075 pr_info.join(", ")
2076 );
2077 }
2079 }
2080
2081 Ok(())
2082 }
2083
2084 async fn show_context_information(
2086 &self,
2087 _repo_view: &crate::data::RepositoryView,
2088 ) -> Result<()> {
2089 Ok(())
2094 }
2095
2096 fn show_commit_range_info(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
2098 let base_branch = match self.base.as_ref() {
2100 Some(branch) => {
2101 let primary_remote_name = repo_view
2104 .remotes
2105 .iter()
2106 .find(|r| r.name == "origin")
2107 .or_else(|| repo_view.remotes.first())
2108 .map(|r| r.name.as_str())
2109 .unwrap_or("origin");
2110 if branch.starts_with(&format!("{}/", primary_remote_name)) {
2112 branch.clone()
2113 } else {
2114 format!("{}/{}", primary_remote_name, branch)
2115 }
2116 }
2117 None => {
2118 repo_view
2120 .remotes
2121 .iter()
2122 .find(|r| r.name == "origin")
2123 .or_else(|| repo_view.remotes.first())
2124 .map(|r| format!("{}/{}", r.name, r.main_branch))
2125 .unwrap_or_else(|| "unknown".to_string())
2126 }
2127 };
2128
2129 let commit_range = format!("{}..HEAD", base_branch);
2130 let commit_count = repo_view.commits.len();
2131
2132 let current_branch = repo_view
2134 .branch_info
2135 .as_ref()
2136 .map(|bi| bi.branch.as_str())
2137 .unwrap_or("unknown");
2138
2139 println!("📊 Branch Analysis:");
2140 println!(" 🌿 Current branch: {}", current_branch);
2141 println!(" 📏 Commit range: {}", commit_range);
2142 println!(" 📝 Commits found: {} commits", commit_count);
2143 println!();
2144
2145 Ok(())
2146 }
2147
2148 async fn collect_context(
2150 &self,
2151 repo_view: &crate::data::RepositoryView,
2152 ) -> Result<crate::data::context::CommitContext> {
2153 use crate::claude::context::{BranchAnalyzer, ProjectDiscovery, WorkPatternAnalyzer};
2154 use crate::data::context::CommitContext;
2155 use crate::git::GitRepository;
2156
2157 let mut context = CommitContext::new();
2158
2159 let context_dir = std::path::PathBuf::from(".omni-dev");
2161
2162 let repo_root = std::path::PathBuf::from(".");
2164 let discovery = ProjectDiscovery::new(repo_root, context_dir.clone());
2165 match discovery.discover() {
2166 Ok(project_context) => {
2167 context.project = project_context;
2168 }
2169 Err(_e) => {
2170 context.project = Default::default();
2171 }
2172 }
2173
2174 let repo = GitRepository::open()?;
2176 let current_branch = repo
2177 .get_current_branch()
2178 .unwrap_or_else(|_| "HEAD".to_string());
2179 context.branch = BranchAnalyzer::analyze(¤t_branch).unwrap_or_default();
2180
2181 if !repo_view.commits.is_empty() {
2183 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
2184 }
2185
2186 Ok(context)
2187 }
2188
2189 fn show_guidance_files_status(
2191 &self,
2192 project_context: &crate::data::context::ProjectContext,
2193 ) -> Result<()> {
2194 let context_dir = std::path::PathBuf::from(".omni-dev");
2195
2196 println!("📋 Project guidance files status:");
2197
2198 let pr_guidelines_found = project_context.pr_guidelines.is_some();
2200 let pr_guidelines_source = if pr_guidelines_found {
2201 let local_path = context_dir.join("local").join("pr-guidelines.md");
2202 let project_path = context_dir.join("pr-guidelines.md");
2203 let home_path = dirs::home_dir()
2204 .map(|h| h.join(".omni-dev").join("pr-guidelines.md"))
2205 .unwrap_or_default();
2206
2207 if local_path.exists() {
2208 format!("✅ Local override: {}", local_path.display())
2209 } else if project_path.exists() {
2210 format!("✅ Project: {}", project_path.display())
2211 } else if home_path.exists() {
2212 format!("✅ Global: {}", home_path.display())
2213 } else {
2214 "✅ (source unknown)".to_string()
2215 }
2216 } else {
2217 "❌ None found".to_string()
2218 };
2219 println!(" 🔀 PR guidelines: {}", pr_guidelines_source);
2220
2221 let scopes_count = project_context.valid_scopes.len();
2223 let scopes_source = if scopes_count > 0 {
2224 let local_path = context_dir.join("local").join("scopes.yaml");
2225 let project_path = context_dir.join("scopes.yaml");
2226 let home_path = dirs::home_dir()
2227 .map(|h| h.join(".omni-dev").join("scopes.yaml"))
2228 .unwrap_or_default();
2229
2230 let source = if local_path.exists() {
2231 format!("Local override: {}", local_path.display())
2232 } else if project_path.exists() {
2233 format!("Project: {}", project_path.display())
2234 } else if home_path.exists() {
2235 format!("Global: {}", home_path.display())
2236 } else {
2237 "(source unknown + ecosystem defaults)".to_string()
2238 };
2239 format!("✅ {} ({} scopes)", source, scopes_count)
2240 } else {
2241 "❌ None found".to_string()
2242 };
2243 println!(" 🎯 Valid scopes: {}", scopes_source);
2244
2245 let pr_template_path = std::path::Path::new(".github/pull_request_template.md");
2247 let pr_template_status = if pr_template_path.exists() {
2248 format!("✅ Project: {}", pr_template_path.display())
2249 } else {
2250 "❌ None found".to_string()
2251 };
2252 println!(" 📋 PR template: {}", pr_template_status);
2253
2254 println!();
2255 Ok(())
2256 }
2257
2258 fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
2260 use crate::data::context::{VerbosityLevel, WorkPattern};
2261
2262 println!("🔍 Context Analysis:");
2263
2264 if !context.project.valid_scopes.is_empty() {
2266 let scope_names: Vec<&str> = context
2267 .project
2268 .valid_scopes
2269 .iter()
2270 .map(|s| s.name.as_str())
2271 .collect();
2272 println!(" 📁 Valid scopes: {}", scope_names.join(", "));
2273 }
2274
2275 if context.branch.is_feature_branch {
2277 println!(
2278 " 🌿 Branch: {} ({})",
2279 context.branch.description, context.branch.work_type
2280 );
2281 if let Some(ref ticket) = context.branch.ticket_id {
2282 println!(" 🎫 Ticket: {}", ticket);
2283 }
2284 }
2285
2286 match context.range.work_pattern {
2288 WorkPattern::Sequential => println!(" 🔄 Pattern: Sequential development"),
2289 WorkPattern::Refactoring => println!(" 🧹 Pattern: Refactoring work"),
2290 WorkPattern::BugHunt => println!(" 🐛 Pattern: Bug investigation"),
2291 WorkPattern::Documentation => println!(" 📖 Pattern: Documentation updates"),
2292 WorkPattern::Configuration => println!(" ⚙️ Pattern: Configuration changes"),
2293 WorkPattern::Unknown => {}
2294 }
2295
2296 match context.suggested_verbosity() {
2298 VerbosityLevel::Comprehensive => {
2299 println!(" 📝 Detail level: Comprehensive (significant changes detected)")
2300 }
2301 VerbosityLevel::Detailed => println!(" 📝 Detail level: Detailed"),
2302 VerbosityLevel::Concise => println!(" 📝 Detail level: Concise"),
2303 }
2304
2305 println!();
2306 Ok(())
2307 }
2308
2309 async fn generate_pr_content_with_client_internal(
2311 &self,
2312 repo_view: &crate::data::RepositoryView,
2313 claude_client: crate::claude::client::ClaudeClient,
2314 ) -> Result<(PrContent, crate::claude::client::ClaudeClient)> {
2315 use tracing::debug;
2316
2317 let pr_template = match &repo_view.pr_template {
2319 Some(template) => template.clone(),
2320 None => self.get_default_pr_template(),
2321 };
2322
2323 debug!(
2324 pr_template_length = pr_template.len(),
2325 pr_template_preview = %pr_template.lines().take(5).collect::<Vec<_>>().join("\\n"),
2326 "Using PR template for generation"
2327 );
2328
2329 println!("🤖 Generating AI-powered PR description...");
2330
2331 debug!("Collecting context for PR generation");
2333 let context = self.collect_context(repo_view).await?;
2334 debug!("Context collection completed");
2335
2336 debug!("About to call Claude AI for PR content generation");
2338 match claude_client
2339 .generate_pr_content_with_context(repo_view, &pr_template, &context)
2340 .await
2341 {
2342 Ok(pr_content) => {
2343 debug!(
2344 ai_generated_title = %pr_content.title,
2345 ai_generated_description_length = pr_content.description.len(),
2346 ai_generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
2347 "AI successfully generated PR content"
2348 );
2349 Ok((pr_content, claude_client))
2350 }
2351 Err(e) => {
2352 debug!(error = %e, "AI PR generation failed, falling back to basic description");
2353 let mut description = pr_template;
2355 self.enhance_description_with_commits(&mut description, repo_view)?;
2356
2357 let title = self.generate_title_from_commits(repo_view);
2359
2360 debug!(
2361 fallback_title = %title,
2362 fallback_description_length = description.len(),
2363 "Created fallback PR content"
2364 );
2365
2366 Ok((PrContent { title, description }, claude_client))
2367 }
2368 }
2369 }
2370
2371 fn get_default_pr_template(&self) -> String {
2373 r#"# Pull Request
2374
2375## Description
2376<!-- Provide a brief description of what this PR does -->
2377
2378## Type of Change
2379<!-- Mark the relevant option with an "x" -->
2380- [ ] Bug fix (non-breaking change which fixes an issue)
2381- [ ] New feature (non-breaking change which adds functionality)
2382- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
2383- [ ] Documentation update
2384- [ ] Refactoring (no functional changes)
2385- [ ] Performance improvement
2386- [ ] Test coverage improvement
2387
2388## Changes Made
2389<!-- List the specific changes made in this PR -->
2390-
2391-
2392-
2393
2394## Testing
2395- [ ] All existing tests pass
2396- [ ] New tests added for new functionality
2397- [ ] Manual testing performed
2398
2399## Additional Notes
2400<!-- Add any additional notes for reviewers -->
2401"#.to_string()
2402 }
2403
2404 fn enhance_description_with_commits(
2406 &self,
2407 description: &mut String,
2408 repo_view: &crate::data::RepositoryView,
2409 ) -> Result<()> {
2410 if repo_view.commits.is_empty() {
2411 return Ok(());
2412 }
2413
2414 description.push_str("\n---\n");
2416 description.push_str("## 📝 Commit Summary\n");
2417 description
2418 .push_str("*This section was automatically generated based on commit analysis*\n\n");
2419
2420 let mut types_found = std::collections::HashSet::new();
2422 let mut scopes_found = std::collections::HashSet::new();
2423 let mut has_breaking_changes = false;
2424
2425 for commit in &repo_view.commits {
2426 let detected_type = &commit.analysis.detected_type;
2427 types_found.insert(detected_type.clone());
2428 if detected_type.contains("BREAKING")
2429 || commit.original_message.contains("BREAKING CHANGE")
2430 {
2431 has_breaking_changes = true;
2432 }
2433
2434 let detected_scope = &commit.analysis.detected_scope;
2435 if !detected_scope.is_empty() {
2436 scopes_found.insert(detected_scope.clone());
2437 }
2438 }
2439
2440 if let Some(feat_pos) = description.find("- [ ] New feature") {
2442 if types_found.contains("feat") {
2443 description.replace_range(feat_pos..feat_pos + 5, "- [x]");
2444 }
2445 }
2446 if let Some(fix_pos) = description.find("- [ ] Bug fix") {
2447 if types_found.contains("fix") {
2448 description.replace_range(fix_pos..fix_pos + 5, "- [x]");
2449 }
2450 }
2451 if let Some(docs_pos) = description.find("- [ ] Documentation update") {
2452 if types_found.contains("docs") {
2453 description.replace_range(docs_pos..docs_pos + 5, "- [x]");
2454 }
2455 }
2456 if let Some(refactor_pos) = description.find("- [ ] Refactoring") {
2457 if types_found.contains("refactor") {
2458 description.replace_range(refactor_pos..refactor_pos + 5, "- [x]");
2459 }
2460 }
2461 if let Some(breaking_pos) = description.find("- [ ] Breaking change") {
2462 if has_breaking_changes {
2463 description.replace_range(breaking_pos..breaking_pos + 5, "- [x]");
2464 }
2465 }
2466
2467 if !scopes_found.is_empty() {
2469 let scopes_list: Vec<_> = scopes_found.into_iter().collect();
2470 description.push_str(&format!(
2471 "**Affected areas:** {}\n\n",
2472 scopes_list.join(", ")
2473 ));
2474 }
2475
2476 description.push_str("### Commits in this PR:\n");
2478 for commit in &repo_view.commits {
2479 let short_hash = &commit.hash[..8];
2480 let first_line = commit.original_message.lines().next().unwrap_or("").trim();
2481 description.push_str(&format!("- `{}` {}\n", short_hash, first_line));
2482 }
2483
2484 let total_files: usize = repo_view
2486 .commits
2487 .iter()
2488 .map(|c| c.analysis.file_changes.total_files)
2489 .sum();
2490
2491 if total_files > 0 {
2492 description.push_str(&format!("\n**Files changed:** {} files\n", total_files));
2493 }
2494
2495 Ok(())
2496 }
2497
2498 fn handle_pr_file(
2500 &self,
2501 pr_file: &std::path::Path,
2502 repo_view: &crate::data::RepositoryView,
2503 ) -> Result<PrAction> {
2504 use std::io::{self, Write};
2505
2506 println!("\n📝 PR details generated.");
2507 println!("💾 Details saved to: {}", pr_file.display());
2508
2509 let is_draft = self.should_create_as_draft();
2511 let status_icon = if is_draft { "📋" } else { "✅" };
2512 let status_text = if is_draft {
2513 "draft"
2514 } else {
2515 "ready for review"
2516 };
2517 println!("{} PR will be created as: {}", status_icon, status_text);
2518 println!();
2519
2520 let has_existing_prs = repo_view
2522 .branch_prs
2523 .as_ref()
2524 .is_some_and(|prs| !prs.is_empty());
2525
2526 loop {
2527 if has_existing_prs {
2528 print!("❓ [U]pdate existing PR, [N]ew PR anyway, [S]how file, [E]dit file, or [Q]uit? [U/n/s/e/q] ");
2529 } else {
2530 print!(
2531 "❓ [A]ccept and create PR, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] "
2532 );
2533 }
2534 io::stdout().flush()?;
2535
2536 let mut input = String::new();
2537 io::stdin().read_line(&mut input)?;
2538
2539 match input.trim().to_lowercase().as_str() {
2540 "u" | "update" if has_existing_prs => return Ok(PrAction::UpdateExisting),
2541 "n" | "new" if has_existing_prs => return Ok(PrAction::CreateNew),
2542 "a" | "accept" | "" if !has_existing_prs => return Ok(PrAction::CreateNew),
2543 "s" | "show" => {
2544 self.show_pr_file(pr_file)?;
2545 println!();
2546 }
2547 "e" | "edit" => {
2548 self.edit_pr_file(pr_file)?;
2549 println!();
2550 }
2551 "q" | "quit" => return Ok(PrAction::Cancel),
2552 _ => {
2553 if has_existing_prs {
2554 println!("Invalid choice. Please enter 'u' to update existing PR, 'n' for new PR, 's' to show, 'e' to edit, or 'q' to quit.");
2555 } else {
2556 println!("Invalid choice. Please enter 'a' to accept, 's' to show, 'e' to edit, or 'q' to quit.");
2557 }
2558 }
2559 }
2560 }
2561 }
2562
2563 fn show_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
2565 use std::fs;
2566
2567 println!("\n📄 PR details file contents:");
2568 println!("─────────────────────────────");
2569
2570 let contents = fs::read_to_string(pr_file).context("Failed to read PR details file")?;
2571 println!("{}", contents);
2572 println!("─────────────────────────────");
2573
2574 Ok(())
2575 }
2576
2577 fn edit_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
2579 use std::env;
2580 use std::io::{self, Write};
2581 use std::process::Command;
2582
2583 let editor = env::var("OMNI_DEV_EDITOR")
2585 .or_else(|_| env::var("EDITOR"))
2586 .unwrap_or_else(|_| {
2587 println!(
2589 "🔧 Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined."
2590 );
2591 print!("Please enter the command to use as your editor: ");
2592 io::stdout().flush().expect("Failed to flush stdout");
2593
2594 let mut input = String::new();
2595 io::stdin()
2596 .read_line(&mut input)
2597 .expect("Failed to read user input");
2598 input.trim().to_string()
2599 });
2600
2601 if editor.is_empty() {
2602 println!("❌ No editor specified. Returning to menu.");
2603 return Ok(());
2604 }
2605
2606 println!("📝 Opening PR details file in editor: {}", editor);
2607
2608 let mut cmd_parts = editor.split_whitespace();
2610 let editor_cmd = cmd_parts.next().unwrap_or(&editor);
2611 let args: Vec<&str> = cmd_parts.collect();
2612
2613 let mut command = Command::new(editor_cmd);
2614 command.args(args);
2615 command.arg(pr_file.to_string_lossy().as_ref());
2616
2617 match command.status() {
2618 Ok(status) => {
2619 if status.success() {
2620 println!("✅ Editor session completed.");
2621 } else {
2622 println!(
2623 "⚠️ Editor exited with non-zero status: {:?}",
2624 status.code()
2625 );
2626 }
2627 }
2628 Err(e) => {
2629 println!("❌ Failed to execute editor '{}': {}", editor, e);
2630 println!(" Please check that the editor command is correct and available in your PATH.");
2631 }
2632 }
2633
2634 Ok(())
2635 }
2636
2637 fn generate_title_from_commits(&self, repo_view: &crate::data::RepositoryView) -> String {
2639 if repo_view.commits.is_empty() {
2640 return "Pull Request".to_string();
2641 }
2642
2643 if repo_view.commits.len() == 1 {
2645 return repo_view.commits[0]
2646 .original_message
2647 .lines()
2648 .next()
2649 .unwrap_or("Pull Request")
2650 .trim()
2651 .to_string();
2652 }
2653
2654 let branch_name = repo_view
2656 .branch_info
2657 .as_ref()
2658 .map(|bi| bi.branch.as_str())
2659 .unwrap_or("feature");
2660
2661 let cleaned_branch = branch_name.replace(['/', '-', '_'], " ");
2662
2663 format!("feat: {}", cleaned_branch)
2664 }
2665
2666 fn create_github_pr(
2668 &self,
2669 repo_view: &crate::data::RepositoryView,
2670 title: &str,
2671 description: &str,
2672 is_draft: bool,
2673 new_base: Option<&str>,
2674 ) -> Result<()> {
2675 use std::process::Command;
2676
2677 let branch_name = repo_view
2679 .branch_info
2680 .as_ref()
2681 .map(|bi| &bi.branch)
2682 .context("Branch info not available")?;
2683
2684 let pr_status = if is_draft {
2685 "draft"
2686 } else {
2687 "ready for review"
2688 };
2689 println!("🚀 Creating pull request ({})...", pr_status);
2690 println!(" 📋 Title: {}", title);
2691 println!(" 🌿 Branch: {}", branch_name);
2692 if let Some(base) = new_base {
2693 println!(" 🎯 Base: {}", base);
2694 }
2695
2696 debug!("Opening git repository to check branch status");
2698 let git_repo =
2699 crate::git::GitRepository::open().context("Failed to open git repository")?;
2700
2701 debug!(
2702 "Checking if branch '{}' exists on remote 'origin'",
2703 branch_name
2704 );
2705 if !git_repo.branch_exists_on_remote(branch_name, "origin")? {
2706 println!("📤 Pushing branch to remote...");
2707 debug!(
2708 "Branch '{}' not found on remote, attempting to push",
2709 branch_name
2710 );
2711 git_repo
2712 .push_branch(branch_name, "origin")
2713 .context("Failed to push branch to remote")?;
2714 } else {
2715 debug!("Branch '{}' already exists on remote 'origin'", branch_name);
2716 }
2717
2718 debug!("Creating PR with gh CLI - title: '{}'", title);
2720 debug!("PR description length: {} characters", description.len());
2721 debug!("PR draft status: {}", is_draft);
2722 if let Some(base) = new_base {
2723 debug!("PR base branch: {}", base);
2724 }
2725
2726 let mut args = vec![
2727 "pr",
2728 "create",
2729 "--head",
2730 branch_name,
2731 "--title",
2732 title,
2733 "--body",
2734 description,
2735 ];
2736
2737 if let Some(base) = new_base {
2738 args.push("--base");
2739 args.push(base);
2740 }
2741
2742 if is_draft {
2743 args.push("--draft");
2744 }
2745
2746 let pr_result = Command::new("gh")
2747 .args(&args)
2748 .output()
2749 .context("Failed to create pull request")?;
2750
2751 if pr_result.status.success() {
2752 let pr_url = String::from_utf8_lossy(&pr_result.stdout);
2753 let pr_url = pr_url.trim();
2754 debug!("PR created successfully with URL: {}", pr_url);
2755 println!("🎉 Pull request created: {}", pr_url);
2756 } else {
2757 let error_msg = String::from_utf8_lossy(&pr_result.stderr);
2758 error!("gh CLI failed to create PR: {}", error_msg);
2759 anyhow::bail!("Failed to create pull request: {}", error_msg);
2760 }
2761
2762 Ok(())
2763 }
2764
2765 fn update_github_pr(
2767 &self,
2768 repo_view: &crate::data::RepositoryView,
2769 title: &str,
2770 description: &str,
2771 new_base: Option<&str>,
2772 ) -> Result<()> {
2773 use std::io::{self, Write};
2774 use std::process::Command;
2775
2776 let existing_pr = repo_view
2778 .branch_prs
2779 .as_ref()
2780 .and_then(|prs| prs.first())
2781 .context("No existing PR found to update")?;
2782
2783 let pr_number = existing_pr.number;
2784 let current_base = &existing_pr.base;
2785
2786 println!("🚀 Updating pull request #{}...", pr_number);
2787 println!(" 📋 Title: {}", title);
2788
2789 let change_base = if let Some(base) = new_base {
2791 if !current_base.is_empty() && current_base != base {
2792 print!(
2793 " 🎯 Current base: {} → New base: {}. Change? [y/N]: ",
2794 current_base, base
2795 );
2796 io::stdout().flush()?;
2797
2798 let mut input = String::new();
2799 io::stdin().read_line(&mut input)?;
2800 let response = input.trim().to_lowercase();
2801 response == "y" || response == "yes"
2802 } else {
2803 false
2804 }
2805 } else {
2806 false
2807 };
2808
2809 debug!(
2810 pr_number = pr_number,
2811 title = %title,
2812 description_length = description.len(),
2813 description_preview = %description.lines().take(3).collect::<Vec<_>>().join("\\n"),
2814 change_base = change_base,
2815 "Updating GitHub PR with title and description"
2816 );
2817
2818 let pr_number_str = pr_number.to_string();
2820 let mut gh_args = vec![
2821 "pr",
2822 "edit",
2823 &pr_number_str,
2824 "--title",
2825 title,
2826 "--body",
2827 description,
2828 ];
2829
2830 if change_base {
2831 if let Some(base) = new_base {
2832 gh_args.push("--base");
2833 gh_args.push(base);
2834 }
2835 }
2836
2837 debug!(
2838 args = ?gh_args,
2839 "Executing gh command to update PR"
2840 );
2841
2842 let pr_result = Command::new("gh")
2843 .args(&gh_args)
2844 .output()
2845 .context("Failed to update pull request")?;
2846
2847 if pr_result.status.success() {
2848 println!("🎉 Pull request updated: {}", existing_pr.url);
2850 if change_base {
2851 if let Some(base) = new_base {
2852 println!(" 🎯 Base branch changed to: {}", base);
2853 }
2854 }
2855 } else {
2856 let error_msg = String::from_utf8_lossy(&pr_result.stderr);
2857 anyhow::bail!("Failed to update pull request: {}", error_msg);
2858 }
2859
2860 Ok(())
2861 }
2862
2863 fn show_model_info_from_client(
2865 &self,
2866 client: &crate::claude::client::ClaudeClient,
2867 ) -> Result<()> {
2868 use crate::claude::model_config::get_model_registry;
2869
2870 println!("🤖 AI Model Configuration:");
2871
2872 let metadata = client.get_ai_client_metadata();
2874 let registry = get_model_registry();
2875
2876 if let Some(spec) = registry.get_model_spec(&metadata.model) {
2877 if metadata.model != spec.api_identifier {
2879 println!(
2880 " 📡 Model: {} → \x1b[33m{}\x1b[0m",
2881 metadata.model, spec.api_identifier
2882 );
2883 } else {
2884 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
2885 }
2886
2887 println!(" 🏷️ Provider: {}", spec.provider);
2888 println!(" 📊 Generation: {}", spec.generation);
2889 println!(" ⭐ Tier: {} ({})", spec.tier, {
2890 if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
2891 &tier_info.description
2892 } else {
2893 "No description available"
2894 }
2895 });
2896 println!(" 📤 Max output tokens: {}", spec.max_output_tokens);
2897 println!(" 📥 Input context: {}", spec.input_context);
2898
2899 if spec.legacy {
2900 println!(" ⚠️ Legacy model (consider upgrading to newer version)");
2901 }
2902 } else {
2903 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
2905 println!(" 🏷️ Provider: {}", metadata.provider);
2906 println!(" ⚠️ Model not found in registry, using client metadata:");
2907 println!(" 📤 Max output tokens: {}", metadata.max_response_length);
2908 println!(" 📥 Input context: {}", metadata.max_context_length);
2909 }
2910
2911 println!();
2912 Ok(())
2913 }
2914}
2915
2916impl CheckCommand {
2917 pub async fn execute(self) -> Result<()> {
2919 use crate::data::check::OutputFormat;
2920
2921 let output_format: OutputFormat = self.format.parse().unwrap_or(OutputFormat::Text);
2923
2924 if !self.quiet && output_format == OutputFormat::Text {
2925 println!("🔍 Checking commit messages against guidelines...");
2926 }
2927
2928 let repo_view = self.generate_repository_view().await?;
2930
2931 if repo_view.commits.is_empty() {
2933 eprintln!("error: no commits found in range");
2934 std::process::exit(3);
2935 }
2936
2937 if !self.quiet && output_format == OutputFormat::Text {
2938 println!("📊 Found {} commits to check", repo_view.commits.len());
2939 }
2940
2941 let guidelines = self.load_guidelines().await?;
2943 let valid_scopes = self.load_scopes();
2944
2945 if !self.quiet && output_format == OutputFormat::Text {
2946 self.show_guidance_files_status(&guidelines, &valid_scopes);
2947 }
2948
2949 let claude_client = crate::claude::create_default_claude_client(self.model.clone())?;
2951
2952 if self.verbose && output_format == OutputFormat::Text {
2953 self.show_model_info(&claude_client)?;
2954 }
2955
2956 let report = if repo_view.commits.len() > self.batch_size {
2958 if !self.quiet && output_format == OutputFormat::Text {
2959 println!(
2960 "📦 Processing {} commits in batches of {}...",
2961 repo_view.commits.len(),
2962 self.batch_size
2963 );
2964 }
2965 self.check_with_batching(
2966 &claude_client,
2967 &repo_view,
2968 guidelines.as_deref(),
2969 &valid_scopes,
2970 )
2971 .await?
2972 } else {
2973 if !self.quiet && output_format == OutputFormat::Text {
2975 println!("🤖 Analyzing commits with AI...");
2976 }
2977 claude_client
2978 .check_commits_with_scopes(
2979 &repo_view,
2980 guidelines.as_deref(),
2981 &valid_scopes,
2982 !self.no_suggestions,
2983 )
2984 .await?
2985 };
2986
2987 self.output_report(&report, output_format)?;
2989
2990 let exit_code = report.exit_code(self.strict);
2992 if exit_code != 0 {
2993 std::process::exit(exit_code);
2994 }
2995
2996 Ok(())
2997 }
2998
2999 async fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
3001 use crate::data::{
3002 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
3003 WorkingDirectoryInfo,
3004 };
3005 use crate::git::{GitRepository, RemoteInfo};
3006 use crate::utils::ai_scratch;
3007
3008 let repo = GitRepository::open()
3010 .context("Failed to open git repository. Make sure you're in a git repository.")?;
3011
3012 let current_branch = repo
3014 .get_current_branch()
3015 .unwrap_or_else(|_| "HEAD".to_string());
3016
3017 let commit_range = match &self.commit_range {
3019 Some(range) => range.clone(),
3020 None => {
3021 let base = if repo.branch_exists("main")? {
3023 "main"
3024 } else if repo.branch_exists("master")? {
3025 "master"
3026 } else {
3027 "HEAD~5"
3028 };
3029 format!("{}..HEAD", base)
3030 }
3031 };
3032
3033 let wd_status = repo.get_working_directory_status()?;
3035 let working_directory = WorkingDirectoryInfo {
3036 clean: wd_status.clean,
3037 untracked_changes: wd_status
3038 .untracked_changes
3039 .into_iter()
3040 .map(|fs| FileStatusInfo {
3041 status: fs.status,
3042 file: fs.file,
3043 })
3044 .collect(),
3045 };
3046
3047 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
3049
3050 let commits = repo.get_commits_in_range(&commit_range)?;
3052
3053 let versions = Some(VersionInfo {
3055 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
3056 });
3057
3058 let ai_scratch_path =
3060 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
3061 let ai_info = AiInfo {
3062 scratch: ai_scratch_path.to_string_lossy().to_string(),
3063 };
3064
3065 let mut repo_view = RepositoryView {
3067 versions,
3068 explanation: FieldExplanation::default(),
3069 working_directory,
3070 remotes,
3071 ai: ai_info,
3072 branch_info: Some(BranchInfo {
3073 branch: current_branch,
3074 }),
3075 pr_template: None,
3076 pr_template_location: None,
3077 branch_prs: None,
3078 commits,
3079 };
3080
3081 repo_view.update_field_presence();
3083
3084 Ok(repo_view)
3085 }
3086
3087 async fn load_guidelines(&self) -> Result<Option<String>> {
3089 use std::fs;
3090
3091 if let Some(guidelines_path) = &self.guidelines {
3093 let content = fs::read_to_string(guidelines_path).with_context(|| {
3094 format!("Failed to read guidelines file: {:?}", guidelines_path)
3095 })?;
3096 return Ok(Some(content));
3097 }
3098
3099 let context_dir = self
3101 .context_dir
3102 .clone()
3103 .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
3104
3105 let local_path = context_dir.join("local").join("commit-guidelines.md");
3107 if local_path.exists() {
3108 let content = fs::read_to_string(&local_path)
3109 .with_context(|| format!("Failed to read guidelines: {:?}", local_path))?;
3110 return Ok(Some(content));
3111 }
3112
3113 let project_path = context_dir.join("commit-guidelines.md");
3115 if project_path.exists() {
3116 let content = fs::read_to_string(&project_path)
3117 .with_context(|| format!("Failed to read guidelines: {:?}", project_path))?;
3118 return Ok(Some(content));
3119 }
3120
3121 if let Some(home) = dirs::home_dir() {
3123 let home_path = home.join(".omni-dev").join("commit-guidelines.md");
3124 if home_path.exists() {
3125 let content = fs::read_to_string(&home_path)
3126 .with_context(|| format!("Failed to read guidelines: {:?}", home_path))?;
3127 return Ok(Some(content));
3128 }
3129 }
3130
3131 Ok(None)
3133 }
3134
3135 fn load_scopes(&self) -> Vec<crate::data::context::ScopeDefinition> {
3140 use crate::data::context::ScopeDefinition;
3141 use std::fs;
3142
3143 #[derive(serde::Deserialize)]
3145 struct ScopesConfig {
3146 scopes: Vec<ScopeDefinition>,
3147 }
3148
3149 let context_dir = self
3150 .context_dir
3151 .clone()
3152 .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
3153
3154 let local_path = context_dir.join("local").join("scopes.yaml");
3156 if local_path.exists() {
3157 if let Ok(content) = fs::read_to_string(&local_path) {
3158 if let Ok(config) = serde_yaml::from_str::<ScopesConfig>(&content) {
3159 return config.scopes;
3160 }
3161 }
3162 }
3163
3164 let project_path = context_dir.join("scopes.yaml");
3166 if project_path.exists() {
3167 if let Ok(content) = fs::read_to_string(&project_path) {
3168 if let Ok(config) = serde_yaml::from_str::<ScopesConfig>(&content) {
3169 return config.scopes;
3170 }
3171 }
3172 }
3173
3174 if let Some(home) = dirs::home_dir() {
3176 let home_path = home.join(".omni-dev").join("scopes.yaml");
3177 if home_path.exists() {
3178 if let Ok(content) = fs::read_to_string(&home_path) {
3179 if let Ok(config) = serde_yaml::from_str::<ScopesConfig>(&content) {
3180 return config.scopes;
3181 }
3182 }
3183 }
3184 }
3185
3186 Vec::new()
3188 }
3189
3190 fn show_guidance_files_status(
3192 &self,
3193 guidelines: &Option<String>,
3194 valid_scopes: &[crate::data::context::ScopeDefinition],
3195 ) {
3196 let context_dir = self
3197 .context_dir
3198 .clone()
3199 .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
3200
3201 println!("📋 Project guidance files status:");
3202
3203 let guidelines_found = guidelines.is_some();
3205 let guidelines_source = if guidelines_found {
3206 let local_path = context_dir.join("local").join("commit-guidelines.md");
3207 let project_path = context_dir.join("commit-guidelines.md");
3208 let home_path = dirs::home_dir()
3209 .map(|h| h.join(".omni-dev").join("commit-guidelines.md"))
3210 .unwrap_or_default();
3211
3212 if local_path.exists() {
3213 format!("✅ Local override: {}", local_path.display())
3214 } else if project_path.exists() {
3215 format!("✅ Project: {}", project_path.display())
3216 } else if home_path.exists() {
3217 format!("✅ Global: {}", home_path.display())
3218 } else {
3219 "✅ (source unknown)".to_string()
3220 }
3221 } else {
3222 "⚪ Using defaults".to_string()
3223 };
3224 println!(" 📝 Commit guidelines: {}", guidelines_source);
3225
3226 let scopes_count = valid_scopes.len();
3228 let scopes_source = if scopes_count > 0 {
3229 let local_path = context_dir.join("local").join("scopes.yaml");
3230 let project_path = context_dir.join("scopes.yaml");
3231 let home_path = dirs::home_dir()
3232 .map(|h| h.join(".omni-dev").join("scopes.yaml"))
3233 .unwrap_or_default();
3234
3235 let source = if local_path.exists() {
3236 format!("Local override: {}", local_path.display())
3237 } else if project_path.exists() {
3238 format!("Project: {}", project_path.display())
3239 } else if home_path.exists() {
3240 format!("Global: {}", home_path.display())
3241 } else {
3242 "(source unknown)".to_string()
3243 };
3244 format!("✅ {} ({} scopes)", source, scopes_count)
3245 } else {
3246 "⚪ None found (any scope accepted)".to_string()
3247 };
3248 println!(" 🎯 Valid scopes: {}", scopes_source);
3249
3250 println!();
3251 }
3252
3253 async fn check_with_batching(
3255 &self,
3256 claude_client: &crate::claude::client::ClaudeClient,
3257 full_repo_view: &crate::data::RepositoryView,
3258 guidelines: Option<&str>,
3259 valid_scopes: &[crate::data::context::ScopeDefinition],
3260 ) -> Result<crate::data::check::CheckReport> {
3261 use crate::data::check::{CheckReport, CommitCheckResult};
3262
3263 let commit_batches: Vec<_> = full_repo_view.commits.chunks(self.batch_size).collect();
3264 let total_batches = commit_batches.len();
3265 let mut all_results: Vec<CommitCheckResult> = Vec::new();
3266
3267 for (batch_num, commit_batch) in commit_batches.into_iter().enumerate() {
3268 if !self.quiet {
3269 println!(
3270 "🔄 Processing batch {}/{} ({} commits)...",
3271 batch_num + 1,
3272 total_batches,
3273 commit_batch.len()
3274 );
3275 }
3276
3277 let batch_repo_view = crate::data::RepositoryView {
3279 versions: full_repo_view.versions.clone(),
3280 explanation: full_repo_view.explanation.clone(),
3281 working_directory: full_repo_view.working_directory.clone(),
3282 remotes: full_repo_view.remotes.clone(),
3283 ai: full_repo_view.ai.clone(),
3284 branch_info: full_repo_view.branch_info.clone(),
3285 pr_template: full_repo_view.pr_template.clone(),
3286 pr_template_location: full_repo_view.pr_template_location.clone(),
3287 branch_prs: full_repo_view.branch_prs.clone(),
3288 commits: commit_batch.to_vec(),
3289 };
3290
3291 let batch_report = claude_client
3293 .check_commits_with_scopes(
3294 &batch_repo_view,
3295 guidelines,
3296 valid_scopes,
3297 !self.no_suggestions,
3298 )
3299 .await?;
3300
3301 all_results.extend(batch_report.commits);
3303
3304 if batch_num + 1 < total_batches {
3305 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
3307 }
3308 }
3309
3310 Ok(CheckReport::new(all_results))
3311 }
3312
3313 fn output_report(
3315 &self,
3316 report: &crate::data::check::CheckReport,
3317 format: crate::data::check::OutputFormat,
3318 ) -> Result<()> {
3319 use crate::data::check::OutputFormat;
3320
3321 match format {
3322 OutputFormat::Text => self.output_text_report(report),
3323 OutputFormat::Json => {
3324 let json = serde_json::to_string_pretty(report)
3325 .context("Failed to serialize report to JSON")?;
3326 println!("{}", json);
3327 Ok(())
3328 }
3329 OutputFormat::Yaml => {
3330 let yaml =
3331 crate::data::to_yaml(report).context("Failed to serialize report to YAML")?;
3332 println!("{}", yaml);
3333 Ok(())
3334 }
3335 }
3336 }
3337
3338 fn output_text_report(&self, report: &crate::data::check::CheckReport) -> Result<()> {
3340 use crate::data::check::IssueSeverity;
3341
3342 println!();
3343
3344 for result in &report.commits {
3345 if result.passes && !self.show_passing {
3347 continue;
3348 }
3349
3350 if self.quiet {
3352 let has_errors_or_warnings = result
3353 .issues
3354 .iter()
3355 .any(|i| matches!(i.severity, IssueSeverity::Error | IssueSeverity::Warning));
3356 if !has_errors_or_warnings {
3357 continue;
3358 }
3359 }
3360
3361 let icon = if result.passes {
3363 "✅"
3364 } else if result
3365 .issues
3366 .iter()
3367 .any(|i| i.severity == IssueSeverity::Error)
3368 {
3369 "❌"
3370 } else {
3371 "⚠️ "
3372 };
3373
3374 let short_hash = if result.hash.len() > 7 {
3376 &result.hash[..7]
3377 } else {
3378 &result.hash
3379 };
3380
3381 println!("{} {} - \"{}\"", icon, short_hash, result.message);
3382
3383 for issue in &result.issues {
3385 if self.quiet && issue.severity == IssueSeverity::Info {
3387 continue;
3388 }
3389
3390 let severity_str = match issue.severity {
3391 IssueSeverity::Error => "\x1b[31mERROR\x1b[0m ",
3392 IssueSeverity::Warning => "\x1b[33mWARNING\x1b[0m",
3393 IssueSeverity::Info => "\x1b[36mINFO\x1b[0m ",
3394 };
3395
3396 println!(
3397 " {} [{}] {}",
3398 severity_str, issue.section, issue.explanation
3399 );
3400 }
3401
3402 if !self.quiet {
3404 if let Some(suggestion) = &result.suggestion {
3405 println!();
3406 println!(" Suggested message:");
3407 for line in suggestion.message.lines() {
3408 println!(" {}", line);
3409 }
3410 if self.verbose {
3411 println!();
3412 println!(" Why this is better:");
3413 for line in suggestion.explanation.lines() {
3414 println!(" {}", line);
3415 }
3416 }
3417 }
3418 }
3419
3420 println!();
3421 }
3422
3423 println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
3425 println!("Summary: {} commits checked", report.summary.total_commits);
3426 println!(
3427 " {} errors, {} warnings",
3428 report.summary.error_count, report.summary.warning_count
3429 );
3430 println!(
3431 " {} passed, {} with issues",
3432 report.summary.passing_commits, report.summary.failing_commits
3433 );
3434
3435 Ok(())
3436 }
3437
3438 fn show_model_info(&self, client: &crate::claude::client::ClaudeClient) -> Result<()> {
3440 use crate::claude::model_config::get_model_registry;
3441
3442 println!("🤖 AI Model Configuration:");
3443
3444 let metadata = client.get_ai_client_metadata();
3445 let registry = get_model_registry();
3446
3447 if let Some(spec) = registry.get_model_spec(&metadata.model) {
3448 if metadata.model != spec.api_identifier {
3449 println!(
3450 " 📡 Model: {} → \x1b[33m{}\x1b[0m",
3451 metadata.model, spec.api_identifier
3452 );
3453 } else {
3454 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
3455 }
3456 println!(" 🏷️ Provider: {}", spec.provider);
3457 } else {
3458 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
3459 println!(" 🏷️ Provider: {}", metadata.provider);
3460 }
3461
3462 println!();
3463 Ok(())
3464 }
3465}