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> {
323 let context_dir = self
324 .context_dir
325 .clone()
326 .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
327 crate::claude::context::load_project_scopes(&context_dir, &std::path::PathBuf::from("."))
328 }
329
330 fn show_guidance_files_status(
332 &self,
333 guidelines: &Option<String>,
334 valid_scopes: &[crate::data::context::ScopeDefinition],
335 ) {
336 let context_dir = self
337 .context_dir
338 .clone()
339 .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
340
341 println!("📋 Project guidance files status:");
342
343 let guidelines_found = guidelines.is_some();
345 let guidelines_source = if guidelines_found {
346 let local_path = context_dir.join("local").join("commit-guidelines.md");
347 let project_path = context_dir.join("commit-guidelines.md");
348 let home_path = dirs::home_dir()
349 .map(|h| h.join(".omni-dev").join("commit-guidelines.md"))
350 .unwrap_or_default();
351
352 if local_path.exists() {
353 format!("✅ Local override: {}", local_path.display())
354 } else if project_path.exists() {
355 format!("✅ Project: {}", project_path.display())
356 } else if home_path.exists() {
357 format!("✅ Global: {}", home_path.display())
358 } else {
359 "✅ (source unknown)".to_string()
360 }
361 } else {
362 "⚪ Using defaults".to_string()
363 };
364 println!(" 📝 Commit guidelines: {}", guidelines_source);
365
366 let scopes_count = valid_scopes.len();
368 let scopes_source = if scopes_count > 0 {
369 let local_path = context_dir.join("local").join("scopes.yaml");
370 let project_path = context_dir.join("scopes.yaml");
371 let home_path = dirs::home_dir()
372 .map(|h| h.join(".omni-dev").join("scopes.yaml"))
373 .unwrap_or_default();
374
375 let source = if local_path.exists() {
376 format!("Local override: {}", local_path.display())
377 } else if project_path.exists() {
378 format!("Project: {}", project_path.display())
379 } else if home_path.exists() {
380 format!("Global: {}", home_path.display())
381 } else {
382 "(source unknown)".to_string()
383 };
384 format!("✅ {} ({} scopes)", source, scopes_count)
385 } else {
386 "⚪ None found (any scope accepted)".to_string()
387 };
388 println!(" 🎯 Valid scopes: {}", scopes_source);
389
390 println!();
391 }
392
393 async fn check_with_map_reduce(
399 &self,
400 claude_client: &crate::claude::client::ClaudeClient,
401 full_repo_view: &crate::data::RepositoryView,
402 guidelines: Option<&str>,
403 valid_scopes: &[crate::data::context::ScopeDefinition],
404 ) -> Result<crate::data::check::CheckReport> {
405 use std::sync::atomic::{AtomicUsize, Ordering};
406 use std::sync::Arc;
407
408 use crate::claude::batch;
409 use crate::claude::token_budget;
410 use crate::data::check::{CheckReport, CommitCheckResult};
411
412 let total_commits = full_repo_view.commits.len();
413
414 let metadata = claude_client.get_ai_client_metadata();
416 let system_prompt = crate::claude::prompts::generate_check_system_prompt_with_scopes(
417 guidelines,
418 valid_scopes,
419 );
420 let system_prompt_tokens = token_budget::estimate_tokens(&system_prompt);
421 let batch_plan =
422 batch::plan_batches(&full_repo_view.commits, &metadata, system_prompt_tokens);
423
424 if !self.quiet && batch_plan.batches.len() < total_commits {
425 println!(
426 " 📦 Grouped {} commits into {} batches by token budget",
427 total_commits,
428 batch_plan.batches.len()
429 );
430 }
431
432 let semaphore = Arc::new(tokio::sync::Semaphore::new(self.concurrency));
433 let completed = Arc::new(AtomicUsize::new(0));
434
435 let futs: Vec<_> = batch_plan
437 .batches
438 .iter()
439 .map(|batch| {
440 let sem = semaphore.clone();
441 let completed = completed.clone();
442 let batch_indices = &batch.commit_indices;
443
444 async move {
445 let _permit = sem
446 .acquire()
447 .await
448 .map_err(|e| anyhow::anyhow!("semaphore closed: {e}"))?;
449
450 let batch_size = batch_indices.len();
451
452 let batch_view = if batch_size == 1 {
454 full_repo_view.single_commit_view(&full_repo_view.commits[batch_indices[0]])
455 } else {
456 let commits: Vec<_> = batch_indices
457 .iter()
458 .map(|&i| &full_repo_view.commits[i])
459 .collect();
460 full_repo_view.multi_commit_view(&commits)
461 };
462
463 let result = claude_client
464 .check_commits_with_scopes(
465 &batch_view,
466 guidelines,
467 valid_scopes,
468 !self.no_suggestions,
469 )
470 .await;
471
472 match result {
473 Ok(report) => {
474 let done =
475 completed.fetch_add(batch_size, Ordering::Relaxed) + batch_size;
476 if !self.quiet {
477 println!(" ✅ {}/{} commits checked", done, total_commits);
478 }
479
480 let items: Vec<_> = report
481 .commits
482 .into_iter()
483 .map(|r| {
484 let summary = r.summary.clone().unwrap_or_default();
485 (r, summary)
486 })
487 .collect();
488 Ok(items)
489 }
490 Err(e) if batch_size > 1 => {
491 eprintln!(
493 "warning: batch of {} failed, retrying individually: {e}",
494 batch_size
495 );
496 let mut items = Vec::new();
497 for &idx in batch_indices {
498 let single_view =
499 full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
500 let single_result = claude_client
501 .check_commits_with_scopes(
502 &single_view,
503 guidelines,
504 valid_scopes,
505 !self.no_suggestions,
506 )
507 .await;
508 match single_result {
509 Ok(report) => {
510 if let Some(r) = report.commits.into_iter().next() {
511 let summary = r.summary.clone().unwrap_or_default();
512 items.push((r, summary));
513 }
514 }
515 Err(e) => {
516 eprintln!("warning: failed to check commit: {e}");
517 }
518 }
519 let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
520 if !self.quiet {
521 println!(" ✅ {}/{} commits checked", done, total_commits);
522 }
523 }
524 Ok(items)
525 }
526 Err(e) => Err(e),
527 }
528 }
529 })
530 .collect();
531
532 let results = futures::future::join_all(futs).await;
533
534 let mut successes: Vec<(CommitCheckResult, String)> = Vec::new();
536 let mut failure_count = 0;
537
538 for result in results {
539 match result {
540 Ok(items) => successes.extend(items),
541 Err(e) => {
542 eprintln!("warning: failed to check commit: {e}");
543 failure_count += 1;
544 }
545 }
546 }
547
548 if failure_count > 0 {
549 eprintln!("warning: {failure_count} commit(s) failed to check");
550 }
551
552 if successes.is_empty() {
553 anyhow::bail!("All commits failed to check");
554 }
555
556 let single_batch = batch_plan.batches.len() <= 1;
559 if !self.no_coherence && !single_batch && successes.len() >= 2 {
560 if !self.quiet {
561 println!("🔗 Running cross-commit coherence pass...");
562 }
563 match claude_client
564 .refine_checks_coherence(&successes, full_repo_view)
565 .await
566 {
567 Ok(refined) => {
568 if !self.quiet {
569 println!("✅ All commits checked!");
570 }
571 return Ok(refined);
572 }
573 Err(e) => {
574 eprintln!("warning: coherence pass failed, using individual results: {e}");
575 }
576 }
577 }
578
579 if !self.quiet {
580 println!("✅ All commits checked!");
581 }
582
583 let all_results: Vec<CommitCheckResult> = successes.into_iter().map(|(r, _)| r).collect();
584
585 Ok(CheckReport::new(all_results))
586 }
587
588 fn output_report(
590 &self,
591 report: &crate::data::check::CheckReport,
592 format: crate::data::check::OutputFormat,
593 ) -> Result<()> {
594 use crate::data::check::OutputFormat;
595
596 match format {
597 OutputFormat::Text => self.output_text_report(report),
598 OutputFormat::Json => {
599 let json = serde_json::to_string_pretty(report)
600 .context("Failed to serialize report to JSON")?;
601 println!("{}", json);
602 Ok(())
603 }
604 OutputFormat::Yaml => {
605 let yaml =
606 crate::data::to_yaml(report).context("Failed to serialize report to YAML")?;
607 println!("{}", yaml);
608 Ok(())
609 }
610 }
611 }
612
613 fn output_text_report(&self, report: &crate::data::check::CheckReport) -> Result<()> {
615 use crate::data::check::IssueSeverity;
616
617 println!();
618
619 for result in &report.commits {
620 if result.passes && !self.show_passing {
622 continue;
623 }
624
625 if self.quiet {
627 let has_errors_or_warnings = result
628 .issues
629 .iter()
630 .any(|i| matches!(i.severity, IssueSeverity::Error | IssueSeverity::Warning));
631 if !has_errors_or_warnings {
632 continue;
633 }
634 }
635
636 let icon = if result.passes {
638 "✅"
639 } else if result
640 .issues
641 .iter()
642 .any(|i| i.severity == IssueSeverity::Error)
643 {
644 "❌"
645 } else {
646 "⚠️ "
647 };
648
649 let short_hash = if result.hash.len() > 7 {
651 &result.hash[..7]
652 } else {
653 &result.hash
654 };
655
656 println!("{} {} - \"{}\"", icon, short_hash, result.message);
657
658 for issue in &result.issues {
660 if self.quiet && issue.severity == IssueSeverity::Info {
662 continue;
663 }
664
665 let severity_str = match issue.severity {
666 IssueSeverity::Error => "\x1b[31mERROR\x1b[0m ",
667 IssueSeverity::Warning => "\x1b[33mWARNING\x1b[0m",
668 IssueSeverity::Info => "\x1b[36mINFO\x1b[0m ",
669 };
670
671 println!(
672 " {} [{}] {}",
673 severity_str, issue.section, issue.explanation
674 );
675 }
676
677 if !self.quiet {
679 if let Some(suggestion) = &result.suggestion {
680 println!();
681 println!(" Suggested message:");
682 for line in suggestion.message.lines() {
683 println!(" {}", line);
684 }
685 if self.verbose {
686 println!();
687 println!(" Why this is better:");
688 for line in suggestion.explanation.lines() {
689 println!(" {}", line);
690 }
691 }
692 }
693 }
694
695 println!();
696 }
697
698 println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
700 println!("Summary: {} commits checked", report.summary.total_commits);
701 println!(
702 " {} errors, {} warnings",
703 report.summary.error_count, report.summary.warning_count
704 );
705 println!(
706 " {} passed, {} with issues",
707 report.summary.passing_commits, report.summary.failing_commits
708 );
709
710 Ok(())
711 }
712
713 fn show_model_info(&self, client: &crate::claude::client::ClaudeClient) -> Result<()> {
715 use crate::claude::model_config::get_model_registry;
716
717 println!("🤖 AI Model Configuration:");
718
719 let metadata = client.get_ai_client_metadata();
720 let registry = get_model_registry();
721
722 if let Some(spec) = registry.get_model_spec(&metadata.model) {
723 if metadata.model != spec.api_identifier {
724 println!(
725 " 📡 Model: {} → \x1b[33m{}\x1b[0m",
726 metadata.model, spec.api_identifier
727 );
728 } else {
729 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
730 }
731 println!(" 🏷️ Provider: {}", spec.provider);
732 } else {
733 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
734 println!(" 🏷️ Provider: {}", metadata.provider);
735 }
736
737 println!();
738 Ok(())
739 }
740
741 fn build_amendments_from_suggestions(
743 &self,
744 report: &crate::data::check::CheckReport,
745 repo_view: &crate::data::RepositoryView,
746 ) -> Vec<crate::data::amendments::Amendment> {
747 use crate::data::amendments::Amendment;
748
749 report
750 .commits
751 .iter()
752 .filter(|r| !r.passes)
753 .filter_map(|r| {
754 let suggestion = r.suggestion.as_ref()?;
755 let full_hash = repo_view.commits.iter().find_map(|c| {
756 if c.hash.starts_with(&r.hash) || r.hash.starts_with(&c.hash) {
757 Some(c.hash.clone())
758 } else {
759 None
760 }
761 });
762 full_hash.map(|hash| Amendment::new(hash, suggestion.message.clone()))
763 })
764 .collect()
765 }
766
767 async fn prompt_and_apply_suggestions(
770 &self,
771 amendments: Vec<crate::data::amendments::Amendment>,
772 ) -> Result<bool> {
773 use crate::data::amendments::AmendmentFile;
774 use crate::git::AmendmentHandler;
775 use std::io::{self, Write};
776
777 println!();
778 println!(
779 "🔧 {} commit(s) have issues with suggested fixes available.",
780 amendments.len()
781 );
782
783 loop {
784 print!("❓ [A]pply suggested fixes, or [Q]uit? [A/q] ");
785 io::stdout().flush()?;
786
787 let mut input = String::new();
788 io::stdin().read_line(&mut input)?;
789
790 match input.trim().to_lowercase().as_str() {
791 "a" | "apply" | "" => {
792 let amendment_file = AmendmentFile { amendments };
793 let temp_file = tempfile::NamedTempFile::new()
794 .context("Failed to create temp file for amendments")?;
795 amendment_file
796 .save_to_file(temp_file.path())
797 .context("Failed to save amendments")?;
798
799 let handler = AmendmentHandler::new()
800 .context("Failed to initialize amendment handler")?;
801 handler
802 .apply_amendments(&temp_file.path().to_string_lossy())
803 .context("Failed to apply amendments")?;
804
805 println!("✅ Suggested fixes applied successfully!");
806 return Ok(true);
807 }
808 "q" | "quit" => return Ok(false),
809 _ => {
810 println!("Invalid choice. Please enter 'a' to apply or 'q' to quit.");
811 }
812 }
813 }
814 }
815}