diff --git a/Cargo.toml b/Cargo.toml index 46e41a9..a4c09fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,11 +14,12 @@ name = "coduck-backend" anyhow = "1.0" axum = { version = "0.8.4", features = ["json", "multipart"] } chrono = { version = "0.4.38", features = ["serde"] } -reqwest = { version = "0.12.19", features = ["json", "rustls-tls"] } +git2 = "0.20.2" serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.133" tokio = { version = "1.45.1", features = ["full"] } uuid = { version = "1.17.0", features = ["v4"] } [dev-dependencies] +reqwest = { version = "0.12.19", features = ["json", "rustls-tls"] } rstest = "0.25.0" diff --git a/src/file_manager/git.rs b/src/file_manager/git.rs new file mode 100644 index 0000000..f679ba3 --- /dev/null +++ b/src/file_manager/git.rs @@ -0,0 +1,392 @@ +#![allow(dead_code)] + +use anyhow::{Context, Result}; +use git2::{DiffOptions, IndexAddOption, Repository, StatusOptions, Time}; +use std::path::PathBuf; +use tokio::fs; + +const UPLOAD_DIR: &str = "uploads"; +const DEFAULT_DIRECTORIES: [&str; 3] = ["solutions", "tests", "statements"]; + +#[derive(Debug)] +struct GitManager { + problem_id: u32, +} + +impl GitManager { + fn new(problem_id: u32) -> Self { + Self { problem_id } + } + + fn git_init(&self) -> Result<()> { + let path = PathBuf::from(UPLOAD_DIR).join(self.problem_id.to_string()); + Repository::init(&path) + .and_then(|repo| { + let mut config = repo.config()?; + config.set_str("user.name", "admin")?; + config.set_str("user.email", "admin@coduck.com")?; + Ok(repo) + }) + .map(|_| ()) + .with_context(|| format!("Failed to init git repo at {:?}", path)) + } + + async fn create_problem(&self) -> Result<()> { + self.git_init()?; + self.create_default_directories().await?; + self.git_add_all()?; + Ok(()) + } + + fn git_add_all(&self) -> Result<()> { + let repo = self.get_repository()?; + let mut idx = repo.index()?; + idx.add_all(["."].iter(), IndexAddOption::DEFAULT, None)?; + idx.write()?; + Ok(()) + } + + fn git_commit(&self, message: String) -> Result { + self.git_add_all()?; + let repo = self.get_repository()?; + let mut idx = repo.index()?; + let tree_id = idx.write_tree()?; + let tree = repo.find_tree(tree_id)?; + let sig = repo.signature()?; + let parent_commits = match repo.head() { + Ok(head_ref) => { + let head = head_ref + .target() + .ok_or_else(|| anyhow::anyhow!("HEAD does not point to a valid commit"))?; + vec![repo.find_commit(head)?] + } + Err(_) => Vec::new(), + }; + + let parents: Vec<&git2::Commit> = parent_commits.iter().collect(); + let commit_oid = repo.commit(Some("HEAD"), &sig, &sig, &message, &tree, &parents)?; + Ok(commit_oid.to_string()) + } + + fn git_status(&self) -> Result> { + let repo = self.get_repository()?; + let mut status_opts = StatusOptions::new(); + status_opts + .include_untracked(true) + .recurse_untracked_dirs(true); + let statuses = repo.statuses(Some(&mut status_opts))?; + let mut file_infos = Vec::new(); + for entry in statuses.iter() { + let status = Self::status_to_string(entry.status()); + let path = entry.path().unwrap_or("unknown").to_string(); + file_infos.push(FileInfo { status, path }); + } + Ok(file_infos) + } + + fn git_log(&self) -> Result> { + let repo = self.get_repository()?; + let mut revwalk = repo.revwalk()?; + revwalk.push_head()?; + let mut changed_logs = Vec::new(); + for commit_id in revwalk { + let commit = &repo.find_commit(commit_id?)?; + let user = commit.author().name().unwrap_or("unknown").to_string(); + let time = commit.time(); + let message = commit.message().unwrap_or("").to_string(); + let tree = commit.tree()?; + let parent = if commit.parent_count() > 0 { + Some(commit.parent(0)?.tree()?) + } else { + None + }; + let mut diff_opts = DiffOptions::new(); + diff_opts.include_untracked(false); + diff_opts.include_ignored(false); + let diff = + repo.diff_tree_to_tree(parent.as_ref(), Some(&tree), Some(&mut diff_opts))?; + let mut paths = Vec::new(); + for delta in diff.deltas() { + let status = Self::delta_status_to_string(delta.status()); + let path = delta + .new_file() + .path() + .and_then(|p| p.to_str()) + .unwrap_or("unknown") + .to_string(); + paths.push(FileInfo { status, path }); + } + changed_logs.push(ChangedLog { + user, + time, + message, + paths, + }); + } + Ok(changed_logs) + } + + async fn create_default_directories(&self) -> Result<()> { + let base_path = PathBuf::from(UPLOAD_DIR).join(self.problem_id.to_string()); + for dir in DEFAULT_DIRECTORIES { + let path = base_path.join(dir); + fs::create_dir_all(path) + .await + .with_context(|| format!("Failed to create directory: {}", dir))?; + } + Ok(()) + } + + fn delta_status_to_string(status: git2::Delta) -> String { + match status { + git2::Delta::Added => "ADDED".to_string(), + git2::Delta::Modified => "MODIFIED".to_string(), + git2::Delta::Deleted => "DELETED".to_string(), + git2::Delta::Renamed => "RENAMED".to_string(), + git2::Delta::Typechange => "TYPECHANGE".to_string(), + _ => "OTHER".to_string(), + } + } + + fn status_to_string(status: git2::Status) -> String { + match status { + // Not yet added to index + git2::Status::WT_NEW => "ADDED".to_string(), + git2::Status::WT_MODIFIED => "MODIFIED".to_string(), + git2::Status::WT_DELETED => "DELETED".to_string(), + git2::Status::WT_RENAMED => "RENAMED".to_string(), + git2::Status::WT_TYPECHANGE => "TYPECHANGE".to_string(), + + // Staged state + git2::Status::INDEX_NEW => "ADDED".to_string(), + git2::Status::INDEX_MODIFIED => "MODIFIED".to_string(), + git2::Status::INDEX_DELETED => "DELETED".to_string(), + git2::Status::INDEX_RENAMED => "RENAMED".to_string(), + git2::Status::INDEX_TYPECHANGE => "TYPECHANGE".to_string(), + + _ => "OTHER".to_string(), + } + } + + fn get_repository(&self) -> Result { + let path = PathBuf::from(UPLOAD_DIR).join(self.problem_id.to_string()); + Repository::open(&path) + .with_context(|| format!("Failed to open git repository at {:?}", path)) + } +} + +#[derive(Debug, PartialEq, Eq)] +struct ChangedLog { + user: String, + time: Time, + message: String, + paths: Vec, +} + +#[derive(Debug, PartialEq, Eq)] +struct FileInfo { + status: String, + path: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + use std::path::Path; + use tokio::fs; + + #[rstest] + #[case(git2::Delta::Added, "ADDED")] + #[case(git2::Delta::Modified, "MODIFIED")] + #[case(git2::Delta::Deleted, "DELETED")] + #[case(git2::Delta::Renamed, "RENAMED")] + #[case(git2::Delta::Typechange, "TYPECHANGE")] + fn can_parse_delta_status_to_string(#[case] status: git2::Delta, #[case] expected: String) { + assert_eq!(GitManager::delta_status_to_string(status), expected); + } + + #[rstest] + #[case(git2::Status::WT_NEW, "ADDED")] + #[case(git2::Status::WT_MODIFIED, "MODIFIED")] + #[case(git2::Status::WT_DELETED, "DELETED")] + #[case(git2::Status::WT_RENAMED, "RENAMED")] + #[case(git2::Status::WT_TYPECHANGE, "TYPECHANGE")] + #[case(git2::Status::INDEX_NEW, "ADDED")] + #[case(git2::Status::INDEX_MODIFIED, "MODIFIED")] + #[case(git2::Status::INDEX_DELETED, "DELETED")] + #[case(git2::Status::INDEX_RENAMED, "RENAMED")] + #[case(git2::Status::INDEX_TYPECHANGE, "TYPECHANGE")] + fn can_parse_status_to_string(#[case] status: git2::Status, #[case] expected: String) { + assert_eq!(GitManager::status_to_string(status), expected); + } + + #[tokio::test] + async fn can_init_git_repository() -> Result<(), std::io::Error> { + let problem_id = 10; + let git_manager = GitManager::new(problem_id); + assert!(git_manager.git_init().is_ok()); + assert!(Path::new(format!("{UPLOAD_DIR}/{problem_id}").as_str()).exists()); + assert!(Path::new(format!("{UPLOAD_DIR}/{problem_id}/.git").as_str()).exists()); + + fs::remove_dir_all(format!("{UPLOAD_DIR}/{problem_id}")).await?; + Ok(()) + } + + #[tokio::test] + async fn can_set_config() -> Result<(), std::io::Error> { + let problem_id = 11; + let git_manager = GitManager::new(problem_id); + assert!(git_manager.git_init().is_ok()); + let repo = git_manager.get_repository().unwrap(); + let config = repo.config().unwrap(); + assert_eq!(config.get_string("user.name"), Ok("admin".to_string())); + assert_eq!( + config.get_string("user.email"), + Ok("admin@coduck.com".to_string()) + ); + + fs::remove_dir_all(format!("{UPLOAD_DIR}/{problem_id}")).await?; + Ok(()) + } + + #[tokio::test] + async fn can_create_default_file() -> Result<(), std::io::Error> { + let problem_id = 12; + let git_manager = GitManager::new(problem_id); + assert!(git_manager.create_default_directories().await.is_ok()); + assert!(Path::new(format!("{UPLOAD_DIR}/{problem_id}/solutions").as_str()).exists()); + assert!(Path::new(format!("{UPLOAD_DIR}/{problem_id}/tests").as_str()).exists()); + assert!(Path::new(format!("{UPLOAD_DIR}/{problem_id}/statements").as_str()).exists()); + + fs::remove_dir_all(format!("{UPLOAD_DIR}/{problem_id}")).await?; + Ok(()) + } + + #[tokio::test] + async fn can_create_problem() -> Result<(), std::io::Error> { + let problem_id = 13; + let git_manager = GitManager::new(problem_id); + assert!(git_manager.create_problem().await.is_ok()); + assert!(Path::new(format!("{UPLOAD_DIR}/{problem_id}").as_str()).exists()); + assert!(Path::new(format!("{UPLOAD_DIR}/{problem_id}/.git").as_str()).exists()); + assert!(Path::new(format!("{UPLOAD_DIR}/{problem_id}/solutions").as_str()).exists()); + assert!(Path::new(format!("{UPLOAD_DIR}/{problem_id}/tests").as_str()).exists()); + assert!(Path::new(format!("{UPLOAD_DIR}/{problem_id}/statements").as_str()).exists()); + + fs::remove_dir_all(format!("{UPLOAD_DIR}/{problem_id}")).await?; + Ok(()) + } + + #[tokio::test] + async fn can_get_git_status() -> Result<(), tokio::io::Error> { + let problem_id = 14; + let git_manager = GitManager::new(problem_id); + + git_manager.git_init().unwrap(); + + fs::create_dir_all(format!("{UPLOAD_DIR}/{problem_id}/tests")).await?; + fs::write(format!("{UPLOAD_DIR}/{problem_id}/tests/1.in"), "1 2").await?; + fs::write(format!("{UPLOAD_DIR}/{problem_id}/tests/1.out"), "3").await?; + + let file_infos = git_manager.git_status().unwrap(); + let expected = vec![ + FileInfo { + status: "ADDED".to_string(), + path: "tests/1.in".to_string(), + }, + FileInfo { + status: "ADDED".to_string(), + path: "tests/1.out".to_string(), + }, + ]; + assert_eq!(file_infos, expected); + + fs::remove_dir_all(format!("{UPLOAD_DIR}/{problem_id}")).await?; + Ok(()) + } + + #[tokio::test] + async fn can_git_add() -> Result<(), tokio::io::Error> { + let problem_id = 15; + let git_manager = GitManager::new(problem_id); + + git_manager.git_init().unwrap(); + + let repo = git_manager.get_repository().unwrap(); + fs::create_dir_all(format!("{UPLOAD_DIR}/{problem_id}/tests")).await?; + fs::write(format!("{UPLOAD_DIR}/{problem_id}/tests/1.in"), "1 2").await?; + fs::write(format!("{UPLOAD_DIR}/{problem_id}/tests/1.out"), "3").await?; + + assert!(git_manager.git_add_all().is_ok()); + + let statuses = repo.statuses(None).unwrap(); + + // 워킹 디렉토리에 존재하지 않아야 한다. + assert!(!statuses.iter().any(|e| e.status().is_wt_new())); + assert!(!statuses.iter().any(|e| e.status().is_wt_modified())); + assert!(!statuses.iter().any(|e| e.status().is_wt_deleted())); + + // 스테이징 영역에 올라와야 한다. + assert!(statuses.iter().all(|e| e.status().is_index_new())); + + fs::remove_dir_all(format!("{UPLOAD_DIR}/{problem_id}")).await?; + Ok(()) + } + + #[tokio::test] + async fn can_commit() -> Result<(), tokio::io::Error> { + let problem_id = 16; + let git_manager = GitManager::new(problem_id); + git_manager.git_init().unwrap(); + + fs::create_dir_all(format!("{UPLOAD_DIR}/{problem_id}/tests")).await?; + fs::write(format!("{UPLOAD_DIR}/{problem_id}/tests/1.in"), "1 2").await?; + fs::write(format!("{UPLOAD_DIR}/{problem_id}/tests/1.out"), "3").await?; + + let commit_message = "add test 1"; + + assert!(git_manager.git_commit(commit_message.to_string()).is_ok()); + + let repo = git_manager.get_repository().unwrap(); + let head = repo.head().unwrap(); + let commit = head.peel_to_commit().unwrap(); + + assert_eq!(commit.message(), Some(commit_message)); + + fs::remove_dir_all(format!("{UPLOAD_DIR}/{problem_id}")).await?; + Ok(()) + } + + #[tokio::test] + async fn can_get_log() -> Result<(), tokio::io::Error> { + let problem_id = 17; + let git_manager = GitManager::new(problem_id); + git_manager.git_init().unwrap(); + git_manager.create_default_directories().await.unwrap(); + + fs::write(format!("{UPLOAD_DIR}/{problem_id}/tests/1.in"), "1 2").await?; + fs::write(format!("{UPLOAD_DIR}/{problem_id}/tests/1.out"), "3").await?; + + git_manager + .git_commit("create default file".to_string()) + .unwrap(); + + let log = git_manager.git_log().unwrap(); + + let expected_path = vec![ + FileInfo { + status: "ADDED".to_string(), + path: "tests/1.in".to_string(), + }, + FileInfo { + status: "ADDED".to_string(), + path: "tests/1.out".to_string(), + }, + ]; + assert_eq!(log[0].paths, expected_path); + + fs::remove_dir_all(format!("{UPLOAD_DIR}/{problem_id}")).await?; + Ok(()) + } +} diff --git a/src/file_manager/mod.rs b/src/file_manager/mod.rs index bd9731a..b4ad472 100644 --- a/src/file_manager/mod.rs +++ b/src/file_manager/mod.rs @@ -1,3 +1,4 @@ +mod git; mod handlers; mod models;