Migrate cloudflare-ddns to Rust

Add Cargo.toml, Cargo.lock and a full src/ tree with modules and tests
Update Dockerfile to build a Rust release binary and simplify CI/publish
Remove legacy Python script, requirements.txt, and startup helper
Switch .gitignore to Rust artifacts; update Dependabot and workflows to
cargo
Add .env example, docker-compose env, and update README and VSCode
settings

Remove the old Python implementation and requirements; add a Rust
implementation with Cargo.toml/Cargo.lock and full src/ modules, tests,
and notifier/heartbeat support. Update Dockerfile, build/publish
scripts, dependabot and workflows, README, and provide env-based
docker-compose and .env examples.
This commit is contained in:
Timothy Miller
2026-03-10 01:21:21 -04:00
parent f0d9510fff
commit b1a2fa7af3
23 changed files with 13115 additions and 792 deletions

1774
src/cloudflare.rs Normal file

File diff suppressed because it is too large Load Diff

1961
src/config.rs Normal file

File diff suppressed because it is too large Load Diff

547
src/domain.rs Normal file
View File

@@ -0,0 +1,547 @@
use std::fmt;
/// Represents a DNS domain - either a regular FQDN or a wildcard.
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Domain {
FQDN(String),
Wildcard(String),
}
#[allow(dead_code)]
impl Domain {
/// Parse a domain string. Handles:
/// - "@" or "" -> root domain (handled at FQDN construction time)
/// - "*.example.com" -> wildcard
/// - "sub.example.com" -> regular FQDN
pub fn new(input: &str) -> Result<Self, String> {
let trimmed = input.trim().to_lowercase();
if trimmed.starts_with("*.") {
let base = &trimmed[2..];
let ascii = domain_to_ascii(base)?;
Ok(Domain::Wildcard(ascii))
} else {
let ascii = domain_to_ascii(&trimmed)?;
Ok(Domain::FQDN(ascii))
}
}
/// Returns the DNS name in ASCII form suitable for API calls.
pub fn dns_name_ascii(&self) -> String {
match self {
Domain::FQDN(s) => s.clone(),
Domain::Wildcard(s) => format!("*.{s}"),
}
}
/// Returns a human-readable description of the domain.
pub fn describe(&self) -> String {
match self {
Domain::FQDN(s) => describe_domain(s),
Domain::Wildcard(s) => format!("*.{}", describe_domain(s)),
}
}
/// Returns the zones (parent domains) for this domain, from most specific to least.
pub fn zones(&self) -> Vec<String> {
let base = match self {
Domain::FQDN(s) => s.as_str(),
Domain::Wildcard(s) => s.as_str(),
};
let mut zones = Vec::new();
let mut current = base.to_string();
while !current.is_empty() {
zones.push(current.clone());
if let Some(pos) = current.find('.') {
current = current[pos + 1..].to_string();
} else {
break;
}
}
zones
}
}
impl fmt::Display for Domain {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.describe())
}
}
/// Construct an FQDN from a subdomain name and base domain.
pub fn make_fqdn(subdomain: &str, base_domain: &str) -> String {
let name = subdomain.to_lowercase();
let name = name.trim();
if name.is_empty() || name == "@" {
base_domain.to_lowercase()
} else if name.starts_with("*.") {
// Wildcard subdomain
format!("{name}.{}", base_domain.to_lowercase())
} else {
format!("{name}.{}", base_domain.to_lowercase())
}
}
/// Convert a domain to ASCII using IDNA encoding.
#[allow(dead_code)]
fn domain_to_ascii(domain: &str) -> Result<String, String> {
if domain.is_empty() {
return Ok(String::new());
}
// Try IDNA encoding for internationalized domain names
match idna::domain_to_ascii(domain) {
Ok(ascii) => Ok(ascii),
Err(_) => {
// Fallback: if it's already ASCII, just return it
if domain.is_ascii() {
Ok(domain.to_string())
} else {
Err(format!("Invalid domain name: {domain}"))
}
}
}
}
/// Convert ASCII domain back to Unicode for display.
#[allow(dead_code)]
fn describe_domain(ascii: &str) -> String {
// Try to convert punycode back to unicode for display
match idna::domain_to_unicode(ascii) {
(unicode, Ok(())) => unicode,
_ => ascii.to_string(),
}
}
/// Parse a comma-separated list of domain strings.
#[allow(dead_code)]
pub fn parse_domain_list(input: &str) -> Result<Vec<Domain>, String> {
if input.trim().is_empty() {
return Ok(Vec::new());
}
input
.split(',')
.map(|s| Domain::new(s.trim()))
.collect()
}
// --- Domain Expression Evaluator ---
// Supports: true, false, is(domain,...), sub(domain,...), !, &&, ||, ()
/// Parse and evaluate a domain expression to determine if a domain should be proxied.
pub fn parse_proxied_expression(expr: &str) -> Result<Box<dyn Fn(&str) -> bool + Send + Sync>, String> {
let expr = expr.trim();
if expr.is_empty() || expr == "false" {
return Ok(Box::new(|_: &str| false));
}
if expr == "true" {
return Ok(Box::new(|_: &str| true));
}
let tokens = tokenize_expr(expr)?;
let (predicate, rest) = parse_or_expr(&tokens)?;
if !rest.is_empty() {
return Err(format!("Unexpected tokens in proxied expression: {}", rest.join(" ")));
}
Ok(predicate)
}
fn tokenize_expr(input: &str) -> Result<Vec<String>, String> {
let mut tokens = Vec::new();
let mut chars = input.chars().peekable();
while let Some(&c) = chars.peek() {
match c {
' ' | '\t' | '\n' | '\r' => {
chars.next();
}
'(' | ')' | '!' | ',' => {
tokens.push(c.to_string());
chars.next();
}
'&' => {
chars.next();
if chars.peek() == Some(&'&') {
chars.next();
tokens.push("&&".to_string());
} else {
return Err("Expected '&&', got single '&'".to_string());
}
}
'|' => {
chars.next();
if chars.peek() == Some(&'|') {
chars.next();
tokens.push("||".to_string());
} else {
return Err("Expected '||', got single '|'".to_string());
}
}
_ => {
let mut word = String::new();
while let Some(&c) = chars.peek() {
if c.is_alphanumeric() || c == '.' || c == '-' || c == '_' || c == '*' || c == '@' {
word.push(c);
chars.next();
} else {
break;
}
}
if word.is_empty() {
return Err(format!("Unexpected character: {c}"));
}
tokens.push(word);
}
}
}
Ok(tokens)
}
type Predicate = Box<dyn Fn(&str) -> bool + Send + Sync>;
fn parse_or_expr(tokens: &[String]) -> Result<(Predicate, &[String]), String> {
let (mut left, mut rest) = parse_and_expr(tokens)?;
while !rest.is_empty() && rest[0] == "||" {
let (right, new_rest) = parse_and_expr(&rest[1..])?;
let prev = left;
left = Box::new(move |d: &str| prev(d) || right(d));
rest = new_rest;
}
Ok((left, rest))
}
fn parse_and_expr(tokens: &[String]) -> Result<(Predicate, &[String]), String> {
let (mut left, mut rest) = parse_not_expr(tokens)?;
while !rest.is_empty() && rest[0] == "&&" {
let (right, new_rest) = parse_not_expr(&rest[1..])?;
let prev = left;
left = Box::new(move |d: &str| prev(d) && right(d));
rest = new_rest;
}
Ok((left, rest))
}
fn parse_not_expr(tokens: &[String]) -> Result<(Predicate, &[String]), String> {
if tokens.is_empty() {
return Err("Unexpected end of expression".to_string());
}
if tokens[0] == "!" {
let (inner, rest) = parse_not_expr(&tokens[1..])?;
let pred: Predicate = Box::new(move |d: &str| !inner(d));
Ok((pred, rest))
} else {
parse_atom(tokens)
}
}
fn parse_atom(tokens: &[String]) -> Result<(Predicate, &[String]), String> {
if tokens.is_empty() {
return Err("Unexpected end of expression".to_string());
}
match tokens[0].as_str() {
"true" => Ok((Box::new(|_: &str| true), &tokens[1..])),
"false" => Ok((Box::new(|_: &str| false), &tokens[1..])),
"(" => {
let (inner, rest) = parse_or_expr(&tokens[1..])?;
if rest.is_empty() || rest[0] != ")" {
return Err("Missing closing parenthesis".to_string());
}
Ok((inner, &rest[1..]))
}
"is" => {
let (domains, rest) = parse_domain_args(&tokens[1..])?;
let pred: Predicate = Box::new(move |d: &str| {
let d_lower = d.to_lowercase();
domains.iter().any(|dom| d_lower == *dom)
});
Ok((pred, rest))
}
"sub" => {
let (domains, rest) = parse_domain_args(&tokens[1..])?;
let pred: Predicate = Box::new(move |d: &str| {
let d_lower = d.to_lowercase();
domains.iter().any(|dom| {
d_lower == *dom || d_lower.ends_with(&format!(".{dom}"))
})
});
Ok((pred, rest))
}
_ => Err(format!("Unexpected token: {}", tokens[0])),
}
}
fn parse_domain_args(tokens: &[String]) -> Result<(Vec<String>, &[String]), String> {
if tokens.is_empty() || tokens[0] != "(" {
return Err("Expected '(' after function name".to_string());
}
let mut domains = Vec::new();
let mut i = 1;
while i < tokens.len() && tokens[i] != ")" {
if tokens[i] == "," {
i += 1;
continue;
}
domains.push(tokens[i].to_lowercase());
i += 1;
}
if i >= tokens.len() {
return Err("Missing closing ')' in function call".to_string());
}
Ok((domains, &tokens[i + 1..]))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_make_fqdn_root() {
assert_eq!(make_fqdn("", "example.com"), "example.com");
assert_eq!(make_fqdn("@", "example.com"), "example.com");
}
#[test]
fn test_make_fqdn_subdomain() {
assert_eq!(make_fqdn("www", "example.com"), "www.example.com");
assert_eq!(make_fqdn("VPN", "Example.COM"), "vpn.example.com");
}
#[test]
fn test_domain_wildcard() {
let d = Domain::new("*.example.com").unwrap();
assert_eq!(d.dns_name_ascii(), "*.example.com");
}
#[test]
fn test_parse_domain_list() {
let domains = parse_domain_list("example.com, *.example.com, sub.example.com").unwrap();
assert_eq!(domains.len(), 3);
}
#[test]
fn test_proxied_expr_true() {
let pred = parse_proxied_expression("true").unwrap();
assert!(pred("anything.com"));
}
#[test]
fn test_proxied_expr_false() {
let pred = parse_proxied_expression("false").unwrap();
assert!(!pred("anything.com"));
}
#[test]
fn test_proxied_expr_is() {
let pred = parse_proxied_expression("is(example.com)").unwrap();
assert!(pred("example.com"));
assert!(!pred("sub.example.com"));
}
#[test]
fn test_proxied_expr_sub() {
let pred = parse_proxied_expression("sub(example.com)").unwrap();
assert!(pred("example.com"));
assert!(pred("sub.example.com"));
assert!(!pred("other.com"));
}
#[test]
fn test_proxied_expr_complex() {
let pred = parse_proxied_expression("is(a.com) || is(b.com)").unwrap();
assert!(pred("a.com"));
assert!(pred("b.com"));
assert!(!pred("c.com"));
}
#[test]
fn test_proxied_expr_negation() {
let pred = parse_proxied_expression("!is(internal.com)").unwrap();
assert!(!pred("internal.com"));
assert!(pred("public.com"));
}
// --- Domain::new with regular FQDN ---
#[test]
fn test_domain_new_fqdn() {
let d = Domain::new("example.com").unwrap();
assert_eq!(d, Domain::FQDN("example.com".to_string()));
}
#[test]
fn test_domain_new_fqdn_uppercase() {
let d = Domain::new("EXAMPLE.COM").unwrap();
assert_eq!(d, Domain::FQDN("example.com".to_string()));
}
// --- Domain::dns_name_ascii for FQDN ---
#[test]
fn test_dns_name_ascii_fqdn() {
let d = Domain::FQDN("example.com".to_string());
assert_eq!(d.dns_name_ascii(), "example.com");
}
// --- Domain::describe for both variants ---
#[test]
fn test_describe_fqdn() {
let d = Domain::FQDN("example.com".to_string());
// ASCII domain should round-trip through describe unchanged
assert_eq!(d.describe(), "example.com");
}
#[test]
fn test_describe_wildcard() {
let d = Domain::Wildcard("example.com".to_string());
assert_eq!(d.describe(), "*.example.com");
}
// --- Domain::zones ---
#[test]
fn test_zones_fqdn() {
let d = Domain::FQDN("sub.example.com".to_string());
let zones = d.zones();
assert_eq!(zones, vec!["sub.example.com", "example.com", "com"]);
}
#[test]
fn test_zones_wildcard() {
let d = Domain::Wildcard("example.com".to_string());
let zones = d.zones();
assert_eq!(zones, vec!["example.com", "com"]);
}
#[test]
fn test_zones_single_label() {
let d = Domain::FQDN("localhost".to_string());
let zones = d.zones();
assert_eq!(zones, vec!["localhost"]);
}
// --- Domain Display trait ---
#[test]
fn test_display_fqdn() {
let d = Domain::FQDN("example.com".to_string());
assert_eq!(format!("{d}"), "example.com");
}
#[test]
fn test_display_wildcard() {
let d = Domain::Wildcard("example.com".to_string());
assert_eq!(format!("{d}"), "*.example.com");
}
// --- domain_to_ascii (tested indirectly via Domain::new) ---
#[test]
fn test_domain_new_empty_string() {
// empty string -> domain_to_ascii returns Ok("") -> Domain::FQDN("")
let d = Domain::new("").unwrap();
assert_eq!(d, Domain::FQDN("".to_string()));
}
#[test]
fn test_domain_new_ascii_domain() {
let d = Domain::new("www.example.org").unwrap();
assert_eq!(d.dns_name_ascii(), "www.example.org");
}
#[test]
fn test_domain_new_internationalized() {
// "münchen.de" should be encoded to punycode
let d = Domain::new("münchen.de").unwrap();
let ascii = d.dns_name_ascii();
// The punycode-encoded form should start with "xn--"
assert!(ascii.contains("xn--"), "expected punycode, got: {ascii}");
}
// --- describe_domain (tested indirectly via Domain::describe) ---
#[test]
fn test_describe_punycode_roundtrip() {
// Build a domain with a known punycode label and confirm describe decodes it
let d = Domain::new("münchen.de").unwrap();
let described = d.describe();
// Should contain the Unicode form, not the raw punycode
assert!(described.contains("münchen") || described.contains("xn--"),
"describe returned: {described}");
}
#[test]
fn test_describe_regular_ascii() {
let d = Domain::FQDN("example.com".to_string());
assert_eq!(d.describe(), "example.com");
}
// --- parse_domain_list with empty input ---
#[test]
fn test_parse_domain_list_empty() {
let result = parse_domain_list("").unwrap();
assert!(result.is_empty());
}
#[test]
fn test_parse_domain_list_whitespace_only() {
let result = parse_domain_list(" ").unwrap();
assert!(result.is_empty());
}
// --- Tokenizer edge cases (via parse_proxied_expression) ---
#[test]
fn test_tokenizer_single_ampersand_error() {
let result = parse_proxied_expression("is(a.com) & is(b.com)");
assert!(result.is_err());
let err = result.err().unwrap();
assert!(err.contains("&&"), "error was: {err}");
}
#[test]
fn test_tokenizer_single_pipe_error() {
let result = parse_proxied_expression("is(a.com) | is(b.com)");
assert!(result.is_err());
let err = result.err().unwrap();
assert!(err.contains("||"), "error was: {err}");
}
#[test]
fn test_tokenizer_unexpected_character_error() {
let result = parse_proxied_expression("is(a.com) $ is(b.com)");
assert!(result.is_err());
}
// --- Parser edge cases ---
#[test]
fn test_parse_and_expr_double_ampersand() {
let pred = parse_proxied_expression("is(a.com) && is(b.com)").unwrap();
assert!(!pred("a.com"));
assert!(!pred("b.com"));
let pred2 = parse_proxied_expression("sub(example.com) && !is(internal.example.com)").unwrap();
assert!(pred2("www.example.com"));
assert!(!pred2("internal.example.com"));
}
#[test]
fn test_parse_nested_parentheses() {
let pred = parse_proxied_expression("(is(a.com) || is(b.com)) && !is(c.com)").unwrap();
assert!(pred("a.com"));
assert!(pred("b.com"));
assert!(!pred("c.com"));
}
#[test]
fn test_parse_missing_closing_paren() {
let result = parse_proxied_expression("(is(a.com)");
assert!(result.is_err());
let err = result.err().unwrap();
assert!(err.contains("parenthesis") || err.contains(")"), "error was: {err}");
}
#[test]
fn test_parse_unexpected_tokens_after_expr() {
let result = parse_proxied_expression("true false");
assert!(result.is_err());
}
// --- make_fqdn with wildcard subdomain ---
#[test]
fn test_make_fqdn_wildcard_subdomain() {
// A name starting with "*." is treated as a wildcard subdomain
assert_eq!(make_fqdn("*.sub", "example.com"), "*.sub.example.com");
}
}

