diff --git a/.gitignore b/.gitignore index f35903d..5539271 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/src/knowledge_engine/index.rs b/src/knowledge_engine/index.rs index 21183b9..b20964f 100644 --- a/src/knowledge_engine/index.rs +++ b/src/knowledge_engine/index.rs @@ -473,3 +473,125 @@ fn save_repo_index_state( )?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::RepoEntry; + use crate::registry::test_helpers::WorkspaceRegistry; + use std::path::Path; + + fn init_git_repo(path: &Path) -> git2::Repository { + let repo = git2::Repository::init(path).unwrap(); + let mut config = repo.config().unwrap(); + config.set_str("user.name", "Test").unwrap(); + config.set_str("user.email", "test@example.com").unwrap(); + let sig = repo.signature().unwrap(); + let tree_id = { + let mut index = repo.index().unwrap(); + index.write_tree().unwrap() + }; + let tree = repo.find_tree(tree_id).unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[]).unwrap(); + drop(tree); + repo + } + + #[test] + fn test_prepare_repos_empty_path_returns_all() -> anyhow::Result<()> { + let mut conn = WorkspaceRegistry::init_in_memory()?; + let _ = WorkspaceRegistry::seed_test_repo(&mut conn, "repo1")?; + let _ = WorkspaceRegistry::seed_test_repo(&mut conn, "repo2")?; + + let repos = prepare_repos(&mut conn, "")?; + assert_eq!(repos.len(), 2); + assert!(repos.iter().any(|r| r.id == "repo1")); + assert!(repos.iter().any(|r| r.id == "repo2")); + Ok(()) + } + + #[test] + fn test_prepare_repos_matching_path_returns_one() -> anyhow::Result<()> { + let mut conn = WorkspaceRegistry::init_in_memory()?; + let tmp = tempfile::tempdir()?; + let path = tmp.path().join("myrepo"); + std::fs::create_dir(&path)?; + + let repo = RepoEntry { + id: "myrepo".to_string(), + local_path: path.clone(), + tags: vec![], + language: Some("rust".to_string()), + discovered_at: chrono::Utc::now(), + workspace_type: "git".to_string(), + data_tier: "private".to_string(), + last_synced_at: None, + stars: None, + remotes: vec![], + }; + crate::registry::repo::save_repo(&mut conn, &repo)?; + + let repos = prepare_repos(&mut conn, path.to_str().unwrap())?; + assert_eq!(repos.len(), 1); + assert_eq!(repos[0].id, "myrepo"); + Ok(()) + } + + #[test] + fn test_prepare_repos_nonexistent_path_errors() -> anyhow::Result<()> { + let mut conn = WorkspaceRegistry::init_in_memory()?; + let result = prepare_repos(&mut conn, "/nonexistent/path/12345"); + assert!(result.is_err()); + Ok(()) + } + + #[test] + fn test_prepare_repos_unregistered_existing_path_auto_registers() -> anyhow::Result<()> { + let mut conn = WorkspaceRegistry::init_in_memory()?; + let tmp = tempfile::tempdir()?; + let path = tmp.path().join("unregistered"); + std::fs::create_dir(&path)?; + let _ = init_git_repo(&path); + + let repos = prepare_repos(&mut conn, path.to_str().unwrap())?; + assert_eq!(repos.len(), 1); + // Use file_name comparison to avoid Windows short-name (8.3) path mismatches + assert_eq!(repos[0].local_path.file_name(), path.file_name()); + assert!(repos[0].local_path.exists()); + // Verify it was saved to registry + let all = crate::registry::repo::list_repos(&conn)?; + assert_eq!(all.len(), 1); + Ok(()) + } + + #[test] + fn test_save_and_get_repo_index_state() -> anyhow::Result<()> { + let mut conn = WorkspaceRegistry::init_in_memory()?; + let tmp = tempfile::tempdir()?; + let path = tmp.path().join("gitrepo"); + std::fs::create_dir(&path)?; + let repo = git2::Repository::init(&path)?; + let mut config = repo.config().unwrap(); + config.set_str("user.name", "Test").unwrap(); + config.set_str("user.email", "test@example.com").unwrap(); + let sig = repo.signature().unwrap(); + let tree_id = { + let mut index = repo.index().unwrap(); + index.write_tree().unwrap() + }; + let tree = repo.find_tree(tree_id).unwrap(); + let oid = repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])?; + + save_repo_index_state(&mut conn, "test-repo", &oid.to_string())?; + + let hash: Option = conn + .query_row( + "SELECT last_commit_hash FROM repo_index_state WHERE repo_id = ?1", + ["test-repo"], + |row| row.get(0), + ) + .unwrap_or(None); + assert_eq!(hash, Some(oid.to_string())); + Ok(()) + } +} diff --git a/src/knowledge_engine/index_state.rs b/src/knowledge_engine/index_state.rs index c17686e..4c1edea 100644 --- a/src/knowledge_engine/index_state.rs +++ b/src/knowledge_engine/index_state.rs @@ -140,4 +140,200 @@ mod tests { assert!(json.contains("\"state\":\"unknown\"")); assert!(json.contains("\"reason\":\"x\"")); } + + #[test] + fn test_index_state_is_fresh_and_changed_count() { + assert!(IndexState::Fresh.is_fresh()); + assert!(!IndexState::Missing.is_fresh()); + assert!(!IndexState::Unknown { reason: "err".into() }.is_fresh()); + + let stale = IndexState::Stale { + added: vec!["a.rs".into()], + modified: vec!["b.rs".into(), "c.rs".into()], + deleted: vec![], + }; + assert_eq!(stale.changed_files_count(), 3); + assert_eq!(IndexState::Fresh.changed_files_count(), 0); + assert_eq!(IndexState::Missing.changed_files_count(), 0); + } + + #[test] + fn test_get_repo_index_state_missing() -> anyhow::Result<()> { + use crate::registry::test_helpers::WorkspaceRegistry; + let conn = WorkspaceRegistry::init_in_memory()?; + let tmp = tempfile::tempdir()?; + let path = tmp.path().join("repo"); + std::fs::create_dir(&path)?; + let repo = git2::Repository::init(&path)?; + let mut config = repo.config().unwrap(); + config.set_str("user.name", "Test").unwrap(); + config.set_str("user.email", "test@example.com").unwrap(); + let sig = repo.signature().unwrap(); + let tree_id = { + let mut index = repo.index().unwrap(); + index.write_tree().unwrap() + }; + let tree = repo.find_tree(tree_id).unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])?; + + let entry = RepoEntry { + id: "missing-repo".to_string(), + local_path: path, + tags: vec![], + language: Some("rust".to_string()), + discovered_at: chrono::Utc::now(), + workspace_type: "git".to_string(), + data_tier: "private".to_string(), + last_synced_at: None, + stars: None, + remotes: vec![], + }; + + let state = get_repo_index_state(&conn, &entry); + assert!(matches!(state, IndexState::Missing)); + Ok(()) + } + + #[test] + fn test_get_repo_index_state_fresh() -> anyhow::Result<()> { + use crate::registry::test_helpers::WorkspaceRegistry; + let conn = WorkspaceRegistry::init_in_memory()?; + let tmp = tempfile::tempdir()?; + let path = tmp.path().join("repo"); + std::fs::create_dir(&path)?; + let repo = git2::Repository::init(&path)?; + let mut config = repo.config().unwrap(); + config.set_str("user.name", "Test").unwrap(); + config.set_str("user.email", "test@example.com").unwrap(); + let sig = repo.signature().unwrap(); + let tree_id = { + let mut index = repo.index().unwrap(); + index.write_tree().unwrap() + }; + let tree = repo.find_tree(tree_id).unwrap(); + let oid = repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])?; + + conn.execute( + "INSERT INTO repo_index_state (repo_id, last_commit_hash, indexed_at) VALUES (?1, ?2, datetime('now'))", + ["fresh-repo", &oid.to_string()], + )?; + + let entry = RepoEntry { + id: "fresh-repo".to_string(), + local_path: path, + tags: vec![], + language: Some("rust".to_string()), + discovered_at: chrono::Utc::now(), + workspace_type: "git".to_string(), + data_tier: "private".to_string(), + last_synced_at: None, + stars: None, + remotes: vec![], + }; + + let state = get_repo_index_state(&conn, &entry); + assert!(matches!(state, IndexState::Fresh)); + Ok(()) + } + + #[test] + fn test_get_repo_index_state_stale() -> anyhow::Result<()> { + use crate::registry::test_helpers::WorkspaceRegistry; + let conn = WorkspaceRegistry::init_in_memory()?; + let tmp = tempfile::tempdir()?; + let path = tmp.path().join("repo"); + std::fs::create_dir(&path)?; + let repo = git2::Repository::init(&path)?; + let mut config = repo.config().unwrap(); + config.set_str("user.name", "Test").unwrap(); + config.set_str("user.email", "test@example.com").unwrap(); + let sig = repo.signature().unwrap(); + + // First commit + let tree_id = { + let mut index = repo.index().unwrap(); + index.write_tree().unwrap() + }; + let tree = repo.find_tree(tree_id).unwrap(); + let old_oid = repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])?; + drop(tree); + + // Save the first commit hash as last indexed + conn.execute( + "INSERT INTO repo_index_state (repo_id, last_commit_hash, indexed_at) VALUES (?1, ?2, datetime('now'))", + ["stale-repo", &old_oid.to_string()], + )?; + + // Second commit so HEAD moves forward and diff_since detects changes + let tree_id2 = { + let mut index = repo.index().unwrap(); + // Add a dummy file to create a new tree + let blob_oid = repo.blob(b"hello")?; + index.add_frombuffer( + &git2::IndexEntry { + ctime: git2::IndexTime::new(0, 0), + mtime: git2::IndexTime::new(0, 0), + dev: 0, + ino: 0, + mode: 0o100644, + uid: 0, + gid: 0, + file_size: 5, + id: blob_oid, + flags: 0, + flags_extended: 0, + path: b"file.txt".to_vec(), + }, + b"hello", + )?; + index.write_tree().unwrap() + }; + let parent = repo.find_commit(old_oid)?; + let tree2 = repo.find_tree(tree_id2).unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "second", &tree2, &[&parent])?; + drop(tree2); + + let entry = RepoEntry { + id: "stale-repo".to_string(), + local_path: path, + tags: vec![], + language: Some("rust".to_string()), + discovered_at: chrono::Utc::now(), + workspace_type: "git".to_string(), + data_tier: "private".to_string(), + last_synced_at: None, + stars: None, + remotes: vec![], + }; + + let state = get_repo_index_state(&conn, &entry); + assert!(matches!(state, IndexState::Stale { .. }), "expected Stale, got {:?}", state); + Ok(()) + } + + #[test] + fn test_get_repo_index_state_unknown_not_git() -> anyhow::Result<()> { + use crate::registry::test_helpers::WorkspaceRegistry; + let conn = WorkspaceRegistry::init_in_memory()?; + let tmp = tempfile::tempdir()?; + let path = tmp.path().join("not-git"); + std::fs::create_dir(&path)?; + + let entry = RepoEntry { + id: "unknown-repo".to_string(), + local_path: path, + tags: vec![], + language: Some("rust".to_string()), + discovered_at: chrono::Utc::now(), + workspace_type: "git".to_string(), + data_tier: "private".to_string(), + last_synced_at: None, + stars: None, + remotes: vec![], + }; + + let state = get_repo_index_state(&conn, &entry); + assert!(matches!(state, IndexState::Unknown { .. }), "expected Unknown, got {:?}", state); + Ok(()) + } } diff --git a/src/registry.rs b/src/registry.rs index d4967cb..971e610 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -517,7 +517,7 @@ impl crate::clients::RegistryClient for crate::storage::AppContext { } #[cfg(test)] -mod test_helpers; +pub mod test_helpers; #[cfg(test)] mod tests; diff --git a/src/registry/test_helpers.rs b/src/registry/test_helpers.rs index bddce67..ec57e55 100644 --- a/src/registry/test_helpers.rs +++ b/src/registry/test_helpers.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 juice094 +pub use super::WorkspaceRegistry; use super::*; #[cfg(test)] @@ -355,6 +356,12 @@ CREATE TABLE IF NOT EXISTS orphan_tantivy_docs ( repo_id TEXT PRIMARY KEY, detected_at DATETIME DEFAULT current_timestamp ); + +CREATE TABLE IF NOT EXISTS repo_index_state ( + repo_id TEXT PRIMARY KEY, + last_commit_hash TEXT, + indexed_at DATETIME DEFAULT current_timestamp +); "#; #[cfg(test)]