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(
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
159pub 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; 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
179pub 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
512pub 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
537pub 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
562pub fn preset_is_modified(cfg: &AppConfig, snapshot: &LlmPresetFields) -> bool {
564 let current = fields_from_config(cfg);
565 current != *snapshot
566}
567
568pub 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 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); assert_eq!(file.presets.len(), 1); }
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}