diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0213894..4a48457 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,12 +9,77 @@ on: jobs: test: runs-on: ubuntu-latest + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_DB: test_db + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d test_db" + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: components: clippy, rustfmt - uses: Swatinem/rust-cache@v2 - - run: cargo fmt --check + - name: Initialize test database + env: + PGPASSWORD: postgres + run: | + psql -h 127.0.0.1 -U postgres -d test_db <<'SQL' + CREATE TABLE workspace_members ( + team_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role TEXT NOT NULL, + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE TABLE knowledge_base ( + id TEXT PRIMARY KEY DEFAULT md5(random()::text || clock_timestamp()::text), + team_id TEXT NOT NULL, + file_path TEXT NOT NULL, + content_hash TEXT NOT NULL, + embedding REAL[], + metadata JSONB, + user_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE TABLE pending_knowledge ( + id TEXT PRIMARY KEY DEFAULT md5(random()::text || clock_timestamp()::text), + file_path TEXT NOT NULL, + content_hash TEXT NOT NULL, + submitted_by TEXT NOT NULL, + team_id TEXT NOT NULL, + status TEXT NOT NULL, + reviewed_by TEXT, + reviewed_at TIMESTAMPTZ, + rejection_reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + SQL + - run: cargo fmt --package code-memory -- --check - run: cargo clippy -- -D warnings - run: cargo test + env: + TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/test_db + + ultracite: + name: Ultracite + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 22 + - name: Install Node.js dependencies + working-directory: npm + run: npm install --ignore-scripts + - name: Ultracite check + working-directory: npm + run: npm run check diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..28d2de8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,123 @@ +# Ultracite Code Standards + +This project uses **Ultracite**, a zero-config preset that enforces strict code quality standards through automated formatting and linting. + +## Quick Reference + +- **Format code**: `npm exec -- ultracite fix` +- **Check for issues**: `npm exec -- ultracite check` +- **Diagnose setup**: `npm exec -- ultracite doctor` + +Biome (the underlying engine) provides robust linting and formatting. Most issues are automatically fixable. + +--- + +## Core Principles + +Write code that is **accessible, performant, type-safe, and maintainable**. Focus on clarity and explicit intent over brevity. + +### Type Safety & Explicitness + +- Use explicit types for function parameters and return values when they enhance clarity +- Prefer `unknown` over `any` when the type is genuinely unknown +- Use const assertions (`as const`) for immutable values and literal types +- Leverage TypeScript's type narrowing instead of type assertions +- Use meaningful variable names instead of magic numbers - extract constants with descriptive names + +### Modern JavaScript/TypeScript + +- Use arrow functions for callbacks and short functions +- Prefer `for...of` loops over `.forEach()` and indexed `for` loops +- Use optional chaining (`?.`) and nullish coalescing (`??`) for safer property access +- Prefer template literals over string concatenation +- Use destructuring for object and array assignments +- Use `const` by default, `let` only when reassignment is needed, never `var` + +### Async & Promises + +- Always `await` promises in async functions - don't forget to use the return value +- Use `async/await` syntax instead of promise chains for better readability +- Handle errors appropriately in async code with try-catch blocks +- Don't use async functions as Promise executors + +### React & JSX + +- Use function components over class components +- Call hooks at the top level only, never conditionally +- Specify all dependencies in hook dependency arrays correctly +- Use the `key` prop for elements in iterables (prefer unique IDs over array indices) +- Nest children between opening and closing tags instead of passing as props +- Don't define components inside other components +- Use semantic HTML and ARIA attributes for accessibility: + - Provide meaningful alt text for images + - Use proper heading hierarchy + - Add labels for form inputs + - Include keyboard event handlers alongside mouse events + - Use semantic elements (` + +

Pro-only feature. Upgrade to Pro

+ +"# + .to_string() +} + +pub fn search_results(query: &str) -> String { + format!( + r#" + + + Search: {query} + + + +

Search Results: {query}

+
+

No results yet (search implementation pending)

+
+

Back to search

