Skip to main content

vdsl_sync/infra/
location.rs

1//! Location — 拠点の多態抽象。
2//!
3//! 各拠点は「何があるか」(scan) と「どこにファイルがあるか」(file_root) を知っている。
4//! Local/SSH/Cloud で処理内容が根本的に異なるため、trait による多態で実装を切り替える。
5//!
6//! # 層配置
7//!
8//! `Location` trait は infra層に配置する。
9//! 理由: 実装が RemoteShell, StorageBackend, ContentHasher 等の infra型に依存するため。
10//! Domain層の `LocationId` は値オブジェクト(識別子のみ)として残る。
11//!
12//! # 責務
13//!
14//! - `id()` → この拠点の識別子
15//! - `kind()` → 拠点の物理的分類(コスト推定に使用)
16//! - `file_root()` → ファイルのベースパス
17//! - `scanner()` → この拠点のスキャン能力(LocationScanner)
18//! - `ensure()` → 到達確認 + 外部ツールの確保(rclone等)
19
20use std::path::{Path, PathBuf};
21use std::sync::Arc;
22
23use async_trait::async_trait;
24
25use crate::domain::location::LocationId;
26use crate::infra::error::InfraError;
27use crate::infra::location_scanner::LocationScanner;
28
29/// 拠点の物理的分類。
30///
31/// `SdkImplBuilder::build()` でルートコストを自動推定するために使用する。
32/// 2拠点間の転送コストは、双方の `LocationKind` の組み合わせで決まる:
33///
34/// | src → dest | コスト | 根拠 |
35/// |---|---|---|
36/// | Local → Remote | 1.0 | LAN/SSH、低レイテンシ |
37/// | Remote → Cloud | 2.0 | DC帯域、中速 |
38/// | Local → Cloud | 5.0 | 家庭回線アップロード、低速 |
39/// | Cloud → Remote | 2.0 | DC帯域、中速 |
40/// | Cloud → Local | 5.0 | 家庭回線ダウンロード |
41/// | Remote → Local | 1.0 | LAN/SSH |
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub enum LocationKind {
44    /// ローカルファイルシステム(開発マシン等)。
45    Local,
46    /// SSH経由リモートホスト(GPU Pod, NAS等)。データセンター帯域。
47    Remote,
48    /// クラウドストレージ(B2, S3等)。オブジェクトストア。
49    Cloud,
50}
51
52/// 拠点の多態抽象。
53///
54/// 各拠点は自分のスキャン方法を知っている:
55/// - Local: walkdir + ContentHasher
56/// - SSH: RemoteShell + batch_inspect
57/// - Cloud: StorageBackend.list() (metadata only)
58///
59/// `Location` trait 実装を `SdkImplBuilder::location()` に渡すことで、
60/// Scanner と Route の整合性が保証される。
61/// `kind()` は `SdkImplBuilder::build()` でルートコストの自動推定に使用される。
62#[async_trait]
63pub trait Location: Send + Sync {
64    /// この拠点の識別子。
65    fn id(&self) -> &LocationId;
66
67    /// 拠点の物理的分類。
68    ///
69    /// ルート間コスト推定に使用される。
70    fn kind(&self) -> LocationKind;
71
72    /// ファイルのベースパス。
73    ///
74    /// Local: `/Users/.../output`
75    /// Pod: `/workspace/comfyui/output`
76    /// Cloud: `vdsl/output`
77    fn file_root(&self) -> &Path;
78
79    /// この拠点のスキャナーを返す。
80    ///
81    /// 各実装が自分のスキャン方法に応じたLocationScannerを構築して返す。
82    fn scanner(&self) -> Arc<dyn LocationScanner>;
83
84    /// 拠点の到達可能性を検証し、必要な外部ツールを確保する。
85    ///
86    /// sync開始前に全Locationに対して呼ばれる。
87    /// - Local: file_rootの存在確認(なければ作成)
88    /// - SSH: SSH接続テスト
89    /// - Cloud: rcloneバイナリ確認 + バケット接続テスト
90    ///
91    /// 失敗時は早期エラーで、数分かかるscanを無駄にしない。
92    async fn ensure(&self) -> Result<(), InfraError>;
93}
94
95// =============================================================================
96// LocalLocation
97// =============================================================================
98
99use crate::infra::hasher::ContentHasher;
100use crate::infra::location_scanner::LocalScanner;
101
102/// ローカルファイルシステムの拠点。
103///
104/// walkdir + ContentHasher でスキャンする。
105pub struct LocalLocation {
106    id: LocationId,
107    root: PathBuf,
108    hasher: Arc<dyn ContentHasher>,
109}
110
111impl LocalLocation {
112    /// Create a `LocalLocation` with the canonical `"local"` [`LocationId`].
113    ///
114    /// # Arguments
115    ///
116    /// * `root` - Local filesystem path used as `file_root` for scan and route resolution.
117    /// * `hasher` - Shared content hasher for change detection.
118    ///
119    /// # Returns
120    ///
121    /// A `LocalLocation` identified as `"local"`.
122    ///
123    /// For multiple `LocalLocation`s with distinct IDs, use [`Self::new_with_id`].
124    pub fn new(root: PathBuf, hasher: Arc<dyn ContentHasher>) -> Self {
125        Self::new_with_id(LocationId::local(), root, hasher)
126    }
127
128    /// Create a `LocalLocation` with an arbitrary [`LocationId`].
129    ///
130    /// Useful when registering multiple local roots as separate locations
131    /// (e.g. `output` vs `projects`). The caller is responsible for ensuring
132    /// the `LocationId` is unique within a single [`crate::application::sdk_impl::SdkImplBuilder`].
133    ///
134    /// # Arguments
135    ///
136    /// * `id` - Location identifier. Must be unique among all locations registered
137    ///   with the same builder. Constructed via [`LocationId::new`].
138    /// * `root` - Local filesystem path used as `file_root` for scan and route resolution.
139    /// * `hasher` - Shared content hasher for change detection.
140    ///
141    /// # Returns
142    ///
143    /// A `LocalLocation` with the provided `id` and `root`.
144    pub fn new_with_id(id: LocationId, root: PathBuf, hasher: Arc<dyn ContentHasher>) -> Self {
145        Self { id, root, hasher }
146    }
147}
148
149#[async_trait]
150impl Location for LocalLocation {
151    fn id(&self) -> &LocationId {
152        &self.id
153    }
154
155    fn kind(&self) -> LocationKind {
156        LocationKind::Local
157    }
158
159    fn file_root(&self) -> &Path {
160        &self.root
161    }
162
163    fn scanner(&self) -> Arc<dyn LocationScanner> {
164        Arc::new(LocalScanner::new(
165            self.id.clone(),
166            self.root.clone(),
167            self.hasher.clone(),
168        ))
169    }
170
171    async fn ensure(&self) -> Result<(), InfraError> {
172        if !self.root.exists() {
173            std::fs::create_dir_all(&self.root).map_err(|e| {
174                InfraError::Init(format!(
175                    "local file_root '{}' does not exist and could not be created: {e}",
176                    self.root.display()
177                ))
178            })?;
179        }
180        if !self.root.is_dir() {
181            return Err(InfraError::Init(format!(
182                "local file_root '{}' exists but is not a directory",
183                self.root.display()
184            )));
185        }
186        Ok(())
187    }
188}
189
190// =============================================================================
191// SshLocation
192// =============================================================================
193
194use crate::infra::location_scanner::SshScanner;
195use crate::infra::shell::RemoteShell;
196
197/// SSH経由リモートホストの拠点。
198///
199/// RemoteShell.batch_inspect() でスキャンする。
200pub struct SshLocation {
201    id: LocationId,
202    root: PathBuf,
203    shell: Arc<dyn RemoteShell>,
204}
205
206impl SshLocation {
207    pub fn new(id: LocationId, root: PathBuf, shell: Arc<dyn RemoteShell>) -> Self {
208        Self { id, root, shell }
209    }
210}
211
212#[async_trait]
213impl Location for SshLocation {
214    fn id(&self) -> &LocationId {
215        &self.id
216    }
217
218    fn kind(&self) -> LocationKind {
219        LocationKind::Remote
220    }
221
222    fn file_root(&self) -> &Path {
223        &self.root
224    }
225
226    fn scanner(&self) -> Arc<dyn LocationScanner> {
227        Arc::new(SshScanner::new(
228            self.id.clone(),
229            self.root.clone(),
230            self.shell.clone(),
231        ))
232    }
233
234    async fn ensure(&self) -> Result<(), InfraError> {
235        let output = self.shell.exec(&["echo", "pong"], Some(30)).await?;
236        if !output.success {
237            return Err(InfraError::Init(format!(
238                "SSH location '{}' unreachable (exit {}): {}",
239                self.id,
240                output.exit_code.unwrap_or(-1),
241                output.stderr.trim()
242            )));
243        }
244        Ok(())
245    }
246}
247
248// =============================================================================
249// CloudLocation
250// =============================================================================
251
252use crate::infra::backend::StorageBackend;
253use crate::infra::location_scanner::CloudScanner;
254
255/// Cloud storage の拠点。
256///
257/// StorageBackend.list() でメタデータのみ取得する。
258/// コンテンツハッシュはダウンロードが必要なため取得しない。
259pub struct CloudLocation {
260    id: LocationId,
261    root: PathBuf,
262    backend: Arc<dyn StorageBackend>,
263}
264
265impl CloudLocation {
266    pub fn new(id: LocationId, root: PathBuf, backend: Arc<dyn StorageBackend>) -> Self {
267        Self { id, root, backend }
268    }
269}
270
271#[async_trait]
272impl Location for CloudLocation {
273    fn id(&self) -> &LocationId {
274        &self.id
275    }
276
277    fn kind(&self) -> LocationKind {
278        LocationKind::Cloud
279    }
280
281    fn file_root(&self) -> &Path {
282        &self.root
283    }
284
285    fn scanner(&self) -> Arc<dyn LocationScanner> {
286        Arc::new(CloudScanner::new(
287            self.id.clone(),
288            self.root.clone(),
289            self.backend.clone(),
290        ))
291    }
292
293    async fn ensure(&self) -> Result<(), InfraError> {
294        self.backend.ensure().await.map_err(|e| {
295            InfraError::Init(format!("cloud location '{}' ensure failed: {e}", self.id))
296        })
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use std::path::PathBuf;
303    use std::sync::Arc;
304
305    use super::*;
306    use crate::domain::location::LocationId;
307    use crate::infra::hasher::Djb2Hasher;
308
309    fn make_hasher() -> Arc<dyn ContentHasher> {
310        Arc::new(Djb2Hasher)
311    }
312
313    // T1: happy path — new_with_id stores the provided LocationId
314    #[test]
315    fn new_with_id_stores_custom_id() {
316        let root = PathBuf::from("/tmp/projects");
317        // SAFETY: "projects" is valid (lowercase alphanum), unwrap cannot panic
318        let id = LocationId::new("projects").unwrap();
319        let loc = LocalLocation::new_with_id(id, root, make_hasher());
320        assert_eq!(loc.id().as_str(), "projects");
321    }
322
323    // T1: happy path — new_with_id produces LocationKind::Local and correct file_root
324    #[test]
325    fn new_with_id_kind_and_file_root() {
326        let root = PathBuf::from("/tmp/projects");
327        // SAFETY: "my-loc" is valid (lowercase alphanum + hyphen), unwrap cannot panic
328        let id = LocationId::new("my-loc").unwrap();
329        let loc = LocalLocation::new_with_id(id, root.clone(), make_hasher());
330        assert_eq!(loc.kind(), LocationKind::Local);
331        assert_eq!(loc.file_root(), root.as_path());
332    }
333
334    // T2: boundary — existing new() still produces "local" id (delegation compatibility)
335    #[test]
336    fn new_delegates_to_local_id() {
337        let root = PathBuf::from("/tmp/output");
338        let loc = LocalLocation::new(root, make_hasher());
339        assert_eq!(loc.id().as_str(), "local");
340        assert_eq!(loc.kind(), LocationKind::Local);
341    }
342
343    // T3: error path — LocationId::new rejects invalid input (uppercase, space)
344    #[test]
345    fn location_id_rejects_invalid_chars() {
346        assert!(LocationId::new("Invalid").is_err());
347        assert!(LocationId::new("has space").is_err());
348        assert!(LocationId::new("").is_err());
349    }
350}