Skip to main content

teamcraft/domain/
concurrency.rs

1//! Concurrency: base versions and file sync states.
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::AppError;
6
7/// The server version a client based a change of a file on – the version it
8/// checked out, by number or by content hash. A check-in that names a base is
9/// refused when the file moved on since (optimistic concurrency).
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub enum BaseVersion {
12    /// Version number; `0` means "this file is new, it must not exist yet".
13    Version(i32),
14    /// SHA-256 (lower-case hex) of the content the client started from.
15    Sha256(String),
16}
17
18impl BaseVersion {
19    /// `3` or `"3"` → version 3, a 64-digit hex string → SHA-256.
20    pub fn from_json(value: &serde_json::Value) -> Result<BaseVersion, AppError> {
21        let invalid = || {
22            AppError::BadRequest(
23                "A base must be a version number or the SHA-256 of the checked-out content.".into(),
24            )
25        };
26        match value {
27            serde_json::Value::Number(n) => n
28                .as_i64()
29                .and_then(|v| i32::try_from(v).ok())
30                .filter(|v| *v >= 0)
31                .map(BaseVersion::Version)
32                .ok_or_else(invalid),
33            serde_json::Value::String(s) => BaseVersion::parse(s).ok_or_else(invalid),
34            _ => Err(invalid()),
35        }
36    }
37
38    pub fn parse(raw: &str) -> Option<BaseVersion> {
39        let raw = raw.trim();
40        let raw = raw.strip_prefix('v').unwrap_or(raw);
41        if raw.len() == 64 && raw.bytes().all(|b| b.is_ascii_hexdigit()) {
42            return Some(BaseVersion::Sha256(raw.to_ascii_lowercase()));
43        }
44        if !raw.is_empty() && raw.len() <= 9 && raw.bytes().all(|b| b.is_ascii_digit()) {
45            return raw.parse().ok().map(BaseVersion::Version);
46        }
47        None
48    }
49}
50
51/// The server side of a file for the concurrency checks.
52#[derive(Clone, Copy, Debug)]
53pub struct ServerFile<'a> {
54    pub version: i32,
55    pub sha256: &'a str,
56}
57
58/// Why a check-in with a stated base is refused, if it is.
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub enum BaseConflict {
61    /// The file moved on: the client started from another version.
62    Moved,
63    /// The client expected a new file, but one with that name exists now.
64    AddedMeanwhile,
65    /// The client changed a file that was removed on the server.
66    Removed,
67}
68
69/// Checks an upload against the current server file. Identical content is
70/// never a conflict (nothing would be overwritten).
71pub fn check_base(
72    base: &BaseVersion,
73    current: Option<ServerFile<'_>>,
74    upload_sha256: &str,
75) -> Result<(), BaseConflict> {
76    match (base, current) {
77        (_, Some(cur)) if cur.sha256 == upload_sha256 => Ok(()),
78        (BaseVersion::Version(0), None) => Ok(()),
79        (BaseVersion::Version(0), Some(_)) => Err(BaseConflict::AddedMeanwhile),
80        (_, None) => Err(BaseConflict::Removed),
81        (BaseVersion::Version(v), Some(cur)) if *v == cur.version => Ok(()),
82        (BaseVersion::Sha256(s), Some(cur)) if s.eq_ignore_ascii_case(cur.sha256) => Ok(()),
83        (_, Some(_)) => Err(BaseConflict::Moved),
84    }
85}
86
87/// State of a workspace file compared with the server.
88#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum FileSyncState {
91    /// The local file is the current version.
92    UpToDate,
93    /// Changed locally; the server still has the version it was based on.
94    Modified,
95    /// Unchanged locally, but the server has a newer version – get it.
96    Outdated,
97    /// Changed locally **and** on the server since the local base: a check-in
98    /// would overwrite somebody else's work.
99    Conflict,
100    /// Only local: not on the server yet.
101    New,
102    /// Only on the server: not in the workspace.
103    Missing,
104}
105
106impl FileSyncState {
107    pub fn as_str(self) -> &'static str {
108        match self {
109            FileSyncState::UpToDate => "up_to_date",
110            FileSyncState::Modified => "modified",
111            FileSyncState::Outdated => "outdated",
112            FileSyncState::Conflict => "conflict",
113            FileSyncState::New => "new",
114            FileSyncState::Missing => "missing",
115        }
116    }
117}
118
119/// Compares a local file (`local_sha256`) with the server file.
120///
121/// `history` holds `(version, sha256)` of every version of the file. Without
122/// a stated base, local content equal to an older version counts as
123/// outdated, and unknown content as modified (the base is then assumed to be
124/// the current version). With a base, local content that differs from both
125/// the base and the current version is a conflict once the server moved on.
126pub fn sync_state(
127    current: Option<ServerFile<'_>>,
128    history: &[(i32, &str)],
129    local_sha256: Option<&str>,
130    base: Option<&BaseVersion>,
131) -> FileSyncState {
132    let (cur, local) = match (current, local_sha256) {
133        (None, None) => return FileSyncState::Missing,
134        (None, Some(_)) => return FileSyncState::New,
135        (Some(_), None) => return FileSyncState::Missing,
136        (Some(cur), Some(local)) => (cur, local.to_ascii_lowercase()),
137    };
138    if cur.sha256.eq_ignore_ascii_case(&local) {
139        return FileSyncState::UpToDate;
140    }
141    let base_sha = match base {
142        Some(BaseVersion::Sha256(s)) => Some(s.to_ascii_lowercase()),
143        Some(BaseVersion::Version(v)) => history
144            .iter()
145            .find(|(n, _)| n == v)
146            .map(|(_, s)| s.to_ascii_lowercase()),
147        None => None,
148    };
149    match base_sha {
150        Some(base) if base.eq_ignore_ascii_case(cur.sha256) => FileSyncState::Modified,
151        Some(base) if base == local => FileSyncState::Outdated,
152        Some(_) => FileSyncState::Conflict,
153        // An unknown base version (e.g. 0 for "new") with an existing file:
154        // the file appeared on the server meanwhile.
155        None if base.is_some() => FileSyncState::Conflict,
156        None if history.iter().any(|(_, s)| s.eq_ignore_ascii_case(&local)) => {
157            FileSyncState::Outdated
158        }
159        None => FileSyncState::Modified,
160    }
161}
162
163/// `3 days`, `5 h`, `12 min`, `just now` – the age of a lock or an event.
164pub fn format_age(seconds: i64) -> String {
165    let s = seconds.max(0);
166    match s {
167        0..=59 => "just now".into(),
168        60..=3_599 => format!("{} min", s / 60),
169        3_600..=86_399 => format!("{} h", s / 3_600),
170        86_400..=172_799 => "1 day".into(),
171        _ => format!("{} days", s / 86_400),
172    }
173}