//! Driving the `pimsync` binary. //! //! pimsync handles the CalDAV and WebCal legs, mirroring each remote into a local //! vdir. It is invoked one-shot: its own daemon would race the reconciler over the //! same files, so scheduling belongs to calcalist. use std::fmt; use std::fs; use std::io; use std::os::fd::AsFd; use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; use thiserror::Error; use crate::config::{Config, Endpoint, EndpointKind}; pub const BINARY: &str = "pimsync"; /// The oldest pimsync this build generates configuration for. `discover`'s /// output format is undocumented and changed between 0.5.9 and 0.5.11, and /// `parse_discovery` only understands the newer shape. pub const MIN_VERSION: Version = Version { major: 0, minor: 5, patch: 11, }; /// The first release this build makes no claim about. pimsync is pre-1.0 and /// its config syntax may change between minor releases, so a series is /// admitted only once read: 0.6.0 renamed `auth` to `auth_method`, which costs /// nothing here — generated storages carry `username` and `password`, never /// `auth` — and left `discover`'s output word for word as it was. pub const NEXT_UNKNOWN_VERSION: Version = Version { major: 0, minor: 7, patch: 0, }; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub struct Version { pub major: u64, pub minor: u64, pub patch: u64, } impl fmt::Display for Version { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}.{}.{}", self.major, self.minor, self.patch) } } impl Version { pub fn is_supported(&self) -> bool { *self >= MIN_VERSION && *self < NEXT_UNKNOWN_VERSION } /// Parses the `pimsync 0.5.11` line printed by `pimsync version`. fn parse(output: &str) -> Option { let field = output.split_whitespace().nth(1)?; let mut parts = field.split('.'); Some(Version { major: number(parts.next()?)?, minor: number(parts.next()?)?, patch: number(parts.next().unwrap_or("0"))?, }) } } /// The leading digits of one version component. pimsync takes its version from /// `git describe`, so a build off a tag carries a suffix — `0.6.0-1-ga08ee3c` /// — which belongs to no number. fn number(part: &str) -> Option { part.split(|c: char| !c.is_ascii_digit()) .next()? .parse() .ok() } #[derive(Debug, Error)] pub enum PimsyncError { #[error("`{BINARY}` was not found on PATH; install pimsync {MIN_VERSION} or newer")] NotFound, #[error("could not run `{BINARY}`: {0}")] Spawn(#[source] io::Error), #[error("`{BINARY} version` failed: {0}")] Failed(String), #[error("could not read a version out of `{0}`")] Unparseable(String), #[error("could not write {path}: {source}")] Write { path: std::path::PathBuf, #[source] source: io::Error, }, #[error("`{BINARY} {command}` failed: {message}")] Command { command: String, message: String }, #[error( "endpoint `{endpoint}` has url `{url}`, which names no calendar collection; it must point at the calendar itself, not at the server root" )] UrlHasNoCollection { endpoint: String, url: String }, } /// Asks the installed pimsync for its version. pub fn probe() -> Result { let output = Command::new(BINARY) .arg("version") .output() .map_err(spawn_error)?; if !output.status.success() { return Err(PimsyncError::Failed( String::from_utf8_lossy(&output.stderr).trim().to_string(), )); } let text = String::from_utf8_lossy(&output.stdout); let line = text.lines().next().unwrap_or_default().trim(); Version::parse(line).ok_or_else(|| PimsyncError::Unparseable(line.to_string())) } /// Name of the single local storage every pair mirrors into. pimsync allows one /// storage to take part in several pairs, so one declaration serves all of them. const LOCAL_STORAGE: &str = "local"; pub fn config_path(state_dir: &Path) -> PathBuf { state_dir.join("pimsync.conf") } pub(crate) fn status_path(state_dir: &Path) -> PathBuf { state_dir.join("pimsync-status") } /// Which way a generated configuration lets items travel. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Direction { /// The ordinary cycle: remote and local converge on each other. Both, /// Remote to local only, for `--dry-run`. pimsync writes nothing to any /// server in this mode — but it does delete local items the remote has /// never seen, so it is only ever pointed at a throwaway copy of the vdirs. PullOnly, } /// Builds a pimsync configuration covering every CalDAV and WebCal endpoint. /// /// Google endpoints are absent by design: pimsync has no REST storage, and its /// CalDAV storage speaks only HTTP Basic auth, which Google's endpoint has /// rejected since March 2025. calcalist syncs those itself. pub fn generate_for( config: &Config, state_dir: &Path, direction: Direction, ) -> Result { let mut out = String::new(); out.push_str("# Generated by calcalist. Edits here are overwritten on every sync.\n\n"); out.push_str(&format!( "status_path {}\n\n", quote(&status_path(state_dir).display().to_string()) )); out.push_str(&format!( "storage {LOCAL_STORAGE} {{\n\ttype vdir/icalendar\n\tpath {}\n\tfileext ics\n}}\n", quote(&crate::sync::vdir_root(state_dir).display().to_string()) )); for endpoint in &config.endpoints { match &endpoint.kind { EndpointKind::Caldav { .. } => out.push_str(&caldav_pair(endpoint, direction)?), EndpointKind::Webcal { url, url_command } => { out.push_str(&webcal_pair( endpoint, url.as_deref(), url_command.as_deref(), )); } // Handled by calcalist's own Google module. EndpointKind::Google { .. } => {} } } Ok(out) } fn caldav_pair(endpoint: &Endpoint, direction: Direction) -> Result { let EndpointKind::Caldav { url, username, secret_command, } = &endpoint.kind else { unreachable!("caller matched on Caldav") }; let (base, href) = split_collection_url(url).ok_or_else(|| PimsyncError::UrlHasNoCollection { endpoint: endpoint.id.clone(), url: url.clone(), })?; let id = &endpoint.id; let mut block = format!( "\nstorage {id}_remote {{\n\ttype caldav\n\turl {}\n\tdiscovery collections\n\tusername {}\n", quote(&base), quote(username) ); if let Some(command) = secret_command { // Secrets never live in the portable config, so pimsync runs the same // command calcalist would to fetch them. block.push_str(&format!("\tpassword {{\n\t\tshell {command}\n\t}}\n")); } block.push_str("}\n"); // The remote is storage_a, so a concurrent server-side change wins over a // local one calcalist has not yet reconciled. Nothing is lost: the next // cycle sees the server's version and decides properly. let resolution = match direction { // `one_way` never produces a conflict, so there is nothing to resolve. Direction::PullOnly => "\tone_way\n", Direction::Both => "\tconflict_resolution keep a\n", }; block.push_str(&format!( "\npair {id} {{\n\tstorage_a {id}_remote\n\tstorage_b {LOCAL_STORAGE}\n\ \tcollection {{\n\t\talias {id}\n\t\thref_a {}\n\t\tid_b {id}\n\t}}\n\ {resolution}\ton_empty skip\n\ton_delete skip\n}}\n", quote(&href) )); Ok(block) } fn webcal_pair(endpoint: &Endpoint, url: Option<&str>, url_command: Option<&str>) -> String { let id = &endpoint.id; // A feed URL that is itself a credential is fetched by command, the same way // passwords are, so it never appears in either configuration file. let url_directive = match (url, url_command) { (_, Some(command)) => format!("\turl {{\n\t\tshell {command}\n\t}}\n"), (Some(url), None) => format!("\turl {}\n", quote(url)), (None, None) => String::new(), }; // No `read_only` directive here: pimsync 0.5.11 documents it as applying to // every storage type, but rejects it on a webcal storage with a bare "Could // not parse file". It is redundant regardless — webcal is read-only by type, // and `one_way` already fixes the direction. format!( "\nstorage {id}_remote {{\n\ttype webcal\n{url_directive}\tcollection_id {id}\n}}\n\ \npair {id} {{\n\tstorage_a {id}_remote\n\tstorage_b {LOCAL_STORAGE}\n\ \tcollection {{\n\t\talias {id}\n\t\tid_a {id}\n\t\tid_b {id}\n\t}}\n\ \tone_way\n\ton_empty skip\n\ton_delete skip\n}}\n" ) } /// Splits a calendar URL into the container to enumerate and the collection's /// own path. /// /// pimsync identifies a collection either by an id taken from a URL segment or /// by its exact path. The path is used here, so a calendar whose last segment /// happens to match another's cannot be confused for it. fn split_collection_url(url: &str) -> Option<(String, String)> { let authority_start = url.find("://")? + 3; let path_start = authority_start + url[authority_start..].find('/')?; let segments: Vec<&str> = url[path_start..] .split('/') .filter(|segment| !segment.is_empty()) .collect(); let (_, parents) = segments.split_last()?; let href = format!("/{}/", segments.join("/")); let base = match parents { [] => format!("{}/", &url[..path_start]), parents => format!("{}/{}/", &url[..path_start], parents.join("/")), }; Some((base, href)) } /// pimsync accepts quoted values, which keeps paths with spaces intact. fn quote(value: &str) -> String { format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) } /// Writes the generated configuration, returning its path. pub fn write_config(config: &Config, state_dir: &Path) -> Result { write_config_for(config, state_dir, Direction::Both) } pub fn write_config_for( config: &Config, state_dir: &Path, direction: Direction, ) -> Result { let path = config_path(state_dir); let text = generate_for(config, state_dir, direction)?; fs::create_dir_all(status_path(state_dir)).map_err(|source| PimsyncError::Write { path: status_path(state_dir), source, })?; fs::write(&path, text).map_err(|source| PimsyncError::Write { path: path.clone(), source, })?; Ok(path) } /// The storage name the probe config gives the server being examined. Parsing /// anchors on it, because pimsync discovers *both* storages in a pair and prints /// their collections under one heading each. const PROBE_REMOTE: &str = "probe_remote"; /// One collection found on a server. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Collection { /// The path pimsync reported, e.g. `/calendars/me/work/`. pub href: String, /// A usable endpoint id, taken from the last meaningful path segment. pub id: String, } /// Asks pimsync which calendars a server offers. /// /// `url` is a container to enumerate — an account or principal path — rather /// than a single calendar, so `split_collection_url` does not apply here. /// /// `pimsync discover` is documented as informational and as not altering /// pimsync's state, so pointing it at a server the user has not configured yet /// is safe. The probe configuration it needs is written under a name of its own /// so it can never be mistaken for, or overwrite, the generated `pimsync.conf`. pub fn discover( state_dir: &Path, url: &str, username: &str, secret_command: Option<&str>, ) -> Result, PimsyncError> { let config_path = state_dir.join("discover.conf"); let scratch = state_dir.join("discover-vdir"); fs::create_dir_all(&scratch).map_err(|source| PimsyncError::Write { path: scratch.clone(), source, })?; fs::write( &config_path, probe_config(state_dir, &scratch, url, username, secret_command), ) .map_err(|source| PimsyncError::Write { path: config_path.clone(), source, })?; let output = Command::new(BINARY) .arg("-c") .arg(&config_path) .arg("discover") .output() .map_err(spawn_error)?; // Everything, including the failures, comes back on stdout. let text = String::from_utf8_lossy(&output.stdout).into_owned(); let _ = fs::remove_dir_all(&scratch); if !output.status.success() { return Err(PimsyncError::Command { command: "discover".to_string(), message: strip_ansi(&text).trim().to_string(), }); } Ok(parse_discovery(&text)) } fn probe_config( state_dir: &Path, scratch: &Path, url: &str, username: &str, secret_command: Option<&str>, ) -> String { let password = match secret_command { Some(command) => format!("\tpassword {{\n\t\tshell {command}\n\t}}\n"), None => String::new(), }; format!( "# Generated by `calcalist discover`. Rewritten on every run.\n\ status_path {}\n\n\ storage local {{\n\ttype vdir/icalendar\n\tpath {}\n\tfileext ics\n}}\n\n\ storage {PROBE_REMOTE} {{\n\ttype caldav\n\turl {}\n\tdiscovery collections\n\ \tusername {}\n{password}}}\n\n\ pair probe {{\n\tstorage_a {PROBE_REMOTE}\n\tstorage_b local\n\ \tcollections from a\n}}\n", quote(&state_dir.join("discover-status").display().to_string()), quote(&scratch.display().to_string()), quote(url), quote(username), ) } /// Reads the collections out of `pimsync discover` output. /// /// The format is not documented, so this was established against a real server /// and is deliberately forgiving. The one thing it must get right: a pair has /// two storages and pimsync reports both, so `Found` lines are only taken after /// the heading naming the server. Otherwise the scratch directory's own /// contents would be offered as if they were the user's calendars. fn parse_discovery(output: &str) -> Vec { let mut collections = Vec::new(); let mut in_remote = false; for line in output.lines() { let line = strip_ansi(line); let line = line.trim(); if line.starts_with("==>") { in_remote = line.contains(PROBE_REMOTE); continue; } let Some(href) = line.strip_prefix("Found ") else { continue; }; let href = href.trim(); if !in_remote || href.is_empty() { continue; } collections.push(Collection { id: collection_id(href), href: href.to_string(), }); } collections } /// A plausible endpoint id: the last non-empty path segment, restricted to /// characters that are unambiguous in a configuration file. fn collection_id(href: &str) -> String { let segment = href .rsplit('/') .find(|segment| !segment.is_empty()) .unwrap_or(href); let id: String = segment .chars() .map(|ch| match ch { 'a'..='z' | '0'..='9' | '-' | '_' => ch, 'A'..='Z' => ch.to_ascii_lowercase(), _ => '-', }) .collect(); let id = id.trim_matches('-').to_string(); if id.is_empty() { "calendar".into() } else { id } } /// Removes ANSI colour, which pimsync emits even when its output is a pipe and /// even under NO_COLOR. fn strip_ansi(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut chars = text.chars(); while let Some(ch) = chars.next() { if ch != '\u{1b}' { out.push(ch); continue; } // Skip up to and including the terminating letter of the escape. for next in chars.by_ref() { if next.is_ascii_alphabetic() { break; } } } out } /// Runs one pimsync cycle. /// /// Deliberately one-shot: `pimsync daemon` would write the same vdirs the /// reconciler is reading, so calcalist owns the scheduling instead. /// A cycle is minutes of network work, and pimsync's per-item progress is the /// only sign it is alive. Capturing that output left `sync` looking wedged for /// the whole run and then printing nothing, so it goes straight to the terminal. pub fn sync(config_path: &Path) -> Result<(), PimsyncError> { let status = invocation(config_path, "sync") .stdout(progress_stream()?) .stderr(Stdio::inherit()) .status() .map_err(spawn_error)?; if status.success() { return Ok(()); } // The detail has already been printed as it happened; repeating it here // would only put a second copy under the report. Err(PimsyncError::Command { command: "sync".to_string(), message: format!("exited with {status}"), }) } /// Validates the generated configuration and the storages it names. /// /// Captured rather than streamed: `doctor` reads the message to tell a config /// it must fix from a server it cannot reach, and prints its own summary. pub fn check(config_path: &Path) -> Result<(), PimsyncError> { let output = invocation(config_path, "check") .output() .map_err(spawn_error)?; if output.status.success() { return Ok(()); } let message = diagnostics(&output); Err(PimsyncError::Command { command: "check".to_string(), message: if message.is_empty() { format!("exited with {}", output.status) } else { message }, }) } /// `pimsync -c `, with stdin closed so that a secret helper /// reaching for a terminal fails outright rather than waiting on one forever. fn invocation(config_path: &Path, command: &str) -> Command { let mut process = Command::new(BINARY); process .arg("-c") .arg(config_path) .arg(command) .stdin(Stdio::null()); process } /// pimsync's progress, routed to our stderr so that stdout carries only the /// report and stays worth piping. fn progress_stream() -> Result { io::stderr() .as_fd() .try_clone_to_owned() .map(Stdio::from) .map_err(PimsyncError::Spawn) } fn spawn_error(error: io::Error) -> PimsyncError { if error.kind() == io::ErrorKind::NotFound { PimsyncError::NotFound } else { PimsyncError::Spawn(error) } } /// What a failed run had to say for itself. /// /// Both streams are read: pimsync reports execution errors on stderr but puts /// parse failures on stdout, and which one carries the reason is not knowable /// from here. fn diagnostics(output: &Output) -> String { let streams = [&output.stderr, &output.stdout]; let said: Vec = streams .iter() .map(|stream| String::from_utf8_lossy(stream).trim().to_string()) .filter(|text| !text.is_empty()) .collect(); said.join("\n") } #[cfg(test)] mod tests { use super::*; use crate::config::Config; use std::path::Path; const CONFIG: &str = r#" version = 1 [[endpoint]] id = "work" type = "caldav" url = "https://dav.example.com/dav/calendars/user/me/work/" username = "me@example.com" secret_command = "pass show example/caldav" [[endpoint]] id = "gcal" type = "google" calendar_id = "me@example.com" client_id = "abc.apps.googleusercontent.com" [[endpoint]] id = "holidays" type = "webcal" url = "https://example.org/holidays.ics" "#; /// The text of one `storage`/`pair` block, so assertions do not accidentally /// match a directive belonging to a different block. fn block<'a>(text: &'a str, opening: &str) -> &'a str { let start = text.find(opening).expect("block should be present"); let rest = &text[start..]; rest.find("\n}\n").map_or(rest, |end| &rest[..end]) } fn generated() -> String { let config: Config = toml::from_str(CONFIG).expect("config should parse"); generate_for(&config, Path::new("/var/state/calcalist"), Direction::Both) .expect("generation should succeed") } #[test] fn declares_one_local_storage_for_every_pair() { let text = generated(); assert_eq!(text.matches("storage local {").count(), 1); assert!(text.contains("path \"/var/state/calcalist/vdir\"")); assert!(text.contains("status_path \"/var/state/calcalist/pimsync-status\"")); } /// pimsync cannot authenticate to Google at all, so it must not be asked to. #[test] fn google_endpoints_are_left_out_entirely() { let text = generated(); assert!(!text.contains("gcal"), "google endpoint leaked into {text}"); } #[test] fn a_caldav_endpoint_becomes_a_bidirectional_pair() { let text = generated(); assert!(text.contains("storage work_remote {")); assert!(text.contains("type caldav")); // The container is enumerated, and the calendar picked out by exact path. assert!(text.contains("url \"https://dav.example.com/dav/calendars/user/me/\"")); assert!(text.contains("href_a \"/dav/calendars/user/me/work/\"")); assert!(text.contains("id_b work")); assert!( !block(&text, "pair work {").contains("one_way"), "caldav pairs must stay bidirectional" ); } /// Secrets stay out of the portable config; pimsync fetches them the same way. #[test] fn secrets_are_fetched_by_command_not_embedded() { let text = generated(); assert!(text.contains("password {")); assert!(text.contains("shell pass show example/caldav")); } #[test] fn a_feed_becomes_a_one_way_read_only_pair() { let text = generated(); assert!(text.contains("type webcal")); assert!(text.contains("one_way")); // pimsync 0.5.11 rejects `read_only` on a webcal storage despite // documenting it for all storage types. assert!(!block(&text, "storage holidays_remote {").contains("read_only")); assert!(text.contains("collection_id holidays")); } /// Both guards against a failed pull being mirrored as a mass deletion. #[test] fn every_pair_carries_the_deletion_guards() { let text = generated(); let pairs = text.matches("\npair ").count(); assert_eq!(pairs, 2); assert_eq!(text.matches("on_empty skip").count(), pairs); assert_eq!(text.matches("on_delete skip").count(), pairs); } /// A feed whose URL is itself a credential — Google's secret iCal address — /// must be fetched by command, never written into either config file. #[test] fn a_secret_feed_url_is_fetched_by_command() { let config: Config = toml::from_str( r#" version = 1 [[endpoint]] id = "gcal-feed" type = "webcal" url_command = "secret-tool lookup service calcalist account gcal-ics" "#, ) .expect("config should parse"); let text = generate_for(&config, Path::new("/var/state/calcalist"), Direction::Both) .expect("generate"); assert!(text.contains("url {")); assert!(text.contains("shell secret-tool lookup service calcalist account gcal-ics")); assert!(!text.contains("https://"), "no literal URL should appear"); } #[test] fn splits_a_calendar_url_into_container_and_path() { assert_eq!( split_collection_url("https://dav.example.com/dav/calendars/user/me/work/"), Some(( "https://dav.example.com/dav/calendars/user/me/".into(), "/dav/calendars/user/me/work/".into() )) ); // A trailing slash is optional. assert_eq!( split_collection_url("https://dav.example.com/calendars/work"), Some(( "https://dav.example.com/calendars/".into(), "/calendars/work/".into() )) ); // A single segment leaves the root as the container. assert_eq!( split_collection_url("https://dav.example.com/work/"), Some(("https://dav.example.com/".into(), "/work/".into())) ); } /// A bare server root names no calendar, so it must be rejected rather than /// guessed at. #[test] fn a_url_without_a_collection_is_refused() { assert_eq!(split_collection_url("https://dav.example.com/"), None); assert_eq!(split_collection_url("https://dav.example.com"), None); } #[test] fn parses_the_version_line() { assert_eq!( Version::parse("pimsync 0.5.11"), Some(Version { major: 0, minor: 5, patch: 11 }) ); } #[test] fn tolerates_a_two_component_version() { assert_eq!( Version::parse("pimsync 1.2"), Some(Version { major: 1, minor: 2, patch: 0 }) ); } /// pimsync built from a checkout rather than a tag reports `git describe`'s /// suffix, which must not swallow the patch number. #[test] fn tolerates_a_describe_suffix() { assert_eq!( Version::parse("pimsync 0.6.0-1-ga08ee3c"), Some(Version { major: 0, minor: 6, patch: 0 }) ); } #[test] fn rejects_unparseable_output() { assert_eq!(Version::parse("pimsync"), None); assert_eq!(Version::parse("pimsync unreleased"), None); } #[test] fn the_read_series_are_accepted() { let floor = Version { major: 0, minor: 5, patch: 11, }; let read = Version { major: 0, minor: 6, patch: 0, }; let unread = Version { major: 0, minor: 7, patch: 0, }; assert!(floor.is_supported()); assert!(read.is_supported()); assert!(!unread.is_supported()); } #[test] fn a_patch_older_than_the_floor_is_rejected() { // discover's output format changed between 0.5.9 and 0.5.11; older // patches within the otherwise-supported minor series are refused. let too_old = Version { major: 0, minor: 5, patch: 9, }; assert!(!too_old.is_supported()); } /// Established against a real server: everything arrives on stdout, the /// headings are colour-coded even through a pipe, and both storages of the /// pair are reported. const DISCOVERY: &str = "\u{1b}[1m\u{1b}[32m==>\u{1b}[0m Discovering collections in local\u{2026}\n\ Found leftover-one\n\ Found leftover-two\n\ \u{1b}[1m\u{1b}[32m==>\u{1b}[0m Discovering collections in probe_remote\u{2026}\n\ Found /calendars/me/work/\n\ Found /calendars/me/Private Stuff/\n"; /// The trap: pimsync reports the scratch directory's contents too, and /// offering those as the user's calendars would be nonsense. #[test] fn only_the_servers_collections_are_taken() { let found = parse_discovery(DISCOVERY); assert_eq!( found.iter().map(|c| c.href.as_str()).collect::>(), vec!["/calendars/me/work/", "/calendars/me/Private Stuff/"] ); } #[test] fn an_id_is_taken_from_the_last_segment() { let found = parse_discovery(DISCOVERY); assert_eq!(found[0].id, "work"); // Spaces and capitals cannot appear in an id used as a routing marker. assert_eq!(found[1].id, "private-stuff"); } #[test] fn a_server_offering_nothing_yields_nothing() { let empty = "\u{1b}[1m==>\u{1b}[0m Discovering collections in probe_remote\u{2026}\n"; assert!(parse_discovery(empty).is_empty()); } /// A failure is reported in place of the collections, so nothing is found. #[test] fn an_unreachable_server_yields_nothing() { let failed = "\u{1b}[1m==>\u{1b}[0m Discovering collections in probe_remote\u{2026}\n\ input/output error: client error executing request: client error (Connect)\n"; assert!(parse_discovery(failed).is_empty()); } #[test] fn colour_is_stripped_from_headings() { assert_eq!( strip_ansi("\u{1b}[1m\u{1b}[32m==>\u{1b}[0m Discovering"), "==> Discovering" ); } /// The probe must never be mistaken for the real configuration, and must /// name the storage the parser anchors on. #[test] fn the_probe_config_is_separate_and_parseable() { let text = probe_config( Path::new("/var/state/calcalist"), Path::new("/var/state/calcalist/discover-vdir"), "https://dav.example.com/calendars/me/", "me@example.com", Some("secret-tool lookup service dav"), ); assert!( text.contains(&format!("storage {PROBE_REMOTE} {{")), "{text}" ); assert!(text.contains("collections from a"), "{text}"); assert!(text.contains("discovery collections"), "{text}"); assert!( text.contains("shell secret-tool lookup service dav"), "{text}" ); // Its status path is its own, so a probe cannot disturb a real sync. assert!(text.contains("discover-status"), "{text}"); } #[test] fn a_server_needing_no_password_gets_no_password_directive() { let text = probe_config( Path::new("/var/state/calcalist"), Path::new("/tmp/scratch"), "https://dav.example.com/calendars/me/", "me", None, ); assert!(!text.contains("password"), "{text}"); } }