Skip to main content

vdsl_sync/domain/
view.rs

1//! クエリ用ビュー型。
2//!
3//! Transfer/LocationFileから導出される読み取り専用のビュー。
4//! ドメインエンティティではない。外部API (MCP, CLI, Lua bridge) への
5//! レスポンス構築用。
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::fmt;
10
11use super::location::LocationId;
12use super::retry::RetryPolicy;
13use super::transfer::{Transfer, TransferKind, TransferState};
14
15/// 特定locationでのファイルの存在状態。
16///
17/// 最新のTransferから導出される。ドメインエンティティではない。
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum PresenceState {
21    /// Completed Transferあり — ファイルが到達済み。
22    Present,
23    /// Queued Transferあり — 転送待ち。
24    Pending,
25    /// InFlight Transferあり — 転送中。
26    Syncing,
27    /// リトライ上限到達 or 永続的エラー — 手動介入が必要。
28    Failed,
29    /// ソースにファイルなしで失敗 — 再スキャンが必要。
30    Absent,
31}
32
33impl PresenceState {
34    pub fn as_str(&self) -> &'static str {
35        match self {
36            Self::Present => "present",
37            Self::Pending => "pending",
38            Self::Syncing => "syncing",
39            Self::Failed => "failed",
40            Self::Absent => "absent",
41        }
42    }
43
44    /// Priority for conflict resolution when a location appears as both
45    /// src and dest for the same file. Higher value wins.
46    ///
47    /// Failed > Syncing > Pending > Present > Absent
48    pub fn priority(&self) -> u8 {
49        match self {
50            Self::Absent => 0,
51            Self::Present => 1,
52            Self::Pending => 2,
53            Self::Syncing => 3,
54            Self::Failed => 4,
55        }
56    }
57
58    /// Transfer + RetryPolicy からPresenceStateを導出。
59    pub fn from_transfer(transfer: &Transfer, policy: &RetryPolicy) -> Self {
60        match transfer.state() {
61            TransferState::Blocked => Self::Pending,
62            TransferState::Queued => Self::Pending,
63            TransferState::InFlight => Self::Syncing,
64            TransferState::Completed => Self::Present,
65            TransferState::Failed => {
66                if transfer.is_retryable(policy) {
67                    Self::Pending
68                } else {
69                    Self::Failed
70                }
71            }
72            TransferState::Cancelled => Self::Absent,
73        }
74    }
75}
76
77impl fmt::Display for PresenceState {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        f.write_str(self.as_str())
80    }
81}
82
83/// 特定locationでのファイルの存在状況ビュー。
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct PresenceView {
86    pub location: LocationId,
87    pub state: PresenceState,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub error: Option<String>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub synced_at: Option<DateTime<Utc>>,
92    pub attempt: u32,
93}
94
95/// 失敗したTransferの表示情報。
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct ErrorEntry {
98    pub file_id: String,
99    pub src: LocationId,
100    pub dest: LocationId,
101    pub error: String,
102    pub attempts: u32,
103}
104
105impl ErrorEntry {
106    pub(crate) fn from_transfer(t: &Transfer) -> Self {
107        Self {
108            file_id: t.file_id().to_string(),
109            src: t.src().clone(),
110            dest: t.dest().clone(),
111            error: t.error().unwrap_or("unknown error").to_string(),
112            attempts: t.attempt(),
113        }
114    }
115}
116
117/// 待機中Transferの表示情報。
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct PendingEntry {
120    pub file_id: String,
121    pub src: LocationId,
122    pub dest: LocationId,
123    pub kind: TransferKind,
124}
125
126impl PendingEntry {
127    pub(crate) fn from_transfer(t: &Transfer) -> Self {
128        Self {
129            file_id: t.file_id().to_string(),
130            src: t.src().clone(),
131            dest: t.dest().clone(),
132            kind: t.kind(),
133        }
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::domain::retry::TransferErrorKind;
141
142    fn loc(s: &str) -> LocationId {
143        LocationId::new(s).unwrap()
144    }
145
146    fn make_transfer(
147        state: TransferState,
148        error_kind: Option<TransferErrorKind>,
149        attempt: u32,
150    ) -> Transfer {
151        Transfer::reconstitute(
152            "t-1".into(),
153            "f-1".into(),
154            loc("local"),
155            loc("cloud"),
156            TransferKind::Sync,
157            state,
158            if state == TransferState::Failed {
159                Some("err".into())
160            } else {
161                None
162            },
163            error_kind,
164            attempt,
165            Utc::now(),
166            if state != TransferState::Queued {
167                Some(Utc::now())
168            } else {
169                None
170            },
171            if matches!(state, TransferState::Completed | TransferState::Failed) {
172                Some(Utc::now())
173            } else {
174                None
175            },
176        )
177    }
178
179    #[test]
180    fn presence_from_queued() {
181        let t = make_transfer(TransferState::Queued, None, 1);
182        assert_eq!(
183            PresenceState::from_transfer(&t, &RetryPolicy::default()),
184            PresenceState::Pending
185        );
186    }
187
188    #[test]
189    fn presence_from_completed() {
190        let t = make_transfer(TransferState::Completed, None, 1);
191        assert_eq!(
192            PresenceState::from_transfer(&t, &RetryPolicy::default()),
193            PresenceState::Present
194        );
195    }
196
197    #[test]
198    fn presence_from_in_flight() {
199        let t = make_transfer(TransferState::InFlight, None, 1);
200        assert_eq!(
201            PresenceState::from_transfer(&t, &RetryPolicy::default()),
202            PresenceState::Syncing
203        );
204    }
205
206    #[test]
207    fn presence_from_failed_transient_retryable() {
208        let policy = RetryPolicy::new(3);
209        let t = make_transfer(TransferState::Failed, Some(TransferErrorKind::Transient), 1);
210        assert_eq!(
211            PresenceState::from_transfer(&t, &policy),
212            PresenceState::Pending
213        );
214    }
215
216    #[test]
217    fn presence_from_failed_transient_exhausted() {
218        let policy = RetryPolicy::new(3);
219        let t = make_transfer(TransferState::Failed, Some(TransferErrorKind::Transient), 3);
220        assert_eq!(
221            PresenceState::from_transfer(&t, &policy),
222            PresenceState::Failed
223        );
224    }
225
226    #[test]
227    fn presence_from_failed_permanent() {
228        let policy = RetryPolicy::new(10);
229        let t = make_transfer(TransferState::Failed, Some(TransferErrorKind::Permanent), 1);
230        assert_eq!(
231            PresenceState::from_transfer(&t, &policy),
232            PresenceState::Failed
233        );
234    }
235}