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