use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct PeerId(String);
impl PeerId {
pub(crate) fn from_derived(value: String) -> Self {
Self(value)
}
pub fn parse(raw: &str) -> Result<Self, String> {
if raw.is_empty() {
return Err("a peer id is not empty".to_string());
}
if raw.len() > 64 {
return Err(format!("a peer id is at most 64 characters, this is {}", raw.len()));
}
if !raw.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) {
return Err(format!(
"'{raw}' is not a peer id: they are lowercase letters and digits"
));
}
Ok(Self(raw.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for PeerId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct SharedWorktreeId(String);
impl SharedWorktreeId {
pub fn of(family: &str, worktree: &str) -> Result<Self, String> {
Self::parse(&format!("{family}@{worktree}"))
}
pub fn parse(raw: &str) -> Result<Self, String> {
let Some((family, worktree)) = raw.split_once('@') else {
return Err(format!(
"'{raw}' is not a worktree id: <family>@<worktree>"
));
};
if family.is_empty()
|| family.len() > 64
|| !family.chars().all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
{
return Err(format!(
"'{family}' is not a repository family: up to 64 lowercase hex digits"
));
}
if !is_worktree_name(worktree) {
return Err(format!(
"'{worktree}' cannot name a worktree two machines share: letters, digits, \
'.', '_' and '-', starting with a letter or digit"
));
}
Ok(Self(raw.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn family(&self) -> &str {
self.0.split_once('@').map(|(family, _)| family).unwrap_or(&self.0)
}
pub fn worktree(&self) -> &str {
self.0.split_once('@').map(|(_, worktree)| worktree).unwrap_or_default()
}
}
impl TryFrom<String> for SharedWorktreeId {
type Error = String;
fn try_from(raw: String) -> Result<Self, String> {
Self::parse(&raw)
}
}
impl From<SharedWorktreeId> for String {
fn from(id: SharedWorktreeId) -> String {
id.0
}
}
fn is_worktree_name(name: &str) -> bool {
let mut chars = name.chars();
matches!(chars.next(), Some(first) if first.is_ascii_alphanumeric())
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
}
impl std::fmt::Display for SharedWorktreeId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct RepositoryId(String);
impl RepositoryId {
pub fn root() -> Self {
Self(String::new())
}
pub fn parse(raw: &str) -> Result<Self, String> {
if raw.len() > 512 {
return Err("a repository path is at most 512 characters".to_string());
}
if raw.starts_with('/') || raw.contains("..") || raw.contains('\\') {
return Err(format!(
"'{raw}' is not a repository path relative to the worktree root"
));
}
Ok(Self(raw.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn is_root(&self) -> bool {
self.0.is_empty()
}
pub fn label(&self) -> &str {
if self.0.is_empty() {
"superproject"
} else {
&self.0
}
}
}
impl std::fmt::Display for RepositoryId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PeerState {
Unpaired,
Offline,
Connecting,
Connected,
Error,
}
impl PeerState {
pub fn as_str(self) -> &'static str {
match self {
Self::Unpaired => "unpaired",
Self::Offline => "offline",
Self::Connecting => "connecting",
Self::Connected => "connected",
Self::Error => "error",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorktreePresence {
LocalOnly,
PeerOnly,
Shared,
}
impl WorktreePresence {
pub fn as_str(self) -> &'static str {
match self {
Self::LocalOnly => "local_only",
Self::PeerOnly => "peer_only",
Self::Shared => "shared",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub enum Service {
Files,
Plugin(crate::plugin::PluginService),
}
impl Service {
pub const BUILT_IN: [Service; 1] = [Service::Files];
pub fn as_str(&self) -> &str {
match self {
Self::Files => "files",
Self::Plugin(service) => service.as_str(),
}
}
pub fn parse(raw: &str) -> Result<Self, String> {
match raw {
"files" => Ok(Self::Files),
other if other.contains('.') => {
Ok(Self::Plugin(crate::plugin::PluginService::parse(other)?))
}
other => Err(format!(
"'{other}' is not a service: files, or a plugin's <plugin>.<service>"
)),
}
}
pub fn describe(&self) -> String {
match self {
Self::Files => "carry this machine's working tree with that machine".to_string(),
Self::Plugin(service) => format!(
"let the {} plugin answer that machine's {} requests",
service.plugin(),
service
),
}
}
}
impl TryFrom<String> for Service {
type Error = String;
fn try_from(raw: String) -> Result<Self, String> {
Self::parse(&raw)
}
}
impl From<Service> for String {
fn from(service: Service) -> String {
service.as_str().to_string()
}
}
impl std::fmt::Display for Service {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServiceSwitch {
pub service: String,
pub switch: Switch,
pub describes: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PeerServices {
pub peer: String,
pub services: Vec<ServiceSwitch>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Switch {
On,
Off,
}
impl Switch {
pub fn default_for(service: &Service) -> Self {
match service {
Service::Files => Self::On,
Service::Plugin(_) => Self::Off,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::On => "on",
Self::Off => "off",
}
}
pub fn parse(raw: &str) -> Result<Self, String> {
match raw {
"on" => Ok(Self::On),
"off" => Ok(Self::Off),
other => Err(format!("'{other}' is not a switch: on, off")),
}
}
pub fn is_on(self) -> bool {
matches!(self, Self::On)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SyncMode {
Manual,
Auto,
}
impl SyncMode {
pub fn as_str(self) -> &'static str {
match self {
Self::Manual => "manual",
Self::Auto => "auto",
}
}
pub fn parse(raw: &str) -> Result<Self, String> {
match raw {
"manual" => Ok(Self::Manual),
"auto" => Ok(Self::Auto),
other => Err(format!("'{other}' is not a sync mode: manual, auto")),
}
}
}
impl Default for SyncMode {
fn default() -> Self {
Self::Manual
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Continuity {
Off,
Paused,
Manual,
Auto,
}
impl Continuity {
pub fn of(files: Switch, desired: Desired, mode: SyncMode) -> Self {
match (files, desired) {
(Switch::Off, _) => Self::Off,
(_, Desired::Disabled) => Self::Off,
(_, Desired::Paused) => Self::Paused,
(_, Desired::Enabled) => match mode {
SyncMode::Manual => Self::Manual,
SyncMode::Auto => Self::Auto,
},
}
}
pub fn compares(self) -> bool {
!matches!(self, Self::Off)
}
pub fn takes_undecided(self) -> bool {
matches!(self, Self::Auto)
}
pub fn carries_decided(self) -> bool {
matches!(self, Self::Manual | Self::Auto)
}
pub fn as_str(self) -> &'static str {
match self {
Self::Off => "off",
Self::Paused => "paused",
Self::Manual => "manual",
Self::Auto => "auto",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Desired {
Disabled,
Enabled,
Paused,
}
impl Desired {
pub fn as_str(self) -> &'static str {
match self {
Self::Disabled => "disabled",
Self::Enabled => "enabled",
Self::Paused => "paused",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Observed {
Idle,
Checking,
Ready,
Syncing,
Synced,
Paused,
Undecided,
Blocked,
Conflict,
Error,
}
impl Observed {
pub fn as_str(self) -> &'static str {
match self {
Self::Idle => "idle",
Self::Checking => "checking",
Self::Ready => "ready",
Self::Syncing => "syncing",
Self::Synced => "synced",
Self::Paused => "paused",
Self::Undecided => "undecided",
Self::Blocked => "blocked",
Self::Conflict => "conflict",
Self::Error => "error",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "reason", rename_all = "snake_case")]
pub enum Blocked {
RepositoryMismatch { here: String, there: String },
BranchMismatch { here: String, there: String },
DetachedHead { here: bool, there: bool },
HeadMismatch { here: String, there: String },
GitOperation { here: bool, there: bool },
WorktreeMissing,
SubmoduleMissing { here: bool, there: bool },
PeerOffline,
PolicyMismatch { detail: String },
}
impl Blocked {
pub fn code(&self) -> &'static str {
match self {
Self::RepositoryMismatch { .. } => "repository_mismatch",
Self::BranchMismatch { .. } => "branch_mismatch",
Self::DetachedHead { .. } => "detached_head",
Self::HeadMismatch { .. } => "head_mismatch",
Self::GitOperation { .. } => "git_operation",
Self::WorktreeMissing => "worktree_missing",
Self::SubmoduleMissing { .. } => "submodule_missing",
Self::PeerOffline => "peer_offline",
Self::PolicyMismatch { .. } => "policy_mismatch",
}
}
pub fn describe(&self) -> String {
match self {
Self::RepositoryMismatch { here, there } => format!(
"these are not the same repository — here {here}, on the peer {there}"
),
Self::BranchMismatch { here, there } => {
format!("branch mismatch — here {here}, on the peer {there}")
}
Self::DetachedHead { here, there } => match (here, there) {
(true, true) => "both sides are on a detached HEAD".to_string(),
(true, false) => "this side is on a detached HEAD".to_string(),
(false, true) => "the peer is on a detached HEAD".to_string(),
(false, false) => "a detached HEAD was reported with neither side detached"
.to_string(),
},
Self::HeadMismatch { here, there } => format!(
"HEAD mismatch — here {}, on the peer {}",
short_commit(here),
short_commit(there)
),
Self::GitOperation { here, there } => match (here, there) {
(true, true) => "a git operation is in progress on both sides".to_string(),
(true, false) => "a git operation is in progress here".to_string(),
(false, true) => "a git operation is in progress on the peer".to_string(),
(false, false) => "a git operation was reported with neither side busy".to_string(),
},
Self::WorktreeMissing => "the peer does not have this worktree".to_string(),
Self::SubmoduleMissing { here, there } => match (here, there) {
(false, true) => "this side does not have this repository".to_string(),
(true, false) => "the peer does not have this repository".to_string(),
_ => "this repository is missing on both sides".to_string(),
},
Self::PeerOffline => "the peer is not connected".to_string(),
Self::PolicyMismatch { detail } => {
format!("the two sides declare different continuity policy: {detail}")
}
}
}
}
pub(crate) fn short_commit(id: &str) -> String {
id.chars().take(7).collect()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryStatus {
pub repository: RepositoryId,
pub desired: Desired,
pub continuity: Continuity,
pub observed: Observed,
#[serde(skip_serializing_if = "Option::is_none")]
pub blocked: Option<Blocked>,
pub pending: usize,
pub incoming: usize,
pub outgoing: usize,
pub conflicts: usize,
pub undecided: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub head: Option<String>,
}
impl RepositoryStatus {
pub fn check(&self) -> Result<(), String> {
match (self.observed, &self.blocked) {
(Observed::Blocked, None) => Err(format!(
"{} is blocked with no reason",
self.repository.label()
)),
(state, Some(reason)) if state != Observed::Blocked => Err(format!(
"{} is {} and carries the blocked reason {}",
self.repository.label(),
state.as_str(),
reason.code()
)),
_ => Ok(()),
}
}
}
#[cfg(test)]
#[path = "model_tests.rs"]
mod tests;