1use anyhow::{Context, Result};
4use clap::Parser;
5
6use super::parse_beta_header;
7
8#[derive(Parser)]
10pub struct CheckCommand {
11 #[arg(value_name = "COMMIT_RANGE")]
14 pub commit_range: Option<String>,
15
16 #[arg(long)]
18 pub model: Option<String>,
19
20 #[arg(long, value_name = "KEY:VALUE")]
23 pub beta_header: Option<String>,
24
25 #[arg(long)]
27 pub context_dir: Option<std::path::PathBuf>,
28
29 #[arg(long)]
31 pub guidelines: Option<std::path::PathBuf>,
32
33 #[arg(long, default_value = "text")]
35 pub format: String,
36
37 #[arg(long)]
39 pub strict: bool,
40
41 #[arg(long)]
43 pub quiet: bool,
44
45 #[arg(long)]
47 pub verbose: bool,
48
49 #[arg(long)]
51 pub show_passing: bool,
52
53 #[arg(long, default_value = "4", hide = true)]
55 pub batch_size: usize,
56
57 #[arg(long, default_value = "4")]
59 pub concurrency: usize,
60
61 #[arg(long)]
63 pub no_coherence: bool,
64
65 #[arg(long)]
67 pub no_suggestions: bool,
68
69 #[arg(long)]
71 pub twiddle: bool,
72}
73
74impl CheckCommand {
75 pub async fn execute(self) -> Result<()> {
77 use crate::data::check::OutputFormat;
78
79 let output_format: OutputFormat = self.format.parse().unwrap_or(OutputFormat::Text);
81
82 let ai_info = crate::utils::check_ai_command_prerequisites(self.model.as_deref())?;
84 if !self.quiet && output_format == OutputFormat::Text {
85 println!(
86 "✓ {} credentials verified (model: {})",
87 ai_info.provider, ai_info.model
88 );
89 }
90
91 if !self.quiet && output_format == OutputFormat::Text {
92 println!("🔍 Checking commit messages against guidelines...");
93 }
94
95 let mut repo_view = self.generate_repository_view().await?;
97
98 if repo_view.commits.is_empty() {
100 eprintln!("error: no commits found in range");
101 std::process::exit(3);
102 }
103
104 if !self.quiet && output_format == OutputFormat::Text {
105 println!("📊 Found {} commits to check", repo_view.commits.len());
106 }
107
108 let guidelines = self.load_guidelines().await?;
110 let valid_scopes = self.load_scopes();
111
112 for commit in &mut repo_view.commits {
114 commit.analysis.refine_scope(&valid_scopes);
115 }
116
117 if !self.quiet && output_format == OutputFormat::Text {
118 self.show_guidance_files_status(&guidelines, &valid_scopes);
119 }
120
121 let beta = self
123 .beta_header
124 .as_deref()
125 .map(parse_beta_header)
126 .transpose()?;
127 let claude_client = crate::claude::create_default_claude_client(self.model.clone(), beta)?;
128
129 if self.verbose && output_format == OutputFormat::Text {
130 self.show_model_info(&claude_client)?;
131 }
132
133 let report = if repo_view.commits.len() > 1 {
135 if !self.quiet && output_format == OutputFormat::Text {
136 println!(
137 "🔄 Processing {} commits in parallel (concurrency: {})...",
138 repo_view.commits.len(),
139 self.concurrency
140 );
141 }
142 self.check_with_map_reduce(
143 &claude_client,
144 &repo_view,
145 guidelines.as_deref(),
146 &valid_scopes,
147 )
148 .await?
149 } else {
150 if !self.quiet && output_format == OutputFormat::Text {
152 println!("🤖 Analyzing commits with AI...");
153 }
154 claude_client
155 .check_commits_with_scopes(
156 &repo_view,
157 guidelines.as_deref(),
158 &valid_scopes,
159 !self.no_suggestions,
160 )
161 .await?
162 };
163
164 self.output_report(&report, output_format)?;
166
167 if self.twiddle && report.has_errors() && output_format == OutputFormat::Text {
169 let amendments = self.build_amendments_from_suggestions(&report, &repo_view);
170 if !amendments.is_empty() && self.prompt_and_apply_suggestions(amendments).await? {
171 return Ok(());
173 }
174 }
175
176 let exit_code = report.exit_code(self.strict);
178 if exit_code != 0 {
179 std::process::exit(exit_code);
180 }
181
182 Ok(())
183 }
184
185 async fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
187 use crate::data::{
188 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
189 WorkingDirectoryInfo,
190 };
191 use crate::git::{GitRepository, RemoteInfo};
192 use crate::utils::ai_scratch;
193
194 let repo = GitRepository::open()
196 .context("Failed to open git repository. Make sure you're in a git repository.")?;
197
198 let current_branch = repo
200 .get_current_branch()
201 .unwrap_or_else(|_| "HEAD".to_string());
202
203 let commit_range = match &self.commit_range {
205 Some(range) => range.clone(),
206 None => {
207 let base = if repo.branch_exists("main")? {
209 "main"
210 } else if repo.branch_exists("master")? {
211 "master"
212 } else {
213 "HEAD~5"
214 };
215 format!("{}..HEAD", base)
216 }
217 };
218
219 let wd_status = repo.get_working_directory_status()?;
221 let working_directory = WorkingDirectoryInfo {
222 clean: wd_status.clean,
223 untracked_changes: wd_status
224 .untracked_changes
225 .into_iter()
226 .map(|fs| FileStatusInfo {
227 status: fs.status,
228 file: fs.file,
229 })
230 .collect(),
231 };
232
233 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
235
236 let commits = repo.get_commits_in_range(&commit_range)?;
238
239 let versions = Some(VersionInfo {
241 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
242 });
243
244 let ai_scratch_path =
246 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
247 let ai_info = AiInfo {
248 scratch: ai_scratch_path.to_string_lossy().to_string(),
249 };
250
251 let mut repo_view = RepositoryView {
253 versions,
254 explanation: FieldExplanation::default(),
255 working_directory,
256 remotes,
257 ai: ai_info,
258 branch_info: Some(BranchInfo {
259 branch: current_branch,
260 }),
261 pr_template: None,
262 pr_template_location: None,
263 branch_prs: None,
264 commits,
265 };
266
267 repo_view.update_field_presence();
269
270 Ok(repo_view)
271 }
272
273 async fn load_guidelines(&self) -> Result<Option<String>> {
275 use std::fs;
276
277 if let Some(guidelines_path) = &self.guidelines {
279 let content = fs::read_to_string(guidelines_path).with_context(|| {
280 format!("Failed to read guidelines file: {:?}", guidelines_path)
281 })?;
282 return Ok(Some(content));
283 }
284
285 let context_dir = self
287 .context_dir
288 .clone()
289 .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
290
291 let local_path = context_dir.join("local").join("commit-guidelines.md");
293 if local_path.exists() {
294 let content = fs::read_to_string(&local_path)
295 .with_context(|| format!("Failed to read guidelines: {:?}", local_path))?;
296 return Ok(Some(content));
297 }
298
299 let project_path = context_dir.join("commit-guidelines.md");
301 if project_path.exists() {
302 let content = fs::read_to_string(&project_path)
303 .with_context(|| format!("Failed to read guidelines: {:?}", project_path))?;
304 return Ok(Some(content));
305 }
306
307 if let Some(home) = dirs::home_dir() {
309 let home_path = home.join(".omni-dev").join("commit-guidelines.md");
310 if home_path.exists() {
311 let content = fs::read_to_string(&home_path)
312 .with_context(|| format!("Failed to read guidelines: {:?}", home_path))?;
313 return Ok(Some(content));
314 }
315 }
316
317 Ok(None)
319 }
320
321 fn load_scopes(&self) -> Vec<crate::data::context::ScopeDefinition> {
326 use crate::data::context::ScopeDefinition;
327 use std::fs;
328
329 #[derive(serde::Deserialize)]
331 struct ScopesConfig {
332 scopes: Vec<ScopeDefinition>,
333 }
334
335 let context_dir = self
336 .context_dir
337 .clone()
338 .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
339
340 let mut candidates: Vec<std::path::PathBuf> = vec![
342 context_dir.join("local").join("scopes.yaml"),
343 context_dir.join("scopes.yaml"),
344 ];
345 if let Some(home) = dirs::home_dir() {
346 candidates.push(home.join(".omni-dev").join("scopes.yaml"));
347 }
348
349 for path in &candidates {
350 if !path.exists() {
351 continue;
352 }
353 match fs::read_to_string(path) {
354 Ok(content) => match serde_yaml::from_str::<ScopesConfig>(&content) {
355 Ok(config) => return config.scopes,
356 Err(e) => {
357 eprintln!(
358 "warning: ignoring malformed scopes file {}: {e}",
359 path.display()
360 );
361 }
362 },
363 Err(e) => {
364 eprintln!("warning: cannot read scopes file {}: {e}", path.display());
365 }
366 }
367 }
368
369 Vec::new()
371 }
372
373 fn show_guidance_files_status(
375 &self,
376 guidelines: &Option<String>,
377 valid_scopes: &[crate::data::context::ScopeDefinition],
378 ) {
379 let context_dir = self
380 .context_dir
381 .clone()
382 .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
383
384 println!("📋 Project guidance files status:");
385
386 let guidelines_found = guidelines.is_some();
388 let guidelines_source = if guidelines_found {
389 let local_path = context_dir.join("local").join("commit-guidelines.md");
390 let project_path = context_dir.join("commit-guidelines.md");
391 let home_path = dirs::home_dir()
392 .map(|h| h.join(".omni-dev").join("commit-guidelines.md"))
393 .unwrap_or_default();
394
395 if local_path.exists() {
396 format!("✅ Local override: {}", local_path.display())
397 } else if project_path.exists() {
398 format!("✅ Project: {}", project_path.display())
399 } else if home_path.exists() {
400 format!("✅ Global: {}", home_path.display())
401 } else {
402 "✅ (source unknown)".to_string()
403 }
404 } else {
405 "⚪ Using defaults".to_string()
406 };
407 println!(" 📝 Commit guidelines: {}", guidelines_source);
408
409 let scopes_count = valid_scopes.len();
411 let scopes_source = if scopes_count > 0 {
412 let local_path = context_dir.join("local").join("scopes.yaml");
413 let project_path = context_dir.join("scopes.yaml");
414 let home_path = dirs::home_dir()
415 .map(|h| h.join(".omni-dev").join("scopes.yaml"))
416 .unwrap_or_default();
417
418 let source = if local_path.exists() {
419 format!("Local override: {}", local_path.display())
420 } else if project_path.exists() {
421 format!("Project: {}", project_path.display())
422 } else if home_path.exists() {
423 format!("Global: {}", home_path.display())
424 } else {
425 "(source unknown)".to_string()
426 };
427 format!("✅ {} ({} scopes)", source, scopes_count)
428 } else {
429 "⚪ None found (any scope accepted)".to_string()
430 };
431 println!(" 🎯 Valid scopes: {}", scopes_source);
432
433 println!();
434 }
435
436 async fn check_with_map_reduce(
442 &self,
443 claude_client: &crate::claude::client::ClaudeClient,
444 full_repo_view: &crate::data::RepositoryView,
445 guidelines: Option<&str>,
446 valid_scopes: &[crate::data::context::ScopeDefinition],
447 ) -> Result<crate::data::check::CheckReport> {
448 use std::sync::atomic::{AtomicUsize, Ordering};
449 use std::sync::Arc;
450
451 use crate::claude::batch;
452 use crate::claude::token_budget;
453 use crate::data::check::{CheckReport, CommitCheckResult};
454
455 let total_commits = full_repo_view.commits.len();
456
457 let metadata = claude_client.get_ai_client_metadata();
459 let system_prompt = crate::claude::prompts::generate_check_system_prompt_with_scopes(
460 guidelines,
461 valid_scopes,
462 );
463 let system_prompt_tokens = token_budget::estimate_tokens(&system_prompt);
464 let batch_plan =
465 batch::plan_batches(&full_repo_view.commits, &metadata, system_prompt_tokens);
466
467 if !self.quiet && batch_plan.batches.len() < total_commits {
468 println!(
469 " 📦 Grouped {} commits into {} batches by token budget",
470 total_commits,
471 batch_plan.batches.len()
472 );
473 }
474
475 let semaphore = Arc::new(tokio::sync::Semaphore::new(self.concurrency));
476 let completed = Arc::new(AtomicUsize::new(0));
477
478 let futs: Vec<_> = batch_plan
480 .batches
481 .iter()
482 .map(|batch| {
483 let sem = semaphore.clone();
484 let completed = completed.clone();
485 let batch_indices = &batch.commit_indices;
486
487 async move {
488 let _permit = sem
489 .acquire()
490 .await
491 .map_err(|e| anyhow::anyhow!("semaphore closed: {e}"))?;
492
493 let batch_size = batch_indices.len();
494
495 let batch_view = if batch_size == 1 {
497 full_repo_view.single_commit_view(&full_repo_view.commits[batch_indices[0]])
498 } else {
499 let commits: Vec<_> = batch_indices
500 .iter()
501 .map(|&i| &full_repo_view.commits[i])
502 .collect();
503 full_repo_view.multi_commit_view(&commits)
504 };
505
506 let result = claude_client
507 .check_commits_with_scopes(
508 &batch_view,
509 guidelines,
510 valid_scopes,
511 !self.no_suggestions,
512 )
513 .await;
514
515 match result {
516 Ok(report) => {
517 let done =
518 completed.fetch_add(batch_size, Ordering::Relaxed) + batch_size;
519 if !self.quiet {
520 println!(" ✅ {}/{} commits checked", done, total_commits);
521 }
522
523 let items: Vec<_> = report
524 .commits
525 .into_iter()
526 .map(|r| {
527 let summary = r.summary.clone().unwrap_or_default();
528 (r, summary)
529 })
530 .collect();
531 Ok(items)
532 }
533 Err(e) if batch_size > 1 => {
534 eprintln!(
536 "warning: batch of {} failed, retrying individually: {e}",
537 batch_size
538 );
539 let mut items = Vec::new();
540 for &idx in batch_indices {
541 let single_view =
542 full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
543 let single_result = claude_client
544 .check_commits_with_scopes(
545 &single_view,
546 guidelines,
547 valid_scopes,
548 !self.no_suggestions,
549 )
550 .await;
551 match single_result {
552 Ok(report) => {
553 if let Some(r) = report.commits.into_iter().next() {
554 let summary = r.summary.clone().unwrap_or_default();
555 items.push((r, summary));
556 }
557 }
558 Err(e) => {
559 eprintln!("warning: failed to check commit: {e}");
560 }
561 }
562 let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
563 if !self.quiet {
564 println!(" ✅ {}/{} commits checked", done, total_commits);
565 }
566 }
567 Ok(items)
568 }
569 Err(e) => Err(e),
570 }
571 }
572 })
573 .collect();
574
575 let results = futures::future::join_all(futs).await;
576
577 let mut successes: Vec<(CommitCheckResult, String)> = Vec::new();
579 let mut failure_count = 0;
580
581 for result in results {
582 match result {
583 Ok(items) => successes.extend(items),
584 Err(e) => {
585 eprintln!("warning: failed to check commit: {e}");
586 failure_count += 1;
587 }
588 }
589 }
590
591 if failure_count > 0 {
592 eprintln!("warning: {failure_count} commit(s) failed to check");
593 }
594
595 if successes.is_empty() {
596 anyhow::bail!("All commits failed to check");
597 }
598
599 let single_batch = batch_plan.batches.len() <= 1;
602 if !self.no_coherence && !single_batch && successes.len() >= 2 {
603 if !self.quiet {
604 println!("🔗 Running cross-commit coherence pass...");
605 }
606 match claude_client
607 .refine_checks_coherence(&successes, full_repo_view)
608 .await
609 {
610 Ok(refined) => {
611 if !self.quiet {
612 println!("✅ All commits checked!");
613 }
614 return Ok(refined);
615 }
616 Err(e) => {
617 eprintln!("warning: coherence pass failed, using individual results: {e}");
618 }
619 }
620 }
621
622 if !self.quiet {
623 println!("✅ All commits checked!");
624 }
625
626 let all_results: Vec<CommitCheckResult> = successes.into_iter().map(|(r, _)| r).collect();
627
628 Ok(CheckReport::new(all_results))
629 }
630
631 fn output_report(
633 &self,
634 report: &crate::data::check::CheckReport,
635 format: crate::data::check::OutputFormat,
636 ) -> Result<()> {
637 use crate::data::check::OutputFormat;
638
639 match format {
640 OutputFormat::Text => self.output_text_report(report),
641 OutputFormat::Json => {
642 let json = serde_json::to_string_pretty(report)
643 .context("Failed to serialize report to JSON")?;
644 println!("{}", json);
645 Ok(())
646 }
647 OutputFormat::Yaml => {
648 let yaml =
649 crate::data::to_yaml(report).context("Failed to serialize report to YAML")?;
650 println!("{}", yaml);
651 Ok(())
652 }
653 }
654 }
655
656 fn output_text_report(&self, report: &crate::data::check::CheckReport) -> Result<()> {
658 use crate::data::check::IssueSeverity;
659
660 println!();
661
662 for result in &report.commits {
663 if result.passes && !self.show_passing {
665 continue;
666 }
667
668 if self.quiet {
670 let has_errors_or_warnings = result
671 .issues
672 .iter()
673 .any(|i| matches!(i.severity, IssueSeverity::Error | IssueSeverity::Warning));
674 if !has_errors_or_warnings {
675 continue;
676 }
677 }
678
679 let icon = if result.passes {
681 "✅"
682 } else if result
683 .issues
684 .iter()
685 .any(|i| i.severity == IssueSeverity::Error)
686 {
687 "❌"
688 } else {
689 "⚠️ "
690 };
691
692 let short_hash = if result.hash.len() > 7 {
694 &result.hash[..7]
695 } else {
696 &result.hash
697 };
698
699 println!("{} {} - \"{}\"", icon, short_hash, result.message);
700
701 for issue in &result.issues {
703 if self.quiet && issue.severity == IssueSeverity::Info {
705 continue;
706 }
707
708 let severity_str = match issue.severity {
709 IssueSeverity::Error => "\x1b[31mERROR\x1b[0m ",
710 IssueSeverity::Warning => "\x1b[33mWARNING\x1b[0m",
711 IssueSeverity::Info => "\x1b[36mINFO\x1b[0m ",
712 };
713
714 println!(
715 " {} [{}] {}",
716 severity_str, issue.section, issue.explanation
717 );
718 }
719
720 if !self.quiet {
722 if let Some(suggestion) = &result.suggestion {
723 println!();
724 println!(" Suggested message:");
725 for line in suggestion.message.lines() {
726 println!(" {}", line);
727 }
728 if self.verbose {
729 println!();
730 println!(" Why this is better:");
731 for line in suggestion.explanation.lines() {
732 println!(" {}", line);
733 }
734 }
735 }
736 }
737
738 println!();
739 }
740
741 println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
743 println!("Summary: {} commits checked", report.summary.total_commits);
744 println!(
745 " {} errors, {} warnings",
746 report.summary.error_count, report.summary.warning_count
747 );
748 println!(
749 " {} passed, {} with issues",
750 report.summary.passing_commits, report.summary.failing_commits
751 );
752
753 Ok(())
754 }
755
756 fn show_model_info(&self, client: &crate::claude::client::ClaudeClient) -> Result<()> {
758 use crate::claude::model_config::get_model_registry;
759
760 println!("🤖 AI Model Configuration:");
761
762 let metadata = client.get_ai_client_metadata();
763 let registry = get_model_registry();
764
765 if let Some(spec) = registry.get_model_spec(&metadata.model) {
766 if metadata.model != spec.api_identifier {
767 println!(
768 " 📡 Model: {} → \x1b[33m{}\x1b[0m",
769 metadata.model, spec.api_identifier
770 );
771 } else {
772 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
773 }
774 println!(" 🏷️ Provider: {}", spec.provider);
775 } else {
776 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
777 println!(" 🏷️ Provider: {}", metadata.provider);
778 }
779
780 println!();
781 Ok(())
782 }
783
784 fn build_amendments_from_suggestions(
786 &self,
787 report: &crate::data::check::CheckReport,
788 repo_view: &crate::data::RepositoryView,
789 ) -> Vec<crate::data::amendments::Amendment> {
790 use crate::data::amendments::Amendment;
791
792 report
793 .commits
794 .iter()
795 .filter(|r| !r.passes)
796 .filter_map(|r| {
797 let suggestion = r.suggestion.as_ref()?;
798 let full_hash = repo_view.commits.iter().find_map(|c| {
799 if c.hash.starts_with(&r.hash) || r.hash.starts_with(&c.hash) {
800 Some(c.hash.clone())
801 } else {
802 None
803 }
804 });
805 full_hash.map(|hash| Amendment::new(hash, suggestion.message.clone()))
806 })
807 .collect()
808 }
809
810 async fn prompt_and_apply_suggestions(
813 &self,
814 amendments: Vec<crate::data::amendments::Amendment>,
815 ) -> Result<bool> {
816 use crate::data::amendments::AmendmentFile;
817 use crate::git::AmendmentHandler;
818 use std::io::{self, Write};
819
820 println!();
821 println!(
822 "🔧 {} commit(s) have issues with suggested fixes available.",
823 amendments.len()
824 );
825
826 loop {
827 print!("❓ [A]pply suggested fixes, or [Q]uit? [A/q] ");
828 io::stdout().flush()?;
829
830 let mut input = String::new();
831 io::stdin().read_line(&mut input)?;
832
833 match input.trim().to_lowercase().as_str() {
834 "a" | "apply" | "" => {
835 let amendment_file = AmendmentFile { amendments };
836 let temp_file = tempfile::NamedTempFile::new()
837 .context("Failed to create temp file for amendments")?;
838 amendment_file
839 .save_to_file(temp_file.path())
840 .context("Failed to save amendments")?;
841
842 let handler = AmendmentHandler::new()
843 .context("Failed to initialize amendment handler")?;
844 handler
845 .apply_amendments(&temp_file.path().to_string_lossy())
846 .context("Failed to apply amendments")?;
847
848 println!("✅ Suggested fixes applied successfully!");
849 return Ok(true);
850 }
851 "q" | "quit" => return Ok(false),
852 _ => {
853 println!("Invalid choice. Please enter 'a' to apply or 'q' to quit.");
854 }
855 }
856 }
857 }
858}