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