use anyhow::{anyhow, Context, Result};
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Deserialize, Default)]
pub struct ReconConfig {
pub netstatus: Option<NetstatusConfig>,
pub editor: Option<EditorConfig>,
pub ai: Option<AiConfig>,
#[serde(default)]
pub sampledata: HashMap<String, SampleDataConfig>,
}
#[derive(Deserialize, Default, Debug, Clone)]
pub struct AiConfig {
#[serde(default)]
pub default_backend: Option<String>,
#[serde(default)]
pub default_model: Option<String>,
#[serde(default)]
pub timeout_secs: Option<u64>,
#[serde(default)]
pub backends: HashMap<String, AiBackendConfig>,
}
#[derive(Deserialize, Default, Debug, Clone)]
pub struct AiBackendConfig {
#[serde(default)]
pub cmd: Vec<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub model_flag: Option<String>,
#[serde(default)]
pub system_flag: Option<String>,
}
#[derive(Deserialize, Default, Debug)]
pub struct EditorConfig {
#[serde(default)]
pub default: Option<String>,
#[serde(default)]
pub aliases: HashMap<String, String>,
}
#[derive(Deserialize, Default, Debug, Clone)]
pub struct SampleDataConfig {
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
pub default_format: Option<String>,
#[serde(default)]
pub count: Option<u32>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub urls: HashMap<String, String>,
#[serde(default)]
pub headers: Vec<String>,
#[serde(default)]
pub basic_auth: Option<String>,
}
#[derive(Deserialize, Default)]
pub struct NetstatusConfig {
#[serde(default)]
pub ip_sources: Vec<String>,
#[serde(default)]
pub dns_lookup_domains: Vec<String>,
#[serde(default)]
pub probes: Vec<String>,
#[serde(default)]
pub dns_hijack_checks: Vec<DnsHijackCheck>,
}
#[derive(Deserialize, Clone)]
pub struct DnsHijackCheck {
pub server: String,
pub domain: String,
pub expected: String,
}
impl NetstatusConfig {
pub fn validate(&self) -> Result<()> {
let has_dns_probe = self.probes.iter().any(|p| p.starts_with("dns://"));
if has_dns_probe && self.dns_lookup_domains.is_empty() {
return Err(anyhow!(
"dns:// probes require at least one entry in dns_lookup_domains"
));
}
Ok(())
}
}
pub fn load() -> Result<ReconConfig> {
let path = config_path();
let text = std::fs::read_to_string(&path).with_context(|| {
format!(
"Cannot read config file: {}\n\
Create it with a [netstatus] section — see: recon --help netstatus",
path.display()
)
})?;
let config: ReconConfig =
toml::from_str(&text).map_err(|e| anyhow!("Failed to parse config file: {}", e))?;
Ok(config)
}
fn config_path() -> std::path::PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
std::path::PathBuf::from(home).join(".recon").join("config.toml")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_valid_config() {
let toml_str = r#"
[netstatus]
ip_sources = ["https://api.ipify.org", "https://ifconfig.me/ip"]
dns_lookup_domains = ["example.com"]
probes = ["https://www.google.com", "ping://8.8.8.8"]
"#;
let config: ReconConfig = toml::from_str(toml_str).unwrap();
let ns = config.netstatus.unwrap();
assert_eq!(ns.ip_sources.len(), 2);
assert_eq!(ns.dns_lookup_domains, vec!["example.com"]);
assert_eq!(ns.probes.len(), 2);
assert!(ns.dns_hijack_checks.is_empty());
}
#[test]
fn test_parse_dns_hijack_checks() {
let toml_str = r#"
[netstatus]
ip_sources = []
dns_lookup_domains = ["example.com"]
probes = []
[[netstatus.dns_hijack_checks]]
server = "8.8.8.8"
domain = "example.com"
expected = "93.184.216.34"
[[netstatus.dns_hijack_checks]]
server = "1.1.1.1"
domain = "example.com"
expected = "93.184.216.34"
"#;
let config: ReconConfig = toml::from_str(toml_str).unwrap();
let ns = config.netstatus.unwrap();
assert_eq!(ns.dns_hijack_checks.len(), 2);
assert_eq!(ns.dns_hijack_checks[0].server, "8.8.8.8");
assert_eq!(ns.dns_hijack_checks[1].server, "1.1.1.1");
}
#[test]
fn test_validate_dns_probe_requires_lookup_domains() {
let config = NetstatusConfig {
dns_lookup_domains: vec![],
probes: vec!["dns://8.8.8.8".to_string()],
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn test_validate_passes_when_no_dns_probes() {
let config = NetstatusConfig {
dns_lookup_domains: vec![],
probes: vec!["https://www.google.com".to_string()],
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn test_parse_editor_config() {
let toml_str = r#"
[editor]
default = "zed"
[editor.aliases]
mycode = "code --new-window"
altzed = "zed --dev"
"#;
let config: ReconConfig = toml::from_str(toml_str).unwrap();
let editor = config.editor.expect("editor section should parse");
assert_eq!(editor.default.as_deref(), Some("zed"));
assert_eq!(
editor.aliases.get("mycode").map(String::as_str),
Some("code --new-window"),
);
assert_eq!(
editor.aliases.get("altzed").map(String::as_str),
Some("zed --dev"),
);
}
#[test]
fn test_editor_config_all_optional() {
let toml_str = r#"
[editor]
"#;
let config: ReconConfig = toml::from_str(toml_str).unwrap();
let editor = config.editor.expect("editor section should parse");
assert!(editor.default.is_none());
assert!(editor.aliases.is_empty());
}
#[test]
fn test_editor_section_missing_is_none() {
let toml_str = r#"
[netstatus]
ip_sources = []
dns_lookup_domains = []
probes = []
"#;
let config: ReconConfig = toml::from_str(toml_str).unwrap();
assert!(config.editor.is_none());
}
#[test]
fn test_parse_sampledata_full_entry() {
let toml_str = r#"
[sampledata.customer]
mode = "bulk"
default_format = "json"
count = 25
description = "Customer profiles"
urls.json = "https://api.example.com/users?limit={{count}}"
urls.csv = "https://api.example.com/users.csv?n={{count}}"
headers = ["Authorization: Bearer xxx", "X-Tenant: acme"]
basic_auth = "alice:secret"
"#;
let config: ReconConfig = toml::from_str(toml_str).unwrap();
let s = config.sampledata.get("customer").expect("present");
assert_eq!(s.mode.as_deref(), Some("bulk"));
assert_eq!(s.default_format.as_deref(), Some("json"));
assert_eq!(s.count, Some(25));
assert_eq!(s.description.as_deref(), Some("Customer profiles"));
assert_eq!(s.urls.len(), 2);
assert_eq!(
s.urls.get("json").map(String::as_str),
Some("https://api.example.com/users?limit={{count}}"),
);
assert_eq!(s.headers.len(), 2);
assert_eq!(s.basic_auth.as_deref(), Some("alice:secret"));
}
#[test]
fn test_parse_sampledata_minimal_entry() {
let toml_str = r#"
[sampledata.foo]
default_format = "json"
urls.json = "https://example.com/foo"
"#;
let config: ReconConfig = toml::from_str(toml_str).unwrap();
let s = config.sampledata.get("foo").expect("present");
assert!(s.mode.is_none());
assert_eq!(s.default_format.as_deref(), Some("json"));
assert!(s.count.is_none());
assert!(s.headers.is_empty());
assert!(s.basic_auth.is_none());
}
#[test]
fn test_sampledata_missing_is_empty_map() {
let toml_str = r#"
[netstatus]
ip_sources = []
dns_lookup_domains = []
probes = []
"#;
let config: ReconConfig = toml::from_str(toml_str).unwrap();
assert!(config.sampledata.is_empty());
}
#[test]
fn test_parse_ai_config_full() {
let toml_str = r#"
[ai]
default_backend = "claude"
default_model = "sonnet"
timeout_secs = 90
[ai.backends.claude]
model = "claude-sonnet-4-5"
[ai.backends.my-llm]
cmd = ["my-llm-cli", "--print"]
model_flag = "--model"
system_flag = "--system"
"#;
let config: ReconConfig = toml::from_str(toml_str).unwrap();
let ai = config.ai.expect("ai section should parse");
assert_eq!(ai.default_backend.as_deref(), Some("claude"));
assert_eq!(ai.default_model.as_deref(), Some("sonnet"));
assert_eq!(ai.timeout_secs, Some(90));
let claude = ai.backends.get("claude").expect("claude backend");
assert_eq!(claude.model.as_deref(), Some("claude-sonnet-4-5"));
assert!(claude.cmd.is_empty());
let custom = ai.backends.get("my-llm").expect("my-llm backend");
assert_eq!(custom.cmd, vec!["my-llm-cli", "--print"]);
assert_eq!(custom.model_flag.as_deref(), Some("--model"));
assert_eq!(custom.system_flag.as_deref(), Some("--system"));
}
#[test]
fn test_parse_ai_config_all_optional() {
let toml_str = r#"
[ai]
"#;
let config: ReconConfig = toml::from_str(toml_str).unwrap();
let ai = config.ai.expect("ai section");
assert!(ai.default_backend.is_none());
assert!(ai.default_model.is_none());
assert!(ai.timeout_secs.is_none());
assert!(ai.backends.is_empty());
}
#[test]
fn test_ai_section_missing_is_none() {
let toml_str = r#"
[netstatus]
ip_sources = []
dns_lookup_domains = []
probes = []
"#;
let config: ReconConfig = toml::from_str(toml_str).unwrap();
assert!(config.ai.is_none());
}
}