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 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
102fn 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
151pub 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; 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
171pub 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
494pub 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
519pub 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
544pub fn preset_is_modified(cfg: &AppConfig, snapshot: &LlmPresetFields) -> bool {
546 let current = fields_from_config(cfg);
547 current != *snapshot
548}
549
550pub 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 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); assert_eq!(file.presets.len(), 1); }
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}