mirror of
https://github.com/timothymiller/cloudflare-ddns.git
synced 2026-03-21 22:48:57 -03:00
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:
1774
src/cloudflare.rs
Normal file
1774
src/cloudflare.rs
Normal file
File diff suppressed because it is too large
Load Diff
1961
src/config.rs
Normal file
1961
src/config.rs
Normal file
File diff suppressed because it is too large
Load Diff
547
src/domain.rs
Normal file
547
src/domain.rs
Normal 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
920
src/main.rs
Normal 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, ¬ifier, &heartbeat, &ppfmt, running).await;
|
||||
} else {
|
||||
// --- Env var mode (cf-ddns behavior) ---
|
||||
run_env_mode(&app_config, &handle, ¬ifier, &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, ¬ifier, &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
1436
src/notifier.rs
Normal file
File diff suppressed because it is too large
Load Diff
435
src/pp.rs
Normal file
435
src/pp.rs
Normal 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
1201
src/provider.rs
Normal file
File diff suppressed because it is too large
Load Diff
2375
src/updater.rs
Normal file
2375
src/updater.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user