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.
This commit is contained in:
randogoth 2026-09-13 15:32:43 +03:00
parent 3d701361da
commit d03748472a
8 changed files with 250 additions and 94 deletions

View file

@ -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<u64> {
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<Version, PimsyncError> {
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 <config> <command>`, 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<Stdio, PimsyncError> {
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<String> = 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]