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:
parent
3d701361da
commit
d03748472a
8 changed files with 250 additions and 94 deletions
187
src/pimsync.rs
187
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<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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue