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())
}
}