use std::path::{Path, PathBuf};
use std::sync::Arc;
use async_trait::async_trait;
use crate::domain::location::LocationId;
use crate::infra::error::InfraError;
use crate::infra::location_scanner::LocationScanner;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LocationKind {
Local,
Remote,
Cloud,
}
#[async_trait]
pub trait Location: Send + Sync {
fn id(&self) -> &LocationId;
fn kind(&self) -> LocationKind;
fn file_root(&self) -> &Path;
fn scanner(&self) -> Arc<dyn LocationScanner>;
async fn ensure(&self) -> Result<(), InfraError>;
}
use crate::infra::hasher::ContentHasher;
use crate::infra::location_scanner::LocalScanner;
pub struct LocalLocation {
id: LocationId,
root: PathBuf,
hasher: Arc<dyn ContentHasher>,
}
impl LocalLocation {
pub fn new(root: PathBuf, hasher: Arc<dyn ContentHasher>) -> Self {
Self::new_with_id(LocationId::local(), root, hasher)
}
pub fn new_with_id(id: LocationId, root: PathBuf, hasher: Arc<dyn ContentHasher>) -> Self {
Self { id, root, hasher }
}
}
#[async_trait]
impl Location for LocalLocation {
fn id(&self) -> &LocationId {
&self.id
}
fn kind(&self) -> LocationKind {
LocationKind::Local
}
fn file_root(&self) -> &Path {
&self.root
}
fn scanner(&self) -> Arc<dyn LocationScanner> {
Arc::new(LocalScanner::new(
self.id.clone(),
self.root.clone(),
self.hasher.clone(),
))
}
async fn ensure(&self) -> Result<(), InfraError> {
if !self.root.exists() {
std::fs::create_dir_all(&self.root).map_err(|e| {
InfraError::Init(format!(
"local file_root '{}' does not exist and could not be created: {e}",
self.root.display()
))
})?;
}
if !self.root.is_dir() {
return Err(InfraError::Init(format!(
"local file_root '{}' exists but is not a directory",
self.root.display()
)));
}
Ok(())
}
}
use crate::infra::location_scanner::SshScanner;
use crate::infra::shell::RemoteShell;
pub struct SshLocation {
id: LocationId,
root: PathBuf,
shell: Arc<dyn RemoteShell>,
}
impl SshLocation {
pub fn new(id: LocationId, root: PathBuf, shell: Arc<dyn RemoteShell>) -> Self {
Self { id, root, shell }
}
}
#[async_trait]
impl Location for SshLocation {
fn id(&self) -> &LocationId {
&self.id
}
fn kind(&self) -> LocationKind {
LocationKind::Remote
}
fn file_root(&self) -> &Path {
&self.root
}
fn scanner(&self) -> Arc<dyn LocationScanner> {
Arc::new(SshScanner::new(
self.id.clone(),
self.root.clone(),
self.shell.clone(),
))
}
async fn ensure(&self) -> Result<(), InfraError> {
let output = self.shell.exec(&["echo", "pong"], Some(30)).await?;
if !output.success {
return Err(InfraError::Init(format!(
"SSH location '{}' unreachable (exit {}): {}",
self.id,
output.exit_code.unwrap_or(-1),
output.stderr.trim()
)));
}
Ok(())
}
}
use crate::infra::backend::StorageBackend;
use crate::infra::location_scanner::CloudScanner;
pub struct CloudLocation {
id: LocationId,
root: PathBuf,
backend: Arc<dyn StorageBackend>,
}
impl CloudLocation {
pub fn new(id: LocationId, root: PathBuf, backend: Arc<dyn StorageBackend>) -> Self {
Self { id, root, backend }
}
}
#[async_trait]
impl Location for CloudLocation {
fn id(&self) -> &LocationId {
&self.id
}
fn kind(&self) -> LocationKind {
LocationKind::Cloud
}
fn file_root(&self) -> &Path {
&self.root
}
fn scanner(&self) -> Arc<dyn LocationScanner> {
Arc::new(CloudScanner::new(
self.id.clone(),
self.root.clone(),
self.backend.clone(),
))
}
async fn ensure(&self) -> Result<(), InfraError> {
self.backend.ensure().await.map_err(|e| {
InfraError::Init(format!("cloud location '{}' ensure failed: {e}", self.id))
})
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::sync::Arc;
use super::*;
use crate::domain::location::LocationId;
use crate::infra::hasher::Djb2Hasher;
fn make_hasher() -> Arc<dyn ContentHasher> {
Arc::new(Djb2Hasher)
}
#[test]
fn new_with_id_stores_custom_id() {
let root = PathBuf::from("/tmp/projects");
let id = LocationId::new("projects").unwrap();
let loc = LocalLocation::new_with_id(id, root, make_hasher());
assert_eq!(loc.id().as_str(), "projects");
}
#[test]
fn new_with_id_kind_and_file_root() {
let root = PathBuf::from("/tmp/projects");
let id = LocationId::new("my-loc").unwrap();
let loc = LocalLocation::new_with_id(id, root.clone(), make_hasher());
assert_eq!(loc.kind(), LocationKind::Local);
assert_eq!(loc.file_root(), root.as_path());
}
#[test]
fn new_delegates_to_local_id() {
let root = PathBuf::from("/tmp/output");
let loc = LocalLocation::new(root, make_hasher());
assert_eq!(loc.id().as_str(), "local");
assert_eq!(loc.kind(), LocationKind::Local);
}
#[test]
fn location_id_rejects_invalid_chars() {
assert!(LocationId::new("Invalid").is_err());
assert!(LocationId::new("has space").is_err());
assert!(LocationId::new("").is_err());
}
}