Skip to main content

git_iris/
commands.rs

1use crate::common::CommonParams;
2use crate::config::Config;
3use crate::instruction_presets::{
4    PresetType, get_instruction_preset_library, list_presets_formatted_by_type,
5};
6use crate::log_debug;
7use crate::providers::{Provider, ProviderConfig};
8use crate::ui;
9use anyhow::Context;
10use anyhow::{Result, anyhow};
11use colored::Colorize;
12use std::collections::HashMap;
13
14/// Helper to get themed colors for terminal output
15mod colors {
16    use crate::theme;
17    use crate::theme::names::tokens;
18
19    pub fn accent_primary() -> (u8, u8, u8) {
20        let c = theme::current().color(tokens::ACCENT_PRIMARY);
21        (c.r, c.g, c.b)
22    }
23
24    pub fn accent_secondary() -> (u8, u8, u8) {
25        let c = theme::current().color(tokens::ACCENT_SECONDARY);
26        (c.r, c.g, c.b)
27    }
28
29    pub fn accent_tertiary() -> (u8, u8, u8) {
30        let c = theme::current().color(tokens::ACCENT_TERTIARY);
31        (c.r, c.g, c.b)
32    }
33
34    pub fn warning() -> (u8, u8, u8) {
35        let c = theme::current().color(tokens::WARNING);
36        (c.r, c.g, c.b)
37    }
38
39    pub fn success() -> (u8, u8, u8) {
40        let c = theme::current().color(tokens::SUCCESS);
41        (c.r, c.g, c.b)
42    }
43
44    pub fn text_secondary() -> (u8, u8, u8) {
45        let c = theme::current().color(tokens::TEXT_SECONDARY);
46        (c.r, c.g, c.b)
47    }
48
49    pub fn text_dim() -> (u8, u8, u8) {
50        let c = theme::current().color(tokens::TEXT_DIM);
51        (c.r, c.g, c.b)
52    }
53}
54
55/// Apply common configuration changes to a config object
56/// Returns true if any changes were made
57///
58/// This centralized function handles changes to configuration objects, used by both
59/// personal and project configuration commands.
60///
61/// # Arguments
62///
63/// * `config` - The configuration object to modify
64/// * `common` - Common parameters from command line
65/// * `model` - Optional model to set for the selected provider
66/// * `token_limit` - Optional token limit to set
67/// * `param` - Optional additional parameters to set
68/// * `api_key` - Optional API key to set (ignored in project configs)
69///
70/// # Returns
71///
72/// Boolean indicating if any changes were made to the configuration
73fn apply_config_changes(
74    config: &mut Config,
75    common: &CommonParams,
76    model: Option<String>,
77    fast_model: Option<String>,
78    token_limit: Option<usize>,
79    param: Option<Vec<String>>,
80    api_key: Option<String>,
81    subagent_timeout: Option<u64>,
82) -> anyhow::Result<bool> {
83    let mut changes_made = false;
84
85    // Apply common parameters to the config and track if changes were made
86    let common_changes = common.apply_to_config(config)?;
87    changes_made |= common_changes;
88
89    // Handle provider change - validate and insert if needed
90    if let Some(provider_str) = &common.provider {
91        let provider: Provider = provider_str.parse().map_err(|_| {
92            anyhow!(
93                "Invalid provider: {}. Available: {}",
94                provider_str,
95                Provider::all_names().join(", ")
96            )
97        })?;
98
99        // Only check for provider insertion if it wasn't already handled
100        if !config.providers.contains_key(provider.name()) {
101            config.providers.insert(
102                provider.name().to_string(),
103                ProviderConfig::with_defaults(provider),
104            );
105            changes_made = true;
106        }
107    }
108
109    let provider_config = config
110        .providers
111        .get_mut(&config.default_provider)
112        .context("Could not get default provider")?;
113
114    // Apply API key if provided
115    if let Some(key) = api_key
116        && provider_config.api_key != key
117    {
118        provider_config.api_key = key;
119        changes_made = true;
120    }
121
122    // Apply model change
123    if let Some(model) = model
124        && provider_config.model != model
125    {
126        provider_config.model = model;
127        changes_made = true;
128    }
129
130    // Apply fast model change
131    if let Some(fast_model) = fast_model
132        && provider_config.fast_model != Some(fast_model.clone())
133    {
134        provider_config.fast_model = Some(fast_model);
135        changes_made = true;
136    }
137
138    // Apply parameter changes
139    if let Some(params) = param {
140        let additional_params = parse_additional_params(&params);
141        if provider_config.additional_params != additional_params {
142            provider_config.additional_params = additional_params;
143            changes_made = true;
144        }
145    }
146
147    // Apply gitmoji setting
148    if let Some(use_gitmoji) = common.resolved_gitmoji()
149        && config.use_gitmoji != use_gitmoji
150    {
151        config.use_gitmoji = use_gitmoji;
152        changes_made = true;
153    }
154
155    // Apply instructions
156    if let Some(instr) = &common.instructions
157        && config.instructions != *instr
158    {
159        config.instructions.clone_from(instr);
160        changes_made = true;
161    }
162
163    // Apply token limit
164    if let Some(limit) = token_limit
165        && provider_config.token_limit != Some(limit)
166    {
167        provider_config.token_limit = Some(limit);
168        changes_made = true;
169    }
170
171    // Apply preset
172    if let Some(preset) = &common.preset {
173        let preset_library = get_instruction_preset_library();
174        if preset_library.get_preset(preset).is_some() {
175            if config.instruction_preset != *preset {
176                config.instruction_preset.clone_from(preset);
177                changes_made = true;
178            }
179        } else {
180            return Err(anyhow!("Invalid preset: {}", preset));
181        }
182    }
183
184    // Apply subagent timeout
185    if let Some(timeout) = subagent_timeout
186        && config.subagent_timeout_secs != timeout
187    {
188        config.subagent_timeout_secs = timeout;
189        changes_made = true;
190    }
191
192    Ok(changes_made)
193}
194
195/// Handle the 'config' command
196#[allow(clippy::too_many_lines)]
197///
198/// # Errors
199///
200/// Returns an error when configuration loading, validation, or saving fails.
201pub fn handle_config_command(
202    common: &CommonParams,
203    api_key: Option<String>,
204    model: Option<String>,
205    fast_model: Option<String>,
206    token_limit: Option<usize>,
207    param: Option<Vec<String>>,
208    subagent_timeout: Option<u64>,
209) -> anyhow::Result<()> {
210    log_debug!(
211        "Starting 'config' command with common: {:?}, api_key: {}, model: {:?}, token_limit: {:?}, param: {:?}, subagent_timeout: {:?}",
212        common,
213        if api_key.is_some() {
214            "[REDACTED]"
215        } else {
216            "<none>"
217        },
218        model,
219        token_limit,
220        param,
221        subagent_timeout
222    );
223
224    let mut config = Config::load()?;
225
226    // Apply configuration changes
227    let changes_made = apply_config_changes(
228        &mut config,
229        common,
230        model,
231        fast_model,
232        token_limit,
233        param,
234        api_key,
235        subagent_timeout,
236    )?;
237
238    if changes_made {
239        config.save()?;
240        ui::print_success("Configuration updated successfully.");
241        ui::print_newline();
242    }
243
244    // Print the configuration with beautiful styling
245    print_configuration(&config);
246
247    Ok(())
248}
249
250/// Handle printing current project configuration
251///
252/// Loads and displays the current project configuration if it exists,
253/// or shows a message if no project configuration is found.
254fn print_project_config() {
255    if let Ok(project_config) = Config::load_project_config() {
256        ui::print_message(&format!(
257            "\n{}",
258            "Current project configuration:".bright_cyan().bold()
259        ));
260        print_configuration(&project_config);
261    } else {
262        ui::print_message(&format!(
263            "\n{}",
264            "No project configuration file found.".yellow()
265        ));
266        ui::print_message("You can create one with the project-config command.");
267    }
268}
269
270/// Handle the 'project-config' command
271///
272/// Creates or updates a project-specific configuration file (.irisconfig)
273/// in the repository root. Project configurations allow teams to share
274/// common settings without sharing sensitive data like API keys.
275///
276/// # Security
277///
278/// API keys are never stored in project configuration files, ensuring that
279/// sensitive credentials are not accidentally committed to version control.
280///
281/// # Arguments
282///
283/// * `common` - Common parameters from command line
284/// * `model` - Optional model to set for the selected provider
285/// * `token_limit` - Optional token limit to set
286/// * `param` - Optional additional parameters to set
287/// * `print` - Whether to just print the current project config
288///
289/// # Returns
290///
291/// Result indicating success or an error
292///
293/// # Errors
294///
295/// Returns an error when project configuration validation or saving fails.
296pub fn handle_project_config_command(
297    common: &CommonParams,
298    model: Option<String>,
299    fast_model: Option<String>,
300    token_limit: Option<usize>,
301    param: Option<Vec<String>>,
302    subagent_timeout: Option<u64>,
303    print: bool,
304) -> anyhow::Result<()> {
305    log_debug!(
306        "Starting 'project-config' command with common: {:?}, model: {:?}, token_limit: {:?}, param: {:?}, subagent_timeout: {:?}, print: {}",
307        common,
308        model,
309        token_limit,
310        param,
311        subagent_timeout,
312        print
313    );
314
315    println!("\n{}", "✨ Project Configuration".bright_magenta().bold());
316
317    if print {
318        print_project_config();
319        return Ok(());
320    }
321
322    let mut config = Config::load_project_config().unwrap_or_else(|_| Config {
323        default_provider: String::new(),
324        providers: HashMap::new(),
325        use_gitmoji: true,
326        instructions: String::new(),
327        instruction_preset: String::new(),
328        theme: String::new(),
329        subagent_timeout_secs: 120,
330        temp_instructions: None,
331        temp_preset: None,
332        is_project_config: true,
333        gitmoji_override: None,
334    });
335
336    let mut changes_made = false;
337
338    // Apply provider settings
339    let provider_name = apply_provider_settings(
340        &mut config,
341        common,
342        model,
343        fast_model,
344        token_limit,
345        param,
346        &mut changes_made,
347    )?;
348
349    // Apply common settings
350    apply_common_settings(&mut config, common, subagent_timeout, &mut changes_made)?;
351
352    // Display result
353    display_project_config_result(&config, changes_made, &provider_name)?;
354
355    Ok(())
356}
357
358/// Apply provider-related settings to config
359fn apply_provider_settings(
360    config: &mut Config,
361    common: &CommonParams,
362    model: Option<String>,
363    fast_model: Option<String>,
364    token_limit: Option<usize>,
365    param: Option<Vec<String>>,
366    changes_made: &mut bool,
367) -> anyhow::Result<String> {
368    // Apply provider change
369    if let Some(provider_str) = &common.provider {
370        let provider: Provider = provider_str.parse().map_err(|_| {
371            anyhow!(
372                "Invalid provider: {}. Available: {}",
373                provider_str,
374                Provider::all_names().join(", ")
375            )
376        })?;
377
378        if config.default_provider != provider.name() {
379            config.default_provider = provider.name().to_string();
380            config
381                .providers
382                .entry(provider.name().to_string())
383                .or_default();
384            *changes_made = true;
385        }
386    }
387
388    // Get provider name to use
389    let provider_name = common
390        .provider
391        .clone()
392        .or_else(|| {
393            if config.default_provider.is_empty() {
394                None
395            } else {
396                Some(config.default_provider.clone())
397            }
398        })
399        .unwrap_or_else(|| Provider::default().name().to_string());
400
401    // Ensure provider config entry exists if setting model options
402    if model.is_some() || fast_model.is_some() || token_limit.is_some() || param.is_some() {
403        config.providers.entry(provider_name.clone()).or_default();
404    }
405
406    // Apply model settings
407    if let Some(m) = model
408        && let Some(pc) = config.providers.get_mut(&provider_name)
409        && pc.model != m
410    {
411        pc.model = m;
412        *changes_made = true;
413    }
414
415    if let Some(fm) = fast_model
416        && let Some(pc) = config.providers.get_mut(&provider_name)
417        && pc.fast_model != Some(fm.clone())
418    {
419        pc.fast_model = Some(fm);
420        *changes_made = true;
421    }
422
423    if let Some(limit) = token_limit
424        && let Some(pc) = config.providers.get_mut(&provider_name)
425        && pc.token_limit != Some(limit)
426    {
427        pc.token_limit = Some(limit);
428        *changes_made = true;
429    }
430
431    if let Some(params) = param
432        && let Some(pc) = config.providers.get_mut(&provider_name)
433    {
434        let additional_params = parse_additional_params(&params);
435        if pc.additional_params != additional_params {
436            pc.additional_params = additional_params;
437            *changes_made = true;
438        }
439    }
440
441    Ok(provider_name)
442}
443
444/// Apply common settings (gitmoji, instructions, preset, timeout)
445fn apply_common_settings(
446    config: &mut Config,
447    common: &CommonParams,
448    subagent_timeout: Option<u64>,
449    changes_made: &mut bool,
450) -> anyhow::Result<()> {
451    if let Some(use_gitmoji) = common.resolved_gitmoji()
452        && config.use_gitmoji != use_gitmoji
453    {
454        config.use_gitmoji = use_gitmoji;
455        *changes_made = true;
456    }
457
458    if let Some(instr) = &common.instructions
459        && config.instructions != *instr
460    {
461        config.instructions.clone_from(instr);
462        *changes_made = true;
463    }
464
465    if let Some(preset) = &common.preset {
466        let preset_library = get_instruction_preset_library();
467        if preset_library.get_preset(preset).is_some() {
468            if config.instruction_preset != *preset {
469                config.instruction_preset.clone_from(preset);
470                *changes_made = true;
471            }
472        } else {
473            return Err(anyhow!("Invalid preset: {}", preset));
474        }
475    }
476
477    if let Some(timeout) = subagent_timeout
478        && config.subagent_timeout_secs != timeout
479    {
480        config.subagent_timeout_secs = timeout;
481        *changes_made = true;
482    }
483
484    Ok(())
485}
486
487/// Display the result of project config command
488fn display_project_config_result(
489    config: &Config,
490    changes_made: bool,
491    _provider_name: &str,
492) -> anyhow::Result<()> {
493    if changes_made {
494        config.save_as_project_config()?;
495        ui::print_success("Project configuration created/updated successfully.");
496        println!();
497        println!(
498            "{}",
499            "Note: API keys are never stored in project configuration files."
500                .yellow()
501                .italic()
502        );
503        println!();
504        println!("{}", "Current project configuration:".bright_cyan().bold());
505        print_configuration(config);
506    } else {
507        println!("{}", "No changes made to project configuration.".yellow());
508        println!();
509
510        if let Ok(project_config) = Config::load_project_config() {
511            println!("{}", "Current project configuration:".bright_cyan().bold());
512            print_configuration(&project_config);
513        } else {
514            println!("{}", "No project configuration exists yet.".bright_yellow());
515            println!(
516                "{}",
517                "Use this command with options like --model or --provider to create one."
518                    .bright_white()
519            );
520        }
521    }
522    Ok(())
523}
524
525/// Display the configuration with `SilkCircuit` styling
526fn print_configuration(config: &Config) {
527    let purple = colors::accent_primary();
528    let cyan = colors::accent_secondary();
529    let green = colors::success();
530    let dim = colors::text_secondary();
531    let dim_sep = colors::text_dim();
532
533    println!();
534    println!(
535        "{}  {}  {}",
536        "━━━".truecolor(purple.0, purple.1, purple.2),
537        "IRIS CONFIGURATION"
538            .truecolor(cyan.0, cyan.1, cyan.2)
539            .bold(),
540        "━━━".truecolor(purple.0, purple.1, purple.2)
541    );
542    println!();
543
544    // Global Settings
545    print_section_header("GLOBAL");
546
547    print_config_row("Provider", &config.default_provider, cyan, true);
548
549    // Theme
550    let theme = crate::theme::current();
551    print_config_row("Theme", &theme.meta.name, purple, false);
552
553    print_config_row(
554        "Gitmoji",
555        if config.use_gitmoji {
556            "enabled"
557        } else {
558            "disabled"
559        },
560        if config.use_gitmoji { green } else { dim },
561        false,
562    );
563    print_config_row("Preset", &config.instruction_preset, dim, false);
564    print_config_row(
565        "Timeout",
566        &format!("{}s", config.subagent_timeout_secs),
567        dim,
568        false,
569    );
570
571    // Config file paths
572    if let Ok(config_path) = Config::get_personal_config_path() {
573        let home = dirs::home_dir()
574            .map(|h| h.to_string_lossy().to_string())
575            .unwrap_or_default();
576        let path_str = config_path.to_string_lossy().to_string();
577        let path_display = if home.is_empty() {
578            path_str
579        } else {
580            path_str.replace(&home, "~")
581        };
582        print_config_row("Config", &path_display, dim, false);
583    }
584
585    // Project config status
586    if let Ok(project_path) = Config::get_project_config_path()
587        && project_path.exists()
588    {
589        print_config_row("Project", ".irisconfig ✓", green, false);
590    }
591
592    // Custom Instructions (if any)
593    if !config.instructions.is_empty() {
594        println!();
595        print_section_header("INSTRUCTIONS");
596        // Truncate long instructions for display
597        let preview: String = config
598            .instructions
599            .lines()
600            .take(3)
601            .collect::<Vec<_>>()
602            .join("\n");
603        for line in preview.lines() {
604            println!("    {}", line.truecolor(dim.0, dim.1, dim.2).italic());
605        }
606        let total_lines = config.instructions.lines().count();
607        if total_lines > 3 {
608            println!(
609                "    {}",
610                format!("… ({} more lines)", total_lines - 3)
611                    .truecolor(dim_sep.0, dim_sep.1, dim_sep.2)
612                    .italic()
613            );
614        }
615    }
616
617    // Show ALL providers — active provider first, then rest alphabetically
618    let mut provider_names: Vec<String> =
619        Provider::ALL.iter().map(|p| p.name().to_string()).collect();
620    provider_names.sort();
621    // Move active provider to front
622    if let Some(pos) = provider_names
623        .iter()
624        .position(|n| n == &config.default_provider)
625    {
626        let active = provider_names.remove(pos);
627        provider_names.insert(0, active);
628    }
629
630    for provider_name in &provider_names {
631        println!();
632        print_provider_section(config, provider_name);
633    }
634
635    println!();
636    println!(
637        "{}",
638        "─".repeat(44).truecolor(dim_sep.0, dim_sep.1, dim_sep.2)
639    );
640    println!();
641}
642
643/// Print a single provider section with all its details
644fn print_provider_section(config: &Config, provider_name: &str) {
645    let cyan = colors::accent_secondary();
646    let coral = colors::accent_tertiary();
647    let yellow = colors::warning();
648    let green = colors::success();
649    let dim = colors::text_secondary();
650    let error_red: (u8, u8, u8) = (255, 99, 99);
651
652    let is_active = provider_name == config.default_provider;
653    let provider: Option<Provider> = provider_name.parse().ok();
654
655    let header = if is_active {
656        format!("{} ✦", provider_name.to_uppercase())
657    } else {
658        provider_name.to_uppercase()
659    };
660    print_section_header(&header);
661
662    let provider_config = config.providers.get(provider_name);
663
664    // Model (resolve effective model)
665    let model = provider_config
666        .and_then(|pc| provider.map(|p| pc.effective_model(p).to_string()))
667        .or_else(|| provider.map(|p| p.default_model().to_string()))
668        .unwrap_or_default();
669    print_config_row("Model", &model, cyan, is_active);
670
671    // Fast Model (resolve effective)
672    let fast_model = provider_config
673        .and_then(|pc| provider.map(|p| pc.effective_fast_model(p).to_string()))
674        .or_else(|| provider.map(|p| p.default_fast_model().to_string()))
675        .unwrap_or_default();
676    print_config_row("Fast Model", &fast_model, dim, false);
677
678    // Context window
679    if let Some(p) = provider {
680        let effective_limit =
681            provider_config.map_or_else(|| p.context_window(), |pc| pc.effective_token_limit(p));
682        let limit_str = format_token_count(effective_limit);
683        let is_custom = provider_config.and_then(|pc| pc.token_limit).is_some();
684        if is_custom {
685            print_config_row("Context", &format!("{limit_str} (custom)"), coral, false);
686        } else {
687            print_config_row("Context", &limit_str, dim, false);
688        }
689    }
690
691    // API Key status
692    if let Some(p) = provider {
693        let has_config_key = provider_config.is_some_and(ProviderConfig::has_api_key);
694        let has_env_key = std::env::var(p.api_key_env()).is_ok();
695        let env_var = p.api_key_env();
696
697        let (status, status_color) = if has_config_key {
698            // Safe: has_config_key guarantees provider_config is Some with a key
699            let key = &provider_config.expect("checked above").api_key;
700            let masked = mask_api_key(key);
701            (format!("✓ {masked}"), green)
702        } else if has_env_key {
703            (format!("✓ ${env_var}"), green)
704        } else {
705            (format!("✗ not set  →  ${env_var}"), error_red)
706        };
707        print_config_row("API Key", &status, status_color, false);
708
709        // Show format warning if key exists but has bad format
710        let key_value = if has_config_key {
711            provider_config.map(|pc| pc.api_key.clone())
712        } else if has_env_key {
713            std::env::var(p.api_key_env()).ok()
714        } else {
715            None
716        };
717        if let Some(ref key) = key_value
718            && let Err(warning) = p.validate_api_key_format(key)
719        {
720            println!(
721                "              {}",
722                format!("⚠ {warning}").truecolor(yellow.0, yellow.1, yellow.2)
723            );
724        }
725    }
726
727    // Additional Parameters
728    if let Some(pc) = provider_config
729        && !pc.additional_params.is_empty()
730    {
731        for (key, value) in &pc.additional_params {
732            print_config_row(key, value, dim, false);
733        }
734    }
735}
736
737/// Format a token count in human-readable form (128K, 200K, 1M)
738fn format_token_count(count: usize) -> String {
739    if count >= 1_000_000 && count.is_multiple_of(1_000_000) {
740        format!("{}M tokens", count / 1_000_000)
741    } else if count >= 1_000 {
742        format!("{}K tokens", count / 1_000)
743    } else {
744        format!("{count} tokens")
745    }
746}
747
748/// Mask an API key for display, showing only prefix and last 4 chars
749fn mask_api_key(key: &str) -> String {
750    if key.len() <= 8 {
751        return "••••".to_string();
752    }
753    // Show the prefix (e.g. "sk-ant-") and last 4 chars
754    let prefix_end = key.find('-').map_or(4, |i| {
755        // Find the last hyphen in the prefix portion (first 12 chars)
756        key[..12.min(key.len())].rfind('-').map_or(i + 1, |j| j + 1)
757    });
758    let prefix = &key[..prefix_end.min(key.len())];
759    let suffix = &key[key.len() - 4..];
760    format!("{prefix}••••{suffix}")
761}
762
763/// Print a section header in `SilkCircuit` style
764fn print_section_header(name: &str) {
765    let purple = colors::accent_primary();
766    let dim_sep = colors::text_dim();
767    println!(
768        "{} {} {}",
769        "─".truecolor(purple.0, purple.1, purple.2),
770        name.truecolor(purple.0, purple.1, purple.2).bold(),
771        "─"
772            .repeat(30 - name.len().min(28))
773            .truecolor(dim_sep.0, dim_sep.1, dim_sep.2)
774    );
775}
776
777/// Print a config row with label and value
778fn print_config_row(label: &str, value: &str, value_color: (u8, u8, u8), highlight: bool) {
779    let dim = colors::text_secondary();
780    let label_styled = format!("{label:>12}").truecolor(dim.0, dim.1, dim.2);
781
782    let value_styled = if highlight {
783        value
784            .truecolor(value_color.0, value_color.1, value_color.2)
785            .bold()
786    } else {
787        value.truecolor(value_color.0, value_color.1, value_color.2)
788    };
789
790    println!("{label_styled}  {value_styled}");
791}
792
793/// Parse additional parameters from the command line
794fn parse_additional_params(params: &[String]) -> HashMap<String, String> {
795    params
796        .iter()
797        .filter_map(|param| {
798            let parts: Vec<&str> = param.splitn(2, '=').collect();
799            if parts.len() == 2 {
800                Some((parts[0].to_string(), parts[1].to_string()))
801            } else {
802                None
803            }
804        })
805        .collect()
806}
807
808/// Handle the '`list_presets`' command
809///
810/// # Errors
811///
812/// Returns an error if preset formatting fails in the future; currently always succeeds.
813pub fn handle_list_presets_command() -> Result<()> {
814    let library = get_instruction_preset_library();
815
816    // Get different categories of presets
817    let both_presets = list_presets_formatted_by_type(&library, Some(PresetType::Both));
818    let commit_only_presets = list_presets_formatted_by_type(&library, Some(PresetType::Commit));
819    let review_only_presets = list_presets_formatted_by_type(&library, Some(PresetType::Review));
820
821    println!(
822        "{}",
823        "\nGit-Iris Instruction Presets\n".bright_magenta().bold()
824    );
825
826    println!(
827        "{}",
828        "General Presets (usable for both commit and review):"
829            .bright_cyan()
830            .bold()
831    );
832    println!("{both_presets}\n");
833
834    if !commit_only_presets.is_empty() {
835        println!("{}", "Commit-specific Presets:".bright_green().bold());
836        println!("{commit_only_presets}\n");
837    }
838
839    if !review_only_presets.is_empty() {
840        println!("{}", "Review-specific Presets:".bright_blue().bold());
841        println!("{review_only_presets}\n");
842    }
843
844    println!("{}", "Usage:".bright_yellow().bold());
845    println!("  git-iris gen --preset <preset-key>");
846    println!("  git-iris review --preset <preset-key>");
847    println!("\nPreset types: [B] = Both commands, [C] = Commit only, [R] = Review only");
848
849    Ok(())
850}
851
852/// Marker comment embedded in hooks installed by git-iris
853const HOOK_MARKER: &str = "# Installed by git-iris";
854
855/// Handle the `hook` command - install or uninstall the prepare-commit-msg hook
856///
857/// # Errors
858///
859/// Returns an error when hook installation or removal fails.
860pub fn handle_hook_command(action: &crate::cli::HookAction) -> Result<()> {
861    match action {
862        crate::cli::HookAction::Install { force } => handle_hook_install(*force),
863        crate::cli::HookAction::Uninstall => handle_hook_uninstall(),
864    }
865}
866
867/// Install the prepare-commit-msg hook
868fn handle_hook_install(force: bool) -> Result<()> {
869    use std::fs;
870
871    let hook_dir = find_git_hooks_dir()?;
872    let hook_path = hook_dir.join("prepare-commit-msg");
873
874    // Reject symlinks to prevent redirect attacks
875    if hook_path
876        .symlink_metadata()
877        .is_ok_and(|m| m.file_type().is_symlink())
878    {
879        anyhow::bail!(
880            "Hook path is a symlink — refusing to write. Remove it manually: {}",
881            hook_path.display()
882        );
883    }
884
885    // Check for existing hook
886    if hook_path.exists() {
887        let existing = fs::read_to_string(&hook_path).context("Failed to read existing hook")?;
888
889        if existing.contains(HOOK_MARKER) {
890            let (r, g, b) = colors::success();
891            println!(
892                "{}",
893                "✨ Git-iris hook is already installed.".truecolor(r, g, b)
894            );
895            return Ok(());
896        }
897
898        if !force {
899            let (r, g, b) = colors::warning();
900            println!(
901                "{}",
902                "⚠️  A prepare-commit-msg hook already exists and was not installed by git-iris."
903                    .truecolor(r, g, b)
904            );
905            println!("{}", "   Use --force to overwrite it.".truecolor(r, g, b));
906            return Ok(());
907        }
908    }
909
910    let hook_content = format!(
911        "#!/bin/sh\n{HOOK_MARKER}\n# Generates an AI commit message using git-iris\nexec git-iris gen --print > \"$1\"\n"
912    );
913
914    fs::write(&hook_path, hook_content).context("Failed to write hook file")?;
915
916    // Make executable
917    #[cfg(unix)]
918    {
919        use std::os::unix::fs::PermissionsExt;
920        fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755))
921            .context("Failed to set hook permissions")?;
922    }
923
924    let (r, g, b) = colors::success();
925    println!(
926        "{}",
927        "✨ prepare-commit-msg hook installed successfully!".truecolor(r, g, b)
928    );
929    println!(
930        "   {}",
931        format!("Hook path: {}", hook_path.display()).truecolor(r, g, b)
932    );
933    println!(
934        "   {}",
935        "AI commit messages will be generated automatically when you run 'git commit'."
936            .truecolor(r, g, b)
937    );
938
939    Ok(())
940}
941
942/// Uninstall the prepare-commit-msg hook
943fn handle_hook_uninstall() -> Result<()> {
944    use std::fs;
945
946    let hook_dir = find_git_hooks_dir()?;
947    let hook_path = hook_dir.join("prepare-commit-msg");
948
949    // Reject symlinks to prevent redirect attacks
950    if hook_path
951        .symlink_metadata()
952        .is_ok_and(|m| m.file_type().is_symlink())
953    {
954        anyhow::bail!(
955            "Hook path is a symlink — refusing to remove. Delete it manually: {}",
956            hook_path.display()
957        );
958    }
959
960    if !hook_path.exists() {
961        let (r, g, b) = colors::warning();
962        println!("{}", "No prepare-commit-msg hook found.".truecolor(r, g, b));
963        return Ok(());
964    }
965
966    let content = fs::read_to_string(&hook_path).context("Failed to read hook file")?;
967
968    if !content.contains(HOOK_MARKER) {
969        let (r, g, b) = colors::warning();
970        println!(
971            "{}",
972            "⚠️  The existing prepare-commit-msg hook was not installed by git-iris."
973                .truecolor(r, g, b)
974        );
975        println!(
976            "   {}",
977            "Refusing to remove it. Delete it manually if needed.".truecolor(r, g, b)
978        );
979        return Ok(());
980    }
981
982    fs::remove_file(&hook_path).context("Failed to remove hook file")?;
983
984    let (r, g, b) = colors::success();
985    println!(
986        "{}",
987        "✨ prepare-commit-msg hook uninstalled successfully.".truecolor(r, g, b)
988    );
989
990    Ok(())
991}
992
993/// Find the hooks directory using libgit2.
994///
995/// Uses `repo.path()` which resolves correctly for both normal repos and
996/// worktrees (where `.git` is a file, not a directory). Also respects
997/// `core.hooksPath` when configured.
998fn find_git_hooks_dir() -> Result<std::path::PathBuf> {
999    use crate::git::GitRepo;
1000
1001    let repo_root = GitRepo::get_repo_root()
1002        .context("Not in a Git repository. Run this command from within a Git repository.")?;
1003
1004    let repo = git2::Repository::open(&repo_root).context("Failed to open Git repository")?;
1005
1006    // Check for core.hooksPath override first
1007    let hooks_dir = repo
1008        .config()
1009        .ok()
1010        .and_then(|cfg| cfg.get_path("core.hooksPath").ok())
1011        .unwrap_or_else(|| repo.path().join("hooks"));
1012
1013    // Create hooks dir if it doesn't exist
1014    if !hooks_dir.exists() {
1015        std::fs::create_dir_all(&hooks_dir).context("Failed to create hooks directory")?;
1016    }
1017
1018    Ok(hooks_dir)
1019}