Scaffold calcalist: repo, toolchain, config model and doctor

Initialise the project per the approved implementation plan (M0).

- Pin the toolchain with devbox: Rust 1.97.1, pimsync 0.5.11, jujutsu 0.44.0,
  and radicale 3.7.8 for later integration tests.
- Add the configuration model with full referential validation, reporting
  every problem in one pass rather than short-circuiting on the first.
- Add `calcalist doctor`, verifying pimsync's presence and version series,
  the state directory, and the configuration.
- Define the whole CLI surface; only `doctor` acts, the rest exit 2 rather
  than pretending to work.
- Rewrite SPECS.md: Rust naming conventions in place of the JS/TS style
  lines, a `devbox run check` gate instead of a pre-commit hook (jj runs no
  git hooks), and the sync semantics the spec previously left unstated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
randogoth 2026-09-10 09:10:16 +03:00
commit 2bc93262f8
12 changed files with 1674 additions and 0 deletions

134
src/pimsync.rs Normal file
View file

@ -0,0 +1,134 @@
//! 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::io;
use std::process::Command;
use thiserror::Error;
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.
pub const SUPPORTED_MAJOR: u64 = 0;
pub const SUPPORTED_MINOR: u64 = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
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.major == SUPPORTED_MAJOR && self.minor == SUPPORTED_MINOR
}
/// Parses the `pimsync 0.5.11` line printed by `pimsync version`.
fn parse(output: &str) -> Option<Self> {
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()?,
})
}
}
#[derive(Debug, Error)]
pub enum PimsyncError {
#[error("`{BINARY}` was not found on PATH; install it (devbox provides {BINARY}@0.5.11)")]
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),
}
/// Asks the installed pimsync for its version.
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)
}
})?;
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()))
}
#[cfg(test)]
mod tests {
use super::*;
#[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
})
);
}
#[test]
fn rejects_unparseable_output() {
assert_eq!(Version::parse("pimsync"), None);
assert_eq!(Version::parse("pimsync unreleased"), None);
}
#[test]
fn only_the_supported_series_is_accepted() {
let supported = Version {
major: 0,
minor: 5,
patch: 0,
};
let next_series = Version {
major: 0,
minor: 6,
patch: 0,
};
assert!(supported.is_supported());
assert!(!next_series.is_supported());
}
}