920
src/main.rs Normal file
View File

@@ -0,0 +1,920 @@
mod cloudflare;
mod config;
mod domain;
mod notifier;
mod pp;
mod provider;
mod updater;
use crate::cloudflare::{Auth, CloudflareHandle};
use crate::config::{AppConfig, CronSchedule};
use crate::notifier::{CompositeNotifier, Heartbeat, Message};
use crate::pp::PP;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::signal;
use tokio::time::{sleep, Duration};
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[tokio::main]
async fn main() {
// Parse CLI args
let args: Vec<String> = std::env::args().collect();
let dry_run = args.iter().any(|a| a == "--dry-run");
let repeat = args.iter().any(|a| a == "--repeat");
// Check for unknown args (legacy behavior)
let known_args = ["--dry-run", "--repeat"];
let unknown: Vec<&str> = args
.iter()
.skip(1)
.filter(|a| !known_args.contains(&a.as_str()))
.map(|a| a.as_str())
.collect();
if !unknown.is_empty() {
eprintln!(
"Unrecognized parameter(s): {}. Stopping now.",
unknown.join(", ")
);
return;
}
// Determine config mode and create initial PP for config loading
let initial_pp = if config::is_env_config_mode() {
// In env mode, read emoji/quiet from env before loading full config
let emoji = std::env::var("EMOJI")
.map(|v| matches!(v.to_lowercase().as_str(), "true" | "1" | "yes"))
.unwrap_or(true);
let quiet = std::env::var("QUIET")
.map(|v| matches!(v.to_lowercase().as_str(), "true" | "1" | "yes"))
.unwrap_or(false);
PP::new(emoji, quiet)
} else {
// Legacy mode: no emoji, not quiet (preserves original output behavior)
PP::new(false, false)
};
println!("cloudflare-ddns v{VERSION}");
// Load config
let app_config = match config::load_config(dry_run, repeat, &initial_pp) {
Ok(c) => c,
Err(e) => {
eprintln!("{e}");
sleep(Duration::from_secs(10)).await;
std::process::exit(1);
}
};
// Create PP with final settings
let ppfmt = PP::new(app_config.emoji, app_config.quiet);
if dry_run {
ppfmt.noticef(
pp::EMOJI_WARNING,
"[DRY RUN] No records will be created, updated, or deleted.",
);
}
// Print config summary (env mode only)
config::print_config_summary(&app_config, &ppfmt);
// Setup notifiers and heartbeats
let notifier = config::setup_notifiers(&ppfmt);
let heartbeat = config::setup_heartbeats(&ppfmt);
// Create Cloudflare handle (for env mode)
let handle = if !app_config.legacy_mode {
CloudflareHandle::new(
app_config.auth.clone(),
app_config.update_timeout,
app_config.managed_comment_regex.clone(),
app_config.managed_waf_comment_regex.clone(),
)
} else {
// Create a dummy handle for legacy mode (won't be used)
CloudflareHandle::new(
Auth::Token(String::new()),
Duration::from_secs(30),
None,
None,
)
};
// Signal handler for graceful shutdown
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
tokio::spawn(async move {
let _ = signal::ctrl_c().await;
println!("Stopping...");
r.store(false, Ordering::SeqCst);
});
// Start heartbeat
heartbeat.start().await;
if app_config.legacy_mode {
// --- Legacy mode (original cloudflare-ddns behavior) ---
run_legacy_mode(&app_config, &handle, &notifier, &heartbeat, &ppfmt, running).await;
} else {
// --- Env var mode (cf-ddns behavior) ---
run_env_mode(&app_config, &handle, &notifier, &heartbeat, &ppfmt, running).await;
}
// On shutdown: delete records if configured
if app_config.delete_on_stop && !app_config.legacy_mode {
ppfmt.noticef(pp::EMOJI_STOP, "Deleting records on stop...");
updater::final_delete(&app_config, &handle, &notifier, &heartbeat, &ppfmt).await;
}
// Exit heartbeat
heartbeat
.exit(&Message::new_ok("Shutting down"))
.await;
}
async fn run_legacy_mode(
config: &AppConfig,
handle: &CloudflareHandle,
notifier: &CompositeNotifier,
heartbeat: &Heartbeat,
ppfmt: &PP,
running: Arc<AtomicBool>,
) {
let legacy = match &config.legacy_config {
Some(l) => l,
None => return,
};
if config.repeat {
match (legacy.a, legacy.aaaa) {
(true, true) => println!(
"Updating IPv4 (A) & IPv6 (AAAA) records every {} seconds",
legacy.ttl
),
(true, false) => {
println!("Updating IPv4 (A) records every {} seconds", legacy.ttl)
}
(false, true) => {
println!("Updating IPv6 (AAAA) records every {} seconds", legacy.ttl)
}
(false, false) => println!("Both IPv4 and IPv6 are disabled"),
}
while running.load(Ordering::SeqCst) {
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await;
for _ in 0..legacy.ttl {
if !running.load(Ordering::SeqCst) {
break;
}
sleep(Duration::from_secs(1)).await;
}
}
} else {
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await;
}
}
async fn run_env_mode(
config: &AppConfig,
handle: &CloudflareHandle,
notifier: &CompositeNotifier,
heartbeat: &Heartbeat,
ppfmt: &PP,
running: Arc<AtomicBool>,
) {
match &config.update_cron {
CronSchedule::Once => {
if config.update_on_start {
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await;
}
}
schedule => {
let interval = schedule.next_duration().unwrap_or(Duration::from_secs(300));
ppfmt.noticef(
pp::EMOJI_LAUNCH,
&format!(
"Started cloudflare-ddns, updating every {}",
describe_duration(interval)
),
);
// Update on start if configured
if config.update_on_start {
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await;
}
// Main loop
while running.load(Ordering::SeqCst) {
// Sleep for interval, checking running flag each second
let secs = interval.as_secs();
let next_time = chrono::Local::now() + chrono::Duration::seconds(secs as i64);
ppfmt.infof(
pp::EMOJI_SLEEP,
&format!(
"Next update at {}",
next_time.format("%Y-%m-%d %H:%M:%S %Z")
),
);
for _ in 0..secs {
if !running.load(Ordering::SeqCst) {
return;
}
sleep(Duration::from_secs(1)).await;
}
if !running.load(Ordering::SeqCst) {
return;
}
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await;
}
}
}
}
fn describe_duration(d: Duration) -> String {
let secs = d.as_secs();
if secs >= 3600 {
let hours = secs / 3600;
let mins = (secs % 3600) / 60;
if mins > 0 {
format!("{hours}h{mins}m")
} else {
format!("{hours}h")
}
} else if secs >= 60 {
let mins = secs / 60;
let s = secs % 60;
if s > 0 {
format!("{mins}m{s}s")
} else {
format!("{mins}m")
}
} else {
format!("{secs}s")
}
}
// ============================================================
// Tests (backwards compatible with original test suite)
// ============================================================
#[cfg(test)]
mod tests {
use crate::config::{
LegacyAuthentication, LegacyCloudflareEntry, LegacyConfig, LegacySubdomainEntry,
parse_legacy_config,
};
use crate::provider::parse_trace_ip;
use reqwest::Client;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn test_config(zone_id: &str) -> LegacyConfig {
LegacyConfig {
cloudflare: vec![LegacyCloudflareEntry {
authentication: LegacyAuthentication {
api_token: "test-token".to_string(),
api_key: None,
},
zone_id: zone_id.to_string(),
subdomains: vec![
LegacySubdomainEntry::Detailed {
name: "".to_string(),
proxied: false,
},
LegacySubdomainEntry::Detailed {
name: "vpn".to_string(),
proxied: true,
},
],
proxied: false,
}],
a: true,
aaaa: false,
purge_unknown_records: false,
ttl: 300,
}
}
// Helper to create a legacy client for testing
struct TestDdnsClient {
client: Client,
cf_api_base: String,
ipv4_urls: Vec<String>,
dry_run: bool,
}
impl TestDdnsClient {
fn new(base_url: &str) -> Self {
Self {
client: Client::new(),
cf_api_base: base_url.to_string(),
ipv4_urls: vec![format!("{base_url}/cdn-cgi/trace")],
dry_run: false,
}
}
fn dry_run(mut self) -> Self {
self.dry_run = true;
self
}
async fn cf_api<T: serde::de::DeserializeOwned>(
&self,
endpoint: &str,
method_str: &str,
token: &str,
body: Option<&impl serde::Serialize>,
) -> Option<T> {
let url = format!("{}/{endpoint}", self.cf_api_base);
let mut req = match method_str {
"GET" => self.client.get(&url),
"POST" => self.client.post(&url),
"PUT" => self.client.put(&url),
"DELETE" => self.client.delete(&url),
_ => return None,
};
req = req.header("Authorization", format!("Bearer {token}"));
if let Some(b) = body {
req = req.json(b);
}
match req.send().await {
Ok(resp) if resp.status().is_success() => resp.json::<T>().await.ok(),
Ok(resp) => {
let text = resp.text().await.unwrap_or_default();
eprintln!("Error: {text}");
None
}
Err(e) => {
eprintln!("Exception: {e}");
None
}
}
}
async fn get_ip(&self) -> Option<String> {
for url in &self.ipv4_urls {
if let Ok(resp) = self.client.get(url).send().await {
if let Ok(body) = resp.text().await {
if let Some(ip) = parse_trace_ip(&body) {
return Some(ip);
}
}
}
}
None
}
async fn commit_record(
&self,
ip: &str,
record_type: &str,
config: &[LegacyCloudflareEntry],
ttl: i64,
purge_unknown_records: bool,
) {
for entry in config {
#[derive(serde::Deserialize)]
struct Resp<T> {
result: Option<T>,
}
#[derive(serde::Deserialize)]
struct Zone {
name: String,
}
#[derive(serde::Deserialize)]
struct Rec {
id: String,
name: String,
content: String,
proxied: bool,
}
let zone_resp: Option<Resp<Zone>> = self
.cf_api(
&format!("zones/{}", entry.zone_id),
"GET",
&entry.authentication.api_token,
None::<&()>.as_ref(),
)
.await;
let base_domain = match zone_resp.and_then(|r| r.result) {
Some(z) => z.name,
None => continue,
};
for subdomain in &entry.subdomains {
let (name, proxied) = match subdomain {
LegacySubdomainEntry::Detailed { name, proxied } => {
(name.to_lowercase().trim().to_string(), *proxied)
}
LegacySubdomainEntry::Simple(name) => {
(name.to_lowercase().trim().to_string(), entry.proxied)
}
};
let fqdn = crate::domain::make_fqdn(&name, &base_domain);
#[derive(serde::Serialize)]
struct Payload {
#[serde(rename = "type")]
record_type: String,
name: String,
content: String,
proxied: bool,
ttl: i64,
}
let record = Payload {
record_type: record_type.to_string(),
name: fqdn.clone(),
content: ip.to_string(),
proxied,
ttl,
};
let dns_endpoint = format!(
"zones/{}/dns_records?per_page=100&type={record_type}",
entry.zone_id
);
let dns_records: Option<Resp<Vec<Rec>>> = self
.cf_api(
&dns_endpoint,
"GET",
&entry.authentication.api_token,
None::<&()>.as_ref(),
)
.await;
let mut identifier: Option<String> = None;
let mut modified = false;
let mut duplicate_ids: Vec<String> = Vec::new();
if let Some(resp) = dns_records {
if let Some(records) = resp.result {
for r in &records {
if r.name == fqdn {
if let Some(ref existing_id) = identifier {
if r.content == ip {
duplicate_ids.push(existing_id.clone());
identifier = Some(r.id.clone());
} else {
duplicate_ids.push(r.id.clone());
}
} else {
identifier = Some(r.id.clone());
if r.content != ip || r.proxied != proxied {
modified = true;
}
}
}
}
}
}
if let Some(ref id) = identifier {
if modified {
if self.dry_run {
println!("[DRY RUN] Would update record {fqdn} -> {ip}");
} else {
println!("Updating record {fqdn} -> {ip}");
let update_endpoint =
format!("zones/{}/dns_records/{id}", entry.zone_id);
let _: Option<serde_json::Value> = self
.cf_api(
&update_endpoint,
"PUT",
&entry.authentication.api_token,
Some(&record),
)
.await;
}
} else if self.dry_run {
println!("[DRY RUN] Record {fqdn} is up to date ({ip})");
}
} else if self.dry_run {
println!("[DRY RUN] Would add new record {fqdn} -> {ip}");
} else {
println!("Adding new record {fqdn} -> {ip}");
let create_endpoint =
format!("zones/{}/dns_records", entry.zone_id);
let _: Option<serde_json::Value> = self
.cf_api(
&create_endpoint,
"POST",
&entry.authentication.api_token,
Some(&record),
)
.await;
}
if purge_unknown_records {
for dup_id in &duplicate_ids {
if self.dry_run {
println!("[DRY RUN] Would delete stale record {dup_id}");
} else {
println!("Deleting stale record {dup_id}");
let del_endpoint =
format!("zones/{}/dns_records/{dup_id}", entry.zone_id);
let _: Option<serde_json::Value> = self
.cf_api(
&del_endpoint,
"DELETE",
&entry.authentication.api_token,
None::<&()>.as_ref(),
)
.await;
}
}
}
}
}
}
}
#[test]
fn test_parse_trace_ip() {
let body = "fl=1f1\nh=1.1.1.1\nip=203.0.113.42\nts=1234567890\nvisit_scheme=https\n";
assert_eq!(parse_trace_ip(body), Some("203.0.113.42".to_string()));
}
#[test]
fn test_parse_trace_ip_missing() {
let body = "fl=1f1\nh=1.1.1.1\nts=1234567890\n";
assert_eq!(parse_trace_ip(body), None);
}
#[test]
fn test_parse_config_minimal() {
let json = r#"{
"cloudflare": [{
"authentication": { "api_token": "tok123" },
"zone_id": "zone1",
"subdomains": ["@"]
}]
}"#;
let config = parse_legacy_config(json).unwrap();
assert!(config.a);
assert!(config.aaaa);
assert!(!config.purge_unknown_records);
assert_eq!(config.ttl, 300);
}
#[test]
fn test_parse_config_low_ttl() {
let json = r#"{
"cloudflare": [{
"authentication": { "api_token": "tok123" },
"zone_id": "zone1",
"subdomains": ["@"]
}],
"ttl": 10
}"#;
let config = parse_legacy_config(json).unwrap();
assert_eq!(config.ttl, 1);
}
#[tokio::test]
async fn test_ip_detection() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/cdn-cgi/trace"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("fl=1f1\nh=mock\nip=198.51.100.7\nts=0\n"),
)
.mount(&mock_server)
.await;
let ddns = TestDdnsClient::new(&mock_server.uri());
let ip = ddns.get_ip().await;
assert_eq!(ip, Some("198.51.100.7".to_string()));
}
#[tokio::test]
async fn test_creates_new_record() {
let mock_server = MockServer::start().await;
let zone_id = "zone-abc-123";
Mock::given(method("GET"))
.and(path(format!("/zones/{zone_id}")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"result": { "name": "example.com" }
})))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path(format!("/zones/{zone_id}/dns_records")))
.and(query_param("type", "A"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"result": []
})))
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path(format!("/zones/{zone_id}/dns_records")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"result": { "id": "new-record-1" }
})))
.expect(2)
.mount(&mock_server)
.await;
let ddns = TestDdnsClient::new(&mock_server.uri());
let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false)
.await;
}
#[tokio::test]
async fn test_updates_existing_record() {
let mock_server = MockServer::start().await;
let zone_id = "zone-update-1";
Mock::given(method("GET"))
.and(path(format!("/zones/{zone_id}")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"result": { "name": "example.com" }
})))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path(format!("/zones/{zone_id}/dns_records")))
.and(query_param("type", "A"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"result": [
{ "id": "rec-1", "name": "example.com", "content": "10.0.0.1", "proxied": false },
{ "id": "rec-2", "name": "vpn.example.com", "content": "10.0.0.1", "proxied": true }
]
})))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path(format!("/zones/{zone_id}/dns_records/rec-1")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"result": { "id": "rec-1" }
})))
.expect(1)
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path(format!("/zones/{zone_id}/dns_records/rec-2")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"result": { "id": "rec-2" }
})))
.expect(1)
.mount(&mock_server)
.await;
let ddns = TestDdnsClient::new(&mock_server.uri());
let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false)
.await;
}
#[tokio::test]
async fn test_skips_up_to_date_record() {
let mock_server = MockServer::start().await;
let zone_id = "zone-noop";
Mock::given(method("GET"))
.and(path(format!("/zones/{zone_id}")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"result": { "name": "example.com" }
})))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path(format!("/zones/{zone_id}/dns_records")))
.and(query_param("type", "A"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"result": [
{ "id": "rec-1", "name": "example.com", "content": "198.51.100.7", "proxied": false },
{ "id": "rec-2", "name": "vpn.example.com", "content": "198.51.100.7", "proxied": true }
]
})))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&mock_server)
.await;
let ddns = TestDdnsClient::new(&mock_server.uri());
let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false)
.await;
}
#[tokio::test]
async fn test_dry_run_does_not_mutate() {
let mock_server = MockServer::start().await;
let zone_id = "zone-dry";
Mock::given(method("GET"))
.and(path(format!("/zones/{zone_id}")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"result": { "name": "example.com" }
})))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path(format!("/zones/{zone_id}/dns_records")))
.and(query_param("type", "A"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"result": []
})))
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&mock_server)
.await;
let ddns = TestDdnsClient::new(&mock_server.uri()).dry_run();
let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false)
.await;
}
#[tokio::test]
async fn test_purge_duplicate_records() {
let mock_server = MockServer::start().await;
let zone_id = "zone-purge";
Mock::given(method("GET"))
.and(path(format!("/zones/{zone_id}")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"result": { "name": "example.com" }
})))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path(format!("/zones/{zone_id}/dns_records")))
.and(query_param("type", "A"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"result": [
{ "id": "rec-keep", "name": "example.com", "content": "198.51.100.7", "proxied": false },
{ "id": "rec-dup", "name": "example.com", "content": "198.51.100.7", "proxied": false }
]
})))
.mount(&mock_server)
.await;
Mock::given(method("DELETE"))
.and(path(format!("/zones/{zone_id}/dns_records/rec-keep")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.expect(1)
.mount(&mock_server)
.await;
let ddns = TestDdnsClient::new(&mock_server.uri());
let config = LegacyConfig {
cloudflare: vec![LegacyCloudflareEntry {
authentication: LegacyAuthentication {
api_token: "test-token".to_string(),
api_key: None,
},
zone_id: zone_id.to_string(),
subdomains: vec![LegacySubdomainEntry::Detailed {
name: "".to_string(),
proxied: false,
}],
proxied: false,
}],
a: true,
aaaa: false,
purge_unknown_records: true,
ttl: 300,
};
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, true)
.await;
}
// --- describe_duration tests ---
#[test]
fn test_describe_duration_seconds_only() {
use tokio::time::Duration;
assert_eq!(super::describe_duration(Duration::from_secs(45)), "45s");
}
#[test]
fn test_describe_duration_exact_minutes() {
use tokio::time::Duration;
assert_eq!(super::describe_duration(Duration::from_secs(300)), "5m");
}
#[test]
fn test_describe_duration_minutes_and_seconds() {
use tokio::time::Duration;
assert_eq!(super::describe_duration(Duration::from_secs(330)), "5m30s");
}
#[test]
fn test_describe_duration_exact_hours() {
use tokio::time::Duration;
assert_eq!(super::describe_duration(Duration::from_secs(7200)), "2h");
}
#[test]
fn test_describe_duration_hours_and_minutes() {
use tokio::time::Duration;
assert_eq!(super::describe_duration(Duration::from_secs(5400)), "1h30m");
}
#[tokio::test]
async fn test_end_to_end_detect_and_update() {
let mock_server = MockServer::start().await;
let zone_id = "zone-e2e";
Mock::given(method("GET"))
.and(path("/cdn-cgi/trace"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("fl=1f1\nh=mock\nip=203.0.113.99\nts=0\n"),
)
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path(format!("/zones/{zone_id}")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"result": { "name": "example.com" }
})))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path(format!("/zones/{zone_id}/dns_records")))
.and(query_param("type", "A"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"result": [
{ "id": "rec-root", "name": "example.com", "content": "10.0.0.1", "proxied": false }
]
})))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path(format!("/zones/{zone_id}/dns_records/rec-root")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"result": { "id": "rec-root" }
})))
.expect(1)
.mount(&mock_server)
.await;
let ddns = TestDdnsClient::new(&mock_server.uri());
let ip = ddns.get_ip().await;
assert_eq!(ip, Some("203.0.113.99".to_string()));
let config = LegacyConfig {
cloudflare: vec![LegacyCloudflareEntry {
authentication: LegacyAuthentication {
api_token: "test-token".to_string(),
api_key: None,
},
zone_id: zone_id.to_string(),
subdomains: vec![LegacySubdomainEntry::Detailed {
name: "".to_string(),
proxied: false,
}],
proxied: false,
}],
a: true,
aaaa: false,
purge_unknown_records: false,
ttl: 300,
};
ddns.commit_record("203.0.113.99", "A", &config.cloudflare, 300, false)
.await;
}
}

