diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..6bdbcc5 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,40 @@ +name: Test + +on: + pull_request: + paths: + - src/*.rs + - Cargo.toml + - jakefile.toml + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Test on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Jake (also sets up Rust) + uses: AstraBert/setup-jake@v0.1.0 + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Run tests + run: jake test diff --git a/Cargo.lock b/Cargo.lock index 2379405..d95c064 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,6 +24,7 @@ dependencies = [ "ignore", "serde", "serde_json", + "serial_test", "sha1-checked", "time", "tokio", @@ -1108,6 +1109,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -2040,6 +2052,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + [[package]] name = "schannel" version = "0.1.29" @@ -2065,6 +2086,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "sec1" version = "0.3.0" @@ -2160,6 +2187,32 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serial_test" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.6" diff --git a/Cargo.toml b/Cargo.toml index eac6f29..2a86790 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,3 +25,6 @@ clap = { version = "4.6.1", features = ["derive"] } ignore = "0.4.25" serde_json = "1.0.149" tokio = { version = "1.52.1", features = ["full"] } + +[dev-dependencies] +serial_test = "3.4.0" diff --git a/README.md b/README.md index e83eb6a..fda01ab 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,14 @@ S3-backed, git-versioned object storage for agents. ## Installation ```bash -cargo install aggit@0.1.0-alpha +cargo install aggit@0.2.0-alpha ``` + +> [!NOTE] +> +> `aggit` is not yet compatible with Windows. + ### As an Agent Skill You can use `aggit` as an agent skill, downloading it with the `skills` CLI tool: diff --git a/jakefile.toml b/jakefile.toml index 408a59b..de7c064 100644 --- a/jakefile.toml +++ b/jakefile.toml @@ -1,5 +1,5 @@ build = "cargo build" -test = "echo 'No task yet for test'" +test = "cargo test" clippy-fix = "cargo clippy --fix --bin aggit --allow-dirty" clippy = "cargo clippy" format = "cargo fmt" diff --git a/src/gitops/mod.rs b/src/gitops/mod.rs index 1bf1bba..ad35003 100644 --- a/src/gitops/mod.rs +++ b/src/gitops/mod.rs @@ -1127,3 +1127,202 @@ pub fn checkout_commit(commit_hash: String) -> anyhow::Result<()> { restore_working_tree(commit_hash)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn setup_test_repo() { + let _ = fs::remove_dir_all(".aggit"); + init(PathBuf::from(".")).unwrap(); + } + + fn cleanup_test_repo() { + let _ = fs::remove_dir_all(".aggit"); + } + + #[test] + #[serial_test::serial] + fn test_init_creates_directories() { + cleanup_test_repo(); + let repo = PathBuf::from(".aggit_test_repo"); + let _ = fs::remove_dir_all(&repo); + init(repo.clone()).unwrap(); + assert!(repo.join(".aggit").exists()); + assert!(repo.join(".aggit/objects").exists()); + assert!(repo.join(".aggit/refs/heads").exists()); + assert!(repo.join(".aggit/refs/index").exists()); + let head = fs::read_to_string(repo.join(".aggit/HEAD")).unwrap(); + assert_eq!(head, "ref: refs/heads/main"); + let _ = fs::remove_dir_all(&repo); + } + + #[test] + #[serial_test::serial] + fn test_hash_object_without_write() { + cleanup_test_repo(); + setup_test_repo(); + let mut data = b"hello world".to_vec(); + let hash = hash_object(&mut data, ObjectType::Blob, false).unwrap(); + assert_eq!(hash.len(), 40); + let path = PathBuf::from(".aggit/objects") + .join(&hash[..2]) + .join(&hash[2..]); + assert!(!path.exists()); + cleanup_test_repo(); + } + + #[test] + #[serial_test::serial] + fn test_hash_object_with_write() { + cleanup_test_repo(); + setup_test_repo(); + let mut data = b"hello world".to_vec(); + let hash = hash_object(&mut data, ObjectType::Blob, true).unwrap(); + let path = PathBuf::from(".aggit/objects") + .join(&hash[..2]) + .join(&hash[2..]); + assert!(path.exists()); + cleanup_test_repo(); + } + + #[test] + #[serial_test::serial] + fn test_find_object_success() { + cleanup_test_repo(); + setup_test_repo(); + let mut data = b"find me".to_vec(); + let hash = hash_object(&mut data, ObjectType::Blob, true).unwrap(); + let found = find_object(&hash).unwrap(); + assert!(found.to_string_lossy().contains(&hash[..2])); + cleanup_test_repo(); + } + + #[test] + #[serial_test::serial] + fn test_find_object_not_found() { + cleanup_test_repo(); + setup_test_repo(); + let result = find_object("0000000000000000000000000000000000000000"); + assert!(result.is_err()); + cleanup_test_repo(); + } + + #[test] + fn test_read_object_roundtrip() { + let data = b"roundtrip data".to_vec(); + let hash = hash_object(&mut data.clone(), ObjectType::Blob, true).unwrap(); + let (obj_type, read_data) = read_object(&hash).unwrap(); + assert_eq!(obj_type, ObjectType::Blob); + assert_eq!(read_data, b"roundtrip data"); + } + + #[test] + #[serial_test::serial] + fn test_get_current_branch() { + cleanup_test_repo(); + setup_test_repo(); + let branch = get_current_branch().unwrap(); + assert_eq!(branch, "main"); + cleanup_test_repo(); + } + + #[test] + fn test_index_path_for_branch() { + let path = index_path_for_branch("main"); + assert_eq!(path, PathBuf::from(".aggit/refs/index/main")); + } + + #[test] + fn test_head_path_for_branch() { + let path = head_path_for_branch("main"); + assert_eq!(path, PathBuf::from(".aggit/refs/heads/main")); + } + + #[test] + fn test_object_type_from_str() { + assert_eq!(ObjectType::from_str("blob").unwrap(), ObjectType::Blob); + assert_eq!(ObjectType::from_str("commit").unwrap(), ObjectType::Commit); + assert_eq!(ObjectType::from_str("tree").unwrap(), ObjectType::Tree); + assert!(ObjectType::from_str("unknown").is_err()); + } + + #[test] + fn test_cat_file_mode_from_str() { + assert!(CatFileMode::from_str("blob").is_ok()); + assert!(CatFileMode::from_str("size").is_ok()); + assert!(CatFileMode::from_str("type").is_ok()); + assert!(CatFileMode::from_str("pretty").is_ok()); + assert!(CatFileMode::from_str("invalid").is_err()); + } + + #[test] + #[serial_test::serial] + fn test_write_and_read_index() { + cleanup_test_repo(); + setup_test_repo(); + let entries = vec![IndexEntry { + ctime_s: 0, + ctime_n: 0, + mtime_s: 0, + mtime_n: 0, + dev: 0, + ino: 0, + mode: 0o100644, + uid: 0, + gid: 0, + size: 5, + sha1: [0u8; 20], + flags: 4, + path: "test.txt".to_string(), + }]; + write_index(&entries).unwrap(); + let read = read_index().unwrap(); + assert_eq!(read.len(), 1); + assert_eq!(read[0].path, "test.txt"); + cleanup_test_repo(); + } + + #[test] + fn test_read_tree_with_data() { + let mut tree_data = Vec::new(); + tree_data.extend_from_slice(b"100644 file.txt\x00"); + tree_data.extend_from_slice(&[0u8; 20]); + let entries = read_tree(None, Some(tree_data)).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].1, "file.txt"); + } + + #[test] + #[serial_test::serial] + fn test_get_local_current_hash_no_commits() { + cleanup_test_repo(); + setup_test_repo(); + let (hash, branch) = get_local_current_hash().unwrap(); + assert!(hash.is_none()); + assert_eq!(branch, "main"); + cleanup_test_repo(); + } + + #[test] + #[serial_test::serial] + fn test_list_branches_no_error() { + cleanup_test_repo(); + setup_test_repo(); + list_branches().unwrap(); + cleanup_test_repo(); + } + + #[test] + #[serial_test::serial] + fn test_collect_reachable_objects_blob_only() { + cleanup_test_repo(); + setup_test_repo(); + let mut data = b"blob content".to_vec(); + let hash = hash_object(&mut data, ObjectType::Blob, true).unwrap(); + let objects = collect_reachable_objects(&hash, None).unwrap(); + assert!(objects.contains(&hash)); + cleanup_test_repo(); + } +} diff --git a/src/repository/mod.rs b/src/repository/mod.rs index 07a1972..d2d2767 100644 --- a/src/repository/mod.rs +++ b/src/repository/mod.rs @@ -40,3 +40,99 @@ pub fn get_repository() -> anyhow::Result { Ok(validated) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn setup_aggit_dir() { + fs::create_dir_all(".aggit").unwrap(); + } + + fn cleanup_aggit_dir() { + let _ = fs::remove_dir_all(".aggit"); + } + + #[test] + #[serial_test::serial] + fn test_config_repository_success() { + cleanup_aggit_dir(); + setup_aggit_dir(); + + let result = config_repository( + "test-repo".to_string(), + "A test repository".to_string(), + vec!["test".to_string(), "rust".to_string()], + ); + assert!(result.is_ok()); + assert!(PathBuf::from(".aggit/repo.toml").exists()); + + let content = fs::read_to_string(".aggit/repo.toml").unwrap(); + assert!(content.contains("name = \"test-repo\"")); + assert!(content.contains("description = \"A test repository\"")); + + cleanup_aggit_dir(); + } + + #[test] + #[serial_test::serial] + fn test_config_repository_already_configured() { + cleanup_aggit_dir(); + setup_aggit_dir(); + + config_repository( + "test-repo".to_string(), + "A test repository".to_string(), + vec!["test".to_string()], + ) + .unwrap(); + + let result = config_repository( + "test-repo-2".to_string(), + "Another test repository".to_string(), + vec!["test".to_string()], + ); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("already configured")); + + cleanup_aggit_dir(); + } + + #[test] + #[serial_test::serial] + fn test_get_repository_success() { + cleanup_aggit_dir(); + setup_aggit_dir(); + + config_repository( + "my-repo".to_string(), + "My description".to_string(), + vec!["a".to_string(), "b".to_string()], + ) + .unwrap(); + + let repo = get_repository().unwrap(); + assert_eq!(repo.name, "my-repo"); + assert_eq!(repo.description, "My description"); + assert_eq!(repo.topics, vec!["a", "b"]); + + cleanup_aggit_dir(); + } + + #[test] + #[serial_test::serial] + fn test_get_repository_not_configured() { + cleanup_aggit_dir(); + setup_aggit_dir(); + + let result = get_repository(); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("yet to be configured")); + + cleanup_aggit_dir(); + } +} + diff --git a/src/s3ops/mod.rs b/src/s3ops/mod.rs index 26f276f..adf1b0d 100644 --- a/src/s3ops/mod.rs +++ b/src/s3ops/mod.rs @@ -525,3 +525,227 @@ pub async fn clone( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn cleanup_origin_file() { + let _ = fs::remove_file(ORIGIN_FILE); + } + + #[test] + fn test_origin_action_from_str() { + assert!(matches!( + OriginAction::from_str("add").unwrap(), + OriginAction::Add + )); + assert!(matches!( + OriginAction::from_str("create").unwrap(), + OriginAction::Create + )); + assert!(matches!( + OriginAction::from_str("update").unwrap(), + OriginAction::Update + )); + assert!(OriginAction::from_str("delete").is_err()); + } + + #[test] + #[serial_test::serial] + fn test_create_origin_success() { + cleanup_origin_file(); + let result = manage_origin( + "create", + "origin1", + Some("http://localhost:9000".to_string()), + Some("secret".to_string()), + Some("keyid".to_string()), + Some("us-east-1".to_string()), + ); + assert!(result.is_ok()); + assert!(PathBuf::from(ORIGIN_FILE).exists()); + let content = fs::read_to_string(ORIGIN_FILE).unwrap(); + assert!(content.contains("origin1")); + assert!(content.contains("http://localhost:9000")); + cleanup_origin_file(); + } + + #[test] + #[serial_test::serial] + fn test_create_origin_already_exists() { + cleanup_origin_file(); + manage_origin( + "create", + "origin1", + Some("http://localhost:9000".to_string()), + Some("secret".to_string()), + Some("keyid".to_string()), + Some("us-east-1".to_string()), + ) + .unwrap(); + + let result = manage_origin( + "create", + "origin2", + Some("http://localhost:9001".to_string()), + Some("secret2".to_string()), + Some("keyid2".to_string()), + Some("us-east-1".to_string()), + ); + assert!(result.is_err()); + cleanup_origin_file(); + } + + #[test] + #[serial_test::serial] + fn test_add_origin_success() { + cleanup_origin_file(); + manage_origin( + "create", + "origin1", + Some("http://localhost:9000".to_string()), + Some("secret".to_string()), + Some("keyid".to_string()), + Some("us-east-1".to_string()), + ) + .unwrap(); + + let result = manage_origin( + "add", + "origin2", + Some("http://localhost:9001".to_string()), + Some("secret2".to_string()), + Some("keyid2".to_string()), + Some("us-east-1".to_string()), + ); + assert!(result.is_ok()); + let content = fs::read_to_string(ORIGIN_FILE).unwrap(); + assert!(content.contains("origin1")); + assert!(content.contains("origin2")); + cleanup_origin_file(); + } + + #[test] + #[serial_test::serial] + fn test_add_origin_duplicate_name() { + cleanup_origin_file(); + manage_origin( + "create", + "origin1", + Some("http://localhost:9000".to_string()), + Some("secret".to_string()), + Some("keyid".to_string()), + Some("us-east-1".to_string()), + ) + .unwrap(); + + let result = manage_origin( + "add", + "origin1", + Some("http://localhost:9001".to_string()), + Some("secret2".to_string()), + Some("keyid2".to_string()), + Some("us-east-1".to_string()), + ); + assert!(result.is_err()); + cleanup_origin_file(); + } + + #[test] + #[serial_test::serial] + fn test_update_origin_success() { + cleanup_origin_file(); + manage_origin( + "create", + "origin1", + Some("http://localhost:9000".to_string()), + Some("secret".to_string()), + Some("keyid".to_string()), + Some("us-east-1".to_string()), + ) + .unwrap(); + + let result = manage_origin( + "update", + "origin1", + Some("http://localhost:9002".to_string()), + None, + None, + None, + ); + assert!(result.is_ok()); + let content = fs::read_to_string(ORIGIN_FILE).unwrap(); + assert!(content.contains("http://localhost:9002")); + cleanup_origin_file(); + } + + #[test] + #[serial_test::serial] + fn test_update_origin_not_found() { + cleanup_origin_file(); + manage_origin( + "create", + "origin1", + Some("http://localhost:9000".to_string()), + Some("secret".to_string()), + Some("keyid".to_string()), + Some("us-east-1".to_string()), + ) + .unwrap(); + + let result = manage_origin( + "update", + "nonexistent", + Some("http://localhost:9002".to_string()), + None, + None, + None, + ); + assert!(result.is_err()); + cleanup_origin_file(); + } + + #[test] + #[serial_test::serial] + fn test_manage_origin_missing_fields_for_create() { + cleanup_origin_file(); + let result = manage_origin( + "create", + "origin1", + Some("http://localhost:9000".to_string()), + None, + None, + None, + ); + assert!(result.is_err()); + cleanup_origin_file(); + } + + #[test] + #[serial_test::serial] + fn test_add_origin_to_gitignore() { + cleanup_origin_file(); + add_origin_to_gitignore().unwrap(); + let gitignore = fs::read_to_string(GITIGNORE).unwrap(); + let aggitignore = fs::read_to_string(AGGITIGNORE).unwrap(); + assert!(gitignore.contains(ORIGIN_FILE)); + assert!(aggitignore.contains(ORIGIN_FILE)); + cleanup_origin_file(); + } + + #[test] + fn test_trim_path_valid() { + let path = Path::new("some/path/.aggit/objects/ab/cd"); + let result = trim_path(path).unwrap(); + assert_eq!(result, "objects/ab/cd"); + } + + #[test] + fn test_trim_path_invalid() { + let path = Path::new("some/path/without/aggit"); + let result = trim_path(path); + assert!(result.is_err()); + } +}