1use anyhow::{bail, Context, Result};
10use chrono::Utc;
11use directories::ProjectDirs;
12use serde::{Deserialize, Serialize};
13use std::path::PathBuf;
14
15use zeroize::{Zeroize, ZeroizeOnDrop};
16
17use unicode_normalization::UnicodeNormalization;
18
19use crate::errors::Context7Error;
20use crate::i18n::{t, Message};
21
22#[derive(Debug, Serialize, Deserialize, Clone)]
28pub struct StoredKey {
29 pub value: String,
31 pub added_at: String,
33}
34
35#[derive(Debug, Serialize, Deserialize, Default)]
39pub struct FileConfig {
40 pub schema_version: u32,
42 #[serde(default)]
44 pub keys: Vec<StoredKey>,
45}
46
47#[derive(Clone, Zeroize, ZeroizeOnDrop)]
55pub struct ApiKey(String);
56
57impl ApiKey {
58 pub fn new(value: String) -> Self {
60 Self(value)
61 }
62
63 pub fn value(&self) -> &str {
65 &self.0
66 }
67}
68
69impl PartialEq<&str> for ApiKey {
70 fn eq(&self, other: &&str) -> bool {
71 self.0 == *other
72 }
73}
74
75impl std::fmt::Debug for ApiKey {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 write!(f, "ApiKey({})", mask_key(self.value()))
78 }
79}
80
81impl std::fmt::Display for ApiKey {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 write!(f, "{}", mask_key(self.value()))
84 }
85}
86
87#[must_use]
94pub fn apply_600_permissions(path: &std::path::Path) -> Result<()> {
95 #[cfg(unix)]
96 {
97 use std::os::unix::fs::PermissionsExt;
98 let mut perms = std::fs::metadata(path)
99 .with_context(|| format!("Failed to read metadata of: {}", path.display()))?
100 .permissions();
101 perms.set_mode(0o600);
102 std::fs::set_permissions(path, perms)
103 .with_context(|| format!("Failed to set permissions on: {}", path.display()))?;
104 }
105 #[cfg(not(unix))]
106 let _ = path;
107 Ok(())
108}
109
110fn resolve_home_override() -> Option<PathBuf> {
118 let home = std::env::var("CONTEXT7_HOME").ok()?;
119 if home.is_empty() {
120 return None;
121 }
122 let base = PathBuf::from(&home);
123 if base
125 .components()
126 .any(|c| c == std::path::Component::ParentDir)
127 {
128 tracing::warn!(
129 "CONTEXT7_HOME='{}' rejected (path traversal) — using XDG default",
130 home
131 );
132 return None;
133 }
134 let reserved_names = [
136 "CON", "PRN", "AUX", "NUL", "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
137 "COM8", "COM9", "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8",
138 "LPT9",
139 ];
140 for component in base.components() {
141 if let std::path::Component::Normal(name) = component {
142 let name_upper = name.to_string_lossy().to_uppercase();
143 let base_name = name_upper.split('.').next().unwrap_or("");
145 if reserved_names.contains(&base_name) {
146 tracing::warn!(
147 "CONTEXT7_HOME='{}' rejected (Windows reserved name '{}') — using XDG default",
148 home, base_name
149 );
150 return None;
151 }
152 }
153 }
154 Some(base)
155}
156
157#[must_use]
164pub fn discover_config_path() -> Option<PathBuf> {
165 let path = if let Some(base) = resolve_home_override() {
166 base.join("context7").join("config.toml")
167 } else {
168 ProjectDirs::from("", "", "context7")?
169 .config_dir()
170 .join("config.toml")
171 };
172 let path_str = path.to_string_lossy().nfc().collect::<String>();
174 Some(PathBuf::from(path_str))
175}
176
177#[must_use]
184pub fn discover_xdg_log_paths() -> Option<PathBuf> {
185 let path = if let Some(base) = resolve_home_override() {
186 base.join("context7").join("logs")
187 } else {
188 let dirs = ProjectDirs::from("", "", "context7")?;
189 #[cfg(target_os = "linux")]
191 {
192 dirs.state_dir()
193 .unwrap_or_else(|| dirs.data_local_dir())
194 .to_path_buf()
195 }
196 #[cfg(not(target_os = "linux"))]
197 {
198 dirs.data_local_dir().to_path_buf()
199 }
200 };
201 let path_str = path.to_string_lossy().nfc().collect::<String>();
203 Some(PathBuf::from(path_str))
204}
205
206#[must_use]
215pub fn read_env_var_key() -> Option<Vec<String>> {
216 std::env::var("CONTEXT7_API_KEYS")
217 .ok()
218 .map(|value| {
219 let estimate = value.matches(',').count() + 1;
220 let mut keys = Vec::with_capacity(estimate);
221 for s in value.split(',') {
222 let trimmed = s.trim().to_string();
223 if !trimmed.is_empty() {
224 keys.push(trimmed);
225 }
226 }
227 keys
228 })
229 .filter(|v| !v.is_empty())
230}
231
232#[must_use]
237pub fn read_xdg_config() -> Result<Option<Vec<String>>> {
238 let path = match discover_config_path() {
239 Some(p) => p,
240 None => return Ok(None),
241 };
242
243 if !path.exists() {
244 return Ok(None);
245 }
246
247 let content = std::fs::read_to_string(&path)
248 .with_context(|| format!("Failed to read XDG configuration at: {}", path.display()))?;
249
250 let config: FileConfig = toml::from_str(&content)
251 .with_context(|| format!("Invalid TOML at: {}", path.display()))?;
252
253 let keys: Vec<String> = config
254 .keys
255 .into_iter()
256 .map(|c| c.value)
257 .filter(|v| !v.is_empty())
258 .collect();
259
260 if keys.is_empty() {
261 Ok(None)
262 } else {
263 Ok(Some(keys))
264 }
265}
266
267#[must_use]
272pub fn read_env_cwd() -> Option<Vec<String>> {
273 let path = std::env::current_dir().ok().map(|d| d.join(".env"))?;
274
275 if !path.exists() {
276 return None;
277 }
278
279 std::fs::read_to_string(&path)
280 .ok()
281 .and_then(|content| extract_env_keys(&content).ok())
282}
283
284#[must_use]
295pub fn read_compile_time_env() -> Option<Vec<String>> {
296 option_env!("CONTEXT7_API_KEYS").map(|value| {
297 let estimate = value.matches(',').count() + 1;
298 let mut keys = Vec::with_capacity(estimate);
299 for s in value.split(',') {
300 let trimmed = s.trim().to_string();
301 if !trimmed.is_empty() {
302 keys.push(trimmed);
303 }
304 }
305 keys
306 })
307}
308
309#[must_use]
318pub fn load_api_keys() -> Result<Vec<ApiKey>> {
319 use tracing::{info, warn};
320
321 if let Some(keys) = read_env_var_key() {
323 info!("Keys loaded from CONTEXT7_API_KEYS environment variable");
324 return Ok(keys.into_iter().map(ApiKey::new).collect());
325 }
326
327 match read_xdg_config() {
329 Ok(Some(keys)) => {
330 info!("Keys loaded from XDG configuration");
331 return Ok(keys.into_iter().map(ApiKey::new).collect());
332 }
333 Ok(None) => {}
334 Err(e) => {
335 warn!("Failed to read XDG configuration (continuing): {}", e);
336 }
337 }
338
339 if let Some(keys) = read_env_cwd() {
341 info!(
342 "Starting context7 with {} API keys available",
343 keys.len()
344 );
345 return Ok(keys.into_iter().map(ApiKey::new).collect());
346 }
347
348 if let Some(keys) = read_compile_time_env() {
350 info!("Keys loaded from compile-time CONTEXT7_API_KEYS");
351 return Ok(keys.into_iter().map(ApiKey::new).collect());
352 }
353
354 bail!(t(Message::NoKeyConfigured))
355}
356
357#[must_use]
364pub fn write_xdg_config(new_key: &str) -> Result<PathBuf> {
365 let path = discover_config_path()
366 .context("System does not support XDG directories — cannot save configuration")?;
367
368 if let Some(parent) = path.parent() {
370 std::fs::create_dir_all(parent)
371 .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
372 }
373
374 let mut config = if path.exists() {
376 let content = std::fs::read_to_string(&path)
377 .with_context(|| format!("Failed to read existing config: {}", path.display()))?;
378 toml::from_str::<FileConfig>(&content)
379 .with_context(|| format!("Invalid TOML at: {}", path.display()))?
380 } else {
381 FileConfig {
382 schema_version: 1,
383 keys: Vec::new(),
384 }
385 };
386
387 let already_exists = config.keys.iter().any(|c| c.value == new_key);
389 if !already_exists {
390 config.keys.push(StoredKey {
391 value: new_key.to_string(),
392 added_at: Utc::now().to_rfc3339(),
393 });
394 }
395
396 let toml_str =
398 toml::to_string_pretty(&config).context("Failed to serialise configuration to TOML")?;
399 std::fs::write(&path, &toml_str)
400 .with_context(|| format!("Failed to write config at: {}", path.display()))?;
401
402 apply_600_permissions(&path)?;
403
404 Ok(path)
405}
406
407#[must_use]
412pub fn read_xdg_config_raw() -> Result<Option<FileConfig>> {
413 let path = match discover_config_path() {
414 Some(p) => p,
415 None => return Ok(None),
416 };
417
418 if !path.exists() {
419 return Ok(None);
420 }
421
422 let content = std::fs::read_to_string(&path)
423 .with_context(|| format!("Failed to read XDG configuration at: {}", path.display()))?;
424
425 let config: FileConfig = toml::from_str(&content)
426 .with_context(|| format!("Invalid TOML at: {}", path.display()))?;
427
428 Ok(Some(config))
429}
430
431#[must_use]
436pub fn write_file_config(config: &FileConfig) -> Result<PathBuf> {
437 let path = discover_config_path()
438 .context("System does not support XDG directories — cannot save configuration")?;
439
440 if let Some(parent) = path.parent() {
441 std::fs::create_dir_all(parent)
442 .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
443 }
444
445 let toml_str =
446 toml::to_string_pretty(config).context("Failed to serialise configuration to TOML")?;
447 std::fs::write(&path, &toml_str)
448 .with_context(|| format!("Failed to write config at: {}", path.display()))?;
449
450 apply_600_permissions(&path)?;
451
452 Ok(path)
453}
454
455#[must_use]
464pub fn mask_key(key: &str) -> String {
465 let n_chars = key.chars().count();
466 let prefix_len = 12;
467 let suffix_len = 4;
468 if n_chars <= prefix_len + suffix_len {
469 return "***".to_string();
470 }
471 let prefix: String = key.chars().take(prefix_len).collect();
472 let suffix: String = key
473 .chars()
474 .rev()
475 .take(suffix_len)
476 .collect::<String>()
477 .chars()
478 .rev()
479 .collect();
480 format!("{}...{}", prefix, suffix)
481}
482
483#[must_use]
489pub fn extract_env_keys(content: &str) -> Result<Vec<String>> {
490 let keys: Vec<String> = content
491 .lines()
492 .filter_map(|line| {
493 let line_no_comment = line.split('#').next().unwrap_or("").trim();
495 line_no_comment
496 .strip_prefix("CONTEXT7_API=")
497 .map(|value| {
498 value
500 .trim()
501 .trim_matches('"')
502 .trim_matches('\'')
503 .to_string()
504 })
505 .filter(|v| !v.is_empty())
506 })
507 .collect();
508
509 if keys.is_empty() {
510 bail!(t(Message::NoContext7KeyInFile));
511 }
512
513 Ok(keys)
514}
515
516#[must_use]
523pub fn cmd_keys_add(key: &str) -> Result<()> {
524 let trimmed_key = key.trim();
525 if trimmed_key.is_empty() {
526 crate::output::print_invalid_empty_key();
527 bail!(Context7Error::KeysOperationFailed);
528 }
529 if !trimmed_key.starts_with("ctx7sk-") || trimmed_key.len() < 16 {
530 crate::output::print_key_format_warning();
531 }
532 if let Some(config) = read_xdg_config_raw()? {
534 if config.keys.iter().any(|c| c.value == trimmed_key) {
535 crate::output::print_key_already_existed();
536 return Ok(());
537 }
538 }
539 let path = write_xdg_config(trimmed_key)?;
540 crate::output::print_key_added(&path);
541 Ok(())
542}
543
544#[must_use]
548pub fn cmd_keys_list(json: bool) -> Result<()> {
549 match read_xdg_config_raw()? {
550 None => {
551 if json {
552 crate::output::print_empty_json_array();
553 } else {
554 crate::output::print_no_keys();
555 }
556 }
557 Some(config) if config.keys.is_empty() => {
558 if json {
559 crate::output::print_empty_json_array();
560 } else {
561 crate::output::print_no_keys();
562 }
563 }
564 Some(config) => {
565 if json {
566 let mut masked: Vec<serde_json::Value> = Vec::with_capacity(config.keys.len());
567 masked.extend(config.keys.iter().enumerate().map(|(i, k)| {
568 serde_json::json!({
569 "index": i + 1,
570 "masked_key": mask_key(&k.value),
571 "added_at": crate::output::format_added_at_display(&k.added_at)
572 })
573 }));
574 crate::output::print_raw_json(
575 &serde_json::to_string_pretty(&masked).with_context(|| {
576 crate::i18n::t(crate::i18n::Message::JsonSerialiseFailure)
577 })?,
578 );
579 } else {
580 crate::output::print_masked_keys(&config.keys, mask_key);
581 }
582 }
583 }
584 Ok(())
585}
586
587#[must_use]
589pub fn cmd_keys_remove(index: usize) -> Result<()> {
590 let mut config = match read_xdg_config_raw()? {
591 None => {
592 crate::output::print_no_keys_to_remove();
593 bail!(Context7Error::KeysOperationFailed);
594 }
595 Some(c) if c.keys.is_empty() => {
596 crate::output::print_no_keys_to_remove();
597 bail!(Context7Error::KeysOperationFailed);
598 }
599 Some(c) => c,
600 };
601
602 if index == 0 || index > config.keys.len() {
603 crate::output::print_invalid_index(index, config.keys.len());
604 bail!(Context7Error::KeysOperationFailed);
605 }
606
607 let removed = config.keys.remove(index - 1);
608 write_file_config(&config)?;
609 crate::output::print_key_removed(&mask_key(&removed.value));
610 Ok(())
611}
612
613#[must_use]
615pub fn cmd_keys_clear(yes: bool) -> Result<()> {
616 if !yes && !crate::output::confirm_clear()? {
617 crate::output::print_operation_cancelled();
618 return Ok(());
619 }
620
621 let config = FileConfig {
622 schema_version: 1,
623 keys: Vec::new(),
624 };
625 write_file_config(&config)?;
626 crate::output::print_keys_removed();
627 Ok(())
628}
629
630#[must_use]
632#[allow(clippy::unnecessary_wraps)]
633pub fn cmd_keys_path() -> Result<()> {
634 match discover_config_path() {
635 Some(path) => crate::output::print_config_path(&path),
636 None => crate::output::print_xdg_unsupported(),
637 }
638 Ok(())
639}
640
641#[must_use]
645pub fn cmd_keys_import(file: &std::path::Path) -> Result<()> {
646 let content = std::fs::read_to_string(file)
647 .with_context(|| format!("Failed to read file: {}", file.display()))?;
648
649 let keys =
650 extract_env_keys(&content).with_context(|| format!("File: {}", file.display()))?;
651
652 let total = keys.len();
653 let mut imported = 0usize;
654
655 for key in &keys {
656 write_xdg_config(key)?;
657 imported += 1;
658 }
659
660 crate::output::print_import_completed(imported, total);
661 Ok(())
662}
663
664#[must_use]
668pub fn cmd_keys_export() -> Result<()> {
669 match read_xdg_config_raw()? {
670 None => {}
671 Some(config) if config.keys.is_empty() => {}
672 Some(config) => {
673 for key in &config.keys {
674 crate::output::print_exported_key(&key.value);
675 }
676 }
677 }
678 Ok(())
679}
680
681#[cfg(test)]
684mod tests {
685 use super::*;
686
687 fn read_toml_config_from_path(path: &std::path::Path) -> Result<FileConfig> {
691 let content = std::fs::read_to_string(path)
692 .with_context(|| format!("Failed to read: {}", path.display()))?;
693 toml::from_str(&content)
694 .with_context(|| format!("Invalid TOML at: {}", path.display()))
695 }
696
697 #[test]
700 fn test_env_parsing_with_multiple_equal_keys() {
701 let mut content = String::new();
702 for i in 0..17 {
703 content.push_str(&format!("CONTEXT7_API=ctx7sk-key-{:02}\n", i));
704 }
705 let keys = extract_env_keys(&content).expect("Must extract 17 keys without error");
706 assert_eq!(keys.len(), 17, "Must return exactly 17 keys");
707 for (i, key) in keys.iter().enumerate() {
708 assert_eq!(
709 key,
710 &format!("ctx7sk-key-{:02}", i),
711 "Chave {} deve ter o value correto",
712 i
713 );
714 }
715 }
716
717 #[test]
718 fn test_env_parsing_ignores_comments_and_blank_lines() {
719 let content = "# Este é um comentário\n\
720 CONTEXT7_API=ctx7sk-key-valida-01\n\
721 \n\
722 # Outro comentário\n\
723 CONTEXT7_API=ctx7sk-key-valida-02\n\
724 \n";
725 let keys = extract_env_keys(content).expect("Must extract keys without error");
726 assert_eq!(keys.len(), 2, "Must ignore comments and blank lines");
727 assert_eq!(keys[0], "ctx7sk-key-valida-01");
728 assert_eq!(keys[1], "ctx7sk-key-valida-02");
729 }
730
731 #[test]
732 fn test_env_parsing_removes_double_quotes() {
733 let content = "CONTEXT7_API=\"ctx7sk-abc-com-aspas\"\n";
734 let keys = extract_env_keys(content).expect("Must extract key without error");
735 assert_eq!(keys.len(), 1);
736 assert_eq!(
737 keys[0], "ctx7sk-abc-com-aspas",
738 "Must remove double quotes"
739 );
740 }
741
742 #[test]
743 fn test_env_parsing_removes_single_quotes() {
744 let content = "CONTEXT7_API='ctx7sk-abc-aspas-simples'\n";
745 let keys = extract_env_keys(content).expect("Must extract key without error");
746 assert_eq!(keys.len(), 1);
747 assert_eq!(
748 keys[0], "ctx7sk-abc-aspas-simples",
749 "Must remove single quotes"
750 );
751 }
752
753 #[test]
754 fn test_env_parsing_error_when_no_keys() {
755 let content = "# Apenas comentários\n\
756 OUTRA_VAR=value\n\
757 \n";
758 let result = extract_env_keys(content);
759 assert!(
760 result.is_err(),
761 "Must return Err when there are no CONTEXT7_API keys"
762 );
763 let mensagem_erro = result.unwrap_err().to_string();
764 assert!(
765 mensagem_erro.contains("key")
766 || mensagem_erro.contains("CONTEXT7_API")
767 || mensagem_erro.contains("key")
768 || mensagem_erro.contains("API"),
769 "Message de erro deve mencionar CONTEXT7_API, key, key ou API, obteve: {}",
770 mensagem_erro
771 );
772 }
773
774 #[test]
775 fn test_env_parsing_ignores_empty_keys() {
776 let content = "CONTEXT7_API=\n\
777 CONTEXT7_API=ctx7sk-valida\n";
778 let keys = extract_env_keys(content).expect("Must extract key without error");
779 assert_eq!(
780 keys.len(),
781 1,
782 "Must ignore CONTEXT7_API entries without value"
783 );
784 assert_eq!(keys[0], "ctx7sk-valida");
785 }
786
787 #[test]
788 fn test_env_parsing_ignores_inline_comment() {
789 let content = "CONTEXT7_API=ctx7sk-valida # comentário aqui\n";
790 let keys = extract_env_keys(content).expect("Must extract key without error");
791 assert_eq!(keys.len(), 1);
792 assert_eq!(keys[0], "ctx7sk-valida");
793 }
794
795 #[test]
798 fn test_env_parsing_with_crlf_line_endings() {
799 let content = "CONTEXT7_API=ctx7sk-crlf-key-a\r\nCONTEXT7_API=ctx7sk-crlf-key-b\r\n";
801 let keys =
802 extract_env_keys(content).expect("Must extract 2 keys from CRLF content without error");
803 assert_eq!(
804 keys.len(),
805 2,
806 "Must return exactly 2 keys with CRLF"
807 );
808 assert_eq!(
809 keys[0], "ctx7sk-crlf-key-a",
810 "Primeira key não deve conter \\r residual"
811 );
812 assert_eq!(
813 keys[1], "ctx7sk-crlf-key-b",
814 "Segunda key não deve conter \\r residual"
815 );
816 }
817
818 #[test]
819 fn test_env_parsing_with_mixed_line_endings() {
820 let content = "CONTEXT7_API=ctx7sk-mixed-key-a\nCONTEXT7_API=ctx7sk-mixed-key-b\r\n";
822 let keys = extract_env_keys(content)
823 .expect("Must extract 2 keys from mixed LF/CRLF content without error");
824 assert_eq!(
825 keys.len(),
826 2,
827 "Must return exactly 2 keys with mixed line endings"
828 );
829 assert_eq!(
830 keys[0], "ctx7sk-mixed-key-a",
831 "Chave com LF não deve ter \\r residual"
832 );
833 assert_eq!(
834 keys[1], "ctx7sk-mixed-key-b",
835 "Chave com CRLF não deve ter \\r residual"
836 );
837 }
838
839 #[test]
842 fn test_mask_key_long_value_shows_prefix_and_suffix() {
843 let key = "ctx7sk-abc123-def456-ghi789";
844 assert_eq!(key.len(), 27, "Pré-condição: key deve ter 27 chars");
845 let masked = mask_key(key);
846 assert!(
847 masked.starts_with("ctx7sk-abc12"),
848 "Must start with the first 12 chars, got: {}",
849 masked
850 );
851 assert!(
852 masked.ends_with("i789"),
853 "Must end with the last 4 chars, got: {}",
854 masked
855 );
856 assert!(
857 masked.contains("..."),
858 "Must contain '...' between prefix and suffix, got: {}",
859 masked
860 );
861 }
862
863 #[test]
864 fn test_mask_key_short_returns_asterisks() {
865 let exactly_16_chars_key = "ctx7sk-abcdef012";
866 assert_eq!(
867 exactly_16_chars_key.len(),
868 16,
869 "Pré-condição: key deve ter 16 chars"
870 );
871 let masked = mask_key(exactly_16_chars_key);
872 assert_eq!(
873 masked, "***",
874 "16-char key must return '***', got: {}",
875 masked
876 );
877 }
878
879 #[test]
880 fn test_mask_key_empty_returns_asterisks() {
881 let masked = mask_key("");
882 assert_eq!(
883 masked, "***",
884 "Empty key must return '***', got: {}",
885 masked
886 );
887 }
888
889 #[test]
890 fn test_mask_key_exactly_17_chars_masks_correctly() {
891 let key = "ctx7sk-abcdef0123"; assert_eq!(key.len(), 17, "Pré-condição: key deve ter 17 chars");
893 let masked = mask_key(key);
894 assert!(
895 masked.contains("..."),
896 "Chave de 17 chars deve ser masked, obteve: {}",
897 masked
898 );
899 assert_eq!(
900 &masked[..12],
901 &key[..12],
902 "12-char prefix must be preserved"
903 );
904 assert!(
905 masked.ends_with(&key[key.len() - 4..]),
906 "4-char suffix must be preserved"
907 );
908 }
909
910 #[test]
913 #[serial_test::serial]
914 fn test_read_env_var_key_returns_some_when_set() {
915 unsafe {
918 std::env::set_var("CONTEXT7_API_KEYS", "ctx7sk-key-teste-01");
919 }
920 let result = read_env_var_key();
921 unsafe {
922 std::env::remove_var("CONTEXT7_API_KEYS");
923 }
924
925 let keys = result.expect("Must return Some with valid key");
926 assert_eq!(keys.len(), 1, "Must return exactly 1 key");
927 assert_eq!(keys[0], "ctx7sk-key-teste-01");
928 }
929
930 #[test]
931 #[serial_test::serial]
932 fn test_read_env_var_key_accepts_multiple_comma_separated() {
933 unsafe {
935 std::env::set_var(
936 "CONTEXT7_API_KEYS",
937 "ctx7sk-key-a, ctx7sk-key-b , ctx7sk-key-c",
938 );
939 }
940 let result = read_env_var_key();
941 unsafe {
942 std::env::remove_var("CONTEXT7_API_KEYS");
943 }
944
945 let keys = result.expect("Must return Some with multiple keys");
946 assert_eq!(keys.len(), 3, "Must return 3 keys");
947 assert_eq!(keys[0], "ctx7sk-key-a");
948 assert_eq!(keys[1], "ctx7sk-key-b");
949 assert_eq!(keys[2], "ctx7sk-key-c");
950 }
951
952 #[test]
953 #[serial_test::serial]
954 fn test_read_env_var_key_returns_none_when_empty() {
955 unsafe {
957 std::env::set_var("CONTEXT7_API_KEYS", "");
958 }
959 let result = read_env_var_key();
960 unsafe {
961 std::env::remove_var("CONTEXT7_API_KEYS");
962 }
963
964 assert!(
965 result.is_none(),
966 "Must return None when env var is empty"
967 );
968 }
969
970 #[test]
971 #[serial_test::serial]
972 fn test_read_env_var_key_returns_none_when_only_whitespace() {
973 unsafe {
975 std::env::set_var("CONTEXT7_API_KEYS", " , , ");
976 }
977 let result = read_env_var_key();
978 unsafe {
979 std::env::remove_var("CONTEXT7_API_KEYS");
980 }
981
982 assert!(
983 result.is_none(),
984 "Must return None when env var contains only whitespace/commas"
985 );
986 }
987
988 #[test]
989 #[serial_test::serial]
990 fn test_read_env_var_key_returns_none_when_missing() {
991 unsafe {
993 std::env::remove_var("CONTEXT7_API_KEYS");
994 }
995 let result = read_env_var_key();
996
997 assert!(
998 result.is_none(),
999 "Must return None when env var does not exist"
1000 );
1001 }
1002
1003 #[test]
1006 #[serial_test::serial]
1007 fn test_context7_home_rejects_path_traversal() {
1008 let cases = ["../../../etc", "..", "/tmp/../etc"];
1009 for case in &cases {
1010 unsafe {
1012 std::env::set_var("CONTEXT7_HOME", case);
1013 }
1014 let result = discover_config_path();
1015 unsafe {
1016 std::env::remove_var("CONTEXT7_HOME");
1017 }
1018
1019 if let Some(path) = result {
1021 let s = path.to_string_lossy();
1022 assert!(
1023 !s.contains(".."),
1024 "Path traversal '{case}' não deve resultar em path com '..': {s}"
1025 );
1026 }
1027 }
1029 }
1030
1031 #[test]
1034 #[serial_test::serial]
1035 fn test_read_xdg_config_returns_none_for_nonexistent_file() {
1036 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1037 unsafe {
1039 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
1040 }
1041 let result = read_xdg_config();
1042 unsafe {
1043 std::env::remove_var("CONTEXT7_HOME");
1044 }
1045
1046 let value = result.expect("Must return Ok when file does not exist");
1047 assert!(
1048 value.is_none(),
1049 "Must return None when config.toml does not exist"
1050 );
1051 }
1052
1053 #[test]
1054 #[serial_test::serial]
1055 fn test_read_xdg_config_reads_valid_toml_with_multiple_keys() {
1056 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1057 let context7_dir = temp_dir.path().join("context7");
1058 std::fs::create_dir_all(&context7_dir).expect("Must create context7 directory");
1059
1060 let toml_content = r#"schema_version = 1
1061
1062[[keys]]
1063value = "ctx7sk-key-xdg-01"
1064added_at = "2026-01-01T00:00:00+00:00"
1065
1066[[keys]]
1067value = "ctx7sk-key-xdg-02"
1068added_at = "2026-01-02T00:00:00+00:00"
1069"#;
1070 std::fs::write(context7_dir.join("config.toml"), toml_content)
1071 .expect("Must write config.toml");
1072
1073 unsafe {
1075 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
1076 }
1077 let result = read_xdg_config();
1078 unsafe {
1079 std::env::remove_var("CONTEXT7_HOME");
1080 }
1081
1082 let keys = result
1083 .expect("Must return Ok")
1084 .expect("Must return Some with keys");
1085 assert_eq!(keys.len(), 2, "Must return 2 keys");
1086 assert_eq!(keys[0], "ctx7sk-key-xdg-01");
1087 assert_eq!(keys[1], "ctx7sk-key-xdg-02");
1088 }
1089
1090 #[test]
1091 #[serial_test::serial]
1092 fn test_read_xdg_config_returns_err_on_invalid_toml() {
1093 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1094 let context7_dir = temp_dir.path().join("context7");
1095 std::fs::create_dir_all(&context7_dir).expect("Must create context7 directory");
1096
1097 std::fs::write(
1098 context7_dir.join("config.toml"),
1099 "schema_version = INVALIDO\n[[[malformado",
1100 )
1101 .expect("Must write invalid TOML");
1102
1103 unsafe {
1105 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
1106 }
1107 let result = read_xdg_config();
1108 unsafe {
1109 std::env::remove_var("CONTEXT7_HOME");
1110 }
1111
1112 assert!(
1113 result.is_err(),
1114 "Must return Err when TOML is malformed"
1115 );
1116 }
1117
1118 #[test]
1119 #[serial_test::serial]
1120 fn test_read_xdg_config_preserves_key_order() {
1121 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1122 let context7_dir = temp_dir.path().join("context7");
1123 std::fs::create_dir_all(&context7_dir).expect("Must create context7 directory");
1124
1125 let toml_content = r#"schema_version = 1
1126
1127[[keys]]
1128value = "ctx7sk-primeira"
1129added_at = "2026-01-01T00:00:00+00:00"
1130
1131[[keys]]
1132value = "ctx7sk-segunda"
1133added_at = "2026-01-02T00:00:00+00:00"
1134
1135[[keys]]
1136value = "ctx7sk-terceira"
1137added_at = "2026-01-03T00:00:00+00:00"
1138"#;
1139 std::fs::write(context7_dir.join("config.toml"), toml_content)
1140 .expect("Must write config.toml");
1141
1142 unsafe {
1144 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
1145 }
1146 let result = read_xdg_config();
1147 unsafe {
1148 std::env::remove_var("CONTEXT7_HOME");
1149 }
1150
1151 let keys = result
1152 .expect("Must return Ok")
1153 .expect("Must return Some");
1154 assert_eq!(keys[0], "ctx7sk-primeira");
1155 assert_eq!(keys[1], "ctx7sk-segunda");
1156 assert_eq!(keys[2], "ctx7sk-terceira");
1157 }
1158
1159 #[test]
1160 #[serial_test::serial]
1161 fn test_read_xdg_config_returns_none_for_empty_keys() {
1162 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1163 let context7_dir = temp_dir.path().join("context7");
1164 std::fs::create_dir_all(&context7_dir).expect("Must create context7 directory");
1165
1166 let toml_without_keys = "schema_version = 1\n";
1167 std::fs::write(context7_dir.join("config.toml"), toml_without_keys)
1168 .expect("Must write config.toml without keys");
1169
1170 unsafe {
1172 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
1173 }
1174 let result = read_xdg_config();
1175 unsafe {
1176 std::env::remove_var("CONTEXT7_HOME");
1177 }
1178
1179 let value = result.expect("Must return Ok");
1180 assert!(
1181 value.is_none(),
1182 "Must return None when config.toml exists but keys is empty"
1183 );
1184 }
1185
1186 #[test]
1189 #[serial_test::serial]
1190 fn test_write_xdg_config_roundtrip_serialises_and_deserialises() {
1191 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1192 unsafe {
1194 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
1195 }
1196
1197 let path =
1198 write_xdg_config("ctx7sk-roundtrip-01").expect("Must write config without error");
1199
1200 let read_config = read_toml_config_from_path(&path)
1201 .expect("Must read TOML written by write_xdg_config");
1202
1203 unsafe {
1204 std::env::remove_var("CONTEXT7_HOME");
1205 }
1206
1207 assert_eq!(read_config.schema_version, 1, "schema_version must be 1");
1208 assert_eq!(read_config.keys.len(), 1, "Must contain 1 key");
1209 assert_eq!(
1210 read_config.keys[0].value, "ctx7sk-roundtrip-01",
1211 "Valor da key deve ser preservado"
1212 );
1213 assert!(
1214 !read_config.keys[0].added_at.is_empty(),
1215 "added_at não deve ser vazio"
1216 );
1217 }
1218
1219 #[test]
1220 #[serial_test::serial]
1221 fn test_write_xdg_config_creates_parent_dirs_if_missing() {
1222 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1223 let xdg_novo = temp_dir.path().join("xdg_inexistente");
1224 unsafe {
1226 std::env::set_var("CONTEXT7_HOME", &xdg_novo);
1227 }
1228
1229 let result = write_xdg_config("ctx7sk-mkdir-teste");
1230 unsafe {
1231 std::env::remove_var("CONTEXT7_HOME");
1232 }
1233
1234 let path = result.expect("Must create parent directory and write config");
1235 assert!(
1236 path.exists(),
1237 "Arquivo de config deve existir após escrita"
1238 );
1239 }
1240
1241 #[test]
1242 #[serial_test::serial]
1243 fn test_write_xdg_config_does_not_duplicate_existing_key() {
1244 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1245 unsafe {
1247 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
1248 }
1249
1250 write_xdg_config("ctx7sk-unica").expect("First write must work");
1251 write_xdg_config("ctx7sk-unica").expect("Second write must not fail");
1252
1253 let path = discover_config_path().expect("Must have XDG path");
1254 let config = read_toml_config_from_path(&path).expect("Must read config");
1255
1256 unsafe {
1257 std::env::remove_var("CONTEXT7_HOME");
1258 }
1259
1260 assert_eq!(
1261 config.keys.len(),
1262 1,
1263 "Não deve duplicar key já existente — deve ter apenas 1"
1264 );
1265 }
1266
1267 #[test]
1268 #[serial_test::serial]
1269 fn test_write_xdg_config_accumulates_distinct_keys() {
1270 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1271 unsafe {
1273 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
1274 }
1275
1276 write_xdg_config("ctx7sk-key-a").expect("First write must work");
1277 write_xdg_config("ctx7sk-key-b").expect("Second write must work");
1278 write_xdg_config("ctx7sk-key-c").expect("Third write must work");
1279
1280 let path = discover_config_path().expect("Must have XDG path");
1281 let config = read_toml_config_from_path(&path).expect("Must read config");
1282
1283 unsafe {
1284 std::env::remove_var("CONTEXT7_HOME");
1285 }
1286
1287 assert_eq!(config.keys.len(), 3, "Must accumulate 3 distinct keys");
1288 let valores: Vec<&str> = config.keys.iter().map(|c| c.value.as_str()).collect();
1289 assert!(valores.contains(&"ctx7sk-key-a"));
1290 assert!(valores.contains(&"ctx7sk-key-b"));
1291 assert!(valores.contains(&"ctx7sk-key-c"));
1292 }
1293
1294 #[test]
1295 #[cfg(unix)]
1296 #[serial_test::serial]
1297 fn test_write_xdg_config_applies_600_permissions_on_unix() {
1298 use std::os::unix::fs::PermissionsExt;
1299
1300 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1301 unsafe {
1303 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
1304 }
1305
1306 let path =
1307 write_xdg_config("ctx7sk-perm-600").expect("Must write config without error");
1308 unsafe {
1309 std::env::remove_var("CONTEXT7_HOME");
1310 }
1311
1312 let metadata = std::fs::metadata(&path).expect("Must obtain file metadata");
1313 let mode = metadata.permissions().mode() & 0o777;
1314
1315 assert_eq!(mode, 0o600, "Permissions must be 600, got: {:o}", mode);
1316 }
1317
1318 #[test]
1321 fn test_file_config_serde_roundtrip_preserves_all_fields() {
1322 let original_config = FileConfig {
1323 schema_version: 1,
1324 keys: vec![
1325 StoredKey {
1326 value: "ctx7sk-serde-01".to_string(),
1327 added_at: "2026-01-01T12:00:00+00:00".to_string(),
1328 },
1329 StoredKey {
1330 value: "ctx7sk-serde-02".to_string(),
1331 added_at: "2026-01-02T12:00:00+00:00".to_string(),
1332 },
1333 ],
1334 };
1335
1336 let toml_str = toml::to_string_pretty(&original_config)
1337 .expect("Must serialise FileConfig to TOML");
1338 let deserialised_config: FileConfig =
1339 toml::from_str(&toml_str).expect("Must deserialise TOML back to FileConfig");
1340
1341 assert_eq!(
1342 deserialised_config.schema_version, original_config.schema_version,
1343 "schema_version must be preserved in roundtrip"
1344 );
1345 assert_eq!(
1346 deserialised_config.keys.len(),
1347 original_config.keys.len(),
1348 "Número de keys deve ser preservado"
1349 );
1350 assert_eq!(
1351 deserialised_config.keys[0].value, original_config.keys[0].value,
1352 "Valor da primeira key deve ser preservado"
1353 );
1354 assert_eq!(
1355 deserialised_config.keys[0].added_at, original_config.keys[0].added_at,
1356 "added_at da primeira key deve ser preservado"
1357 );
1358 }
1359
1360 #[test]
1361 fn test_file_config_schema_version_always_present_in_serialisation() {
1362 let config = FileConfig {
1363 schema_version: 1,
1364 keys: Vec::new(),
1365 };
1366
1367 let toml_str = toml::to_string_pretty(&config).expect("Must serialise to TOML");
1368
1369 assert!(
1370 toml_str.contains("schema_version"),
1371 "schema_version must be present in TOML serialisation"
1372 );
1373 assert!(toml_str.contains('1'), "Value 1 must be present");
1374 }
1375
1376 #[test]
1377 fn test_file_config_empty_keys_accepted_in_deserialisation() {
1378 let toml_str = "schema_version = 1\n";
1379 let config: FileConfig =
1380 toml::from_str(toml_str).expect("Must deserialise with keys absent (empty default)");
1381
1382 assert_eq!(config.schema_version, 1);
1383 assert!(
1384 config.keys.is_empty(),
1385 "keys must be empty when not present in TOML"
1386 );
1387 }
1388
1389 #[test]
1390 fn test_stored_key_preserves_added_at_as_utc_string() {
1391 let timestamp = "2026-04-08T20:00:00+00:00";
1392 let key = StoredKey {
1393 value: "ctx7sk-timestamp".to_string(),
1394 added_at: timestamp.to_string(),
1395 };
1396
1397 let toml_str = toml::to_string_pretty(&key).expect("Must serialise StoredKey");
1398 let chave_de_volta: StoredKey =
1399 toml::from_str(&toml_str).expect("Must deserialise StoredKey");
1400
1401 assert_eq!(
1402 chave_de_volta.added_at, timestamp,
1403 "Timestamp added_at deve ser preservado exactly"
1404 );
1405 }
1406
1407 #[test]
1410 #[serial_test::serial]
1411 fn test_load_api_keys_env_var_takes_priority_over_xdg() {
1412 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1413 let context7_dir = temp_dir.path().join("context7");
1414 std::fs::create_dir_all(&context7_dir).expect("Must create context7 directory");
1415
1416 let toml_xdg = r#"schema_version = 1
1417[[keys]]
1418value = "ctx7sk-xdg-deve-ser-ignorada"
1419added_at = "2026-01-01T00:00:00+00:00"
1420"#;
1421 std::fs::write(context7_dir.join("config.toml"), toml_xdg)
1422 .expect("Must write XDG config");
1423
1424 unsafe {
1426 std::env::set_var("CONTEXT7_API_KEYS", "ctx7sk-env-var-prioritaria");
1427 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
1428 }
1429
1430 let result = load_api_keys();
1431
1432 unsafe {
1433 std::env::remove_var("CONTEXT7_API_KEYS");
1434 std::env::remove_var("CONTEXT7_HOME");
1435 }
1436
1437 let keys = result.expect("Must load keys via env var");
1438 assert_eq!(keys.len(), 1);
1439 assert_eq!(
1440 keys[0], "ctx7sk-env-var-prioritaria",
1441 "Env var deve ter prioridade sobre XDG"
1442 );
1443 }
1444
1445 #[test]
1446 #[serial_test::serial]
1447 fn test_load_api_keys_xdg_used_when_env_var_missing() {
1448 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1449 let context7_dir = temp_dir.path().join("context7");
1450 std::fs::create_dir_all(&context7_dir).expect("Must create context7 directory");
1451
1452 let toml_xdg = r#"schema_version = 1
1453[[keys]]
1454value = "ctx7sk-via-xdg"
1455added_at = "2026-01-01T00:00:00+00:00"
1456"#;
1457 std::fs::write(context7_dir.join("config.toml"), toml_xdg)
1458 .expect("Must write XDG config");
1459
1460 unsafe {
1462 std::env::remove_var("CONTEXT7_API_KEYS");
1463 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
1464 }
1465
1466 let result = load_api_keys();
1467
1468 unsafe {
1469 std::env::remove_var("CONTEXT7_HOME");
1470 }
1471
1472 let keys = result.expect("Must load keys via XDG");
1473 assert_eq!(keys.len(), 1);
1474 assert_eq!(keys[0], "ctx7sk-via-xdg");
1475 }
1476
1477 #[test]
1478 #[serial_test::serial]
1479 fn test_load_api_keys_returns_err_when_nothing_available() {
1480 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1481 let empty_xdg_dir = temp_dir.path().join("xdg_vazio");
1482 std::fs::create_dir_all(&empty_xdg_dir).expect("Must create empty XDG directory");
1483
1484 let no_env_dir = temp_dir.path().join("sem_env");
1485 std::fs::create_dir_all(&no_env_dir).expect("Must create directory without .env");
1486
1487 unsafe {
1489 std::env::remove_var("CONTEXT7_API_KEYS");
1490 std::env::set_var("CONTEXT7_HOME", &empty_xdg_dir);
1491 }
1492 let original_cwd = std::env::current_dir().expect("Must obtain current CWD");
1493 std::env::set_current_dir(&no_env_dir).expect("Must change CWD");
1494
1495 let result = load_api_keys();
1496
1497 std::env::set_current_dir(&original_cwd).expect("Must restore CWD");
1498 unsafe {
1499 std::env::remove_var("CONTEXT7_HOME");
1500 }
1501
1502 assert!(
1503 result.is_err(),
1504 "Must return Err when no layer provides keys"
1505 );
1506 }
1507
1508 #[test]
1509 #[serial_test::serial]
1510 fn test_read_env_cwd_reads_env_with_multiple_context7_api_keys() {
1511 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1512 let conteudo_env = "CONTEXT7_API=ctx7sk-cwd-01\nCONTEXT7_API=ctx7sk-cwd-02\n";
1513 std::fs::write(temp_dir.path().join(".env"), conteudo_env)
1514 .expect("Must write temporary .env");
1515
1516 let original_cwd = std::env::current_dir().expect("Must obtain CWD");
1517 std::env::set_current_dir(temp_dir.path()).expect("Must change CWD to temp");
1518
1519 let result = read_env_cwd();
1520
1521 std::env::set_current_dir(&original_cwd).expect("Must restore CWD");
1522
1523 let keys = result.expect("Must return Some with keys from CWD .env");
1524 assert_eq!(keys.len(), 2, "Must read 2 keys from .env");
1525 assert_eq!(keys[0], "ctx7sk-cwd-01");
1526 assert_eq!(keys[1], "ctx7sk-cwd-02");
1527 }
1528
1529 #[test]
1530 #[serial_test::serial]
1531 fn test_read_env_cwd_returns_none_when_env_missing() {
1532 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1533
1534 let original_cwd = std::env::current_dir().expect("Must obtain CWD");
1535 std::env::set_current_dir(temp_dir.path()).expect("Must change CWD to temp without .env");
1536
1537 let result = read_env_cwd();
1538
1539 std::env::set_current_dir(&original_cwd).expect("Must restore CWD");
1540
1541 assert!(
1542 result.is_none(),
1543 "Must return None when there is no .env in CWD"
1544 );
1545 }
1546
1547 #[test]
1548 fn test_discover_xdg_log_paths_returns_some_valid_path() {
1549 let result = discover_xdg_log_paths();
1550
1551 if let Some(path) = result {
1552 let path_str = path.to_string_lossy();
1553 assert!(
1554 path_str.contains("context7"),
1555 "Caminho de logs XDG deve conter 'context7', obteve: {}",
1556 path_str
1557 );
1558 }
1559 }
1560
1561 #[test]
1562 #[serial_test::serial]
1563 fn test_load_api_keys_env_cwd_used_when_env_var_and_xdg_missing() {
1564 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1565 let xdg_dir_no_config = temp_dir.path().join("xdg_sem_config");
1566 std::fs::create_dir_all(&xdg_dir_no_config).expect("Must create empty XDG directory");
1567
1568 let cwd_dir = temp_dir.path().join("cwd_com_env");
1569 std::fs::create_dir_all(&cwd_dir).expect("Must create temporary CWD");
1570 std::fs::write(cwd_dir.join(".env"), "CONTEXT7_API=ctx7sk-cwd-camada-3\n")
1571 .expect("Must write .env in CWD");
1572
1573 unsafe {
1575 std::env::remove_var("CONTEXT7_API_KEYS");
1576 std::env::set_var("CONTEXT7_HOME", &xdg_dir_no_config);
1577 }
1578 let original_cwd = std::env::current_dir().expect("Must obtain CWD");
1579 std::env::set_current_dir(&cwd_dir).expect("Must change CWD");
1580
1581 let result = load_api_keys();
1582
1583 std::env::set_current_dir(&original_cwd).expect("Must restore CWD");
1584 unsafe {
1585 std::env::remove_var("CONTEXT7_HOME");
1586 }
1587
1588 let keys = result.expect("Must load keys via CWD .env");
1589 assert_eq!(keys.len(), 1);
1590 assert_eq!(keys[0], "ctx7sk-cwd-camada-3");
1591 }
1592
1593 #[test]
1594 #[serial_test::serial]
1595 fn test_load_api_keys_falls_back_when_xdg_invalid() {
1596 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1597 let context7_dir = temp_dir.path().join("context7");
1598 std::fs::create_dir_all(&context7_dir).expect("Must create context7 directory");
1599
1600 std::fs::write(context7_dir.join("config.toml"), "[[[invalido")
1601 .expect("Must write invalid TOML");
1602
1603 let cwd_dir = temp_dir.path().join("cwd_fallback");
1604 std::fs::create_dir_all(&cwd_dir).expect("Must create CWD with .env");
1605 std::fs::write(cwd_dir.join(".env"), "CONTEXT7_API=ctx7sk-fallback-cwd\n")
1606 .expect("Must write .env in CWD");
1607
1608 unsafe {
1610 std::env::remove_var("CONTEXT7_API_KEYS");
1611 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
1612 }
1613 let original_cwd = std::env::current_dir().expect("Must obtain CWD");
1614 std::env::set_current_dir(&cwd_dir).expect("Must change CWD");
1615
1616 let result = load_api_keys();
1617
1618 std::env::set_current_dir(&original_cwd).expect("Must restore CWD");
1619 unsafe {
1620 std::env::remove_var("CONTEXT7_HOME");
1621 }
1622
1623 let keys = result.expect("Must load keys via fallback CWD .env");
1624 assert_eq!(keys.len(), 1);
1625 assert_eq!(keys[0], "ctx7sk-fallback-cwd");
1626 }
1627
1628 #[test]
1631 #[serial_test::serial]
1632 fn test_cmd_keys_add_creates_config_when_missing() {
1633 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1634 unsafe {
1636 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
1637 }
1638
1639 let result = cmd_keys_add("ctx7sk-nova-key-add-test");
1640
1641 unsafe {
1642 std::env::remove_var("CONTEXT7_HOME");
1643 }
1644
1645 result.expect("cmd_keys_add deve funcionar em config vazio");
1646
1647 let path = temp_dir.path().join("context7").join("config.toml");
1648 assert!(
1649 path.exists(),
1650 "config.toml deve existir após cmd_keys_add"
1651 );
1652
1653 let config = read_toml_config_from_path(&path).expect("Must read created config");
1654 assert_eq!(config.keys.len(), 1, "Config deve ter 1 key");
1655 assert_eq!(config.keys[0].value, "ctx7sk-nova-key-add-test");
1656 }
1657
1658 #[test]
1659 #[serial_test::serial]
1660 fn test_cmd_keys_add_accumulates_in_existing_config() {
1661 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1662 unsafe {
1664 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
1665 }
1666
1667 cmd_keys_add("ctx7sk-key-um").expect("Primeira adição deve funcionar");
1668 cmd_keys_add("ctx7sk-key-dois").expect("Segunda adição deve funcionar");
1669
1670 let path = discover_config_path().expect("Must have XDG path");
1671 let config = read_toml_config_from_path(&path).expect("Must read config");
1672
1673 unsafe {
1674 std::env::remove_var("CONTEXT7_HOME");
1675 }
1676
1677 assert_eq!(config.keys.len(), 2, "Must accumulate 2 keys");
1678 assert_eq!(config.keys[0].value, "ctx7sk-key-um");
1679 assert_eq!(config.keys[1].value, "ctx7sk-key-dois");
1680 }
1681
1682 #[test]
1683 #[serial_test::serial]
1684 fn test_cmd_keys_add_does_not_duplicate_existing_key() {
1685 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1686 unsafe {
1688 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
1689 }
1690
1691 cmd_keys_add("ctx7sk-unica-dedup").expect("Primeira adição deve funcionar");
1692 cmd_keys_add("ctx7sk-unica-dedup").expect("Segunda adição da mesma key não deve falhar");
1693
1694 let path = discover_config_path().expect("Must have XDG path");
1695 let config = read_toml_config_from_path(&path).expect("Must read config");
1696
1697 unsafe {
1698 std::env::remove_var("CONTEXT7_HOME");
1699 }
1700
1701 assert_eq!(config.keys.len(), 1, "Não deve duplicar key já existente");
1702 }
1703
1704 #[test]
1705 #[cfg(unix)]
1706 #[serial_test::serial]
1707 fn test_cmd_keys_add_applies_600_permissions_on_unix() {
1708 use std::os::unix::fs::PermissionsExt;
1709
1710 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1711 unsafe {
1713 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
1714 }
1715
1716 cmd_keys_add("ctx7sk-perm-600-keys-add").expect("Must add key without error");
1717
1718 let path = discover_config_path().expect("Must have XDG path");
1719 unsafe {
1720 std::env::remove_var("CONTEXT7_HOME");
1721 }
1722
1723 let metadata = std::fs::metadata(&path).expect("Must obtain metadata");
1724 let mode = metadata.permissions().mode() & 0o777;
1725 assert_eq!(
1726 mode, 0o600,
1727 "Permissões devem ser 600 após cmd_keys_add, obteve: {:o}",
1728 mode
1729 );
1730 }
1731
1732 #[test]
1735 #[serial_test::serial]
1736 fn test_cmd_keys_remove_index_1_from_config_with_3_keys() {
1737 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1738 unsafe {
1740 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
1741 }
1742
1743 write_xdg_config("ctx7sk-rem-alpha").expect("Must write key 1");
1744 write_xdg_config("ctx7sk-rem-beta").expect("Must write key 2");
1745 write_xdg_config("ctx7sk-rem-gamma").expect("Must write key 3");
1746
1747 cmd_keys_remove(1).expect("Remove index 1 must work");
1748
1749 let path = discover_config_path().expect("Must have XDG path");
1750 let config = read_toml_config_from_path(&path).expect("Must read config");
1751
1752 unsafe {
1753 std::env::remove_var("CONTEXT7_HOME");
1754 }
1755
1756 assert_eq!(config.keys.len(), 2, "Must remain 2 keys after removal");
1757 assert_eq!(config.keys[0].value, "ctx7sk-rem-beta");
1758 assert_eq!(config.keys[1].value, "ctx7sk-rem-gamma");
1759 }
1760
1761 #[test]
1762 #[serial_test::serial]
1763 fn test_cmd_keys_remove_index_2_from_config_with_3_keys() {
1764 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1765 unsafe {
1767 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
1768 }
1769
1770 write_xdg_config("ctx7sk-mid-alpha").expect("Must write key 1");
1771 write_xdg_config("ctx7sk-mid-beta").expect("Must write key 2");
1772 write_xdg_config("ctx7sk-mid-gamma").expect("Must write key 3");
1773
1774 cmd_keys_remove(2).expect("Remove index 2 must work");
1775
1776 let path = discover_config_path().expect("Must have XDG path");
1777 let config = read_toml_config_from_path(&path).expect("Must read config");
1778
1779 unsafe {
1780 std::env::remove_var("CONTEXT7_HOME");
1781 }
1782
1783 assert_eq!(
1784 config.keys.len(),
1785 2,
1786 "Must remain 2 keys after removing the middle one"
1787 );
1788 assert_eq!(config.keys[0].value, "ctx7sk-mid-alpha");
1789 assert_eq!(config.keys[1].value, "ctx7sk-mid-gamma");
1790 }
1791
1792 #[test]
1793 #[serial_test::serial]
1794 fn test_cmd_keys_remove_index_zero_returns_err_with_message() {
1795 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1796 unsafe {
1798 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
1799 }
1800
1801 write_xdg_config("ctx7sk-idx-zero-test").expect("Must write key");
1802
1803 let result = cmd_keys_remove(0);
1804
1805 unsafe {
1806 std::env::remove_var("CONTEXT7_HOME");
1807 }
1808
1809 assert!(
1810 result.is_err(),
1811 "Invalid index 0 must return Err (exit code 1), got: {:?}",
1812 result
1813 );
1814 }
1815
1816 #[test]
1817 #[serial_test::serial]
1818 fn test_cmd_keys_remove_index_greater_than_len_returns_err_with_message() {
1819 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1820 unsafe {
1822 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
1823 }
1824
1825 write_xdg_config("ctx7sk-overflow-test").expect("Must write key");
1826
1827 let result = cmd_keys_remove(99);
1828
1829 unsafe {
1830 std::env::remove_var("CONTEXT7_HOME");
1831 }
1832
1833 assert!(
1834 result.is_err(),
1835 "Index out of range must return Err (exit code 1), got: {:?}",
1836 result
1837 );
1838 }
1839
1840 #[test]
1841 #[serial_test::serial]
1842 fn test_cmd_keys_remove_from_empty_config_returns_err() {
1843 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1844 unsafe {
1846 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
1847 }
1848
1849 let result = cmd_keys_remove(1);
1850
1851 unsafe {
1852 std::env::remove_var("CONTEXT7_HOME");
1853 }
1854
1855 assert!(
1856 result.is_err(),
1857 "Remove from empty config must return Err (exit code 1), got: {:?}",
1858 result
1859 );
1860 }
1861
1862 #[test]
1865 #[serial_test::serial]
1866 fn test_cmd_keys_clear_with_yes_true_clears_all_keys() {
1867 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1868 unsafe {
1870 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
1871 }
1872
1873 write_xdg_config("ctx7sk-clear-alpha").expect("Must write key 1");
1874 write_xdg_config("ctx7sk-clear-beta").expect("Must write key 2");
1875
1876 let path = discover_config_path().expect("Must have XDG path");
1877 let before = read_toml_config_from_path(&path).expect("Must read before config");
1878 assert_eq!(before.keys.len(), 2, "Pré-condição: 2 keys before do clear");
1879
1880 cmd_keys_clear(true).expect("clear with yes=true must work");
1881
1882 let after = read_toml_config_from_path(&path).expect("Must read after config");
1883
1884 unsafe {
1885 std::env::remove_var("CONTEXT7_HOME");
1886 }
1887
1888 assert!(
1889 after.keys.is_empty(),
1890 "Após clear com yes=true, keys devem estar vazias"
1891 );
1892 }
1893
1894 #[test]
1895 #[serial_test::serial]
1896 fn test_cmd_keys_clear_with_yes_true_works_on_nonexistent_config() {
1897 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1898 unsafe {
1900 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
1901 }
1902
1903 let result = cmd_keys_clear(true);
1904
1905 unsafe {
1906 std::env::remove_var("CONTEXT7_HOME");
1907 }
1908
1909 assert!(
1910 result.is_ok(),
1911 "clear on nonexistent config must return Ok (idempotent), got: {:?}",
1912 result
1913 );
1914 }
1915
1916 #[test]
1919 #[serial_test::serial]
1920 fn test_cmd_keys_import_valid_env_with_multiple_keys() {
1921 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1922 let arquivo_env = temp_dir.path().join("keys.env");
1923 std::fs::write(
1924 &arquivo_env,
1925 "CONTEXT7_API=ctx7sk-import-alpha\nCONTEXT7_API=ctx7sk-import-beta\n",
1926 )
1927 .expect("Must write test .env");
1928
1929 unsafe {
1931 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
1932 }
1933
1934 let result = cmd_keys_import(&arquivo_env);
1935
1936 let path = discover_config_path().expect("Must have XDG path");
1937 let config = read_toml_config_from_path(&path).expect("Must read config after import");
1938
1939 unsafe {
1940 std::env::remove_var("CONTEXT7_HOME");
1941 }
1942
1943 result.expect("valid .env import must work");
1944 assert_eq!(config.keys.len(), 2, "Must have imported 2 keys");
1945
1946 let valores: Vec<&str> = config.keys.iter().map(|c| c.value.as_str()).collect();
1947 assert!(valores.contains(&"ctx7sk-import-alpha"));
1948 assert!(valores.contains(&"ctx7sk-import-beta"));
1949 }
1950
1951 #[test]
1952 #[serial_test::serial]
1953 fn test_cmd_keys_import_env_without_keys_returns_err() {
1954 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1955 let arquivo_env = temp_dir.path().join("vazio.env");
1956 std::fs::write(&arquivo_env, "# apenas comentario\nOUTRA_VAR=value\n")
1957 .expect("Must write .env without keys");
1958
1959 unsafe {
1961 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
1962 }
1963
1964 let result = cmd_keys_import(&arquivo_env);
1965
1966 unsafe {
1967 std::env::remove_var("CONTEXT7_HOME");
1968 }
1969
1970 assert!(
1971 result.is_err(),
1972 "Import de .env sem keys CONTEXT7_API deve retornar Err"
1973 );
1974 }
1975
1976 #[test]
1977 #[serial_test::serial]
1978 fn test_cmd_keys_import_nonexistent_file_returns_err() {
1979 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
1980 let arquivo_inexistente = temp_dir.path().join("nao_existe.env");
1981
1982 unsafe {
1984 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
1985 }
1986
1987 let result = cmd_keys_import(&arquivo_inexistente);
1988
1989 unsafe {
1990 std::env::remove_var("CONTEXT7_HOME");
1991 }
1992
1993 assert!(
1994 result.is_err(),
1995 "Import de file inexistente deve retornar Err"
1996 );
1997 }
1998
1999 #[test]
2000 #[serial_test::serial]
2001 fn test_cmd_keys_import_roundtrip_add_then_list() {
2002 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
2003 let arquivo_env = temp_dir.path().join("roundtrip.env");
2004 std::fs::write(
2005 &arquivo_env,
2006 "CONTEXT7_API=ctx7sk-rtrip-01\nCONTEXT7_API=ctx7sk-rtrip-02\n",
2007 )
2008 .expect("Must write roundtrip .env");
2009
2010 unsafe {
2012 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
2013 }
2014
2015 cmd_keys_import(&arquivo_env).expect("Import must work");
2016
2017 let config = read_xdg_config_raw()
2018 .expect("Must return Ok")
2019 .expect("Must return Some after import");
2020
2021 unsafe {
2022 std::env::remove_var("CONTEXT7_HOME");
2023 }
2024
2025 assert_eq!(
2026 config.keys.len(),
2027 2,
2028 "Roundtrip: deve ter 2 keys após import"
2029 );
2030 assert_eq!(config.keys[0].value, "ctx7sk-rtrip-01");
2031 assert_eq!(config.keys[1].value, "ctx7sk-rtrip-02");
2032 }
2033
2034 #[test]
2037 #[serial_test::serial]
2038 fn test_cmd_keys_export_empty_config_returns_ok() {
2039 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
2040 unsafe {
2042 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
2043 }
2044
2045 let result = cmd_keys_export();
2046
2047 unsafe {
2048 std::env::remove_var("CONTEXT7_HOME");
2049 }
2050
2051 assert!(
2052 result.is_ok(),
2053 "Export of empty config must return Ok, got: {:?}",
2054 result
2055 );
2056 }
2057
2058 #[test]
2059 #[serial_test::serial]
2060 fn test_cmd_keys_export_returns_ok_with_existing_keys() {
2061 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
2062 unsafe {
2064 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
2065 }
2066
2067 write_xdg_config("ctx7sk-export-um").expect("Must write key 1");
2068 write_xdg_config("ctx7sk-export-dois").expect("Must write key 2");
2069
2070 let result = cmd_keys_export();
2071
2072 unsafe {
2073 std::env::remove_var("CONTEXT7_HOME");
2074 }
2075
2076 assert!(
2077 result.is_ok(),
2078 "Export com keys existentes deve retornar Ok, obteve: {:?}",
2079 result
2080 );
2081 }
2082
2083 #[test]
2086 #[serial_test::serial]
2087 fn test_read_xdg_config_raw_returns_none_without_file() {
2088 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
2089 unsafe {
2091 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
2092 }
2093
2094 let result = read_xdg_config_raw();
2095
2096 unsafe {
2097 std::env::remove_var("CONTEXT7_HOME");
2098 }
2099
2100 let value = result.expect("Must return Ok");
2101 assert!(
2102 value.is_none(),
2103 "Must return None when config.toml does not exist"
2104 );
2105 }
2106
2107 #[test]
2108 #[serial_test::serial]
2109 fn test_read_xdg_config_raw_returns_config_with_keys() {
2110 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
2111 let context7_dir = temp_dir.path().join("context7");
2112 std::fs::create_dir_all(&context7_dir).expect("Must create context7 directory");
2113
2114 let toml = r#"schema_version = 1
2115
2116[[keys]]
2117value = "ctx7sk-raw-01"
2118added_at = "2026-04-08T00:00:00+00:00"
2119
2120[[keys]]
2121value = "ctx7sk-raw-02"
2122added_at = "2026-04-08T00:01:00+00:00"
2123"#;
2124 std::fs::write(context7_dir.join("config.toml"), toml).expect("Must write config.toml");
2125
2126 unsafe {
2128 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
2129 }
2130
2131 let result = read_xdg_config_raw();
2132
2133 unsafe {
2134 std::env::remove_var("CONTEXT7_HOME");
2135 }
2136
2137 let config = result
2138 .expect("Must return Ok")
2139 .expect("Must return Some with config");
2140 assert_eq!(config.keys.len(), 2);
2141 assert_eq!(config.keys[0].value, "ctx7sk-raw-01");
2142 assert_eq!(config.keys[1].value, "ctx7sk-raw-02");
2143 }
2144
2145 #[test]
2146 #[serial_test::serial]
2147 fn test_cmd_keys_path_returns_ok() {
2148 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
2149 unsafe {
2151 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
2152 }
2153
2154 let result = cmd_keys_path();
2155
2156 unsafe {
2157 std::env::remove_var("CONTEXT7_HOME");
2158 }
2159
2160 result.expect("cmd_keys_path must return Ok");
2161 }
2162
2163 #[test]
2164 #[serial_test::serial]
2165 fn test_discover_config_path_ends_with_config_toml() {
2166 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
2167 unsafe {
2169 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
2170 }
2171
2172 let path = discover_config_path();
2173
2174 unsafe {
2175 std::env::remove_var("CONTEXT7_HOME");
2176 }
2177
2178 let path = path.expect("Must return valid XDG path");
2179 assert!(
2180 path.to_string_lossy().ends_with("config.toml"),
2181 "Path must end with config.toml, got: {}",
2182 path.display()
2183 );
2184 assert!(
2185 path.to_string_lossy().contains("context7"),
2186 "Path must contain 'context7', got: {}",
2187 path.display()
2188 );
2189 }
2190
2191 #[test]
2194 #[serial_test::serial]
2195 fn test_complete_flow_add_list_remove_clear() {
2196 let temp_dir = tempfile::TempDir::new().expect("Must create temporary directory");
2197 unsafe {
2199 std::env::set_var("CONTEXT7_HOME", temp_dir.path());
2200 }
2201
2202 cmd_keys_add("ctx7sk-fluxo-01").expect("Add 1 must work");
2203 cmd_keys_add("ctx7sk-fluxo-02").expect("Add 2 must work");
2204 cmd_keys_add("ctx7sk-fluxo-03").expect("Add 3 must work");
2205
2206 let config_before = read_xdg_config_raw()
2207 .expect("Ok")
2208 .expect("Some com 3 keys");
2209 assert_eq!(config_before.keys.len(), 3, "Must have 3 keys after 3 adds");
2210
2211 cmd_keys_remove(2).expect("Remove index 2 must work");
2212
2213 let config_after_remove = read_xdg_config_raw()
2214 .expect("Ok")
2215 .expect("Some com 2 keys");
2216 assert_eq!(
2217 config_after_remove.keys.len(),
2218 2,
2219 "Must have 2 keys after remove"
2220 );
2221 assert_eq!(config_after_remove.keys[0].value, "ctx7sk-fluxo-01");
2222 assert_eq!(config_after_remove.keys[1].value, "ctx7sk-fluxo-03");
2223
2224 cmd_keys_clear(true).expect("Clear com yes=true deve funcionar");
2225
2226 let path = discover_config_path().expect("Must have path");
2227 let final_config = read_toml_config_from_path(&path).expect("Must read final config");
2228
2229 unsafe {
2230 std::env::remove_var("CONTEXT7_HOME");
2231 }
2232
2233 assert!(
2234 final_config.keys.is_empty(),
2235 "Após clear, keys devem estar vazias"
2236 );
2237 }
2238
2239 #[test]
2242 #[serial_test::serial]
2243 fn test_context7_home_override_config_path() {
2244 let tmp = tempfile::TempDir::new().expect("Must create tempdir");
2245 unsafe {
2248 std::env::set_var("CONTEXT7_HOME", tmp.path());
2249 }
2250
2251 let path = discover_config_path();
2252
2253 unsafe {
2254 std::env::remove_var("CONTEXT7_HOME");
2255 }
2256
2257 let path = path.expect("Must return Some when CONTEXT7_HOME is defined");
2258 let expected = tmp.path().join("context7").join("config.toml");
2259 assert_eq!(
2260 path, expected,
2261 "CONTEXT7_HOME must define path as {{CONTEXT7_HOME}}/context7/config.toml"
2262 );
2263 }
2264
2265 #[test]
2266 #[serial_test::serial]
2267 fn test_context7_home_override_logs_path() {
2268 let tmp = tempfile::TempDir::new().expect("Must create tempdir");
2269 unsafe {
2271 std::env::set_var("CONTEXT7_HOME", tmp.path());
2272 }
2273
2274 let path = discover_xdg_log_paths();
2275
2276 unsafe {
2277 std::env::remove_var("CONTEXT7_HOME");
2278 }
2279
2280 let path = path.expect("Must return Some when CONTEXT7_HOME is defined");
2281 let expected = tmp.path().join("context7").join("logs");
2282 assert_eq!(
2283 path, expected,
2284 "CONTEXT7_HOME must define logs as {{CONTEXT7_HOME}}/context7/logs"
2285 );
2286 }
2287
2288 #[test]
2289 #[serial_test::serial]
2290 fn test_context7_home_empty_falls_back_to_projectdirs() {
2291 let tmp = tempfile::TempDir::new().expect("Must create tempdir");
2292 unsafe {
2294 std::env::set_var("CONTEXT7_HOME", "");
2295 }
2296
2297 let path = discover_config_path();
2298
2299 unsafe {
2300 std::env::remove_var("CONTEXT7_HOME");
2301 }
2302
2303 if let Some(c) = path {
2305 let tmp_str = tmp.path().to_string_lossy();
2306 assert!(
2307 !c.to_string_lossy().starts_with(tmp_str.as_ref()),
2308 "CONTEXT7_HOME empty must not use the tempdir: {}",
2309 c.display()
2310 );
2311 }
2312 }
2314}