1436
src/notifier.rs Normal file

File diff suppressed because it is too large Load Diff

435
src/pp.rs Normal file
View File

@@ -0,0 +1,435 @@
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
// Verbosity levels
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Verbosity {
Quiet,
Notice,
Info,
Verbose,
}
// Emoji constants
#[allow(dead_code)]
pub const EMOJI_GLOBE: &str = "\u{1F30D}";
pub const EMOJI_WARNING: &str = "\u{26A0}\u{FE0F}";
pub const EMOJI_ERROR: &str = "\u{274C}";
#[allow(dead_code)]
pub const EMOJI_SUCCESS: &str = "\u{2705}";
pub const EMOJI_LAUNCH: &str = "\u{1F680}";
pub const EMOJI_STOP: &str = "\u{1F6D1}";
pub const EMOJI_SLEEP: &str = "\u{1F634}";
pub const EMOJI_DETECT: &str = "\u{1F50D}";
pub const EMOJI_UPDATE: &str = "\u{2B06}\u{FE0F}";
pub const EMOJI_CREATE: &str = "\u{2795}";
pub const EMOJI_DELETE: &str = "\u{2796}";
pub const EMOJI_SKIP: &str = "\u{23ED}\u{FE0F}";
pub const EMOJI_NOTIFY: &str = "\u{1F514}";
pub const EMOJI_HEARTBEAT: &str = "\u{1F493}";
pub const EMOJI_CONFIG: &str = "\u{2699}\u{FE0F}";
#[allow(dead_code)]
pub const EMOJI_HINT: &str = "\u{1F4A1}";
const INDENT_PREFIX: &str = " ";
pub struct PP {
pub verbosity: Verbosity,
pub emoji: bool,
indent: usize,
seen: Arc<Mutex<HashSet<String>>>,
}
impl PP {
pub fn new(emoji: bool, quiet: bool) -> Self {
Self {
verbosity: if quiet { Verbosity::Quiet } else { Verbosity::Verbose },
emoji,
indent: 0,
seen: Arc::new(Mutex::new(HashSet::new())),
}
}
pub fn default_pp() -> Self {
Self::new(false, false)
}
pub fn is_showing(&self, level: Verbosity) -> bool {
self.verbosity >= level
}
pub fn indent(&self) -> PP {
PP {
verbosity: self.verbosity,
emoji: self.emoji,
indent: self.indent + 1,
seen: Arc::clone(&self.seen),
}
}
fn output(&self, emoji: &str, msg: &str) {
let prefix = INDENT_PREFIX.repeat(self.indent);
if self.emoji && !emoji.is_empty() {
println!("{prefix}{emoji} {msg}");
} else {
println!("{prefix}{msg}");
}
}
fn output_err(&self, emoji: &str, msg: &str) {
let prefix = INDENT_PREFIX.repeat(self.indent);
if self.emoji && !emoji.is_empty() {
eprintln!("{prefix}{emoji} {msg}");
} else {
eprintln!("{prefix}{msg}");
}
}
pub fn infof(&self, emoji: &str, msg: &str) {
if self.is_showing(Verbosity::Info) {
self.output(emoji, msg);
}
}
pub fn noticef(&self, emoji: &str, msg: &str) {
if self.is_showing(Verbosity::Notice) {
self.output(emoji, msg);
}
}
pub fn warningf(&self, emoji: &str, msg: &str) {
self.output_err(emoji, msg);
}
pub fn errorf(&self, emoji: &str, msg: &str) {
self.output_err(emoji, msg);
}
#[allow(dead_code)]
pub fn info_once(&self, key: &str, emoji: &str, msg: &str) {
if self.is_showing(Verbosity::Info) {
let mut seen = self.seen.lock().unwrap();
if seen.insert(key.to_string()) {
self.output(emoji, msg);
}
}
}
#[allow(dead_code)]
pub fn notice_once(&self, key: &str, emoji: &str, msg: &str) {
if self.is_showing(Verbosity::Notice) {
let mut seen = self.seen.lock().unwrap();
if seen.insert(key.to_string()) {
self.output(emoji, msg);
}
}
}
#[allow(dead_code)]
pub fn blank_line_if_verbose(&self) {
if self.is_showing(Verbosity::Verbose) {
println!();
}
}
}
#[allow(dead_code)]
pub fn english_join(items: &[String]) -> String {
match items.len() {
0 => String::new(),
1 => items[0].clone(),
2 => format!("{} and {}", items[0], items[1]),
_ => {
let (last, rest) = items.split_last().unwrap();
format!("{}, and {last}", rest.join(", "))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// ---- PP::new with emoji flag ----
#[test]
fn new_with_emoji_true() {
let pp = PP::new(true, false);
assert!(pp.emoji);
}
#[test]
fn new_with_emoji_false() {
let pp = PP::new(false, false);
assert!(!pp.emoji);
}
// ---- PP::new with quiet flag (verbosity levels) ----
#[test]
fn new_quiet_true_sets_verbosity_quiet() {
let pp = PP::new(false, true);
assert_eq!(pp.verbosity, Verbosity::Quiet);
}
#[test]
fn new_quiet_false_sets_verbosity_verbose() {
let pp = PP::new(false, false);
assert_eq!(pp.verbosity, Verbosity::Verbose);
}
// ---- PP::is_showing at different verbosity levels ----
#[test]
fn quiet_shows_only_quiet_level() {
let pp = PP::new(false, true);
assert!(pp.is_showing(Verbosity::Quiet));
assert!(!pp.is_showing(Verbosity::Notice));
assert!(!pp.is_showing(Verbosity::Info));
assert!(!pp.is_showing(Verbosity::Verbose));
}
#[test]
fn verbose_shows_all_levels() {
let pp = PP::new(false, false);
assert!(pp.is_showing(Verbosity::Quiet));
assert!(pp.is_showing(Verbosity::Notice));
assert!(pp.is_showing(Verbosity::Info));
assert!(pp.is_showing(Verbosity::Verbose));
}
#[test]
fn notice_level_shows_quiet_and_notice_only() {
let mut pp = PP::new(false, false);
pp.verbosity = Verbosity::Notice;
assert!(pp.is_showing(Verbosity::Quiet));
assert!(pp.is_showing(Verbosity::Notice));
assert!(!pp.is_showing(Verbosity::Info));
assert!(!pp.is_showing(Verbosity::Verbose));
}
#[test]
fn info_level_shows_up_to_info() {
let mut pp = PP::new(false, false);
pp.verbosity = Verbosity::Info;
assert!(pp.is_showing(Verbosity::Quiet));
assert!(pp.is_showing(Verbosity::Notice));
assert!(pp.is_showing(Verbosity::Info));
assert!(!pp.is_showing(Verbosity::Verbose));
}
// ---- PP::indent ----
#[test]
fn indent_increments_indent_level() {
let pp = PP::new(true, false);
assert_eq!(pp.indent, 0);
let child = pp.indent();
assert_eq!(child.indent, 1);
let grandchild = child.indent();
assert_eq!(grandchild.indent, 2);
}
#[test]
fn indent_preserves_verbosity_and_emoji() {
let pp = PP::new(true, true);
let child = pp.indent();
assert_eq!(child.verbosity, pp.verbosity);
assert_eq!(child.emoji, pp.emoji);
}
#[test]
fn indent_shares_seen_state() {
let pp = PP::new(false, false);
let child = pp.indent();
// Insert via parent's seen set
pp.seen.lock().unwrap().insert("key1".to_string());
// Child should observe the same entry
assert!(child.seen.lock().unwrap().contains("key1"));
// Insert via child
child.seen.lock().unwrap().insert("key2".to_string());
// Parent should observe it too
assert!(pp.seen.lock().unwrap().contains("key2"));
}
// ---- PP::infof, noticef, warningf, errorf - no panic and verbosity gating ----
#[test]
fn infof_does_not_panic_when_verbose() {
let pp = PP::new(false, false);
pp.infof("", "test info message");
}
#[test]
fn infof_does_not_panic_when_quiet() {
let pp = PP::new(false, true);
// Should simply not print, and not panic
pp.infof("", "test info message");
}
#[test]
fn noticef_does_not_panic_when_verbose() {
let pp = PP::new(true, false);
pp.noticef(EMOJI_DETECT, "test notice message");
}
#[test]
fn noticef_does_not_panic_when_quiet() {
let pp = PP::new(false, true);
pp.noticef("", "test notice message");
}
#[test]
fn warningf_does_not_panic() {
let pp = PP::new(true, false);
pp.warningf(EMOJI_WARNING, "test warning");
}
#[test]
fn warningf_does_not_panic_when_quiet() {
// warningf always outputs (no verbosity check), just verify no panic
let pp = PP::new(false, true);
pp.warningf("", "test warning");
}
#[test]
fn errorf_does_not_panic() {
let pp = PP::new(true, false);
pp.errorf(EMOJI_ERROR, "test error");
}
#[test]
fn errorf_does_not_panic_when_quiet() {
let pp = PP::new(false, true);
pp.errorf("", "test error");
}
// ---- PP::info_once and notice_once ----
#[test]
fn info_once_suppresses_duplicates() {
let pp = PP::new(false, false);
// First call inserts the key
pp.info_once("dup_key", "", "first");
// The key should now be in the seen set
assert!(pp.seen.lock().unwrap().contains("dup_key"));
// Calling again with the same key should not insert again (set unchanged)
let size_before = pp.seen.lock().unwrap().len();
pp.info_once("dup_key", "", "second");
let size_after = pp.seen.lock().unwrap().len();
assert_eq!(size_before, size_after);
}
#[test]
fn info_once_allows_different_keys() {
let pp = PP::new(false, false);
pp.info_once("key_a", "", "msg a");
pp.info_once("key_b", "", "msg b");
let seen = pp.seen.lock().unwrap();
assert!(seen.contains("key_a"));
assert!(seen.contains("key_b"));
assert_eq!(seen.len(), 2);
}
#[test]
fn info_once_skipped_when_quiet() {
let pp = PP::new(false, true);
pp.info_once("quiet_key", "", "should not register");
// Because verbosity is Quiet, info_once should not even insert the key
assert!(!pp.seen.lock().unwrap().contains("quiet_key"));
}
#[test]
fn notice_once_suppresses_duplicates() {
let pp = PP::new(false, false);
pp.notice_once("notice_dup", "", "first");
assert!(pp.seen.lock().unwrap().contains("notice_dup"));
let size_before = pp.seen.lock().unwrap().len();
pp.notice_once("notice_dup", "", "second");
let size_after = pp.seen.lock().unwrap().len();
assert_eq!(size_before, size_after);
}
#[test]
fn notice_once_skipped_when_quiet() {
let pp = PP::new(false, true);
pp.notice_once("quiet_notice", "", "should not register");
assert!(!pp.seen.lock().unwrap().contains("quiet_notice"));
}
#[test]
fn info_once_shared_via_indent() {
let pp = PP::new(false, false);
let child = pp.indent();
// Mark a key via the parent
pp.info_once("shared_key", "", "parent");
assert!(pp.seen.lock().unwrap().contains("shared_key"));
// Child should see it as already present, so set size stays the same
let size_before = child.seen.lock().unwrap().len();
child.info_once("shared_key", "", "child duplicate");
let size_after = child.seen.lock().unwrap().len();
assert_eq!(size_before, size_after);
// Child can add a new key visible to parent
child.info_once("child_key", "", "child new");
assert!(pp.seen.lock().unwrap().contains("child_key"));
}
// ---- english_join ----
#[test]
fn english_join_empty() {
let items: Vec<String> = vec![];
assert_eq!(english_join(&items), "");
}
#[test]
fn english_join_single() {
let items = vec!["alpha".to_string()];
assert_eq!(english_join(&items), "alpha");
}
#[test]
fn english_join_two() {
let items = vec!["alpha".to_string(), "beta".to_string()];
assert_eq!(english_join(&items), "alpha and beta");
}
#[test]
fn english_join_three() {
let items = vec![
"alpha".to_string(),
"beta".to_string(),
"gamma".to_string(),
];
assert_eq!(english_join(&items), "alpha, beta, and gamma");
}
#[test]
fn english_join_four() {
let items = vec![
"a".to_string(),
"b".to_string(),
"c".to_string(),
"d".to_string(),
];
assert_eq!(english_join(&items), "a, b, c, and d");
}
// ---- default_pp ----
#[test]
fn default_pp_is_verbose_no_emoji() {
let pp = PP::default_pp();
assert!(!pp.emoji);
assert_eq!(pp.verbosity, Verbosity::Verbose);
}
}

1201
src/provider.rs Normal file

File diff suppressed because it is too large Load Diff

2375
src/updater.rs Normal file

File diff suppressed because it is too large Load Diff