+ +"#, + query = query + ) +} diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs new file mode 100644 index 0000000..8c1a1f1 --- /dev/null +++ b/src/workspace/mod.rs @@ -0,0 +1,3 @@ +pub mod team_workspace; + +pub use team_workspace::{TeamWorkspace, WorkspaceConfig, WorkspaceMember}; diff --git a/src/workspace/team_workspace.rs b/src/workspace/team_workspace.rs new file mode 100644 index 0000000..4f8a93d --- /dev/null +++ b/src/workspace/team_workspace.rs @@ -0,0 +1,145 @@ +use serde::{Deserialize, Serialize}; +use tokio_postgres::{Client, Error, NoTls}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceConfig { + pub team_id: String, + pub name: String, + pub database_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceMember { + pub user_id: String, + pub role: String, + pub joined_at: chrono::DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KnowledgeEntry { + pub id: String, + pub file_path: String, + pub content_hash: String, + pub added_by: Option, + pub created_at: chrono::DateTime, +} + +pub struct TeamWorkspace { + config: WorkspaceConfig, + client: Client, +} + +impl TeamWorkspace { + pub async fn new(config: WorkspaceConfig) -> Result { + let (client, connection) = tokio_postgres::connect(&config.database_url, NoTls).await?; + + // Spawn connection handler + tokio::spawn(async move { + if let Err(e) = connection.await { + eprintln!("connection error: {}", e); + } + }); + + Ok(Self { config, client }) + } + + pub fn team_id(&self) -> &str { + &self.config.team_id + } + + pub async fn add_member(&self, user_id: &str, role: &str) -> Result<(), Error> { + self.client + .execute( + "INSERT INTO workspace_members (team_id, user_id, role) VALUES ($1, $2, $3)", + &[&self.config.team_id, &user_id, &role], + ) + .await?; + + Ok(()) + } + + pub async fn remove_member(&self, user_id: &str) -> Result<(), Error> { + self.client + .execute( + "DELETE FROM workspace_members WHERE team_id = $1 AND user_id = $2", + &[&self.config.team_id, &user_id], + ) + .await?; + + Ok(()) + } + + pub async fn list_members(&self) -> Result, Error> { + let rows = self + .client + .query( + "SELECT user_id, role, joined_at FROM workspace_members WHERE team_id = $1", + &[&self.config.team_id], + ) + .await?; + + let members = rows + .iter() + .map(|row| WorkspaceMember { + user_id: row.get(0), + role: row.get(1), + joined_at: row.get(2), + }) + .collect(); + + Ok(members) + } + + pub async fn add_knowledge( + &self, + file_path: &str, + content_hash: &str, + embedding: Option>, + metadata: Option, + ) -> Result { + let row = self + .client + .query_one( + "INSERT INTO knowledge_base (team_id, file_path, content_hash, embedding, metadata) + VALUES ($1, $2, $3, $4, $5) + RETURNING id", + &[ + &self.config.team_id, + &file_path, + &content_hash, + &embedding, + &metadata, + ], + ) + .await?; + + let id: String = row.get(0); + Ok(id) + } + + pub async fn search_knowledge(&self, query: &str) -> Result, Error> { + let rows = self + .client + .query( + "SELECT id, file_path, content_hash, user_id, created_at + FROM knowledge_base + WHERE team_id = $1 AND file_path ILIKE $2 + LIMIT 50", + &[&self.config.team_id, &format!("%{}%", query)], + ) + .await?; + + let entries = rows + .iter() + .map(|row| KnowledgeEntry { + id: row.get(0), + file_path: row.get(1), + content_hash: row.get(2), + added_by: row.get(3), + created_at: row.get(4), + }) + .collect(); + + Ok(entries) + } +} diff --git a/tests/approval_test.rs b/tests/approval_test.rs new file mode 100644 index 0000000..8de5e14 --- /dev/null +++ b/tests/approval_test.rs @@ -0,0 +1,63 @@ +use code_memory::approval::workflow::{ApprovalStatus, ApprovalWorkflow, PendingKnowledge}; + +fn test_database_url() -> String { + std::env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgresql://localhost/test_db".to_string()) +} + +#[tokio::test] +async fn test_submit_for_approval() { + let database_url = test_database_url(); + let workflow = ApprovalWorkflow::new(&database_url).await.unwrap(); + + let pending = PendingKnowledge { + file_path: "src/auth.rs".to_string(), + content_hash: "xyz789".to_string(), + submitted_by: "user_123".to_string(), + team_id: "team_456".to_string(), + }; + + let id = workflow.submit(pending).await.unwrap(); + assert!(!id.is_empty()); +} + +#[tokio::test] +async fn test_approve_knowledge() { + let database_url = test_database_url(); + let workflow = ApprovalWorkflow::new(&database_url).await.unwrap(); + + let pending = PendingKnowledge { + file_path: "src/auth.rs".to_string(), + content_hash: "xyz789".to_string(), + submitted_by: "user_123".to_string(), + team_id: "team_456".to_string(), + }; + + let id = workflow.submit(pending).await.unwrap(); + workflow.approve(&id, "reviewer_789").await.unwrap(); + + let status = workflow.get_status(&id).await.unwrap(); + assert_eq!(status, ApprovalStatus::Approved); +} + +#[tokio::test] +async fn test_reject_knowledge() { + let database_url = test_database_url(); + let workflow = ApprovalWorkflow::new(&database_url).await.unwrap(); + + let pending = PendingKnowledge { + file_path: "secrets.env".to_string(), + content_hash: "bad123".to_string(), + submitted_by: "user_123".to_string(), + team_id: "team_456".to_string(), + }; + + let id = workflow.submit(pending).await.unwrap(); + workflow + .reject(&id, "reviewer_789", "Contains secrets") + .await + .unwrap(); + + let status = workflow.get_status(&id).await.unwrap(); + assert_eq!(status, ApprovalStatus::Rejected); +} diff --git a/tests/decision_extraction_test.rs b/tests/decision_extraction_test.rs new file mode 100644 index 0000000..34a34df --- /dev/null +++ b/tests/decision_extraction_test.rs @@ -0,0 +1,74 @@ +use code_memory::git::decision_parser::DecisionParser; + +#[test] +fn test_extract_decision_from_commit() { + let parser = DecisionParser::new(); + + let commit_message = r#"refactor: decided to migrate from REST to GraphQL + +We chose GraphQL for the following reasons: +1. Better type safety +2. Reduced over-fetching +3. Single endpoint + +Files affected: +- src/api/graphql/schema.ts +- src/api/rest/legacy.ts (deprecated) +"#; + + let decisions = parser.parse_message(commit_message); + + assert_eq!(decisions.len(), 1); + + let decision = &decisions[0]; + assert_eq!(decision.decision_type, "migration"); + assert!(decision.reasoning.contains("type safety")); + assert!(decision.from.is_some()); + assert_eq!(decision.from.as_ref().unwrap(), "REST"); + assert_eq!(decision.to, "GraphQL"); +} + +#[test] +fn test_ignore_non_decision_commits() { + let parser = DecisionParser::new(); + + let commit_message = "fix: typo in README"; + + let decisions = parser.parse_message(commit_message); + + assert!(decisions.is_empty()); +} + +#[test] +fn test_extract_architectural_decision() { + let parser = DecisionParser::new(); + + let commit_message = r#"arch: switching to microservices architecture + +Decided to split monolith into services for better scalability. +"#; + + let decisions = parser.parse_message(commit_message); + + assert_eq!(decisions.len(), 1); + assert_eq!(decisions[0].decision_type, "architecture"); +} + +#[test] +fn test_extract_reasoning_text() { + let parser = DecisionParser::new(); + + let commit_message = r#"refactor: chose TypeScript + +TypeScript provides: +- Static type checking +- Better IDE support +- Improved refactoring +"#; + + let decisions = parser.parse_message(commit_message); + + assert_eq!(decisions.len(), 1); + assert!(decisions[0].reasoning.contains("Static type checking")); + assert!(decisions[0].reasoning.contains("Better IDE support")); +} diff --git a/tests/drift_detection_test.rs b/tests/drift_detection_test.rs new file mode 100644 index 0000000..78d0be2 --- /dev/null +++ b/tests/drift_detection_test.rs @@ -0,0 +1,60 @@ +use code_memory::drift::detector::DriftDetector; +use code_memory::git::decision_parser::Decision; +use std::path::PathBuf; + +#[test] +fn test_detect_architectural_drift() { + let mut detector = DriftDetector::new(); + + // Historical decision: Use REST API + let decision = Decision { + decision_type: "architecture".to_string(), + from: None, + to: "REST".to_string(), + reasoning: "Simple, well-understood".to_string(), + commit_sha: Some("abc123".to_string()), + author: Some("alice".to_string()), + timestamp: Some(1000000), + }; + + detector.add_decision(decision); + + // Current codebase: GraphQL file exists + let current_files = vec![ + PathBuf::from("src/api/graphql/schema.ts"), + PathBuf::from("src/api/rest/endpoints.ts"), + ]; + + detector.scan_files(¤t_files); + + let alerts = detector.get_alerts(); + + assert!(!alerts.is_empty()); + assert!(alerts[0].message.contains("REST")); + assert!(alerts[0].message.contains("GraphQL")); +} + +#[test] +fn test_no_drift_when_consistent() { + let mut detector = DriftDetector::new(); + + let decision = Decision { + decision_type: "architecture".to_string(), + from: None, + to: "REST".to_string(), + reasoning: "".to_string(), + commit_sha: None, + author: None, + timestamp: None, + }; + + detector.add_decision(decision); + + let current_files = vec![PathBuf::from("src/api/rest/endpoints.ts")]; + + detector.scan_files(¤t_files); + + let alerts = detector.get_alerts(); + + assert!(alerts.is_empty()); +} diff --git a/tests/file_limit_test.rs b/tests/file_limit_test.rs new file mode 100644 index 0000000..689c8e9 --- /dev/null +++ b/tests/file_limit_test.rs @@ -0,0 +1,33 @@ +use code_memory::indexer::limits::check_file_limit; +use code_memory::license::LicenseStatus; + +#[test] +fn test_free_tier_blocks_over_5k_files() { + let status = LicenseStatus::Free; + let current_files = 5001; + + let result = check_file_limit(current_files, &status); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("5000")); +} + +#[test] +fn test_free_tier_allows_under_5k_files() { + let status = LicenseStatus::Free; + let current_files = 4999; + + let result = check_file_limit(current_files, &status); + assert!(result.is_ok()); +} + +#[test] +fn test_pro_tier_allows_unlimited_files() { + let status = LicenseStatus::Pro { + expires_at: 9999999999, + features: vec!["unlimited-files".to_string()], + }; + let current_files = 50000; + + let result = check_file_limit(current_files, &status); + assert!(result.is_ok()); +} diff --git a/tests/incremental_indexing_test.rs b/tests/incremental_indexing_test.rs new file mode 100644 index 0000000..4a59c12 --- /dev/null +++ b/tests/incremental_indexing_test.rs @@ -0,0 +1,91 @@ +use code_memory::indexer::watcher::FileWatcher; +use std::fs; +use std::time::Duration; + +#[test] +fn test_file_change_detection() { + let temp_dir = std::env::temp_dir().join("code-memory-test"); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).unwrap(); + + let test_file = temp_dir.join("test.rs"); + fs::write(&test_file, "fn main() {}").unwrap(); + + // Create watcher after file exists + let mut watcher = FileWatcher::new(temp_dir.clone()).unwrap(); + + // Give watcher time to initialize + std::thread::sleep(Duration::from_millis(100)); + + // Modify file + fs::write(&test_file, "fn main() { println!(\"hello\"); }").unwrap(); + + // Poll for changes with debouncing + // First poll: collect events but don't return yet (debounce period not elapsed) + std::thread::sleep(Duration::from_millis(100)); + let changes = watcher.get_changes(); + assert!( + changes.is_empty(), + "Should not return changes during debounce period" + ); + + // Second poll: after debounce period, should return changes + std::thread::sleep(Duration::from_millis(500)); + let changes = watcher.get_changes(); + + assert!(!changes.is_empty(), "No changes detected"); + // On macOS, paths might be canonicalized (/var -> /private/var) + let test_file_canonical = test_file.canonicalize().unwrap_or(test_file.clone()); + let changes_canonical: Vec<_> = changes + .iter() + .map(|p| p.canonicalize().unwrap_or_else(|_| p.clone())) + .collect(); + assert!( + changes_canonical.contains(&test_file_canonical), + "Test file {:?} not in changes {:?}", + test_file_canonical, + changes_canonical + ); + + // Cleanup + fs::remove_dir_all(&temp_dir).unwrap(); +} + +#[test] +fn test_debouncing() { + let temp_dir = std::env::temp_dir().join("code-memory-test-debounce"); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).unwrap(); + + let test_file = temp_dir.join("test.rs"); + fs::write(&test_file, "fn main() {}").unwrap(); + + // Create watcher after file exists + let mut watcher = FileWatcher::new(temp_dir.clone()).unwrap(); + + // Give watcher time to initialize + std::thread::sleep(Duration::from_millis(100)); + + // Multiple rapid changes + for i in 0..10 { + fs::write(&test_file, format!("fn main() {{ println!(\"{}\"); }}", i)).unwrap(); + std::thread::sleep(Duration::from_millis(50)); + } + + // Poll immediately - should collect events but not return (still debouncing) + let changes = watcher.get_changes(); + assert!( + changes.is_empty(), + "Should not return changes during debounce period" + ); + + // Wait for debounce period and poll again + std::thread::sleep(Duration::from_millis(600)); + let changes = watcher.get_changes(); + + // Should only register once due to debouncing + assert_eq!(changes.len(), 1, "Expected 1 change, got {}", changes.len()); + + // Cleanup + fs::remove_dir_all(&temp_dir).unwrap(); +} diff --git a/tests/license_test.rs b/tests/license_test.rs new file mode 100644 index 0000000..40c3d92 --- /dev/null +++ b/tests/license_test.rs @@ -0,0 +1,30 @@ +use code_memory::license::{verify_license, LicenseStatus}; + +#[test] +fn test_valid_license_unlocks_pro() { + let key = "valid-pro-key-12345"; + let status = verify_license(key); + assert!(matches!(status, LicenseStatus::Pro { .. })); +} + +#[test] +fn test_invalid_license_defaults_free() { + let key = "invalid-key"; + let status = verify_license(key); + assert!(matches!(status, LicenseStatus::Free)); +} + +#[test] +fn test_free_tier_has_5k_limit() { + let status = LicenseStatus::Free; + assert_eq!(status.max_files(), 5000); +} + +#[test] +fn test_pro_tier_has_unlimited_files() { + let status = LicenseStatus::Pro { + expires_at: 9999999999, + features: vec!["unlimited-files".to_string()], + }; + assert_eq!(status.max_files(), usize::MAX); +} diff --git a/tests/web_ui_test.rs b/tests/web_ui_test.rs new file mode 100644 index 0000000..a0bf47a --- /dev/null +++ b/tests/web_ui_test.rs @@ -0,0 +1,41 @@ +use std::thread; +use std::time::Duration; + +#[test] +fn test_web_server_starts() { + use code_memory::web::server::start_server; + + // Start server in background thread + let handle = thread::spawn(|| { + start_server("127.0.0.1:8081").unwrap(); + }); + + // Wait for server to start + thread::sleep(Duration::from_millis(500)); + + // Test connection + let response = + reqwest::blocking::get("http://127.0.0.1:8081").expect("Failed to connect to server"); + + assert_eq!(response.status(), 200); + + // Cleanup + drop(handle); +} + +#[test] +fn test_search_endpoint() { + // Start server + let handle = thread::spawn(|| { + code_memory::web::server::start_server("127.0.0.1:8082").unwrap(); + }); + + thread::sleep(Duration::from_millis(500)); + + let response = + reqwest::blocking::get("http://127.0.0.1:8082/search?q=test").expect("Failed to connect"); + + assert_eq!(response.status(), 200); + + drop(handle); +} diff --git a/tests/workspace_test.rs b/tests/workspace_test.rs new file mode 100644 index 0000000..a1af5f4 --- /dev/null +++ b/tests/workspace_test.rs @@ -0,0 +1,58 @@ +use code_memory::workspace::team_workspace::{TeamWorkspace, WorkspaceConfig}; + +fn test_database_url() -> String { + std::env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgresql://localhost/test_db".to_string()) +} + +#[tokio::test] +async fn test_create_workspace() { + let config = WorkspaceConfig { + team_id: "team_123".to_string(), + name: "Engineering".to_string(), + database_url: test_database_url(), + }; + + let workspace = TeamWorkspace::new(config).await.unwrap(); + assert_eq!(workspace.team_id(), "team_123"); +} + +#[tokio::test] +async fn test_add_member() { + let config = WorkspaceConfig { + team_id: "team_123".to_string(), + name: "Engineering".to_string(), + database_url: test_database_url(), + }; + + let workspace = TeamWorkspace::new(config).await.unwrap(); + workspace.add_member("user_456", "developer").await.unwrap(); + + let members = workspace.list_members().await.unwrap(); + assert_eq!(members.len(), 1); + assert_eq!(members[0].user_id, "user_456"); +} + +#[tokio::test] +async fn test_shared_knowledge() { + let config = WorkspaceConfig { + team_id: "team_123".to_string(), + name: "Engineering".to_string(), + database_url: test_database_url(), + }; + + let workspace = TeamWorkspace::new(config).await.unwrap(); + + workspace + .add_knowledge( + "src/main.rs", + "abc123", + None, + Some(serde_json::json!({"language": "rust"})), + ) + .await + .unwrap(); + + let knowledge = workspace.search_knowledge("main.rs").await.unwrap(); + assert_eq!(knowledge.len(), 1); +}