use crate::db::{models::File, repository::FileRepository};
use crate::services::ServiceContext;
use anyhow::{Context, Result};
use chrono::Utc;
use std::path::{Path, PathBuf};
use uuid::Uuid;
fn is_inside_git_repo(path: &Path) -> bool {
let mut dir = path.parent();
while let Some(d) = dir {
if d.join(".git").exists() {
return true;
}
dir = d.parent();
}
false
}
fn is_ephemeral_share(path: &Path) -> bool {
path.starts_with(crate::config::opencrabs_home().join("tmp"))
}
#[cfg(unix)]
fn symlink_file(target: &Path, dest: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(target, dest)
}
#[cfg(not(unix))]
fn symlink_file(_target: &Path, _dest: &Path) -> std::io::Result<()> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"symlink not supported on this platform",
))
}
pub(crate) fn slugify_project_name(name: &str) -> String {
let mut slug = String::with_capacity(name.len());
let mut prev_dash = false;
for c in name.chars() {
if c.is_ascii_alphanumeric() {
slug.push(c.to_ascii_lowercase());
prev_dash = false;
} else if !prev_dash {
slug.push('-');
prev_dash = true;
}
}
let trimmed = slug.trim_matches('-');
if trimmed.is_empty() {
"project".to_string()
} else {
trimmed.to_string()
}
}
#[derive(Clone)]
pub struct FileService {
context: ServiceContext,
}
impl FileService {
pub fn new(context: ServiceContext) -> Self {
Self { context }
}
pub async fn track_file(
&self,
session_id: Uuid,
path: PathBuf,
content: Option<String>,
) -> Result<File> {
let repo = FileRepository::new(self.context.pool());
let size = std::fs::metadata(&path).ok().map(|m| m.len() as i64);
let file = File {
id: Uuid::new_v4(),
session_id,
path: path.clone(),
content,
size,
created_at: Utc::now(),
updated_at: Utc::now(),
};
repo.create(&file).await.context("Failed to track file")?;
tracing::debug!("Tracked new file: {:?} in session {}", path, session_id);
Ok(file)
}
pub async fn get_file(&self, id: Uuid) -> Result<Option<File>> {
let repo = FileRepository::new(self.context.pool());
repo.find_by_id(id).await.context("Failed to get file")
}
pub async fn get_file_required(&self, id: Uuid) -> Result<File> {
self.get_file(id)
.await?
.ok_or_else(|| anyhow::anyhow!("File not found: {}", id))
}
pub async fn list_files_for_session(&self, session_id: Uuid) -> Result<Vec<File>> {
let repo = FileRepository::new(self.context.pool());
repo.find_by_session(session_id)
.await
.context("Failed to list files for session")
}
pub async fn find_file_by_path(&self, session_id: Uuid, path: &Path) -> Result<Option<File>> {
let repo = FileRepository::new(self.context.pool());
repo.find_by_path(session_id, path)
.await
.context("Failed to find file by path")
}
pub async fn update_file(&self, file: &File) -> Result<()> {
let repo = FileRepository::new(self.context.pool());
let mut updated_file = file.clone();
updated_file.updated_at = Utc::now();
repo.update(&updated_file)
.await
.context("Failed to update file")?;
tracing::debug!("Updated file: {:?}", file.path);
Ok(())
}
pub async fn update_file_content(&self, id: Uuid, content: Option<String>) -> Result<()> {
let mut file = self.get_file_required(id).await?;
file.content = content;
file.updated_at = Utc::now();
let repo = FileRepository::new(self.context.pool());
repo.update(&file)
.await
.context("Failed to update file content")?;
tracing::debug!("Updated file content: {:?}", file.path);
Ok(())
}
pub async fn delete_file(&self, id: Uuid) -> Result<()> {
let repo = FileRepository::new(self.context.pool());
repo.delete(id).await.context("Failed to delete file")?;
tracing::debug!("Deleted file: {}", id);
Ok(())
}
pub async fn delete_files_for_session(&self, session_id: Uuid) -> Result<()> {
let repo = FileRepository::new(self.context.pool());
repo.delete_by_session(session_id)
.await
.context("Failed to delete files for session")?;
tracing::info!("Deleted files for session {}", session_id);
Ok(())
}
pub async fn count_files_in_session(&self, session_id: Uuid) -> Result<i64> {
let repo = FileRepository::new(self.context.pool());
repo.count_by_session(session_id)
.await
.context("Failed to count files in session")
}
pub async fn is_file_tracked(&self, session_id: Uuid, path: &Path) -> Result<bool> {
let file = self.find_file_by_path(session_id, path).await?;
Ok(file.is_some())
}
pub async fn get_or_create_file(
&self,
session_id: Uuid,
path: PathBuf,
content: Option<String>,
) -> Result<File> {
let path = self.archive_into_project(session_id, path).await;
if let Some(file) = self.find_file_by_path(session_id, &path).await? {
return Ok(file);
}
self.track_file(session_id, path, content).await
}
pub async fn project_files_dir(&self, session_id: Uuid) -> Option<PathBuf> {
use crate::db::repository::{ProjectRepository, SessionRepository};
let session = SessionRepository::new(self.context.pool())
.find_by_id(session_id)
.await
.ok()??;
let project_id = session.project_id?;
let project = ProjectRepository::new(self.context.pool())
.find_by_id(project_id)
.await
.ok()??;
let dir = crate::services::ProjectService::projects_dir()
.join(slugify_project_name(&project.name))
.join("files");
std::fs::create_dir_all(&dir).ok()?;
Some(dir)
}
async fn archive_into_project(&self, session_id: Uuid, path: PathBuf) -> PathBuf {
if is_inside_git_repo(&path) {
return path;
}
let Some(dir) = self.project_files_dir(session_id).await else {
return path;
};
if !path.is_file() || path.starts_with(&dir) {
return path;
}
let Some(name) = path.file_name() else {
return path;
};
let dest = dir.join(name);
if dest.exists() {
return dest;
}
if is_ephemeral_share(&path) {
match std::fs::copy(&path, &dest) {
Ok(_) => {
tracing::info!(
"Copied shared file into project: {} -> {}",
path.display(),
dest.display()
);
dest
}
Err(e) => {
tracing::warn!("Failed to copy shared file into project dir: {e}");
path
}
}
} else {
let target = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
match symlink_file(&target, &dest) {
Ok(()) => {
tracing::info!(
"Linked local file into project: {} -> {}",
dest.display(),
target.display()
);
dest
}
Err(link_err) => match std::fs::copy(&path, &dest) {
Ok(_) => {
tracing::info!(
"Symlink unavailable ({link_err}); copied local file into project: {} -> {}",
path.display(),
dest.display()
);
dest
}
Err(e) => {
tracing::warn!("Failed to link or copy local file into project dir: {e}");
path
}
},
}
}
}
pub async fn get_files_with_content(&self, session_id: Uuid) -> Result<Vec<File>> {
let files = self.list_files_for_session(session_id).await?;
Ok(files.into_iter().filter(|f| f.content.is_some()).collect())
}
pub async fn get_files_without_content(&self, session_id: Uuid) -> Result<Vec<File>> {
let files = self.list_files_for_session(session_id).await?;
Ok(files.into_iter().filter(|f| f.content.is_none()).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::services::SessionService;
async fn create_test_service() -> (FileService, SessionService) {
use crate::db::Database;
let db = Database::connect_in_memory().await.unwrap();
db.run_migrations().await.unwrap();
let pool = db.pool().clone();
let context = ServiceContext::new(pool);
(
FileService::new(context.clone()),
SessionService::new(context),
)
}
#[tokio::test]
async fn test_track_file() {
let (file_service, session_service) = create_test_service().await;
let session = session_service
.create_session(Some("Test".to_string()))
.await
.unwrap();
let file = file_service
.track_file(
session.id,
PathBuf::from("/test/file.txt"),
Some("content".to_string()),
)
.await
.unwrap();
assert_eq!(file.session_id, session.id);
assert_eq!(file.path, PathBuf::from("/test/file.txt"));
assert_eq!(file.content, Some("content".to_string()));
}
#[tokio::test]
async fn test_get_file() {
let (file_service, session_service) = create_test_service().await;
let session = session_service
.create_session(Some("Test".to_string()))
.await
.unwrap();
let created = file_service
.track_file(session.id, PathBuf::from("/test/file.txt"), None)
.await
.unwrap();
let found = file_service.get_file(created.id).await.unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().id, created.id);
}
#[tokio::test]
async fn test_list_files_for_session() {
let (file_service, session_service) = create_test_service().await;
let session = session_service
.create_session(Some("Test".to_string()))
.await
.unwrap();
file_service
.track_file(session.id, PathBuf::from("/test/file1.txt"), None)
.await
.unwrap();
file_service
.track_file(session.id, PathBuf::from("/test/file2.txt"), None)
.await
.unwrap();
let files = file_service
.list_files_for_session(session.id)
.await
.unwrap();
assert_eq!(files.len(), 2);
}
#[tokio::test]
async fn test_find_file_by_path() {
let (file_service, session_service) = create_test_service().await;
let session = session_service
.create_session(Some("Test".to_string()))
.await
.unwrap();
let path = PathBuf::from("/test/file.txt");
file_service
.track_file(session.id, path.clone(), None)
.await
.unwrap();
let found = file_service
.find_file_by_path(session.id, &path)
.await
.unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().path, path);
}
#[tokio::test]
async fn test_update_file_content() {
let (file_service, session_service) = create_test_service().await;
let session = session_service
.create_session(Some("Test".to_string()))
.await
.unwrap();
let file = file_service
.track_file(session.id, PathBuf::from("/test/file.txt"), None)
.await
.unwrap();
file_service
.update_file_content(file.id, Some("new content".to_string()))
.await
.unwrap();
let updated = file_service.get_file_required(file.id).await.unwrap();
assert_eq!(updated.content, Some("new content".to_string()));
}
#[tokio::test]
async fn test_delete_file() {
let (file_service, session_service) = create_test_service().await;
let session = session_service
.create_session(Some("Test".to_string()))
.await
.unwrap();
let file = file_service
.track_file(session.id, PathBuf::from("/test/file.txt"), None)
.await
.unwrap();
file_service.delete_file(file.id).await.unwrap();
let result = file_service.get_file(file.id).await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn test_delete_files_for_session() {
let (file_service, session_service) = create_test_service().await;
let session = session_service
.create_session(Some("Test".to_string()))
.await
.unwrap();
file_service
.track_file(session.id, PathBuf::from("/test/file1.txt"), None)
.await
.unwrap();
file_service
.track_file(session.id, PathBuf::from("/test/file2.txt"), None)
.await
.unwrap();
file_service
.delete_files_for_session(session.id)
.await
.unwrap();
let files = file_service
.list_files_for_session(session.id)
.await
.unwrap();
assert_eq!(files.len(), 0);
}
#[tokio::test]
async fn test_count_files_in_session() {
let (file_service, session_service) = create_test_service().await;
let session = session_service
.create_session(Some("Test".to_string()))
.await
.unwrap();
file_service
.track_file(session.id, PathBuf::from("/test/file1.txt"), None)
.await
.unwrap();
file_service
.track_file(session.id, PathBuf::from("/test/file2.txt"), None)
.await
.unwrap();
let count = file_service
.count_files_in_session(session.id)
.await
.unwrap();
assert_eq!(count, 2);
}
#[tokio::test]
async fn test_is_file_tracked() {
let (file_service, session_service) = create_test_service().await;
let session = session_service
.create_session(Some("Test".to_string()))
.await
.unwrap();
let path = PathBuf::from("/test/file.txt");
file_service
.track_file(session.id, path.clone(), None)
.await
.unwrap();
let is_tracked = file_service
.is_file_tracked(session.id, &path)
.await
.unwrap();
assert!(is_tracked);
let not_tracked = file_service
.is_file_tracked(session.id, &PathBuf::from("/test/other.txt"))
.await
.unwrap();
assert!(!not_tracked);
}
#[tokio::test]
async fn test_get_or_create_file() {
let (file_service, session_service) = create_test_service().await;
let session = session_service
.create_session(Some("Test".to_string()))
.await
.unwrap();
let path = PathBuf::from("/test/file.txt");
let file1 = file_service
.get_or_create_file(session.id, path.clone(), Some("content".to_string()))
.await
.unwrap();
let file2 = file_service
.get_or_create_file(session.id, path.clone(), None)
.await
.unwrap();
assert_eq!(file1.id, file2.id);
}
#[tokio::test]
async fn test_get_files_with_content() {
let (file_service, session_service) = create_test_service().await;
let session = session_service
.create_session(Some("Test".to_string()))
.await
.unwrap();
file_service
.track_file(
session.id,
PathBuf::from("/test/file1.txt"),
Some("content".to_string()),
)
.await
.unwrap();
file_service
.track_file(session.id, PathBuf::from("/test/file2.txt"), None)
.await
.unwrap();
let with_content = file_service
.get_files_with_content(session.id)
.await
.unwrap();
let without_content = file_service
.get_files_without_content(session.id)
.await
.unwrap();
assert_eq!(with_content.len(), 1);
assert_eq!(without_content.len(), 1);
}
}