From d03748472a52f9d79976d6a062846f3dc372fdef Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 13 Sep 2026 15:32:43 +0300 Subject: [PATCH] Widen the accepted pimsync range to the 0.6 series; stream `sync` output and bound the Google HTTP calls The version work and the sync-output work landed in one change; they can be split apart if the history wants them separate. --- README.md | 11 ++- flake.lock | 21 ++++- flake.nix | 78 ++++++++++++++----- src/doctor.rs | 7 +- src/google/api.rs | 5 +- src/google/auth.rs | 10 +-- src/google/mod.rs | 25 ++++++ src/pimsync.rs | 187 ++++++++++++++++++++++++++++++++------------- 8 files changed, 250 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index a6cf791..67f9206 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,16 @@ Or straight from the repo, without cloning it first: nix profile install "git+https://code.randogoth.com/randogoth/CalCalist.git#calcalist" ``` -One runtime dependency is needed for CalDAV and iCal sync: **`pimsync` 0.5.11 or newer on `PATH`**. The Nix flake installation method already ensures this. +One runtime dependency is needed for CalDAV and iCal sync: **`pimsync`, from 0.5.11 up to but not including 0.7.0, on `PATH`**. The Nix flake installation method already ensures this: it builds pimsync from source and wraps `calcalist` so that copy is the one found, whatever else is installed. + +To run a different pimsync — a fork, or a working tree — override the input at install time: + +```sh +nix profile install "/path/to/calcalist#calcalist" \ + --override-input pimsync-src "git+file:///path/to/pimsync" +``` + +The built pimsync reports the revision it came from (`pimsync 0.6.0-a08ee3c`), so the two are told apart. Note that `nix profile upgrade` does **not** remember an override: upgrading rebuilds against the default input, and the install command has to be repeated to get the fork back. Then check the environment, which is worth doing before writing any config: diff --git a/flake.lock b/flake.lock index fc28473..4174f47 100644 --- a/flake.lock +++ b/flake.lock @@ -16,9 +16,28 @@ "type": "github" } }, + "pimsync-src": { + "flake": false, + "locked": { + "lastModified": 1788568982, + "narHash": "sha256-vpmms553q1mzxfkMGEjvTddQVksEAzu50g8bVudZsXg=", + "ref": "refs/tags/v0.6.0", + "rev": "a111a44cd9e65c031ce9801fdced912057e4a4a0", + "revCount": 1616, + "type": "git", + "url": "https://git.sr.ht/~whynothugo/pimsync" + }, + "original": { + "ref": "refs/tags/v0.6.0", + "rev": "a111a44cd9e65c031ce9801fdced912057e4a4a0", + "type": "git", + "url": "https://git.sr.ht/~whynothugo/pimsync" + } + }, "root": { "inputs": { - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "pimsync-src": "pimsync-src" } } }, diff --git a/flake.nix b/flake.nix index 0f4e8fe..c1962bc 100644 --- a/flake.nix +++ b/flake.nix @@ -3,8 +3,26 @@ inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + # pimsync is built from source rather than taken from nixpkgs, which trails + # the releases calcalist has been read against. Being an input rather than a + # `fetchFromSourcehut` call makes it substitutable: a fork or a working tree + # can be swapped in at install time with + # `--override-input pimsync-src git+file:///path/to/pimsync`, without the + # committed flake naming anything that exists on one machine only. + # + # The rev is the commit v0.6.0 points at: nix cannot peel an annotated tag, + # and resolving the ref alone fails. + inputs.pimsync-src = { + url = "git+https://git.sr.ht/~whynothugo/pimsync?ref=refs/tags/v0.6.0&rev=a111a44cd9e65c031ce9801fdced912057e4a4a0"; + flake = false; + }; + outputs = - { self, nixpkgs }: + { + self, + nixpkgs, + pimsync-src, + }: let systems = [ "x86_64-linux" @@ -19,30 +37,48 @@ let pkgs = import nixpkgs { inherit system; }; - # nixpkgs currently ships pimsync 0.5.9, but `discover`'s output - # format is undocumented and changed between 0.5.9 and 0.5.11 — - # calcalist's parser only understands the newer shape (see - # SUPPORTED_PATCH_MIN in src/pimsync.rs). Build the exact version - # rather than trust whatever nixpkgs happens to carry. - pimsync0_5_11 = pkgs.rustPlatform.buildRustPackage (finalAttrs: { - pname = "pimsync"; - version = "0.5.11"; - src = pkgs.fetchFromSourcehut { - owner = "~whynothugo"; - repo = "pimsync"; - rev = "v${finalAttrs.version}"; - hash = "sha256-iMdBqSSguViF+54e47IGV8hH3qvTxcNkWkmND1QAAxw="; - }; - cargoHash = "sha256-dvkZ047eJnvYvyH1iW1NJo3Uv0L2T7waPYKN12bi+dA="; + # pimsync's Cargo.toml carries no version — the binary learns its own + # from `git describe`, which has nothing to describe inside the + # store. So it is assembled: the newest changelog heading for the + # release, and the input's revision for the tree it came from. That + # second half is what distinguishes an overridden fork from the + # upstream release it descends from, and a suffixed version is + # pimsync's own convention for a build off a tag. + pimsyncVersion = + let + lines = pkgs.lib.splitString "\n" (builtins.readFile "${pimsync-src}/CHANGELOG.rst"); + isRelease = line: builtins.match "v[0-9]+\\.[0-9]+\\.[0-9]+" line != null; + newest = + pkgs.lib.findFirst isRelease (throw "no release heading found in pimsync's CHANGELOG.rst") + lines; + release = pkgs.lib.removePrefix "v" newest; + in + "${release}-${pimsync-src.dirtyShortRev or pimsync-src.shortRev}"; - # Skips build.rs's `git describe` call, which has nothing to - # describe once fetched as a plain source tarball. - env.PIMSYNC_VERSION = finalAttrs.version; + pimsync = pkgs.rustPlatform.buildRustPackage { + pname = "pimsync"; + version = pimsyncVersion; + src = pimsync-src; + + cargoLock = { + lockFile = "${pimsync-src}/Cargo.lock"; + + # pimsync pins a few dependencies to git revisions, and vstorage + # — which the fork swaps in — keeps libdav and libjmap as + # submodules of its own. Naming those revisions in + # `outputHashes` fetches them without submodules and vendors + # two empty directories; the builtin fetcher recurses properly. + # The revisions come from the lock file, so evaluation stays + # pure, and there is no hash here to go stale when a fork moves. + allowBuiltinFetchGit = true; + }; + + env.PIMSYNC_VERSION = pimsyncVersion; nativeBuildInputs = [ pkgs.pkg-config ]; buildInputs = [ pkgs.sqlite ]; doCheck = false; - }); + }; in rec { calcalist = pkgs.rustPlatform.buildRustPackage { @@ -62,7 +98,7 @@ # against, regardless of what else is on the user's PATH. postInstall = '' wrapProgram $out/bin/calcalist \ - --prefix PATH : ${pkgs.lib.makeBinPath [ pimsync0_5_11 ]} + --prefix PATH : ${pkgs.lib.makeBinPath [ pimsync ]} ''; meta = { diff --git a/src/doctor.rs b/src/doctor.rs index 4ba4902..2425ee2 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -100,11 +100,10 @@ fn check_pimsync() -> Check { let outcome = match pimsync::probe() { Ok(version) if version.is_supported() => Outcome::Ok(version.to_string()), Ok(version) => Outcome::Warn(format!( - "{version} is outside the supported {}.{}.{}+ series; \ + "{version} is outside the supported range, {} up to but not including {}; \ config syntax may differ, and older patches misreport `discover`", - pimsync::SUPPORTED_MAJOR, - pimsync::SUPPORTED_MINOR, - pimsync::SUPPORTED_PATCH_MIN, + pimsync::MIN_VERSION, + pimsync::NEXT_UNKNOWN_VERSION, )), Err(error) => Outcome::Fail(error.to_string()), }; diff --git a/src/google/api.rs b/src/google/api.rs index 03fd12e..63073ff 100644 --- a/src/google/api.rs +++ b/src/google/api.rs @@ -62,10 +62,7 @@ pub struct Changes { impl Client { pub fn new(access_token: String, calendar_id: String) -> Self { - let agent: ureq::Agent = ureq::Agent::config_builder() - .http_status_as_error(false) - .build() - .into(); + let agent = super::agent(); Client { agent, access_token, diff --git a/src/google/auth.rs b/src/google/auth.rs index b7c92b4..6a1a71f 100644 --- a/src/google/auth.rs +++ b/src/google/auth.rs @@ -401,10 +401,7 @@ pub fn login( /// Returned as an option rather than an error: failing to name the account only /// costs the sharing, and the authorisation itself is perfectly good without it. fn primary_calendar_id(access_token: &str) -> Option { - let agent: ureq::Agent = ureq::Agent::config_builder() - .http_status_as_error(false) - .build() - .into(); + let agent = super::agent(); let mut response = agent .get("https://www.googleapis.com/calendar/v3/calendars/primary") .header("Authorization", format!("Bearer {access_token}")) @@ -487,10 +484,7 @@ fn post_token(form: &[(&str, &str)]) -> Result { .collect::>() .join("&"); - let agent: ureq::Agent = ureq::Agent::config_builder() - .http_status_as_error(false) - .build() - .into(); + let agent = super::agent(); let mut response = agent .post(TOKEN_ENDPOINT) .content_type("application/x-www-form-urlencoded") diff --git a/src/google/mod.rs b/src/google/mod.rs index 53833cc..dadf189 100644 --- a/src/google/mod.rs +++ b/src/google/mod.rs @@ -8,3 +8,28 @@ pub mod api; pub mod auth; pub mod convert; + +use std::time::Duration; + +/// How long one request may take, end to end. ureq defaults every timeout to +/// none, so a server that accepts a connection and then says nothing used to +/// stall a cycle for as long as the machine stayed up — indistinguishable, from +/// the terminal, from a sync that was merely slow. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); + +/// Long enough for a handshake over a bad link, short enough that an +/// unreachable host is reported rather than waited on. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); + +/// The HTTP client every Google call goes through. +/// +/// `http_status_as_error` is off because the callers read the status themselves: +/// a 410 drives a full resync, and a 404 is an ordinary answer here. +pub fn agent() -> ureq::Agent { + ureq::Agent::config_builder() + .http_status_as_error(false) + .timeout_global(Some(REQUEST_TIMEOUT)) + .timeout_connect(Some(CONNECT_TIMEOUT)) + .build() + .into() +} diff --git a/src/pimsync.rs b/src/pimsync.rs index 10d8515..9abdcb2 100644 --- a/src/pimsync.rs +++ b/src/pimsync.rs @@ -7,8 +7,9 @@ use std::fmt; use std::fs; use std::io; +use std::os::fd::AsFd; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Output, Stdio}; use thiserror::Error; @@ -16,16 +17,27 @@ use crate::config::{Config, Endpoint, EndpointKind}; pub const BINARY: &str = "pimsync"; -/// The `0.5.x` series this build generates configuration for. pimsync is -/// pre-1.0 and its config syntax may change between minor releases, so the -/// range is deliberately narrow. The patch floor matters too: `discover`'s +/// 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 SUPPORTED_MAJOR: u64 = 0; -pub const SUPPORTED_MINOR: u64 = 5; -pub const SUPPORTED_PATCH_MIN: u64 = 11; +pub const MIN_VERSION: Version = Version { + major: 0, + minor: 5, + patch: 11, +}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// 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, @@ -40,9 +52,7 @@ impl fmt::Display for Version { impl Version { pub fn is_supported(&self) -> bool { - self.major == SUPPORTED_MAJOR - && self.minor == SUPPORTED_MINOR - && self.patch >= SUPPORTED_PATCH_MIN + *self >= MIN_VERSION && *self < NEXT_UNKNOWN_VERSION } /// Parses the `pimsync 0.5.11` line printed by `pimsync version`. @@ -50,18 +60,26 @@ impl Version { let field = output.split_whitespace().nth(1)?; let mut parts = field.split('.'); Some(Version { - major: parts.next()?.parse().ok()?, - minor: parts.next()?.parse().ok()?, - patch: parts.next().unwrap_or("0").parse().ok()?, + 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 {SUPPORTED_MAJOR}.{SUPPORTED_MINOR}.{SUPPORTED_PATCH_MIN} or newer" - )] + #[error("`{BINARY}` was not found on PATH; install pimsync {MIN_VERSION} or newer")] NotFound, #[error("could not run `{BINARY}`: {0}")] Spawn(#[source] io::Error), @@ -88,13 +106,7 @@ pub fn probe() -> Result { let output = Command::new(BINARY) .arg("version") .output() - .map_err(|error| { - if error.kind() == io::ErrorKind::NotFound { - PimsyncError::NotFound - } else { - PimsyncError::Spawn(error) - } - })?; + .map_err(spawn_error)?; if !output.status.success() { return Err(PimsyncError::Failed( String::from_utf8_lossy(&output.stderr).trim().to_string(), @@ -332,13 +344,7 @@ pub fn discover( .arg(&config_path) .arg("discover") .output() - .map_err(|error| { - if error.kind() == io::ErrorKind::NotFound { - PimsyncError::NotFound - } else { - PimsyncError::Spawn(error) - } - })?; + .map_err(spawn_error)?; // Everything, including the failures, comes back on stdout. let text = String::from_utf8_lossy(&output.stdout).into_owned(); @@ -453,34 +459,40 @@ fn strip_ansi(text: &str) -> String { /// /// 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> { - run(config_path, "sync") + 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> { - run(config_path, "check") -} - -fn run(config_path: &Path, command: &str) -> Result<(), PimsyncError> { - let output = Command::new(BINARY) - .arg("-c") - .arg(config_path) - .arg(command) + let output = invocation(config_path, "check") .output() - .map_err(|error| { - if error.kind() == io::ErrorKind::NotFound { - PimsyncError::NotFound - } else { - PimsyncError::Spawn(error) - } - })?; + .map_err(spawn_error)?; if output.status.success() { return Ok(()); } - let message = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let message = diagnostics(&output); Err(PimsyncError::Command { - command: command.to_string(), + command: "check".to_string(), message: if message.is_empty() { format!("exited with {}", output.status) } else { @@ -489,6 +501,51 @@ fn run(config_path: &Path, command: &str) -> Result<(), PimsyncError> { }) } +/// `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::*; @@ -670,6 +727,20 @@ url_command = "secret-tool lookup service calcalist account gcal-ics" ); } + /// 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); @@ -677,19 +748,25 @@ url_command = "secret-tool lookup service calcalist account gcal-ics" } #[test] - fn only_the_supported_series_is_accepted() { - let supported = Version { + fn the_read_series_are_accepted() { + let floor = Version { major: 0, minor: 5, patch: 11, }; - let next_series = Version { + let read = Version { major: 0, minor: 6, - patch: 11, + patch: 0, }; - assert!(supported.is_supported()); - assert!(!next_series.is_supported()); + let unread = Version { + major: 0, + minor: 7, + patch: 0, + }; + assert!(floor.is_supported()); + assert!(read.is_supported()); + assert!(!unread.is_supported()); } #[test]