Skip to main content

auto_commit_rs/
preset.rs

1use anyhow::{Context, Result};
2use colored::Colorize;
3use inquire::{Select, Text};
4use serde::{Deserialize, Serialize};
5use std::path::PathBuf;
6
7use crate::config::AppConfig;
8use crate::ui;
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11pub struct LlmPresetFields {
12    pub provider: String,
13    pub model: String,
14    pub api_key: String,
15    pub api_url: String,
16    pub api_headers: String,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Preset {
21    pub id: u32,
22    pub name: String,
23    #[serde(flatten)]
24    pub fields: LlmPresetFields,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, Default)]
28pub struct FallbackConfig {
29    #[serde(default = "crate::config::default_true")]
30    pub enabled: bool,
31    #[serde(default)]
32    pub order: Vec<u32>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, Default)]
36pub struct PresetsFile {
37    #[serde(default)]
38    pub next_id: u32,
39    #[serde(default)]
40    pub presets: Vec<Preset>,
41    #[serde(default)]
42    pub fallback: FallbackConfig,
43}
44
45fn presets_file_path() -> Option<PathBuf> {
46    crate::config::global_config_path().map(|p| {
47        p.parent()
48            .expect("global config path should have a parent")
49            .join("presets.toml")
50    })
51}
52
53pub fn load_presets() -> Result<PresetsFile> {
54    let path = match presets_file_path() {
55        Some(p) => p,
56        None => return Ok(PresetsFile::default()),
57    };
58    if !path.exists() {
59        return Ok(PresetsFile::default());
60    }
61    let content = std::fs::read_to_string(&path)
62        .with_context(|| format!("Failed to read {}", path.display()))?;
63    let file: PresetsFile =
64        toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))?;
65    Ok(file)
66}
67
68pub fn save_presets(file: &PresetsFile) -> Result<()> {
69    let path = presets_file_path().context("Could not determine presets file path")?;
70    if let Some(parent) = path.parent() {
71        std::fs::create_dir_all(parent)
72            .with_context(|| format!("Failed to create {}", parent.display()))?;
73    }
74    let content = toml::to_string_pretty(file).context("Failed to serialize presets")?;
75    // Write to temp file then rename for atomicity
76    let tmp_path = path.with_extension("toml.tmp");
77    std::fs::write(&tmp_path, &content)
78        .with_context(|| format!("Failed to write {}", tmp_path.display()))?;
79    std::fs::rename(&tmp_path, &path)
80        .with_context(|| format!("Failed to rename temp file to {}", path.display()))?;
81    Ok(())
82}
83
84pub fn fields_from_config(cfg: &AppConfig) -> LlmPresetFields {
85    LlmPresetFields {
86        provider: cfg.provider.clone(),
87        model: cfg.model.clone(),
88        api_key: cfg.api_key.clone(),
89        api_url: cfg.api_url.clone(),
90        api_headers: cfg.api_headers.clone(),
91    }
92}
93
94pub fn apply_preset_to_config(cfg: &mut AppConfig, preset: &Preset) {
95    cfg.provider = preset.fields.provider.clone();
96    cfg.model = preset.fields.model.clone();
97    cfg.api_key = preset.fields.api_key.clone();
98    cfg.api_url = preset.fields.api_url.clone();
99    cfg.api_headers = preset.fields.api_headers.clone();
100}
101
102/// Dedup key: (provider, model, api_key, api_url) — headers excluded
103fn dedup_key(fields: &LlmPresetFields) -> (&str, &str, &str, &str) {
104    (
105        &fields.provider,
106        &fields.model,
107        &fields.api_key,
108        &fields.api_url,
109    )
110}
111
112pub fn find_duplicate(file: &PresetsFile, fields: &LlmPresetFields) -> Option<u32> {
113    let key = dedup_key(fields);
114    file.presets
115        .iter()
116        .find(|p| dedup_key(&p.fields) == key)
117        .map(|p| p.id)
118}
119
120pub fn create_preset(file: &mut PresetsFile, name: Option<String>, fields: LlmPresetFields) -> u32 {
121    let id = file.next_id;
122    file.next_id += 1;
123    let name = name.unwrap_or_else(|| format!("{}/{}", fields.provider, fields.model));
124    file.presets.push(Preset { id, name, fields });
125    id
126}
127
128pub fn delete_preset(file: &mut PresetsFile, id: u32) {
129    file.presets.retain(|p| p.id != id);
130    file.fallback.order.retain(|&fid| fid != id);
131}
132
133pub fn rename_preset(file: &mut PresetsFile, id: u32, new_name: String) {
134    if let Some(p) = file.presets.iter_mut().find(|p| p.id == id) {
135        p.name = new_name;
136    }
137}
138
139pub fn duplicate_preset(file: &mut PresetsFile, id: u32) -> Result<u32> {
140    let preset = file
141        .presets
142        .iter()
143        .find(|p| p.id == id)
144        .context("Preset not found")?
145        .clone();
146    let new_name = format!("{} (copy)", preset.name);
147    let new_id = create_preset(file, Some(new_name), preset.fields);
148    Ok(new_id)
149}
150
151/// Export presets as standalone TOML. If `!include_keys`, api_key is replaced with "".
152pub fn export_presets(file: &PresetsFile, ids: &[u32], include_keys: bool) -> Result<String> {
153    let mut export = PresetsFile {
154        next_id: 0,
155        presets: Vec::new(),
156        fallback: FallbackConfig::default(),
157    };
158    for &id in ids {
159        if let Some(p) = file.presets.iter().find(|p| p.id == id) {
160            let mut preset = p.clone();
161            preset.id = 0; // IDs are reassigned on import
162            if !include_keys {
163                preset.fields.api_key = String::new();
164            }
165            export.presets.push(preset);
166        }
167    }
168    toml::to_string_pretty(&export).context("Failed to serialize presets for export")
169}
170
171/// Import presets from TOML string. Deduplicates against existing. Returns count imported.
172pub fn import_presets(file: &mut PresetsFile, data: &str) -> Result<usize> {
173    let imported: PresetsFile =
174        toml::from_str(data).context("Failed to parse imported presets data")?;
175    let mut count = 0;
176    for p in imported.presets {
177        if find_duplicate(file, &p.fields).is_some() {
178            continue;
179        }
180        create_preset(file, Some(p.name), p.fields);
181        count += 1;
182    }
183    Ok(count)
184}
185
186fn preset_display(p: &Preset) -> String {
187    let key_status = if p.fields.api_key.is_empty() {
188        "no key"
189    } else {
190        "key set"
191    };
192    format!(
193        "{} ({}/{}, {})",
194        p.name, p.fields.provider, p.fields.model, key_status
195    )
196}
197
198pub fn interactive_presets() -> Result<()> {
199    loop {
200        let mut file = load_presets()?;
201
202        if file.presets.is_empty() {
203            println!("\n{}", "No presets found.".dimmed());
204        } else {
205            println!("\n{}", "Presets:".cyan().bold());
206            for p in &file.presets {
207                println!("  [{}] {}", p.id, preset_display(p));
208            }
209        }
210
211        let mut choices = vec!["Create new preset"];
212        if !file.presets.is_empty() {
213            choices.push("Manage existing preset...");
214            choices.push("Export presets");
215        }
216        choices.push("Import presets");
217        choices.push("Back");
218
219        let action = match Select::new("Presets:", choices).prompt() {
220            Ok(a) => a,
221            Err(_) => break,
222        };
223
224        match action {
225            "Create new preset" => {
226                let provider = Text::new("Provider:")
227                    .with_default("groq")
228                    .prompt()
229                    .unwrap_or_default();
230                let default_model = crate::provider::default_model_for(&provider);
231                let model = Text::new("Model:")
232                    .with_default(if default_model.is_empty() {
233                        ""
234                    } else {
235                        default_model
236                    })
237                    .prompt()
238                    .unwrap_or_default();
239                let api_key = Text::new("API Key:").prompt().unwrap_or_default();
240                let api_url = Text::new("API URL (blank for auto):")
241                    .prompt()
242                    .unwrap_or_default();
243                let api_headers = Text::new("API Headers (blank for auto):")
244                    .prompt()
245                    .unwrap_or_default();
246                let name = Text::new("Preset name (blank for auto):")
247                    .prompt()
248                    .unwrap_or_default();
249                let name = if name.is_empty() { None } else { Some(name) };
250
251                let fields = LlmPresetFields {
252                    provider,
253                    model,
254                    api_key,
255                    api_url,
256                    api_headers,
257                };
258                if let Some(dup_id) = find_duplicate(&file, &fields) {
259                    println!(
260                        "  {} Duplicate of existing preset [{}]",
261                        "note:".yellow().bold(),
262                        dup_id
263                    );
264                    continue;
265                }
266                let id = create_preset(&mut file, name, fields);
267                save_presets(&file)?;
268                println!("  {} Created preset [{}]", "done!".green().bold(), id);
269            }
270            "Manage existing preset..." => {
271                let options: Vec<String> = file.presets.iter().map(preset_display).collect();
272                let Ok(choice) = Select::new("Select preset:", options.clone()).prompt() else {
273                    continue;
274                };
275                let idx = options.iter().position(|o| o == &choice).unwrap();
276                let selected_id = file.presets[idx].id;
277
278                let manage_choices = vec!["Rename", "Duplicate", "Delete", "Back"];
279                let Ok(manage_action) = Select::new("Action:", manage_choices).prompt() else {
280                    continue;
281                };
282
283                match manage_action {
284                    "Rename" => {
285                        if let Ok(new_name) = Text::new("New name:").prompt() {
286                            rename_preset(&mut file, selected_id, new_name);
287                            save_presets(&file)?;
288                            println!("  {}", "Renamed.".green().bold());
289                        }
290                    }
291                    "Duplicate" => {
292                        let new_id = duplicate_preset(&mut file, selected_id)?;
293                        save_presets(&file)?;
294                        println!("  {} Duplicated as [{}]", "done!".green().bold(), new_id);
295                    }
296                    "Delete" => {
297                        let confirm = ui::confirm("Delete this preset?", false);
298                        if confirm {
299                            delete_preset(&mut file, selected_id);
300                            save_presets(&file)?;
301                            println!("  {}", "Deleted.".green().bold());
302                        }
303                    }
304                    _ => {}
305                }
306            }
307            "Export presets" => {
308                let include_keys = ui::confirm("Include API keys in export?", false);
309                let ids: Vec<u32> = file.presets.iter().map(|p| p.id).collect();
310                match export_presets(&file, &ids, include_keys) {
311                    Ok(data) => {
312                        println!("\n{}", "Exported TOML:".cyan().bold());
313                        println!("{data}");
314                    }
315                    Err(e) => println!("  {} {}", "error:".red().bold(), e),
316                }
317            }
318            "Import presets" => {
319                println!("Paste TOML data (end with an empty line):");
320                let mut data = String::new();
321                loop {
322                    let mut line = String::new();
323                    if std::io::stdin().read_line(&mut line).is_err() {
324                        break;
325                    }
326                    if line.trim().is_empty() {
327                        break;
328                    }
329                    data.push_str(&line);
330                }
331                match import_presets(&mut file, &data) {
332                    Ok(count) => {
333                        save_presets(&file)?;
334                        println!("  {} Imported {} preset(s)", "done!".green().bold(), count);
335                    }
336                    Err(e) => println!("  {} {}", "error:".red().bold(), e),
337                }
338            }
339            _ => break,
340        }
341    }
342    Ok(())
343}
344
345pub fn interactive_fallback_order() -> Result<()> {
346    loop {
347        let mut file = load_presets()?;
348
349        println!("\n{}", "Fallback Order:".cyan().bold());
350        if file.fallback.order.is_empty() {
351            println!("  {}", "(empty)".dimmed());
352        } else {
353            for (i, &id) in file.fallback.order.iter().enumerate() {
354                let name = file
355                    .presets
356                    .iter()
357                    .find(|p| p.id == id)
358                    .map(|p| p.name.as_str())
359                    .unwrap_or("(missing)");
360                println!("  {}. [{}] {}", i + 1, id, name);
361            }
362        }
363
364        let choices = vec![
365            "Add preset",
366            "Remove entry",
367            "Move up",
368            "Move down",
369            "Clear all",
370            "Back",
371        ];
372
373        let action = match Select::new("Configure fallback order:", choices).prompt() {
374            Ok(a) => a,
375            Err(_) => break,
376        };
377
378        match action {
379            "Add preset" => {
380                let available: Vec<&Preset> = file
381                    .presets
382                    .iter()
383                    .filter(|p| !file.fallback.order.contains(&p.id))
384                    .collect();
385                if available.is_empty() {
386                    println!("  {}", "No presets available to add.".dimmed());
387                    continue;
388                }
389                let options: Vec<String> = available.iter().map(|p| preset_display(p)).collect();
390                if let Ok(choice) = Select::new("Select preset to add:", options.clone()).prompt() {
391                    let idx = options.iter().position(|o| o == &choice).unwrap();
392                    let id = available[idx].id;
393                    file.fallback.order.push(id);
394                    save_presets(&file)?;
395                    println!("  {}", "Added.".green().bold());
396                }
397            }
398            "Remove entry" => {
399                if file.fallback.order.is_empty() {
400                    continue;
401                }
402                let options: Vec<String> = file
403                    .fallback
404                    .order
405                    .iter()
406                    .map(|&id| {
407                        let name = file
408                            .presets
409                            .iter()
410                            .find(|p| p.id == id)
411                            .map(|p| p.name.as_str())
412                            .unwrap_or("(missing)");
413                        format!("[{}] {}", id, name)
414                    })
415                    .collect();
416                if let Ok(choice) = Select::new("Select entry to remove:", options.clone()).prompt()
417                {
418                    let idx = options.iter().position(|o| o == &choice).unwrap();
419                    file.fallback.order.remove(idx);
420                    save_presets(&file)?;
421                    println!("  {}", "Removed.".green().bold());
422                }
423            }
424            "Move up" => {
425                if file.fallback.order.len() < 2 {
426                    continue;
427                }
428                let options: Vec<String> = file
429                    .fallback
430                    .order
431                    .iter()
432                    .enumerate()
433                    .map(|(i, &id)| {
434                        let name = file
435                            .presets
436                            .iter()
437                            .find(|p| p.id == id)
438                            .map(|p| p.name.as_str())
439                            .unwrap_or("(missing)");
440                        format!("{}. [{}] {}", i + 1, id, name)
441                    })
442                    .collect();
443                if let Ok(choice) = Select::new("Move up:", options.clone()).prompt() {
444                    let idx = options.iter().position(|o| o == &choice).unwrap();
445                    if idx > 0 {
446                        file.fallback.order.swap(idx, idx - 1);
447                        save_presets(&file)?;
448                        println!("  {}", "Moved.".green().bold());
449                    }
450                }
451            }
452            "Move down" => {
453                if file.fallback.order.len() < 2 {
454                    continue;
455                }
456                let options: Vec<String> = file
457                    .fallback
458                    .order
459                    .iter()
460                    .enumerate()
461                    .map(|(i, &id)| {
462                        let name = file
463                            .presets
464                            .iter()
465                            .find(|p| p.id == id)
466                            .map(|p| p.name.as_str())
467                            .unwrap_or("(missing)");
468                        format!("{}. [{}] {}", i + 1, id, name)
469                    })
470                    .collect();
471                if let Ok(choice) = Select::new("Move down:", options.clone()).prompt() {
472                    let idx = options.iter().position(|o| o == &choice).unwrap();
473                    if idx < file.fallback.order.len() - 1 {
474                        file.fallback.order.swap(idx, idx + 1);
475                        save_presets(&file)?;
476                        println!("  {}", "Moved.".green().bold());
477                    }
478                }
479            }
480            "Clear all" => {
481                let confirm = ui::confirm("Clear entire fallback order?", false);
482                if confirm {
483                    file.fallback.order.clear();
484                    save_presets(&file)?;
485                    println!("  {}", "Cleared.".green().bold());
486                }
487            }
488            _ => break,
489        }
490    }
491    Ok(())
492}
493
494/// Select and load a preset into the config. Returns (preset_id, snapshot) if loaded.
495pub fn select_and_load_preset(cfg: &mut AppConfig) -> Result<Option<(u32, LlmPresetFields)>> {
496    let file = load_presets()?;
497    if file.presets.is_empty() {
498        println!("  {}", "No presets found.".dimmed());
499        return Ok(None);
500    }
501    let options: Vec<String> = file.presets.iter().map(preset_display).collect();
502    match Select::new("Select preset to load:", options.clone()).prompt() {
503        Ok(choice) => {
504            let idx = options.iter().position(|o| o == &choice).unwrap();
505            let preset = &file.presets[idx];
506            let snapshot = preset.fields.clone();
507            apply_preset_to_config(cfg, preset);
508            println!(
509                "  {} Loaded preset: {}",
510                "done!".green().bold(),
511                preset.name
512            );
513            Ok(Some((preset.id, snapshot)))
514        }
515        Err(_) => Ok(None),
516    }
517}
518
519/// Save current config LLM fields as a new preset.
520pub fn save_current_as_preset(cfg: &AppConfig) -> Result<()> {
521    let fields = fields_from_config(cfg);
522    let mut file = load_presets()?;
523
524    if let Some(dup_id) = find_duplicate(&file, &fields) {
525        println!(
526            "  {} Already saved as preset [{}]",
527            "note:".yellow().bold(),
528            dup_id
529        );
530        return Ok(());
531    }
532
533    let name = Text::new("Preset name (blank for auto):")
534        .prompt()
535        .unwrap_or_default();
536    let name = if name.is_empty() { None } else { Some(name) };
537
538    let id = create_preset(&mut file, name, fields);
539    save_presets(&file)?;
540    println!("  {} Created preset [{}]", "done!".green().bold(), id);
541    Ok(())
542}
543
544/// Check if current config fields differ from the loaded preset snapshot.
545pub fn preset_is_modified(cfg: &AppConfig, snapshot: &LlmPresetFields) -> bool {
546    let current = fields_from_config(cfg);
547    current != *snapshot
548}
549
550/// Prompt user to update the loaded preset with current config fields.
551pub fn prompt_update_preset(cfg: &AppConfig, preset_id: u32) -> Result<()> {
552    let should_update = ui::confirm("Update the loaded preset too?", false);
553    if !should_update {
554        return Ok(());
555    }
556    let mut file = load_presets()?;
557    if let Some(p) = file.presets.iter_mut().find(|p| p.id == preset_id) {
558        p.fields = fields_from_config(cfg);
559        save_presets(&file)?;
560        println!("  {} Preset updated.", "done!".green().bold());
561    }
562    Ok(())
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568
569    fn sample_fields() -> LlmPresetFields {
570        LlmPresetFields {
571            provider: "groq".into(),
572            model: "llama-3.3-70b-versatile".into(),
573            api_key: "test-key".into(),
574            api_url: String::new(),
575            api_headers: String::new(),
576        }
577    }
578
579    #[test]
580    fn test_create_preset_auto_name() {
581        let mut file = PresetsFile::default();
582        let fields = sample_fields();
583        let id = create_preset(&mut file, None, fields);
584        assert_eq!(id, 0);
585        assert_eq!(file.presets.len(), 1);
586        assert_eq!(file.presets[0].name, "groq/llama-3.3-70b-versatile");
587        assert_eq!(file.next_id, 1);
588    }
589
590    #[test]
591    fn test_create_preset_custom_name() {
592        let mut file = PresetsFile::default();
593        let id = create_preset(&mut file, Some("My Preset".into()), sample_fields());
594        assert_eq!(file.presets[0].name, "My Preset");
595        assert_eq!(id, 0);
596    }
597
598    #[test]
599    fn test_find_duplicate() {
600        let mut file = PresetsFile::default();
601        let fields = sample_fields();
602        create_preset(&mut file, None, fields.clone());
603        assert_eq!(find_duplicate(&file, &fields), Some(0));
604
605        let different = LlmPresetFields {
606            provider: "openai".into(),
607            ..fields
608        };
609        assert_eq!(find_duplicate(&file, &different), None);
610    }
611
612    #[test]
613    fn test_find_duplicate_ignores_headers() {
614        let mut file = PresetsFile::default();
615        let fields = sample_fields();
616        create_preset(&mut file, None, fields.clone());
617
618        let with_headers = LlmPresetFields {
619            api_headers: "X-Custom: value".into(),
620            ..fields
621        };
622        assert_eq!(find_duplicate(&file, &with_headers), Some(0));
623    }
624
625    #[test]
626    fn test_delete_preset_removes_from_fallback() {
627        let mut file = PresetsFile::default();
628        let id = create_preset(&mut file, None, sample_fields());
629        file.fallback.order.push(id);
630
631        delete_preset(&mut file, id);
632        assert!(file.presets.is_empty());
633        assert!(file.fallback.order.is_empty());
634    }
635
636    #[test]
637    fn test_rename_preset() {
638        let mut file = PresetsFile::default();
639        let id = create_preset(&mut file, None, sample_fields());
640        rename_preset(&mut file, id, "New Name".into());
641        assert_eq!(file.presets[0].name, "New Name");
642    }
643
644    #[test]
645    fn test_duplicate_preset() {
646        let mut file = PresetsFile::default();
647        let id = create_preset(&mut file, Some("Original".into()), sample_fields());
648        let new_id = duplicate_preset(&mut file, id).unwrap();
649        assert_eq!(file.presets.len(), 2);
650        assert_eq!(file.presets[1].name, "Original (copy)");
651        assert_ne!(id, new_id);
652    }
653
654    #[test]
655    fn test_export_without_keys() {
656        let mut file = PresetsFile::default();
657        let id = create_preset(&mut file, None, sample_fields());
658        let exported = export_presets(&file, &[id], false).unwrap();
659        assert!(exported.contains("api_key = \"\""));
660    }
661
662    #[test]
663    fn test_export_with_keys() {
664        let mut file = PresetsFile::default();
665        let id = create_preset(&mut file, None, sample_fields());
666        let exported = export_presets(&file, &[id], true).unwrap();
667        assert!(exported.contains("test-key"));
668    }
669
670    #[test]
671    fn test_import_deduplicates() {
672        let mut file = PresetsFile::default();
673        create_preset(&mut file, None, sample_fields());
674
675        let import_data = toml::to_string_pretty(&file).unwrap();
676        let count = import_presets(&mut file, &import_data).unwrap();
677        assert_eq!(count, 0);
678        assert_eq!(file.presets.len(), 1);
679    }
680
681    #[test]
682    fn test_import_new_preset() {
683        let mut file = PresetsFile::default();
684
685        let mut import_file = PresetsFile::default();
686        create_preset(&mut import_file, Some("Imported".into()), sample_fields());
687        let import_data = toml::to_string_pretty(&import_file).unwrap();
688
689        let count = import_presets(&mut file, &import_data).unwrap();
690        assert_eq!(count, 1);
691        assert_eq!(file.presets.len(), 1);
692    }
693
694    #[test]
695    fn test_fields_from_config() {
696        let cfg = AppConfig::default();
697        let fields = fields_from_config(&cfg);
698        assert_eq!(fields.provider, "groq");
699        assert_eq!(fields.model, "llama-3.3-70b-versatile");
700    }
701
702    #[test]
703    fn test_apply_preset_to_config() {
704        let mut cfg = AppConfig::default();
705        let preset = Preset {
706            id: 0,
707            name: "test".into(),
708            fields: LlmPresetFields {
709                provider: "openai".into(),
710                model: "gpt-4o".into(),
711                api_key: "sk-test".into(),
712                api_url: String::new(),
713                api_headers: String::new(),
714            },
715        };
716        apply_preset_to_config(&mut cfg, &preset);
717        assert_eq!(cfg.provider, "openai");
718        assert_eq!(cfg.model, "gpt-4o");
719        assert_eq!(cfg.api_key, "sk-test");
720    }
721
722    #[test]
723    fn test_preset_is_modified() {
724        let cfg = AppConfig::default();
725        let snapshot = fields_from_config(&cfg);
726        assert!(!preset_is_modified(&cfg, &snapshot));
727
728        let mut modified_cfg = cfg.clone();
729        modified_cfg.provider = "openai".into();
730        assert!(preset_is_modified(&modified_cfg, &snapshot));
731    }
732
733    #[test]
734    fn test_incrementing_ids() {
735        let mut file = PresetsFile::default();
736        let id1 = create_preset(&mut file, None, sample_fields());
737        let fields2 = LlmPresetFields {
738            provider: "openai".into(),
739            ..sample_fields()
740        };
741        let id2 = create_preset(&mut file, None, fields2);
742        assert_eq!(id1, 0);
743        assert_eq!(id2, 1);
744        assert_eq!(file.next_id, 2);
745    }
746
747    #[test]
748    fn test_preset_display_with_key() {
749        let preset = Preset {
750            id: 1,
751            name: "My Preset".into(),
752            fields: LlmPresetFields {
753                provider: "groq".into(),
754                model: "llama".into(),
755                api_key: "sk-test".into(),
756                api_url: String::new(),
757                api_headers: String::new(),
758            },
759        };
760        let display = preset_display(&preset);
761        assert!(display.contains("My Preset"));
762        assert!(display.contains("groq"));
763        assert!(display.contains("llama"));
764        assert!(display.contains("key set"));
765    }
766
767    #[test]
768    fn test_preset_display_no_key() {
769        let preset = Preset {
770            id: 1,
771            name: "Empty Key".into(),
772            fields: LlmPresetFields {
773                provider: "openai".into(),
774                model: "gpt-4".into(),
775                api_key: String::new(),
776                api_url: String::new(),
777                api_headers: String::new(),
778            },
779        };
780        let display = preset_display(&preset);
781        assert!(display.contains("no key"));
782    }
783
784    #[test]
785    fn test_dedup_key_extracts_correct_fields() {
786        let fields = LlmPresetFields {
787            provider: "groq".into(),
788            model: "llama".into(),
789            api_key: "key123".into(),
790            api_url: "https://api.example.com".into(),
791            api_headers: "X-Custom: value".into(),
792        };
793        let key = dedup_key(&fields);
794        assert_eq!(key.0, "groq");
795        assert_eq!(key.1, "llama");
796        assert_eq!(key.2, "key123");
797        assert_eq!(key.3, "https://api.example.com");
798    }
799
800    #[test]
801    fn test_fallback_config_serde() {
802        let config = FallbackConfig {
803            enabled: true,
804            order: vec![1, 2, 3],
805        };
806        let toml_str = toml::to_string(&config).unwrap();
807        let parsed: FallbackConfig = toml::from_str(&toml_str).unwrap();
808        assert!(parsed.enabled);
809        assert_eq!(parsed.order, vec![1, 2, 3]);
810    }
811
812    #[test]
813    fn test_presets_file_serde_full() {
814        let mut file = PresetsFile::default();
815        create_preset(&mut file, Some("Test".into()), sample_fields());
816        file.fallback.order.push(0);
817
818        let toml_str = toml::to_string_pretty(&file).unwrap();
819        let parsed: PresetsFile = toml::from_str(&toml_str).unwrap();
820
821        assert_eq!(parsed.presets.len(), 1);
822        assert_eq!(parsed.presets[0].name, "Test");
823        assert_eq!(parsed.fallback.order, vec![0]);
824    }
825
826    #[test]
827    fn test_duplicate_preset_preserves_fields() {
828        let mut file = PresetsFile::default();
829        let fields = LlmPresetFields {
830            provider: "anthropic".into(),
831            model: "claude".into(),
832            api_key: "sk-ant".into(),
833            api_url: "https://api.anthropic.com".into(),
834            api_headers: "x-api-key: test".into(),
835        };
836        let id = create_preset(&mut file, Some("Original".into()), fields);
837        let dup_id = duplicate_preset(&mut file, id).unwrap();
838
839        let dup = file.presets.iter().find(|p| p.id == dup_id).unwrap();
840        assert_eq!(dup.fields.provider, "anthropic");
841        assert_eq!(dup.fields.model, "claude");
842        assert_eq!(dup.fields.api_key, "sk-ant");
843    }
844
845    #[test]
846    fn test_rename_nonexistent_preset_does_nothing() {
847        let mut file = PresetsFile::default();
848        create_preset(&mut file, Some("Original".into()), sample_fields());
849        rename_preset(&mut file, 999, "New Name".into());
850        // Should not panic, original unchanged
851        assert_eq!(file.presets[0].name, "Original");
852    }
853
854    #[test]
855    fn test_export_presets_multiple() {
856        let mut file = PresetsFile::default();
857        let id1 = create_preset(&mut file, Some("First".into()), sample_fields());
858        let fields2 = LlmPresetFields {
859            provider: "openai".into(),
860            ..sample_fields()
861        };
862        let id2 = create_preset(&mut file, Some("Second".into()), fields2);
863
864        let exported = export_presets(&file, &[id1, id2], true).unwrap();
865        assert!(exported.contains("First"));
866        assert!(exported.contains("Second"));
867    }
868
869    #[test]
870    fn test_import_presets_multiple() {
871        let mut import_file = PresetsFile::default();
872        create_preset(&mut import_file, Some("Import1".into()), sample_fields());
873        let fields2 = LlmPresetFields {
874            provider: "openai".into(),
875            ..sample_fields()
876        };
877        create_preset(&mut import_file, Some("Import2".into()), fields2);
878
879        let import_data = toml::to_string_pretty(&import_file).unwrap();
880
881        let mut file = PresetsFile::default();
882        let count = import_presets(&mut file, &import_data).unwrap();
883
884        assert_eq!(count, 2);
885        assert_eq!(file.presets.len(), 2);
886    }
887
888    #[test]
889    fn test_delete_preset_nonexistent() {
890        let mut file = PresetsFile::default();
891        create_preset(&mut file, None, sample_fields());
892        delete_preset(&mut file, 999); // Non-existent ID
893        assert_eq!(file.presets.len(), 1); // Original still there
894    }
895
896    #[test]
897    fn test_llm_preset_fields_clone() {
898        let fields = sample_fields();
899        let cloned = fields.clone();
900        assert_eq!(fields.provider, cloned.provider);
901        assert_eq!(fields.model, cloned.model);
902    }
903}